Sumário
Backend (S1–S7) = Node.js + Express + MySQL | Frontend (S8) = React + TypeScript + Tailwind | Deploy (S9) = VPS + Nginx + PM2 + SSL
Esta apostila ensina a construir um SaaS completo. Para acompanhar, você precisa ter:
☑ VS Code — editor de código
☑ MySQL instalado — rodando localmente (porta 3306)
☑ Noções básicas de JavaScript — variáveis, funções, arrays, objetos
☑ Noções básicas de React — JSX, useState, useEffect, componentes
☑ Git — saber clonar repositório e fazer commits
Se você não domina algum desses itens, recomendamos estudar a Apostila Completa React + Node.js (Níveis 1 a 6) antes de começar este módulo.
☑ 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.
| Tecnologia | Versão | Função |
|---|---|---|
| Node.js | 20+ LTS | Runtime JavaScript no servidor |
| Express | 4.x | Framework web (rotas, middleware) |
| MySQL | 8.x | Banco de dados relacional |
| mysql2 | 3.x | Driver MySQL para Node.js (com Promises) |
| bcryptjs | 2.x | Hash de senhas |
| jsonwebtoken | 9.x | Autenticação via JWT |
| React | 18.x | Biblioteca de UI (frontend) |
| TypeScript | 5.x | Tipagem estática |
| Vite | 6.x | Bundler e dev server |
| Tailwind CSS | 3.x | CSS utilitário |
| lucide-react | 0.x | Biblioteca de ícones |
| react-router-dom | 6.x | Roteamento SPA |
git clone https://github.com/PauloInacioPI/saas-estoque-template.gitcd saas-estoque-templatenpm install (na pasta backend/)npm install (na pasta frontend/)O template já vem com a estrutura de pastas, configurações e stubs (funções vazias retornando 501). Sua missão: implementar tudo.
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 |
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);
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".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
✅ 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
✅ 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.