Sumário
INICIANTE = Níveis 1 a 3 (React) | JÚNIOR = Níveis 4 e 5 (Backend + Fullstack) | PLENO = Níveis 6 e 7 (Projeto real + Deploy)
FUNDAMENTOS
Antes de codar, aprenda a pensar como programador, preparar suas ferramentas e versionar seu código.
Antes de escrever código, um programador pensa. O raciocinio é sempre o mesmo:
2. Que dados eu preciso?
3. De onde vem esses dados?
4. Como eu entrego esses dados?
5. Como eu protejo essa entrega?
Exemplo: API de Lista de Produtos
| Pergunta | Resposta |
|---|---|
| O que preciso entregar? | Uma URL que devolve os produtos |
| Que dados? | Nome, preço, estoque, categoria |
| De onde vem? | Banco de dados MySQL |
| Como entrego? | Rota HTTP que retorna JSON |
| Como protejo? | Token JWT no header |
2. Isso funciona sozinho ou depende de algo?
3. Se depende -> o que falta conectar?
4. Vai fazer essa conexão
5. Testa
6. Funciona? -> próximo passo | Não funciona? -> le o erro, entende, corrige
| Ferramenta | O que e | Como instalar |
|---|---|---|
| Node.js | Roda JavaScript fora do navegador | nodejs.org (versão LTS) |
| npm | Gerenciador de pacotes (ja vem com Node) | Ja vem com o Node.js |
| VS Code | Editor de código | code.visualstudio.com |
| Git | Controle de versão | git-scm.com |
Verificar instalação
$ node --version # v20.x.x
$ npm --version # 10.x.x
$ git --version # git version 2.x.x
Extensões do VS Code
| Extensao | Pra que |
|---|---|
| ES7+ React/Redux snippets | Atalhos pra React |
| Tailwind CSS IntelliSense | Autocomplete Tailwind |
| Prettier | Formata código automáticamente |
| ESLint | Mostra erros em tempo real |
| Thunder Client | Testar APIs no VS Code |
☑ Git Bash (vem junto com o Git para Windows) — recomendado
☑ Windows Terminal com WSL (Windows Subsystem for Linux)
☑ Terminal integrado do VS Code (configurado para Git Bash)
Comandos como
curl, cd, mkdir, npm e node funcionam igual em todos. Onde houver diferença (ex: editar arquivo com nano), a apostila indica a alternativa Windows.Dica: No VS Code, aperte
Ctrl+' para abrir o terminal integrado. Vá em Terminal → Default Profile → Git Bash para usar o bash no Windows.
Git é o sistema de controle de versão que todo programador usa. Ele salva "fotos" do seu código ao longo do tempo. Se algo quebrar, você volta atrás.
3.1 Instalação e Configuração
# Verificar se já tem git --version # Configurar seu nome e email (uma vez só) git config --global user.name "Seu Nome" git config --global user.email "seu@email.com"
3.2 Conceitos Fundamentais
Pense assim: você edita os arquivos → escolhe quais quer salvar (git add) → salva com uma mensagem (git commit).
3.3 Comandos do Dia a Dia
| Comando | O que faz | Quando usar |
|---|---|---|
git init |
Cria um repositório novo | Projeto novo, uma vez só |
git status |
Mostra o que mudou | Antes de commitar (use SEMPRE) |
git add arquivo.js |
Prepara um arquivo | Escolhe o que vai no commit |
git add . |
Prepara TODOS | Quando quer commitar tudo |
git commit -m "msg" |
Salva uma versão | Quando termina algo que funciona |
git log --oneline |
Mostra o histórico | Ver o que já foi feito |
git diff |
Mostra as mudanças | Ver o que mudou antes de commitar |
3.4 Exemplo Prático Completo
# 1. Criar projeto e inicializar git mkdir meu-projeto && cd meu-projeto git init # 2. Criar um arquivo echo "console.log('Hello')" > index.js # 3. Ver status (vai mostrar index.js em vermelho = não rastreado) git status # 4. Adicionar ao staging git add index.js # 5. Ver status de novo (agora verde = pronto p/ commit) git status # 6. Commitar git commit -m "feat: criar arquivo inicial" # 7. Ver histórico git log --oneline
3.5 Branches (Ramificações)
Branches permitem trabalhar em funcionalidades separadas sem mexer no código principal.
# Criar e ir para uma branch nova git checkout -b minha-feature # Trabalhar, commitar normalmente... git add . git commit -m "feat: nova funcionalidade" # Voltar pra main e juntar (merge) git checkout main git merge minha-feature # Deletar a branch (já foi juntada) git branch -d minha-feature
3.6 GitHub - Repositório Remoto
# Conectar seu projeto local ao GitHub git remote add origin https://github.com/seu-user/seu-repo.git # Enviar código para o GitHub (primeira vez) git push -u origin main # Depois, só: git push # Baixar mudanças do GitHub git pull
3.7 .gitignore
Arquivo que diz ao Git o que NÃO rastrear. Crie na raiz do projeto:
# .gitignore
node_modules/
.env
dist/
*.log
node_modules/ (são milhares de arquivos, instala com npm install),
.env (contém senhas e chaves secretas) e
dist/ (é gerado pelo build).
3.8 Padrão de Mensagens de Commit
| Prefixo | Quando usar | Exemplo |
|---|---|---|
feat: |
Funcionalidade nova | feat: adicionar login |
fix: |
Corrigir bug | fix: corrigir erro no cadastro |
refactor: |
Reorganizar código | refactor: separar rotas |
docs: |
Documentação | docs: atualizar README |
style: |
Visual/formatação | style: ajustar cores |
README.md.2. Faça pelo menos 3 commits com mensagens descritivas (use prefixos como
feat:,
fix:, docs:).3. Crie uma branch chamada
feature/teste, adicione um arquivo novo nela e faça commit.4. Volte pra branch
main e faça o merge da branch feature/teste.5. Crie um arquivo
.gitignore que ignore a pasta node_modules/ e o arquivo
.env.
3.9.1 git stash — Guardar Trabalho Temporariamente
Você está no meio de uma feature e precisa trocar de branch urgente. Mas não quer fazer commit de código incompleto.
$ git stash
# "Guarda" todas as mudanças não commitadas numa pilha temporária
# Seu diretório volta ao estado do último commit
$ git checkout outra-branch
# Agora você pode trocar de branch tranquilo
$ git checkout minha-feature
$ git stash pop
# Restaura as mudanças guardadas e remove da pilha
Comandos úteis do stash
| Comando | O que faz |
|---|---|
git stash |
Guarda mudanças na pilha |
git stash pop |
Restaura e remove da pilha |
git stash apply |
Restaura mas mantém na pilha |
git stash list |
Lista todos os stashes guardados |
git stash drop |
Remove o stash mais recente sem aplicar |
git stash save "mensagem" |
Guarda com uma descrição |
2. Quer testar algo limpo sem perder o trabalho atual
3. Precisa fazer pull mas tem mudanças locais que conflitam
3.9.2 git rebase — Reescrever Histórico
Rebase "replanta" seus commits em cima de outra branch, criando um histórico linear e limpo.
# Você está na branch feature e quer atualizar com a main
$ git checkout feature
$ git rebase main
# Pega seus commits e aplica EM CIMA dos commits mais recentes da main
# Resultado: histórico linear, como se você tivesse começado da main atualizada
Rebase vs Merge — Qual usar?
| Situação | Use | Por quê |
|---|---|---|
| Atualizar sua branch com a main | git rebase main |
Histórico limpo, sem commits de merge |
| Finalizar feature na main | git merge feature |
Preserva o histórico real do projeto |
| Branch pública (já fez push) | git merge |
Rebase em branch pública causa conflitos pros outros |
git push da branch e outras pessoas estão trabalhando nela, use merge.
Rebase reescreve o histórico — se outros já baixaram os commits antigos, vai dar conflito.Resolvendo conflitos no rebase
$ git rebase main
# CONFLICT (content): Merge conflict in src/App.tsx
# 1. Abra o arquivo com conflito e resolva manualmente
# 2. Adicione o arquivo resolvido:
$ git add src/App.tsx
# 3. Continue o rebase:
$ git rebase --continue
# Se quiser cancelar tudo e voltar ao estado anterior:
$ git rebase --abort
3.9.3 git cherry-pick — Pegar Um Commit Específico
Cherry-pick copia um commit específico de outra branch para a sua. Útil quando você precisa de uma correção que está em outra branch mas não quer fazer merge de tudo.
# 1. Descubra o hash do commit que você quer
$ git log --oneline feature
abc1234 fix: corrigir validação de telefone
def5678 feat: adicionar campo email
ghi9012 refactor: organizar imports
# 2. Vá para a branch onde quer aplicar
$ git checkout main
# 3. Cherry-pick do commit específico
$ git cherry-pick abc1234
# Pronto! O commit "fix: corrigir validação" agora está na main
2. Feature parcial: quer levar só 1 commit de uma branch grande
3. Backport: aplicar uma correção numa versão antiga
3.9.4 git reflog — Desfazer o "Irreversível"
O reflog registra tudo que aconteceu no seu repositório. Mesmo que você faça reset --hard e "perca" commits, o reflog ainda tem a referência.
$ git reflog
abc1234 HEAD@{0}: commit: feat: adicionar dashboard
def5678 HEAD@{1}: checkout: moving from feature to main
ghi9012 HEAD@{2}: commit: fix: corrigir login
jkl3456 HEAD@{3}: reset: moving to HEAD~2 (ops, perdi commits!)
# Recuperar o commit "perdido":
$ git checkout abc1234
# Ou criar uma branch a partir dele:
$ git branch recuperar abc1234
git reflog mostra o histórico completo. Encontre o estado que quer voltar e use
git reset --hard HEAD@{N} ou git checkout HEAD@{N}.3.9.5 Resumo — Git Avançado
| Comando | Quando usar | Cuidado |
|---|---|---|
git stash |
Guardar trabalho temporário | Lembre de fazer pop depois |
git rebase |
Atualizar branch com histórico limpo | Nunca em branch pública |
git cherry-pick |
Copiar 1 commit específico | Pode gerar conflito se depende de outros commits |
git reflog |
Recuperar commits "perdidos" | Funciona só no seu computador (local) |
git stash, troque de branch, volte e use git stash pop.2. Crie uma branch, faça 2 commits, volte pra main e use
git rebase pra atualizar.3. Use
git cherry-pick pra trazer um commit de outra branch pra main.4. Use
git reflog pra ver o histórico completo de ações no seu repo.3.5.1 Variáveis: let, const e var
| Palavra | Pode mudar? | Quando usar |
|---|---|---|
const |
NÃO | Sempre que possível (padrão) |
let |
SIM | Quando o valor vai mudar (contador, loop) |
var |
SIM | NUNCA — é antigo e causa bugs |
const nome = 'João'; // não pode mudar
let idade = 25; // pode mudar
idade = 26; // ok!
nome = 'Maria'; // ERRO! const não muda
const pra tudo. Só troque pra let se der erro dizendo que não pode reatribuir.3.5.2 Tipos de Dados
| Tipo | Exemplo | Descrição |
|---|---|---|
string |
'João' ou "João" |
Texto |
number |
42, 3.14 |
Número (inteiro ou decimal) |
boolean |
true, false |
Verdadeiro ou falso |
null |
null |
Vazio de propósito |
undefined |
undefined |
Nunca recebeu valor |
array |
[1, 2, 3] |
Lista de coisas |
object |
{ nome: 'Jo' } |
Estrutura com chave: valor |
3.5.3 Template Literals (Crase)
Em vez de concatenar com +, use crase ` e ${}:
// Jeito antigo (ruim)
const msg = 'Olá, ' + nome + '. Você tem ' + idade + ' anos.';
// Jeito moderno (bom)
const msg = `Olá, ${nome}. Você tem ${idade} anos.`;
3.5.4 Funções
// Função normal
function somar(a, b) {
return a + b;
}
// Arrow function (mais usada no React)
const somar = (a, b) => {
return a + b;
};
// Arrow function curta (1 linha = return automático)
const somar = (a, b) => a + b;
// Sem parâmetros
const dizerOi = () => 'Oi!';
// Com 1 parâmetro (parênteses opcional)
const dobro = n => n * 2;
const NomeComponente = () => { ... }, é uma arrow function que retorna JSX (HTML do React).3.5.5 Arrays (Listas)
const frutas = ['maçã', 'banana', 'uva'];
frutas.length; // 3 (quantos itens)
frutas[0]; // 'maçã' (primeiro item — começa em 0!)
frutas[2]; // 'uva' (terceiro item)
frutas.push('manga'); // adiciona no final
frutas.includes('banana'); // true (existe na lista?)
Métodos que você vai usar MUITO no React:
const numeros = [1, 2, 3, 4, 5];
// .map() — transforma cada item (retorna novo array)
const dobrados = numeros.map(n => n * 2);
// [2, 4, 6, 8, 10]
// .filter() — filtra itens (retorna novo array)
const grandes = numeros.filter(n => n > 3);
// [4, 5]
// .find() — acha o primeiro que bate
const achou = numeros.find(n => n === 3);
// 3
// .forEach() — faz algo com cada item (NÃO retorna nada)
numeros.forEach(n => console.log(n));
// .some() — algum item bate?
numeros.some(n => n > 4); // true
// .every() — TODOS batem?
numeros.every(n => n > 0); // true
.map() retorna um novo array — use quando precisa do resultado (ex: renderizar lista no React)..forEach() NÃO retorna nada — use quando só quer executar algo (ex: console.log).
3.5.6 Objetos
// Criar objeto
const usuario = {
nome: 'João',
idade: 25,
email: 'joao@email.com',
};
// Acessar valores
usuario.nome; // 'João'
usuario['email']; // 'joao@email.com'
// Alterar
usuario.idade = 26;
// Adicionar campo novo
usuario.ativo = true;
3.5.7 Desestruturação
Desestruturação é extrair valores de objetos ou arrays de forma rápida. Você vai ver isso em TODA linha de React.
// SEM desestruturação (verboso)
const nome = usuario.nome;
const email = usuario.email;
// COM desestruturação (limpo)
const { nome, email } = usuario;
// Funciona em parâmetros de função também:
function saudar({ nome, idade }) {
return `Olá ${nome}, ${idade} anos`;
}
saudar(usuario); // 'Olá João, 25 anos'
// Desestruturação de ARRAY
const [primeiro, segundo] = ['a', 'b', 'c'];
// primeiro = 'a', segundo = 'b'
// No React: const [valor, setValor] = useState(''); ← é desestruturação de array!
3.5.8 Spread Operator (...)
// Copiar array
const original = [1, 2, 3];
const copia = [...original]; // [1, 2, 3]
const comMais = [...original, 4, 5]; // [1, 2, 3, 4, 5]
// Copiar objeto
const user = { nome: 'Jo', idade: 25 };
const atualizado = { ...user, idade: 26 };
// { nome: 'Jo', idade: 26 } — copiou e atualizou só a idade
// No React, spread é ESSENCIAL pra atualizar estado sem mutar:
// setUsuario({ ...usuario, nome: 'Maria' });
3.5.9 Operador Ternário
// if/else normal
let msg;
if (idade >= 18) {
msg = 'Maior de idade';
} else {
msg = 'Menor de idade';
}
// Ternário (faz a mesma coisa em 1 linha)
const msg = idade >= 18 ? 'Maior de idade' : 'Menor de idade';
// condição ? se verdadeiro : se falso
// No React, usado pra renderização condicional:
// {logado ? <Dashboard /> : <Login />}
3.5.10 Truthy e Falsy
No JavaScript, alguns valores são considerados "falsos" mesmo sem ser false:
| Falsy (falso) | Truthy (verdadeiro) |
|---|---|
false |
Qualquer número diferente de 0 |
0 |
Qualquer string com texto |
"" (string vazia) |
[] (array vazio!) |
null |
{} (objeto vazio!) |
undefined |
"0" (string com zero) |
NaN |
true |
// Muito usado no React:
const nome = '';
if (nome) { ... } // NÃO entra — string vazia é falsy
const lista = [];
if (lista.length) { ... } // NÃO entra — 0 é falsy
// Operador && (E lógico) — muito usado no JSX:
// {erro && <p>{erro}</p>} ← só mostra se erro existir
3.5.11 Promises e Async/Await
Quando o JavaScript precisa esperar algo (buscar dados de uma API, ler um banco), ele usa código assíncrono.
// Promise = "promessa" de que um valor vai chegar no futuro
// async/await = forma moderna de lidar com Promises
// Função assíncrona (usa async)
async function buscarUsuario(id) {
const resposta = await fetch(`/api/usuarios/${id}`); // espera a resposta
const dados = await resposta.json(); // espera converter pra JSON
return dados;
}
// Chamar:
const user = await buscarUsuario(1);
// SEMPRE use try/catch com async/await:
async function buscar() {
try {
const res = await fetch('/api/dados');
const dados = await res.json();
console.log(dados);
} catch (erro) {
console.error('Deu erro:', erro.message);
}
}
await. Sem ele, você recebe um objeto Promise em vez dos dados reais.const dados = fetch('/api'); ← dados = Promise (errado!)const dados = await fetch('/api'); ← dados = resposta real (certo!)
3.5.12 Import e Export (Módulos)
// ==========================================
// FRONTEND (React) — usa import/export (ESM)
// ==========================================
// Exportar (arquivo utils.ts):
export const formatar = (valor) => `R$ ${valor}`;
export default function App() { ... }
// Importar (outro arquivo):
import App from './App'; // default export
import { formatar } from './utils'; // named export
// ==========================================
// BACKEND (Node.js) — usa require/module.exports (CommonJS)
// ==========================================
// Exportar (arquivo userModel.js):
module.exports = { criar, buscar, deletar };
// Importar (outro arquivo):
const { criar, buscar } = require('./userModel');
import/export).O Node.js (backend) historicamente usa CommonJS (
require/module.exports).São dois sistemas de módulos. No frontend você sempre usa
import. No backend, require.
Não misture!3.5.14 Promises em Profundidade
No capítulo 3.5.11 você aprendeu o básico de async/await. Agora vamos entender de verdade como funciona por baixo — essencial para backend e APIs.
O que é uma Promise?
Uma Promise é um objeto que representa uma operação que ainda não terminou. Ela pode estar em 3 estados:
// Criando uma Promise do zero (para entender)
const minhaPromise = new Promise((resolve, reject) => {
// Simula operação que demora (ex: buscar no banco)
setTimeout(() => {
const sucesso = true;
if (sucesso) {
resolve({ nome: 'João', idade: 25 }); // deu certo!
} else {
reject(new Error('Falhou!')); // deu errado!
}
}, 2000);
});
// Consumindo com .then() e .catch()
minhaPromise
.then(dados => console.log('Sucesso:', dados))
.catch(erro => console.error('Erro:', erro))
.finally(() => console.log('Terminou (com ou sem erro)'));
await é "açúcar sintático" — por baixo ele usa .then().const dados = await fetch('/api');é igual a:
fetch('/api').then(dados => { ... });await é mais legível, por isso preferimos ele.Encadeamento de Promises (Promise Chain)
Cada .then() retorna uma nova Promise. Isso permite encadear operações:
// Sem encadeamento (callback hell)
fetch('/api/user/1')
.then(res => res.json())
.then(user => fetch(`/api/pedidos?userId=${user.id}`))
.then(res => res.json())
.then(pedidos => console.log('Pedidos:', pedidos))
.catch(err => console.error('Qualquer erro da cadeia cai aqui'));
// Com async/await (muito mais legível!)
async function buscarPedidos() {
const userRes = await fetch('/api/user/1');
const user = await userRes.json();
const pedidosRes = await fetch(`/api/pedidos?userId=${user.id}`);
const pedidos = await pedidosRes.json();
console.log('Pedidos:', pedidos);
}
3.5.15 Promise.all, Promise.race e Promise.allSettled
Quando você precisa fazer várias operações assíncronas ao mesmo tempo:
Promise.all — Todas ao mesmo tempo, falha se UMA falhar
// Busca 3 coisas AO MESMO TEMPO (paralelo)
const [users, leads, campanhas] = await Promise.all([
fetch('/api/users').then(r => r.json()),
fetch('/api/leads').then(r => r.json()),
fetch('/api/campanhas').then(r => r.json()),
]);
// Se qualquer uma falhar, TODAS falham (cai no catch)
// Se todas derem certo, retorna array com os 3 resultados
Sequencial (3x await): 200 + 200 + 200 = 600ms
Paralelo (Promise.all): max(200, 200, 200) = 200ms
Use Promise.all sempre que as operações são independentes!
Promise.allSettled — Todas ao mesmo tempo, nunca falha
// Mesmo que uma falhe, retorna o resultado de TODAS
const resultados = await Promise.allSettled([
fetch('/api/users').then(r => r.json()),
fetch('/api/leads').then(r => r.json()), // ← se esta falhar...
fetch('/api/campanhas').then(r => r.json()),
]);
// resultados = [
// { status: 'fulfilled', value: [...users] },
// { status: 'rejected', reason: Error }, ← falhou mas não parou
// { status: 'fulfilled', value: [...campanhas] }
// ]
// Filtrar só os que deram certo:
const sucessos = resultados
.filter(r => r.status === 'fulfilled')
.map(r => r.value);
Promise.race — Retorna a PRIMEIRA que terminar
// Útil para timeout: se a API demorar mais de 5s, cancela
const fetchComTimeout = async (url, ms = 5000) => {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout!')), ms)
);
return Promise.race([
fetch(url).then(r => r.json()),
timeout,
]);
};
// Se /api/dados demorar mais de 5s, retorna erro "Timeout!"
const dados = await fetchComTimeout('/api/dados', 5000);
Resumo — Qual usar?
| Método | Comportamento | Quando usar |
|---|---|---|
Promise.all |
Espera TODAS. Falha se uma falhar | Preciso de todos os dados pra continuar |
Promise.allSettled |
Espera TODAS. Nunca falha | Quero o máximo de dados, ok se algum falhar |
Promise.race |
Retorna a PRIMEIRA que terminar | Timeout, cache vs rede |
3.5.16 Padrões Async no Backend
No backend com Express, async/await aparece em todo lugar. Aqui estão os padrões que você vai usar diariamente:
Padrão 1: Controller com try/catch
// O padrão mais comum — toda rota assíncrona usa isso
exports.getLeads = async (req, res) => {
try {
const [rows] = await pool.query(
'SELECT * FROM leads WHERE user_id = ?',
[req.user.id]
);
res.json({ success: true, data: rows });
} catch (error) {
console.error('Erro:', error);
res.status(500).json({ error: 'Erro interno' });
}
};
Padrão 2: Múltiplas queries paralelas
// Dashboard que busca várias estatísticas ao mesmo tempo
exports.getDashboard = async (req, res) => {
try {
const [totalLeads, totalCampanhas, totalEnviados] = await Promise.all([
pool.query('SELECT COUNT(*) as total FROM leads WHERE user_id = ?', [req.user.id]),
pool.query('SELECT COUNT(*) as total FROM campanhas WHERE user_id = ?', [req.user.id]),
pool.query('SELECT COUNT(*) as total FROM mensagens WHERE user_id = ?', [req.user.id]),
]);
res.json({
leads: totalLeads[0][0].total,
campanhas: totalCampanhas[0][0].total,
enviados: totalEnviados[0][0].total,
});
} catch (error) {
res.status(500).json({ error: 'Erro ao buscar dashboard' });
}
};
Padrão 3: Operações sequenciais (uma depende da outra)
// Criar campanha: primeiro verifica, depois insere, depois loga
exports.criarCampanha = async (req, res) => {
try {
// 1. Verificar limite do plano
const [user] = await pool.query(
'SELECT plano, campanhas_criadas FROM users WHERE id = ?',
[req.user.id]
);
if (user[0].campanhas_criadas >= limiteDoPlano) {
return res.status(403).json({ error: 'Limite do plano atingido' });
}
// 2. Inserir campanha (depende da verificação acima)
const [result] = await pool.query(
'INSERT INTO campanhas (nome, user_id) VALUES (?, ?)',
[req.body.nome, req.user.id]
);
// 3. Atualizar contador (depende do insert acima)
await pool.query(
'UPDATE users SET campanhas_criadas = campanhas_criadas + 1 WHERE id = ?',
[req.user.id]
);
res.status(201).json({ id: result.insertId });
} catch (error) {
res.status(500).json({ error: 'Erro ao criar campanha' });
}
};
1. Independentes? → Use
Promise.all (paralelo, mais rápido)
2. Uma depende da outra? → Use
await sequencial
3. Sempre envolva com
try/catch para não crashar o servidor
4. Nunca esqueça o
await — sem ele você recebe Promise em vez de dados
3.5.17 Resumo Visual
.map() pra transformar cada nome em maiúsculo
(.toUpperCase()).2. Crie um objeto
produto com nome, preco e estoque. Use desestruturação pra extrair só nome e
preco.3. Escreva uma arrow function
maiorDeIdade que recebe uma idade e retorna true se
>= 18.4. Dado o array
[10, 25, 3, 48, 7, 31], use .filter() pra pegar só os maiores que
20, depois .map() pra dobrar cada valor.5. Crie um objeto
usuario e use spread pra criar usuarioAtualizado mudando só o
email.
REACT: SUA PRIMEIRA TELA
Crie seu primeiro projeto React, entenda a arquitetura de pastas e dê estilo com Tailwind.
| Ferramenta | Velocidade | Usar? |
|---|---|---|
| Vite | Muito rápido | SIM (recomendado) |
| Create React App | Lento, descontinuado | NAO |
| Next.js | Rapido | Pra sites com SEO |
3 comandos pra criar
$ npm create vite@latest meu-projeto -- --template react-ts
# Cria projeto React + TypeScript com Vite
$ cd meu-projeto && npm install
# Entra na pasta e instala dependencias
$ npm run dev
# Roda! Acessa http://localhost:5173
O que o Vite cria
meu-projeto/
|- node_modules/ # Bibliotecas (NUNCA mexa)
|- public/ # Arquivos estaticos (icones, imagens)
|- src/ # SEU CODIGO VAI AQUI
| |- App.tsx # Componente raiz
| |- main.tsx # Entrada do React
|- index.html # HTML base
|- package.json # Dependencias e scripts
|- tsconfig.json # Config TypeScript
|- vite.config.ts # Config Vite
main.tsx - Ponto de entrada
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(<App />)
// Renderiza o App dentro da div#root do index.html
package.json - Scripts
{
"scripts": {
"dev": "vite", // npm run dev = desenvolvimento
"build": "vite build", // npm run build = gerar producao
"preview": "vite preview" // npm run preview = pre-visualizar build
},
"dependencies": {}, // Bibliotecas que o projeto USA
"devDependencies": {} // Ferramentas so pra desenvolvimento
}
Instalar bibliotecas essenciais
$ npm install react-router-dom # Navegacao entre paginas
$ npm install -D tailwindcss @tailwindcss/vite # Estilizacao
$ npx shadcn-ui@latest init # Componentes prontos
$ npm install lucide-react # Icones
Configurar atalho @/ nos imports
vite.config.ts:
import path from 'path'
export default defineConfig({
resolve: { alias: { '@': path.resolve(__dirname, './src') } }
})
// Agora: import { Button } from '@/components/ui/button'
// Em vez de: import { Button } from '../../components/ui/button'
Configurar React Router (navegação)
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import Dashboard from './pages/Dashboard'
import Leads from './pages/Leads'
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/leads" element={<Leads />} />
{/* path = URL no navegador. element = componente que renderiza */}
</Routes>
</BrowserRouter>
)
}
npm create vite@latest e escolha o
template React + TypeScript.2. Rode o projeto com
npm run dev e acesse no navegador. Mude o texto da página inicial pra
confirmar que o hot reload funciona.3. Instale o React Router (
npm install react-router-dom) e crie 2 páginas: uma
Home e uma Sobre, com navegação entre elas.
Backend (backend/src/) = o que PROCESSA e GUARDA dados (Express + MySQL)
O frontend NUNCA acessa o banco direto. Sempre pede pro backend via API.
5.1 Backend
backend/src/
|- routes/ # Define quais URLs existem
|- controllers/ # Recebe pedido, devolve resposta
|- models/ # Fala com as tabelas do banco (SQL)
|- services/ # Logica complexa (validacoes, regras)
|- middlewares/ # Verifica permissao antes do controller
|- database/ # Conexao + estrutura das tabelas
|- config/ # Valores fixos (precos, limites)
|- utils/ # Funcoes auxiliares reutilizaveis
|- workers/ # Tarefas pesadas em segundo plano
|- app.js # Junta tudo (porta de entrada)
|- server.js # Liga o servidor
| Pasta | Analogia Restaurante | Quando criar arquivo |
|---|---|---|
| routes/ | Cardapio | Novo grupo de URLs |
| controllers/ | Garcom | Pra cada rota |
| models/ | Estoquista | Pra cada tabela do banco |
| services/ | Chef | Lógica complexa (regras, validações) |
| middlewares/ | Segurança | Verificações usadas em varias rotas |
| database/ | Estoque | Conexão, tabelas, migrations |
| config/ | Manual | Precos, nomes, limites |
| utils/ | Caixa de ferramentas | Funcao usada em 2+ lugares |
5.2 Frontend
src/
|- pages/ # Telas inteiras (cada uma tem URL)
|- components/ # Pecas reutilizaveis de interface
| |- ui/ # Botoes, inputs, modais basicos
|- services/ # Chamadas pra API do backend
|- contexts/ # Dados globais (usuario logado)
|- hooks/ # Logica reutilizavel com hooks
|- types/ # Formato dos dados (TypeScript)
|- lib/ # Funcoes auxiliares
|- data/ # Dados fixos (nao vem do banco)
|- App.tsx # Rotas de pagina
| Pasta | O que colocar | Exemplo |
|---|---|---|
| pages/ | Tela inteira com URL propria | Dashboard.tsx, Leads.tsx |
| components/ | Peca usada em 2+ páginas | LeadTable.tsx, LeadCard.tsx |
| components/ui/ | Botao, input, dialog genérico | button.tsx, dialog.tsx |
| services/ | Chamadas HTTP pro backend | leadService.ts, api.ts |
| contexts/ | Dados que MUITAS páginas usam | AuthContext.tsx |
| hooks/ | useState+useEffect reutilizável | useLeads.ts |
| types/ | Formato dos dados (interface) | lead.ts, campanha.ts |
| lib/ | Funcoes auxiliares genéricas | utils.ts (formatar data) |
| data/ | Listas fixas, constantes | estados.ts, ramos.ts |
2. Dado um componente de listagem de produtos, identifique em qual pasta ele deve ficar:
pages/, components/, services/ ou types/. Justifique.3. Crie as pastas
frontend/src (com pages, components,
services, types) e backend/src (com routes,
controllers, models, middlewares) na sua máquina.
Tailwind é um framework CSS utilitário. Em vez de escrever CSS em arquivos separados, você aplica classes direto no HTML/JSX. É o padrão da indústria moderna.
6.1 Instalação no Vite + React
npm install -D tailwindcss @tailwindcss/vite
Configure o plugin no vite.config.ts:
import tailwindcss from '@tailwindcss/vite'; export default defineConfig({ plugins: [react(), tailwindcss()], });
No seu src/index.css, adicione:
@import "tailwindcss";
6.2 CSS Tradicional vs Tailwind
| CSS Tradicional | Tailwind |
|---|---|
.btn {
background: #3b82f6;
color: white;
padding: 8px 16px;
border-radius: 8px;
font-weight: bold;
}
|
<button className="bg-blue-500 text-white px-4 py-2 rounded-lg font-bold"> Clique </button> |
6.3 Classes Mais Usadas
| Categoria | Classes | CSS equivalente |
|---|---|---|
| Espaçamento | p-4 px-6 py-2 m-2 mt-4 |
padding, margin |
| Cores | bg-blue-500 text-white text-gray-700 |
background, color |
| Flex | flex items-center justify-between gap-4 |
display:flex, align, justify |
| Grid | grid grid-cols-3 gap-4 |
display:grid |
| Tamanho | w-full h-screen w-64 max-w-md |
width, height, max-width |
| Borda | rounded-lg border border-gray-300 |
border-radius, border |
| Texto | text-xl font-bold text-center |
font-size, font-weight |
| Sombra | shadow-md shadow-lg |
box-shadow |
6.4 Responsivo
Tailwind usa prefixos para breakpoints. Mobile first: sem prefixo = mobile, md: = tablet,
lg: = desktop.
<!-- 1 coluna no mobile, 2 no tablet, 3 no desktop --> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <Card /> <Card /> <Card /> </div>
| Prefixo | Largura mínima | Dispositivo |
|---|---|---|
| (nenhum) | 0px | Mobile |
sm: |
640px | Mobile grande |
md: |
768px | Tablet |
lg: |
1024px | Desktop |
xl: |
1280px | Desktop grande |
6.5 Hover, Focus e Estados
<button className="bg-blue-500 hover:bg-blue-600 active:bg-blue-700 transition-colors duration-200 focus:ring-2 focus:ring-blue-300"> Salvar </button> <input className="border border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-200 rounded-lg px-4 py-2 outline-none transition-all" />
6.6 Dark Mode
<div className="bg-white dark:bg-gray-900
text-gray-900 dark:text-white">
<p>Texto que se adapta ao tema!</p>
</div>
6.7 Exemplo: Card Completo
function UserCard({ nome, email, plano }) { return ( <div className="bg-white rounded-xl shadow-md p-6 border border-gray-100 hover:shadow-lg transition-shadow"> <div className="flex items-center gap-4 mb-4"> <div className="w-12 h-12 bg-blue-500 rounded-full flex items-center justify-center text-white font-bold text-lg"> {nome[0]} </div> <div> <h3 className="font-semibold text-gray-900">{nome}</h3> <p className="text-sm text-gray-500">{email}</p> </div> </div> <span className="bg-green-100 text-green-700 px-3 py-1 rounded-full text-xs font-medium"> {plano} </span> </div> ); }
rounded-lg, shadow-md, p-4 e bg-white.2. Faça um layout responsivo com 1 coluna no celular e 3 colunas no desktop usando
grid grid-cols-1 md:grid-cols-3 gap-4.3. Adicione suporte a dark mode no card do exercício 1 usando as classes
dark:bg-gray-800 e
dark:text-white.
Este capítulo ensina as operações fundamentais do React: clicar em botão, pegar dados de input, renderizar listas, trabalhar com objetos e passar dados entre componentes. Tudo com exemplos práticos que você pode copiar e rodar.
6.5.1 JSX — HTML Dentro do JavaScript
No React, você escreve "HTML" dentro do JavaScript. Isso se chama JSX. Não é HTML de verdade — o React transforma em JavaScript por baixo.
// Isso é JSX (parece HTML, mas é JavaScript)
function MeuComponente() {
const nome = 'João';
const ativo = true;
return (
<div>
<h1>Olá, {nome}!</h1> {/* usa {} pra JavaScript */}
<p>Status: {ativo ? 'Ativo' : 'Inativo'}</p>
<p>2 + 2 = {2 + 2}</p> {/* qualquer expressão JS */}
</div>
);
}
| HTML | JSX (React) | Por quê? |
|---|---|---|
class="btn" |
className="btn" |
class é palavra reservada do JS |
for="email" |
htmlFor="email" |
for é palavra reservada do JS |
onclick="fn()" |
onClick={fn} |
camelCase + sem aspas |
style="color: red" |
style={{ color: 'red' }} |
Objeto JS, não string |
<br> |
<br /> |
Tags precisam fechar |
Dentro de
{ } = JavaScript puro. Fora = estrutura visual. Tudo que está entre chaves é calculado
pelo JS antes de aparecer na tela.
6.5.2 Componentes — Funções que Retornam Tela
No React, tudo é componente. Um componente é uma função que retorna JSX (a tela). O nome sempre começa com letra maiúscula.
// Componente simples
function Titulo() {
return <h1>Meus Leads</h1>;
}
// Usando o componente
function App() {
return (
<div>
<Titulo /> {/* usa como se fosse uma tag HTML */}
<Titulo /> {/* pode usar quantas vezes quiser */}
</div>
);
}
6.5.3 onClick — Clique em Botão
Para fazer algo quando o usuário clica, use onClick:
// Exemplo 1: Função inline
function Botao() {
return (
<button onClick={() => alert('Clicou!')}>
Clique Aqui
</button>
);
}
// Exemplo 2: Função separada (melhor pra lógica complexa)
function BotaoSalvar() {
function handleClick() {
console.log('Salvando...');
// chamar API, validar, etc.
}
return (
<button onClick={handleClick}>
Salvar
</button>
);
}
// Exemplo 3: Passando dados no clique
function BotaoLead() {
function handleClick(nome) {
alert(`Selecionou: ${nome}`);
}
return (
<div>
<button onClick={() => handleClick('João')}>João</button>
<button onClick={() => handleClick('Maria')}>Maria</button>
</div>
);
}
// ❌ ERRADO — executa na hora, não no clique!
<button onClick={handleClick('João')}> // chama IMEDIATAMENTE
// ✅ CERTO — arrow function "embrulha" a chamada
<button onClick={() => handleClick('João')}> // só executa no clique
Se precisa passar parâmetros, use () => para envolver a chamada.
| Evento | Quando dispara | Exemplo |
|---|---|---|
onClick |
Clicou no elemento | onClick={salvar} |
onDoubleClick |
Clicou duas vezes | onDoubleClick={editar} |
onSubmit |
Enviou formulário | onSubmit={handleSubmit} |
onKeyDown |
Apertou tecla | onKeyDown={handleTecla} |
onMouseEnter |
Mouse entrou no elemento | onMouseEnter={mostrar} |
6.5.4 onChange — Pegando Dado do Input
Para pegar o que o usuário digita, use onChange + useState:
import { useState } from 'react';
function CampoBusca() {
const [texto, setTexto] = useState('');
// ↑ valor atual ↑ função pra mudar
return (
<div>
<input
value={texto} {/* mostra o valor */}
onChange={(e) => setTexto(e.target.value)} {/* atualiza ao digitar */}
placeholder="Buscar lead..."
/>
<p>Você digitou: {texto}</p>
<p>Total de letras: {texto.length}</p>
</div>
);
}
Múltiplos inputs com objeto
function FormularioLead() {
const [form, setForm] = useState({
nome: '',
telefone: '',
email: '',
});
// UMA função pra TODOS os inputs
function handleChange(e) {
setForm({
...form, // copia tudo que já tem
[e.target.name]: e.target.value // atualiza só o campo que mudou
});
}
function handleSalvar() {
console.log('Dados:', form);
// form = { nome: 'João', telefone: '21999...', email: 'joao@...' }
}
return (
<div>
<input name="nome" value={form.nome} onChange={handleChange} placeholder="Nome" />
<input name="telefone" value={form.telefone} onChange={handleChange} placeholder="Telefone" />
<input name="email" value={form.email} onChange={handleChange} placeholder="Email" />
<button onClick={handleSalvar}>Salvar</button>
</div>
);
}
[e.target.name]
O atributo
name="telefone" do input vira a chave do objeto. Assim você escreve UMA função
handleChange pra todos os inputs, em vez de uma pra cada campo.
6.5.5 .map() — Renderizar Listas
O .map() é a forma do React de fazer um "for each" na tela. Ele pega cada item de um array e
transforma em um elemento visual:
function ListaDeNomes() {
const nomes = ['João', 'Maria', 'Pedro', 'Ana'];
return (
<ul>
{nomes.map((nome, index) => (
<li key={index}>{nome}</li>
))}
</ul>
);
}
// Resultado na tela:
// • João
// • Maria
// • Pedro
// • Ana
Map com objetos (mais comum no dia a dia)
function ListaDeLeads() {
const leads = [
{ id: 1, nome: 'Barbearia do João', telefone: '21999001122', status: 'novo' },
{ id: 2, nome: 'Clínica Saúde', telefone: '21988003344', status: 'contatado' },
{ id: 3, nome: 'Pet Shop Rex', telefone: '21977005566', status: 'respondeu' },
];
return (
<div>
<h2>Meus Leads ({leads.length})</h2>
{leads.map((lead) => (
<div key={lead.id} style={{ border: '1px solid #ddd', padding: '12px', marginBottom: '8px' }}>
<h3>{lead.nome}</h3>
<p>Tel: {lead.telefone}</p>
<p>Status: {lead.status}</p>
<button onClick={() => alert(`Ligar para ${lead.nome}`)}>
Contatar
</button>
</div>
))}
</div>
);
}
key?
O React usa a
key pra identificar qual item mudou, foi adicionado ou removido. Sem key, ele
redesenha TUDO. Com key, só redesenha o que mudou.
Regras da key:
• Use
key={item.id} quando tem ID (o melhor)
• Use
key={index} quando NÃO tem ID (funciona, mas menos eficiente)
• Nunca use
key={Math.random()} (gera key nova toda hora!)
6.5.6 Objetos no React
Objetos são a estrutura de dados mais usada no React. Um lead, um usuário, um produto — tudo é objeto.
// Criar um objeto
const lead = {
nome: 'Barbearia do João',
telefone: '21999001122',
ramo: 'Beleza',
ativo: true,
};
// Acessar propriedades
lead.nome // 'Barbearia do João'
lead.telefone // '21999001122'
lead['ramo'] // 'Beleza' (útil quando a chave é variável)
// Desestruturação — extrair valores do objeto
const { nome, telefone, ramo } = lead;
console.log(nome); // 'Barbearia do João' (sem precisar de lead.nome)
// Spread — copiar e alterar
const leadAtualizado = { ...lead, ativo: false };
// Copia tudo de lead, mas troca ativo pra false
Operações comuns com objetos
const lead = { nome: 'João', email: 'joao@test.com', status: 'novo' };
// Object.keys — pegar as chaves
Object.keys(lead); // ['nome', 'email', 'status']
// Object.values — pegar os valores
Object.values(lead); // ['João', 'joao@test.com', 'novo']
// Object.entries — pegar chave + valor (útil pra .map)
Object.entries(lead); // [['nome','João'], ['email','joao@...'], ['status','novo']]
// Renderizar objeto dinamicamente
function DetalhesLead({ lead }) {
return (
<div>
{Object.entries(lead).map(([chave, valor]) => (
<p key={chave}><b>{chave}:</b> {valor}</p>
))}
</div>
);
// Resultado: nome: João / email: joao@... / status: novo
}
6.5.7 .filter() e .find() — Filtrar Dados
Além do .map(), dois métodos de array são essenciais no React:
| Método | O que faz | Retorna |
|---|---|---|
.map() |
Transforma cada item | Array novo (mesmo tamanho) |
.filter() |
Filtra itens por condição | Array novo (menor ou igual) |
.find() |
Acha o primeiro que bate | Um item (ou undefined) |
.forEach() |
Executa algo pra cada item | Nada (void) |
const leads = [
{ id: 1, nome: 'João', status: 'novo' },
{ id: 2, nome: 'Maria', status: 'respondeu' },
{ id: 3, nome: 'Pedro', status: 'novo' },
{ id: 4, nome: 'Ana', status: 'convertido' },
];
// .filter() — pegar só os novos
const novos = leads.filter(lead => lead.status === 'novo');
// [{ id:1, nome:'João'... }, { id:3, nome:'Pedro'... }]
// .find() — achar um lead específico
const maria = leads.find(lead => lead.nome === 'Maria');
// { id:2, nome:'Maria', status:'respondeu' }
// .forEach() — fazer algo com cada lead (NÃO retorna nada)
leads.forEach(lead => {
console.log(`Lead: ${lead.nome} — ${lead.status}`);
});
// Encadear! filter + map (muito comum)
const nomesNovos = leads
.filter(lead => lead.status === 'novo')
.map(lead => lead.nome);
// ['João', 'Pedro']
Exemplo prático: Lista com filtro
import { useState } from 'react';
function ListaComFiltro() {
const [filtro, setFiltro] = useState('todos');
const leads = [
{ id: 1, nome: 'João', status: 'novo' },
{ id: 2, nome: 'Maria', status: 'respondeu' },
{ id: 3, nome: 'Pedro', status: 'novo' },
];
// Filtra com base no estado selecionado
const leadsFiltrados = filtro === 'todos'
? leads
: leads.filter(l => l.status === filtro);
return (
<div>
<button onClick={() => setFiltro('todos')}>Todos</button>
<button onClick={() => setFiltro('novo')}>Novos</button>
<button onClick={() => setFiltro('respondeu')}>Respondeu</button>
<p>Mostrando: {leadsFiltrados.length} leads</p>
{leadsFiltrados.map(lead => (
<div key={lead.id}>
{lead.nome} — {lead.status}
</div>
))}
</div>
);
}
•
.map() retorna um novo array → use pra renderizar na tela
•
.forEach() não retorna nada → use pra executar ações (log, enviar dados)
❌ leads.forEach(l => <div>{l.nome}</div>) — não funciona na tela!
✅ leads.map(l => <div key={l.id}>{l.nome}</div>) — funciona!
6.5.8 Props — Passar Dados Entre Componentes
Props são os dados que um componente pai passa para o filho. É como chamar uma função com argumentos:
// Componente filho — RECEBE props
function LeadCard({ nome, telefone, status }) {
// ↑ desestrutura as props
return (
<div style={{ border: '1px solid #ddd', padding: '12px', marginBottom: '8px' }}>
<h3>{nome}</h3>
<p>Tel: {telefone}</p>
<span>{status}</span>
</div>
);
}
// Componente pai — ENVIA props
function App() {
return (
<div>
<LeadCard nome="Barbearia João" telefone="21999001122" status="novo" />
<LeadCard nome="Clínica Saúde" telefone="21988003344" status="respondeu" />
{/* ↑ passa os dados como "atributos" */}
</div>
);
}
Props com função (callback)
O pai pode passar funções como props. Assim o filho consegue "avisar" o pai quando algo aconteceu:
// Filho: tem um botão que chama a função do pai
function LeadCard({ nome, telefone, onDelete }) {
return (
<div>
<h3>{nome}</h3>
<p>{telefone}</p>
<button onClick={onDelete}>Excluir</button>
{/* ↑ chama a função que o pai passou */}
</div>
);
}
// Pai: define o que acontece quando o filho clica "Excluir"
function App() {
function handleDelete(id) {
console.log(`Excluindo lead ${id}...`);
// chamar API, atualizar estado, etc.
}
return (
<div>
<LeadCard nome="João" telefone="21999..." onDelete={() => handleDelete(1)} />
<LeadCard nome="Maria" telefone="21988..." onDelete={() => handleDelete(2)} />
</div>
);
}
6.5.9 Renderização Condicional — Mostrar/Esconder
No React, você controla o que aparece na tela com JavaScript. Três formas:
function Painel({ loading, erro, leads }) {
// 1. IF/RETURN — retorna cedo se algo impede
if (loading) return <p>Carregando...</p>;
if (erro) return <p style={{color: 'red'}}>{erro}</p>;
return (
<div>
{/* 2. && — mostra SÓ SE a condição for true */}
{leads.length === 0 && <p>Nenhum lead encontrado.</p>}
{/* 3. TERNÁRIO — se/senão inline */}
{leads.length > 0
? <p>{leads.length} leads encontrados</p>
: <p>Lista vazia</p>
}
{/* Mostrar botão só se tem leads */}
{leads.length > 0 && (
<button>Enviar Mensagem para Todos</button>
)}
</div>
);
}
| Forma | Quando usar | Exemplo |
|---|---|---|
if/return |
Bloquear toda a renderização | Loading, erro, sem permissão |
{x && <div/>} |
Mostrar algo se condição true | Badges, avisos, botões extras |
{x ? <A/> : <B/>} |
Mostrar A ou B | Logado/deslogado, ativo/inativo |
Exemplo real: Badge de status
function StatusBadge({ status }) {
const cores = {
novo: 'bg-blue-100 text-blue-700',
contatado: 'bg-yellow-100 text-yellow-700',
respondeu: 'bg-green-100 text-green-700',
convertido: 'bg-purple-100 text-purple-700',
};
return (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${cores[status] || 'bg-gray-100'}`}>
{status}
</span>
);
}
6.5.10 Mini-Projeto: Lista de Contatos
Vamos juntar tudo que aprendemos em um componente funcional completo:
import { useState } from 'react';
// ─── Componente Filho: Card do Contato ───
function ContatoCard({ contato, onRemover }) {
return (
<div style={{
border: '1px solid #e0e0e0',
borderRadius: '8px',
padding: '12px',
marginBottom: '8px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}>
<div>
<strong>{contato.nome}</strong>
<p style={{ color: '#666', margin: 0 }}>{contato.telefone}</p>
</div>
<button
onClick={() => onRemover(contato.id)}
style={{ background: '#ef4444', color: 'white', border: 'none', borderRadius: '4px', padding: '4px 12px', cursor: 'pointer' }}
>
Remover
</button>
</div>
);
}
// ─── Componente Pai: Lista Completa ───
function ListaContatos() {
// Estado: lista de contatos
const [contatos, setContatos] = useState([
{ id: 1, nome: 'João Silva', telefone: '(21) 99900-1122' },
{ id: 2, nome: 'Maria Oliveira', telefone: '(21) 98800-3344' },
]);
// Estado: formulário de novo contato
const [form, setForm] = useState({ nome: '', telefone: '' });
// Estado: busca
const [busca, setBusca] = useState('');
// Adicionar contato
function handleAdicionar() {
if (!form.nome || !form.telefone) return;
const novo = {
id: Date.now(), // ID único simples
nome: form.nome,
telefone: form.telefone,
};
setContatos([...contatos, novo]); // spread: copia + adiciona
setForm({ nome: '', telefone: '' }); // limpa formulário
}
// Remover contato
function handleRemover(id) {
setContatos(contatos.filter(c => c.id !== id)); // filter: remove o item
}
// Filtrar pela busca
const contatosFiltrados = contatos.filter(c =>
c.nome.toLowerCase().includes(busca.toLowerCase())
);
return (
<div style={{ maxWidth: '500px', margin: '20px auto', fontFamily: 'sans-serif' }}>
<h1>Lista de Contatos</h1>
{/* ── Formulário ── */}
<div style={{ marginBottom: '16px' }}>
<input
placeholder="Nome"
value={form.nome}
onChange={e => setForm({ ...form, nome: e.target.value })}
style={{ marginRight: '8px', padding: '8px' }}
/>
<input
placeholder="Telefone"
value={form.telefone}
onChange={e => setForm({ ...form, telefone: e.target.value })}
style={{ marginRight: '8px', padding: '8px' }}
/>
<button onClick={handleAdicionar} style={{ padding: '8px 16px' }}>
Adicionar
</button>
</div>
{/* ── Busca ── */}
<input
placeholder="Buscar contato..."
value={busca}
onChange={e => setBusca(e.target.value)}
style={{ width: '100%', padding: '8px', marginBottom: '12px' }}
/>
{/* ── Contagem ── */}
<p>{contatosFiltrados.length} contato(s) encontrado(s)</p>
{/* ── Lista ── */}
{contatosFiltrados.length === 0 && <p>Nenhum contato.</p>}
{contatosFiltrados.map(contato => (
<ContatoCard
key={contato.id}
contato={contato} {/* passa o objeto como prop */}
onRemover={handleRemover} {/* passa a função como prop */}
/>
))}
</div>
);
}
1. useState — 3 estados: contatos (array), form (objeto), busca (string)
2. onChange — pegar dados de 3 inputs diferentes
3. onClick — botão adicionar e botão remover
4. .map() — renderizar lista de contatos
5. .filter() — buscar e remover contatos
6. Spread —
[...contatos, novo] e {...form, nome: valor}
7. Props — ContatoCard recebe dados e função do pai
8. Condicional —
{length === 0 && <p>Nenhum</p>}
9. key — cada ContatoCard tem
key={contato.id}
onClick={fn} → executar ação no clique
onChange={e => set(e.target.value)} → pegar valor do input
array.map(item => <X key={id}/>) → renderizar lista
array.filter(item => condição) → filtrar itens
array.find(item => condição) → achar um item
{condição && <X/>} → mostrar se true
{cond ? <A/> : <B/>} → mostrar A ou B
<Comp nome="x" /> → passar prop
function Comp({ nome }) → receber prop
{...obj, chave: valor} → copiar e alterar objeto
[...arr, novo] → copiar e adicionar no array
.tsx em vez de .jsx.6.8.1 Tipos Básicos
// Tipos primitivos
const nome: string = 'João';
const idade: number = 25;
const ativo: boolean = true;
// Arrays
const nomes: string[] = ['Ana', 'João'];
const idades: number[] = [20, 25, 30];
// Pode ser null ou undefined
const telefone: string | null = null; // union type
const email: string | undefined = undefined;
6.8.2 Interface — Definir Formato de Objetos
// Interface = "contrato" — diz quais campos o objeto TEM que ter
interface Lead {
id: number;
nome: string;
telefone: string;
email?: string; // ? = opcional (pode não ter)
ativo: boolean;
}
// Usar a interface:
const lead: Lead = {
id: 1,
nome: 'Maria',
telefone: '21999999999',
ativo: true,
// email é opcional, pode omitir
};
// Array de leads:
const leads: Lead[] = [lead];
interface é mais comum pra objetos. type é mais flexível.interface Lead { nome: string } ← para objetos (mais usado)type Status = 'ativo' | 'inativo' | 'pendente' ← para union types (valores fixos)
6.8.3 TypeScript no React — Tipando Componentes
// Tipando props de um componente
interface CardProps {
titulo: string;
valor: number;
destaque?: boolean; // opcional
}
function Card({ titulo, valor, destaque }: CardProps) {
return (
<div className={destaque ? 'bg-blue-500' : 'bg-gray-100'}>
<h3>{titulo}</h3>
<p>{valor}</p>
</div>
);
}
// Usando o componente:
<Card titulo="Leads" valor={42} /> // OK
<Card titulo="Leads" valor={42} destaque /> // OK (destaque = true)
<Card titulo="Leads" /> // ERRO! faltou 'valor'
<Card titulo={123} valor={42} /> // ERRO! titulo tem que ser string
6.8.4 Tipando useState
// Tipos simples — TypeScript infere automaticamente
const [nome, setNome] = useState(''); // infere string
const [count, setCount] = useState(0); // infere number
const [ativo, setAtivo] = useState(true); // infere boolean
// Arrays e objetos — PRECISA tipar com <Tipo>
const [leads, setLeads] = useState<Lead[]>([]);
// ↑ diz que é array de Lead
// Pode ser null no início
const [user, setUser] = useState<Lead | null>(null);
// ↑ Lead OU null
6.8.5 Tipando Eventos
// Evento de input (onChange)
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setNome(e.target.value);
};
// Evento de formulário (onSubmit)
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
};
// Evento de clique (onClick)
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
console.log('clicou');
};
onChange ou
onClick no JSX — ele mostra o tipo automaticamente. Copie e cole na sua função.6.8.6 Tipando Funções e Respostas de API
// Tipar resposta de API
interface ApiResponse<T> {
success: boolean;
data: T;
message: string;
}
// Função que busca leads da API
async function buscarLeads(): Promise<Lead[]> {
const res = await fetch('/api/leads');
const json: ApiResponse<Lead[]> = await res.json();
return json.data;
}
// Tipar callback de função
interface ButtonProps {
label: string;
onClick: () => void; // função sem retorno
onDelete: (id: number) => void; // função que recebe id
}
6.8.7 Utility Types (Ferramentas Prontas)
| Utility | O que faz | Exemplo |
|---|---|---|
Partial<T> |
Todos os campos ficam opcionais | Partial<Lead> — pode passar só nome |
Omit<T, K> |
Remove campo(s) | Omit<Lead, 'id'> — Lead sem id (pra criar) |
Pick<T, K> |
Pega só campo(s) | Pick<Lead, 'nome' | 'email'> |
Record<K, V> |
Objeto com chave/valor tipados | Record<string, number> |
// Exemplo prático: formulário de criação (sem id, pois o banco gera)
type CriarLead = Omit<Lead, 'id'>;
// Exemplo prático: formulário de edição (tudo opcional)
type EditarLead = Partial<Lead>;
6.8.8 Erros Mais Comuns do TypeScript
| Erro | Causa | Solução |
|---|---|---|
Type 'string' is not assignable to type 'number' |
Passou tipo errado | Verificar o tipo esperado e converter se necessário |
Property 'x' does not exist on type 'Y' |
Campo não existe na interface | Adicionar o campo na interface ou verificar typo |
Object is possibly 'null' |
Valor pode ser null | Usar if (valor) antes ou valor?.campo |
Argument of type ... is not assignable |
Formato do dado não bate | Verificar a interface esperada pela função |
'T' is not generic |
Tipo não aceita <> |
Usar interface/type que tem generic definido |
interface Produto com: id (number), nome (string), preco (number), descricao
(string opcional).2. Crie um
type StatusPedido que só aceita:
'pendente' | 'pago' | 'enviado' | 'entregue'.3. Tipar um componente
Badge que recebe texto: string e
cor: 'verde' | 'vermelho' | 'azul'.4. Criar um
type CriarProduto usando Omit pra remover o id do Produto.5. Tipar um
useState que começa como array vazio de Produto.
REACT AVANÇADO
O capítulo mais importante. Domine Hooks — o coração do React moderno.
Hooks são o coração do React moderno. Se você dominar hooks, domina React. Se não dominar, vai travar em tudo. Leia com calma, teste cada exemplo.
7.1 Por que Variável Normal Não Funciona
No JavaScript puro, você faz isso e funciona:
// JavaScript puro - funciona
let contador = 0;
document.getElementById('btn').addEventListener('click', () => {
contador++;
document.getElementById('numero').textContent = contador;
});
No React, isso NÃO funciona:
// ❌ ERRADO - o número NUNCA muda na tela
function Contador() {
let contador = 0; // variável normal
function incrementar() {
contador++; // muda o valor, mas...
console.log(contador); // aqui mostra 1, 2, 3...
}
return (
<div>
<p>{contador}</p> // ← SEMPRE mostra 0!
<button onClick={incrementar}>+1</button>
</div>
);
}
React funciona assim: quando algo muda, ele re-executa a função inteira do componente pra redesenhar a tela. Mas a variável
let contador = 0 volta pra 0 toda vez que a função
re-executa!É como um funcionário que anota o estoque num papel, mas toda vez que alguém pergunta, ele pega um papel NOVO e começa do zero.
A solução é o useState — ele guarda o valor entre re-execuções:
// ✅ CERTO - agora funciona!
import { useState } from 'react';
function Contador() {
const [contador, setContador] = useState(0);
// ↑ valor ↑ função ↑ valor inicial
// atual pra mudar (guardado pelo React)
return (
<div>
<p>{contador}</p> // ← Agora atualiza!
<button onClick={() => setContador(contador + 1)}>+1</button>
</div>
);
}
7.2 useState Profundo
Tipos de estado
| Código | Tipo | Quando usar |
|---|---|---|
useState('') |
Texto | Inputs, busca, nome |
useState(0) |
Número | Contadores, páginas, preço |
useState(false) |
Booleano | Abrir/fechar modal, loading |
useState([]) |
Array | Lista de leads, itens, resultados |
useState({}) |
Objeto | Formulário com vários campos |
useState(null) |
Nulo | Dado que ainda não carregou |
Estado com Arrays — NUNCA mute diretamente
const [leads, setLeads] = useState([]);
// ❌ ERRADO - modificar o array diretamente
leads.push({ nome: 'João' }); // React NÃO detecta a mudança!
setLeads(leads); // É o mesmo objeto na memória, React ignora
// ✅ CERTO - criar array novo com spread
setLeads([...leads, { nome: 'João' }]); // Novo array = React re-renderiza
// ✅ Remover item (filter cria array novo)
setLeads(leads.filter(l => l.id !== idParaRemover));
// ✅ Atualizar item (map cria array novo)
setLeads(leads.map(l => l.id === id ? { ...l, status: 'ativo' } : l));
.push(), .splice(), .sort()
diretamente no estado. Sempre crie um novo array/objeto com spread [...] ou métodos que retornam novo
array (.filter, .map).
Estado com Objetos
const [form, setForm] = useState({
nome: '',
email: '',
telefone: ''
});
// ❌ ERRADO
form.nome = 'João';
setForm(form);
// ✅ CERTO - spread copia tudo e sobrescreve só o que mudou
setForm({ ...form, nome: 'João' });
// ↑ copia email e telefone ↑ sobrescreve nome
Callback Form — quando o novo valor depende do anterior
// ❌ Pode dar problema se clicar muito rápido
setContador(contador + 1);
// ✅ SEGURO - garante que usa o valor mais recente
setContador(prev => prev + 1);
// ↑ React te dá o valor atual como argumento
// ✅ Adicionar item usando callback
setLeads(prev => [...prev, novoLead]);
// ✅ Remover usando callback
setLeads(prev => prev.filter(l => l.id !== id));
prev =>) sempre que o novo valor depender do
anterior. É mais seguro e evita bugs em atualizações rápidas.
7.3 useEffect Profundo
useEffect é "quando algo acontecer, execute isso". Serve para: buscar dados da API, ouvir eventos, configurar timers.
Exemplo real: buscar dados da API
import { useState, useEffect } from 'react';
function ListaLeads() {
const [leads, setLeads] = useState([]);
const [loading, setLoading] = useState(true);
const [erro, setErro] = useState(null);
useEffect(() => {
// Função async DENTRO do useEffect
async function carregar() {
try {
const resp = await fetch('/api/leads');
const data = await resp.json();
setLeads(data);
} catch (err) {
setErro('Falha ao carregar leads');
} finally {
setLoading(false);
}
}
carregar();
}, []); // [] = só executa uma vez, quando a página abre
if (loading) return <p>Carregando...</p>;
if (erro) return <p>{erro}</p>;
return (
<ul>
{leads.map(lead => <li key={lead.id}>{lead.nome}</li>)}
</ul>
);
}
Reagir a mudanças (busca com filtro)
const [busca, setBusca] = useState('');
const [resultados, setResultados] = useState([]);
useEffect(() => {
if (busca.length < 3) return; // só busca com 3+ letras
const timer = setTimeout(async () => {
const resp = await fetch(`/api/leads?q=${busca}`);
const data = await resp.json();
setResultados(data);
}, 500); // debounce: espera 500ms
return () => clearTimeout(timer); // cleanup: cancela se digitar de novo
}, [busca]); // executa toda vez que "busca" mudar
Cleanup — Limpeza
A função return dentro do useEffect é a limpeza. Ela roda quando:
- O componente some da tela (desmonta)
- Antes de re-executar o efeito (quando dependência muda)
useEffect(() => {
const intervalo = setInterval(() => {
console.log('Verificando novos leads...');
}, 30000);
return () => {
clearInterval(intervalo); // Para o timer quando sai da página
};
}, []);
• Para calcular algo que depende de estado/props → use uma variável normal
• Para reagir a clique do usuário → use onClick no botão
• Para formatar dados → faça na hora de renderizar
useEffect é para efeitos colaterais: buscar dados, ouvir eventos, manipular DOM, timers.
7.4 useRef — Guardar Sem Re-renderizar
useRef guarda um valor que persiste entre re-renders mas NÃO redesenha a tela quando muda.
| useState | useRef |
|---|---|
| Muda → redesenha a tela | Muda → nada acontece na tela |
| Para dados que o usuário VÊ | Para dados internos/invisíveis |
const [x, setX] = useState(0) |
const x = useRef(0) |
Ler: x |
Ler: x.current |
Mudar: setX(1) |
Mudar: x.current = 1 |
Uso 1: Acessar elemento do DOM
import { useRef } from 'react';
function BuscaLeads() {
const inputRef = useRef(null);
function focarInput() {
inputRef.current.focus(); // acessa o elemento real do DOM
}
return (
<div>
<input ref={inputRef} placeholder="Buscar..." />
<button onClick={focarInput}>Focar</button>
</div>
);
}
Uso 2: Guardar valor anterior
function Preco({ valor }) {
const anteriorRef = useRef(valor);
useEffect(() => {
anteriorRef.current = valor; // atualiza DEPOIS do render
}, [valor]);
return (
<p>
Atual: R$ {valor} | Anterior: R$ {anteriorRef.current}
</p>
);
}
7.5 Formulários com Hooks
Formulários são o uso mais comum de hooks no dia a dia. O padrão é: cada input controlado por um estado.
Formulário simples
import { useState } from 'react';
function FormLead() {
const [nome, setNome] = useState('');
const [telefone, setTelefone] = useState('');
return (
<form>
<input
value={nome}
onChange={e => setNome(e.target.value)}
placeholder="Nome"
/>
<input
value={telefone}
onChange={e => setTelefone(e.target.value)}
placeholder="Telefone"
/>
</form>
);
}
Formulário com objeto (vários campos)
function FormLeadCompleto() {
const [form, setForm] = useState({
nome: '', email: '', telefone: '', ramo: ''
});
const [loading, setLoading] = useState(false);
const [erro, setErro] = useState('');
// UMA função para TODOS os inputs
function handleChange(e) {
const { name, value } = e.target;
setForm(prev => ({ ...prev, [name]: value }));
// ↑ computed property name
// [name] = "nome", "email", "telefone"... depende do input
}
async function handleSubmit(e) {
e.preventDefault(); // impede reload da página
if (!form.nome || !form.telefone) {
setErro('Nome e telefone são obrigatórios');
return;
}
setLoading(true);
setErro('');
try {
await fetch('/api/leads', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form)
});
setForm({ nome: '', email: '', telefone: '', ramo: '' }); // limpa
} catch {
setErro('Erro ao salvar lead');
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit}>
{erro && <p style={{color: 'red'}}>{erro}</p>}
<input name="nome" value={form.nome} onChange={handleChange} placeholder="Nome" />
<input name="email" value={form.email} onChange={handleChange} placeholder="Email" />
<input name="telefone" value={form.telefone} onChange={handleChange} placeholder="Telefone" />
<input name="ramo" value={form.ramo} onChange={handleChange} placeholder="Ramo" />
<button disabled={loading}>{loading ? 'Salvando...' : 'Salvar Lead'}</button>
</form>
);
}
[name]: Em vez de criar uma função handleNome, handleEmail,
handleTelefone... você cria UMA função. O atributo name="email" do input vira a chave do objeto.
Menos código, menos erro.
7.6 Custom Hooks — Extrair Lógica Reutilizável
Quando dois ou mais componentes precisam da mesma lógica (buscar dados, controlar um modal, debounce...), você extrai para um custom hook.
use (useLeads, useForm, useDebounce). É
obrigatório — o React usa isso pra aplicar as regras de hooks.
Antes (código repetido em várias páginas)
// PaginaLeads.tsx - busca leads
const [leads, setLeads] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
leadService.listar().then(setLeads).finally(() => setLoading(false));
}, []);
// Dashboard.tsx - busca leads DE NOVO (código repetido!)
const [leads, setLeads] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
leadService.listar().then(setLeads).finally(() => setLoading(false));
}, []);
Depois (custom hook — uma vez só)
// hooks/useLeads.ts
import { useState, useEffect } from 'react';
import { leadService } from '@/services/leadService';
export function useLeads() {
const [leads, setLeads] = useState([]);
const [loading, setLoading] = useState(true);
async function carregar() {
setLoading(true);
const data = await leadService.listar();
setLeads(data);
setLoading(false);
}
useEffect(() => { carregar(); }, []);
return { leads, loading, recarregar: carregar };
}
// Agora em QUALQUER página:
const { leads, loading, recarregar } = useLeads();
7.7 Erros Comuns e Como Resolver
| ❌ Errado | ✅ Certo |
|---|---|
Loop infinito no useEffectuseEffect(() => {Sem [] → roda toda render → seta estado → render → roda de novo... |
useEffect(() => {Com [] → roda só uma vez |
Mutação direta do estadoleads.push(novo)Mesmo objeto → React ignora |
setLeads([...leads, novo])Novo array → React detecta |
useEffect async diretouseEffect(async () => {useEffect NÃO aceita async |
useEffect(() => {Função async DENTRO |
Estado derivado desnecessárioconst [filtrados, setFilt] = useState([])
|
const filtrados = leads.filter(...)Se depende de outro estado, calcule direto — sem useState, sem useEffect |
Condição dentro de hookif (logado) {Hooks NUNCA dentro de if/for/while |
const [x, setX] = useState(0)Sempre no topo do componente, mesma ordem toda vez |
Ler estado logo após setarsetNome('João')setState é assíncrono |
const novoNome = 'João'Use a variável, não o estado |
useState → "Guardar dados que aparecem na tela"
useEffect → "Quando algo acontecer, fazer algo (buscar dados, ouvir eventos)"
useRef → "Guardar algo sem redesenhar a tela (DOM, timers, valores anteriores)"
Com esses 3, você constrói 95% de qualquer aplicação React.
useLocalStorage que salva e recupera valores do
localStorage. Ele deve receber uma chave e um valor inicial, e retornar
[valor, setValor].2. Crie um componente que usa
useEffect pra buscar dados de uma API pública (ex:
jsonplaceholder.typicode.com/users) ao montar, e exiba a lista na tela.3. Crie um formulário controlado com
useState contendo campos nome, email e mensagem. Ao
clicar em "Enviar", mostre os dados num alert().
BACKEND + BANCO DE DADOS
Banco de dados, rotas, controllers e models. O motor invisível da aplicação.
Padrao de consulta no código
const [resultado] = await pool.query('SQL AQUI');
// [resultado] = desestruturacao. Pega so as linhas
| Comando | O que faz | Exemplo |
|---|---|---|
SELECT |
Buscar dados | SELECT nome FROM users |
COUNT(*) |
Contar linhas | SELECT COUNT(*) AS total FROM leads |
SUM(col) |
Somar valores | SELECT SUM(valor) FROM faturas |
AS nome |
Dar apelido | COUNT(*) AS totalLeads |
FROM |
De qual tabela | FROM users |
WHERE |
Filtrar | WHERE ativo = 1 |
AND |
Mais filtros | WHERE ativo = 1 AND plano = 'pro' |
GROUP BY |
Agrupar | GROUP BY status |
ORDER BY |
Ordenar | ORDER BY criado_em DESC |
LIMIT |
Limitar qtd | LIMIT 10 |
INSERT INTO |
Criar registro | INSERT INTO leads (nome) VALUES ('Jo') |
UPDATE |
Atualizar | UPDATE leads SET nome='Jo' WHERE id=1 |
DELETE FROM |
Apagar | DELETE FROM leads WHERE id = 1 |
WHERE status = 'ativa'Numeros SEM aspas:
WHERE ativo = 1COALESCE evita null:
COALESCE(SUM(valor), 0) retorna 0 se não tiver dados8.2 CREATE TABLE — Criando Tabelas
CREATE TABLE leads (
id INT AUTO_INCREMENT PRIMARY KEY,
nome VARCHAR(200) NOT NULL,
email VARCHAR(200) DEFAULT NULL,
telefone VARCHAR(20) NOT NULL,
status ENUM('novo', 'contatado', 'respondeu', 'convertido') DEFAULT 'novo',
ativo TINYINT(1) DEFAULT 1,
user_id INT NOT NULL,
criado_em TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
| Tipo | Quando usar | Exemplo |
|---|---|---|
INT |
Números inteiros | id, quantidade, user_id |
VARCHAR(N) |
Texto com limite | nome (200), telefone (20) |
TEXT |
Texto longo | descrição, mensagem |
DECIMAL(10,2) |
Dinheiro | preco, valor |
TINYINT(1) |
Boolean (0 ou 1) | ativo, pago |
ENUM(...) |
Valores fixos | status, plano |
TIMESTAMP |
Data/hora | criado_em, atualizado_em |
DATE |
Só data | data_nascimento |
| Constraint | O que faz |
|---|---|
PRIMARY KEY |
Identificador único da tabela (geralmente id) |
AUTO_INCREMENT |
Banco gera o número automaticamente (1, 2, 3...) |
NOT NULL |
Campo obrigatório (não pode ser vazio) |
DEFAULT valor |
Valor padrão se não informar |
UNIQUE |
Não pode repetir (ex: email único) |
FOREIGN KEY |
Referência a outra tabela (relacionamento) |
8.3 Relacionamentos entre Tabelas
-- Tabela intermediária (N:M)
CREATE TABLE campanha_leads (
id INT AUTO_INCREMENT PRIMARY KEY,
campanha_id INT NOT NULL,
lead_id INT NOT NULL,
status ENUM('pendente', 'enviado', 'respondeu') DEFAULT 'pendente',
FOREIGN KEY (campanha_id) REFERENCES campanhas(id) ON DELETE CASCADE,
FOREIGN KEY (lead_id) REFERENCES leads(id) ON DELETE CASCADE
);
8.4 JOINs — Buscar Dados de Várias Tabelas
-- INNER JOIN: só traz onde tem correspondência nas DUAS tabelas
SELECT leads.nome, leads.telefone, users.email AS dono_email
FROM leads
INNER JOIN users ON users.id = leads.user_id
WHERE leads.status = 'novo';
-- LEFT JOIN: traz TODOS da esquerda, mesmo sem correspondência
SELECT c.nome AS campanha, COUNT(cl.lead_id) AS total_leads
FROM campanhas c
LEFT JOIN campanha_leads cl ON cl.campanha_id = c.id
GROUP BY c.id;
-- Campanhas sem leads aparecem com total_leads = 0
-- Exemplo real: buscar leads de uma campanha com dados completos
SELECT l.id, l.nome, l.telefone, cl.status AS status_campanha
FROM campanha_leads cl
INNER JOIN leads l ON l.id = cl.lead_id
WHERE cl.campanha_id = ?
ORDER BY l.nome;
| Tipo de JOIN | Quando usar | Resultado |
|---|---|---|
INNER JOIN |
Só quero dados que existem nas duas tabelas | Leads que TÊM dono |
LEFT JOIN |
Quero TODOS da esquerda, mesmo sem match | Campanhas (até as vazias) |
RIGHT JOIN |
Quero TODOS da direita | Pouco usado, inverta e use LEFT |
8.5 Índices — Acelerar Consultas
-- Índice = atalho pro banco achar dados mais rápido
-- Crie índice em colunas que aparecem no WHERE, JOIN ou ORDER BY
CREATE INDEX idx_leads_user_id ON leads(user_id);
CREATE INDEX idx_leads_telefone ON leads(telefone);
CREATE INDEX idx_campanha_leads ON campanha_leads(campanha_id, lead_id);
CREATE UNIQUE INDEX idx_users_email ON users(email);
WHERE, JOIN ON e ORDER BY frequentemente. Não crie
índice em tudo — cada índice ocupa espaço e deixa INSERT/UPDATE mais lento.8.6 Paginação
-- Página 1 (itens 1-20)
SELECT * FROM leads WHERE user_id = ? ORDER BY id DESC LIMIT 20 OFFSET 0;
-- Página 2 (itens 21-40)
SELECT * FROM leads WHERE user_id = ? ORDER BY id DESC LIMIT 20 OFFSET 20;
-- Fórmula: OFFSET = (pagina - 1) * limite
-- Contar total (para mostrar "Página 1 de 5"):
SELECT COUNT(*) AS total FROM leads WHERE user_id = ?;
produtos com: id, nome (obrigatório), preco (decimal), estoque (inteiro,
padrão 0), criado_em (timestamp).2. Insira 3 produtos e faça um SELECT que traga apenas os com estoque > 0, ordenados por preço.
3. Escreva um INNER JOIN que traga o nome do lead e o nome da campanha a que ele pertence.
4. Escreva uma query com LEFT JOIN que mostre todas as campanhas e quantos leads cada uma tem.
5. Faça uma query paginada que traga a página 3 com 10 itens por página.
9.1 routes/exemploRoutes.js
const express = require('express');
const router = express.Router();
const ctrl = require('../controllers/exemploController');
const { authenticate } = require('../middlewares/auth');
router.get('/', authenticate, ctrl.listar); // GET /exemplo
router.get('/:id', authenticate, ctrl.buscarPorId); // GET /exemplo/42
router.post('/', authenticate, ctrl.criar); // POST /exemplo
router.put('/:id', authenticate, ctrl.atualizar); // PUT /exemplo/42
router.delete('/:id', authenticate, ctrl.deletar); // DELETE /exemplo/42
// router.METODO('url', middleware, controller.funcao)
module.exports = router;
// No app.js: app.use('/exemplo', require('./routes/exemploRoutes'))
9.2 controllers/exemploController.js
const { pool } = require('../database/connection');
// { pool } = desestruturacao. Pega SO o pool do objeto exportado
// LISTAR
exports.listar = async (req, res) => {
try {
const [rows] = await pool.query(
'SELECT * FROM exemplos WHERE user_id = ?', [req.user.id]
// ? = placeholder seguro (evita SQL injection)
// [req.user.id] = valor que substitui o ?
);
res.json({ success: true, data: rows });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
};
// BUSCAR POR ID
exports.buscarPorId = async (req, res) => {
try {
const [rows] = await pool.query(
'SELECT * FROM exemplos WHERE id = ? AND user_id = ?',
[req.params.id, req.user.id]
// req.params.id = da URL. GET /exemplo/42 -> id = 42
);
if (!rows.length) return res.status(404).json({ error: 'Nao encontrado' });
res.json({ success: true, data: rows[0] });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
};
// CRIAR
exports.criar = async (req, res) => {
try {
const { nome, descricao } = req.body;
// req.body = dados enviados pelo cliente (POST/PUT)
const [result] = await pool.query(
'INSERT INTO exemplos (user_id, nome, descricao) VALUES (?, ?, ?)',
[req.user.id, nome, descricao]
);
res.status(201).json({ success: true, id: result.insertId });
// 201 = criado. insertId = ID gerado pelo banco
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
};
// ATUALIZAR
exports.atualizar = async (req, res) => {
try {
const { nome, descricao } = req.body;
await pool.query(
'UPDATE exemplos SET nome = ?, descricao = ? WHERE id = ? AND user_id = ?',
[nome, descricao, req.params.id, req.user.id]
);
res.json({ success: true, message: 'Atualizado' });
} catch (e) { res.status(500).json({ error: e.message }); }
};
// DELETAR
exports.deletar = async (req, res) => {
try {
await pool.query('DELETE FROM exemplos WHERE id = ? AND user_id = ?',
[req.params.id, req.user.id]);
// SEMPRE filtre por user_id! Senao um usuario deleta dados de outro
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
};
9.3 models/exemploModel.js
const { pool } = require('../database/connection');
const ExemploModel = {
async findAll(userId, filtros = {}) {
let sql = 'SELECT * FROM exemplos WHERE user_id = ?';
const params = [userId];
if (filtros.status) { sql += ' AND status = ?'; params.push(filtros.status); }
if (filtros.search) { sql += ' AND nome LIKE ?'; params.push(`%${filtros.search}%`); }
// LIKE '%texto%' = busca parcial. "bar" encontra "barbearia"
sql += ' ORDER BY criado_em DESC';
const [rows] = await pool.execute(sql, params);
return rows;
},
async findById(userId, id) {
const [rows] = await pool.execute(
'SELECT * FROM exemplos WHERE id = ? AND user_id = ?', [id, userId]);
return rows[0] || null;
// Se nao encontrou, retorna null
},
async create(userId, data) {
const [result] = await pool.execute(
'INSERT INTO exemplos (user_id, nome) VALUES (?, ?)', [userId, data.nome]);
return this.findById(userId, result.insertId);
// Depois de criar, busca o registro completo e retorna
},
};
module.exports = ExemploModel;
9.4 services/exemploService.js
const Model = require('../models/exemploModel');
class ExemploService {
async criar(userId, data) {
// Regra: nao pode duplicar nome
const existe = await Model.findByNome(userId, data.nome);
if (existe) {
const err = new Error('Ja existe'); err.statusCode = 409; throw err;
// throw = lanca erro pro controller capturar no catch
}
return Model.create(userId, data);
}
}
module.exports = new ExemploService();
// Quando NAO precisa de service: consulta simples (stats, monitoramento)
// Quando PRECISA: validacoes, regras de negocio, combinar dados
9.5 middlewares/ - API Key e Validação
// API KEY (protege rotas externas)
function apiKeyAuth(req, res, next) {
const key = req.headers['x-api-key'];
// req.headers = headers que o cliente mandou
if (!key || key !== process.env.MONITOR_API_KEY) {
return res.status(401).json({ error: 'API Key invalida' });
// return = PARA AQUI. Controller NAO executa
}
next(); // Chave ok! Segue pro controller
}
// VALIDACAO DE CAMPOS
function validar(campos) {
return (req, res, next) => {
for (const c of campos) {
if (!req.body[c]) return res.status(400).json({ error: `${c} obrigatorio` });
}
next();
};
}
module.exports = { apiKeyAuth, validar };
// Na rota: router.post('/', apiKeyAuth, validar(['nome']), ctrl.criar)
// 1o verifica 2o valida 3o executa
9.6 database/connection.js
const mysql = require('mysql2/promise');
require('dotenv').config();
const pool = mysql.createPool({
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'meu_banco',
connectionLimit: 10,
// Pool = piscina de conexoes. Reutiliza em vez de abrir/fechar toda hora
});
module.exports = { pool };
9.7 database/migration-exemplo.sql
CREATE TABLE IF NOT EXISTS exemplos (
id INT AUTO_INCREMENT PRIMARY KEY, -- ID unico, gerado automatico
user_id INT NOT NULL, -- Obrigatorio
nome VARCHAR(255) NOT NULL, -- Texto ate 255 chars
descricao TEXT, -- Texto longo
valor DECIMAL(10,2) DEFAULT 0, -- 2 casas decimais
status ENUM('ativo','inativo') DEFAULT 'ativo', -- So aceita esses
ativo TINYINT(1) DEFAULT 1, -- 0 ou 1 (boolean)
criado_em TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- Auto
FOREIGN KEY (user_id) REFERENCES users(id)
);
9.8 config/ e utils/
// config/plans.js - Valores fixos
const PLAN_PRICES = { starter: 97, profissional: 197, empresarial: 397 };
module.exports = { PLAN_PRICES };
// utils/response.js - Funcoes auxiliares
function sendSuccess(res, data, msg = 'OK', code = 200) {
res.status(code).json({ success: true, message: msg, data });
}
function sendError(res, msg = 'Erro', code = 500) {
res.status(code).json({ success: false, message: msg });
}
module.exports = { sendSuccess, sendError };
produtoModel.js
(com funções getAll, getById, create, update, delete), produtoController.js e
produtoRoutes.js.2. Crie um middleware de validação que verifica se os campos
nome e preco foram
enviados no body antes de criar ou atualizar um produto.
FULLSTACK: CONECTANDO TUDO
Conecte frontend e backend. Consuma APIs e veja tudo funcionando junto.
Essa é a dúvida #1 de quem começa: "eu criei os arquivos, mas quem chama quem?"
▶.1 Visão Geral — A Cadeia de Chamadas
Quando o usuário clica num botão e vê dados na tela, a informação percorre uma cadeia de arquivos. Cada arquivo tem UMA responsabilidade:
▶.2 Frontend — Ordem de Criação e Chamada
No frontend, você cria nesta ordem (de baixo pra cima) e os arquivos chamam de cima pra baixo:
| Arquivo | Responsabilidade | Importa de | Exemplo |
|---|---|---|---|
| types/lead.ts | Define o formato dos dados | Ninguém | interface Lead { id, nome, telefone } |
| services/leadService.ts | Chama a API (fetch/axios) | types/ | api.get('/leads') |
| hooks/useLeads.ts | Gerencia estado (useState + useEffect) | services/ | const { leads, loading } = useLeads() |
| pages/LeadsPage.tsx | Renderiza na tela | hooks/ ou services/ | leads.map(l => <Card />) |
| components/LeadCard.tsx | Pedaço visual reutilizável | types/ | <LeadCard lead={lead} /> |
▶.3 Backend — Ordem de Criação e Chamada
No backend, a requisição entra pela Route e vai descendo:
| Arquivo | Responsabilidade | Chama | Analogia |
|---|---|---|---|
| routes/ | Mapeia URL → função | Controller | Recepcionista (direciona) |
| controllers/ | Lógica da requisição | Model ou Service | Gerente (decide o que fazer) |
| services/ | Regra de negócio complexa | Model | Especialista (cálculos, validações) |
| models/ | SQL puro (banco de dados) | MySQL pool | Arquivista (busca e guarda dados) |
▶.4 Fluxo Completo — Do Clique ao Dado na Tela
Exemplo real: o usuário abre a página de Leads.
• Type não importa ninguém (é só a definição)
• Service importa Type (pra tipar os dados)
• Hook importa Service + Type
• Page importa Hook (ou Service direto) + Components
• Component importa Type (pra receber props tipadas)
Se um arquivo de baixo está importando um de cima, algo está errado!
Backend: Model → Controller → Route (de dentro pra fora)
Frontend: Type → Service → Hook → Page (de baixo pra cima)
Crie primeiro o que NÃO depende de nada, depois o que depende.
10.1 pages/ExemploPage.tsx
import { useState, useEffect } from 'react';
import { exemploService } from '@/services/exemploService';
import { Exemplo } from '@/types/exemplo';
const ExemploPage = () => {
const [items, setItems] = useState<Exemplo[]>([]); // lista vazia
const [loading, setLoading] = useState(true); // carregando
const [error, setError] = useState<string|null>(null); // sem erro
useEffect(() => { carregar(); }, []);
// [] vazio = roda so quando a pagina abre
const carregar = async () => {
try {
setLoading(true);
const data = await exemploService.listar();
setItems(data); // coloca dados na caixa -> redesenha
} catch (err) {
setError('Erro ao carregar');
} finally {
setLoading(false); // finally = roda sempre
}
};
if (loading) return <p>Carregando...</p>;
if (error) return <p>{error}</p>;
return (
<div>
<h1>Itens ({items.length})</h1>
{items.map(item => (
<div key={item.id}>{item.nome}</div>
{/* key = id unico. map = percorre array renderizando cada item */}
))}
</div>
);
};
export default ExemploPage;
10.2 components/ExemploCard.tsx
import { Exemplo } from '@/types/exemplo';
interface Props {
item: Exemplo; // obrigatorio
onDelete?: (id: number) => void; // ? = opcional
}
export const ExemploCard = ({ item, onDelete }: Props) => {
// { item, onDelete } = desestrutura as props recebidas
return (
<div className="p-4 border rounded-lg">
{/* className = class no HTML. Tailwind CSS pra estilizar */}
<h3>{item.nome}</h3>
{onDelete && (
{/* && = "se existir onDelete, mostra o botao" */}
<button onClick={() => onDelete(item.id)}>Deletar</button>
{/* () => = funcao anonima. Sem isso, executaria ao renderizar */}
)}
</div>
);
};
// Uso: <ExemploCard item={meuItem} onDelete={handleDelete} />
10.3 services/exemploService.ts
import { apiFetch } from './api';
import { Exemplo } from '@/types/exemplo';
export const exemploService = {
async listar(): Promise<Exemplo[]> {
return apiFetch('/exemplos'); // GET
},
async criar(data: Partial<Exemplo>) {
return apiFetch('/exemplos', {
method: 'POST',
body: JSON.stringify(data), // objeto -> texto JSON
});
},
async deletar(id: number) {
return apiFetch(`/exemplos/${id}`, { method: 'DELETE' });
},
};
10.4 contexts/ExemploContext.tsx
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
interface ContextType {
usuario: { nome: string } | null;
logout: () => void;
}
const Ctx = createContext<ContextType | undefined>(undefined);
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [usuario, setUsuario] = useState(null);
const logout = () => setUsuario(null);
return (
<Ctx.Provider value={{ usuario, logout }}>
{children}
{/* children = tudo que estiver DENTRO do Provider */}
</Ctx.Provider>
);
};
export const useAuth = () => {
const ctx = useContext(Ctx);
if (!ctx) throw new Error('Fora do Provider');
return ctx;
};
// App.tsx: <AuthProvider><App/></AuthProvider>
// Qualquer page: const { usuario } = useAuth();
10.5 hooks/useExemplos.ts
import { useState, useEffect } from 'react';
import { exemploService } from '@/services/exemploService';
import { Exemplo } from '@/types/exemplo';
export const useExemplos = () => {
const [items, setItems] = useState<Exemplo[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => { carregar(); }, []);
const carregar = async () => {
const data = await exemploService.listar();
setItems(data); setLoading(false);
};
const adicionar = async (data: Partial<Exemplo>) => {
const novo = await exemploService.criar(data);
setItems(prev => [...prev, novo]);
// ...prev = espalha itens anteriores. Adiciona novo no final
};
const remover = async (id: number) => {
await exemploService.deletar(id);
setItems(prev => prev.filter(i => i.id !== id));
// filter = mantem todos MENOS o que tem esse id
};
return { items, loading, adicionar, remover, recarregar: carregar };
};
// Uso: const { items, loading, adicionar } = useExemplos();
10.6 types/exemplo.ts
export interface Exemplo {
id: number; // numero
nome: string; // texto
descricao?: string; // ? = opcional
status: 'ativo' | 'inativo'; // so esses valores
ativo: boolean; // true/false
tags?: string[]; // array de textos
criado_em: string; // data como texto
}
export type CriarDTO = Omit<Exemplo, 'id' | 'criado_em'>;
// Omit = tudo do Exemplo MENOS id e criado_em
src/services/api.ts - A base de todas as chamadas
const API_URL = 'http://localhost:3737';
export async function apiFetch(endpoint: string, options: RequestInit = {}) {
const token = localStorage.getItem('token');
const res = await fetch(`${API_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
// Se tem token JWT, manda no header
...options.headers,
},
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.message || 'Erro');
}
return res.json();
}
Ordem pra criar qualquer funcionalidade
fetch (ou a função apiFetch) pra buscar uma
lista de dados do seu backend e exibir na tela dentro de uma tabela ou lista.2. Adicione um estado de
loading que mostra "Carregando..." enquanto a requisição está em
andamento, e um tratamento de erro que exibe a mensagem de erro caso a API falhe.
Consumir uma API é pedir dados pra um servidor e exibir na tela. Vamos usar duas APIs gratuitas que não precisam de cadastro:
| API | O que faz | URL |
|---|---|---|
| ViaCEP | Busca endereço pelo CEP | viacep.com.br |
| JSONPlaceholder | Lista de usuários fake | jsonplaceholder.typicode.com |
12.1 fetch + await - Buscando dados
fetch é a função nativa do JavaScript pra fazer requisições HTTP. Ela é assíncrona (demora),
então precisa de await.
// fetch = "buscar" - faz requisição HTTP pra uma URL
// await = "espere" - pausa até o servidor responder
const resposta = await fetch('https://viacep.com.br/ws/01001000/json/');
// 1º await: espera o servidor RESPONDER
const dados = await resposta.json();
// 2º await: espera CONVERTER a resposta pra JSON
console.log(dados.logradouro); // "Praça da Sé"
console.log(dados.bairro); // "Sé"
console.log(dados.localidade); // "São Paulo"
await fetch(...) = espera o servidor responder2º
await resposta.json() = espera converter o texto da resposta em objeto JavaScriptSão duas operações separadas que demoram.
12.2 Exemplo 1: Busca de CEP (ViaCEP)
Um input onde o usuário digita o CEP e vê o endereço. Usa: useState, fetch,
await, try/catch, eventos.
import { useState } from 'react';
const BuscaCep = () => {
const [cep, setCep] = useState('');
// valor função-pra-mudar valor-inicial (vazio)
const [endereco, setEndereco] = useState(null);
// null = "nada ainda" (não buscou)
const [erro, setErro] = useState('');
// Função que busca o CEP na API
const buscar = async () => {
// async = "essa função vai ter await dentro"
try {
setErro('');
const res = await fetch(`https://viacep.com.br/ws/${cep}/json/`);
// template string: `texto ${variavel}` = junta texto com variável
const dados = await res.json();
if (dados.erro) {
setErro('CEP não encontrado');
setEndereco(null);
} else {
setEndereco(dados);
// Coloca os dados na "caixa" → React redesenha a tela
}
} catch (err) {
setErro('Erro ao buscar CEP');
}
};
return (
<div>
<h2>Busca de CEP</h2>
<input
value={cep}
{/* value = o que aparece no input (ligado ao useState) */}
onChange={(e) => setCep(e.target.value)}
{/* onChange = "quando digitar algo, atualize o estado"
e.target.value = texto que o usuário digitou */}
placeholder="Digite o CEP"
/>
<button onClick={buscar}>Buscar</button>
{/* onClick = "quando clicar, execute a função buscar" */}
{erro && <p style={{ color: 'red' }}>{erro}</p>}
{/* && = "se erro existir, mostra o <p>" */}
{endereco && (
<div>
<p><b>Rua:</b> {endereco.logradouro}</p>
<p><b>Bairro:</b> {endereco.bairro}</p>
<p><b>Cidade:</b> {endereco.localidade} - {endereco.uf}</p>
</div>
)}
</div>
);
};
export default BuscaCep;
useState = guardar cep, endereço e erroasync/await = esperar resposta da APIfetch = fazer a requisição HTTPtry/catch = tratar errosonChange = capturar digitaçãoonClick = capturar clique&& = renderização condicional (mostra se existir)${variavel} = template string
12.3 Exemplo 2: Lista de Usuários (JSONPlaceholder)
Uma lista que carrega automaticamente quando a página abre. Usa: useEffect, map,
props, componentes.
Passo 1: O componente filho (Card)
// UserCard.tsx - Componente que mostra UM usuário
// interface = formato dos dados (TypeScript)
interface UserCardProps {
nome: string;
email: string;
cidade: string;
}
// Props = dados que o componente PAI passa pro FILHO
// É como parâmetro de função
const UserCard = ({ nome, email, cidade }: UserCardProps) => {
// ^^^^^^^^^^^^^^^^^^^^^^^^
// Desestrutura as props: pega nome, email e cidade
return (
<div style={{ border: '1px solid #ddd', padding: '12px', borderRadius: '8px', marginBottom: '8px' }}>
<h3>{nome}</h3>
<p>{email}</p>
<p>{cidade}</p>
</div>
);
};
export default UserCard;
O pai decide O QUE passar. O filho decide COMO exibir.
<UserCard nome="João" email="j@mail.com" cidade="SP" />Dentro do UserCard:
nome vale "João", email vale "j@mail.com"
Passo 2: A página pai (Lista)
// ListaUsuarios.tsx - Página que busca e mostra a lista
import { useState, useEffect } from 'react';
import UserCard from './UserCard';
// Formato dos dados que a API retorna
interface User {
id: number;
name: string;
email: string;
address: { city: string };
}
const ListaUsuarios = () => {
const [usuarios, setUsuarios] = useState<User[]>([]);
// User[] = array de User. Começa com [] (lista vazia)
const [loading, setLoading] = useState(true);
// useEffect com [] = roda UMA VEZ quando a página abre
useEffect(() => {
buscarUsuarios();
}, []);
// [] vazio = "rode só quando o componente aparecer"
const buscarUsuarios = async () => {
const res = await fetch('https://jsonplaceholder.typicode.com/users');
const dados = await res.json();
setUsuarios(dados);
// Coloca os 10 usuários na "caixa" → tela redesenha
setLoading(false);
};
if (loading) return <p>Carregando...</p>;
return (
<div>
<h2>Usuários ({usuarios.length})</h2>
{/* .length = quantidade de itens no array */}
{usuarios.map(user => (
{/* map = percorre CADA item do array e transforma em elemento
user = cada usuário, um de cada vez */}
<UserCard
key={user.id}
{/* key = identificador único. React usa pra saber qual mudou */}
nome={user.name}
email={user.email}
cidade={user.address.city}
{/* Passando PROPS pro componente filho */}
/>
))}
</div>
);
};
export default ListaUsuarios;
[1, 2, 3].map(n => n * 2) → [2, 4, 6]Pega cada item, transforma, e devolve um novo array.
No React:
usuarios.map(user => <Card ... />)Pega cada usuário e transforma num componente visual.
10 usuários → 10 cards na tela.
12.4 Fluxo Completo: Da API até a Tela
12.5 Resumo dos Conceitos
| Conceito | O que é | Exemplo |
|---|---|---|
fetch |
Faz requisição HTTP pra uma URL | fetch('https://api.com/dados') |
await |
Espera operação terminar | const res = await fetch(...) |
async |
Marca função que tem await | const fn = async () => {} |
useState |
Caixa que redesenha a tela | const [x, setX] = useState('') |
useEffect |
Roda código quando abre/muda | useEffect(() => {}, []) |
map |
Transforma cada item do array | lista.map(i => <Card />) |
props |
Dados do pai pro filho | <Card nome="Jo" /> |
key |
ID único pro React rastrear | key={item.id} |
&& |
Mostra se condição for true | {erro && <p>{erro}</p>} |
onChange |
Quando digitou no input | onChange={e => set(e.target.value)} |
onClick |
Quando clicou no botão | onClick={buscar} |
try/catch |
Trata erros sem crashar | try { ... } catch { ... } |
viacep.com.br/ws/{cep}/json/): crie um input onde o usuário digita
o CEP e, ao clicar em "Buscar", exiba o endereço completo na tela.2. Crie um componente que busca a previsão do tempo de uma cidade usando uma API pública gratuita (ex: Open-Meteo) e exiba temperatura, condição e umidade.
P1.1 Instalando o MySQL
# No Ubuntu/Debian:
$ sudo apt update
$ sudo apt install mysql-server
$ sudo mysql_secure_installation
# Entrar no MySQL:
$ sudo mysql -u root -p
P1.2 Criando o Banco e a Tabela
-- Execute no terminal do MySQL:
-- 1. Criar o banco
CREATE DATABASE meu_app;
USE meu_app;
-- 2. Criar tabela de usuários
CREATE TABLE usuarios (
id INT AUTO_INCREMENT PRIMARY KEY,
nome VARCHAR(100) NOT NULL,
email VARCHAR(150) NOT NULL UNIQUE,
senha VARCHAR(255) NOT NULL, -- armazena hash, NUNCA texto puro
criado_em TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 3. Verificar se criou
DESCRIBE usuarios;
| Campo | Tipo | Por quê? |
|---|---|---|
id |
INT AUTO_INCREMENT | ID único, cresce sozinho |
nome |
VARCHAR(100) | Nome do usuário |
email |
VARCHAR(150) UNIQUE | UNIQUE = não pode repetir |
senha |
VARCHAR(255) | Hash bcrypt (60+ caracteres) |
criado_em |
TIMESTAMP | Data de criação automática |
❌ senha = "123456" → se hackear o banco, tem todas as senhas
✅ senha = "$2b$10$xK..." → hash irreversível com bcrypt
P1.3 Criando um Usuário para a Aplicação
-- Criar usuário MySQL para a app (não use root em produção!)
CREATE USER 'app_user'@'localhost' IDENTIFIED BY 'senha_segura_123';
GRANT ALL PRIVILEGES ON meu_app.* TO 'app_user'@'localhost';
FLUSH PRIVILEGES;
P2.1 Setup do Projeto Backend
$ mkdir login-app && cd login-app
$ mkdir backend && cd backend
$ npm init -y
$ npm install express mysql2 bcryptjs jsonwebtoken cors dotenv
| Pacote | Pra quê |
|---|---|
express |
Criar o servidor e rotas |
mysql2 |
Conectar ao banco MySQL |
bcryptjs |
Criptografar senhas |
jsonwebtoken |
Gerar tokens JWT |
cors |
Permitir o React acessar a API |
dotenv |
Variáveis de ambiente (.env) |
P2.2 Arquivo .env
# backend/.env
DB_HOST=localhost
DB_USER=app_user
DB_PASS=senha_segura_123
DB_NAME=meu_app
JWT_SECRET=minha_chave_secreta_super_forte_123
PORT=3737
Adicione
.env no .gitignore. Esse arquivo contém senhas.
P2.3 Conexão com o Banco
// backend/database.js
const mysql = require('mysql2/promise');
require('dotenv').config();
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
});
module.exports = pool;
// pool = "piscina" de conexões. Reutiliza conexões automaticamente.
P2.4 Servidor Express
// backend/server.js — arquivo principal
const express = require('express');
const cors = require('cors');
require('dotenv').config();
const authRoutes = require('./routes/auth');
const app = express();
// Middlewares
app.use(cors()); // Permite React acessar
app.use(express.json()); // Lê JSON do body
// Rotas
app.use('/api/auth', authRoutes); // /api/auth/register, /api/auth/login
// Iniciar
const PORT = process.env.PORT || 3737;
app.listen(PORT, () => {
console.log(`✅ Servidor rodando na porta ${PORT}`);
});
P2.5 Rotas de Registro e Login
Este é o arquivo mais importante do backend. Ele tem duas rotas:
// backend/routes/auth.js
const express = require('express');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const pool = require('../database');
const router = express.Router();
// ═══════════════════════════════════════
// POST /api/auth/register — Criar conta
// ═══════════════════════════════════════
router.post('/register', async (req, res) => {
try {
const { nome, email, senha } = req.body;
// 1. Validar campos
if (!nome || !email || !senha) {
return res.status(400).json({ erro: 'Preencha todos os campos' });
}
// 2. Verificar se email já existe
const [existe] = await pool.query(
'SELECT id FROM usuarios WHERE email = ?',
[email]
);
if (existe.length > 0) {
return res.status(400).json({ erro: 'Email já cadastrado' });
}
// 3. Criptografar a senha
const senhaCripto = await bcrypt.hash(senha, 10);
// ↑ 10 = "custo" do hash (mais alto = mais seguro)
// 4. Salvar no banco
const [result] = await pool.query(
'INSERT INTO usuarios (nome, email, senha) VALUES (?, ?, ?)',
[nome, email, senhaCripto]
);
// 5. Gerar token JWT
const token = jwt.sign(
{ id: result.insertId, nome, email }, // dados dentro do token
process.env.JWT_SECRET, // chave secreta
{ expiresIn: '7d' } // expira em 7 dias
);
res.json({ sucesso: true, token, usuario: { id: result.insertId, nome, email } });
} catch (error) {
console.error('Erro no registro:', error);
res.status(500).json({ erro: 'Erro interno do servidor' });
}
});
// ═══════════════════════════════════════
// POST /api/auth/login — Entrar
// ═══════════════════════════════════════
router.post('/login', async (req, res) => {
try {
const { email, senha } = req.body;
// 1. Validar campos
if (!email || !senha) {
return res.status(400).json({ erro: 'Preencha email e senha' });
}
// 2. Buscar usuário pelo email
const [rows] = await pool.query(
'SELECT * FROM usuarios WHERE email = ?',
[email]
);
if (rows.length === 0) {
return res.status(401).json({ erro: 'Email ou senha incorretos' });
}
const usuario = rows[0];
// 3. Comparar senha digitada com hash do banco
const senhaCorreta = await bcrypt.compare(senha, usuario.senha);
// ↑ texto puro ↑ hash do banco
if (!senhaCorreta) {
return res.status(401).json({ erro: 'Email ou senha incorretos' });
}
// 4. Gerar token JWT
const token = jwt.sign(
{ id: usuario.id, nome: usuario.nome, email: usuario.email },
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
res.json({
sucesso: true,
token,
usuario: { id: usuario.id, nome: usuario.nome, email: usuario.email }
});
} catch (error) {
console.error('Erro no login:', error);
res.status(500).json({ erro: 'Erro interno do servidor' });
}
});
module.exports = router;
Register: Recebe dados → verifica se email existe → criptografa senha → salva no banco → gera token
Login: Recebe email+senha → busca no banco → compara senha com hash → gera token
O token JWT é como um "crachá digital" — depois do login, o frontend manda ele em toda requisição pra provar quem é.
P2.6 Testando o Backend
$ node server.js
✅ Servidor rodando na porta 3737
# Testar registro (outro terminal):
$ curl -X POST http://localhost:3737/api/auth/register \
-H "Content-Type: application/json" \
-d '{"nome":"João","email":"joao@test.com","senha":"123456"}'
# Resposta esperada:
{"sucesso":true,"token":"eyJhbG...","usuario":{"id":1,"nome":"João","email":"joao@test.com"}}
# Testar login:
$ curl -X POST http://localhost:3737/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"joao@test.com","senha":"123456"}'
mysql> SELECT * FROM usuarios;
+----+-------+----------------+--------------------------------------------------------------+
| id | nome | email | senha |
+----+-------+----------------+--------------------------------------------------------------+
| 1 | João | joao@test.com | $2b$10$xK8qF... ← hash bcrypt (nunca texto puro!) |
+----+-------+----------------+--------------------------------------------------------------+
P3.1 Estrutura de Pastas
login-app/
├── backend/ # ← já criamos (P2)
│ ├── .env
│ ├── database.js
│ ├── server.js
│ └── routes/auth.js
│
└── frontend/ # ← vamos criar agora
└── src/
├── App.tsx
├── pages/
│ ├── Login.tsx
│ ├── Register.tsx
│ └── Dashboard.tsx
└── main.tsx
$ cd login-app
$ npm create vite@latest frontend -- --template react-ts
$ cd frontend && npm install
$ npm install react-router-dom
P3.2 Página de Login — Passo a Passo
// frontend/src/pages/Login.tsx
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
export default function Login() {
// ── Estado do formulário ──
const [email, setEmail] = useState(''); // guarda o email
const [senha, setSenha] = useState(''); // guarda a senha
const [erro, setErro] = useState(''); // mensagem de erro
const [loading, setLoading] = useState(false); // botão desabilitado
const navigate = useNavigate(); // pra redirecionar depois do login
// ── Função chamada ao clicar "Entrar" ──
async function handleLogin() {
// 1. Validação básica
if (!email || !senha) {
setErro('Preencha email e senha');
return;
}
setLoading(true);
setErro('');
try {
// 2. Enviar para o backend
const resposta = await fetch('http://localhost:3737/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, senha }),
});
const dados = await resposta.json();
// 3. Verificar resposta
if (!dados.sucesso) {
setErro(dados.erro || 'Erro ao fazer login');
return;
}
// 4. Salvar token e redirecionar
localStorage.setItem('token', dados.token);
localStorage.setItem('usuario', JSON.stringify(dados.usuario));
navigate('/dashboard');
} catch {
setErro('Erro de conexão com o servidor');
} finally {
setLoading(false);
}
}
// ── Tela ──
return (
<div style={{ maxWidth: '400px', margin: '80px auto', padding: '32px', fontFamily: 'sans-serif' }}>
<h1>Login</h1>
{/* Mostra erro se existir */}
{erro && <p style={{ color: 'red' }}>{erro}</p>}
{/* Input de email — onChange atualiza o estado */}
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
style={{ width: '100%', padding: '10px', marginBottom: '12px', fontSize: '16px' }}
/>
{/* Input de senha */}
<input
type="password"
placeholder="Senha"
value={senha}
onChange={(e) => setSenha(e.target.value)}
style={{ width: '100%', padding: '10px', marginBottom: '16px', fontSize: '16px' }}
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
/>
{/* ↑ onKeyDown: se apertar Enter, faz login */}
{/* Botão — onClick chama handleLogin */}
<button
onClick={handleLogin}
disabled={loading}
style={{ width: '100%', padding: '12px', fontSize: '16px', cursor: 'pointer' }}
>
{loading ? 'Entrando...' : 'Entrar'}
</button>
<p style={{ marginTop: '16px', textAlign: 'center' }}>
Não tem conta? <Link to="/register">Criar conta</Link>
</p>
</div>
);
}
•
useState('') → guarda o valor de cada campo
•
onChange={(e) => setEmail(e.target.value)} → atualiza quando digita
•
onClick={handleLogin} → executa ao clicar no botão
•
fetch('...', { method: 'POST', body: ... }) → envia dados pro backend
•
localStorage.setItem('token', ...) → salva o token no navegador
•
navigate('/dashboard') → redireciona pra página protegida
P3.3 Página de Registro
// frontend/src/pages/Register.tsx
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
export default function Register() {
const [form, setForm] = useState({ nome: '', email: '', senha: '' });
const [erro, setErro] = useState('');
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
// Uma função pra todos os inputs (truque do [name])
function handleChange(e) {
setForm({ ...form, [e.target.name]: e.target.value });
}
async function handleRegister() {
if (!form.nome || !form.email || !form.senha) {
setErro('Preencha todos os campos');
return;
}
setLoading(true);
setErro('');
try {
const resp = await fetch('http://localhost:3737/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
});
const dados = await resp.json();
if (!dados.sucesso) {
setErro(dados.erro);
return;
}
localStorage.setItem('token', dados.token);
localStorage.setItem('usuario', JSON.stringify(dados.usuario));
navigate('/dashboard');
} catch {
setErro('Erro de conexão');
} finally {
setLoading(false);
}
}
return (
<div style={{ maxWidth: '400px', margin: '80px auto', padding: '32px' }}>
<h1>Criar Conta</h1>
{erro && <p style={{ color: 'red' }}>{erro}</p>}
<input name="nome" placeholder="Nome" value={form.nome} onChange={handleChange} style={{ width:'100%',padding:'10px',marginBottom:'12px' }} />
<input name="email" placeholder="Email" value={form.email} onChange={handleChange} type="email" style={{ width:'100%',padding:'10px',marginBottom:'12px' }} />
<input name="senha" placeholder="Senha" value={form.senha} onChange={handleChange} type="password" style={{ width:'100%',padding:'10px',marginBottom:'16px' }} />
<button onClick={handleRegister} disabled={loading} style={{ width:'100%',padding:'12px',fontSize:'16px' }}>
{loading ? 'Criando...' : 'Criar Conta'}
</button>
<p style={{ marginTop:'16px', textAlign:'center' }}>
Já tem conta? <Link to="/login">Entrar</Link>
</p>
</div>
);
}
P3.4 Dashboard — Página Protegida
// frontend/src/pages/Dashboard.tsx
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
export default function Dashboard() {
const navigate = useNavigate();
const [usuario, setUsuario] = useState<{nome: string, email: string} | null>(null);
// useEffect roda DEPOIS do render — lugar certo para verificar auth
useEffect(() => {
const token = localStorage.getItem('token');
if (!token) {
navigate('/login');
return;
}
const dados = JSON.parse(localStorage.getItem('usuario') || 'null');
setUsuario(dados);
}, [navigate]);
function handleLogout() {
localStorage.removeItem('token');
localStorage.removeItem('usuario');
navigate('/login');
}
if (!usuario) return <p>Carregando...</p>;
return (
<div style={{ maxWidth: '600px', margin: '80px auto', padding: '32px' }}>
<h1>Dashboard</h1>
<p>Bem-vindo, <b>{usuario.nome}</b>!</p>
<p>Email: {usuario.email}</p>
<button onClick={handleLogout}>Sair</button>
</div>
);
}
navigate() direto no corpo do componente causa um warning no React: "Cannot update during
render". O useEffect roda depois do render, que é o momento correto para fazer
redirecionamentos e ler localStorage.P4.1 App.tsx — Rotas
// frontend/src/App.tsx
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import Login from './pages/Login';
import Register from './pages/Register';
import Dashboard from './pages/Dashboard';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="*" element={<Navigate to="/login" />} />
</Routes>
</BrowserRouter>
);
}
P4.2 Fluxo Completo — Do Clique ao Banco
P4.3 Rodando o Projeto
# Terminal 1 — Backend:
$ cd login-app/backend
$ node server.js
✅ Servidor rodando na porta 3737
# Terminal 2 — Frontend:
$ cd login-app/frontend
$ npm run dev
➜ Local: http://localhost:5173
# Abrir no navegador: http://localhost:5173/register
P4.4 Checklist — Tudo Funcionando?
☐ MySQL rodando (Linux:
sudo systemctl status mysql | Windows: services.msc → MySQL)
☐ Banco
meu_app criado com tabela usuarios
☐ Backend rodando na porta 3737
☐ Frontend rodando na porta 5173
☐ Abrir /register → preencher → clicar "Criar Conta"
☐ Redireciona pro /dashboard com nome do usuário
☐ Clicar "Sair" → volta pro /login
☐ Fazer login com email e senha criados
☐ Redireciona pro /dashboard novamente
☐ Verificar no MySQL:
SELECT * FROM usuarios; → tem o registro
1. CREATE TABLE — criar tabela no MySQL com campos tipados
2. Express — criar servidor com rotas POST
3. bcrypt — criptografar senha (hash) e comparar no login
4. JWT — gerar token de autenticação
5. useState — guardar email, senha, erro, loading no React
6. onChange — pegar o que o usuário digita nos inputs
7. onClick — executar o login/registro ao clicar no botão
8. fetch POST — enviar dados do React pro backend
9. localStorage — salvar token no navegador
10. useNavigate — redirecionar entre páginas
11. Rota protegida — verificar token antes de mostrar Dashboard
• Adicionar Tailwind CSS pra deixar bonito (capítulo 06)
• Criar middleware de autenticação no backend (capítulo 13)
• Adicionar mais páginas protegidas
• Usar Context API pra gerenciar o estado global do usuário
• Fazer deploy na VPS (capítulo 16)
PRODUÇÃO E DEPLOY
Autenticação avançada, debug, boas práticas e deploy em produção. Pronto pro mercado.
Autenticação é como o sistema sabe quem é você. JWT (JSON Web Token) é o padrão mais usado em APIs.
13.1 Como Funciona
O token é como um crachá digital. Você faz login, recebe o crachá, e mostra ele em cada porta que precisa passar.
13.2 Instalando Dependências
# No backend
npm install jsonwebtoken bcryptjs
| Pacote | Para quê |
|---|---|
jsonwebtoken |
Criar e verificar tokens JWT |
bcryptjs |
Criptografar senhas (nunca salvar senha pura!) |
13.3 Registro de Usuário
// routes/auth.js const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); // POST /auth/register router.post('/register', async (req, res) => { const { nome, email, senha } = req.body; // 1. Verificar se email já existe const [existe] = await pool.query( 'SELECT id FROM users WHERE email = ?', [email] ); if (existe.length > 0) { return res.status(400).json({ error: 'Email já cadastrado' }); } // 2. Criptografar a senha (NUNCA salvar senha pura) const senhaHash = await bcrypt.hash(senha, 10); // ↑ "força" da criptografia // 3. Inserir no banco const [result] = await pool.query( 'INSERT INTO users (nome, email, senha_hash) VALUES (?, ?, ?)', [nome, email, senhaHash] ); res.status(201).json({ message: 'Usuário criado!', id: result.insertId }); });
bcrypt.hash(). Se alguém invadir seu banco, as
senhas estarão criptografadas e inúteis.13.4 Login (Gerar Token)
// POST /auth/login router.post('/login', async (req, res) => { const { email, senha } = req.body; // 1. Buscar usuário pelo email const [users] = await pool.query( 'SELECT * FROM users WHERE email = ?', [email] ); if (users.length === 0) { return res.status(401).json({ error: 'Email ou senha inválidos' }); } const user = users[0]; // 2. Comparar senha digitada com o hash do banco const senhaCorreta = await bcrypt.compare(senha, user.senha_hash); if (!senhaCorreta) { return res.status(401).json({ error: 'Email ou senha inválidos' }); } // 3. Gerar o token JWT const token = jwt.sign( { id: user.id, email: user.email }, // dados dentro do token process.env.JWT_SECRET, // chave secreta (do .env) { expiresIn: '24h' } // expira em 24 horas ); res.json({ token, user: { id: user.id, nome: user.nome } }); });
13.5 Middleware de Proteção
O middleware verifica o token em cada requisição protegida. Se não tiver token ou for inválido, bloqueia.
// middlewares/auth.js const jwt = require('jsonwebtoken'); function authenticateToken(req, res, next) { // Pega o token do header: "Bearer eyJhbGciOi..." const authHeader = req.headers['authorization']; const token = authHeader && authHeader.split(' ')[1]; // ↑ pega só o token, sem "Bearer" if (!token) { return res.status(401).json({ error: 'Token não fornecido' }); } jwt.verify(token, process.env.JWT_SECRET, (err, decoded) => { if (err) { return res.status(403).json({ error: 'Token inválido' }); } req.user = decoded; // coloca os dados do user no req next(); // libera para a próxima função }); } module.exports = { authenticateToken };
13.6 Usando o Middleware nas Rotas
const { authenticateToken } = require('./middlewares/auth'); // Rota PÚBLICA (qualquer um acessa) router.get('/produtos', produtoController.listar); // Rota PROTEGIDA (precisa de token) router.post('/produtos', authenticateToken, produtoController.criar); router.delete('/produtos/:id', authenticateToken, produtoController.deletar); // Dentro do controller, você tem acesso ao usuário logado: exports.criar = async (req, res) => { const userId = req.user.id; // ← vem do middleware! // ... };
13.7 No Frontend (React)
// services/api.ts - salvar e enviar token import axios from 'axios'; const api = axios.create({ baseURL: 'http://localhost:3737' }); // Interceptor: adiciona token em TODA requisição automaticamente api.interceptors.request.use((config) => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); // Login: salva o token async function login(email: string, senha: string) { const { data } = await api.post('/auth/login', { email, senha }); localStorage.setItem('token', data.token); // salva no navegador return data.user; } // Logout: remove o token function logout() { localStorage.removeItem('token'); window.location.href = '/login'; }
13.8 Variável de Ambiente
# backend/.env
JWT_SECRET=minha_chave_super_secreta_nunca_commitar_isso_123
JWT_SECRET deve ser uma string longa e aleatória. Nunca suba o .env pro GitHub. Cada
ambiente (dev, produção) deve ter sua própria chave.13.9 Avançado: Roles e Permissões
No nível 6 você aprendeu login básico. Agora vamos adicionar roles — diferentes níveis de acesso (admin, user).
// Ao gerar o token, inclua o role do usuário
const token = jwt.sign(
{ id: user.id, email: user.email, role: user.role }, // ← role aqui
process.env.JWT_SECRET,
{ expiresIn: '24h' }
);
// Middleware que verifica se é admin
function isAdmin(req, res, next) {
if (req.user.role !== 'admin') {
return res.status(403).json({ error: 'Acesso negado. Apenas administradores.' });
}
next();
}
// Uso nas rotas:
router.get('/admin/users', authenticateToken, isAdmin, adminController.listarUsuarios);
// ↑ verifica token ↑ verifica se é admin
13.10 Avançado: Token Expirado no Frontend
// Interceptor de RESPOSTA: detecta token expirado e faz logout
api.interceptors.response.use(
(response) => response, // sucesso: retorna normal
(error) => {
if (error.response?.status === 401 || error.response?.status === 403) {
// Token expirou ou é inválido
localStorage.removeItem('token');
localStorage.removeItem('usuario');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
2. Depois de 24h, qualquer requisição retorna 401
3. O interceptor detecta → limpa localStorage → redireciona pro login
4. Usuário faz login de novo → novo token de 24h
13.11 Segurança — Checklist
| Regra | Por quê |
|---|---|
Senhas com bcrypt.hash() |
Se banco for hackeado, senhas ficam ilegíveis |
JWT_SECRET longo e aleatório |
Chave curta pode ser adivinhada por força bruta |
Token com expiresIn |
Se token vazar, expira e para de funcionar |
| Validar dados no backend | Nunca confie nos dados do frontend |
SQL com ? (parametrizado) |
Previne SQL injection |
Usar helmet no Express |
Adiciona headers de segurança automaticamente |
| Rate limiting em login | Previne ataques de força bruta |
| HTTPS em produção | Dados criptografados em trânsito |
// Segurança básica no Express (instale: npm install helmet express-rate-limit)
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
app.use(helmet()); // headers de segurança
// Limitar tentativas de login: máximo 5 por minuto por IP
const loginLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minuto
max: 5,
message: { error: 'Muitas tentativas. Tente novamente em 1 minuto.' },
});
router.post('/login', loginLimiter, authController.login);
isAdmin que verifica se o usuário autenticado tem a propriedade
role: 'admin' no token JWT. Se não for admin, retorne status 403.2. Implemente um sistema de refresh token: ao fazer login, gere um token de acesso (expira em 15min) e um refresh token (expira em 7 dias). Crie a rota
POST /auth/refresh que gera um novo token de
acesso.3. No frontend, implemente a função de logout que limpa o token do
localStorage e redireciona
o usuário pra página de login.
13.5.1 Tipos de Testes
13.5.2 Ferramentas
| Ferramenta | Pra que | Onde |
|---|---|---|
Jest |
Runner de testes (roda e verifica) | Backend + Frontend |
Supertest |
Testar rotas HTTP | Backend |
React Testing Library |
Testar componentes React | Frontend |
# Instalar no BACKEND
npm install --save-dev jest supertest
# Instalar no FRONTEND (Vite)
npm install --save-dev @testing-library/react @testing-library/jest-dom jsdom vitest
13.5.3 Jest — Testes Unitários (Backend)
// backend/tests/utils.test.js
// Arquivo: testa funções puras (sem banco, sem API)
function normalizarTelefone(tel) {
let limpo = tel.replace(/\D/g, '');
if (!limpo.startsWith('55')) limpo = '55' + limpo;
return limpo;
}
// describe = grupo de testes
describe('normalizarTelefone', () => {
// it (ou test) = 1 teste
it('deve remover caracteres não numéricos', () => {
expect(normalizarTelefone('(21) 99999-9999')).toBe('5521999999999');
});
it('deve adicionar 55 se não tiver', () => {
expect(normalizarTelefone('21999999999')).toBe('5521999999999');
});
it('não deve duplicar o 55', () => {
expect(normalizarTelefone('5521999999999')).toBe('5521999999999');
});
});
describe('nome do grupo', () => { ... }) — agrupa testes relacionadosit('o que deve acontecer', () => { ... }) — 1 teste específicoexpect(valor).toBe(esperado) — verifica se o resultado é o esperado
Matchers mais usados:
| Matcher | O que verifica | Exemplo |
|---|---|---|
.toBe(x) |
Igual exato | expect(2+2).toBe(4) |
.toEqual(x) |
Igual profundo (objetos) | expect(obj).toEqual({a:1}) |
.toBeTruthy() |
É truthy | expect('texto').toBeTruthy() |
.toBeFalsy() |
É falsy | expect(null).toBeFalsy() |
.toContain(x) |
Array contém item | expect([1,2]).toContain(2) |
.toThrow() |
Lança erro | expect(() => fn()).toThrow() |
.toHaveLength(n) |
Tem N itens | expect(arr).toHaveLength(3) |
Configurar no package.json (backend):
{
"scripts": {
"test": "jest --verbose",
"test:watch": "jest --watch"
}
}
# Rodar testes
npm test
# Rodar em modo watch (re-roda quando muda arquivo)
npm run test:watch
13.5.4 Supertest — Testar Rotas da API
// backend/tests/auth.test.js
const request = require('supertest');
const app = require('../src/app'); // seu express app
describe('POST /auth/register', () => {
it('deve criar usuário com dados válidos', async () => {
const res = await request(app)
.post('/auth/register')
.send({ nome: 'Teste', email: 'teste@test.com', senha: '123456' });
expect(res.status).toBe(201);
expect(res.body.success).toBe(true);
});
it('deve rejeitar sem email', async () => {
const res = await request(app)
.post('/auth/register')
.send({ nome: 'Teste', senha: '123456' });
expect(res.status).toBe(400);
});
});
describe('POST /auth/login', () => {
it('deve retornar token com credenciais corretas', async () => {
const res = await request(app)
.post('/auth/login')
.send({ email: 'teste@test.com', senha: '123456' });
expect(res.status).toBe(200);
expect(res.body.data.token).toBeTruthy();
});
it('deve rejeitar senha errada', async () => {
const res = await request(app)
.post('/auth/login')
.send({ email: 'teste@test.com', senha: 'errada' });
expect(res.status).toBe(401);
});
});
13.5.5 React Testing Library — Testar Componentes
Configurar vitest (Vite):
// vite.config.ts
export default defineConfig({
// ... resto da config
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/test/setup.ts',
},
});
// src/test/setup.ts
import '@testing-library/jest-dom';
Testar um componente:
// src/components/__tests__/Badge.test.tsx
import { render, screen } from '@testing-library/react';
import Badge from '../Badge';
describe('Badge', () => {
it('deve renderizar o texto', () => {
render(<Badge texto="Ativo" cor="verde" />);
expect(screen.getByText('Ativo')).toBeInTheDocument();
});
it('deve aplicar classe de cor', () => {
render(<Badge texto="Erro" cor="vermelho" />);
const el = screen.getByText('Erro');
expect(el).toHaveClass('bg-red-100');
});
});
Testar interação (clique, digitação):
// src/components/__tests__/LoginForm.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import LoginForm from '../LoginForm';
it('deve chamar onSubmit com email e senha', () => {
const mockSubmit = jest.fn();
render(<LoginForm onSubmit={mockSubmit} />);
// Preencher campos
fireEvent.change(screen.getByPlaceholderText('Email'), {
target: { value: 'joao@test.com' },
});
fireEvent.change(screen.getByPlaceholderText('Senha'), {
target: { value: '123456' },
});
// Clicar no botão
fireEvent.click(screen.getByText('Entrar'));
// Verificar se chamou com os dados corretos
expect(mockSubmit).toHaveBeenCalledWith({
email: 'joao@test.com',
senha: '123456',
});
});
13.5.6 Queries do Testing Library
| Query | Busca por | Quando usar |
|---|---|---|
getByText('X') |
Texto visível | Botões, labels, títulos |
getByPlaceholderText('X') |
Placeholder do input | Campos de formulário |
getByRole('button') |
Role HTML | Botões, links, headings |
getByTestId('X') |
data-testid="X" |
Quando não tem texto/role |
queryByText('X') |
Texto (retorna null se não achar) | Verificar que NÃO existe |
13.5.8 Testes no Frontend — Em Profundidade
A seção anterior mostrou o básico. Agora vamos cobrir os cenários reais que todo frontend precisa testar: componentes com estado, chamadas de API, hooks customizados e mocking.
Configuração completa (Vitest + React Testing Library)
// vite.config.ts
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
css: true,
},
});
// src/test/setup.ts
import '@testing-library/jest-dom';
Testar componente com estado (useState)
// src/components/Counter.tsx
const Counter = () => {
const [count, setCount] = useState(0);
return (
<div>
<p data-testid="count">{count}</p>
<button onClick={() => setCount(c => c + 1)}>+</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
};
// src/components/__tests__/Counter.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import Counter from '../Counter';
describe('Counter', () => {
it('começa em 0', () => {
render(<Counter />);
expect(screen.getByTestId('count')).toHaveTextContent('0');
});
it('incrementa ao clicar +', () => {
render(<Counter />);
fireEvent.click(screen.getByText('+'));
fireEvent.click(screen.getByText('+'));
expect(screen.getByTestId('count')).toHaveTextContent('2');
});
it('reseta ao clicar Reset', () => {
render(<Counter />);
fireEvent.click(screen.getByText('+'));
fireEvent.click(screen.getByText('Reset'));
expect(screen.getByTestId('count')).toHaveTextContent('0');
});
});
Testar componente com API (mock do fetch)
// Componente que busca dados de uma API
const UserList = () => {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/users')
.then(r => r.json())
.then(data => { setUsers(data); setLoading(false); });
}, []);
if (loading) return <p>Carregando...</p>;
return (
<ul>
{users.map(u => <li key={u.id}>{u.nome}</li>)}
</ul>
);
};
// __tests__/UserList.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import UserList from '../UserList';
// Mock global do fetch
const mockUsers = [
{ id: 1, nome: 'João' },
{ id: 2, nome: 'Maria' },
];
beforeEach(() => {
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(mockUsers),
})
);
});
afterEach(() => { vi.restoreAllMocks(); });
describe('UserList', () => {
it('mostra loading inicialmente', () => {
render(<UserList />);
expect(screen.getByText('Carregando...')).toBeInTheDocument();
});
it('mostra lista de usuários após carregar', async () => {
render(<UserList />);
// waitFor espera o componente atualizar (API retornou)
await waitFor(() => {
expect(screen.getByText('João')).toBeInTheDocument();
expect(screen.getByText('Maria')).toBeInTheDocument();
});
// Verifica que loading sumiu
expect(screen.queryByText('Carregando...')).not.toBeInTheDocument();
});
it('chamou fetch com a URL correta', async () => {
render(<UserList />);
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith('/api/users');
});
});
});
beforeEach — roda antes de cada teste (setup)
afterEach — roda depois de cada teste (cleanup)
waitFor — espera o componente re-renderizar (essencial para async)
queryByText — retorna null se não encontrar (útil para testar que algo sumiu)
Testar formulário com validação
// __tests__/LoginForm.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
const mockOnSubmit = vi.fn();
it('não envia se email estiver vazio', () => {
render(<LoginForm onSubmit={mockOnSubmit} />);
// Preenche só a senha, email fica vazio
fireEvent.change(screen.getByPlaceholderText('Senha'), {
target: { value: '123456' },
});
fireEvent.click(screen.getByText('Entrar'));
// Verifica que a função NÃO foi chamada
expect(mockOnSubmit).not.toHaveBeenCalled();
// Verifica que mensagem de erro apareceu
expect(screen.getByText('Email obrigatório')).toBeInTheDocument();
});
it('envia com dados corretos', () => {
render(<LoginForm onSubmit={mockOnSubmit} />);
fireEvent.change(screen.getByPlaceholderText('Email'), {
target: { value: 'joao@email.com' },
});
fireEvent.change(screen.getByPlaceholderText('Senha'), {
target: { value: '123456' },
});
fireEvent.click(screen.getByText('Entrar'));
expect(mockOnSubmit).toHaveBeenCalledWith({
email: 'joao@email.com',
senha: '123456',
});
});
Mocking de módulos (serviços)
// Quando o componente importa um service, você pode "mockar" o módulo inteiro
// O componente faz: import { getLeads } from '../services/leadService'
vi.mock('../services/leadService', () => ({
getLeads: vi.fn(() => Promise.resolve({
data: [
{ id: 1, nome: 'Empresa A', status: 'novo' },
{ id: 2, nome: 'Empresa B', status: 'contatado' },
],
})),
}));
it('renderiza lista de leads do service', async () => {
render(<LeadsPage />);
await waitFor(() => {
expect(screen.getByText('Empresa A')).toBeInTheDocument();
expect(screen.getByText('Empresa B')).toBeInTheDocument();
});
});
Rodar testes e ver cobertura
# Rodar todos os testes
$ npx vitest
# Rodar em modo watch (re-roda quando salva)
$ npx vitest --watch
# Ver cobertura de código (quais linhas estão testadas)
$ npx vitest --coverage
# Rodar só testes de um arquivo
$ npx vitest Counter
Checklist de Testes no Frontend
1. Renderiza sem erro (smoke test)
2. Mostra estado de loading
3. Mostra dados após carregar
4. Mostra mensagem de erro quando API falha
5. Interações (click, change) funcionam
6. Validação de formulário
7. Chamou a API com os parâmetros certos
1. Testar sem
waitFor em componente async → teste passa mas dado não carregou
2. Usar
getByText quando deveria usar queryByText → erro se não encontrar
3. Esquecer
vi.restoreAllMocks() no afterEach → testes contaminam uns aos outros
4. Não mockar fetch/services → teste faz chamada real para a API
13.5.7 Checklist de Testes
- Rota retorna 200 com dados válidos?
- Rota retorna 400 com dados inválidos?
- Rota retorna 401 sem token?
- Rota retorna 404 com id inexistente?
Frontend:
- Componente renderiza sem erro?
- Texto/dados aparecem na tela?
- Cliques chamam as funções corretas?
- Estado de loading aparece?
- Mensagem de erro aparece quando necessário?
calcularDesconto(preco, percentual) que retorna o
preço com desconto.2. Escreva um teste com Supertest para uma rota
GET /leads que deve retornar 401 se não passar
token.3. Escreva um teste com Testing Library para um componente
<Counter /> que tem um botão
"+" e mostra o número na tela.
2. Olha os logs:
pm2 logs app --lines 203. Le a mensagem do erro - diz QUAL linha e POR QUÊ
4. Corrige, reinicia, testa de novo
| Comando | Pra que |
|---|---|
pm2 logs app --lines 20 |
Ver ultimas 20 linhas de log |
pm2 logs app --err --lines 20 |
So logs de ERRO |
pm2 restart app |
Reiniciar servidor |
pm2 list |
Ver processos rodando |
curl http://localhost:PORTA/rota |
Testar rota no terminal |
ss -tlnp | grep node |
Descobrir porta do Node |
| Código HTTP | Significado |
|---|---|
| 200 | OK - Tudo certo |
| 201 | Criado com sucesso |
| 400 | Dados invalidos (cliente errou) |
| 401 | Não autorizado (faltou login/key) |
| 404 | URL não existe |
| 409 | Conflito (ja existe) |
| 500 | Erro interno (servidor quebrou) |
1 "Cannot read properties of undefined (reading 'X')"
O erro mais comum do JavaScript. Significa que você tentou acessar uma propriedade de algo que é
undefined.
// CAUSA: dados ainda não carregaram (API assíncrona)
const [user, setUser] = useState(null);
return <p>{user.nome}</p>; // ERRO! user é null no primeiro render
// SOLUÇÃO 1: optional chaining (?.)
return <p>{user?.nome}</p>; // retorna undefined em vez de dar erro
// SOLUÇÃO 2: guard clause
if (!user) return <p>Carregando...</p>;
return <p>{user.nome}</p>;
2 "Module not found: Can't resolve './Componente'"
// CAUSAS:
// 1. Nome do arquivo errado (maiúscula/minúscula importa!)
import Badge from './badge'; // ERRO se o arquivo é Badge.tsx
import Badge from './Badge'; // CERTO
// 2. Caminho errado
import Badge from './components/Badge'; // verifique se a pasta existe
// 3. Pacote não instalado
// Rode: npm install nome-do-pacote
3 "EADDRINUSE: address already in use :::3737"
Outra instância do servidor já está rodando nessa porta.
# Achar e matar o processo
lsof -i :3737
kill -9 PID_AQUI
# Ou matar todos os node
pkill -f node
# Ou trocar a porta no .env
PORT=3738
4 Tela branca no React (sem erro visível)
2. Erro comum: componente retornando
undefined (esqueceu o return)3. Erro comum: import errado (componente não existe no caminho)
4. Erro comum: rota não bate (verifique o React Router)
5. Verifique se o
npm run dev está rodando sem erros no terminal5 "npm install" deu erro
# Limpar cache e reinstalar
rm -rf node_modules package-lock.json
npm cache clean --force
npm install
# Se persistir, verificar versão do Node
node -v # precisa ser 18+
npm -v
6 CORS: "Access-Control-Allow-Origin"
O navegador bloqueou a requisição porque frontend e backend estão em URLs diferentes.
// SOLUÇÃO: instalar e configurar cors no backend
// npm install cors
const cors = require('cors');
app.use(cors({ origin: 'http://localhost:5173' }));
// Em produção, troque pela URL real do frontend
7 "ER_ACCESS_DENIED_ERROR" (MySQL)
# Verificar credenciais no .env
DB_HOST=localhost
DB_USER=root
DB_PASSWORD=sua_senha_aqui
DB_NAME=nome_do_banco
# Verificar se o MySQL está rodando
systemctl status mysql
# Testar conexão manualmente
mysql -u root -p
8 "jwt malformed" ou "invalid token"
2. Está mandando "Bearer TOKEN" mas a API espera só "TOKEN" (ou vice-versa)
3. JWT_SECRET do backend mudou depois de gerar o token
4. Token está corrompido no localStorage — limpe:
localStorage.clear()9 "Too many re-renders" (React)
// CAUSA: setState dentro do render (sem useEffect ou evento)
function App() {
const [x, setX] = useState(0);
setX(x + 1); // ERRO! Chama setState toda vez que renderiza = loop infinito
// SOLUÇÃO: coloque dentro de useEffect ou num handler
useEffect(() => { setX(1); }, []); // roda só 1 vez
}
10 "Unhandled Promise Rejection" / "await is only valid in async"
// CAUSA: usando await fora de função async
const dados = await fetch('/api'); // ERRO se não está em async
// SOLUÇÃO: marcar a função como async
const carregar = async () => {
const dados = await fetch('/api');
};
11 "404 Not Found" na API
/api/leads vs /leads)2. O backend está rodando? (verifique o terminal)
3. O proxy do Vite está configurado? (vite.config.ts → server.proxy)
4. Em produção: o Nginx está redirecionando pra porta certa?
12 "ERR_CONNECTION_REFUSED"
O servidor não está rodando ou está na porta errada.
# Backend está rodando?
pm2 list # produção
# ou verifique o terminal do npm run dev
# Verificar porta
ss -tlnp | grep node
13 Git: "merge conflict"
// O arquivo vai ter marcações assim:
<<<<<<< HEAD
const cor = 'azul'; // sua versão
=======
const cor = 'verde'; // versão do colega
>>>>>>> branch-nome
// SOLUÇÃO: escolha qual versão manter, delete as marcações e salve
const cor = 'azul'; // mantive a minha
// Depois:
git add arquivo-com-conflito.js
git commit -m "fix: resolver conflito de merge"
14 "Hydration mismatch" / Conteúdo diferente no servidor
Date.now() ou Math.random() direto no render2. Verificar
window ou localStorage sem guardSolução: mova código que depende do browser pra dentro de
useEffect.
15 Como ler um Stack Trace
// Exemplo de erro:
TypeError: Cannot read properties of undefined (reading 'map')
at LeadTable (src/components/LeadTable.tsx:42:18) ← AQUI está o erro
at renderWithHooks (node_modules/react-dom/...) ← ignore (interno do React)
at mountIndeterminateComponent (...) ← ignore
node_modules — são código interno das bibliotecas.Comandos do dia a dia
| Comando | O que faz |
|---|---|
npm run dev |
Roda em desenvolvimento |
npm run build |
Gera versão de produção |
npm install pacote |
Instala biblioteca |
npm install |
Instala tudo do package.json |
git add . && git commit -m "msg" |
Salva no Git |
git push |
Envia pro servidor |
Checklist: Criar nova funcionalidade no Backend
- [ ] Criar controller em controllers/
- [ ] Importar { pool } de database/connection
- [ ] Escrever queries SQL dentro do try/catch
- [ ] Criar rota em routes/
- [ ] Importar controller na rota
- [ ] Conectar no app.js com app.use()
- [ ] Reiniciar servidor (pm2 restart)
- [ ] Testar com curl
Checklist: Criar nova funcionalidade no Frontend
- [ ] Criar type em types/ (formato dos dados)
- [ ] Criar service em services/ (chamada API)
- [ ] Criar component em components/ (se reutilizável)
- [ ] Criar page em pages/ (tela)
- [ ] Adicionar Route no App.tsx
- [ ] Testar no navegador
Checklist: Projeto novo do zero
- [ ] Node.js instalado
- [ ] npm create vite@latest + npm install + npm run dev
- [ ] Tailwind CSS instalado
- [ ] React Router instalado
- [ ] Atalho @/ configurado
- [ ] Pastas criadas (pages, components, services...)
- [ ] Backend: npm init -y + Express + MySQL
- [ ] .env criado (NUNCA no git!)
- [ ] server.js + app.js + connection.js
- [ ] api.ts no frontend conectando ao backend
R Tabela de Referência Rápida
| Preciso de... | Backend | Frontend |
|---|---|---|
| Nova URL | routes/ | Route no App.tsx |
| Lógica de processamento | controllers/ | pages/ |
| Falar com banco | models/ | - |
| Regras de negocio | services/ | - |
| Chamar API | - | services/ |
| Verificar permissão | middlewares/ | contexts/ + ProtectedRoute |
| Peca visual reutilizável | - | components/ |
| Dados compartilhados | - | contexts/ |
| Lógica reutilizável | utils/ | hooks/ |
| Formato dos dados | - | types/ |
| Valores fixos | config/ | data/ |
Deploy é colocar seu projeto no ar para o mundo acessar. Vamos usar uma VPS (servidor Linux) com Nginx, PM2 e SSL.
16.1 Visão Geral da Arquitetura
16.2 Preparar o Backend
Arquivo .env de Produção
# backend/.env (PRODUÇÃO)
NODE_ENV=production
PORT=3737
DB_HOST=localhost
DB_USER=root
DB_PASSWORD=SuaSenhaForte123!
DB_NAME=meu_banco
JWT_SECRET=chave_super_secreta_aleatoria_64_caracteres
Build do Frontend
# Na raiz do projeto frontend npm run build # Isso gera a pasta dist/ com os arquivos otimizados
16.3 PM2 - Gerenciador de Processos
PM2 mantém seu backend rodando 24/7. Se crashar, ele reinicia automaticamente.
# Instalar PM2 globalmente npm install -g pm2 # Iniciar o backend pm2 start backend/src/app.js --name meu-api # Ver processos rodando pm2 list # Ver logs em tempo real pm2 logs meu-api # Reiniciar após mudanças pm2 restart meu-api # Salvar para iniciar junto com o servidor pm2 save pm2 startup
| Comando | O que faz |
|---|---|
pm2 list |
Mostra todos os processos |
pm2 logs nome |
Mostra logs em tempo real |
pm2 restart nome |
Reinicia o processo |
pm2 stop nome |
Para o processo |
pm2 delete nome |
Remove o processo |
pm2 monit |
Monitor visual (CPU, RAM) |
16.4 Nginx - Proxy Reverso
Nginx recebe as requisições da internet e direciona: arquivos estáticos servidos direto, requisições de API redirecionadas para o Node.js.
# /etc/nginx/sites-available/meusite server { listen 80; server_name meusite.com.br; # Frontend (arquivos estáticos) location / { root /root/meu-projeto/dist; try_files $uri $uri/ /index.html; # ↑ Necessário para SPA (React Router) } # Backend (proxy para Node.js) location /api/ { proxy_pass http://localhost:3737/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }
# Ativar o site ln -s /etc/nginx/sites-available/meusite /etc/nginx/sites-enabled/ # Testar configuração nginx -t # Reiniciar Nginx systemctl restart nginx
16.5 SSL (HTTPS) com Let's Encrypt
SSL é obrigatório em 2026. É gratuito com Let's Encrypt e o Certbot configura tudo automaticamente.
# Instalar Certbot apt install certbot python3-certbot-nginx # Gerar certificado (automático!) certbot --nginx -d meusite.com.br # Renovar automaticamente (já vem configurado) certbot renew --dry-run
16.6 Checklist de Deploy
☐
.env configurado com dados de produção
☐
.gitignore com node_modules, .env e dist
☐
npm run build sem erros
☐ Banco de dados criado com as tabelas
☐ PM2 rodando o backend (
pm2 list = online)
☐ Nginx configurado e testado (
nginx -t = ok)
☐ SSL ativo (
certbot --nginx)
☐ Domínio apontando para o IP da VPS (DNS)
☐ Firewall liberando portas 80 e 443
16.7 Workflow de Atualização
Quando precisar atualizar o código em produção:
# 1. No servidor, puxar as mudanças do GitHub cd /root/meu-projeto git pull # 2. Instalar dependências novas (se houver) npm install # 3. Rebuild do frontend npm run build # 4. Reiniciar o backend pm2 restart meu-api # 5. Verificar se está tudo rodando pm2 list
PM2 = mantém o Node.js rodando 24/7, reinicia se crashar
Let's Encrypt = HTTPS gratuito e automático
MySQL = banco de dados em produção
.env = configurações sensíveis (nunca commitar)
Neste módulo bônus, você vai criar um Bot de WhatsApp completo usando Node.js. O bot vai ser capaz de:
- Responder mensagens automaticamente
- Mostrar um menu interativo de opções
- Enviar imagens, documentos e mídia
- Funcionar em grupos e conversas privadas
- Integrar com APIs externas (CEP, clima, etc.)
- Rodar 24h em um servidor com PM2
W1.1 Ferramentas Disponíveis
Existem várias bibliotecas para criar bots de WhatsApp com Node.js. Cada uma tem suas vantagens:
| Ferramenta | Tipo | Vantagem | Dificuldade |
|---|---|---|---|
whatsapp-web.js |
Biblioteca npm | Mais popular, grande comunidade | Fácil |
venom-bot |
Biblioteca npm | Brasileiro, API simples | Fácil |
Baileys |
Biblioteca npm | Multi-device, sem Chromium | Médio |
Evolution API |
API REST | API completa, dashboard web | Médio |
1. +15.000 stars no GitHub — comunidade gigante
2. Documentação completa — exemplos pra tudo
3. Simples de começar — 20 linhas e o bot já funciona
4. Gratuito — não precisa pagar API
W1.2 Como Funciona por Baixo
O whatsapp-web.js funciona simulando o WhatsApp Web no seu servidor:
W2.1 Event-Driven Programming
Node.js é baseado em eventos. Em vez de ficar perguntando "chegou mensagem? chegou mensagem?", o Node.js escuta e reage quando algo acontece:
// ❌ Abordagem errada (polling)
while (true) {
const msg = checkNewMessage(); // Fica perguntando sem parar
if (msg) handleMessage(msg);
}
// ✅ Abordagem correta (event-driven)
client.on('message', (msg) => {
handleMessage(msg); // Só executa quando chega mensagem
});
W2.2 WebSocket — Comunicação em Tempo Real
O WhatsApp usa WebSocket para enviar e receber mensagens em tempo real. Diferente do HTTP (que é "pergunta e resposta"), o WebSocket mantém uma conexão aberta:
| HTTP (Normal) | WebSocket (WhatsApp) |
|---|---|
| Cliente pergunta → Servidor responde | Conexão aberta permanente |
| Uma pergunta = uma resposta | Mensagens vão e voltam a qualquer hora |
| Bom para: páginas web, APIs | Bom para: chat, jogos, tempo real |
W2.3 QR Code — Autenticação
Na primeira vez que o bot conecta, ele gera um QR Code. Você escaneia com o WhatsApp do celular (como faz no WhatsApp Web). Depois disso, a sessão fica salva e o bot reconecta automaticamente.
W2.4 Puppeteer e Chromium
O whatsapp-web.js usa o Puppeteer internamente — o mesmo Chromium que usamos para gerar PDFs!
Ele abre um navegador "invisível" (headless) que simula o WhatsApp Web. Por isso o bot precisa de um ambiente com
Chromium instalado.
• Node.js 18+ instalado
• ~512MB RAM mínimo (Chromium consome memória)
• Chromium/Chrome instalado (ou o Puppeteer baixa automaticamente)
• Conexão com a internet estável
W3.1 Inicializando o Projeto
$ mkdir whatsapp-bot
$ cd whatsapp-bot
$ npm init -y
W3.2 Instalando Dependências
$ npm install whatsapp-web.js qrcode-terminal
| Pacote | Para que serve |
|---|---|
whatsapp-web.js |
Biblioteca principal — conecta ao WhatsApp |
qrcode-terminal |
Mostra o QR Code direto no terminal |
W3.3 Estrutura de Pastas
W3.4 Configurando o .gitignore
# .gitignore
node_modules/
.wwebjs_auth/
.wwebjs_cache/
.env
•
.wwebjs_auth/ — contém sua sessão do WhatsApp (quem tiver acesso controla seu número!)
•
.env — pode conter tokens e senhas
•
node_modules/ — pesado e desnecessário
W4.1 Código Base — O "Hello World" do Bot
Este é o código mínimo para conectar ao WhatsApp e ouvir mensagens:
// index.js
const { Client, LocalAuth } = require('whatsapp-web.js');
const qrcode = require('qrcode-terminal');
// Criar o cliente com persistência de sessão
const client = new Client({
authStrategy: new LocalAuth(),
puppeteer: {
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
}
});
// Evento: QR Code gerado (escanear com celular)
client.on('qr', (qr) => {
console.log('📱 Escaneie o QR Code abaixo:');
qrcode.generate(qr, { small: true });
});
// Evento: Bot conectado com sucesso
client.on('ready', () => {
console.log('✅ Bot conectado e pronto!');
});
// Evento: Mensagem recebida
client.on('message', async (msg) => {
console.log(`Mensagem de ${msg.from}: ${msg.body}`);
if (msg.body === '!ping') {
await msg.reply('🏓 Pong!');
}
});
// Iniciar o bot
client.initialize();
W4.2 Rodando pela Primeira Vez
$ node index.js
📱 Escaneie o QR Code abaixo:
▄▄▄▄▄▄▄ ▄▄ ▄ ▄▄▄▄▄▄▄
█ ▄▄▄ █ ██▀█ █ ▄▄▄ █
...
✅ Bot conectado e pronto!
1. Rode
node index.js no terminal
2. O QR Code aparece no terminal
3. Abra o WhatsApp no celular → Configurações → Dispositivos conectados → Conectar dispositivo
4. Escaneie o QR Code
5. O bot mostra "✅ Bot conectado e pronto!"
6. Envie
!ping para qualquer conversa e o bot responde 🏓 Pong!
W4.3 Entendendo cada Evento
| Evento | Quando dispara | Para que usar |
|---|---|---|
qr |
QR Code gerado | Mostrar QR no terminal/tela |
ready |
Bot conectado | Log de sucesso, iniciar rotinas |
authenticated |
Sessão autenticada | Log, notificar admin |
auth_failure |
Falha na autenticação | Tratar erro, retentar |
disconnected |
Bot desconectou | Reconectar, alertar admin |
message |
Mensagem recebida | Processar e responder |
message_create |
Mensagem criada (enviada/recebida) | Log de todas as mensagens |
W4.4 Persistência de Sessão (LocalAuth)
O LocalAuth salva a sessão em disco na pasta .wwebjs_auth/. Assim, quando você
reiniciar o bot, ele reconecta automaticamente sem precisar escanear o QR Code novamente.
// SEM persistência (pede QR toda vez)
const client = new Client();
// COM persistência (salva sessão em disco)
const client = new Client({
authStrategy: new LocalAuth() // ← Salva em .wwebjs_auth/
});
W5.1 Anatomia de uma Mensagem
Quando o evento message dispara, você recebe um objeto com várias propriedades:
client.on('message', async (msg) => {
console.log(msg.body); // Texto da mensagem
console.log(msg.from); // Quem enviou (5511999999999@c.us)
console.log(msg.to); // Para quem foi
console.log(msg.timestamp); // Quando foi enviada
console.log(msg.hasMedia); // true se tem imagem/vídeo/doc
console.log(msg.isGroupMsg); // true se é mensagem de grupo
// Responder à mensagem
await msg.reply('Recebido!');
// Enviar mensagem (sem ser reply)
await client.sendMessage(msg.from, 'Olá!');
});
W5.2 Bot com Menu de Opções
Agora vamos criar um bot de atendimento completo com menu:
// index.js — Bot de atendimento
const { Client, LocalAuth } = require('whatsapp-web.js');
const qrcode = require('qrcode-terminal');
const client = new Client({
authStrategy: new LocalAuth(),
puppeteer: { args: ['--no-sandbox'] }
});
client.on('qr', qr => qrcode.generate(qr, { small: true }));
client.on('ready', () => console.log('✅ Bot pronto!'));
client.on('message', async (msg) => {
const text = msg.body.toLowerCase().trim();
// Ignorar mensagens do próprio bot
if (msg.fromMe) return;
switch (text) {
case 'menu':
case 'oi':
case 'olá':
await msg.reply(
`👋 *Olá! Sou o Bot de Atendimento*\n\n` +
`Escolha uma opção:\n\n` +
`*1* - 📋 Nossos serviços\n` +
`*2* - 💰 Tabela de preços\n` +
`*3* - 📍 Localização\n` +
`*4* - 📞 Falar com atendente\n\n` +
`_Digite o número da opção_`
);
break;
case '1':
await msg.reply(
`📋 *Nossos Serviços:*\n\n` +
`✅ Desenvolvimento de Sites\n` +
`✅ Aplicativos Mobile\n` +
`✅ Sistemas Web\n` +
`✅ Bots e Automação\n\n` +
`Digite *menu* para voltar`
);
break;
case '2':
await msg.reply(
`💰 *Tabela de Preços:*\n\n` +
`• Site institucional: R$ 1.500\n` +
`• Landing page: R$ 800\n` +
`• Sistema web: sob consulta\n\n` +
`Digite *menu* para voltar`
);
break;
case '3':
await msg.reply('📍 Estamos na Rua Exemplo, 123 - Centro');
break;
case '4':
await msg.reply(
`📞 *Atendimento Humano*\n\n` +
`Um atendente vai responder em breve!\n` +
`Horário: Seg-Sex, 9h às 18h`
);
break;
}
});
client.initialize();
*texto* → negrito
_texto_ → itálico
~texto~ → ```texto``` → monoespaçado
\n → quebra de linha
W5.3 Responder só no Privado (Ignorar Grupos)
client.on('message', async (msg) => {
// Ignorar mensagens de grupo
if (msg.isGroupMsg) return;
// Ignorar mensagens do próprio bot
if (msg.fromMe) return;
// Só responde no privado
await msg.reply('Olá! Digite *menu* para ver as opções.');
});
W5.4 Rate Limiting — Evitar Spam
Se alguém enviar muitas mensagens, o bot pode sobrecarregar. Adicione um controle simples:
const lastReply = new Map(); // Armazena último tempo de resposta
const COOLDOWN = 3000; // 3 segundos entre respostas
client.on('message', async (msg) => {
const now = Date.now();
const last = lastReply.get(msg.from) || 0;
if (now - last < COOLDOWN) return; // Ignora se muito rápido
lastReply.set(msg.from, now);
// ... processar mensagem normalmente
});
W6.1 Enviar Imagens e Documentos
const { MessageMedia } = require('whatsapp-web.js');
// Enviar imagem de um arquivo local
const media = MessageMedia.fromFilePath('./imagem.png');
await client.sendMessage(msg.from, media, {
caption: 'Aqui está a imagem!'
});
// Enviar imagem de uma URL
const mediaUrl = await MessageMedia.fromUrl('https://exemplo.com/foto.jpg');
await client.sendMessage(msg.from, mediaUrl);
// Enviar documento (PDF, etc.)
const doc = MessageMedia.fromFilePath('./proposta.pdf');
await client.sendMessage(msg.from, doc, {
sendMediaAsDocument: true,
caption: 'Segue a proposta em PDF'
});
W6.2 Integrar com API Externa
Vamos fazer o bot consultar um CEP usando a API gratuita do ViaCEP:
// Comando: !cep 01001000
if (text.startsWith('!cep')) {
const cep = text.split(' ')[1];
if (!cep || cep.length !== 8) {
await msg.reply('❌ Use: *!cep 01001000*');
return;
}
try {
const res = await fetch(`https://viacep.com.br/ws/${cep}/json/`);
const data = await res.json();
if (data.erro) {
await msg.reply('❌ CEP não encontrado');
return;
}
await msg.reply(
`📍 *Resultado do CEP:*\n\n` +
`📫 CEP: ${data.cep}\n` +
`🏠 Rua: ${data.logradouro}\n` +
`🏘️ Bairro: ${data.bairro}\n` +
`🌆 Cidade: ${data.localidade}/${data.uf}`
);
} catch (err) {
await msg.reply('❌ Erro ao consultar CEP');
}
}
• Clima: OpenWeatherMap API
• Cotação: AwesomeAPI (dólar, euro, bitcoin)
• Notícias: NewsAPI
• Seu próprio backend: consultar banco de dados, CRM, etc.
W6.3 Reagir a Mensagens
// Reagir com emoji
await msg.react('👍'); // Reação de like
await msg.react('✅'); // Reação de confirmação
await msg.react('❤️'); // Reação de coração
W6.4 Gerenciar Grupos
// Pegar todos os grupos do bot
const chats = await client.getChats();
const groups = chats.filter(c => c.isGroup);
console.log(`Bot está em ${groups.length} grupos`);
// Enviar mensagem para um grupo específico
const group = groups.find(g => g.name === 'Meu Grupo');
if (group) {
await group.sendMessage('📢 Mensagem automática do bot!');
}
// Pegar info de um grupo
if (msg.isGroupMsg) {
const chat = await msg.getChat();
console.log(`Grupo: ${chat.name}`);
console.log(`Membros: ${chat.participants.length}`);
}
W6.5 Agendar Envios
// Enviar mensagem após 5 segundos
setTimeout(async () => {
await client.sendMessage('5511999999999@c.us', 'Lembrete!');
}, 5000);
// Enviar mensagem a cada hora (ex: status)
setInterval(async () => {
const hora = new Date().toLocaleTimeString('pt-BR');
await client.sendMessage('5511999999999@c.us', `⏰ São ${hora}`);
}, 3600000); // 1 hora em milissegundos
W7.1 Rodando com PM2 (24h)
Para manter o bot rodando mesmo após fechar o terminal, use o PM2 (que você já aprendeu no capítulo 16):
$ npm install -g pm2
$ pm2 start index.js --name whatsapp-bot
$ pm2 logs whatsapp-bot # Ver logs em tempo real
$ pm2 restart whatsapp-bot # Reiniciar o bot
$ pm2 save # Salvar para iniciar com o servidor
$ pm2 startup # Iniciar automaticamente no boot
W7.2 Reconexão Automática
// Tratar desconexão e reconectar
client.on('disconnected', (reason) => {
console.log('❌ Bot desconectado:', reason);
console.log('🔄 Reconectando em 5 segundos...');
setTimeout(() => {
client.initialize();
}, 5000);
});
// Log de erros não tratados (evita crash)
process.on('unhandledRejection', (err) => {
console.error('Erro não tratado:', err);
});
W7.3 Boas Práticas — Não Ser Banido
• Enviar mensagens em massa (spam)
• Enviar mensagens para quem não te adicionou
• Enviar muitas mensagens em pouco tempo
• Usar para golpes ou conteúdo proibido
| Prática | Recomendação |
|---|---|
| Velocidade de envio | Máx. 1 mensagem a cada 3-5 segundos |
| Mensagens por dia | Máx. ~200 (número novo) a ~1000 (número antigo) |
| Delay entre mensagens | Adicione await delay(3000) entre envios |
| Só responder | Responder quem te mandou mensagem é mais seguro |
| Número dedicado | Use um chip separado para o bot |
| Simular digitação | Use chat.sendStateTyping() antes de responder |
Simulando Digitação (Humanizar)
// Fazer parecer que o bot está "digitando" antes de responder
client.on('message', async (msg) => {
const chat = await msg.getChat();
await chat.sendStateTyping(); // Mostra "digitando..."
await new Promise(r => setTimeout(r, 2000)); // Espera 2s
await msg.reply('Resposta humanizada!');
});
W7.4 Checklist de Produção
☐ Sessão persistida com
LocalAuth
☐ Tratamento de erros em todo
async/await
☐ Rate limiting para não responder spam
☐
msg.fromMe ignorado (evitar loop)
☐ Logs com
console.log nos eventos importantes
☐ PM2 configurado com
pm2 save && pm2 startup
☐ Reconexão automática no evento
disconnected
☐
.gitignore com .wwebjs_auth/ e .env
☐ Número dedicado (não use seu número pessoal)
☐ Delay entre mensagens (simular humano)
1. Event-driven — Node.js reage a eventos (mensagens, conexão, etc.)
2. whatsapp-web.js — biblioteca que simula WhatsApp Web via Puppeteer
3. QR Code + LocalAuth — autenticação e persistência de sessão
4. Comandos e menus — switch/case para responder diferentes opções
5. MessageMedia — enviar imagens, documentos e mídia
6. API externa — consultar CEP, clima, etc. direto pelo bot
7. Rate limiting — evitar spam e sobrecarga
8. PM2 + deploy — manter o bot 24h no servidor
9. Boas práticas — não ser banido, simular digitação, delay
Você acabou de clonar o repositório e abriu o projeto. Tem dezenas de arquivos, duas pastas grandes (frontend/ e backend/) e nenhuma funcionalidade implementada. É de propósito.
Trabalhar com template é como um programador profissional trabalha: você nunca começa do zero absoluto. Sempre tem um boilerplate, um scaffold, um
create-react-app.S1.1 Estrutura de Pastas
O projeto é dividido em duas aplicações independentes que se comunicam via HTTP:
Frontend — React + TypeScript + Tailwind (porta 5173)
| Pasta / Arquivo | Descrição |
|---|---|
frontend/src/components/ |
Sidebar, AppLayout, StatCard — componentes reutilizáveis |
frontend/src/contexts/ |
AuthContext — estado global de autenticação |
frontend/src/pages/ |
Dashboard, Produtos, Movimentacoes, Categorias, Usuarios, Login, Registro... |
frontend/src/services/api.ts |
Função genérica apiFetch<T> — comunicação com backend |
frontend/src/types/index.ts |
Interfaces TypeScript (User, Produto, Categoria, Movimentacao...) |
frontend/src/App.tsx |
Todas as rotas da aplicação (React Router) |
frontend/src/main.tsx |
Ponto de entrada — BrowserRouter + AuthProvider |
frontend/vite.config.ts |
Dev server + proxy /api para o backend |
Backend — Node.js + Express + MySQL (porta 3737)
| Pasta / Arquivo | Descrição |
|---|---|
backend/src/controllers/ |
Lógica de cada rota — STUBS (ainda não implementados) |
backend/src/routes/ |
Definição das rotas HTTP (GET, POST, PUT, DELETE) |
backend/src/middlewares/auth.js |
JWT verify + requireRole — segurança da aplicação |
backend/src/database/ |
connection.js (pool MySQL) + migration.sql (schema) |
backend/src/config/plans.js |
Limites por plano (free: 50 produtos, pro: 500, enterprise: ilimitado) |
backend/src/utils/response.js |
Helpers: sendSuccess() e sendError() |
backend/src/app.js |
Servidor Express — monta rotas, cors, error handler |
backend/.env.example |
Variáveis de ambiente (PORT, DB, JWT_SECRET) |
S1.2 O Que Já Está Pronto (e o que falta)
| Camada | Status | O que tem |
|---|---|---|
| Banco de dados | ✅ Pronto | Tabelas: empresas, users, produtos, categorias, movimentacoes + índices |
| Backend — Rotas | ✅ Definidas | 6 arquivos de rotas com middlewares de auth e role aplicados |
| Backend — Controllers | ⚠️ Stubs | Funções existem mas retornam [] ou 501 Não implementado |
| Backend — Auth | ✅ Middleware pronto | JWT verify + requireRole funcionam — falta login/registro no controller |
| Frontend — Páginas | ✅ Visuais prontas | 8 páginas com layout, tabelas, botões — tudo com dados mockados |
| Frontend — Contexto Auth | ⚠️ Stub | AuthContext existe, mas login() não chama API ainda |
| Frontend — API Service | ✅ Pronto | apiFetch<T> genérico com Bearer token automático |
Exemplo de stub no controller:
exports.listar = async (req, res) => { res.json({ success: true, data: [] }); }Quando implementado:
exports.listar = async (req, res) => { const [rows] = await pool.query('SELECT * FROM produtos WHERE empresa_id = ?', [req.user.empresa_id]); res.json({ success: true, data: rows }); }
S1.3 Raciocínio do Programador — Onde Começo?
Olhar um projeto inteiro e não saber por onde começar é normal. A regra é: pense nas dependências.
Se A depende de B, faça B primeiro.
Aplicando ao nosso projeto:
| Ordem | O que implementar | Depende de | Por quê? |
|---|---|---|---|
| 1 | Banco de Dados | Nada | Já temos a migration.sql — só rodar |
| 2 | Auth (registro + login) | Banco | Sem token, todas as rotas retornam 401 |
| 3 | CRUD Categorias | Auth | Precisa do token para acessar a rota |
| 4 | CRUD Produtos | Categorias | Produto tem FK categoria_id — sem categoria, INSERT falha |
| 5 | Movimentações | Produtos | Movimentação tem FK produto_id |
| 6 | Dashboard | Tudo acima | Consultas agregadas de todas as tabelas |
| 7 | Frontend — conectar API | Backend pronto | Não adianta conectar se o backend retorna [] |
| 8 | Deploy | Tudo funcionando | VPS + Nginx + PM2 + MySQL em produção |
Auth segundo — sem login, você não tem token, e todas as rotas pedem token.
Categorias antes de Produtos — produto tem
categoria_id, se a categoria não existe, o INSERT falha (FK constraint).Produtos antes de Movimentações — mesma lógica, movimentação referencia
produto_id.Dashboard por último no backend — ele consulta TODAS as tabelas, então todas precisam existir e ter dados.
Frontend por último — não adianta conectar o frontend se o backend retorna
[].S1.4 Conceitos-Chave do Projeto
Multi-tenant — O que é?
Multi-tenant significa que várias empresas usam o mesmo sistema, mas cada uma só vê seus próprios dados. É assim que todo SaaS funciona (Notion, Slack, etc).
No nosso caso, toda tabela tem a coluna empresa_id. Toda query filtra por esse campo:
-- Empresa A (id=1) busca seus produtos:
SELECT * FROM produtos WHERE empresa_id = 1;
-- Empresa B (id=2) busca os dela:
SELECT * FROM produtos WHERE empresa_id = 2;
-- Mesma tabela, dados 100% isolados
SELECT * FROM produtos sem WHERE empresa_id = ?. Se esquecer, uma empresa pode ver os dados de outra. Em todo controller, use req.user.empresa_id.RBAC — Controle de Acesso por Role
RBAC = Role-Based Access Control. Cada usuário tem um papel (role) que define o que ele pode fazer:
| Role | Pode criar produtos | Pode criar movimentações | Pode gerenciar usuários |
|---|---|---|---|
| admin | ✅ | ✅ | ✅ |
| gerente | ✅ | ✅ | ❌ |
| estoquista | ❌ | ✅ | ❌ |
No backend, o middleware requireRole protege as rotas:
// Qualquer logado acessa
router.get('/', authMiddleware, controller.listar);
// Só admin e gerente podem criar
router.post('/', authMiddleware, requireRole('admin', 'gerente'), controller.criar);
// Só admin gerencia usuários
router.post('/', authMiddleware, requireRole('admin'), usuarioController.criar);
O Fluxo Completo — Do Clique ao Banco
Quando o estoquista clica em "Novo Produto" no frontend, acontece isso:
| Passo | Onde | O que acontece |
|---|---|---|
| 1 | Frontend | Usuário preenche o formulário e clica "Salvar" |
| 2 | Frontend | apiFetch('/api/produtos', { method: 'POST', body }) — envia com token no header |
| 3 | Backend | authMiddleware — verifica JWT, extrai empresa_id e role |
| 4 | Backend | requireRole('admin','gerente') — estoquista? retorna 403 |
| 5 | Backend | produtoController.criar — INSERT INTO produtos (...) VALUES (?) |
| 6 | MySQL | Salva o registro no banco com empresa_id do usuário logado |
| 7 | Backend | Retorna { success: true, data: { id: 42, nome: "Camiseta P" } } |
| 8 | Frontend | Recebe a resposta e atualiza a tabela na tela |
S1.5 Os Arquivos Mais Importantes
Se você só pudesse ler 5 arquivos para entender o projeto inteiro, leia estes:
| # | Arquivo | Por quê? |
|---|---|---|
| 1 | backend/src/database/migration.sql |
Mostra TODAS as tabelas e relacionamentos — é o mapa do banco |
| 2 | backend/src/app.js |
Mostra todas as rotas montadas e o setup do Express |
| 3 | backend/src/middlewares/auth.js |
Mostra como JWT e RBAC funcionam (segurança inteira) |
| 4 | frontend/src/App.tsx |
Mostra todas as rotas/páginas da aplicação |
| 5 | frontend/src/types/index.ts |
Mostra o formato de TODOS os dados do sistema |
git clone https://github.com/PauloInacioPI/saas-estoque-template.git2. Abra no VS Code e leia os 5 arquivos da tabela acima
3. Rode a migration no MySQL e confira as tabelas criadas
4. Rode o backend (
npm run dev) e teste GET /health no navegador5. Rode o frontend (
npm run dev) e navegue pelas páginas6. Responda: quantos controllers precisam ser implementados? Quais?
S1.6 Resumo Visual
| Camada | O que tem no template | Status |
|---|---|---|
| Banco de Dados | Tabelas prontas (migration.sql) | ✅ Pronto |
| Backend — Rotas | Rotas definidas + middlewares de auth aplicados | ✅ Pronto |
| Backend — Controllers | Stubs — retornam [] ou 501 |
⚠️ Falta implementar |
| Frontend — Visual | Páginas, layout, sidebar, router | ✅ Pronto |
| Frontend — Dados | Tudo mockado — zero conexão com API real | ❌ Falta conectar |
Primeiro passo: criar o banco, rodar as tabelas e configurar o .env para o backend se conectar. Sem isso, nada funciona.
S2.1 Criando o Banco no MySQL
Abra o terminal e entre no MySQL:
mysql -u root -p
Dentro do MySQL, crie o banco de dados:
CREATE DATABASE saas_estoque;
USE saas_estoque;
Agora rode a migration. Existem dois caminhos:
Opção A — Direto pelo terminal (recomendado)
Saia do MySQL (exit) e rode:
mysql -u root -p saas_estoque < backend/src/database/migration.sql
Opção B — Copiar e colar no MySQL
Se preferir, abra o arquivo migration.sql no VS Code, copie tudo e cole dentro do terminal MySQL.
S2.2 Entendendo a Migration
Vamos analisar cada tabela e por que ela existe:
Tabela empresas — O Tenant
CREATE TABLE empresas (
id INT AUTO_INCREMENT PRIMARY KEY,
nome VARCHAR(255) NOT NULL,
plano ENUM('free', 'pro', 'enterprise') DEFAULT 'free',
ativo TINYINT(1) DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
empresa_id como FK. Sem a tabela empresas, nenhuma outra pode ser criada (o MySQL vai reclamar da FK).| Coluna | Tipo | Para que serve |
|---|---|---|
id |
INT AUTO_INCREMENT | Identificador único — gerado automaticamente |
nome |
VARCHAR(255) | Nome da empresa ("Padaria do João") |
plano |
ENUM | Define limites: free (50 produtos), pro (500), enterprise (ilimitado) |
ativo |
TINYINT(1) | Soft delete — 0 = desativado, 1 = ativo |
created_at |
TIMESTAMP | Data de cadastro (preenchido automaticamente) |
Tabela users — Quem usa o sistema
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
nome VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
senha VARCHAR(255) NOT NULL,
role ENUM('admin', 'gerente', 'estoquista') DEFAULT 'estoquista',
empresa_id INT NOT NULL,
ativo TINYINT(1) DEFAULT 1,
FOREIGN KEY (empresa_id) REFERENCES empresas(id)
);
| Coluna | Detalhe importante |
|---|---|
email |
UNIQUE — não pode ter dois usuários com o mesmo email |
senha |
Armazena o hash bcrypt, nunca a senha em texto puro |
role |
ENUM com 3 valores — define o que o usuário pode fazer (RBAC) |
empresa_id |
FK para empresas — isola os dados (multi-tenant) |
role = 'superadmin' via API. Com ENUM, o MySQL rejeita qualquer valor fora de admin, gerente, estoquista. É uma camada de proteção no nível do banco.Tabela categorias — Organizando os produtos
CREATE TABLE categorias (
id INT AUTO_INCREMENT PRIMARY KEY,
nome VARCHAR(255) NOT NULL,
descricao TEXT,
empresa_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (empresa_id) REFERENCES empresas(id)
);
| Coluna | Detalhe |
|---|---|
nome |
Nome da categoria ("Roupas", "Eletrônicos") |
descricao |
Descrição opcional (TEXT permite textos longos) |
empresa_id |
FK para empresas — cada empresa tem suas próprias categorias (multi-tenant) |
produtos tem uma FK (categoria_id) que referencia categorias(id). O MySQL exige que a tabela referenciada exista antes. É como registrar um funcionário em um departamento — o departamento precisa existir primeiro.Tabela produtos — O coração do sistema
CREATE TABLE produtos (
id INT AUTO_INCREMENT PRIMARY KEY,
nome VARCHAR(255) NOT NULL,
descricao TEXT,
sku VARCHAR(100),
categoria_id INT,
preco_custo DECIMAL(10,2) DEFAULT 0,
preco_venda DECIMAL(10,2) DEFAULT 0,
estoque_atual INT DEFAULT 0,
estoque_minimo INT DEFAULT 0,
unidade VARCHAR(20) DEFAULT 'un',
empresa_id INT NOT NULL,
FOREIGN KEY (categoria_id) REFERENCES categorias(id),
FOREIGN KEY (empresa_id) REFERENCES empresas(id)
);
| Coluna | Detalhe |
|---|---|
sku |
Stock Keeping Unit — código interno do produto ("CAM-P-001") |
preco_custo / preco_venda |
DECIMAL(10,2) — nunca use FLOAT para dinheiro (perde precisão) |
estoque_atual |
Quantidade atual — atualizado pelas movimentações |
estoque_minimo |
Quando estoque_atual <= estoque_minimo, o dashboard alerta |
categoria_id |
FK opcional (INT sem NOT NULL) — produto pode não ter categoria |
0.1 + 0.2 em FLOAT dá 0.30000000000000004. Em dinheiro, isso gera erro de centavos que se acumula. DECIMAL(10,2) garante exatidão: até R$ 99.999.999,99.Tabela movimentacoes — Entrada e saída
CREATE TABLE movimentacoes (
id INT AUTO_INCREMENT PRIMARY KEY,
produto_id INT NOT NULL,
tipo ENUM('entrada', 'saida') NOT NULL,
quantidade INT NOT NULL,
motivo VARCHAR(255),
observacao TEXT,
usuario_id INT NOT NULL,
empresa_id INT NOT NULL,
FOREIGN KEY (produto_id) REFERENCES produtos(id),
FOREIGN KEY (usuario_id) REFERENCES users(id),
FOREIGN KEY (empresa_id) REFERENCES empresas(id)
);
1.
INSERT INTO movimentacoes — registrar a movimentação2.
UPDATE produtos SET estoque_atual = estoque_atual +/- quantidade — atualizar o estoqueSe o tipo for
'entrada', soma. Se for 'saida', subtrai. Isso será implementado no capítulo S5.Índices — Performance
CREATE INDEX idx_produtos_empresa ON produtos(empresa_id);
CREATE INDEX idx_produtos_categoria ON produtos(categoria_id);
CREATE INDEX idx_produtos_sku ON produtos(sku);
CREATE INDEX idx_movimentacoes_empresa ON movimentacoes(empresa_id);
CREATE INDEX idx_movimentacoes_produto ON movimentacoes(produto_id);
CREATE INDEX idx_movimentacoes_data ON movimentacoes(created_at);
CREATE INDEX idx_users_empresa ON users(empresa_id);
CREATE INDEX idx_categorias_empresa ON categorias(empresa_id);
empresa_id, ele vai direto nos registros daquela empresa. Diferença: de segundos para milissegundos quando a tabela cresce.Diagrama de Relacionamentos (ER)
Visualize como as 5 tabelas se conectam. Cada seta representa uma chave estrangeira (FOREIGN KEY):
Cada seta significa: "este campo referencia o id de outra tabela".
Todas as tabelas têm
empresa_id apontando para empresas — isso é o multi-tenant. Cada query filtra por WHERE empresa_id = ?, garantindo que a empresa A nunca veja dados da empresa B.movimentacoes tem 3 FKs:
produto_id (qual produto), usuario_id (quem fez) e empresa_id (de qual empresa). É a tabela mais conectada do sistema.S2.3 Verificando as Tabelas Criadas
Depois de rodar a migration, confirme que tudo foi criado:
mysql -u root -p saas_estoque -e "SHOW TABLES;"
Saída esperada:
+------------------------+
| Tables_in_saas_estoque |
+------------------------+
| categorias |
| empresas |
| movimentacoes |
| produtos |
| users |
+------------------------+
Para ver a estrutura de uma tabela:
mysql -u root -p saas_estoque -e "DESCRIBE produtos;"
+----------------+---------------+------+-----+-------------------+-------+
| Field | Type | Null | Key | Default | Extra |
+----------------+---------------+------+-----+-------------------+-------+
| id | int | NO | PRI | NULL | auto |
| nome | varchar(255) | NO | | NULL | |
| sku | varchar(100) | YES | MUL | NULL | |
| categoria_id | int | YES | MUL | NULL | |
| preco_custo | decimal(10,2) | YES | | 0.00 | |
| preco_venda | decimal(10,2) | YES | | 0.00 | |
| estoque_atual | int | YES | | 0 | |
| estoque_minimo | int | YES | | 0 | |
| empresa_id | int | NO | MUL | NULL | |
+----------------+---------------+------+-----+-------------------+-------+
PRI = Primary Key, UNI = Unique, MUL = Multiple (índice ou FK). Se você vê MUL em empresa_id, significa que o índice foi criado corretamente.S2.4 Configurando o .env
O backend precisa saber como se conectar ao banco. Copie o exemplo e edite:
cd backend
cp .env.example .env
nano .env
code .env para abrir no VS Code, ou abra o arquivo .env manualmente pelo VS Code. O nano é um editor de terminal do Linux — no Windows, use qualquer editor de texto. O cp funciona no Git Bash; no CMD use copy .env.example .env.Conteúdo do .env:
# Servidor
NODE_ENV=development
PORT=3737
# Banco de dados
DB_HOST=localhost
DB_USER=root
DB_PASSWORD=sua_senha_do_mysql
DB_NAME=saas_estoque
# JWT
JWT_SECRET=minha_chave_secreta_super_longa_e_aleatoria_123
JWT_EXPIRES_IN=7d
.env contém senhas e chaves secretas. Ele já está no .gitignore — nunca remova de lá. O .env.example serve como modelo sem dados reais.node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"S2.5 Entendendo o connection.js
O arquivo backend/src/database/connection.js é quem conecta o Express ao MySQL.
Caminho: backend/src/database/connection.js — fica em database/ porque é responsabilidade de conexão com o banco, não lógica de negócio.
.env sozinho. Quem faz isso é a biblioteca dotenv. No arquivo backend/src/app.js (o ponto de entrada do backend), a primeira linha é:
require('dotenv').config();
Essa linha lê o arquivo
.env e coloca cada variável em process.env. Por isso, quando o connection.js usa process.env.DB_HOST, o valor já está disponível. Sem essa linha no app.js, todas as variáveis de ambiente seriam undefined.// backend/src/database/connection.js
const mysql = require('mysql2/promise');
const pool = mysql.createPool({
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'saas_estoque',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
});
module.exports = { pool };
| Propriedade | O que faz |
|---|---|
createPool |
Cria um pool de conexões — reutiliza conexões em vez de abrir/fechar toda hora |
connectionLimit: 10 |
Máximo de 10 conexões simultâneas — evita sobrecarregar o MySQL |
mysql2/promise |
Versão com suporte a async/await — sem callbacks aninhados |
S2.6 Testando a Conexão
Vamos verificar que tudo funciona. Instale as dependências e rode o backend:
cd backend
npm install
npm run dev
Saída esperada:
Servidor rodando na porta 3737
Agora teste no navegador ou com curl:
curl http://localhost:3737/health
{ "status": "ok", "timestamp": "2026-03-02T20:30:00.000Z" }
curl já vem instalado no Windows 10/11 — funciona no Prompt de Comando, PowerShell e Git Bash. Se estiver usando o Git Bash (recomendado), funciona exatamente igual ao Linux.Alternativa visual: em vez de curl, você pode usar o Thunder Client (extensão do VS Code) ou o Postman (gratuito) para testar APIs com interface gráfica. Basta digitar a URL, escolher o método (GET, POST) e clicar em Send.
Linux/Mac:
sudo systemctl status mysqlWindows: abra o app Serviços (Win+R →
services.msc) e procure MySQL — deve estar "Em Execução"2. Verifique se a senha no
.env está correta3. Verifique se o banco
saas_estoque existe: mysql -u root -p -e "SHOW DATABASES;"4. Verifique se as tabelas foram criadas:
mysql -u root -p saas_estoque -e "SHOW TABLES;"S2.7 Inserindo Dados de Teste
Para facilitar o desenvolvimento, vamos inserir uma empresa e alguns dados de teste:
USE saas_estoque;
-- Criar empresa de teste
INSERT INTO empresas (nome, plano) VALUES ('Empresa Teste', 'pro');
-- Criar categoria de teste
INSERT INTO categorias (nome, descricao, empresa_id)
VALUES ('Roupas', 'Vestuário em geral', 1);
-- Criar produto de teste
INSERT INTO produtos (nome, sku, categoria_id, preco_custo, preco_venda, estoque_atual, estoque_minimo, empresa_id)
VALUES ('Camiseta Preta P', 'CAM-P-001', 1, 15.00, 39.90, 50, 10, 1);
-- Verificar
SELECT * FROM empresas;
SELECT * FROM produtos;
saas_estoque criado✅ 5 tabelas criadas com migration.sql
✅ Índices de performance aplicados
✅
.env configurado com credenciais✅ Backend rodando e respondendo
/health✅ Dados de teste inseridos (empresa + categoria + produto)
Próximo passo: S3 — Implementar Auth (registro + login) — o primeiro controller real.
Antes de começar a escrever controllers, precisamos entender algumas ferramentas do JavaScript que vão aparecer em todo o código a partir de agora. Se você já conhece, use como revisão rápida. Se não conhece, leia com atenção — tudo aqui será usado dezenas de vezes.
Arrow Functions (=>) — Outra forma de escrever funções
No JavaScript, existem duas formas de escrever uma função:
// Forma tradicional
function somar(a, b) {
return a + b;
}
// Arrow function (faz a mesma coisa)
const somar = (a, b) => {
return a + b;
};
// Arrow function curta (se tem só 1 linha, não precisa de { } nem return)
const somar = (a, b) => a + b;
function nome()) funciona igual, mas arrow functions são mais comuns no código moderno. O => é lido como "recebe... e retorna...".async e await — Esperando coisas que demoram
Algumas operações demoram: consultar o banco de dados, chamar uma API externa, ler um arquivo. O JavaScript é assíncrono — ele não espera uma operação terminar para ir para a próxima. Mas às vezes precisamos esperar.
// SEM await — o código não espera o banco responder
const resultado = pool.query('SELECT * FROM produtos');
console.log(resultado); // ❌ Mostra "Promise { pending }" — não os dados!
// COM await — o código ESPERA o banco responder
const resultado = await pool.query('SELECT * FROM produtos');
console.log(resultado); // ✅ Mostra os dados reais!
await, você pede a comida e já tenta comer — o prato está vazio (Promise pending). Com await, você pede e espera o garçom trazer — aí sim come os dados reais.await só funciona dentro de funções async
Para usar await, a função que contém ele precisa ter a palavra async antes. É um par inseparável:
const minhaFuncao = async () => { const dados = await buscarDados(); }
Todas as nossas funções de controller serão
async porque todas consultam o banco de dados.try/catch — Tratamento de erros
E se a consulta ao banco der erro? Sem tratamento, o servidor crasheia. O try/catch é uma rede de segurança:
const buscarProdutos = async (req, res, next) => {
try {
// Código que PODE dar erro
const [rows] = await pool.query('SELECT * FROM produtos');
res.json({ success: true, data: rows });
} catch (error) {
// Se deu erro, cai aqui em vez de crashear
next(error); // Passa o erro para o Express tratar
}
};
try é o trapezista fazendo a acrobacia. O catch é a rede embaixo — se ele cair (erro), a rede pega. Sem a rede (sem try/catch), o trapezista cai no chão (o servidor crasheia). O next(error) é avisar o diretor do circo (Express) que algo deu errado.| Parte | O que faz |
|---|---|
try { ... } |
Tenta executar o código dentro. Se funcionar, ótimo. |
catch (error) { ... } |
Se qualquer linha dentro do try der erro, a execução pula direto para cá. O error contém a mensagem do erro. |
next(error) |
Passa o erro para o error handler do Express (aquele app.use((err, req, res, next) => ...) no app.js). |
exports e module.exports — Compartilhando código entre arquivos
No Node.js, cada arquivo é um módulo isolado. Para que outro arquivo use uma função sua, você precisa exportar:
// ===== EXPORTAR =====
// Forma 1: exports.nome (exportar várias coisas)
exports.listar = async (req, res) => { ... };
exports.criar = async (req, res) => { ... };
// O arquivo exporta: { listar, criar }
// Forma 2: module.exports (exportar uma coisa ou um objeto)
module.exports = { pool };
// O arquivo exporta: { pool }
// ===== IMPORTAR =====
// Importar o objeto inteiro
const controller = require('./controllers/produtoController');
controller.listar(req, res); // usar via controller.funcao()
// Importar com desestruturação (pegar só o que precisa)
const { pool } = require('./database/connection');
// pool já está disponível direto, sem "connection.pool"
exports.listar é como colocar o produto "listar" na caixa de entrega. require('./arquivo') é abrir a caixa e pegar o que precisa. A desestruturação ({ pool }) é abrir a caixa e pegar direto o item que quer, sem carregar a caixa inteira.req, res, next — Os 3 parâmetros de todo controller
Toda função de controller no Express recebe 3 parâmetros. Eles são passados automaticamente pelo Express quando uma rota é chamada:
| Parâmetro | Nome completo | O que contém | Exemplo |
|---|---|---|---|
req |
Request (requisição) | Tudo que o cliente enviou | req.body = dados do formulárioreq.params = parâmetros da URL (/produtos/:id)req.user = dados do token JWT (preenchido pelo authMiddleware) |
res |
Response (resposta) | Métodos para responder ao cliente | res.json({...}) = responder com JSON (status 200)res.status(400).json({...}) = responder com erro |
next |
Next (próximo) | Função que passa para o próximo middleware | next(error) = pular para o error handler |
req é o pedido do cliente (o que ele quer). res é o balcão de entrega (como você responde). next é o botão "chamar supervisor" — se algo der errado, você passa adiante.Desestruturação — Extrair valores de objetos e arrays
Desestruturação é um atalho para pegar valores de dentro de objetos ou arrays:
// OBJETO — pegar campos pelo nome
const pessoa = { nome: 'João', idade: 30, cidade: 'SP' };
const { nome, idade } = pessoa; // nome = 'João', idade = 30
// Igual a:
// const nome = pessoa.nome;
// const idade = pessoa.idade;
// ARRAY — pegar elementos pela posição
const resultado = ['dados', 'metadados'];
const [rows, fields] = resultado; // rows = 'dados', fields = 'metadados'
// Ignorar elementos com vírgula
const [rows] = resultado; // Pega só o primeiro
// No req.body (muito comum nos controllers)
const { nome, email, senha } = req.body;
// Em vez de: const nome = req.body.nome; const email = req.body.email; ...
const { nome, email } = req.body; — extrair campos do corpo da requisiçãoconst [rows] = await pool.query(...) — pegar só as linhas do resultado SQLconst { pool } = require('./connection') — importar só o poolQueries parametrizadas (?) — Proteção contra SQL Injection
Quando colocamos valores do usuário dentro de SQL, nunca usamos template literals diretamente:
// ❌ PERIGOSO — SQL Injection!
// Se email for: ' OR 1=1 --
// O SQL vira: SELECT * FROM users WHERE email = '' OR 1=1 --'
// Isso retorna TODOS os usuários!
pool.query(`SELECT * FROM users WHERE email = '${email}'`);
// ✅ SEGURO — Query parametrizada
// O ? é substituído pelo valor de forma SEGURA
// O mysql2 escapa caracteres perigosos automaticamente
pool.query('SELECT * FROM users WHERE email = ?', [email]);
? (prepared statements), o mysql2 trata o valor como dado, nunca como código SQL. Em TODA query que usa dados do usuário (req.body, req.params), use ?. Sem exceção.res.json() vs res.status().json()
// Resposta de sucesso (status 200 é o padrão)
res.json({ success: true, data: produtos });
// Resposta com status específico
res.status(201).json({ success: true }); // 201 = Created (recurso criado)
res.status(400).json({ error: '...' }); // 400 = Bad Request (dados inválidos)
res.status(404).json({ error: '...' }); // 404 = Not Found (não encontrado)
res.status(409).json({ error: '...' }); // 409 = Conflict (email duplicado)
| Status | Nome | Quando usar |
|---|---|---|
| 200 | OK | Tudo deu certo (padrão do res.json()) |
| 201 | Created | Recurso criado com sucesso (INSERT) |
| 400 | Bad Request | Dados inválidos ou faltando |
| 401 | Unauthorized | Não logado / token inválido |
| 403 | Forbidden | Logado mas sem permissão (role errado) |
| 404 | Not Found | Recurso não existe |
| 409 | Conflict | Conflito (ex: email já cadastrado) |
| 500 | Internal Server Error | Bug no servidor (erro do catch) |
Este é o capítulo mais importante do projeto. Sem autenticação, nada mais funciona — todas as rotas exigem token JWT. É como construir a porta de entrada de uma casa: sem ela, ninguém entra nos outros cômodos.
Registro = criar sua conta na portaria pela primeira vez
Login = ir na portaria, provar quem você é, e receber o crachá
Token JWT = o crachá digital que você envia em toda requisição
authMiddleware = o segurança que verifica o crachá em cada andar
requireRole = a placa "somente gerentes" na porta de certas salas
S3.1 O que o Stub Faz Hoje
Antes de implementar, veja o que temos hoje. Abra o arquivo:
Você vai encontrar um stub que não faz nada:
// ANTES — Stub (não funciona)
exports.register = async (req, res, next) => {
try {
// TODO: implementar registro (bcrypt, JWT, criar empresa + user)
res.status(501).json({ success: false, error: 'Não implementado' });
} catch (error) {
next(error);
}
};
Se você chamar POST /auth/register agora, recebe 501 Não implementado. Nosso trabalho é trocar isso por código real.
S3.2 O Fluxo do Registro — Passo a Passo
Quando alguém se cadastra no sistema, precisamos fazer 6 coisas nesta ordem:
| Passo | O que fazer | Por quê? |
|---|---|---|
| 1 | Validar os campos recebidos | Evitar dados incompletos no banco |
| 2 | Verificar se o email já existe | Coluna email é UNIQUE — o INSERT falharia feio |
| 3 | Criar a empresa no banco | O usuário precisa de uma empresa_id (FK obrigatória) |
| 4 | Hash da senha com bcrypt | NUNCA salvar senha em texto puro — se o banco vazar, as senhas estão protegidas |
| 5 | Criar o usuário (role = admin) | Quem cria a empresa é automaticamente o admin dela |
| 6 | Gerar e retornar o token JWT | O frontend precisa do token para fazer as próximas requisições |
2. Verificar se o CNPJ já existe (verificar email)
3. Registrar a empresa na Junta Comercial (INSERT empresas)
4. Criar o carimbo oficial (hash da senha)
5. Registrar o sócio-fundador como responsável (INSERT user como admin)
6. Receber o alvará de funcionamento (token JWT)
S3.3 Entendendo Bcrypt — Por que Hash?
Se você salvar a senha como texto puro no banco, qualquer pessoa com acesso ao banco (DBA, hacker, backup vazado) verá todas as senhas. Bcrypt resolve isso.
O que o Bcrypt faz
Bcrypt transforma a senha em um texto irreversível (hash). Ninguém consegue voltar do hash para a senha original — nem você, nem o servidor.
| Senha original | Hash bcrypt (o que vai no banco) |
|---|---|
123456 |
$2a$10$N9qo8uLOickgx2ZMRZoMy... |
minhaSenha |
$2a$10$xJ5kP2rQwL8vNcDfE7gKj... |
123456 (de novo) |
$2a$10$Tk7mBnR9pQwX1sYfH3jLo... |
Como funciona a verificação no login
Se o hash é irreversível, como o login verifica a senha? O bcrypt faz a mágica:
| Passo | O que acontece |
|---|---|
| 1 | Usuário digita a senha: "minhaSenha" |
| 2 | Buscamos o hash salvo no banco: "$2a$10$xJ5kP2..." |
| 3 | bcrypt.compare("minhaSenha", hash) — extrai o salt do hash, aplica na senha digitada, e compara |
| 4 | Retorna true (bate) ou false (não bate) |
No código, são apenas duas linhas:
// REGISTRO — transformar senha em hash
const salt = await bcrypt.genSalt(10); // gera o sal aleatório
const senhaHash = await bcrypt.hash(senha, salt); // gera o hash
// LOGIN — comparar senha digitada com hash do banco
const senhaCorreta = await bcrypt.compare(senha, user.senha); // true ou false
S3.4 Entendendo JWT — O Crachá Digital
JWT (JSON Web Token) é um texto codificado que o servidor gera e o cliente envia em toda requisição. Ele tem três partes separadas por pontos:
eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiZW1haWwiOiJ0ZXN0ZUB0ZXN0ZS5jb20ifQ.K8x2Nf7mQp4dR1...
| Parte | Nome | Conteúdo |
|---|---|---|
eyJhbGci... |
Header | Algoritmo usado (HS256) — "como foi assinado" |
eyJpZCI6... |
Payload | Os dados do usuário: { id, email, role, empresa_id } |
K8x2Nf7m... |
Signature | Assinatura feita com o JWT_SECRET — prova que o token é legítimo |
Gerar o token no código:
const token = jwt.sign(
{ // payload — dados do crachá
id: user.id,
email: user.email,
role: user.role, // 'admin', 'gerente' ou 'estoquista'
empresa_id: user.empresa_id, // qual empresa ele pertence
},
process.env.JWT_SECRET, // chave secreta (só o servidor sabe)
{ expiresIn: '7d' } // expira em 7 dias
);
authMiddleware decodifica o token e coloca esses dados em req.user. Assim, em qualquer controller, você sabe quem está logado e de qual empresa.NUNCA coloque a senha no token — ele é codificado (base64), não criptografado. Qualquer pessoa pode decodificar o payload.
S3.5 Implementação — O Controller Completo
Agora que você entende bcrypt e JWT, vamos implementar. Abra o arquivo:
Apague tudo que tem lá (o stub) e substitua pelo código abaixo. Vamos ir bloco a bloco:
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { pool } = require('../database/connection');
Essas são as 3 ferramentas que esse controller precisa. Cada require carrega uma biblioteca (pacote que outra pessoa criou e publicou no npm):
| Linha | Biblioteca | O que faz | Instalada via |
|---|---|---|---|
require('bcryptjs') |
bcryptjs | Transforma senhas em hash irreversível e compara senhas com hashes existentes. É a versão JavaScript pura do bcrypt (não precisa compilar C++). | npm install bcryptjs |
require('jsonwebtoken') |
jsonwebtoken | Gera tokens JWT (jwt.sign) e verifica/decodifica tokens (jwt.verify). É o padrão da indústria para autenticação stateless. |
npm install jsonwebtoken |
require('../database/connection') |
pool (nosso arquivo) | O pool de conexões MySQL que criamos no S2.5. Usamos pool.query() para executar SQL. Não é do npm — é nosso código. |
Já existe no projeto |
require é como pegar uma ferramenta da caixa antes de começar o trabalho. bcrypt é o cadeado (protege senhas), jwt é a impressora de crachás (gera tokens), e pool é a chave do almoxarifado (acesso ao banco de dados). Sem qualquer uma delas, o controller não funciona.{ pool } com chaves?
É desestruturação. O arquivo connection.js exporta { pool } (vimos no S2.5). Usando { pool }, pegamos direto o que precisamos. É o mesmo que:const connection = require('../database/connection');const pool = connection.pool;Mas em uma linha só.
Função register — Passo a passo
exports.register = async (req, res, next) => {
try {
const { nome, email, senha, empresa_nome } = req.body;
Extraímos os 4 campos do corpo da requisição. O frontend envia esses dados no body do POST.
// 1. Validação básica
if (!nome || !email || !senha || !empresa_nome) {
return res.status(400).json({
success: false,
error: 'Campos obrigatórios: nome, email, senha, empresa_nome',
});
}
// 2. Verificar se email já existe
const [existente] = await pool.query(
'SELECT id FROM users WHERE email = ?',
[email]
);
if (existente.length > 0) {
return res.status(409).json({
success: false,
error: 'Este email já está cadastrado',
});
}
[existente] com colchetes?
O pool.query() retorna um array com 2 elementos: [rows, fields]. Usando const [existente], pegamos só o primeiro (as linhas). É desestruturação de array — o mesmo que:const resultado = await pool.query(...); const existente = resultado[0];SELECT id e não SELECT *?
Só precisamos saber se existe. Buscar todos os campos (nome, senha, etc.) seria desperdício de memória e banda. Pedir só o id é mais rápido e mais seguro (não trafega dados sensíveis à toa). // 3. Criar empresa
const [empresaResult] = await pool.query(
'INSERT INTO empresas (nome) VALUES (?)',
[empresa_nome]
);
const empresa_id = empresaResult.insertId;
insertId?
Quando você faz um INSERT numa tabela com AUTO_INCREMENT, o MySQL retorna o ID gerado. O insertId é esse valor. Precisamos dele para associar o usuário à empresa recém-criada. // 4. Hash da senha
const salt = await bcrypt.genSalt(10);
const senhaHash = await bcrypt.hash(senha, salt);
// 5. Criar usuário como admin da empresa
const [userResult] = await pool.query(
'INSERT INTO users (nome, email, senha, role, empresa_id) VALUES (?, ?, ?, ?, ?)',
[nome, email, senhaHash, 'admin', empresa_id]
);
// 6. Gerar token JWT
const token = jwt.sign(
{ id: userResult.insertId, email, role: 'admin', empresa_id },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN || '7d' }
);
// 7. Retornar token + dados do usuário
res.status(201).json({
success: true,
data: {
token,
user: { id: userResult.insertId, nome, email, role: 'admin', empresa_id },
},
});
} catch (error) {
next(error);
}
};
200 = OK (sucesso genérico). 201 = Created (recurso criado). Como estamos criando uma empresa e um usuário novos, 201 é o código HTTP correto. É uma boa prática que mostra que você entende o protocolo.Função login — Passo a passo
exports.login = async (req, res, next) => {
try {
const { email, senha } = req.body;
// 1. Validação
if (!email || !senha) {
return res.status(400).json({
success: false,
error: 'Email e senha são obrigatórios',
});
}
// 2. Buscar usuário ativo pelo email
const [rows] = await pool.query(
'SELECT * FROM users WHERE email = ? AND ativo = 1',
[email]
);
if (rows.length === 0) {
return res.status(401).json({
success: false,
error: 'Email ou senha incorretos',
});
}
const user = rows[0];
"Email ou senha incorretos". O atacante não sabe qual dos dois está errado.AND ativo = 1?
Se o admin desativou um usuário (soft delete), esse usuário não pode mais logar. O ativo = 1 garante que só usuários ativos conseguem entrar. // 3. Verificar senha
const senhaCorreta = await bcrypt.compare(senha, user.senha);
if (!senhaCorreta) {
return res.status(401).json({
success: false,
error: 'Email ou senha incorretos',
});
}
// 4. Gerar token e retornar
const token = jwt.sign(
{ id: user.id, email: user.email, role: user.role, empresa_id: user.empresa_id },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN || '7d' }
);
res.json({
success: true,
data: {
token,
user: { id: user.id, nome: user.nome, email: user.email, role: user.role, empresa_id: user.empresa_id },
},
});
} catch (error) {
next(error);
}
};
{ success, data: { token, user } }. Isso facilita o frontend — ele trata a resposta da mesma forma nos dois casos.S3.6 Relembrando — O Middleware que Usa o Token
Você escreveu o controller que gera o token. Agora relembre quem verifica — o arquivo middlewares/auth.js que já está pronto no template:
const jwt = require('jsonwebtoken');
const authMiddleware = (req, res, next) => {
const authHeader = req.headers.authorization; // "Bearer eyJhbG..."
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ success: false, error: 'Token não fornecido' });
}
const token = authHeader.split(' ')[1]; // pega só o token sem "Bearer"
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded; // { id, email, role, empresa_id }
next(); // libera para o controller
} catch {
return res.status(401).json({ success: false, error: 'Token inválido' });
}
};
| Linha | O que faz |
|---|---|
req.headers.authorization |
Pega o header Authorization: Bearer TOKEN |
split(' ')[1] |
Separa "Bearer" e "TOKEN", pega o segundo |
jwt.verify(token, SECRET) |
Verifica a assinatura e decodifica o payload |
req.user = decoded |
Disponibiliza os dados do usuário para os controllers seguintes |
next() |
Passa para o próximo middleware ou controller na cadeia |
requisição → cors() → json() → authMiddleware → requireRole → controllerCada middleware faz uma coisa e chama
next() para passar adiante. Se algum middleware detecta um problema (token inválido, role errada), ele para a esteira e retorna erro. O controller só roda se todos os middlewares anteriores chamaram next().S3.7 Testando com curl
Vamos testar as duas rotas. Certifique-se de que o backend está rodando (npm run dev).
Teste 1 — Registro
curl -X POST http://localhost:3737/auth/register \
-H "Content-Type: application/json" \
-d '{"nome":"João","email":"joao@teste.com","senha":"123456","empresa_nome":"Padaria do João"}'
Resposta esperada (status 201):
{
"success": true,
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {
"id": 1,
"nome": "João",
"email": "joao@teste.com",
"role": "admin",
"empresa_id": 2
}
}
}
Teste 2 — Login
curl -X POST http://localhost:3737/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"joao@teste.com","senha":"123456"}'
Resposta esperada (status 200):
{
"success": true,
"data": {
"token": "eyJhbGciOiJIUzI1NiIs...",
"user": { "id": 1, "nome": "João", "role": "admin", ... }
}
}
Teste 3 — Usando o token em outra rota
Copie o token da resposta e use para acessar uma rota protegida:
curl http://localhost:3737/produtos \
-H "Authorization: Bearer SEU_TOKEN_AQUI"
Resposta: { "success": true, "data": [] } — o array está vazio porque ainda não implementamos o controller de produtos, mas o token foi aceito. Se tirar o token, recebe 401 Token não fornecido.
Teste 4 — Erros que você deve testar
| Teste | Resposta esperada |
|---|---|
Registro sem nome |
400 — Campos obrigatórios: nome, email, senha, empresa_nome |
| Registro com email que já existe | 409 — Este email já está cadastrado |
| Login com email errado | 401 — Email ou senha incorretos |
| Login com senha errada | 401 — Email ou senha incorretos |
| Rota protegida sem token | 401 — Token não fornecido |
| Rota protegida com token inválido | 401 — Token inválido |
S3.8 Verificando no Banco
Depois de registrar um usuário, verifique que os dados foram salvos corretamente:
mysql -u root -p saas_estoque -e "SELECT id, nome, email, role, empresa_id FROM users;"
+----+-------+----------------+-------+------------+
| id | nome | email | role | empresa_id |
+----+-------+----------------+-------+------------+
| 1 | João | joao@teste.com | admin | 2 |
+----+-------+----------------+-------+------------+
E confira que a senha está como hash (nunca texto puro):
mysql -u root -p saas_estoque -e "SELECT email, senha FROM users;"
+----------------+--------------------------------------------------------------+
| email | senha |
+----------------+--------------------------------------------------------------+
| joao@teste.com | $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy |
+----------------+--------------------------------------------------------------+
bcrypt.hash(senha, salt) antes do INSERT. O hash sempre começa com $2a$ ou $2b$.S3.9 Códigos HTTP — Referência Rápida
Nos controllers, usamos diferentes status HTTP. Aqui está o resumo dos que usamos neste projeto:
| Código | Nome | Quando usar |
|---|---|---|
200 |
OK | Sucesso genérico (login, listagem, atualização) |
201 |
Created | Recurso criado com sucesso (registro, novo produto) |
400 |
Bad Request | Dados inválidos ou faltando no body |
401 |
Unauthorized | Sem token ou token inválido / credenciais erradas |
403 |
Forbidden | Token válido, mas sem permissão (role errada) |
404 |
Not Found | Recurso não encontrado (produto com ID inexistente) |
409 |
Conflict | Conflito de dados (email duplicado, SKU duplicado) |
500 |
Internal Server Error | Erro inesperado no servidor (bug, banco fora) |
4xx (amarelo) = O problema é seu (cliente mandou algo errado)
5xx (vermelho) = O problema é meu (servidor quebrou)
S3.10 Entendendo o Arquivo de Rotas
Você escreveu as funções register e login no controller, mas como o Express sabe que POST /auth/register deve chamar a função register? A resposta está no arquivo de rotas.
Arquivo: backend/src/routes/authRoutes.js — fica em routes/ porque define os caminhos (URLs) da API.
// backend/src/routes/authRoutes.js
const express = require('express');
const router = express.Router(); // Cria um mini-roteador
const authController = require('../controllers/authController');
// Quando chegar POST /register, chama authController.register
router.post('/register', authController.register);
// Quando chegar POST /login, chama authController.login
router.post('/login', authController.login);
module.exports = router; // ← OBRIGATÓRIO! Sem isso, o app.js não consegue importar
backend/src/app.js, o roteador é montado com um prefixo:
app.use('/auth', authRoutes);
Isso faz:
/auth + /register = /auth/register. O app.js define o prefixo, o arquivo de rotas define o resto do caminho. Por isso no curl usamos localhost:3737/auth/register.module.exports = router;
Sem essa última linha, o arquivo de rotas exporta undefined. O app.js tentaria fazer app.use('/auth', undefined) e o Express crasheia com: "Router.use() requires a middleware function". Se você vir esse erro, verifique o module.exports do arquivo de rotas.S3.11 Arquivo Completo — authController.js
Para referência, aqui está o arquivo inteiro montado. Se você se perdeu nos blocos anteriores, copie este:
Arquivo: backend/src/controllers/authController.js
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { pool } = require('../database/connection');
exports.register = async (req, res, next) => {
try {
const { nome, email, senha, empresa_nome } = req.body;
if (!nome || !email || !senha || !empresa_nome) {
return res.status(400).json({
success: false, error: 'Campos obrigatórios: nome, email, senha, empresa_nome',
});
}
const [existente] = await pool.query('SELECT id FROM users WHERE email = ?', [email]);
if (existente.length > 0) {
return res.status(409).json({ success: false, error: 'Este email já está cadastrado' });
}
const [empresaResult] = await pool.query('INSERT INTO empresas (nome) VALUES (?)', [empresa_nome]);
const empresa_id = empresaResult.insertId;
const salt = await bcrypt.genSalt(10);
const senhaHash = await bcrypt.hash(senha, salt);
const [userResult] = await pool.query(
'INSERT INTO users (nome, email, senha, role, empresa_id) VALUES (?, ?, ?, ?, ?)',
[nome, email, senhaHash, 'admin', empresa_id]
);
const token = jwt.sign(
{ id: userResult.insertId, email, role: 'admin', empresa_id },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN || '7d' }
);
res.status(201).json({
success: true,
data: { token, user: { id: userResult.insertId, nome, email, role: 'admin', empresa_id } },
});
} catch (error) {
next(error);
}
};
exports.login = async (req, res, next) => {
try {
const { email, senha } = req.body;
if (!email || !senha) {
return res.status(400).json({ success: false, error: 'Email e senha são obrigatórios' });
}
const [users] = await pool.query(
'SELECT * FROM users WHERE email = ? AND ativo = 1', [email]
);
if (users.length === 0) {
return res.status(401).json({ success: false, error: 'Credenciais inválidas' });
}
const user = users[0];
const senhaCorreta = await bcrypt.compare(senha, user.senha);
if (!senhaCorreta) {
return res.status(401).json({ success: false, error: 'Credenciais inválidas' });
}
const token = jwt.sign(
{ id: user.id, email: user.email, role: user.role, empresa_id: user.empresa_id },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN || '7d' }
);
res.json({
success: true,
data: { token, user: { id: user.id, nome: user.nome, email: user.email, role: user.role, empresa_id: user.empresa_id } },
});
} catch (error) {
next(error);
}
};
✅ Entendeu JWT (header + payload + signature)
✅ Implementou
register (valida → verifica email → cria empresa → hash senha → cria user → gera token)✅ Implementou
login (valida → busca user → compara senha → gera token)✅ Viu como o arquivo de rotas conecta URLs às funções do controller
✅ Testou com curl (registro, login, token em rota protegida, cenários de erro)
✅ Verificou no banco que a senha está como hash
Próximo passo: S4 — CRUD de Categorias — o primeiro CRUD real com listagem, criação, edição e exclusão.
CRUD é o padrão mais comum de toda aplicação web. Se você dominar este capítulo, vai repetir o mesmo padrão para produtos, movimentações, usuários — tudo.
Create — Criar (INSERT no banco, POST na API)
Read — Ler/Listar (SELECT no banco, GET na API)
Update — Atualizar (UPDATE no banco, PUT na API)
Delete — Excluir (DELETE no banco, DELETE na API)
Toda tela que tem uma tabela com botões de "Novo", "Editar" e "Excluir" é um CRUD.
Create — pegar uma ficha em branco, preencher e guardar no fichário
Read — abrir o fichário e ler as fichas
Update — pegar uma ficha existente, apagar com borracha e reescrever
Delete — tirar a ficha do fichário e jogar fora
A diferença é que nosso "fichário" é o MySQL e a "mão" que mexe é o controller.
S4.1 Por que Categorias Primeiro?
Categorias é o CRUD mais simples do projeto. A tabela tem apenas 4 colunas:
-- Só isso. Simples.
CREATE TABLE categorias (
id INT AUTO_INCREMENT PRIMARY KEY,
nome VARCHAR(255) NOT NULL,
descricao TEXT,
empresa_id INT NOT NULL
);
Comparado com produtos (12 colunas) ou movimentações (lógica de atualizar estoque), categorias é ideal para aprender o padrão CRUD sem distrações.
produtos tem categoria_id como FK. Se tentarmos criar um produto com categoria_id = 1 e a categoria 1 não existir, o MySQL retorna erro de FK constraint. Por isso categorias vem antes de produtos.S4.2 A Rota Já Está Pronta
Lembre: o template já tem as rotas definidas. Abra o arquivo:
const express = require('express');
const router = express.Router();
const { authMiddleware } = require('../middlewares/auth');
const categoriaController = require('../controllers/categoriaController');
router.use(authMiddleware); // todas as rotas exigem token
router.get('/', categoriaController.listar); // GET /categorias
router.post('/', categoriaController.criar); // POST /categorias
router.put('/:id', categoriaController.atualizar); // PUT /categorias/5
router.delete('/:id', categoriaController.deletar); // DELETE /categorias/5
| Método HTTP | URL | Operação CRUD | SQL equivalente |
|---|---|---|---|
GET |
/categorias |
Read (listar todas) | SELECT * FROM categorias |
POST |
/categorias |
Create (criar nova) | INSERT INTO categorias |
PUT |
/categorias/:id |
Update (editar existente) | UPDATE categorias WHERE id = ? |
DELETE |
/categorias/:id |
Delete (excluir) | DELETE FROM categorias WHERE id = ? |
/:id?
É um parâmetro de rota. Quando o frontend chama PUT /categorias/5, o Express extrai o 5 e coloca em req.params.id. Assim o controller sabe qual categoria atualizar ou deletar.router.use(authMiddleware)?
Quando colocamos router.use() antes das rotas, ele aplica aquele middleware em todas as rotas deste router. É como colocar um segurança na entrada do corredor — todos que passam são verificados. Não precisa repetir authMiddleware em cada rota individual.S4.3 Implementação — O Controller Completo
Agora vamos trocar o stub pelo código real. Abra o arquivo:
Apague tudo e substitua. Primeiro o import:
const { pool } = require('../database/connection');
Só precisamos do pool — categorias não usa bcrypt nem jwt (esses são só para auth). O controller de categorias só faz queries no banco.
Listar (Read) — A mais simples
exports.listar = async (req, res, next) => {
try {
const [rows] = await pool.query(
'SELECT * FROM categorias WHERE empresa_id = ? ORDER BY nome',
[req.user.empresa_id]
);
res.json({ success: true, data: rows });
} catch (error) {
next(error);
}
};
| Trecho | O que faz |
|---|---|
WHERE empresa_id = ? |
Multi-tenant — só retorna categorias da empresa do usuário logado |
[req.user.empresa_id] |
O ? no SQL é substituído por esse valor. É um prepared statement — previne SQL injection |
ORDER BY nome |
Retorna em ordem alfabética — melhor UX na tela |
[rows] |
Desestruturação — pool.query() retorna [rows, fields], pegamos só rows |
res.json({ ... }) |
Retorna status 200 (padrão) com os dados |
??
Se você montar SQL com concatenação ('SELECT * WHERE id = ' + req.body.id), um atacante pode enviar id = "1 OR 1=1" e ver todos os dados. Com ?, o MySQL trata o valor como dado, nunca como código SQL. É como a diferença entre ler uma carta (dado) e executar uma ordem (código).req.user?
Lembre do S3.6: o authMiddleware decodifica o token JWT e coloca os dados em req.user. Quando o controller roda, req.user já tem { id, email, role, empresa_id }. É por isso que a rota exige authMiddleware — sem ele, req.user seria undefined.Criar (Create) — Inserindo no banco
exports.criar = async (req, res, next) => {
try {
const { nome, descricao } = req.body;
// Validação
if (!nome) {
return res.status(400).json({
success: false,
error: 'O campo nome é obrigatório',
});
}
const [result] = await pool.query(
'INSERT INTO categorias (nome, descricao, empresa_id) VALUES (?, ?, ?)',
[nome, descricao || null, req.user.empresa_id]
);
res.status(201).json({
success: true,
data: { id: result.insertId, nome, descricao: descricao || null },
});
} catch (error) {
next(error);
}
};
| Trecho | O que faz |
|---|---|
req.body |
O corpo da requisição POST — contém os dados enviados pelo frontend ({ nome, descricao }) |
if (!nome) |
Valida que o nome foi enviado — a coluna é NOT NULL no banco |
descricao || null |
Se descricao veio vazia ou undefined, salva NULL no banco (campo opcional) |
result.insertId |
O ID gerado pelo AUTO_INCREMENT — retornamos para o frontend saber o ID do recurso criado |
status(201) |
HTTP 201 Created — indica que um recurso novo foi criado |
id para poder editar ou deletar depois sem recarregar a página. Se retornássemos só { success: true }, o frontend teria que fazer outro GET para descobrir o ID. Retornar o objeto completo economiza uma requisição.Atualizar (Update) — Editando um registro
exports.atualizar = async (req, res, next) => {
try {
const { id } = req.params; // vem da URL: /categorias/5
const { nome, descricao } = req.body; // vem do body: { nome, descricao }
if (!nome) {
return res.status(400).json({
success: false,
error: 'O campo nome é obrigatório',
});
}
const [result] = await pool.query(
'UPDATE categorias SET nome = ?, descricao = ? WHERE id = ? AND empresa_id = ?',
[nome, descricao || null, id, req.user.empresa_id]
);
if (result.affectedRows === 0) {
return res.status(404).json({
success: false,
error: 'Categoria não encontrada',
});
}
res.json({
success: true,
data: { id: Number(id), nome, descricao: descricao || null },
});
} catch (error) {
next(error);
}
};
| Trecho | O que faz |
|---|---|
req.params.id |
Vem da URL /categorias/5 — o Express extrai o 5 para req.params.id |
WHERE id = ? AND empresa_id = ? |
Duas condições: o ID certo E da empresa certa. Sem o AND empresa_id, um usuário poderia editar categorias de outra empresa passando o ID dela! |
affectedRows === 0 |
O UPDATE rodou mas não encontrou nenhum registro. Significa: ou o ID não existe, ou é de outra empresa. Retornamos 404. |
Number(id) |
req.params.id vem como string ("5"). Convertemos para número na resposta para manter consistência. |
WHERE id = ?, um atacante da Empresa A poderia enviar PUT /categorias/7 e editar a categoria 7 da Empresa B. O AND empresa_id = ? é a proteção multi-tenant. Nunca esqueça.affectedRows?
Quando você executa UPDATE ou DELETE, o MySQL retorna quantas linhas foram afetadas. Se o WHERE não encontrou nenhum registro, affectedRows é 0. Usamos isso para saber se o recurso existe ou não — sem precisar fazer um SELECT antes.Deletar (Delete) — Com proteção de FK
exports.deletar = async (req, res, next) => {
try {
const { id } = req.params;
// Verificar se há produtos usando esta categoria
const [produtos] = await pool.query(
'SELECT COUNT(*) as total FROM produtos WHERE categoria_id = ? AND empresa_id = ?',
[id, req.user.empresa_id]
);
if (produtos[0].total > 0) {
return res.status(409).json({
success: false,
error: `Não é possível excluir: ${produtos[0].total} produto(s) usam esta categoria`,
});
}
const [result] = await pool.query(
'DELETE FROM categorias WHERE id = ? AND empresa_id = ?',
[id, req.user.empresa_id]
);
if (result.affectedRows === 0) {
return res.status(404).json({ success: false, error: 'Categoria não encontrada' });
}
res.json({ success: true, data: { message: 'Categoria excluída com sucesso' } });
} catch (error) {
next(error);
}
};
| Trecho | O que faz |
|---|---|
SELECT COUNT(*) as total |
Conta quantos produtos usam esta categoria — antes de deletar |
if (total > 0) → 409 |
Se existem produtos, impede a exclusão. Sem isso, o MySQL daria erro de FK constraint (menos amigável) |
status(409) Conflict |
Conflito — o recurso não pode ser deletado porque outros dados dependem dele |
Template string `...${total}...` |
Mensagem dinâmica: "3 produto(s) usam esta categoria" — ajuda o usuário a entender o porquê |
Poderíamos deixar o MySQL dar erro de FK, mas a mensagem seria técnica e confusa para o usuário. Verificando antes, damos uma mensagem clara.
UPDATE ativo = 0) porque o histórico de movimentações referencia o usuario_id. Cada caso tem sua lógica — não existe regra universal.S4.4 Testando com curl
Certifique-se de que o backend está rodando. Primeiro faça login para pegar o token:
curl -X POST http://localhost:3737/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"joao@teste.com","senha":"123456"}'
Copie o token da resposta. Nos testes abaixo, substitua SEU_TOKEN pelo token real.
Teste 1 — Criar categoria
curl -X POST http://localhost:3737/categorias \
-H "Content-Type: application/json" \
-H "Authorization: Bearer SEU_TOKEN" \
-d '{"nome":"Eletrônicos","descricao":"Aparelhos eletrônicos em geral"}'
Resposta esperada (201):
{ "success": true, "data": { "id": 2, "nome": "Eletrônicos", "descricao": "Aparelhos..." } }
Teste 2 — Listar categorias
curl http://localhost:3737/categorias \
-H "Authorization: Bearer SEU_TOKEN"
Resposta — deve aparecer a categoria "Roupas" (do S2.7) e "Eletrônicos" (criada agora):
{ "success": true, "data": [
{ "id": 2, "nome": "Eletrônicos", ... },
{ "id": 1, "nome": "Roupas", ... }
] }
Teste 3 — Atualizar categoria
curl -X PUT http://localhost:3737/categorias/2 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer SEU_TOKEN" \
-d '{"nome":"Eletrônicos e Informática","descricao":"Computadores, celulares, etc."}'
Teste 4 — Deletar categoria (sem produtos)
curl -X DELETE http://localhost:3737/categorias/2 \
-H "Authorization: Bearer SEU_TOKEN"
Resposta (200): { "success": true, "data": { "message": "Categoria excluída com sucesso" } }
Teste 5 — Deletar categoria (com produtos — deve falhar)
curl -X DELETE http://localhost:3737/categorias/1 \
-H "Authorization: Bearer SEU_TOKEN"
Resposta (409): { "success": false, "error": "Não é possível excluir: 1 produto(s) usam esta categoria" }
Teste 6 — Sem token (deve falhar)
curl http://localhost:3737/categorias
Resposta (401): { "success": false, "error": "Token não fornecido" }
categoria_id apontando para nada).S4.5 O Padrão que se Repete
Agora que você fez o CRUD completo de categorias, perceba o padrão que todo controller segue:
| Função | Padrão |
|---|---|
| listar | SELECT * WHERE empresa_id = ? → retorna array |
| criar | Validar → INSERT → retorna objeto com insertId |
| atualizar | Validar → UPDATE WHERE id = ? AND empresa_id = ? → checar affectedRows |
| deletar | Verificar dependências → DELETE WHERE id = ? AND empresa_id = ? → checar affectedRows |
S4.6 Arquivo de Rotas — categoriaRoutes.js
Arquivo: backend/src/routes/categoriaRoutes.js — conecta as URLs às funções do controller.
// backend/src/routes/categoriaRoutes.js
const express = require('express');
const router = express.Router();
const { authMiddleware } = require('../middlewares/auth');
const categoriaController = require('../controllers/categoriaController');
router.use(authMiddleware); // Todas as rotas exigem login
router.get('/', categoriaController.listar);
router.post('/', categoriaController.criar);
router.put('/:id', categoriaController.atualizar);
router.delete('/:id', categoriaController.deletar);
module.exports = router; // ← Obrigatório!
router.use(authMiddleware)?
Aplicar o middleware em todas as rotas deste roteador. Em vez de colocar authMiddleware em cada rota individualmente, colocamos uma vez com use() e ele vale para todas. No app.js, este roteador é montado como app.use('/categorias', categoriaRoutes).✅ Entendeu a tabela de rotas HTTP ↔ operações SQL
✅ Implementou
listar com WHERE empresa_id = ? (multi-tenant)✅ Implementou
criar com validação + insertId✅ Implementou
atualizar com affectedRows para verificar existência✅ Implementou
deletar com verificação de FK (produtos dependentes)✅ Viu o arquivo de rotas e como ele conecta URLs ao controller
✅ Testou todos os cenários com curl
Próximo passo: S5 — CRUD de Produtos — mais colunas, mais validações, mesmo padrão.
No S4 você aprendeu o padrão CRUD com categorias — uma tabela simples de 4 colunas. Agora vamos aplicar o mesmo padrão numa tabela de verdade: 12 colunas, relacionamento com outra tabela (JOIN), preços com decimais, verificação de duplicatas, e uma forma diferente de deletar.
S5.1 Categorias vs Produtos — O que Muda?
Antes de implementar, vamos comparar os dois CRUDs para saber exatamente o que há de novo:
| Aspecto | Categorias (S4) | Produtos (S5) |
|---|---|---|
| Colunas na tabela | 4 (id, nome, descricao, empresa_id) | 12 (nome, sku, preços, estoque, unidade, ativo...) |
| FK para outra tabela | Não | Sim — categoria_id → categorias |
| SELECT usa JOIN? | Não | Sim — precisa mostrar o NOME da categoria |
| Buscar por ID | Não tinha | Sim — GET /produtos/:id |
| Campo único (duplicata) | Não | Sim — SKU não pode repetir na mesma empresa |
| Campos numéricos | Não | Sim — preços (DECIMAL) e estoque (INT) |
| Como deleta | DELETE real (remove do banco) | Soft delete — marca como inativo (ativo = 0) |
| Quem depende dele | Produtos dependem de categorias | Movimentações dependem de produtos |
S5.2 A Tabela de Produtos — Coluna por Coluna
Abra o arquivo migration.sql e releia a tabela de produtos. Vamos entender cada coluna:
CREATE TABLE produtos (
id INT AUTO_INCREMENT PRIMARY KEY,
nome VARCHAR(255) NOT NULL,
descricao TEXT,
sku VARCHAR(100),
categoria_id INT,
preco_custo DECIMAL(10,2) DEFAULT 0,
preco_venda DECIMAL(10,2) DEFAULT 0,
estoque_atual INT DEFAULT 0,
estoque_minimo INT DEFAULT 0,
unidade VARCHAR(20) DEFAULT 'un',
ativo TINYINT(1) DEFAULT 1,
empresa_id INT NOT NULL,
FOREIGN KEY (categoria_id) REFERENCES categorias(id),
FOREIGN KEY (empresa_id) REFERENCES empresas(id)
);
| Coluna | Tipo | O que é | Exemplo |
|---|---|---|---|
nome |
VARCHAR(255) NOT NULL | Nome do produto — obrigatório | "Camiseta Azul M" |
descricao |
TEXT | Descrição longa — opcional | "100% algodão, tamanho M" |
sku |
VARCHAR(100) | Código único do produto — opcional | "CAM-AZ-M-001" |
categoria_id |
INT (FK) | A qual categoria pertence — opcional | 1 (→ "Roupas") |
preco_custo |
DECIMAL(10,2) | Quanto você pagou pelo produto | 25.50 |
preco_venda |
DECIMAL(10,2) | Quanto você cobra do cliente | 49.90 |
estoque_atual |
INT | Quantas unidades tem agora | 150 |
estoque_minimo |
INT | Abaixo disso, o sistema alerta | 10 |
unidade |
VARCHAR(20) | Unidade de medida | "un", "kg", "cx", "lt" |
ativo |
TINYINT(1) | 1 = ativo, 0 = desativado | 1 |
empresa_id |
INT NOT NULL (FK) | Multi-tenant — qual empresa é dona | 2 |
DECIMAL(10,2) significa: até 10 dígitos no total, sendo 2 casas decimais. Ou seja: de 0.00 até 99999999.99.FLOAT e DOUBLE são aproximados — o computador pode armazenar
19.99 como 19.989999999.... Em dinheiro, centavos importam. Se você vender 1000 produtos a R$19.99 e o banco armazenar 19.989999, perde R$10.00 na soma.DECIMAL é exato.
19.99 é armazenado como 19.99 — sem surpresas. Regra: sempre use DECIMAL para dinheiro.estoque_atual = quantas unidades tem no estoque agora. Muda toda vez que entra ou sai mercadoria.estoque_minimo = o mínimo que você quer ter. Se estoque_atual ≤ estoque_minimo, o dashboard mostra um alerta.Exemplo: uma padaria configura
estoque_minimo = 20 para farinha. Quando chega a 15 unidades, o sistema avisa que é hora de comprar mais.Importante: o
estoque_atual nunca é alterado pelo CRUD de produtos. Ele só muda via movimentações (S6). Se o usuário pudesse editar livremente, perderia o histórico de entradas e saídas.DELETE real — a linha some do banco. Em produtos, usamos soft delete: em vez de deletar, marcamos ativo = 0.Por quê? Porque a tabela
movimentacoes referencia produto_id. Se deletássemos o produto, as movimentações ficariam órfãs — "entrou 50 unidades do produto... que não existe mais".Analogia: é como aposentar um funcionário em vez de apagar ele do sistema. O histórico de atividades dele continua, mas ele não aparece mais na lista de ativos.
S5.3 As Rotas — Mesmo Padrão + Uma Nova
Abra o arquivo:
const express = require('express');
const router = express.Router();
const { authMiddleware } = require('../middlewares/auth');
const produtoController = require('../controllers/produtoController');
router.use(authMiddleware);
router.get('/', produtoController.listar); // GET /produtos
router.get('/:id', produtoController.buscarPorId); // GET /produtos/5 ← NOVA!
router.post('/', produtoController.criar); // POST /produtos
router.put('/:id', produtoController.atualizar); // PUT /produtos/5
router.delete('/:id', produtoController.deletar); // DELETE /produtos/5
Comparando com categorias — 4 rotas lá, 5 rotas aqui. A novidade é GET /:id:
| Rota | Para que serve | Tinha em Categorias? |
|---|---|---|
GET /produtos |
Lista todos os produtos (para a tabela) | Sim |
GET /produtos/:id |
Busca UM produto (para a tela de edição) | Não |
POST /produtos |
Cria um produto novo | Sim |
PUT /produtos/:id |
Atualiza um produto existente | Sim |
DELETE /produtos/:id |
Desativa (soft delete) um produto | Sim (mas era DELETE real) |
GET /produtos/5 para buscar todos os dados e preencher o formulário.S5.4 Conceito Novo: JOIN — Juntando Duas Tabelas
Este é o conceito mais importante deste capítulo. No banco, o produto armazena categoria_id = 1. Mas na tela, o usuário quer ver "Roupas", não "1". Como transformar o número no nome?
O JOIN faz exatamente isso: na hora do SELECT, ele consulta a tabela de referência automaticamente e traz o nome junto.
Sem JOIN (ruim)
SELECT * FROM produtos WHERE empresa_id = 1;
-- Resultado: categoria_id = 1 ... mas "1" o quê?
-- O frontend teria que fazer OUTRO request para buscar o nome da categoria
Com JOIN (bom)
SELECT p.*, c.nome AS categoria_nome
FROM produtos p
LEFT JOIN categorias c ON c.id = p.categoria_id
WHERE p.empresa_id = 1;
-- Resultado: categoria_id = 1, categoria_nome = "Roupas"
Vamos entender cada parte:
| Trecho SQL | O que faz |
|---|---|
p.* |
Todas as colunas da tabela produtos (o p é um apelido) |
c.nome AS categoria_nome |
Pega a coluna nome da tabela categorias e renomeia para categoria_nome |
FROM produtos p |
A tabela principal é produtos, e seu apelido é p |
LEFT JOIN categorias c |
Junta com a tabela categorias (apelido c) |
ON c.id = p.categoria_id |
A condição de ligação: o id da categoria = o categoria_id do produto |
nome e id), você precisa dizer de qual tabela está falando. Em vez de escrever produtos.nome e categorias.nome, usamos p.nome e c.nome. É só para escrever menos.categoria_id = NULL, ele desaparece do resultado.LEFT JOIN — retorna todos os produtos, mesmo os sem categoria. O campo
categoria_nome vem como NULL.Usamos LEFT JOIN porque
categoria_id é opcional (não tem NOT NULL). Um produto pode existir sem categoria.Analogia: INNER JOIN é uma festa que só entra quem tem convite. LEFT JOIN inclui todo mundo — quem tem convite e quem não tem.
AS categoria_nome?
Sem o AS, a coluna viria como nome — mas já existe um nome no produto! O AS renomeia a coluna no resultado para evitar conflito. É como dar um crachá novo: "a partir de agora, esse nome se chama categoria_nome".S5.5 Conceito Novo: .map() — Transformando Listas
No listar, depois de buscar os produtos do banco, vamos adicionar um campo calculado: estoque_status que diz se o estoque está "baixo" ou "normal". Para isso, usamos o .map() do JavaScript.
O que é .map()?
.map() é um método de arrays que transforma cada item e retorna um novo array. Ele não modifica o original.
// Array original
const numeros = [1, 2, 3, 4];
// .map() — transforma cada item (multiplica por 2)
const dobrados = numeros.map(numero => numero * 2);
console.log(dobrados); // [2, 4, 6, 8]
console.log(numeros); // [1, 2, 3, 4] ← original intacto!
.map() é uma máquina de carimbar no meio da esteira: cada caixa entra, recebe um carimbo (transformação), e sai do outro lado. No final, você tem as mesmas caixas mas todas carimbadas. O .map() não joga fora nenhuma caixa — ele transforma todas.Se você quer filtrar (jogar fora algumas caixas), use
.filter().Se você quer transformar (modificar cada caixa), use
.map().Como funciona o .map() passo a passo
const nomes = ['ana', 'carlos', 'bia'];
// A função dentro do .map() roda UMA VEZ para cada item
const maiusculos = nomes.map(function(nome) {
return nome.toUpperCase();
});
// ['ANA', 'CARLOS', 'BIA']
// Mesmo resultado com arrow function (forma curta)
const maiusculos2 = nomes.map(nome => nome.toUpperCase());
// ['ANA', 'CARLOS', 'BIA']
| Iteração | Valor de nome |
Retorna |
|---|---|---|
| 1ª | 'ana' |
'ANA' |
| 2ª | 'carlos' |
'CARLOS' |
| 3ª | 'bia' |
'BIA' |
.map() sempre retorna um array do mesmo tamanho. Se entram 3 itens, saem 3 itens. Ele não remove, não filtra, não agrupa. Só transforma. Também cria um array novo — o original fica intacto..map() com objetos — Adicionando campos calculados
No nosso caso, cada item do array é um objeto (produto). Queremos adicionar um campo novo sem perder os existentes:
const produtos = [
{ nome: 'Camiseta', estoque_atual: 5, estoque_minimo: 10 },
{ nome: 'Calça', estoque_atual: 50, estoque_minimo: 10 },
];
const comStatus = produtos.map(produto => ({
...produto, // copia TUDO que já existe
estoque_status: produto.estoque_atual <= produto.estoque_minimo
? 'baixo' // se estoque ≤ mínimo
: 'normal', // senão
}));
// Resultado:
// [
// { nome: 'Camiseta', estoque_atual: 5, estoque_minimo: 10, estoque_status: 'baixo' },
// { nome: 'Calça', estoque_atual: 50, estoque_minimo: 10, estoque_status: 'normal' },
// ]
.map(p => ({ ... }))
Quando uma arrow function retorna um objeto literal direto, você precisa envolver em parênteses. Sem eles, o JavaScript confunde as chaves {} do objeto com um bloco de código.produto => ({ ...produto, campo: valor }) — retorna objetoproduto => { ...produto, campo: valor } — erro de sintaxe!É uma pegadinha clássica. Se o erro disser
Unexpected token, verifique os parênteses....produto (spread)?
O operador ... (spread) espalha todas as propriedades de um objeto dentro de outro. É como copiar e colar:{ ...produto } = cria uma cópia do produto{ ...produto, estoque_status: 'baixo' } = copia tudo E adiciona um campo novoSe o produto tem 12 campos, o spread copia os 12 sem listar todos. Sem spread, você teria que escrever:
{ nome: produto.nome, sku: produto.sku, preco_custo: produto.preco_custo, ... } — 12 linhas para copiar o que o spread faz em 3 caracteres.S5.6 Implementação — O Controller Completo
Agora vamos trocar o stub pelo código real. Abra o arquivo:
Apague tudo e substitua. Vamos bloco a bloco.
const { pool } = require('../database/connection');
Mesmo import do S4 — só o pool. Produtos não precisa de bcrypt nem jwt.
Listar — Com JOIN e .map()
exports.listar = async (req, res, next) => {
try {
const [rows] = await pool.query(
`SELECT p.*, c.nome AS categoria_nome
FROM produtos p
LEFT JOIN categorias c ON c.id = p.categoria_id
WHERE p.empresa_id = ? AND p.ativo = 1
ORDER BY p.nome`,
[req.user.empresa_id]
);
// Adiciona campo calculado: estoque_status
const produtos = rows.map(produto => ({
...produto,
estoque_status: produto.estoque_atual <= produto.estoque_minimo
? 'baixo'
: 'normal',
}));
res.json({ success: true, data: produtos });
} catch (error) {
next(error);
}
};
| Trecho | O que faz |
|---|---|
SELECT p.*, c.nome AS categoria_nome |
Busca tudo de produtos + o nome da categoria (JOIN) |
LEFT JOIN categorias c ON ... |
Junta com categorias — LEFT para incluir produtos sem categoria |
AND p.ativo = 1 |
Só mostra produtos ativos (soft delete: desativados ficam escondidos) |
rows.map(produto => ({ ... })) |
Transforma cada produto adicionando estoque_status |
...produto |
Spread — copia todos os 12+ campos do produto |
? 'baixo' : 'normal' |
Ternário — se estoque ≤ mínimo retorna 'baixo', senão 'normal' |
estoque_status não existe no banco — é calculado na hora. O frontend usa esse campo para mostrar um badge colorido: vermelho = "baixo", verde = "normal". Fazer no backend garante que a lógica é a mesma para qualquer cliente (web, mobile, API).Buscar por ID — Retornando UM produto
exports.buscarPorId = async (req, res, next) => {
try {
const { id } = req.params;
const [rows] = await pool.query(
`SELECT p.*, c.nome AS categoria_nome
FROM produtos p
LEFT JOIN categorias c ON c.id = p.categoria_id
WHERE p.id = ? AND p.empresa_id = ? AND p.ativo = 1`,
[id, req.user.empresa_id]
);
if (rows.length === 0) {
return res.status(404).json({
success: false,
error: 'Produto não encontrado',
});
}
res.json({ success: true, data: rows[0] });
} catch (error) {
next(error);
}
};
| Trecho | O que faz |
|---|---|
WHERE p.id = ? |
Filtra pelo ID específico — busca UM produto |
AND p.empresa_id = ? |
Segurança multi-tenant — não acessa produto de outra empresa |
AND p.ativo = 1 |
Não retorna produtos desativados (soft-deleted) |
rows.length === 0 |
Se não encontrou, retorna 404 |
rows[0] |
Retorna o primeiro (e único) resultado como objeto, não array |
rows[0] e não rows?
O pool.query() sempre retorna um array, mesmo quando é só 1 registro: [{ id: 5, nome: 'Camiseta' }]. Na listagem, retornamos o array inteiro (data: rows). Na busca por ID, o frontend espera um objeto (data: rows[0]) — não um array com 1 item dentro.Criar — Mais Campos, Mais Validações
exports.criar = async (req, res, next) => {
try {
const { nome, descricao, sku, categoria_id,
preco_custo, preco_venda,
estoque_atual, estoque_minimo, unidade } = req.body;
// 1. Validar nome (obrigatório)
if (!nome) {
return res.status(400).json({
success: false,
error: 'O campo nome é obrigatório',
});
}
// 2. Verificar se categoria existe (se informada)
if (categoria_id) {
const [cat] = await pool.query(
'SELECT id FROM categorias WHERE id = ? AND empresa_id = ?',
[categoria_id, req.user.empresa_id]
);
if (cat.length === 0) {
return res.status(400).json({
success: false,
error: 'Categoria não encontrada',
});
}
}
// 3. Verificar SKU duplicado (se informado)
if (sku) {
const [existente] = await pool.query(
'SELECT id FROM produtos WHERE sku = ? AND empresa_id = ? AND ativo = 1',
[sku, req.user.empresa_id]
);
if (existente.length > 0) {
return res.status(409).json({
success: false,
error: 'Já existe um produto com este SKU',
});
}
}
// 4. Inserir no banco
const [result] = await pool.query(
`INSERT INTO produtos
(nome, descricao, sku, categoria_id, preco_custo, preco_venda,
estoque_atual, estoque_minimo, unidade, empresa_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
nome,
descricao || null,
sku || null,
categoria_id || null,
preco_custo || 0,
preco_venda || 0,
estoque_atual || 0,
estoque_minimo || 0,
unidade || 'un',
req.user.empresa_id,
]
);
res.status(201).json({
success: true,
data: { id: result.insertId, nome, sku: sku || null },
});
} catch (error) {
next(error);
}
};
| Passo | O que faz | Tinha em Categorias? |
|---|---|---|
| 1. Validar nome | Mesmo padrão do S4 | Sim |
| 2. Verificar categoria | Se enviou categoria_id, verifica se existe na empresa |
Novo |
| 3. Verificar SKU | Se enviou sku, verifica se já existe (duplicata) |
Novo |
| 4. INSERT | Insere com 10 campos (vs 3 em categorias) | Mesmo padrão |
if (categoria_id) — por que verificar antes?
O categoria_id é opcional (pode ser NULL). Se o frontend não enviar, categoria_id é undefined — e if (undefined) é false, então pula a verificação. Mas se enviar categoria_id: 999 e a categoria 999 não existir, o MySQL daria erro de FK constraint com mensagem técnica confusa. Verificando antes, damos mensagem clara.AND ativo = 1 na verificação de SKU?
Se um produto com SKU "CAM-001" foi desativado (soft delete), esse SKU deve poder ser reutilizado. Imagine que deletou um produto descontinuado e quer criar um novo com o mesmo código. Sem AND ativo = 1, o sistema diria "SKU já existe" — mesmo o produto estando desativado.preco_custo || 0?
O operador || significa "OU". Se preco_custo é undefined, null, ou "" (vazio), usa o valor depois do || — que é 0. Assim garantimos que nenhum campo numérico vai como NULL pro banco.Atualizar — Mesmo Padrão, Mais Campos
exports.atualizar = async (req, res, next) => {
try {
const { id } = req.params;
const { nome, descricao, sku, categoria_id,
preco_custo, preco_venda, estoque_minimo, unidade } = req.body;
if (!nome) {
return res.status(400).json({
success: false,
error: 'O campo nome é obrigatório',
});
}
// Verificar SKU duplicado (excluindo o próprio produto)
if (sku) {
const [existente] = await pool.query(
'SELECT id FROM produtos WHERE sku = ? AND empresa_id = ? AND ativo = 1 AND id != ?',
[sku, req.user.empresa_id, id]
);
if (existente.length > 0) {
return res.status(409).json({
success: false,
error: 'Já existe outro produto com este SKU',
});
}
}
const [result] = await pool.query(
`UPDATE produtos SET
nome = ?, descricao = ?, sku = ?, categoria_id = ?,
preco_custo = ?, preco_venda = ?, estoque_minimo = ?, unidade = ?
WHERE id = ? AND empresa_id = ? AND ativo = 1`,
[
nome,
descricao || null,
sku || null,
categoria_id || null,
preco_custo || 0,
preco_venda || 0,
estoque_minimo || 0,
unidade || 'un',
id,
req.user.empresa_id,
]
);
if (result.affectedRows === 0) {
return res.status(404).json({
success: false,
error: 'Produto não encontrado',
});
}
res.json({
success: true,
data: { id: Number(id), nome, sku: sku || null },
});
} catch (error) {
next(error);
}
};
estoque_atual NÃO está no UPDATE?
Isso é proposital e muito importante. O estoque nunca é editado diretamente — ele só muda via movimentações (entrada/saída no S6). Se pudesse editar livremente, o histórico ficaria inconsistente:Exemplo: estoque diz 100. Movimentações dizem: entrou 50, saiu 30 = deveria ter 20. Quem está certo? Impossível saber se alguém editou manualmente.
Regra: estoque_atual é controlado exclusivamente pelo controller de movimentações.
AND id != ? na verificação de SKU?
Quando estamos atualizando o produto 5, e ele já tem SKU "CAM-001", não queremos que o sistema diga "SKU duplicado" por causa dele mesmo. O AND id != ? exclui o próprio produto da busca. Sem isso, editar sem mudar o SKU sempre daria erro.Deletar — Soft Delete (UPDATE em vez de DELETE)
exports.deletar = async (req, res, next) => {
try {
const { id } = req.params;
const [result] = await pool.query(
'UPDATE produtos SET ativo = 0 WHERE id = ? AND empresa_id = ? AND ativo = 1',
[id, req.user.empresa_id]
);
if (result.affectedRows === 0) {
return res.status(404).json({
success: false,
error: 'Produto não encontrado',
});
}
res.json({ success: true, data: { message: 'Produto desativado com sucesso' } });
} catch (error) {
next(error);
}
};
| Aspecto | Categorias (S4) | Produtos (S5) |
|---|---|---|
| SQL | DELETE FROM categorias WHERE ... |
UPDATE produtos SET ativo = 0 WHERE ... |
| O registro... | Desaparece do banco | Continua no banco, mas com ativo = 0 |
| Verificação antes | Verifica se tem produtos usando (FK) | Não precisa — soft delete não viola FK |
| Mensagem | "Categoria excluída" | "Produto desativado" |
| Pode desfazer? | Não (DELETE é permanente) | Sim (basta UPDATE ativo = 1) |
Produtos: soft delete é como aposentar — não trabalha mais, mas os registros históricos (movimentações) continuam referenciando essa pessoa.
No frontend, a rota DELETE é chamada da mesma forma — o usuário clica "Excluir" e o produto some da lista. Ele não precisa saber que por trás é um UPDATE.
AND ativo = 1 no WHERE do soft delete?
Para não "deletar" um produto que já foi deletado. Se já está com ativo = 0, o UPDATE não encontra nada (affectedRows = 0) e retorna 404.S5.7 Testando com curl
Certifique-se de que o backend está rodando. Faça login para pegar o token:
curl -X POST http://localhost:3737/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"joao@teste.com","senha":"123456"}'
Copie o token. Nos testes abaixo, substitua SEU_TOKEN pelo token real.
Teste 1 — Criar produto
curl -X POST http://localhost:3737/produtos \
-H "Content-Type: application/json" \
-H "Authorization: Bearer SEU_TOKEN" \
-d '{
"nome": "Camiseta Azul M",
"descricao": "100% algodão",
"sku": "CAM-AZ-M-001",
"categoria_id": 1,
"preco_custo": 25.50,
"preco_venda": 49.90,
"estoque_atual": 100,
"estoque_minimo": 10,
"unidade": "un"
}'
Resposta esperada (201):
{ "success": true, "data": { "id": 2, "nome": "Camiseta Azul M", "sku": "CAM-AZ-M-001" } }
Teste 2 — Listar produtos (veja o JOIN e .map() funcionando)
curl http://localhost:3737/produtos \
-H "Authorization: Bearer SEU_TOKEN"
Resposta — note o categoria_nome e estoque_status:
{ "success": true, "data": [
{
"id": 2,
"nome": "Camiseta Azul M",
"sku": "CAM-AZ-M-001",
"categoria_id": 1,
"categoria_nome": "Roupas", // ← veio do JOIN!
"preco_venda": "49.90",
"estoque_atual": 100,
"estoque_minimo": 10,
"estoque_status": "normal", // ← veio do .map()!
...
}
] }
categoria_nome veio do JOIN (buscou na tabela categorias).estoque_status veio do .map() (calculado no JavaScript).Ambos são campos "virtuais" — existem na resposta da API, mas não no banco de dados.
Teste 3 — Buscar por ID
curl http://localhost:3737/produtos/2 \
-H "Authorization: Bearer SEU_TOKEN"
Resposta — note que é um objeto (sem colchetes), não um array:
{ "success": true, "data": { "id": 2, "nome": "Camiseta Azul M", "categoria_nome": "Roupas", ... } }
Teste 4 — Atualizar produto
curl -X PUT http://localhost:3737/produtos/2 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer SEU_TOKEN" \
-d '{"nome":"Camiseta Azul G","sku":"CAM-AZ-G-001","preco_venda":59.90}'
Teste 5 — Deletar produto (soft delete)
curl -X DELETE http://localhost:3737/produtos/2 \
-H "Authorization: Bearer SEU_TOKEN"
Resposta: { "success": true, "data": { "message": "Produto desativado com sucesso" } }
Agora liste novamente — o produto não aparece mais:
curl http://localhost:3737/produtos \
-H "Authorization: Bearer SEU_TOKEN"
mysql -u root -p saas_estoque -e "SELECT id, nome, ativo FROM produtos;"O produto ainda existe com
ativo = 0. Só não aparece na API porque o SELECT filtra WHERE ativo = 1.Teste 6 — SKU duplicado (deve falhar)
Crie dois produtos com o mesmo SKU:
curl -X POST http://localhost:3737/produtos \
-H "Content-Type: application/json" \
-H "Authorization: Bearer SEU_TOKEN" \
-d '{"nome":"Produto A","sku":"TESTE-001"}'
curl -X POST http://localhost:3737/produtos \
-H "Content-Type: application/json" \
-H "Authorization: Bearer SEU_TOKEN" \
-d '{"nome":"Produto B","sku":"TESTE-001"}'
O segundo retorna (409): { "success": false, "error": "Já existe um produto com este SKU" }
Teste 7 — Categoria inexistente (deve falhar)
curl -X POST http://localhost:3737/produtos \
-H "Content-Type: application/json" \
-H "Authorization: Bearer SEU_TOKEN" \
-d '{"nome":"Produto C","categoria_id":999}'
Resposta (400): { "success": false, "error": "Categoria não encontrada" }
Teste 8 — Sem token (deve falhar)
curl http://localhost:3737/produtos
Resposta (401): { "success": false, "error": "Token não fornecido" }
S5.8 O Padrão Consolidado
Com dois CRUDs completos (categorias + produtos), o padrão fica claro:
| Função | Padrão Universal |
|---|---|
| listar | SELECT + WHERE empresa_id = ? + JOIN se precisar + .map() se precisar enriquecer |
| buscarPorId | SELECT WHERE id = ? AND empresa_id = ? → checar rows.length → retornar rows[0] |
| criar | Validar campos → verificar duplicatas → verificar FKs → INSERT → retornar com insertId |
| atualizar | Validar → verificar duplicatas (excluindo self) → UPDATE WHERE id AND empresa_id → checar affectedRows |
| deletar | Hard delete (DELETE) ou soft delete (UPDATE ativo = 0) → checar affectedRows |
S5.9 Arquivo de Rotas — produtoRoutes.js
Arquivo: backend/src/routes/produtoRoutes.js
// backend/src/routes/produtoRoutes.js
const express = require('express');
const router = express.Router();
const { authMiddleware } = require('../middlewares/auth');
const produtoController = require('../controllers/produtoController');
router.use(authMiddleware);
router.get('/', produtoController.listar);
router.get('/:id', produtoController.buscarPorId);
router.post('/', produtoController.criar);
router.put('/:id', produtoController.atualizar);
router.delete('/:id', produtoController.deletar);
module.exports = router;
categoriaRoutes.js. Note a diferença: produtos tem uma rota a mais (GET /:id para buscarPorId). O padrão do roteador é sempre o mesmo — muda apenas o controller importado e as rotas registradas.✅ Aprendeu DECIMAL(10,2) — por que usar para dinheiro (nunca FLOAT)
✅ Aprendeu SKU — código único do produto por empresa
✅ Aprendeu JOIN — juntar duas tabelas para trazer dados relacionados
✅ Entendeu LEFT JOIN vs INNER JOIN — quando usar cada um
✅ Aprendeu .map() — transformar cada item de um array (máquina de carimbar)
✅ Aprendeu spread (...) — copiar objeto e adicionar campos
✅ Implementou buscarPorId — retornar
rows[0] em vez de array✅ Entendeu soft delete (ativo = 0) vs hard delete (DELETE)
✅ Entendeu por que estoque_atual não é editado pelo CRUD
✅ Implementou validação de FK (categoria) e unicidade (SKU)
✅ Testou 8 cenários com curl
Próximo passo: S6 — Movimentações de Estoque — entrada e saída de mercadorias, e o controller que finalmente atualiza o
estoque_atual.Este é o capítulo mais importante do sistema. Até agora, o estoque_atual dos produtos é um número parado — nunca muda. A partir deste capítulo, o estoque ganha vida: mercadoria entra (compra do fornecedor), mercadoria sai (venda, perda, ajuste), e o número atualiza automaticamente.
A tabela
movimentacoes é esse caderno. O estoque_atual do produto é o resultado da soma. O controller de movimentações é o almoxarife que anota E atualiza o total.S6.1 Por que Movimentações é Diferente dos Outros CRUDs
Categorias e Produtos são CRUDs "normais" — cada operação mexe em uma tabela só. Movimentações é diferente:
| Aspecto | Categorias / Produtos | Movimentações |
|---|---|---|
| Quantas tabelas altera | 1 (a própria) | 2 — movimentacoes + produtos |
| Operações CRUD | Listar, Criar, Atualizar, Deletar | Apenas Listar e Criar (não edita nem deleta) |
| Precisa de transação? | Não | Sim — BEGIN / COMMIT / ROLLBACK |
| Validação especial | Campos obrigatórios, FK | Verificar se tem estoque suficiente para saída |
| JOIN na listagem | Produtos: 1 JOIN (categorias) | 2 JOINs (produtos + users) |
Analogia: num extrato do banco, você não "apaga" uma transferência que fez errado. Você faz uma transferência de volta. O histórico completo fica registrado.
S6.2 A Tabela de Movimentações — Coluna por Coluna
CREATE TABLE movimentacoes (
id INT AUTO_INCREMENT PRIMARY KEY,
produto_id INT NOT NULL,
tipo ENUM('entrada', 'saida') NOT NULL,
quantidade INT NOT NULL,
motivo VARCHAR(255),
observacao TEXT,
usuario_id INT NOT NULL,
empresa_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (produto_id) REFERENCES produtos(id),
FOREIGN KEY (usuario_id) REFERENCES users(id),
FOREIGN KEY (empresa_id) REFERENCES empresas(id)
);
| Coluna | Tipo | O que é | Exemplo |
|---|---|---|---|
produto_id |
INT NOT NULL (FK) | Qual produto foi movimentado | 2 (→ "Camiseta Azul M") |
tipo |
ENUM('entrada','saida') | Entrou ou saiu mercadoria | "entrada" |
quantidade |
INT NOT NULL | Quantas unidades (sempre positivo) | 50 |
motivo |
VARCHAR(255) | Por que movimentou | "Compra fornecedor", "Venda", "Perda" |
observacao |
TEXT | Detalhes adicionais — opcional | "NF 12345, lote ABR/2026" |
usuario_id |
INT NOT NULL (FK) | Quem registrou a movimentação | 1 (→ "João") |
empresa_id |
INT NOT NULL (FK) | Multi-tenant | 2 |
created_at |
TIMESTAMP | Data/hora automática | 2026-03-03 10:30:00 |
ENUM é um tipo do MySQL que só aceita valores pré-definidos. Se alguém tentar inserir tipo = "transferencia", o MySQL rejeita com erro. É como um campo de seleção (dropdown) — só pode escolher as opções listadas.Usamos ENUM em vez de VARCHAR porque:
1. Valida automaticamente — o banco não aceita valores inválidos
2. Ocupa menos espaço — internamente é armazenado como número (1 ou 2)
3. Documenta o código — olhando o schema, você já sabe os valores possíveis
quantidade é sempre positivo?
A quantidade é sempre um número positivo (50, 10, 3). O tipo diz se é entrada ou saída. Não usamos números negativos porque é confuso: quantidade = -50 com tipo = 'saida' significaria... entrada? Manter positivo + tipo separado é mais claro e menos propenso a erros.usuario_id?
Para saber quem fez a movimentação. Se o estoque está errado, o admin pode ver: "às 14h30, o estoquista Carlos registrou saída de 50 camisetas". É rastreabilidade — essencial em controle de estoque. O usuario_id vem do token JWT (req.user.id).S6.3 As Rotas — Só Duas
const express = require('express');
const router = express.Router();
const { authMiddleware } = require('../middlewares/auth');
const movimentacaoController = require('../controllers/movimentacaoController');
router.use(authMiddleware);
router.get('/', movimentacaoController.listar); // GET /movimentacoes
router.post('/', movimentacaoController.criar); // POST /movimentacoes
Apenas 2 rotas — o CRUD mais enxuto do projeto:
| Rota | Para que serve |
|---|---|
GET /movimentacoes |
Lista o histórico de entradas e saídas |
POST /movimentacoes |
Registra uma nova entrada ou saída |
Sem PUT (não edita), sem DELETE (não apaga). Movimentação é registro permanente.
S6.4 Conceito Novo: Transação SQL — Tudo ou Nada
Este é o conceito mais importante deste capítulo. Quando o usuário registra uma entrada de 50 camisetas, precisamos fazer duas coisas:
INSERT INTO movimentacoes— registrar a movimentaçãoUPDATE produtos SET estoque_atual = estoque_atual + 50— atualizar o estoque
E se o INSERT funcionar mas o UPDATE falhar? Teríamos uma movimentação registrada, mas o estoque não mudou. O caderno do almoxarife diz "entrou 50" mas a prateleira não mudou. Dados inconsistentes.
A solução: BEGIN, COMMIT e ROLLBACK
Uma transação agrupa várias operações SQL em uma unidade: se todas dão certo, aplica tudo (COMMIT). Se qualquer uma falhar, desfaz tudo (ROLLBACK).
-- SEM transação (perigoso)
INSERT INTO movimentacoes (...); -- ✅ funcionou
UPDATE produtos SET estoque = ...; -- ❌ falhou — dados inconsistentes!
-- COM transação (seguro)
BEGIN; -- abre a transação
INSERT INTO movimentacoes (...); -- ✅ funcionou (mas não aplicou ainda)
UPDATE produtos SET estoque = ...; -- ❌ falhou
ROLLBACK; -- desfaz TUDO — inclusive o INSERT
-- Se tudo der certo:
BEGIN;
INSERT INTO movimentacoes (...); -- ✅
UPDATE produtos SET estoque = ...; -- ✅
COMMIT; -- aplica TUDO de uma vez
| Comando | O que faz | Analogia |
|---|---|---|
BEGIN |
Abre a transação — "começa a anotar no rascunho" | Pegar um lápis e um rascunho |
COMMIT |
Aplica tudo — "passa a limpo no caderno oficial" | Copiar o rascunho para o livro-caixa com caneta |
ROLLBACK |
Desfaz tudo — "amassa o rascunho e joga fora" | Jogar o rascunho no lixo, caderno oficial intacto |
No código: connection.getConnection()
Para usar transações com o pool do mysql2, precisamos pegar uma conexão individual do pool:
// Sem transação (como fizemos até agora)
const [rows] = await pool.query('SELECT ...');
// Com transação (novo!)
const connection = await pool.getConnection(); // pega UMA conexão do pool
await connection.beginTransaction(); // BEGIN
try {
await connection.query('INSERT ...'); // operação 1
await connection.query('UPDATE ...'); // operação 2
await connection.commit(); // COMMIT — aplica tudo
} catch (error) {
await connection.rollback(); // ROLLBACK — desfaz tudo
throw error;
} finally {
connection.release(); // devolve a conexão ao pool
}
| Método | O que faz |
|---|---|
pool.getConnection() |
Pega uma conexão "emprestada" do pool (como pegar uma chave do armário) |
connection.beginTransaction() |
Inicia a transação (BEGIN) |
connection.query() |
Executa SQL nesta conexão específica (não no pool geral) |
connection.commit() |
Aplica todas as operações (COMMIT) |
connection.rollback() |
Desfaz todas as operações (ROLLBACK) |
connection.release() |
Devolve a conexão ao pool (como devolver a chave ao armário) |
pool.getConnection() e não pool.query()?
O pool.query() pega uma conexão, executa, e devolve automaticamente. Cada chamada pode usar uma conexão diferente. Numa transação, todas as operações precisam usar a mesma conexão — se o INSERT vai na conexão A e o UPDATE na conexão B, o BEGIN da conexão A não protege o UPDATE da B.Por isso pegamos uma conexão com
getConnection() e usamos ela para tudo.finally?
O bloco finally roda sempre — deu certo ou deu erro. Usamos para devolver a conexão ao pool. Se esquecer o release(), a conexão fica "presa" e o pool vai ficando sem conexões disponíveis. Com 10 movimentações sem release, o pool trava e o servidor para de responder.Analogia: é como devolver a chave do almoxarifado. Deu certo ou não, você sempre devolve. Se todos saírem sem devolver a chave, ninguém mais consegue entrar.
S6.5 Conceito Novo: Múltiplos JOINs
No S5, usamos 1 JOIN (produtos ← categorias). Na listagem de movimentações, precisamos de 2 JOINs: o nome do produto e o nome do usuário que registrou.
SELECT
m.*,
p.nome AS produto_nome,
u.nome AS usuario_nome
FROM movimentacoes m
LEFT JOIN produtos p ON p.id = m.produto_id
LEFT JOIN users u ON u.id = m.usuario_id
WHERE m.empresa_id = 1
ORDER BY m.created_at DESC;
| JOIN | Liga o quê | Para trazer |
|---|---|---|
LEFT JOIN produtos p ON p.id = m.produto_id |
movimentacao → produto | Nome do produto movimentado |
LEFT JOIN users u ON u.id = m.usuario_id |
movimentacao → usuario | Nome de quem registrou |
ORDER BY m.created_at DESC?
DESC = descendente (mais recente primeiro). Movimentações são como uma timeline — o usuário quer ver as mais recentes no topo. Sem DESC, viria em ordem crescente (mais antigo primeiro).S6.6 O Fluxo da Criação — Passo a Passo
Quando o usuário registra uma movimentação, o controller faz 7 passos:
| Passo | O que faz | Por quê |
|---|---|---|
| 1 | Validar campos obrigatórios | produto_id, tipo e quantidade são NOT NULL |
| 2 | Verificar se o produto existe e está ativo | Não pode movimentar produto desativado |
| 3 | Se for saída: verificar se tem estoque suficiente | Não pode sair mais do que tem (estoque negativo) |
| 4 | BEGIN — abrir transação |
Garantir que as duas operações seguintes são atômicas |
| 5 | INSERT na tabela movimentacoes |
Registrar o histórico |
| 6 | UPDATE o estoque_atual do produto |
Somar (entrada) ou subtrair (saída) a quantidade |
| 7 | COMMIT — aplicar tudo |
Só agora as alterações ficam permanentes |
2. O almoxarife confere se o produto existe no sistema (verificar produto)
3. Se for retirada, confere se tem na prateleira (verificar estoque)
4. Pega o rascunho (BEGIN)
5. Anota no caderno de movimentações (INSERT)
6. Atualiza a contagem na prateleira (UPDATE estoque)
7. Passa a limpo no livro oficial (COMMIT)
S6.7 Implementação — O Controller Completo
Abra o arquivo:
Apague tudo e substitua. Vamos bloco a bloco.
const { pool } = require('../database/connection');
Mesmo import de sempre. A diferença é que neste controller vamos usar pool.getConnection() além de pool.query().
Listar — Dois JOINs + ORDER BY DESC
exports.listar = async (req, res, next) => {
try {
const [rows] = await pool.query(
`SELECT m.*, p.nome AS produto_nome, u.nome AS usuario_nome
FROM movimentacoes m
LEFT JOIN produtos p ON p.id = m.produto_id
LEFT JOIN users u ON u.id = m.usuario_id
WHERE m.empresa_id = ?
ORDER BY m.created_at DESC`,
[req.user.empresa_id]
);
res.json({ success: true, data: rows });
} catch (error) {
next(error);
}
};
| Trecho | O que faz |
|---|---|
m.* |
Todas as colunas de movimentacoes |
p.nome AS produto_nome |
Nome do produto (1º JOIN) |
u.nome AS usuario_nome |
Nome do usuário que registrou (2º JOIN) |
ORDER BY m.created_at DESC |
Mais recente primeiro |
pool.query() normal, como nos capítulos anteriores.Criar — A função com transação
Esta é a função mais complexa do projeto até agora. Vamos ir parte por parte.
exports.criar = async (req, res, next) => {
const connection = await pool.getConnection();
try {
const { produto_id, tipo, quantidade, motivo, observacao } = req.body;
getConnection() está FORA do try?
Se getConnection() falhar (pool cheio, banco offline), não tem conexão para dar release(). Colocando fora do try, o finally só roda o release() se a conexão foi obtida com sucesso. Se colocássemos dentro do try, o finally tentaria connection.release() com connection = undefined — e daria outro erro. // 1. Validação
if (!produto_id || !tipo || !quantidade) {
connection.release();
return res.status(400).json({
success: false,
error: 'Campos obrigatórios: produto_id, tipo, quantidade',
});
}
if (!['entrada', 'saida'].includes(tipo)) {
connection.release();
return res.status(400).json({
success: false,
error: 'Tipo deve ser "entrada" ou "saida"',
});
}
if (quantidade <= 0) {
connection.release();
return res.status(400).json({
success: false,
error: 'Quantidade deve ser maior que zero',
});
}
.includes()?
['entrada', 'saida'].includes(tipo) verifica se tipo é um dos valores do array. Retorna true ou false. É como perguntar: "essa palavra está na lista?"Podíamos confiar no ENUM do MySQL para rejeitar valores inválidos, mas validar no controller dá uma mensagem de erro muito mais clara. O erro do MySQL seria algo como
"Data truncated for column 'tipo'" — inútil para o usuário.connection.release() antes de cada return?
Se retornamos cedo (validação falhou), o finally ainda vai rodar — mas é boa prática liberar a conexão o mais cedo possível. O release() no finally é a rede de segurança; o release() aqui é eficiência. // 2. Verificar se o produto existe e está ativo
const [produto] = await connection.query(
'SELECT id, nome, estoque_atual FROM produtos WHERE id = ? AND empresa_id = ? AND ativo = 1',
[produto_id, req.user.empresa_id]
);
if (produto.length === 0) {
connection.release();
return res.status(404).json({
success: false,
error: 'Produto não encontrado ou desativado',
});
}
estoque_atual do produto?
Precisamos saber quanto tem no estoque agora para duas coisas:1. Verificar se tem o suficiente para saída (passo 3)
2. Informar o novo estoque na resposta da API
// 3. Se for saída: verificar estoque suficiente
if (tipo === 'saida' && produto[0].estoque_atual < quantidade) {
connection.release();
return res.status(409).json({
success: false,
error: `Estoque insuficiente. Disponível: ${produto[0].estoque_atual}, solicitado: ${quantidade}`,
});
}
Analogia: é como tentar sacar R$500 de uma conta com R$100. O banco rejeita porque não tem saldo. Nosso sistema faz o mesmo com estoque.
&&?
É o operador lógico "E" (AND). A condição tipo === 'saida' && estoque_atual < quantidade significa: só verifica estoque SE for saída. Se for entrada, não importa quanto tem — sempre pode entrar mais. // 4. Abrir transação
await connection.beginTransaction();
// 5. Inserir movimentação
const [result] = await connection.query(
`INSERT INTO movimentacoes
(produto_id, tipo, quantidade, motivo, observacao, usuario_id, empresa_id)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
produto_id,
tipo,
quantidade,
motivo || null,
observacao || null,
req.user.id,
req.user.empresa_id,
]
);
// 6. Atualizar estoque do produto
const operacao = tipo === 'entrada'
? 'estoque_atual + ?'
: 'estoque_atual - ?';
await connection.query(
`UPDATE produtos SET estoque_atual = ${operacao} WHERE id = ?`,
[quantidade, produto_id]
);
// 7. Aplicar tudo
await connection.commit();
// Calcular novo estoque para a resposta
const novoEstoque = tipo === 'entrada'
? produto[0].estoque_atual + quantidade
: produto[0].estoque_atual - quantidade;
res.status(201).json({
success: true,
data: {
id: result.insertId,
produto_id,
produto_nome: produto[0].nome,
tipo,
quantidade,
estoque_anterior: produto[0].estoque_atual,
estoque_novo: novoEstoque,
},
});
} catch (error) {
await connection.rollback();
next(error);
} finally {
connection.release();
}
};
Vamos entender as partes mais importantes:
| Trecho | O que faz |
|---|---|
connection.beginTransaction() |
Abre a transação — a partir daqui, nada é permanente até o COMMIT |
req.user.id |
O ID do usuário logado — vem do token JWT. Assim sabemos quem registrou |
tipo === 'entrada' ? '+' : '-' |
Ternário: se for entrada, soma; se for saída, subtrai |
estoque_atual + ? |
Soma direto no SQL — o MySQL faz a conta. Não buscamos o valor, somamos no JS e mandamos de volta |
connection.commit() |
Aplica INSERT + UPDATE de uma vez. Só agora os dados ficam no banco |
connection.rollback() |
No catch: se QUALQUER coisa falhar, desfaz tudo |
connection.release() |
No finally: devolve a conexão ao pool (sempre roda) |
estoque_atual + ? no SQL em vez de calcular no JavaScript?
Se dois usuários registrarem movimentações ao mesmo tempo:No JS (errado): ambos leem estoque = 100. Um soma 50 = 150. Outro subtrai 10 = 90. O último que gravar "ganha" — o estoque fica errado.
No SQL (certo):
estoque_atual + 50 e estoque_atual - 10 são executados pelo MySQL em sequência. Não importa a ordem — o resultado final é 140 (100+50-10). O MySQL garante a atomicidade.Regra: quando incrementar/decrementar um valor, sempre faça a conta no SQL.
try — tenta executar o código. Se tudo der certo, chega ao commit().catch — se qualquer linha do try lançar erro, cai aqui. Faz rollback() para desfazer.finally — roda sempre, deu certo ou não. Faz release() para devolver a conexão.Analogia: try = "tente fazer o trabalho". catch = "se der errado, limpe a bagunça". finally = "independente do que acontecer, devolva a chave".
S6.8 Testando com curl
Certifique-se de que o backend está rodando e faça login:
curl -X POST http://localhost:3737/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"joao@teste.com","senha":"123456"}'
Copie o token.
Teste 1 — Entrada de mercadoria
curl -X POST http://localhost:3737/movimentacoes \
-H "Content-Type: application/json" \
-H "Authorization: Bearer SEU_TOKEN" \
-d '{
"produto_id": 1,
"tipo": "entrada",
"quantidade": 50,
"motivo": "Compra fornecedor",
"observacao": "NF 12345"
}'
Resposta esperada (201):
{
"success": true,
"data": {
"id": 1,
"produto_id": 1,
"produto_nome": "Camiseta Preta",
"tipo": "entrada",
"quantidade": 50,
"estoque_anterior": 100, // ← tinha 100
"estoque_novo": 150 // ← agora tem 150
}
}
Teste 2 — Saída de mercadoria
curl -X POST http://localhost:3737/movimentacoes \
-H "Content-Type: application/json" \
-H "Authorization: Bearer SEU_TOKEN" \
-d '{
"produto_id": 1,
"tipo": "saida",
"quantidade": 30,
"motivo": "Venda"
}'
Resposta (201): estoque_anterior: 150, estoque_novo: 120
Teste 3 — Saída com estoque insuficiente (deve falhar)
curl -X POST http://localhost:3737/movimentacoes \
-H "Content-Type: application/json" \
-H "Authorization: Bearer SEU_TOKEN" \
-d '{
"produto_id": 1,
"tipo": "saida",
"quantidade": 9999,
"motivo": "Teste"
}'
Resposta (409): { "success": false, "error": "Estoque insuficiente. Disponível: 120, solicitado: 9999" }
Teste 4 — Listar movimentações (veja os 2 JOINs)
curl http://localhost:3737/movimentacoes \
-H "Authorization: Bearer SEU_TOKEN"
Resposta — note produto_nome e usuario_nome:
{ "success": true, "data": [
{
"id": 2,
"tipo": "saida",
"quantidade": 30,
"motivo": "Venda",
"produto_nome": "Camiseta Preta", // ← 1º JOIN
"usuario_nome": "João", // ← 2º JOIN
...
},
{
"id": 1,
"tipo": "entrada",
"quantidade": 50,
"motivo": "Compra fornecedor",
...
}
] }
ORDER BY created_at DESC funcionando — mais recente primeiro.Teste 5 — Tipo inválido (deve falhar)
curl -X POST http://localhost:3737/movimentacoes \
-H "Content-Type: application/json" \
-H "Authorization: Bearer SEU_TOKEN" \
-d '{"produto_id":1,"tipo":"transferencia","quantidade":10}'
Resposta (400): { "success": false, "error": "Tipo deve ser \"entrada\" ou \"saida\"" }
Teste 6 — Quantidade zero ou negativa (deve falhar)
curl -X POST http://localhost:3737/movimentacoes \
-H "Content-Type: application/json" \
-H "Authorization: Bearer SEU_TOKEN" \
-d '{"produto_id":1,"tipo":"entrada","quantidade":0}'
Resposta (400): { "success": false, "error": "Quantidade deve ser maior que zero" }
Teste 7 — Verificar estoque no banco
mysql -u root -p saas_estoque -e "SELECT id, nome, estoque_atual FROM produtos WHERE empresa_id = 1;"
O estoque_atual deve refletir todas as movimentações: 100 (inicial) + 50 (entrada) - 30 (saída) = 120.
S6.9 Resumo — O que Este Capítulo Tem de Diferente
| Conceito | Onde apareceu | Por que é importante |
|---|---|---|
| Transação (BEGIN/COMMIT/ROLLBACK) | Função criar |
Garante que INSERT + UPDATE são atômicos (tudo ou nada) |
| pool.getConnection() | Função criar |
Mesma conexão para todas as operações da transação |
| connection.release() | Bloco finally |
Devolve a conexão ao pool (evita vazamento) |
| Múltiplos JOINs | Função listar |
Traz nome do produto E nome do usuário |
| ENUM | Coluna tipo |
Só aceita 'entrada' ou 'saida' — validação no banco |
| .includes() | Validação do tipo |
Verifica se o valor está numa lista de permitidos |
| Incremento no SQL | estoque_atual + ? |
Evita race condition quando dois usuários movimentam ao mesmo tempo |
| Sem UPDATE/DELETE | Rotas | Movimentações são imutáveis — rastreabilidade total |
S6.12 Arquivo de Rotas — movimentacaoRoutes.js
Arquivo: backend/src/routes/movimentacaoRoutes.js
// backend/src/routes/movimentacaoRoutes.js
const express = require('express');
const router = express.Router();
const { authMiddleware } = require('../middlewares/auth');
const movimentacaoController = require('../controllers/movimentacaoController');
router.use(authMiddleware);
router.get('/', movimentacaoController.listar);
router.post('/', movimentacaoController.criar);
// Não tem PUT nem DELETE — movimentações são imutáveis!
module.exports = router;
✅ Aprendeu ENUM no MySQL — tipo restrito a valores pré-definidos
✅ Aprendeu transação SQL (BEGIN, COMMIT, ROLLBACK) — tudo ou nada
✅ Entendeu pool.getConnection() vs pool.query() — mesma conexão
✅ Aprendeu try/catch/finally — e por que
finally é essencial para release()✅ Implementou múltiplos JOINs (produtos + users)
✅ Aprendeu .includes() — verificar se um valor está numa lista
✅ Entendeu por que incrementar no SQL (
estoque_atual + ?) é mais seguro✅ Implementou validação de estoque insuficiente para saídas
✅ Entendeu ORDER BY DESC — mais recente primeiro
✅ Testou 7 cenários com curl
Próximo passo: S7 — Dashboard — queries agregadas (COUNT, SUM) com Promise.all para trazer todas as estatísticas de uma vez.
O Dashboard é a primeira tela que o usuário vê ao fazer login. Ele mostra números-resumo de todo o sistema: quantos produtos, quantas categorias, estoque baixo, movimentações do dia e valor total em estoque. Tudo numa única requisição.
Este capítulo é diferente dos anteriores: não é um CRUD. É uma única rota GET que retorna 5 estatísticas. Mas traz 3 conceitos novos importantes.
S7.1 O que o Stub Faz Hoje
Abra o arquivo:
// ANTES — Stub (retorna zeros)
exports.getStats = async (req, res, next) => {
try {
res.json({
success: true,
data: {
totalProdutos: 0,
totalCategorias: 0,
produtosEstoqueBaixo: 0,
movimentacoesHoje: 0,
valorTotalEstoque: 0,
},
});
} catch (error) {
next(error);
}
};
O frontend já consome esses 5 campos e mostra em cards. Só que todos são zero. Nosso trabalho: trocar os zeros por queries reais.
S7.2 As 5 Estatísticas — Qual Query Cada Uma Precisa
| Estatística | O que mostra | Função SQL | Tabela |
|---|---|---|---|
totalProdutos |
Quantos produtos ativos | COUNT(*) |
produtos |
totalCategorias |
Quantas categorias | COUNT(*) |
categorias |
produtosEstoqueBaixo |
Quantos produtos com estoque ≤ mínimo | COUNT(*) com WHERE |
produtos |
movimentacoesHoje |
Quantas movimentações feitas hoje | COUNT(*) com WHERE data |
movimentacoes |
valorTotalEstoque |
Soma de (preço_custo × estoque_atual) | SUM() |
produtos |
São 5 queries independentes — nenhuma depende do resultado da outra. Isso é perfeito para Promise.all.
S7.3 Conceito Novo: COUNT e SUM — Funções de Agregação
Até agora, todos os nossos SELECTs buscavam linhas (registros individuais). Agora vamos buscar números resumidos — quantos registros existem, quanto vale o total. Isso se chama agregação.
COUNT(*) — Contar registros
-- Quantos produtos ativos nesta empresa?
SELECT COUNT(*) AS total
FROM produtos
WHERE empresa_id = 1 AND ativo = 1;
-- Resultado: { total: 47 }
| Trecho | O que faz |
|---|---|
COUNT(*) |
Conta quantas linhas o WHERE encontrou. O * significa "conte todas, independente de NULL" |
AS total |
Dá um nome ao resultado. Sem o AS, a coluna viria como COUNT(*) — difícil de acessar no JavaScript |
SELECT * é como ir ao depósito e trazer todas as caixas para a mesa. COUNT(*) é ir ao depósito, contar as caixas com o dedo, e voltar só com o número. Muito mais rápido — não precisa carregar nada.COUNT com condição — Contar registros específicos
-- Quantos produtos estão com estoque baixo?
SELECT COUNT(*) AS total
FROM produtos
WHERE empresa_id = 1
AND ativo = 1
AND estoque_atual <= estoque_minimo;
-- Conta apenas os que atendem TODAS as condições
estoque_atual <= estoque_minimo?
No S5, definimos que um produto está com estoque "baixo" quando estoque_atual ≤ estoque_minimo. Aqui fazemos a mesma comparação, mas direto no SQL. O MySQL conta só os que atendem essa condição.COUNT com data — Contar registros de hoje
-- Quantas movimentações foram feitas HOJE?
SELECT COUNT(*) AS total
FROM movimentacoes
WHERE empresa_id = 1
AND DATE(created_at) = CURDATE();
| Função MySQL | O que faz | Exemplo |
|---|---|---|
CURDATE() |
Retorna a data de hoje (sem hora) | 2026-03-03 |
DATE(created_at) |
Extrai só a data de um TIMESTAMP (remove a hora) | 2026-03-03 14:30:00 → 2026-03-03 |
DATE(created_at) e não só created_at?
O created_at é um TIMESTAMP com data E hora: 2026-03-03 14:30:00. Se comparar direto com CURDATE() (2026-03-03), nunca vai bater — porque 14:30:00 ≠ 00:00:00. O DATE() remove a hora para comparar só a data.SUM — Somar valores
-- Qual o valor total do estoque?
SELECT SUM(preco_custo * estoque_atual) AS total
FROM produtos
WHERE empresa_id = 1 AND ativo = 1;
-- Exemplo: se tem 100 camisetas a R$25 e 50 calças a R$40,
-- SUM = (25*100) + (40*50) = 2500 + 2000 = 4500.00
| Trecho | O que faz |
|---|---|
SUM(...) |
Soma todos os valores das linhas encontradas pelo WHERE |
preco_custo * estoque_atual |
Para cada produto, multiplica preço pela quantidade em estoque |
AS total |
Nome do resultado |
COUNT = contar quantas caixas tem no depósito (47 caixas).SUM = somar o valor de todas as caixas (R$4.500,00).COUNT conta linhas. SUM soma valores dentro das linhas.
preco_custo e não preco_venda?
O valor total do estoque representa quanto você investiu (custo). Se usasse preco_venda, mostraria quanto você receberia se vendesse tudo — o que é útil, mas é outro indicador. O padrão contábil usa o custo de aquisição. Se quiser, pode adicionar as duas métricas depois.S7.4 Conceito Novo: Promise.all — Queries em Paralelo
Temos 5 queries independentes. Podemos executá-las de duas formas:
Sequencial (lenta)
// Uma de cada vez — cada query espera a anterior terminar
const produtos = await pool.query('SELECT COUNT...'); // 50ms
const categorias = await pool.query('SELECT COUNT...'); // 50ms
const estoqueBaixo = await pool.query('SELECT COUNT...'); // 50ms
const movHoje = await pool.query('SELECT COUNT...'); // 50ms
const valorTotal = await pool.query('SELECT SUM...'); // 50ms
// Total: 250ms (uma atrás da outra)
Paralelo com Promise.all (rápida)
// Todas ao mesmo tempo — tempo = a mais lenta delas
const [produtos, categorias, estoqueBaixo, movHoje, valorTotal] =
await Promise.all([
pool.query('SELECT COUNT...'), // ┐
pool.query('SELECT COUNT...'), // │ todas rodam
pool.query('SELECT COUNT...'), // │ ao mesmo
pool.query('SELECT COUNT...'), // │ tempo!
pool.query('SELECT SUM...'), // ┘
]);
// Total: ~50ms (todas em paralelo!)
Promise.all = CINCO garçons: cada um vai à cozinha ao mesmo tempo. Cada um traz um prato. Todos voltam quase juntos. 5 pratos = 1 viagem (paralela).
O resultado: 5x mais rápido (na prática, 3-5x — depende da capacidade do banco).
Como funciona Promise.all passo a passo
// Promise.all recebe um ARRAY de promises
const resultados = await Promise.all([
funcaoAsync1(), // promise 1
funcaoAsync2(), // promise 2
funcaoAsync3(), // promise 3
]);
// resultados é um ARRAY com os resultados na MESMA ORDEM
// resultados[0] = resultado da funcaoAsync1
// resultados[1] = resultado da funcaoAsync2
// resultados[2] = resultado da funcaoAsync3
| Regra | O que significa |
|---|---|
| Recebe um array de promises | Cada item é uma operação assíncrona (query, fetch, etc.) |
| Executa todas em paralelo | Não espera uma terminar para começar a próxima |
| Retorna um array de resultados | Na mesma ordem que você passou — resultado[0] é da promise[0] |
| Se qualquer uma falhar, todas falham | Cai no catch. Diferente do Promise.allSettled que espera todas |
await sequencial — quando uma operação depende da anterior. Exemplo: no S6, primeiro buscamos o produto (para saber o estoque), depois inserimos a movimentação. A segunda depende da primeira.
Regra simples: se você pode desenhar as operações em paralelo (lado a lado), use
Promise.all. Se precisa desenhar em série (uma embaixo da outra), use await sequencial.Desestruturação do resultado
Cada pool.query() retorna [rows, fields]. Com 5 queries em Promise.all, o resultado é um array de 5 arrays. Desestruturamos assim:
// Sem desestruturação (confuso)
const resultados = await Promise.all([...]);
const totalProdutos = resultados[0][0][0].total; // [query1][rows][primeira_linha].total
// Com desestruturação (limpo)
const [
[prodRows], // query 1: [rows] do COUNT produtos
[catRows], // query 2: [rows] do COUNT categorias
[estBaixoRows], // query 3: [rows] do COUNT estoque baixo
[movHojeRows], // query 4: [rows] do COUNT movimentações hoje
[valorRows], // query 5: [rows] do SUM valor total
] = await Promise.all([...]);
[prodRows] com colchetes?
Lembre: pool.query() retorna [rows, fields]. O [prodRows] desestrutura esse array e pega só o primeiro elemento (as linhas). É o mesmo [rows] que usamos em todos os capítulos anteriores.A diferença é que aqui temos desestruturação dupla: o Promise.all retorna um array, e cada item desse array também é um array. Parece confuso, mas funciona assim:
Promise.all retorna: [ [rows1, fields1], [rows2, fields2], ... ][ [prodRows], [catRows], ... ] pega o primeiro item de cada sub-array.S7.5 Implementação — O Controller Completo
Abra o arquivo:
Apague tudo e substitua:
const { pool } = require('../database/connection');
exports.getStats = async (req, res, next) => {
try {
const empresaId = req.user.empresa_id;
const [
[prodRows],
[catRows],
[estBaixoRows],
[movHojeRows],
[valorRows],
] = await Promise.all([
// 1. Total de produtos ativos
pool.query(
'SELECT COUNT(*) AS total FROM produtos WHERE empresa_id = ? AND ativo = 1',
[empresaId]
),
// 2. Total de categorias
pool.query(
'SELECT COUNT(*) AS total FROM categorias WHERE empresa_id = ?',
[empresaId]
),
// 3. Produtos com estoque baixo
pool.query(
`SELECT COUNT(*) AS total FROM produtos
WHERE empresa_id = ? AND ativo = 1
AND estoque_atual <= estoque_minimo`,
[empresaId]
),
// 4. Movimentações de hoje
pool.query(
'SELECT COUNT(*) AS total FROM movimentacoes WHERE empresa_id = ? AND DATE(created_at) = CURDATE()',
[empresaId]
),
// 5. Valor total do estoque (custo)
pool.query(
'SELECT SUM(preco_custo * estoque_atual) AS total FROM produtos WHERE empresa_id = ? AND ativo = 1',
[empresaId]
),
]);
res.json({
success: true,
data: {
totalProdutos: prodRows[0].total,
totalCategorias: catRows[0].total,
produtosEstoqueBaixo: estBaixoRows[0].total,
movimentacoesHoje: movHojeRows[0].total,
valorTotalEstoque: Number(valorRows[0].total) || 0,
},
});
} catch (error) {
next(error);
}
};
Vamos entender cada parte:
| Trecho | O que faz |
|---|---|
const empresaId = req.user.empresa_id |
Guarda em variável para não repetir req.user.empresa_id 5 vezes |
Promise.all([...]) |
Executa as 5 queries em paralelo — muito mais rápido que sequencial |
[prodRows] |
Desestrutura o [rows, fields] de cada query, pegando só rows |
prodRows[0].total |
COUNT retorna 1 linha: [{ total: 47 }]. O [0].total pega o 47 |
Number(valorRows[0].total) || 0 |
SUM pode retornar null se não tem produtos. O || 0 converte null para 0 |
Number(...) || 0 no valorTotalEstoque?
Quando a tabela não tem nenhum produto, SUM() retorna null (não zero!). É como somar uma lista vazia — o resultado não é 0, é "nada". O MySQL trabalha assim.O
Number(null) retorna NaN (Not a Number). E NaN || 0 retorna 0. Assim garantimos que o frontend sempre recebe um número válido.Os COUNTs não precisam disso porque
COUNT(*) de uma tabela vazia retorna 0, nunca null.const empresaId em vez de usar req.user.empresa_id direto?
É boa prática: se o nome mudar no futuro (ex: req.user.companyId), você muda em um lugar só. Além disso, o código fica mais limpo — [empresaId] é mais curto que [req.user.empresa_id] repetido 5 vezes.S7.6 A Rota — Uma Só
const express = require('express');
const router = express.Router();
const { authMiddleware } = require('../middlewares/auth');
const dashboardController = require('../controllers/dashboardController');
router.use(authMiddleware);
router.get('/stats', dashboardController.getStats); // GET /dashboard/stats
Uma única rota: GET /dashboard/stats. Sem parâmetros, sem body — só precisa do token.
/stats e não só /dashboard?
Porque no futuro você pode querer outras rotas no dashboard: /dashboard/chart (dados para gráficos), /dashboard/recent (últimas movimentações). Usando /stats como sub-rota, fica organizado.S7.7 Testando com curl
Faça login para pegar o token:
curl -X POST http://localhost:3737/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"joao@teste.com","senha":"123456"}'
Teste 1 — Buscar estatísticas
curl http://localhost:3737/dashboard/stats \
-H "Authorization: Bearer SEU_TOKEN"
Resposta esperada:
{
"success": true,
"data": {
"totalProdutos": 3,
"totalCategorias": 2,
"produtosEstoqueBaixo": 1,
"movimentacoesHoje": 2,
"valorTotalEstoque": 4500
}
}
empresa_id do token bate com os dados.Teste 2 — Sem token (deve falhar)
curl http://localhost:3737/dashboard/stats
Resposta (401): { "success": false, "error": "Token não fornecido" }
Teste 3 — Verificar os números manualmente
Confira cada estatística direto no banco:
mysql -u root -p saas_estoque -e "
SELECT 'produtos' AS tabela, COUNT(*) AS total FROM produtos WHERE empresa_id = 1 AND ativo = 1
UNION ALL
SELECT 'categorias', COUNT(*) FROM categorias WHERE empresa_id = 1
UNION ALL
SELECT 'estoque_baixo', COUNT(*) FROM produtos WHERE empresa_id = 1 AND ativo = 1 AND estoque_atual <= estoque_minimo
UNION ALL
SELECT 'mov_hoje', COUNT(*) FROM movimentacoes WHERE empresa_id = 1 AND DATE(created_at) = CURDATE();
"
UNION ALL junta os resultados de vários SELECTs em uma tabela só. Cada SELECT precisa ter o mesmo número de colunas. Usamos aqui só para verificação — no código real, cada query é separada.S7.8 Comparação: Todos os Controllers do Projeto
Com o dashboard pronto, vamos olhar o projeto como um todo. Cada controller tem sua personalidade:
| Controller | Rotas | Conceito principal |
|---|---|---|
| authController (S3) | 2 (register, login) | Bcrypt + JWT — segurança |
| categoriaController (S4) | 4 (CRUD completo) | Padrão CRUD básico |
| produtoController (S5) | 5 (CRUD + buscarPorId) | JOIN, .map(), soft delete |
| movimentacaoController (S6) | 2 (listar, criar) | Transação SQL, imutabilidade |
| dashboardController (S7) | 1 (getStats) | Agregação (COUNT/SUM) + Promise.all |
S7.5 Arquivo de Rotas — dashboardRoutes.js
Arquivo: backend/src/routes/dashboardRoutes.js
// backend/src/routes/dashboardRoutes.js
const express = require('express');
const router = express.Router();
const { authMiddleware } = require('../middlewares/auth');
const dashboardController = require('../controllers/dashboardController');
router.use(authMiddleware);
router.get('/', dashboardController.getStats);
module.exports = router;
✅ Aprendeu SUM() — somar valores de múltiplas linhas
✅ Aprendeu DATE() e CURDATE() — trabalhar com datas no MySQL
✅ Entendeu a diferença entre COUNT (retorna 0) e SUM (retorna null) para tabelas vazias
✅ Aprendeu Promise.all — executar queries em paralelo (5x mais rápido)
✅ Entendeu quando usar Promise.all (independentes) vs await sequencial (dependentes)
✅ Praticou desestruturação dupla —
[[rows1], [rows2]]✅ Entendeu por que usar
Number(...) || 0 como proteção contra null✅ Viu o projeto completo: 5 controllers, 14 rotas
✅ Testou e verificou os números no banco
Próximo passo: S8 — Frontend — conectar o React às APIs que construímos, usando fetch, useEffect e useState para trazer os dados para a tela.
Antes de conectar o frontend ao backend, precisamos entender os conceitos fundamentais do React que vão aparecer em todo o código do S8. Se você já fez os capítulos anteriores sobre React, use como revisão. Se é a primeira vez, leia com atenção.
O que é JSX
JSX é uma sintaxe que parece HTML mas está dentro do JavaScript. É assim que o React constrói interfaces:
// Isso é JSX — parece HTML, mas é JavaScript!
const MeuComponente = () => {
const nome = 'João';
return (
<div className="container">
<h1>Olá, {nome}!</h1> {/* chaves {} = código JS dentro do JSX */}
<p>Bem-vindo ao sistema.</p>
</div>
);
};
class → className (class é palavra reservada do JS)for → htmlFor (for é palavra reservada do JS){variavel} → insere o valor da variável no JSXTags devem ser sempre fechadas:
<img />, <br />useState — Guardar e Atualizar Dados
useState é a forma do React guardar informações que podem mudar. Quando o valor muda, a tela atualiza automaticamente.
import { useState } from 'react';
const Contador = () => {
// useState retorna um ARRAY com 2 itens:
// [0] = o valor atual
// [1] = a função para mudar o valor
const [numero, setNumero] = useState(0); // começa em 0
return (
<div>
<p>Contagem: {numero}</p>
<button onClick={() => setNumero(numero + 1)}>
Adicionar
</button>
</div>
);
};
numero é o que está escrito na lousa. setNumero é o apagador + giz — apaga o valor antigo e escreve o novo. Quando o professor (setNumero) muda o que está na lousa, todos os alunos (a tela) veem a mudança automaticamente.| Padrão | Exemplo | Tipo do valor |
|---|---|---|
const [valor, setValor] = useState(inicial) | const [nome, setNome] = useState('') | String vazia |
const [items, setItems] = useState([]) | Array vazio | |
const [loading, setLoading] = useState(false) | Boolean | |
const [dados, setDados] = useState(null) | Null (carregando) |
useEffect — Executar Código Quando a Página Abre
useEffect executa código em momentos específicos — por exemplo, quando a página abre pela primeira vez (para buscar dados da API).
import { useState, useEffect } from 'react';
const Dashboard = () => {
const [dados, setDados] = useState(null);
// useEffect(função, dependências)
// [] = array vazio de dependências = executar só 1 vez (ao abrir)
useEffect(() => {
const carregar = async () => {
const resposta = await fetch('/api/dashboard');
const json = await resposta.json();
setDados(json.data);
};
carregar();
}, []); // ← [] = só uma vez!
return <p>Total: {dados?.totalProdutos ?? 0}</p>;
};
[] no final é crucial!
useEffect(fn, []) = executa uma vez ao abrir a páginauseEffect(fn, [id]) = executa toda vez que id mudauseEffect(fn) = executa toda renderização (quase nunca é o que você quer!)Sem o
[], o fetch seria chamado infinitamente, travando o navegador.Componentes Controlados — Formulários no React
No React, inputs de formulário são controlados pelo estado:
const [email, setEmail] = useState('');
// value = o que aparece no input (controlado pelo estado)
// onChange = quando o usuário digita, atualiza o estado
// e = evento do navegador, e.target = o input, e.target.value = texto digitado
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
value + onChange?
Sem value, o React não sabe o que tem no input. Sem onChange, o input fica "congelado" (não aceita digitação). Os dois juntos criam um componente controlado — o React controla o valor, e toda mudança passa pelo estado.useContext — Dados Compartilhados entre Páginas
O Context é uma forma de compartilhar dados (como o usuário logado) entre todas as páginas, sem precisar passar por props de componente em componente.
// 1. Criar o contexto (a "caixa" que guarda os dados)
const AuthContext = createContext();
// 2. O Provider envolve toda a aplicação e fornece os dados
<AuthContext.Provider value={{ user, login, logout }}>
{children} {/* todas as páginas dentro têm acesso */}
</AuthContext.Provider>
// 3. Em qualquer página, pegar os dados com useContext
const { user, login } = useContext(AuthContext);
useContext é o celular conectando ao Wi-Fi — qualquer dispositivo (componente) dentro do prédio acessa a internet (dados) sem fio. children são os cômodos do prédio — tudo que está dentro do Provider recebe o sinal.children?
children são os componentes que estão dentro de outro componente. Exemplo:<AuthProvider> <App /> </AuthProvider>Aqui,
<App /> é o children do AuthProvider. No TypeScript, o tipo é ReactNode — significa "qualquer coisa que o React pode renderizar".React Router — Navegação entre Páginas
O React é uma SPA (Single Page Application) — só tem um index.html. As "páginas" são componentes que o React troca conforme a URL muda:
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
<BrowserRouter> {/* Habilita o roteamento */}
<Routes> {/* Grupo de rotas */}
<Route path="/login" element={<Login />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="*" element={<Navigate to="/login" />} />
</Routes>
</BrowserRouter>
| Conceito | O que faz |
|---|---|
<BrowserRouter> | Habilita a navegação — deve envolver toda a aplicação |
<Routes> | Define o grupo de rotas (qual URL mostra qual componente) |
<Route path="/x" element={...}> | Quando a URL for "/x", renderiza o componente |
<Navigate to="/login"> | Redireciona para outra URL (como um redirect) |
useNavigate() | Hook para navegar via código: navigate('/dashboard') |
<Link to="/x"> | Link que navega sem recarregar a página (substituição do <a href>) |
Tailwind CSS — Estilização por Classes
O template usa Tailwind CSS — uma biblioteca de CSS onde você estiliza com classes utilitárias diretamente no HTML/JSX:
{/* Em vez de escrever CSS em um arquivo separado... */}
<button className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700">
Salvar
</button>
| Classe Tailwind | CSS equivalente | O que faz |
|---|---|---|
bg-blue-600 | background-color: #2563eb | Fundo azul |
text-white | color: white | Texto branco |
px-4 | padding-left: 1rem; padding-right: 1rem | Padding horizontal |
py-2 | padding-top: 0.5rem; padding-bottom: 0.5rem | Padding vertical |
rounded-lg | border-radius: 0.5rem | Bordas arredondadas |
hover:bg-blue-700 | :hover { background-color: #1d4ed8 } | Cor ao passar o mouse |
md:grid-cols-2 | @media (min-width: 768px) { grid-template-columns: repeat(2, 1fr) } | 2 colunas em telas médias+ |
propriedade-valor (ex: text-red-500, mt-4, flex, grid). Com o tempo, você decora os mais usados.Até agora, todo o nosso trabalho foi no backend — testamos tudo com curl no terminal. Agora vamos fazer o frontend React se comunicar com a API. O usuário vai interagir com telas bonitas em vez de digitar comandos.
S8.1 Mapa dos Arquivos que Vamos Mexer
Antes de escrever código, vamos entender onde cada arquivo fica e por quê:
frontend/src/
├── main.tsx # Ponto de entrada — monta BrowserRouter + AuthProvider
├── App.tsx # Define as rotas (qual URL abre qual página)
├── services/
│ └── api.ts # Função apiFetch — o "garçom" que fala com o backend
├── contexts/
│ └── AuthContext.tsx # Estado global de autenticação (user, token, login, logout)
├── pages/
│ ├── Login.tsx # Tela de login — formulário
│ ├── Registro.tsx # Tela de registro — formulário
│ ├── Dashboard.tsx # Tela principal — cards com estatísticas
│ └── Categorias.tsx # CRUD de categorias — listagem + criar/editar/deletar
├── components/
│ └── StatCard.tsx # Card reutilizável do dashboard
└── types/
└── index.ts # Interfaces TypeScript (User, Produto, DashboardStats...)
| Pasta | Por que existe | Analogia |
|---|---|---|
services/ |
Funções que fazem requests HTTP. Separado das páginas para não misturar lógica de API com lógica de tela | O telefone do restaurante — como ligar para a cozinha |
contexts/ |
Estado global compartilhado entre páginas. O AuthContext guarda se o usuário está logado ou não | O quadro de avisos do restaurante — todos os garçons leem |
pages/ |
Cada arquivo = uma tela (rota). Login.tsx é a tela de login, Dashboard.tsx é a tela principal | Cada sala do restaurante — entrada, salão VIP, cozinha |
components/ |
Pedaços reutilizáveis de tela. StatCard aparece 5 vezes no Dashboard | Os pratos do cardápio — usados em várias mesas |
types/ |
Interfaces TypeScript — descrevem a forma dos dados (quais campos tem, que tipo são) | A ficha técnica de cada prato — ingredientes e medidas |
S8.2 O apiFetch — Entendendo o "Garçom"
O arquivo que faz a ponte entre o React e o Express já existe. Vamos entendê-lo linha por linha:
Por que neste caminho? Fica em
services/ porque é uma função utilitária que faz requests HTTP. Não é uma página, nem um componente visual — é um serviço. Qualquer página que precisar falar com o backend importa deste arquivo.const API_URL = import.meta.env.VITE_API_URL || '/api';
| Trecho | O que faz |
|---|---|
import.meta.env.VITE_API_URL |
Lê a variável de ambiente do Vite. Se existir, usa (ex: https://api.meusite.com) |
|| '/api' |
Se não existir, usa /api como padrão. O Vite tem um proxy que redireciona /api para http://localhost:3737 |
http://localhost:3737/produtos direto, o navegador bloqueia por CORS (segurança de origem cruzada).O proxy do Vite resolve: quando o frontend chama
/api/produtos, o Vite intercepta, remove o /api, e repassa para http://localhost:3737/produtos. Para o navegador, parece que tudo está na mesma origem.Isso está configurado em
frontend/vite.config.ts — por isso o /api funciona sem URL completa.export async function apiFetch<T>(
endpoint: string,
options: RequestInit = {}
): Promise<{ success: boolean; data: T; message?: string }> {
const token = localStorage.getItem('token');
const res = await fetch(`${API_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
const json = await res.json();
if (!res.ok) {
throw new Error(json.message || json.error || 'Erro na requisição');
}
return json;
}
| Trecho | O que faz |
|---|---|
localStorage.getItem('token') |
Pega o token JWT salvo no navegador. Se não tiver (não logou), é null |
fetch(`${API_URL}${endpoint}`) |
Faz o request HTTP. Ex: fetch('/api/produtos') |
...options |
Spread — permite passar method: 'POST', body, etc. |
token ? { Authorization: ... } : {} |
Se tem token, manda no header. Se não tem (login/registro), não manda |
res.ok |
true se o status HTTP é 200-299. false se é 400, 401, 404, 500... |
throw new Error(...) |
Se deu erro, lança exceção — quem chamou o apiFetch pega no catch |
localStorage?
É um armazenamento permanente do navegador. Dados gravados no localStorage ficam lá mesmo se o usuário fechar o navegador e abrir de novo. Usamos para guardar o token JWT — assim o usuário não precisa fazer login toda vez que abre o site.Analogia: localStorage é como um post-it colado na geladeira. Você anota o token, fecha a cozinha (fecha o navegador), e quando volta, o post-it ainda está lá.
<T> no apiFetch<T>?
É um generic do TypeScript. O T é um "curinga" que diz qual tipo de dado a API vai retornar. Quando chamamos:apiFetch<DashboardStats>('/dashboard/stats')O TypeScript sabe que
data vai ter os campos de DashboardStats (totalProdutos, totalCategorias, etc.). Se você tentar acessar data.campoQueNaoExiste, o editor mostra erro antes de rodar. É segurança em tempo de desenvolvimento.S8.3 AuthContext — Login que Funciona
O AuthContext é o estado global de autenticação. Todas as páginas sabem se o usuário está logado ou não através dele.
Por que neste caminho? Fica em
contexts/ porque é um Context do React — estado compartilhado entre todos os componentes. Não é uma página nem um serviço. A convenção é: contexts numa pasta, services em outra, pages em outra.O stub atual tem a função login com um console.log. Vamos trocar pelo código real. Apague todo o conteúdo e substitua:
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { User } from '../types';
import { apiFetch } from '../services/api';
createContext, useContext, useState, useEffect — do React. São hooks e funções nativas.User — do nosso arquivo types/index.ts. É a interface que descreve a forma do usuário.apiFetch — do nosso arquivo services/api.ts. É o "garçom" que fala com o backend.interface AuthContextType {
user: User | null;
token: string | null;
login: (email: string, senha: string) => Promise<void>;
register: (nome: string, email: string, senha: string, empresa_nome: string) => Promise<void>;
logout: () => void;
isAuthenticated: boolean;
}
const AuthContext = createContext<AuthContextType | null>(null);
interface AuthContextType descreve o "contrato" do contexto — quais valores e funções ele disponibiliza. Toda página que usar useAuth() terá acesso a user, token, login(), register(), logout() e isAuthenticated. Se você tentar acessar algo fora do contrato, o TypeScript avisa.export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(
localStorage.getItem('token')
);
// Ao abrir o site, se já tem token salvo, busca os dados do usuário
useEffect(() => {
if (token) {
apiFetch<User>('/auth/me')
.then(res => setUser(res.data))
.catch(() => { logout(); });
}
}, []);
// Função que salva o token e o usuário após login ou registro
const handleAuth = (data: { token: string; user: User }) => {
localStorage.setItem('token', data.token);
setToken(data.token);
setUser(data.user);
};
const login = async (email: string, senha: string) => {
const response = await apiFetch<{ token: string; user: User }>(
'/auth/login',
{
method: 'POST',
body: JSON.stringify({ email, senha }),
}
);
handleAuth(response.data);
};
const register = async (
nome: string, email: string, senha: string, empresa_nome: string
) => {
const response = await apiFetch<{ token: string; user: User }>(
'/auth/register',
{
method: 'POST',
body: JSON.stringify({ nome, email, senha, empresa_nome }),
}
);
handleAuth(response.data);
};
const logout = () => {
setUser(null);
setToken(null);
localStorage.removeItem('token');
};
return (
<AuthContext.Provider
value={{ user, token, login, register, logout, isAuthenticated: !!token }}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth deve ser usado dentro de AuthProvider');
return context;
}
| Trecho | O que faz |
|---|---|
useState(localStorage.getItem('token')) |
Ao abrir o site, verifica se já tem token salvo (sessão anterior) |
useEffect + apiFetch('/auth/me') |
Se tem token salvo, busca os dados do usuário no backend. Se o token expirou ou é inválido, faz logout automático |
handleAuth(data) |
Função interna: salva token no localStorage + atualiza estados |
apiFetch('/auth/login', { method: 'POST', body: ... }) |
Chama POST /auth/login no backend — o mesmo que testamos com curl no S3 |
JSON.stringify({ email, senha }) |
Converte o objeto JS para texto JSON — o body do fetch precisa ser string |
!!token |
Converte para booleano: se tem token → true, se é null → false |
useAuth() |
Hook customizado — qualquer componente chama useAuth() para acessar login, logout, user |
JSON.stringify()?
O fetch envia o body como texto. Mas nossos dados são um objeto JavaScript: { email: "joao@teste.com", senha: "123456" }. O JSON.stringify() transforma o objeto em texto JSON: '{"email":"joao@teste.com","senha":"123456"}'.Do outro lado, o Express faz o inverso com
express.json() — transforma o texto de volta em objeto e coloca em req.body.!! (dupla negação)?
É um truque do JavaScript para converter qualquer valor em true ou false:!!null → false!!"abc" → true!!"" → false!!0 → falseUsamos
!!token para dizer: "se tem token, o usuário está autenticado".useEffect com /auth/me?
Sem ele, ao recarregar a página (F5), o token estaria salvo no localStorage, mas user seria null. Resultado: isAuthenticated seria true (tem token), mas user.nome daria erro (null). O useEffect resolve: ao abrir o site, usa o token salvo para buscar os dados do usuário no backend (GET /auth/me). Se o token expirou, o .catch chama logout() e redireciona para login.Importante: essa rota
GET /auth/me precisa existir no backend. Adicione no authController.js:
// Adicionar no authController.js — retorna dados do usuário logado
exports.me = async (req, res, next) => {
try {
const [rows] = await pool.query(
'SELECT id, nome, email, role, empresa_id FROM users WHERE id = ? AND ativo = 1',
[req.user.id]
);
if (rows.length === 0) return res.status(404).json({ success: false, error: 'Usuário não encontrado' });
res.json({ success: true, data: rows[0] });
} catch (error) { next(error); }
};
// Adicionar no authRoutes.js — rota protegida (precisa do token)
router.get('/me', authMiddleware, authController.me);
S8.4 Login.tsx — O Formulário que Conecta
Por que neste caminho? Fica em
pages/ porque é uma tela completa que corresponde a uma rota (/login). Cada arquivo em pages/ = uma URL no navegador.O stub atual tem console.log('Login'). Vamos conectar ao AuthContext. Apague tudo e substitua:
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
export default function Login() {
const [email, setEmail] = useState('');
const [senha, setSenha] = useState('');
const [erro, setErro] = useState('');
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErro('');
setLoading(true);
try {
await login(email, senha);
navigate('/');
} catch (err: any) {
setErro(err.message);
} finally {
setLoading(false);
}
};
| Trecho | O que faz |
|---|---|
useAuth() |
Pega a função login do AuthContext |
useNavigate() |
Hook do React Router para redirecionar. navigate('/') vai para o Dashboard |
e.preventDefault() |
Impede o formulário de recarregar a página (comportamento padrão do HTML) |
setLoading(true) |
Mostra estado de "carregando" enquanto espera a API |
await login(email, senha) |
Chama o AuthContext → que chama apiFetch → que chama o backend |
navigate('/') |
Se o login deu certo, redireciona para o Dashboard |
catch (err) |
Se deu erro (senha errada, servidor offline), mostra a mensagem |
finally { setLoading(false) } |
Deu certo ou não, desliga o "carregando" |
handleSubmit roda2.
handleSubmit chama login(email, senha) do AuthContext3. AuthContext chama
apiFetch('/auth/login', { method: 'POST', body: ... })4. apiFetch faz
fetch para /api/auth/login5. O proxy do Vite redireciona para
http://localhost:3737/auth/login6. O Express recebe, roda
authController.login, retorna { token, user }7. O apiFetch retorna o JSON para o AuthContext
8. O AuthContext salva token no localStorage e user no state
9. O Login.tsx faz
navigate('/') → vai para o Dashboard9 passos — mas para o usuário, é só digitar email/senha e clicar. Toda essa cadeia acontece em milissegundos.
A parte visual (o return com JSX) fica quase igual, mas adicionamos erro e loading:
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">Estoque Pro</h1>
<p className="text-gray-500 mt-2">Faça login para continuar</p>
</div>
<form onSubmit={handleSubmit} className="bg-white rounded-xl border border-gray-200 p-8 space-y-4">
{/* Mensagem de erro */}
{erro && (
<div className="bg-red-50 text-red-600 text-sm p-3 rounded-lg">
{erro}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Senha</label>
<input
type="password"
value={senha}
onChange={(e) => setSenha(e.target.value)}
required
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-primary-600 text-white py-2.5 rounded-lg text-sm font-medium hover:bg-primary-700 disabled:opacity-50"
>
{loading ? 'Entrando...' : 'Entrar'}
</button>
<p className="text-center text-sm text-gray-500">
Não tem conta?{' '}
<Link to="/registro" className="text-primary-600 hover:underline font-medium">
Criar conta
</Link>
</p>
</form>
</div>
</div>
);
}
| Trecho novo | O que faz |
|---|---|
{erro && (<div>...</div>)} |
Renderização condicional: se erro não está vazio, mostra a div vermelha com a mensagem |
disabled={loading} |
Desabilita o botão enquanto a API responde — impede cliques duplos |
{loading ? 'Entrando...' : 'Entrar'} |
Muda o texto do botão durante o loading (ternário) |
required |
Validação HTML nativa — o navegador impede submit com campo vazio |
{erro && (...)}?
É um padrão do React chamado renderização condicional. O && funciona assim:Se
erro é "" (vazio) → JavaScript considera false → não renderiza nadaSe
erro é "Email ou senha incorretos" → JavaScript considera true → renderiza a divÉ como um
if inline: "se erro existe, mostre isso". Muito usado no React para mostrar/esconder elementos.S8.5 Dashboard.tsx — useEffect e useState na Prática
Por que neste caminho? É uma página (rota
/). Quando o usuário faz login, cai aqui. Mostra os 5 cards de estatísticas que o GET /dashboard/stats retorna.O stub mostra zeros fixos. Vamos buscar dados reais da API. Apague tudo e substitua:
import { useState, useEffect } from 'react';
import { Package, FolderOpen, AlertTriangle, ArrowLeftRight, DollarSign } from 'lucide-react';
import StatCard from '../components/StatCard';
import { apiFetch } from '../services/api';
import { DashboardStats } from '../types';
useState, useEffect — hooks do React (veremos a seguir)Package, FolderOpen, ... — ícones da biblioteca lucide-reactStatCard — nosso componente de card (já existe em components/)apiFetch — nosso "garçom" (de services/api.ts)DashboardStats — interface TypeScript (de types/index.ts)export default function Dashboard() {
const [stats, setStats] = useState<DashboardStats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function carregarStats() {
try {
const response = await apiFetch<DashboardStats>('/dashboard/stats');
setStats(response.data);
} catch (err) {
console.error('Erro ao carregar dashboard:', err);
} finally {
setLoading(false);
}
}
carregarStats();
}, []);
useEffect é um hook do React que executa código em momentos específicos. Com [] (array vazio) no final, ele roda uma vez quando o componente aparece na tela.Analogia: é como uma instrução "quando abrir a loja de manhã, busque o relatório do dia". Não busca toda hora — só quando abre. O
[] vazio significa "só na abertura".Sem o
[], o useEffect rodaria em toda re-renderização — fazendo dezenas de requests desnecessários.| Trecho | O que faz |
|---|---|
useState<DashboardStats | null>(null) |
stats começa como null (sem dados). Depois do fetch, recebe o objeto |
useState(true) |
loading começa como true — a página está carregando |
useEffect(() => { ... }, []) |
Quando o Dashboard aparecer na tela, execute a função dentro |
async function carregarStats() |
Função interna (necessária porque o callback do useEffect não pode ser async direto) |
apiFetch<DashboardStats>('/dashboard/stats') |
Chama GET /dashboard/stats — o mesmo endpoint do S7 |
setStats(response.data) |
Salva os dados no state. O React re-renderiza a tela com os novos dados |
carregarStats() dentro do useEffect?
O callback do useEffect não pode ser async diretamente:useEffect(async () => { ... }, []) — ERRADO (React não aceita)useEffect(() => { async function f() { ... } f(); }, []) — CERTOA solução é criar a função async dentro do useEffect e chamá-la imediatamente. É uma limitação técnica do React.
Agora a parte visual — o return com os cards:
if (loading) {
return <p className="text-gray-400 p-8">Carregando...</p>;
}
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-6">Dashboard</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4 mb-8">
<StatCard title="Total Produtos" value={stats?.totalProdutos ?? 0} icon={Package} />
<StatCard title="Categorias" value={stats?.totalCategorias ?? 0} icon={FolderOpen} color="text-green-600" />
<StatCard title="Estoque Baixo" value={stats?.produtosEstoqueBaixo ?? 0} icon={AlertTriangle} color="text-amber-600" />
<StatCard title="Mov. Hoje" value={stats?.movimentacoesHoje ?? 0} icon={ArrowLeftRight} color="text-blue-600" />
<StatCard
title="Valor em Estoque"
value={`R$ ${(stats?.valorTotalEstoque ?? 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 })}`}
icon={DollarSign}
color="text-emerald-600"
/>
</div>
</div>
);
}
| Trecho | O que faz |
|---|---|
if (loading) return ... |
Early return: enquanto carrega, mostra "Carregando..." em vez dos cards vazios |
stats?.totalProdutos |
Optional chaining (?.): se stats é null, retorna undefined em vez de dar erro |
?? 0 |
Nullish coalescing: se o valor é null ou undefined, usa 0 como padrão |
toLocaleString('pt-BR', ...) |
Formata número no padrão brasileiro: 4500 → "4.500,00" |
?. (optional chaining)?
Se stats é null e você escreve stats.totalProdutos, dá erro: "Cannot read property of null". Com stats?.totalProdutos, se stats é null, retorna undefined sem erro.É como perguntar educadamente: "se o relatório existe, me diz o total de produtos?". Se não existe, a resposta é "não sei" (undefined) em vez de explodir.
?? (nullish coalescing)?
Parecido com ||, mas só considera null e undefined como "vazio":0 || 5 → 5 (porque 0 é falsy — ruim! zero é um número válido)0 ?? 5 → 0 (porque 0 não é null/undefined — bom!)Usamos
?? em vez de || porque se o total de produtos for 0, queremos mostrar 0, não 5.S8.6 App.tsx — Protegendo as Rotas
Por que neste caminho? É o componente raiz da aplicação. Define qual página aparece para qual URL. Fica na raiz de
src/ porque é o "mapa" de todo o app.Hoje, qualquer pessoa acessa o Dashboard sem login. Vamos proteger as rotas:
import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuth } from './contexts/AuthContext';
import AppLayout from './components/AppLayout';
import Dashboard from './pages/Dashboard';
import Produtos from './pages/Produtos';
import Movimentacoes from './pages/Movimentacoes';
import Categorias from './pages/Categorias';
import Usuarios from './pages/Usuarios';
import Configuracoes from './pages/Configuracoes';
import Login from './pages/Login';
import Registro from './pages/Registro';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuth();
if (!isAuthenticated) return <Navigate to="/login" />;
return children;
}
export default function App() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/registro" element={<Registro />} />
<Route element={<ProtectedRoute><AppLayout /></ProtectedRoute>}>
<Route path="/" element={<Dashboard />} />
<Route path="/produtos" element={<Produtos />} />
<Route path="/movimentacoes" element={<Movimentacoes />} />
<Route path="/categorias" element={<Categorias />} />
<Route path="/usuarios" element={<Usuarios />} />
<Route path="/configuracoes" element={<Configuracoes />} />
</Route>
</Routes>
);
}
| Trecho | O que faz |
|---|---|
ProtectedRoute |
Componente que verifica se o usuário está logado. Se não está, redireciona para /login |
<Navigate to="/login" /> |
Componente do React Router que redireciona automaticamente |
<ProtectedRoute><AppLayout /></ProtectedRoute> |
Envolve TODAS as rotas internas — se não logou, nenhuma é acessível |
ProtectedRoute é como a catraca na entrada da empresa. Se você tem crachá (token), passa. Se não tem, é redirecionado para a recepção (login). As salas internas (Dashboard, Produtos, etc.) ficam todas atrás da catraca.S8.7 Categorias.tsx — CRUD Completo no Frontend
Por que neste caminho? É a página da rota
/categorias. Mostra a lista de categorias e permite criar, editar e deletar. É o primeiro CRUD completo no frontend — mesmo padrão que vamos repetir para Produtos.O stub mostra "Nenhuma categoria cadastrada". Vamos trocar por uma página funcional com listagem, formulário de criação e botões de editar/excluir:
import { useState, useEffect } from 'react';
import { Plus, Pencil, Trash2 } from 'lucide-react';
import { apiFetch } from '../services/api';
import { Categoria } from '../types';
export default function Categorias() {
const [categorias, setCategorias] = useState<Categoria[]>([]);
const [loading, setLoading] = useState(true);
const [nome, setNome] = useState('');
const [descricao, setDescricao] = useState('');
const [editandoId, setEditandoId] = useState<number | null>(null);
const [erro, setErro] = useState('');
categorias — a lista que veio da API (para mostrar na tela)loading — se está carregando (para mostrar "Carregando...")nome e descricao — os campos do formulárioeditandoId — se é null, estamos criando. Se é um número, estamos editando esse IDerro — mensagem de erro para mostrar ao usuárioCada pedaço de informação que pode mudar na tela precisa de um
useState. // Carregar categorias ao abrir a página
const carregarCategorias = async () => {
try {
const response = await apiFetch<Categoria[]>('/categorias');
setCategorias(response.data);
} catch (err: any) {
setErro(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => { carregarCategorias(); }, []);
carregarCategorias é uma função separada?
Porque vamos chamá-la em dois lugares: no useEffect (ao abrir a página) e depois de criar/editar/deletar (para atualizar a lista). Se estivesse dentro do useEffect, não conseguiríamos chamá-la de novo. // Criar ou editar categoria
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErro('');
try {
if (editandoId) {
// Editando — PUT /categorias/:id
await apiFetch(`/categorias/${editandoId}`, {
method: 'PUT',
body: JSON.stringify({ nome, descricao }),
});
} else {
// Criando — POST /categorias
await apiFetch('/categorias', {
method: 'POST',
body: JSON.stringify({ nome, descricao }),
});
}
// Limpar formulário e recarregar lista
setNome('');
setDescricao('');
setEditandoId(null);
await carregarCategorias();
} catch (err: any) {
setErro(err.message);
}
};
| Trecho | O que faz |
|---|---|
if (editandoId) |
Se tem ID, é edição (PUT). Se não tem, é criação (POST) |
`/categorias/${editandoId}` |
Template literal: monta a URL com o ID. Ex: /categorias/5 |
await carregarCategorias() |
Depois de salvar, recarrega a lista para mostrar a alteração |
// Preencher formulário para edição
const iniciarEdicao = (cat: Categoria) => {
setEditandoId(cat.id);
setNome(cat.nome);
setDescricao(cat.descricao || '');
};
// Deletar categoria
const handleDeletar = async (id: number) => {
if (!confirm('Tem certeza que deseja excluir esta categoria?')) return;
try {
await apiFetch(`/categorias/${id}`, { method: 'DELETE' });
await carregarCategorias();
} catch (err: any) {
setErro(err.message);
}
};
confirm()?
É uma função nativa do navegador que mostra uma caixa de diálogo: "Tem certeza? [OK] [Cancelar]". Retorna true se o usuário clicou OK, false se cancelou. Usamos para evitar deletar sem querer.O
if (!confirm(...)) return; significa: "se o usuário cancelou, não faz nada". Só continua para o DELETE se confirmou.E a parte visual (o return):
if (loading) return <p className="text-gray-400 p-8">Carregando...</p>;
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-6">Categorias</h1>
{erro && (
<div className="bg-red-50 text-red-600 text-sm p-3 rounded-lg mb-4">{erro}</div>
)}
{/* Formulário de criar/editar */}
<form onSubmit={handleSubmit} className="bg-white rounded-xl border border-gray-200 p-6 mb-6">
<h2 className="text-lg font-semibold mb-4">
{editandoId ? 'Editar Categoria' : 'Nova Categoria'}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<input
value={nome}
onChange={(e) => setNome(e.target.value)}
placeholder="Nome da categoria"
required
className="px-4 py-2.5 border border-gray-300 rounded-lg text-sm"
/>
<input
value={descricao}
onChange={(e) => setDescricao(e.target.value)}
placeholder="Descrição (opcional)"
className="px-4 py-2.5 border border-gray-300 rounded-lg text-sm"
/>
</div>
<div className="flex gap-2 mt-4">
<button type="submit" className="bg-primary-600 text-white px-6 py-2.5 rounded-lg text-sm font-medium hover:bg-primary-700">
{editandoId ? 'Salvar' : 'Criar'}
</button>
{editandoId && (
<button type="button" onClick={() => { setEditandoId(null); setNome(''); setDescricao(''); }}
className="bg-gray-200 text-gray-700 px-6 py-2.5 rounded-lg text-sm">
Cancelar
</button>
)}
</div>
</form>
{/* Lista de categorias */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{categorias.map(cat => (
<div key={cat.id} className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="font-semibold text-gray-900">{cat.nome}</h3>
<p className="text-sm text-gray-500 mt-1">{cat.descricao || 'Sem descrição'}</p>
<div className="flex gap-2 mt-4">
<button onClick={() => iniciarEdicao(cat)} className="text-blue-600 hover:bg-blue-50 p-2 rounded-lg">
<Pencil size={16} />
</button>
<button onClick={() => handleDeletar(cat.id)} className="text-red-600 hover:bg-red-50 p-2 rounded-lg">
<Trash2 size={16} />
</button>
</div>
</div>
))}
</div>
</div>
);
}
.map() de novo — agora no React!
No S5, usamos .map() no backend para adicionar campos calculados. Aqui usamos no frontend para transformar um array de dados em componentes visuais:[{id:1, nome:'Roupas'}, {id:2, nome:'Eletrônicos'}]↓
.map()[<div>Roupas</div>, <div>Eletrônicos</div>]Cada item do array vira um card na tela. 10 categorias = 10 cards. É o uso mais comum do
.map() no React.key={cat.id}?
Quando você renderiza uma lista com .map(), o React pede um key único em cada item. O key ajuda o React a saber qual item mudou quando a lista atualiza. Sem key, o React re-renderiza todos; com key, só re-renderiza o que mudou.Sempre use o
id do banco como key — é único e estável.S8.8 Resumo — O Fluxo Completo Frontend ↔ Backend
Usuário clica → React chama função → apiFetch → Proxy Vite → Express → MySQL
MySQL → Express → JSON → apiFetch → setState → React re-renderiza tela
| Arquivo | Caminho | Papel no fluxo |
|---|---|---|
api.ts |
frontend/src/services/api.ts |
O garçom — faz fetch, manda token, trata erros |
AuthContext.tsx |
frontend/src/contexts/AuthContext.tsx |
O crachá global — sabe se logou, guarda token |
App.tsx |
frontend/src/App.tsx |
A catraca — protege rotas, redireciona para login |
Login.tsx |
frontend/src/pages/Login.tsx |
A recepção — formulário + chama AuthContext.login |
Dashboard.tsx |
frontend/src/pages/Dashboard.tsx |
O painel — useEffect + apiFetch para estatísticas |
Categorias.tsx |
frontend/src/pages/Categorias.tsx |
O CRUD visual — lista + formulário + editar/deletar |
S8.9 Arquivos de Suporte que Faltam
Os arquivos acima (api.ts, AuthContext, Login, Dashboard, App, Categorias) dependem de outros arquivos de suporte. Aqui estão todos eles, completos:
Arquivo de Tipos — frontend/src/types/index.ts
Caminho: frontend/src/types/index.ts — fica em types/ porque define as estruturas de dados usadas em todo o frontend. O TypeScript exige saber o "formato" dos dados.
// frontend/src/types/index.ts
// Define o formato dos dados que a API retorna
export interface User {
id: number;
nome: string;
email: string;
role: 'admin' | 'gerente' | 'estoquista';
empresa_id: number;
}
export interface Categoria {
id: number;
nome: string;
descricao?: string; // ? = opcional (pode ser null/undefined)
empresa_id: number;
}
export interface Produto {
id: number;
nome: string;
descricao?: string;
sku?: string;
categoria_id?: number;
categoria_nome?: string;
preco_custo: number;
preco_venda: number;
estoque_atual: number;
estoque_minimo: number;
estoque_status?: string;
unidade: string;
}
export interface DashboardStats {
totalProdutos: number;
totalCategorias: number;
produtosEstoqueBaixo: number;
movimentacoesHoje: number;
valorTotalEstoque: number;
}
interface?
Uma interface é o "contrato" de um objeto — define quais campos ele tem e o tipo de cada um. Se a API retornar um campo que não está na interface, o TypeScript avisa. Se você tentar usar um campo que não existe, o TypeScript avisa. É como um formulário pré-impresso: os campos já estão definidos, você só preenche.Configuração do Vite — frontend/vite.config.ts
Caminho: frontend/vite.config.ts — na raiz do frontend porque é a configuração do bundler (Vite).
// frontend/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5174, // Porta do frontend em desenvolvimento
host: '0.0.0.0', // Acessível na rede local
proxy: {
'/api': {
target: 'http://localhost:3737', // Encaminha para o backend
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''), // Remove /api do caminho
},
},
},
});
fetch('/api/produtos'), o Vite intercepta e encaminha para http://localhost:3737/produtos (remove o /api). Sem isso, o navegador tentaria chamar localhost:5174/api/produtos — que não existe. Em produção, o Nginx faz o mesmo papel.Ponto de Entrada — frontend/src/main.tsx
Caminho: frontend/src/main.tsx — é o primeiro arquivo que o Vite carrega. Monta o React e envolve tudo com os Providers.
// frontend/src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter> {/* Habilita o React Router */}
<AuthProvider> {/* Compartilha dados de auth com toda a app */}
<App /> {/* O componente principal */}
</AuthProvider>
</BrowserRouter>
</React.StrictMode>
);
BrowserRouter envolve AuthProvider que envolve App. Isso porque o AuthProvider pode precisar de useNavigate() (que vem do Router), e o App precisa dos dados do AuthProvider. De fora para dentro: Router → Auth → App. Se inverter, dá erro.Componente StatCard — frontend/src/components/StatCard.tsx
Caminho: frontend/src/components/StatCard.tsx — fica em components/ porque é um componente reutilizável (usado no Dashboard).
// frontend/src/components/StatCard.tsx
interface StatCardProps {
title: string;
value: string | number;
icon: React.ReactNode; // Qualquer coisa renderizável (ícone, texto, etc.)
color?: string; // Classe Tailwind para a cor
}
const StatCard = ({ title, value, icon, color = 'bg-blue-500' }: StatCardProps) => {
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">{title}</p>
<p className="text-2xl font-bold mt-1">{value}</p>
</div>
<div className={`${color} p-3 rounded-lg text-white`}>
{icon}
</div>
</div>
</div>
);
};
export default StatCard;
Layout da Aplicação — frontend/src/components/AppLayout.tsx
Caminho: frontend/src/components/AppLayout.tsx — componente de layout que envolve todas as páginas autenticadas (sidebar + conteúdo).
// frontend/src/components/AppLayout.tsx
import { Outlet, Link, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { LayoutDashboard, Package, FolderOpen, ArrowLeftRight, LogOut } from 'lucide-react';
const AppLayout = () => {
const { user, logout } = useAuth();
const location = useLocation(); // Saber qual rota está ativa
const menu = [
{ path: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ path: '/categorias', label: 'Categorias', icon: FolderOpen },
{ path: '/produtos', label: 'Produtos', icon: Package },
{ path: '/movimentacoes', label: 'Movimentações', icon: ArrowLeftRight },
];
return (
<div className="flex min-h-screen bg-gray-50">
{/* Sidebar */}
<aside className="w-64 bg-white border-r shadow-sm">
<div className="p-4 border-b">
<h1 className="text-xl font-bold text-gray-800">Estoque Pro</h1>
<p className="text-xs text-gray-500">{user?.nome}</p>
</div>
<nav className="p-2">
{menu.map((item) => (
<Link
key={item.path}
to={item.path}
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm
${location.pathname === item.path
? 'bg-blue-50 text-blue-700 font-medium'
: 'text-gray-600 hover:bg-gray-100'}`}
>
<item.icon size={18} />
{item.label}
</Link>
))}
</nav>
<div className="absolute bottom-4 left-4">
<button onClick={logout} className="flex items-center gap-2 text-sm text-gray-500 hover:text-red-600">
<LogOut size={16} /> Sair
</button>
</div>
</aside>
{/* Conteúdo principal */}
<main className="flex-1 p-6">
<Outlet /> {/* Aqui dentro renderiza a página da rota ativa */}
</main>
</div>
);
};
export default AppLayout;
<Outlet />?
É um "espaço reservado" do React Router. Quando o App.tsx define rotas aninhadas dentro de um layout, o <Outlet /> é substituído pelo componente da rota ativa. Se a URL for /dashboard, o Outlet mostra o Dashboard. Se for /categorias, mostra Categorias. O layout (sidebar, header) permanece fixo.lucide-react?
Lucide é uma biblioteca de ícones SVG para React. Cada ícone é um componente: <Package size={18} /> renderiza um ícone de pacote com 18px. A biblioteca já vem no template (package.json). Se precisar de outros ícones, consulte: lucide.dev/icons.Página de Registro — frontend/src/pages/Registro.tsx
Caminho: frontend/src/pages/Registro.tsx — segue o mesmo padrão do Login.
// frontend/src/pages/Registro.tsx
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
const Registro = () => {
const [nome, setNome] = useState('');
const [email, setEmail] = useState('');
const [senha, setSenha] = useState('');
const [empresaNome, setEmpresaNome] = useState('');
const [erro, setErro] = useState('');
const [loading, setLoading] = useState(false);
const { register } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); // Impede o form de recarregar a página
setErro('');
setLoading(true);
try {
await register(nome, email, senha, empresaNome);
navigate('/dashboard');
} catch (err: any) {
setErro(err.message || 'Erro ao registrar');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<form onSubmit={handleSubmit} className="bg-white p-8 rounded-xl shadow-sm w-full max-w-md">
<h1 className="text-2xl font-bold mb-6">Criar Conta</h1>
{erro && <div className="bg-red-50 text-red-600 p-3 rounded-lg mb-4 text-sm">{erro}</div>}
<input type="text" placeholder="Seu nome" value={nome}
onChange={(e) => setNome(e.target.value)} required
className="w-full border rounded-lg p-3 mb-3" />
<input type="email" placeholder="Seu email" value={email}
onChange={(e) => setEmail(e.target.value)} required
className="w-full border rounded-lg p-3 mb-3" />
<input type="password" placeholder="Sua senha" value={senha}
onChange={(e) => setSenha(e.target.value)} required
className="w-full border rounded-lg p-3 mb-3" />
<input type="text" placeholder="Nome da empresa" value={empresaNome}
onChange={(e) => setEmpresaNome(e.target.value)} required
className="w-full border rounded-lg p-3 mb-4" />
<button type="submit" disabled={loading}
className="w-full bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700 disabled:opacity-50">
{loading ? 'Criando...' : 'Criar Conta'}
</button>
<p className="text-center mt-4 text-sm text-gray-500">
Já tem conta? <Link to="/login" className="text-blue-600">Entrar</Link>
</p>
</form>
</div>
);
};
export default Registro;
S8.10 Produtos.tsx — CRUD Completo com Select de Categoria
Mesma pasta que Categorias — é uma página da rota
/produtos. A diferença: tem mais campos, precisa carregar categorias para o <select>, e mostra estoque com cores (verde/amarelo/vermelho).O padrão é o mesmo do Categorias.tsx: estados, useEffect, handleSubmit, handleDeletar, .map(). As novidades são: <select> para categoria, campos numéricos, e badge de estoque.
import { useState, useEffect } from 'react';
import { Plus, Pencil, Trash2, Package } from 'lucide-react';
import { apiFetch } from '../services/api';
import { Produto, Categoria } from '../types';
export default function Produtos() {
const [produtos, setProdutos] = useState<Produto[]>([]);
const [categorias, setCategorias] = useState<Categoria[]>([]);
const [loading, setLoading] = useState(true);
const [erro, setErro] = useState('');
const [editandoId, setEditandoId] = useState<number | null>(null);
// Campos do formulário
const [nome, setNome] = useState('');
const [descricao, setDescricao] = useState('');
const [sku, setSku] = useState('');
const [categoriaId, setCategoriaId] = useState('');
const [precoCusto, setPrecoCusto] = useState('');
const [precoVenda, setPrecoVenda] = useState('');
const [estoqueMinimo, setEstoqueMinimo] = useState('');
const [unidade, setUnidade] = useState('un');
string e não number?
Inputs HTML sempre retornam strings. O e.target.value de um <input type="number"> é "19.90" (string), não 19.90 (number). Convertemos para number só na hora de enviar: parseFloat(precoCusto). Se usássemos useState(0), o input mostraria "0" ao abrir — ruim para UX. const carregarDados = async () => {
try {
const [prodRes, catRes] = await Promise.all([
apiFetch<Produto[]>('/produtos'),
apiFetch<Categoria[]>('/categorias'),
]);
setProdutos(prodRes.data);
setCategorias(catRes.data);
} catch (err: any) { setErro(err.message); }
finally { setLoading(false); }
};
useEffect(() => { carregarDados(); }, []);
<select>). O Promise.all dispara as duas ao mesmo tempo e espera ambas terminarem. Se fizéssemos sequencial (primeiro produtos, depois categorias), levaria o dobro do tempo. const limparForm = () => {
setNome(''); setDescricao(''); setSku(''); setCategoriaId('');
setPrecoCusto(''); setPrecoVenda(''); setEstoqueMinimo('');
setUnidade('un'); setEditandoId(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErro('');
const body = {
nome, descricao, sku, unidade,
categoria_id: categoriaId ? Number(categoriaId) : null,
preco_custo: parseFloat(precoCusto) || 0,
preco_venda: parseFloat(precoVenda) || 0,
estoque_minimo: parseInt(estoqueMinimo) || 0,
};
try {
if (editandoId) {
await apiFetch(`/produtos/${editandoId}`, {
method: 'PUT', body: JSON.stringify(body),
});
} else {
await apiFetch('/produtos', {
method: 'POST', body: JSON.stringify(body),
});
}
limparForm();
await carregarDados();
} catch (err: any) { setErro(err.message); }
};
const iniciarEdicao = (p: Produto) => {
setEditandoId(p.id); setNome(p.nome); setDescricao(p.descricao || '');
setSku(p.sku || ''); setCategoriaId(p.categoria_id?.toString() || '');
setPrecoCusto(p.preco_custo.toString()); setPrecoVenda(p.preco_venda.toString());
setEstoqueMinimo(p.estoque_minimo.toString()); setUnidade(p.unidade);
};
const handleDeletar = async (id: number) => {
if (!confirm('Tem certeza que deseja excluir este produto?')) return;
try {
await apiFetch(`/produtos/${id}`, { method: 'DELETE' });
await carregarDados();
} catch (err: any) { setErro(err.message); }
};
| Novidade em relação a Categorias | O que faz |
|---|---|
Promise.all([apiFetch(...), apiFetch(...)]) |
Carrega produtos e categorias em paralelo |
Number(categoriaId) / parseFloat(precoCusto) |
Converte string do input para número antes de enviar |
limparForm() |
Com 8 campos, limpar um por um no handleSubmit ficaria grande demais. Função separada. |
p.categoria_id?.toString() |
Optional chaining: se categoria_id for null, retorna undefined em vez de erro |
Agora a parte visual (o return):
if (loading) return <p className="text-gray-400 p-8">Carregando...</p>;
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-6">Produtos</h1>
{erro && (
<div className="bg-red-50 text-red-600 text-sm p-3 rounded-lg mb-4">{erro}</div>
)}
{/* Formulário */}
<form onSubmit={handleSubmit} className="bg-white rounded-xl border border-gray-200 p-6 mb-6">
<h2 className="text-lg font-semibold mb-4">
{editandoId ? 'Editar Produto' : 'Novo Produto'}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<input value={nome} onChange={(e) => setNome(e.target.value)}
placeholder="Nome do produto" required
className="px-4 py-2.5 border border-gray-300 rounded-lg text-sm" />
<input value={sku} onChange={(e) => setSku(e.target.value)}
placeholder="SKU (código)"
className="px-4 py-2.5 border border-gray-300 rounded-lg text-sm" />
<select value={categoriaId} onChange={(e) => setCategoriaId(e.target.value)}
className="px-4 py-2.5 border border-gray-300 rounded-lg text-sm">
<option value="">Sem categoria</option>
{categorias.map(c => (
<option key={c.id} value={c.id}>{c.nome}</option>
))}
</select>
<input value={precoCusto} onChange={(e) => setPrecoCusto(e.target.value)}
type="number" step="0.01" placeholder="Preço custo"
className="px-4 py-2.5 border border-gray-300 rounded-lg text-sm" />
<input value={precoVenda} onChange={(e) => setPrecoVenda(e.target.value)}
type="number" step="0.01" placeholder="Preço venda"
className="px-4 py-2.5 border border-gray-300 rounded-lg text-sm" />
<input value={estoqueMinimo} onChange={(e) => setEstoqueMinimo(e.target.value)}
type="number" placeholder="Estoque mínimo"
className="px-4 py-2.5 border border-gray-300 rounded-lg text-sm" />
<input value={unidade} onChange={(e) => setUnidade(e.target.value)}
placeholder="Unidade (un, kg, L)"
className="px-4 py-2.5 border border-gray-300 rounded-lg text-sm" />
<input value={descricao} onChange={(e) => setDescricao(e.target.value)}
placeholder="Descrição (opcional)"
className="px-4 py-2.5 border border-gray-300 rounded-lg text-sm" />
</div>
<div className="flex gap-2 mt-4">
<button type="submit" className="bg-primary-600 text-white px-6 py-2.5 rounded-lg text-sm font-medium hover:bg-primary-700">
{editandoId ? 'Salvar' : 'Criar'}
</button>
{editandoId && (
<button type="button" onClick={limparForm}
className="bg-gray-200 text-gray-700 px-6 py-2.5 rounded-lg text-sm">Cancelar</button>
)}
</div>
</form>
{/* Tabela de produtos */}
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-gray-600">
<tr>
<th className="text-left px-6 py-3">Produto</th>
<th className="text-left px-6 py-3">SKU</th>
<th className="text-left px-6 py-3">Categoria</th>
<th className="text-right px-6 py-3">Custo</th>
<th className="text-right px-6 py-3">Venda</th>
<th className="text-center px-6 py-3">Estoque</th>
<th className="text-center px-6 py-3">Ações</th>
</tr>
</thead>
<tbody>
{produtos.map(p => (
<tr key={p.id} className="border-t border-gray-100 hover:bg-gray-50">
<td className="px-6 py-3 font-medium text-gray-900">{p.nome}</td>
<td className="px-6 py-3 text-gray-500">{p.sku || '—'}</td>
<td className="px-6 py-3 text-gray-500">{p.categoria_nome || '—'}</td>
<td className="px-6 py-3 text-right">R$ {p.preco_custo.toFixed(2)}</td>
<td className="px-6 py-3 text-right">R$ {p.preco_venda.toFixed(2)}</td>
<td className="px-6 py-3 text-center">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
p.estoque_atual <= p.estoque_minimo
? 'bg-red-100 text-red-700'
: 'bg-green-100 text-green-700'
}`}>
{p.estoque_atual} {p.unidade}
</span>
</td>
<td className="px-6 py-3 text-center">
<button onClick={() => iniciarEdicao(p)} className="text-blue-600 hover:bg-blue-50 p-1.5 rounded-lg">
<Pencil size={15} />
</button>
<button onClick={() => handleDeletar(p.id)} className="text-red-600 hover:bg-red-50 p-1.5 rounded-lg ml-1">
<Trash2 size={15} />
</button>
</td>
</tr>
))}
</tbody>
</table>
{produtos.length === 0 && (
<p className="text-gray-400 text-center py-8">Nenhum produto cadastrado.</p>
)}
</div>
</div>
);
}
<select> — o campo de categoria usa um dropdown alimentado por categorias.map(). Cada <option> tem value={c.id}.2.
type="number" e step="0.01" — inputs numéricos. O step permite centavos (R$ 19.90).3. Badge de estoque — se
estoque_atual <= estoque_minimo, mostra vermelho. Se não, verde.4.
toFixed(2) — formata número para 2 casas decimais: 19.9 vira "19.90".5. Tabela em vez de cards — com muitos campos, tabela é mais legível que cards.
O padrão base é idêntico: estados → carregarDados → handleSubmit → handleDeletar → .map() na renderização.
S8.11 Movimentacoes.tsx — Entrada e Saída de Estoque
Diferente de Categorias e Produtos, Movimentações não tem editar nem deletar — é um registro histórico. Entrada e saída de estoque são irreversíveis (como transações bancárias). Só tem: listar e criar.
import { useState, useEffect } from 'react';
import { ArrowDownCircle, ArrowUpCircle } from 'lucide-react';
import { apiFetch } from '../services/api';
import { Produto } from '../types';
interface Movimentacao {
id: number;
produto_nome: string;
tipo: 'entrada' | 'saida';
quantidade: number;
motivo: string;
observacao?: string;
created_at: string;
}
export default function Movimentacoes() {
const [movimentacoes, setMovimentacoes] = useState<Movimentacao[]>([]);
const [produtos, setProdutos] = useState<Produto[]>([]);
const [loading, setLoading] = useState(true);
const [erro, setErro] = useState('');
const [sucesso, setSucesso] = useState('');
// Campos do formulário
const [produtoId, setProdutoId] = useState('');
const [tipo, setTipo] = useState<'entrada' | 'saida'>('entrada');
const [quantidade, setQuantidade] = useState('');
const [motivo, setMotivo] = useState('');
const [observacao, setObservacao] = useState('');
const carregarDados = async () => {
try {
const [movRes, prodRes] = await Promise.all([
apiFetch<Movimentacao[]>('/movimentacoes'),
apiFetch<Produto[]>('/produtos'),
]);
setMovimentacoes(movRes.data);
setProdutos(prodRes.data);
} catch (err: any) { setErro(err.message); }
finally { setLoading(false); }
};
useEffect(() => { carregarDados(); }, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErro(''); setSucesso('');
try {
await apiFetch('/movimentacoes', {
method: 'POST',
body: JSON.stringify({
produto_id: Number(produtoId),
tipo,
quantidade: parseInt(quantidade),
motivo,
observacao,
}),
});
setSucesso(`${tipo === 'entrada' ? 'Entrada' : 'Saída'} registrada com sucesso!`);
setProdutoId(''); setQuantidade(''); setMotivo(''); setObservacao('');
await carregarDados();
} catch (err: any) { setErro(err.message); }
};
if (loading) return <p className="text-gray-400 p-8">Carregando...</p>;
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-6">Movimentações de Estoque</h1>
{erro && <div className="bg-red-50 text-red-600 text-sm p-3 rounded-lg mb-4">{erro}</div>}
{sucesso && <div className="bg-green-50 text-green-600 text-sm p-3 rounded-lg mb-4">{sucesso}</div>}
{/* Formulário de nova movimentação */}
<form onSubmit={handleSubmit} className="bg-white rounded-xl border border-gray-200 p-6 mb-6">
<h2 className="text-lg font-semibold mb-4">Nova Movimentação</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<select value={produtoId} onChange={(e) => setProdutoId(e.target.value)} required
className="px-4 py-2.5 border border-gray-300 rounded-lg text-sm">
<option value="">Selecione o produto</option>
{produtos.map(p => (
<option key={p.id} value={p.id}>{p.nome} (estoque: {p.estoque_atual})</option>
))}
</select>
<select value={tipo} onChange={(e) => setTipo(e.target.value as 'entrada' | 'saida')}
className="px-4 py-2.5 border border-gray-300 rounded-lg text-sm">
<option value="entrada">Entrada (compra/reposição)</option>
<option value="saida">Saída (venda/perda)</option>
</select>
<input value={quantidade} onChange={(e) => setQuantidade(e.target.value)}
type="number" min="1" placeholder="Quantidade" required
className="px-4 py-2.5 border border-gray-300 rounded-lg text-sm" />
<input value={motivo} onChange={(e) => setMotivo(e.target.value)}
placeholder="Motivo (compra, venda, ajuste...)"
className="px-4 py-2.5 border border-gray-300 rounded-lg text-sm" />
<input value={observacao} onChange={(e) => setObservacao(e.target.value)}
placeholder="Observação (opcional)"
className="px-4 py-2.5 border border-gray-300 rounded-lg text-sm" />
</div>
<button type="submit" className="mt-4 bg-primary-600 text-white px-6 py-2.5 rounded-lg text-sm font-medium hover:bg-primary-700">
Registrar Movimentação
</button>
</form>
{/* Histórico */}
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-gray-600">
<tr>
<th className="text-left px-6 py-3">Tipo</th>
<th className="text-left px-6 py-3">Produto</th>
<th className="text-center px-6 py-3">Qtd</th>
<th className="text-left px-6 py-3">Motivo</th>
<th className="text-left px-6 py-3">Data</th>
</tr>
</thead>
<tbody>
{movimentacoes.map(m => (
<tr key={m.id} className="border-t border-gray-100 hover:bg-gray-50">
<td className="px-6 py-3">
{m.tipo === 'entrada'
? <span className="text-green-600 flex items-center gap-1"><ArrowDownCircle size={16}/> Entrada</span>
: <span className="text-red-600 flex items-center gap-1"><ArrowUpCircle size={16}/> Saída</span>
}
</td>
<td className="px-6 py-3 font-medium">{m.produto_nome}</td>
<td className="px-6 py-3 text-center font-mono">{m.quantidade}</td>
<td className="px-6 py-3 text-gray-500">{m.motivo || '—'}</td>
<td className="px-6 py-3 text-gray-500">
{new Date(m.created_at).toLocaleDateString('pt-BR')}
</td>
</tr>
))}
</tbody>
</table>
{movimentacoes.length === 0 && (
<p className="text-gray-400 text-center py-8">Nenhuma movimentação registrada.</p>
)}
</div>
</div>
);
}
as 'entrada' | 'saida'?
O e.target.value de um <select> sempre retorna string. Mas nosso estado tipo espera exatamente 'entrada' ou 'saida' (union type). O as é uma asserção de tipo — diz ao TypeScript: "confie, eu sei que o valor é um desses dois". É seguro aqui porque o <select> só tem essas duas opções.toLocaleDateString('pt-BR')?
O banco retorna datas como "2026-03-03T14:30:00Z" (ISO 8601). O toLocaleDateString('pt-BR') formata para "03/03/2026" — padrão brasileiro. Sem ele, o usuário veria a data crua e feia.✅ Aprendeu como apiFetch funciona — fetch + token + proxy do Vite
✅ Aprendeu localStorage — armazenamento permanente no navegador
✅ Implementou AuthContext real — login, register, logout com apiFetch
✅ Corrigiu o bug do reload — useEffect +
/auth/me restaura o usuário ao recarregar✅ Aprendeu JSON.stringify — converter objeto JS para texto JSON
✅ Implementou Login.tsx e Registro.tsx com tratamento de erro e loading
✅ Criou types/index.ts — interfaces TypeScript para todos os dados
✅ Configurou vite.config.ts com proxy para o backend
✅ Montou main.tsx com BrowserRouter + AuthProvider
✅ Criou StatCard e AppLayout (sidebar + Outlet)
✅ Aprendeu useEffect — executar código quando a página abre
✅ Aprendeu optional chaining (
?.) e nullish coalescing (??)✅ Implementou Dashboard.tsx com dados reais da API
✅ Protegeu rotas com ProtectedRoute — redireciona para login se não logou
✅ Implementou CRUD completo de Categorias — listar, criar, editar, deletar no frontend
✅ Implementou CRUD completo de Produtos — com select de categoria, preços e badge de estoque
✅ Implementou Movimentações — entrada/saída de estoque com histórico
✅ Usou Promise.all — carregar múltiplas APIs em paralelo
✅ Usou .map() no React — transformar array de dados em componentes visuais
✅ Aprendeu renderização condicional (
{erro && (...)})✅ Entendeu o fluxo completo: clique → React → fetch → Express → MySQL → tela
Próximo passo: S9 — Deploy — colocar o sistema no ar em uma VPS, com Nginx, PM2 e domínio próprio.
Até agora, tudo funcionou no seu computador — localhost:3737 para o backend e localhost:5174 para o frontend. Mas ninguém no mundo consegue acessar o seu localhost. Para que outras pessoas usem o sistema, precisamos colocar ele em um servidor na internet.
S9.1 O que é uma VPS e por que precisamos dela
Uma VPS (Virtual Private Server) é um computador na internet que fica ligado 24 horas por dia, 7 dias por semana. Diferente do seu computador, qualquer pessoa no mundo pode acessar ele pelo IP.
| Conceito | O que é | Exemplo |
|---|---|---|
| VPS | Servidor virtual — um computador Linux na nuvem | Hostinger, DigitalOcean, Contabo |
| IP | Endereço numérico do servidor | 189.33.44.55 |
| SSH | Protocolo para acessar o servidor remotamente pelo terminal | ssh root@189.33.44.55 |
| Domínio | Nome bonito que aponta para o IP | meuestoque.com.br |
| DNS | Sistema que traduz domínio → IP | Tipo uma agenda de contatos da internet |
O que o SSH faz
SSH (Secure Shell) é como um controle remoto para o servidor. Você digita um comando no seu computador, mas ele é executado lá no servidor. É seguro porque tudo é criptografado.
# Conectar ao servidor pela primeira vez # Troque pelo IP que a VPS te deu ssh root@189.33.44.55 # A primeira vez pergunta se confia no servidor: # "Are you sure you want to continue connecting?" # Digite: yes # Depois pede a senha que você definiu na VPS # (a senha NÃO aparece enquanto digita — é normal!)
ssh root@IP normalmente. Em versões mais antigas, use o programa PuTTY (gratuito) ou instale o WSL (Windows Subsystem for Linux) para ter um terminal Linux completo.Como configurar:
1. Instale a extensão Remote - SSH (Microsoft) no VS Code
2. Aperte
Ctrl+Shift+P → Remote-SSH: Connect to Host3. Digite
root@SEU_IP e a senha4. Pronto — o VS Code abre a VPS como um projeto local
Isso é muito mais produtivo que editar com
nano no terminal. Você pode arrastar arquivos, usar Ctrl+S, buscar no projeto, etc.2. Escolha o plano mais barato (1GB RAM, 1 vCPU) — suficiente para começar
3. Selecione o sistema: Ubuntu 22.04 LTS (ou mais recente LTS)
4. Defina a senha de root (anote em lugar seguro!)
5. Após a criação, copie o IP que o provedor mostra no painel
6. Use esse IP no comando SSH:
ssh root@SEU_IPS9.2 Preparando o Servidor — Instalando Tudo
Quando você acessa a VPS pela primeira vez, ela é um Ubuntu "pelado" — não tem Node.js, nem MySQL, nem nada. Precisamos instalar tudo que nosso sistema precisa para rodar.
Passo 1 — Atualizar o sistema
Primeiro, atualizamos todos os pacotes do Ubuntu. Isso é como fazer uma revisão antes de começar a usar.
# apt = gerenciador de pacotes do Ubuntu # update = baixar lista de pacotes novos # upgrade = instalar as atualizações # -y = responder "sim" automaticamente apt update && apt upgrade -y
apt?
apt é o gerenciador de pacotes do Ubuntu. Ele funciona como uma "loja de aplicativos" do Linux — você pede para instalar algo e ele baixa, instala e configura tudo automaticamente. Pense nele como o npm, mas para o sistema operacional.Passo 2 — Instalar o Node.js
Nosso backend é feito em Node.js, então precisamos instalá-lo no servidor. Vamos usar a versão LTS (Long Term Support) — a versão mais estável e recomendada para produção.
# Baixar o script que adiciona o repositório oficial do Node.js # curl = ferramenta para baixar coisas da internet (igual o fetch do JS) # -fsSL = flags: fail silently, show errors, follow redirects # | bash = executar o que baixou curl -fsSL https://deb.nodesource.com/setup_20.x | bash - # Agora instalar o Node.js apt install -y nodejs # Verificar se instalou node -v # Deve mostrar: v20.x.x npm -v # Deve mostrar: 10.x.x
Passo 3 — Instalar o MySQL
Nosso banco de dados. Vamos instalar o MySQL Server — o mesmo que usamos em desenvolvimento, mas agora no servidor.
# Instalar o MySQL Server apt install -y mysql-server # Verificar se está rodando systemctl status mysql # Deve mostrar: active (running) ✓ # Entrar no MySQL mysql -u root
systemctl?
systemctl é o comando que controla serviços do Linux. Serviços são programas que rodam em segundo plano (como o MySQL). Pense nele como um "gerenciador de tarefas" do Linux:
systemctl status mysql — ver se está rodandosystemctl start mysql — ligarsystemctl stop mysql — desligarsystemctl restart mysql — reiniciarsystemctl enable mysql — ligar automaticamente quando o servidor reiniciaPasso 4 — Configurar senha do MySQL
Em produção, o MySQL precisa de uma senha forte. Vamos definir uma:
# Entrar no MySQL (sem senha, por enquanto) mysql -u root # Dentro do MySQL, definir a senha: ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'SuaSenhaForte123!'; FLUSH PRIVILEGES; EXIT; # Testar login com a nova senha mysql -u root -p # Digite a senha que acabou de definir
Passo 5 — Criar o banco e rodar a migration
Lembra da migration.sql que criamos no S2? Agora vamos rodá-la no servidor para criar todas as tabelas.
# Criar o banco de dados mysql -u root -p -e "CREATE DATABASE IF NOT EXISTS saas_estoque;" # Rodar a migration (cria todas as tabelas) mysql -u root -p saas_estoque < backend/src/database/migration.sql
< faz?
O < é o operador de redirecionamento de entrada do terminal. Ele pega o conteúdo de um arquivo e "joga" dentro de um comando. Então mysql ... < migration.sql é como se você abrisse o MySQL e colasse todo o conteúdo do arquivo manualmente — mas automático.Passo 6 — Instalar o Nginx
Nginx (lê-se "engine-x") é um servidor web. Ele é a porta de entrada do seu sistema — recebe todas as requisições da internet e decide para onde encaminhar.
# Instalar o Nginx apt install -y nginx # Verificar se está rodando systemctl status nginx # Deve mostrar: active (running) # Testar: abra o IP da VPS no navegador # http://189.33.44.55 — deve aparecer "Welcome to nginx!"
/api/...), ela encaminha para o andar do backend (Node.js na porta 3737). Sem o Nginx, ninguém sabe para onde ir.Passo 7 — Instalar o PM2
PM2 é um gerenciador de processos para Node.js. Ele mantém seu backend rodando 24 horas e reinicia automaticamente se o processo crashar.
# Instalar PM2 globalmente # -g = global (disponível em qualquer pasta) npm install -g pm2 # Verificar se instalou pm2 --version
node app.js direto?
Se você rodar node app.js no terminal e fechar o terminal, o processo morre. Se der um erro, o servidor para. Com PM2:
1. Se o processo crashar → PM2 reinicia automaticamente
2. Se o servidor reiniciar (ex: queda de luz) → PM2 volta tudo sozinho
3. Você pode fechar o terminal → o processo continua rodando
4. Tem logs, monitoramento de CPU/RAM e muito mais
É como ter um vigia que nunca dorme cuidando do seu sistema.
Resumo — O que instalamos
| Ferramenta | Função | Analogia |
|---|---|---|
| Node.js 20 LTS | Executa o JavaScript do backend | O motor do carro |
| MySQL | Armazena todos os dados | O cofre do escritório |
| Nginx | Recebe requisições e encaminha | A recepcionista |
| PM2 | Mantém o Node.js rodando 24/7 | O vigia que reinicia tudo |
S9.3 Enviando o Projeto para o Servidor
O código está no seu computador. Agora precisamos colocar ele no servidor. A maneira mais profissional é usar o Git.
Opção A — Via Git (recomendado)
Se o projeto está no GitHub:
# Instalar o Git (se ainda não tiver) apt install -y git # Clonar o projeto # Troque pela URL do seu repositório cd /root git clone https://github.com/PauloInacioPI/saas-estoque-template.git # Entrar na pasta cd saas-estoque-template
Opção B — Via SCP (sem Git)
Se não usa GitHub, pode copiar direto do seu computador para o servidor:
# SCP = Secure Copy — copia arquivos via SSH # Execute isso NO SEU COMPUTADOR (não no servidor) # -r = recursivo (copiar pasta inteira) scp -r ./saas-estoque-pro root@189.33.44.55:/root/ # Depois, no servidor: cd /root/saas-estoque-pro
scp (Secure Copy) é como o cp (copiar) do Linux, mas funciona entre computadores via SSH. A sintaxe é: scp origem destino. O -r significa recursivo — copiar todas as subpastas e arquivos dentro.Instalar dependências
O node_modules/ nunca vai no Git (está no .gitignore). Precisamos instalar as dependências no servidor:
# Instalar dependências do backend cd /root/saas-estoque-pro/backend npm install # Instalar dependências do frontend cd /root/saas-estoque-pro/frontend npm install
node_modules/ pode ter milhares de arquivos e centenas de megabytes. Enviar isso pelo Git seria absurdamente lento. O package.json já tem a lista de tudo que precisa — basta rodar npm install e ele baixa tudo de novo. É como enviar a receita do bolo em vez de enviar o bolo pronto.S9.4 Configurando o .env de Produção
O arquivo .env guarda as configurações sensíveis do backend. Em produção, os valores são diferentes dos de desenvolvimento.
Arquivo: backend/.env — fica na raiz do backend porque é onde o dotenv procura por padrão (lembra do require('dotenv').config() no S2?).
# Criar o arquivo .env no servidor cd /root/saas-estoque-pro/backend nano .env # Cole o conteúdo abaixo e salve (Ctrl+O, Enter, Ctrl+X)
# backend/.env (PRODUÇÃO — criar este arquivo no servidor) # Indica que estamos em produção (erros não mostram detalhes) NODE_ENV=production # Porta onde o backend escuta (o Nginx vai encaminhar para cá) PORT=3737 # Conexão com o MySQL do servidor DB_HOST=localhost DB_USER=root DB_PASSWORD=SuaSenhaForte123! DB_NAME=saas_estoque # Chave secreta para assinar os tokens JWT # NUNCA use a mesma chave de desenvolvimento # Gere uma aleatória com: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" JWT_SECRET=cole_aqui_a_chave_gerada_com_64_caracteres_aleatorios JWT_EXPIRES_IN=7d
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
Isso gera algo como:
a3f8c2e9b1d4... (128 caracteres). Cole esse valor no JWT_SECRET. Cada servidor deve ter uma chave diferente — se dois servidores usarem a mesma chave, um pode forjar tokens do outro..env contém senhas reais do banco e chave JWT. Se for parar no GitHub (mesmo em repositório privado), qualquer pessoa com acesso pode roubar seus dados. O .env deve estar no .gitignore — o que já fizemos no S2. No servidor, crie o arquivo manualmente.S9.5 Build do Frontend — Gerando os Arquivos de Produção
Em desenvolvimento, o Vite roda um servidor com hot reload (atualiza a tela ao salvar). Em produção, não usamos o Vite — geramos arquivos estáticos otimizados que o Nginx serve diretamente.
# Entrar na pasta do frontend cd /root/saas-estoque-pro/frontend # Gerar o build de produção npm run build # Isso executa: tsc -b && vite build (definido no package.json) # tsc -b = compila TypeScript → verifica erros de tipo # vite build = empacota tudo em arquivos otimizados
O resultado é uma pasta frontend/dist/ com poucos arquivos:
frontend/dist/
├── index.html # HTML único (SPA = Single Page Application)
├── assets/
│ ├── index-abc123.js # Todo o JavaScript, minificado
│ └── index-def456.css # Todo o CSS, minificado
.tsx, .ts e .css e combina em um ou dois arquivos gigantes. Depois remove espaços, quebras de linha e renomeia variáveis para letras curtas (const userName vira const a). O resultado é um arquivo muito menor que carrega mais rápido. Um projeto de 50 arquivos vira 1 arquivo de ~200KB.abc123 no nome do arquivo?
O Vite adiciona um hash (código aleatório) no nome do arquivo: index-abc123.js. Isso é para cache busting — quando você atualiza o código e faz um novo build, o hash muda (index-xyz789.js). O navegador vê que é um arquivo diferente e baixa a versão nova em vez de usar o cache antigo. Sem isso, os usuários veriam a versão velha até limpar o cache manualmente.S9.6 Configurando o PM2 — Backend 24/7
Vamos usar o PM2 para manter o backend rodando. Se o Node.js crashar, o PM2 reinicia automaticamente.
Iniciando o backend com PM2
# Entrar na pasta do backend cd /root/saas-estoque-pro/backend # Iniciar o backend com PM2 # --name = nome que aparece na lista do PM2 pm2 start src/app.js --name estoque-api # Verificar se está rodando pm2 list
O comando pm2 list mostra uma tabela assim:
┌────┬──────────────┬──────┬──────┬────────┬─────────┬────────┐ │ id │ name │ mode │ ↺ │ status │ cpu │ memory │ ├────┼──────────────┼──────┼──────┼────────┼─────────┼────────┤ │ 0 │ estoque-api │ fork │ 0 │ online │ 0% │ 45mb │ └────┴──────────────┴──────┴──────┴────────┴─────────┴────────┘
name — o nome que demos com
--namemode — fork = processo único (suficiente para nosso caso)
↺ — quantas vezes o PM2 reiniciou (se tiver crash, esse número sobe)
status —
online = rodando ✓, errored = deu erro ✗cpu/memory — consumo de recursos
Testando se o backend responde
# Testar o health check que criamos no app.js curl http://localhost:3737/health # Deve retornar: # {"status":"ok","timestamp":"2026-03-03T..."}
pm2 logs estoque-api — mostra os logs do backend. Os erros mais comuns são:
1.
ECONNREFUSED no MySQL → senha errada no .env2.
MODULE_NOT_FOUND → esqueceu de rodar npm install3.
EADDRINUSE → porta 3737 já está em uso (mate o processo antigo)Comandos essenciais do PM2
| Comando | O que faz | Quando usar |
|---|---|---|
pm2 list |
Lista todos os processos | Ver se está online |
pm2 logs estoque-api |
Ver logs em tempo real | Debug quando algo dá errado |
pm2 restart estoque-api |
Reinicia o processo | Após atualizar código |
pm2 stop estoque-api |
Para o processo | Manutenção |
pm2 delete estoque-api |
Remove o processo do PM2 | Começar do zero |
pm2 monit |
Monitor visual (CPU, RAM, logs) | Acompanhar performance |
Salvando para sobreviver a reinicializações
Se o servidor reiniciar (queda de energia, atualização do sistema), o PM2 precisa saber que deve ligar o backend automaticamente:
# Salvar a lista atual de processos pm2 save # Gerar o script de inicialização automática pm2 startup # O PM2 vai mostrar um comando para executar — copie e execute! # Algo como: sudo env PATH=$PATH:/usr/bin pm2 startup systemd -u root --hp /root
pm2 startup faz?
Ele cria um serviço do sistema (como o MySQL e o Nginx) que inicia junto com o servidor. Depois disso, se o servidor reiniciar, o PM2 liga automaticamente e restaura todos os processos que estavam salvos com pm2 save. Sem isso, após um reboot você teria que iniciar tudo manualmente.S9.7 Configurando o Nginx — O Proxy Reverso
Agora vem a parte mais importante do deploy: configurar o Nginx para receber as requisições e direcionar para o lugar certo.
meuestoque.com.br, mas quem responde primeiro é o Nginx. Ele então decide:
• Se o pedido é para
/ ou /login ou /dashboard → serve os arquivos do frontend (pasta dist/)• Se o pedido começa com
/api/ → encaminha para o Node.js na porta 3737É exatamente o que o proxy do Vite fazia em desenvolvimento — mas agora é o Nginx fazendo em produção.
Arquitetura do sistema em produção
Criando o arquivo de configuração do Nginx
Arquivo: /etc/nginx/sites-available/saas-estoque
O Nginx organiza os sites em duas pastas:
| Pasta | O que é | Analogia |
|---|---|---|
/etc/nginx/sites-available/ |
Todos os sites configurados (ativos ou não) | Pasta com todas as fichas de clientes |
/etc/nginx/sites-enabled/ |
Sites ativos (links simbólicos para available) | Fichas que estão na mesa — sendo atendidas |
Criamos o arquivo em sites-available e depois "ativamos" com um link simbólico em sites-enabled.
# Criar o arquivo de configuração
nano /etc/nginx/sites-available/saas-estoque
nano?
nano é um editor de texto do terminal — simples e direto. Você digita o conteúdo, e quando terminar:
Ctrl+O → Salvar (O de "Output")
Enter → Confirmar o nome do arquivo
Ctrl+X → Sair
É como o Bloco de Notas, mas no terminal.
Cole o seguinte conteúdo:
# /etc/nginx/sites-available/saas-estoque server { # Escutar na porta 80 (HTTP padrão) listen 80; # O domínio que este site responde # Troque pelo seu domínio (ou use o IP da VPS enquanto não tem domínio) server_name meuestoque.com.br; # ======= FRONTEND ======= # Qualquer rota que NÃO comece com /api/ # serve os arquivos estáticos do build do React location / { # Caminho para a pasta dist/ (resultado do npm run build) root /root/saas-estoque-pro/frontend/dist; # try_files: tenta encontrar o arquivo pedido. # Se não existir, retorna index.html # Isso é ESSENCIAL para SPA (Single Page Application) try_files $uri $uri/ /index.html; } # ======= BACKEND (API) ======= # Tudo que começar com /api/ vai para o Node.js location /api/ { # proxy_pass: encaminha a requisição para o Node.js # O / no final remove o /api/ do caminho # /api/produtos → localhost:3737/produtos proxy_pass http://localhost:3737/; # Headers que informam ao backend o IP real do usuário proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }
Entendendo cada diretiva — linha por linha
| Diretiva | O que faz |
|---|---|
listen 80 |
Escuta na porta 80 (HTTP). Quando o usuário digita uma URL sem porta, o navegador usa a 80. |
server_name |
Qual domínio este bloco responde. Se tiver vários sites na mesma VPS, cada um tem um server_name diferente. |
root |
Pasta onde estão os arquivos. O Nginx procura os arquivos aqui. |
try_files $uri $uri/ /index.html |
Tenta achar o arquivo pedido. Se não existir, retorna index.html. Essencial para React Router — URLs como /dashboard não existem como arquivo, mas o React sabe renderizar. |
proxy_pass |
Encaminha a requisição para outro endereço. Aqui, para o Node.js na porta 3737. |
X-Real-IP |
Envia o IP real do usuário para o backend. Sem isso, o backend vê apenas 127.0.0.1 (localhost) porque o Nginx é o intermediário. |
try_files é obrigatório para React?
No React, as rotas existem apenas no JavaScript (/dashboard, /categorias). No servidor, não existe um arquivo dashboard.html. Sem try_files, ao acessar meuestoque.com.br/dashboard diretamente, o Nginx retornaria 404 — Not Found. Com try_files, ele retorna o index.html, o React carrega e renderiza a página certa.Ativando o site
# Remover o site padrão do Nginx (a página "Welcome to nginx!") rm /etc/nginx/sites-enabled/default # Criar link simbólico para ativar nosso site # ln -s = criar atalho (link simbólico) ln -s /etc/nginx/sites-available/saas-estoque /etc/nginx/sites-enabled/ # Testar se a configuração está correta (SEMPRE faça isso!) nginx -t # Se aparecer: "syntax is ok" e "test is successful" → tudo certo! # Se der erro → revise o arquivo de configuração # Reiniciar o Nginx para aplicar systemctl restart nginx
ln -s cria um atalho — como um atalho de programa na área de trabalho do Windows. O arquivo real fica em sites-available/, e o link em sites-enabled/ aponta para ele. Para desativar o site, basta deletar o link (o arquivo original continua lá).nginx -t antes de reiniciar!
Se a configuração tiver erro de sintaxe e você reiniciar o Nginx, ele para de funcionar e seu site sai do ar. O nginx -t verifica a sintaxe SEM reiniciar. Só reinicie depois do "test is successful".Testando no navegador
# Se já tem domínio configurado: # Abra: http://meuestoque.com.br # Se ainda não tem domínio, use o IP: # Abra: http://189.33.44.55 # Deve aparecer a tela de login do sistema! # Para testar a API: curl http://189.33.44.55/api/health # Deve retornar: {"status":"ok","timestamp":"..."}
S9.8 Firewall — Protegendo o Servidor
O firewall controla quais portas da VPS estão abertas para a internet. Se não configurar, qualquer porta fica acessível — e isso é perigoso.
# UFW = Uncomplicated Firewall (firewall simples do Ubuntu) # Permitir SSH (FAÇA ISSO PRIMEIRO — senão perde acesso!) ufw allow 22 # Permitir HTTP e HTTPS (Nginx precisa dessas portas) ufw allow 80 ufw allow 443 # Ativar o firewall ufw enable # Verificar as regras ufw status
| Porta | Serviço | Por que abrir |
|---|---|---|
| 22 | SSH | Para você acessar o servidor remotamente |
| 80 | HTTP | Para o site funcionar (e para o Certbot emitir SSL) |
| 443 | HTTPS | Para o site funcionar com criptografia |
localhost — não precisa estar aberto para a internet. Se você abrir a 3737, qualquer pessoa pode acessar sua API sem passar pelo Nginx. Se abrir a 3306, qualquer pessoa pode tentar conectar no seu banco de dados.S9.9 Domínio e DNS — O Endereço do Seu Sistema
Até agora, o sistema é acessado pelo IP (http://189.33.44.55). Para ter um endereço bonito como meuestoque.com.br, você precisa de um domínio.
189.33.44.55. Funciona, mas ninguém decora. O domínio é o nome fantasia: "Restaurante do João". Quando alguém digita meuestoque.com.br, o DNS (a agenda da internet) traduz para 189.33.44.55 e direciona o navegador para lá.Passo 1 — Comprar o domínio
Registradores populares no Brasil:
| Registrador | Tipo | Preço médio |
|---|---|---|
| Registro.br | Domínios .com.br | ~R$ 40/ano |
| Cloudflare | Domínios .com (internacionais) | ~R$ 50/ano |
| Hostinger | Diversos TLDs | A partir de R$ 25/ano |
Passo 2 — Configurar o DNS
No painel do registrador, adicione um registro DNS do tipo A:
| Tipo | Nome | Valor | TTL |
|---|---|---|---|
| A | @ (ou vazio) |
189.33.44.55 (IP da sua VPS) |
3600 |
| A | www |
189.33.44.55 (mesmo IP) |
3600 |
Nome @ — o domínio raiz (
meuestoque.com.br)Nome www — o subdomínio www (
www.meuestoque.com.br)TTL 3600 — "Time To Live" — quanto tempo (em segundos) os servidores DNS guardam o cache. 3600 = 1 hora. Se mudar o IP, pode demorar até 1 hora para propagar.
nslookup meuestoque.com.brS9.10 SSL/HTTPS — O Cadeado Verde
SSL (Secure Sockets Layer) criptografa toda a comunicação entre o navegador e o servidor. Sem SSL, senhas e dados trafegam como texto puro — qualquer pessoa na mesma rede pode interceptar.
Let's Encrypt — SSL gratuito
Let's Encrypt é uma autoridade certificadora gratuita e automática. O Certbot é a ferramenta que pede o certificado e configura o Nginx automaticamente.
# Instalar o Certbot e o plugin do Nginx apt install -y certbot python3-certbot-nginx # Gerar o certificado SSL # O Certbot detecta o Nginx e configura TUDO sozinho certbot --nginx -d meuestoque.com.br -d www.meuestoque.com.br # Ele vai perguntar: # 1. Seu email (para avisos de expiração) # 2. Aceitar os termos → Y # 3. Redirecionar HTTP → HTTPS → escolha 2 (redirecionar)
2. Gera o certificado SSL (arquivos .pem)
3. Modifica o arquivo do Nginx automaticamente — adiciona
listen 443 ssl, caminho dos certificados e redirecionamento HTTP→HTTPS4. Reinicia o Nginx
Em ~30 segundos, seu site tem HTTPS funcionando. Gratuito e automático.
Renovação automática
O certificado do Let's Encrypt expira a cada 90 dias. Mas o Certbot já configura renovação automática:
# Testar se a renovação automática funciona certbot renew --dry-run # Se aparecer "Congratulations" → está configurado! # O Certbot renova automaticamente antes de expirar
S9.11 Ajuste no Frontend — URL da API em Produção
Em desenvolvimento, o Vite proxy encaminhava /api/... para localhost:3737. Em produção, não usamos o Vite — os arquivos são estáticos. Mas isso já funciona porque o Nginx faz o mesmo papel de proxy.
Verifique que o apiFetch usa caminhos relativos:
// frontend/src/services/api.ts // O caminho DEVE começar com /api/ // Em desenvolvimento: Vite proxy encaminha para localhost:3737 // Em produção: Nginx encaminha para localhost:3737 // O código do frontend NÃO muda! const response = await fetch(`/api/${url}`, options);
/api/...) em vez de caminhos absolutos (http://localhost:3737/...). O navegador envia a requisição para o mesmo domínio do site. Em desenvolvimento, o Vite intercepta. Em produção, o Nginx intercepta. O código do frontend é o mesmo nos dois ambientes — zero alteração.S9.12 Workflow de Atualização — Atualizando o Sistema
Quando você fizer alterações no código e quiser atualizar o servidor:
# ====== NO SEU COMPUTADOR ====== # Commitar e enviar para o GitHub git add . git commit -m "feat: nova funcionalidade" git push # ====== NO SERVIDOR (via SSH) ====== ssh root@189.33.44.55 # 1. Puxar as mudanças cd /root/saas-estoque-pro git pull # 2. Se mudou dependências do backend: cd backend && npm install && cd .. # 3. Se mudou o frontend: cd frontend && npm install && npm run build && cd .. # 4. Reiniciar o backend pm2 restart estoque-api # 5. Verificar se tudo está ok pm2 list curl http://localhost:3737/health
Arquivo: /root/saas-estoque-pro/deploy.sh — na raiz do projeto, porque é um script de gerenciamento do projeto inteiro.
#!/bin/bash # deploy.sh — Script de atualização rápida set -e # Parar imediatamente se qualquer comando falhar echo "Atualizando o sistema..." # Puxar código novo cd /root/saas-estoque-pro git pull # Atualizar dependências cd backend && npm install && cd .. cd frontend && npm install && npm run build && cd .. # Reiniciar backend pm2 restart estoque-api echo "Deploy concluído!" pm2 list
# Tornar o script executável chmod +x deploy.sh # Para atualizar, basta executar: ./deploy.sh
chmod +x faz?
chmod (change mode) muda as permissões de um arquivo. +x adiciona permissão de execução. Sem isso, o Linux não deixa executar o script — ele trata como um arquivo de texto normal. É uma proteção: só executa o que você explicitamente autorizar.S9.13 Checklist Final de Deploy
Use esta lista para verificar se tudo está configurado:
Servidor:
☐ VPS com Ubuntu acessível via SSH
☐
apt update && apt upgrade executado☐ Node.js 20 LTS instalado (
node -v)☐ MySQL instalado e rodando (
systemctl status mysql)☐ Senha do MySQL definida (forte!)
☐ Banco
saas_estoque criado☐ Migration rodada (tabelas criadas)
Projeto:
☐ Código clonado no servidor (
git clone ou scp)☐
npm install no backend☐
npm install no frontend☐
.env de produção criado no backend☐ JWT_SECRET gerado com
crypto.randomBytes☐
npm run build no frontend (pasta dist/ gerada)Serviços:
☐ PM2 rodando o backend (
pm2 list = online)☐
pm2 save e pm2 startup executados☐ Nginx configurado (
nginx -t = ok)☐ Site ativado em
sites-enabled/☐
curl http://localhost:3737/health respondendoSegurança:
☐ Firewall (UFW) ativado com portas 22, 80, 443
☐ Portas 3737 e 3306 NÃO abertas no firewall
☐ SSL/HTTPS configurado com Certbot
☐
.env NÃO está no GitDomínio:
☐ Domínio registrado
☐ DNS tipo A apontando para o IP da VPS
☐
server_name no Nginx atualizado com o domínio☐ Certbot executado com o domínio
S9.14 Resumo da Stack Completa
Parabéns! Aqui está tudo que você construiu neste projeto:
| Camada | Tecnologia | Capítulo | O que faz |
|---|---|---|---|
| Banco | MySQL | S2 | Armazena dados das empresas, usuários, produtos, movimentações |
| Auth | JWT + bcrypt | S3 | Registro, login, proteção de rotas |
| API | Express.js | S4–S7 | 5 controllers, 14 rotas — CRUD, transações, dashboard |
| Frontend | React + TypeScript | S8 | Telas, formulários, autenticação visual, consumo de API |
| Deploy | Nginx + PM2 + SSL | S9 | Servidor, proxy reverso, processo 24/7, HTTPS |
S9.15 Backup do Banco de Dados
Seu sistema está em produção com dados reais de clientes. Se o banco corromper ou o servidor falhar, você perde tudo — a menos que tenha backup.
Backup manual com mysqldump
# Fazer backup do banco inteiro # mysqldump = ferramenta do MySQL para exportar dados mysqldump -u root -p saas_estoque > /root/backups/saas_estoque_$(date +%Y%m%d).sql # Exemplo de nome gerado: saas_estoque_20260303.sql # O arquivo contém todos os CREATE TABLE e INSERT INTO
$(date +%Y%m%d)?
É substituição de comando do bash. O $(comando) executa o comando dentro e coloca o resultado no lugar. date +%Y%m%d retorna a data de hoje no formato 20260303. Assim, cada backup tem a data no nome.Backup automático com cron
# Criar pasta de backups mkdir -p /root/backups # Abrir o editor de tarefas agendadas crontab -e # Adicionar esta linha (backup diário às 3h da manhã): 0 3 * * * mysqldump -u root -pSUA_SENHA_AQUI saas_estoque > /root/backups/saas_estoque_$(date +\%Y\%m\%d).sql
cron é o agendador de tarefas do Linux — executa comandos em horários definidos. A sintaxe é: minuto hora dia mês dia_semana comando. 0 3 * * * significa "às 3:00, todo dia, todo mês, todo dia da semana".Restaurar um backup
# Se precisar restaurar:
mysql -u root -p saas_estoque < /root/backups/saas_estoque_20260303.sql
✅ Aprendeu a escolher e comprar uma VPS
✅ Instalou Node.js, MySQL, Nginx e PM2 no servidor
✅ Aprendeu o que é apt — gerenciador de pacotes do Ubuntu
✅ Aprendeu o que é systemctl — controle de serviços do Linux
✅ Enviou o projeto para o servidor com Git ou SCP
✅ Configurou o .env de produção com chave JWT segura
✅ Fez o build do frontend — entendeu minificação e cache busting
✅ Configurou o PM2 — backend rodando 24/7 com reinício automático
✅ Aprendeu
pm2 save e pm2 startup para sobreviver a reboots✅ Configurou o Nginx como proxy reverso — entendeu cada diretiva
✅ Entendeu por que
try_files é obrigatório para SPA (React)✅ Configurou o firewall (UFW) — portas 22, 80, 443 abertas
✅ Entendeu por que NÃO abrir as portas 3737 e 3306
✅ Registrou domínio e configurou DNS (registro tipo A)
✅ Instalou SSL/HTTPS gratuito com Let's Encrypt e Certbot
✅ Criou um script de deploy automatizado (com
set -e)✅ Configurou backup automático do banco com mysqldump + cron
✅ Viu a stack completa — do banco de dados ao navegador do usuário
Parabéns! Você construiu um sistema SaaS completo do zero — banco de dados, autenticação, CRUD, transações, dashboard, frontend React e deploy em produção com backup. O sistema está no ar, com HTTPS, rodando 24/7.