Apostila Completa — Projeto Prático
📦

Criando seu SaaS
do Zero

Controle de Estoque

Multi-tenant + RBAC + React + TypeScript + Node.js + Express + MySQL
Do banco de dados ao deploy em produção

11
Capítulos
9
Módulos
100%
Prático

Desenvolvido por PiTech Sistemas

pitech.com.br @pitechsistemas

Desenvolvedor: Paulo Inacio

Março 2026

Sumário

Sobre esta apostila: Este é um guia 100% prático para construir um sistema SaaS completo de controle de estoque. Você vai criar tudo do zero — banco de dados, autenticação, CRUD, transações, dashboard, frontend React e deploy em produção.

Backend (S1–S7) = Node.js + Express + MySQL   |   Frontend (S8) = React + TypeScript + Tailwind   |   Deploy (S9) = VPS + Nginx + PM2 + SSL
S1   Conhecendo o Template — Por Onde Começar?
Estrutura de pastas, conceitos-chave, multi-tenant, RBAC
S2   Banco de Dados e Configuração Inicial
MySQL, migration.sql, 5 tabelas, .env, connection.js, dados de teste
S2.5   Conceitos JavaScript que Vamos Usar
Arrow functions, async/await, try/catch, exports, req/res/next, desestruturação, SQL injection
S3   Auth — Registro e Login (Primeiro Controller Real)
Bcrypt, JWT, authController completo, middleware, rotas, curl
S4   CRUD de Categorias — Seu Primeiro CRUD Completo
Listar, criar, editar, deletar, padrão que se repete
S5   CRUD de Produtos — O CRUD do Mundo Real
JOIN, .map(), filtros, paginação, busca, controller completo
S6   Movimentações de Estoque — Entrada e Saída
Transação SQL (BEGIN/COMMIT/ROLLBACK), múltiplos JOINs, validação de estoque
S7   Dashboard — A Visão Geral do Negócio
COUNT, SUM, CURDATE, múltiplos SELECTs, alertas de estoque baixo
S7.5   Conceitos React que Vamos Usar
JSX, useState, useEffect, useContext, React Router, Tailwind CSS
S8   Frontend — Conectando o React ao Backend
apiFetch, AuthContext, Login, Dashboard, Categorias, App.tsx, arquivos de suporte, exercício
S9   Deploy — Colocando o Sistema no Ar
VPS, SSH, Node.js, MySQL, Nginx, PM2, firewall, domínio, SSL, deploy script, backup
Pré-requisitos

Esta apostila ensina a construir um SaaS completo. Para acompanhar, você precisa ter:

O que você precisa saber antes de começarNode.js e npm instalados — versão LTS (20+)
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.
💻 Windows, Mac ou Linux? Esta apostila funciona nos três sistemas. Os comandos de terminal usam a sintaxe do bash (Linux/Mac), mas no Windows funcionam igual se você usar um destes terminais:

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.
Tecnologias usadas neste projeto
TecnologiaVersãoFunção
Node.js20+ LTSRuntime JavaScript no servidor
Express4.xFramework web (rotas, middleware)
MySQL8.xBanco de dados relacional
mysql23.xDriver MySQL para Node.js (com Promises)
bcryptjs2.xHash de senhas
jsonwebtoken9.xAutenticação via JWT
React18.xBiblioteca de UI (frontend)
TypeScript5.xTipagem estática
Vite6.xBundler e dev server
Tailwind CSS3.xCSS utilitário
lucide-react0.xBiblioteca de ícones
react-router-dom6.xRoteamento SPA
Clonando o projeto template Antes de começar, clone o repositório do template. Todos os capítulos S1-S9 partem deste template:

git clone https://github.com/PauloInacioPI/saas-estoque-template.git
cd saas-estoque-template
npm 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.
S1 Conhecendo o Template — Por Onde Começar?

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.

O que é um Template? Um template é o esqueleto do projeto — a estrutura de pastas, as configurações, os arquivos base e as rotas definidas, mas sem lógica de negócio. É como a planta de uma casa: as paredes estão no lugar, mas ainda não tem móveis, pintura nem eletricidade funcionando.

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
Os "Stubs" — O que são? Um stub é uma função que tem a assinatura correta (recebe req, res, next) mas não faz nada real. Ela está ali para que o servidor não quebre quando você acessa uma rota. Seu trabalho será trocar cada stub pela lógica real, um de cada vez.

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.

A Regra de Ouro das Dependências Sempre implemente primeiro o que não depende de nada. Depois, o que depende do que você já fez.
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
Por que esta ordem? Banco primeiro — sem tabelas, nenhuma query funciona.
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
Regra de Segurança NUNCA faça 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.criarINSERT 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
Exercício — Antes do Próximo Capítulo 1. Clone o projeto: git clone https://github.com/PauloInacioPI/saas-estoque-template.git
2. 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 navegador
5. Rode o frontend (npm run dev) e navegue pelas páginas
6. 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
Sua missão nos próximos capítulos Trocar cada stub por código real, de baixo pra cima: banco → backend → frontend.
S2 Banco de Dados e Configuração Inicial

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
);
Por que esta tabela é a primeira? Tudo no sistema pertence a uma empresa. Usuários, produtos, categorias — todos têm 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)
Segurança: por que ENUM para role? Se fosse VARCHAR, alguém poderia tentar cadastrar 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)
Por que categorias vem antes de produtos? Porque a tabela 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
Por que DECIMAL e não FLOAT? 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)
);
Lógica de negócio das movimentações Quando criar uma movimentação, o controller precisa fazer duas coisas:
1. INSERT INTO movimentacoes — registrar a movimentação
2. UPDATE produtos SET estoque_atual = estoque_atual +/- quantidade — atualizar o estoque

Se 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);
Por que criar índices? Sem índice, o MySQL faz full table scan — lê a tabela inteira para encontrar os registros. Com índice em 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):

┌──────────────┐ │ empresas │ │──────────────│ │ id (PK) │ │ nome │ │ plano │ │ ativo │ └──────┬───────┘ │ ┌─────────────────┼─────────────────┐ │ empresa_id │ empresa_id │ empresa_id ▼ ▼ ▼ ┌──────────────┐ ┌──────────────┐ ┌───────────────┐ │ users │ │ categorias │ │ movimentacoes│ │──────────────│ │──────────────│ │───────────────│ │ id (PK) │ │ id (PK) │ │ id (PK) │ │ nome │ │ nome │ │ tipo (E/S) │ │ email (UQ) │ │ descricao │ │ quantidade │ │ senha (hash) │ │ empresa_id │◄──│ produto_id │ │ role (RBAC) │ └──────┬───────┘ │ usuario_id │──►users │ empresa_id │ │ │ empresa_id │ │ ativo │ │ └───────────────┘ └──────────────┘ │ categoria_id ▼ ┌──────────────┐ │ produtos │ │──────────────│ │ id (PK) │ │ nome │ │ sku │ │ preco_custo │ │ preco_venda │ │ estoque_atual│ │ estoque_min │ │ categoria_id │──►categorias │ empresa_id │──►empresas └──────────────┘
Como ler o diagrama PK = Primary Key (identificador único). UQ = UNIQUE (sem duplicatas).
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              |       |
+----------------+---------------+------+-----+-------------------+-------+
Dica: MUL na coluna Key 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
💻 No Windows? Use 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
NUNCA commite o .env O arquivo .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.
JWT_SECRET — o que é? É a chave usada para assinar os tokens JWT. Se alguém descobrir essa chave, pode criar tokens falsos e acessar o sistema como qualquer usuário. Use uma string longa e aleatória. Uma boa forma de gerar:

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.

Mas como o Node.js lê o arquivo .env? O Node.js NÃO lê arquivos .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
Pool vs Conexão única — por que pool? Com conexão única, se 10 usuários fazem request ao mesmo tempo, 9 esperam. Com pool de 10 conexões, todos são atendidos em paralelo. Em produção, cada request pega uma conexão do pool, usa, e devolve.

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 no Windows O 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.
Se deu erro de conexão com o banco 1. Verifique se o MySQL está rodando:
    Linux/Mac: sudo systemctl status mysql
    Windows: 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á correta
3. 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;
Nota: ainda não inserimos usuário O usuário precisa ter a senha como hash bcrypt — não dá para inserir manualmente com SQL puro. Isso será feito no próximo capítulo (S3) quando implementarmos o registro via API.
Checkpoint — O que você fez até agora ✅ Banco 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.
S2.5 Conceitos JavaScript que Vamos Usar

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.

Analogia: Aprendendo a usar as ferramentas antes de construir Antes de montar um móvel, você aprende a usar a furadeira, a chave allen e a trena. Aqui é a mesma coisa — vamos aprender async/await, try/catch, arrow functions e outras ferramentas antes de usá-las na construção do sistema.

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;
Quando usar qual? No nosso projeto, usamos arrow functions para tudo — controllers, callbacks, funções auxiliares. A forma tradicional (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!
Analogia: Pedido no restaurante Sem 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.
Regra de ouro: 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
  }
};
Analogia: Rede de proteção do trapezista O 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"
Analogia: Caixas de entrega Cada arquivo é uma fábrica que produz coisas. 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ário
req.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
Analogia: Atendimento ao cliente 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; ...
Onde vamos usar: const { nome, email } = req.body; — extrair campos do corpo da requisição
const [rows] = await pool.query(...) — pegar só as linhas do resultado SQL
const { pool } = require('./connection') — importar só o pool

Queries 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]);
O que é SQL Injection? É quando um atacante coloca código SQL dentro de um campo de texto para manipular suas queries. Com ? (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
200OKTudo deu certo (padrão do res.json())
201CreatedRecurso criado com sucesso (INSERT)
400Bad RequestDados inválidos ou faltando
401UnauthorizedNão logado / token inválido
403ForbiddenLogado mas sem permissão (role errado)
404Not FoundRecurso não existe
409ConflictConflito (ex: email já cadastrado)
500Internal Server ErrorBug no servidor (erro do catch)
Regra prática: Sucesso sem criação = 200. Criou algo = 201. Erro do cliente = 4xx. Erro do servidor = 500. Na dúvida, 200 para sucesso e 400 para erro.
S3 Auth — Registro e Login (Primeiro Controller Real)

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.

Analogia: O Crachá da Empresa Imagine um prédio comercial com várias empresas. Na portaria, você se identifica (login) e recebe um crachá (token JWT). O crachá tem seu nome, empresa e cargo. Cada andar verifica o crachá antes de deixar você entrar. Se o crachá for de outra empresa, você não acessa os documentos dela. Se seu cargo for "estagiário", você não acessa a sala do RH.

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:

Arquivo: backend/src/controllers/authController.js

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
Analogia: Abrir uma empresa no mundo real 1. Preencher a ficha (validar campos)
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...
Percebeu? A mesma senha gera hashes diferentes! Isso é por causa do salt — um valor aleatório misturado na senha antes do hash. Mesmo que dois usuários usem "123456", os hashes serão diferentes. Isso impede ataques com tabelas pré-calculadas (rainbow tables).

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)
Analogia: Cofre com combinação Imagine que a senha é uma combinação de cofre. O bcrypt não guarda a combinação — ele guarda o resultado de girar os números (o hash). Na hora de abrir (login), ele gira os números que você digitou e vê se o cofre abre. Se abrir, a senha tá certa. Mas olhando o resultado, ninguém descobre a combinação original.

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
O que é o número 10 no genSalt? É o custo (salt rounds). Quanto maior, mais lento o hash — e mais seguro. 10 é o padrão recomendado. Com 10, leva ~100ms. Com 15, ~3 segundos. Isso é proposital: se um hacker tentar testar milhões de senhas, cada tentativa vai demorar, tornando o ataque inviável.

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
Analogia: Crachá com holograma O payload é o texto impresso no crachá (seu nome, empresa, cargo). A signature é o holograma — só a portaria oficial consegue criar um holograma válido. Se alguém tentar falsificar o crachá (alterar o payload), o holograma não vai bater, e o segurança (authMiddleware) rejeita na hora.

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
);
O que vai no payload? Só o mínimo necessário para identificar o usuário em cada request. O 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:

Arquivo: backend/src/controllers/authController.js

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
Analogia: Caixa de ferramentas Cada 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.
O que é { 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',
      });
    }
Por que validar no backend se o frontend já valida? Porque qualquer pessoa pode chamar sua API sem o frontend — usando Postman, curl, ou um script. A validação do frontend é para UX (experiência do usuário). A validação do backend é para segurança. Sempre valide nos dois.
    // 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',
      });
    }
O que é o [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];
Por que 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;
O que é 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]
    );
Por que role = 'admin'? Quem cria a empresa é o dono — automaticamente o admin. Depois, esse admin poderá criar outros usuários com roles inferiores (gerente, estoquista) pela tela de Usuários.
    // 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);
  }
};
Por que status 201 e não 200? 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.
Melhoria futura: transação no register O register faz 2 INSERTs: primeiro cria a empresa, depois cria o usuário. Se o primeiro funcionar mas o segundo falhar (ex: email duplicado), fica uma empresa órfã no banco. A solução ideal é usar transação (BEGIN/COMMIT/ROLLBACK), como vamos aprender no S6. Por enquanto, funciona porque validamos o email ANTES dos INSERTs. Mas quando você terminar o S6, volte aqui e adicione transação como exercício.

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];
Segurança: mensagem de erro genérica Nunca diga "email não encontrado" ou "senha incorreta" separadamente. Se fizer isso, um atacante descobre quais emails existem no sistema. Sempre use a mesma mensagem: "Email ou senha incorretos". O atacante não sabe qual dos dois está errado.
Por que 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);
  }
};
Perceba o padrão Tanto o register quanto o login retornam a mesma estrutura: { 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
Analogia: Linha de produção O Express funciona como uma esteira de fábrica. A requisição entra e passa por várias estações:

requisição → cors() → json() → authMiddleware → requireRole → controller

Cada 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
Exercício: teste todos os 6 cenários acima com curl. Para cada um, confirme que o status HTTP e a mensagem de erro estão corretos. Se algum não bater, revise o código do controller.

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 |
+----------------+--------------------------------------------------------------+
Se você vir a senha em texto puro aqui, algo está errado. Verifique se está usando 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)
Analogia: Semáforo HTTP 2xx (verde) = Tudo certo, pode seguir
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
E como o /auth aparece? No 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.
NUNCA esqueça o 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);
  }
};
Checkpoint — O que você fez neste capítulo ✅ Entendeu como bcrypt protege senhas (hash + salt + compare)
✅ 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.
Passo 3 de 8
CRUD Categorias
Seu primeiro CRUD completo — Listar, Criar, Editar, Deletar
✅ Banco ✅ Auth → Categorias Produtos Movimentações Dashboard
S4 CRUD de Categorias — Seu Primeiro CRUD Completo

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.

O que é CRUD? CRUD é uma sigla para as 4 operações básicas de qualquer sistema:

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.
Analogia: Fichário de escritório Imagine um fichário com fichas de categorias. O 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.

Além disso: produtos dependem de categorias A tabela 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:

Arquivo: 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 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 = ?
O que é /: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.
O que é 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:

Arquivo: backend/src/controllers/categoriaController.js

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
O que é SQL Injection e por que usar ?? 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).
De onde vem o 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
Por que retornar o objeto criado? O frontend precisa do 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.
Segurança: sempre filtre por empresa_id no WHERE Se o WHERE fosse apenas 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.
O que é 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ê
Analogia: Pasta com documentos dentro Deletar uma categoria que tem produtos é como tentar jogar fora uma pasta que ainda tem documentos dentro. Primeiro você precisa tirar os documentos (mover para outra categoria ou deletar os produtos). Só depois pode jogar a pasta fora.

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.
Por que DELETE real e não soft delete? Para categorias, usamos DELETE real (remove do banco). Já para usuários, usamos soft delete (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" }

Exercício: teste todos os 6 cenários. O teste 5 é especialmente importante — ele prova que a verificação de FK está funcionando. Se a categoria fosse deletada com produtos associados, esses produtos ficariam órfãos (com 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
Este padrão é o mesmo para produtos, usuários e qualquer outro recurso. As diferenças são: quais campos validar, quais colunas no SQL, e quais verificações de dependência fazer antes de deletar. A estrutura (try/catch, req.params, req.body, pool.query, res.json) é sempre a mesma.

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!
O que é 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).
Checkpoint — O que você fez neste capítulo ✅ Entendeu o padrão CRUD (Create, Read, Update, Delete)
✅ 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.
Passo 4 de 8
CRUD Produtos
O CRUD do mundo real — JOIN, .map(), validações, soft delete
✅ Banco ✅ Auth ✅ Categorias → Produtos Movimentações Dashboard
S5 CRUD de Produtos — O CRUD do Mundo Real

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.

Analogia: Categorias foi o treino, Produtos é o jogo Categorias era como treinar passes no campo vazio — poucos campos, lógica simples. Produtos é o jogo de verdade: mais jogadores (colunas), mais regras (validações), e uma jogada nova que não existia no treino (JOIN). Mas a estrutura do jogo é a mesma: try/catch, pool.query, res.json. Você já sabe jogar.

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
6 conceitos novos neste capítulo Tudo que está em negrito na tabela acima é conceito novo. Vamos aprender cada um antes de escrever o código. Não se assuste com a quantidade — o padrão base (try/catch, pool.query, res.json) é o mesmo do S4.

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
O que é SKU? SKU (Stock Keeping Unit) é um código que você cria para identificar o produto. Exemplo: "CAM-AZ-M-001" = CAMiseta AZul tamanho M, unidade 001. Supermercados usam para organizar estoque. No nosso sistema, o SKU é único por empresa — duas empresas diferentes podem ter o mesmo SKU, mas dentro da mesma empresa não pode repetir.
O que é DECIMAL(10,2) — e por que NÃO usar FLOAT para dinheiro? 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 vs estoque_minimo 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.
O que é soft delete (ativo = 0)? Em categorias, usamos 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:

Arquivo: 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);             // 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)
Por que buscarPorId não existia em categorias? Categorias tem só 2 campos editáveis (nome, descricao) — cabem na tabela. Produtos tem 12 campos — o frontend precisa de uma tela separada para exibir/editar todos os detalhes. Quando o usuário clica em "Editar", o frontend chama 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?

Analogia: Código e tabela de referência Imagine que cada produto no estoque tem uma etiqueta com um código numérico da categoria: "1", "2", "3". Para saber o que "1" significa, você consulta uma tabela de referência colada na parede: 1 = Roupas, 2 = Eletrônicos, 3 = Alimentos.

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
O que são esses apelidos "p" e "c"? São alias (apelidos) das tabelas. Quando duas tabelas têm colunas com o mesmo nome (ambas têm 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.
LEFT JOIN vs INNER JOIN — Qual usar? INNER JOIN — só retorna produtos que TÊM categoria. Se um produto tem 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.
Por que 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!
Analogia: Máquina de carimbar Imagine uma esteira com caixas passando. O .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
'ana' 'ANA'
'carlos' 'CARLOS'
'bia' 'BIA'
O que o .map() NÃO faz .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' },
// ]
Atenção: os parênteses em .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 objeto
produto => { ...produto, campo: valor } — erro de sintaxe!

É uma pegadinha clássica. Se o erro disser Unexpected token, verifique os parênteses.
O que é ...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 novo

Se 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:

Arquivo: backend/src/controllers/produtoController.js

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'
Por que usar crase ` em vez de aspas ' no SQL? A crase (template literal) permite quebrar a string em várias linhas. O SQL com JOIN fica grande — em uma linha só seria impossível de ler. Com crase, cada cláusula (SELECT, FROM, JOIN, WHERE, ORDER) fica em sua própria linha.
Por que o .map() é útil aqui? O campo 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
Por que 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
O que é 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.
Por que 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.
O que é 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);
  }
};
Percebeu que 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.
O que é 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)
Analogia: Deletar vs Aposentar Categorias: DELETE é como demitir e apagar todos os registros.
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.
Por que 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()!
    ...
  }
] }
Dois campos que não existem na tabela apareceram! 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"
Mas o produto ainda está no banco! Verifique direto no MySQL:
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" }

Exercício: teste todos os 8 cenários. Os testes 5, 6 e 7 são os mais importantes — testam soft delete, unicidade de SKU e validação de FK.

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
Quando usar esse padrão no futuro Este padrão serve para qualquer recurso: clientes, fornecedores, pedidos, notas fiscais. O que muda são os campos e as regras de negócio. A estrutura (try/catch, pool.query, res.json, affectedRows) é sempre a mesma.

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;
Exercício: Compare este arquivo com 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.
Checkpoint — O que você aprendeu neste capítulo ✅ Entendeu a tabela de produtos (12 colunas) e o papel de cada campo
✅ 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.
Passo 5 de 8
Movimentações de Estoque
Entrada e saída — onde o estoque finalmente ganha vida
✅ Banco ✅ Auth ✅ Categorias ✅ Produtos → Movimentações Dashboard
S6 Movimentações de Estoque — Entrada e Saída

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.

Analogia: O caderno do almoxarife Imagine um almoxarife que controla o estoque de um depósito. Cada vez que chega mercadoria, ele anota: "Entrou 50 camisetas — compra do fornecedor X". Cada vez que sai: "Saiu 3 camisetas — venda para cliente Y". No final do dia, ele soma tudo para saber quanto tem.

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)
Por que não tem Atualizar nem Deletar? Movimentações são como extratos bancários — uma vez registrada, não pode ser alterada nem apagada. Se entrou errado, você faz uma movimentação de ajuste (nova entrada ou saída) para corrigir. Isso garante rastreabilidade total: dá para saber exatamente o que aconteceu e quando.

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
O que é ENUM('entrada', 'saida')? 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
Por que 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.
Por que 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

Arquivo: 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);    // 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:

  1. INSERT INTO movimentacoes — registrar a movimentação
  2. UPDATE 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.

O problema: operação pela metade Imagine transferir R$100 de uma conta para outra. São 2 operações: debitar de uma conta e creditar na outra. Se o débito funciona mas o crédito falha, o dinheiro sumiu! O banco nunca permite isso — ou transfere tudo, ou não transfere nada.

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
Analogia completa: O rascunho do almoxarife O almoxarife precisa anotar uma entrada E atualizar o total. Ele não anota direto no livro oficial (perigoso). Primeiro, ele anota num rascunho (BEGIN). Se tudo bate, ele passa a limpo no livro (COMMIT). Se algo está errado, ele amassa o rascunho e joga fora (ROLLBACK). O livro oficial nunca fica pela metade.

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)
Por que 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.
O que é 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
É só adicionar outro LEFT JOIN! Múltiplos JOINs funcionam como uma corrente: a tabela principal (movimentacoes) se liga a produtos, e depois se liga a users. Cada JOIN adiciona colunas novas ao resultado. Você pode ter 3, 4, 5 JOINs — a sintaxe é sempre a mesma.
Por que 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
Analogia: Receber mercadoria no depósito 1. O entregador chega com a nota fiscal (validar dados)
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:

Arquivo: backend/src/controllers/movimentacaoController.js

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
Perceba: listar não precisa de transação Transação só é necessária quando você faz várias escritas que precisam ser atômicas. Um SELECT sozinho (leitura) não precisa. A listagem usa 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;
Por que 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',
      });
    }
O que é .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.
Por que 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',
      });
    }
Por que buscamos 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}`,
      });
    }
Por que impedir estoque negativo? Se o estoque do produto é 10 e alguém tenta dar saída de 50, o resultado seria -40. Estoque negativo causa problemas em cascata: o dashboard mostra números absurdos, relatórios ficam errados, e o financeiro não fecha.

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 que é &&? É 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)
Por que 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.
A resposta inclui estoque_anterior e estoque_novo Isso é muito útil para o frontend mostrar: "Entrada de 50 unidades. Estoque: 100 → 150". O usuário vê a confirmação visual de que a operação foi feita corretamente.
try / catch / finally — Resumo 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",
    ...
  }
] }
Note a ordem: saída (id 2) aparece antes da entrada (id 1). Isso é o 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.

Exercício: teste todos os 7 cenários. O teste 3 (estoque insuficiente) e o teste 7 (verificar no banco) são os mais importantes. Eles comprovam que a transação funciona corretamente e que o estoque está consistente.

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;
Percebeu? Só 2 rotas (GET + POST). Não tem PUT nem DELETE — porque movimentações são imutáveis. É como um extrato bancário: você não edita uma transação passada, você cria uma nova para corrigir.
Checkpoint — O que você aprendeu neste capítulo ✅ Entendeu por que movimentações são imutáveis (não edita, não deleta)
✅ 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.
Passo 6 de 8
Dashboard
Queries agregadas, Promise.all e a visão geral do negócio
✅ Banco ✅ Auth ✅ Categorias ✅ Produtos ✅ Movimentações → Dashboard
S7 Dashboard — A Visão Geral do Negócio

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.

Analogia: Painel do carro O dashboard de um carro mostra velocidade, combustível, temperatura e RPM — tudo num olhar só, sem precisar abrir o capô. O dashboard do sistema é igual: mostra a saúde do negócio sem o usuário precisar abrir cada tela separadamente.

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:

Arquivo: backend/src/controllers/dashboardController.js
// 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
Analogia: Contar caixas no depósito 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
O que é 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:002026-03-03
Por que 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
Analogia: Inventário do depósito 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.
Por que 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!)
Analogia: Garçons no restaurante Sequencial = UM garçom: vai até a cozinha pedir o prato 1, espera ficar pronto, traz. Volta, pede o prato 2, espera, traz. 5 pratos = 5 viagens.

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
Promise.all vs await sequencial — Quando usar cada um? Promise.all — quando as operações são independentes (uma não precisa do resultado da outra). Exemplo: 5 queries do dashboard.

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([...]);
O que é [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:

Arquivo: backend/src/controllers/dashboardController.js

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
Por que 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.
Por que 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.
Perceba a simplicidade O controller inteiro tem uma função, uma rota, zero transação. É só leitura (5 SELECTs). Comparado com o S6 (transação, getConnection, rollback), o dashboard é tranquilo. A complexidade está em entender Promise.all e as funções de agregação.

S7.6 A Rota — Uma Só

Arquivo: 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('/stats', dashboardController.getStats);  // GET /dashboard/stats

Uma única rota: GET /dashboard/stats. Sem parâmetros, sem body — só precisa do token.

Por que /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
  }
}
Os números dependem dos seus dados de teste. Se você seguiu os capítulos anteriores e criou produtos, categorias e movimentações, os números vão refletir exatamente o que tem no banco. Se tudo é zero, verifique se tem dados inseridos e se o 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();
"
O que é UNION ALL? 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;
Uma rota só! O dashboard é read-only — apenas lê dados. Uma rota GET que retorna todas as estatísticas de uma vez.
O backend está completo! Com 5 controllers e 14 rotas, temos todas as operações que o sistema precisa: autenticação, CRUD de categorias e produtos, movimentações de estoque e dashboard com estatísticas. O próximo passo é conectar o frontend React a essas APIs.
Checkpoint — O que você aprendeu neste capítulo ✅ Aprendeu COUNT(*) — contar registros no banco
✅ 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.
Passo 7 de 8
Frontend — Conectando ao Backend
React falando com a API — fetch, useEffect, useState, Context
✅ Banco ✅ Auth ✅ Categorias ✅ Produtos ✅ Movimentações ✅ Dashboard → Frontend
S7.5 Conceitos React que Vamos Usar

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>
  );
};
Diferenças do HTML normal: classclassName (class é palavra reservada do JS)
forhtmlFor (for é palavra reservada do JS)
{variavel} → insere o valor da variável no JSX
Tags 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>
  );
};
Analogia: Lousa da sala de aula 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>;
};
O [] no final é crucial! useEffect(fn, []) = executa uma vez ao abrir a página
useEffect(fn, [id]) = executa toda vez que id muda
useEffect(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)}
/>
Por que 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);
Analogia: Wi-Fi do escritório O Provider é o roteador Wi-Fi — transmite o sinal (dados) para todo o prédio. 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.
O que é 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-600background-color: #2563ebFundo azul
text-whitecolor: whiteTexto branco
px-4padding-left: 1rem; padding-right: 1remPadding horizontal
py-2padding-top: 0.5rem; padding-bottom: 0.5remPadding vertical
rounded-lgborder-radius: 0.5remBordas 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+
Não precisa decorar! Tailwind tem documentação excelente. Se precisar de algo, pesquise "tailwind border" ou "tailwind flex". O padrão é: propriedade-valor (ex: text-red-500, mt-4, flex, grid). Com o tempo, você decora os mais usados.
S8 Frontend — Conectando o React ao Backend

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.

Analogia: Garçom entre o cliente e a cozinha O frontend é o salão do restaurante (o que o cliente vê). O backend é a cozinha (onde a comida é preparada). O fetch é o garçom — leva o pedido do salão para a cozinha e traz o prato de volta. Até agora, íamos direto na cozinha gritar o pedido (curl). Agora vamos usar o garçom (fetch) como num restaurante de verdade.

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:

Arquivo: frontend/src/services/api.ts
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
O que é o Proxy do Vite? Em desenvolvimento, o frontend roda na porta 5174 e o backend na 3737. Se o frontend chamar 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
O que é 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á.
O que é <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.

Arquivo: frontend/src/contexts/AuthContext.tsx
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';
Os 3 imports — de onde vem cada um? 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);
O que é uma interface aqui? A 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
Por que o 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);
O que é 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.
O que é !! (dupla negação)? É um truque do JavaScript para converter qualquer valor em true ou false:

!!nullfalse
!!"abc"true
!!""false
!!0false

Usamos !!token para dizer: "se tem token, o usuário está autenticado".

S8.4 Login.tsx — O Formulário que Conecta

Arquivo: frontend/src/pages/Login.tsx
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"
O fluxo completo do clique em "Entrar" 1. Usuário clica "Entrar" → handleSubmit roda
2. handleSubmit chama login(email, senha) do AuthContext
3. AuthContext chama apiFetch('/auth/login', { method: 'POST', body: ... })
4. apiFetch faz fetch para /api/auth/login
5. O proxy do Vite redireciona para http://localhost:3737/auth/login
6. 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 Dashboard

9 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
O que é {erro && (...)}? É um padrão do React chamado renderização condicional. O && funciona assim:

Se erro é "" (vazio) → JavaScript considera false → não renderiza nada
Se 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

Arquivo: frontend/src/pages/Dashboard.tsx
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';
Os imports — de onde vem cada um? useState, useEffect — hooks do React (veremos a seguir)
Package, FolderOpen, ... — ícones da biblioteca lucide-react
StatCard — 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();
  }, []);
Conceito-chave: useEffect — Executar código quando a página abre O 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
Por que criar 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(); }, []) — CERTO

A 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"
O que é ?. (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.
O que é ?? (nullish coalescing)? Parecido com ||, mas só considera null e undefined como "vazio":

0 || 55 (porque 0 é falsy — ruim! zero é um número válido)
0 ?? 50 (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

Arquivo: frontend/src/App.tsx
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
Analogia: Catraca da empresa O 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

Arquivo: frontend/src/pages/Categorias.tsx
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('');
6 estados — por que tantos? 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ário
editandoId — se é null, estamos criando. Se é um número, estamos editando esse ID
erro — mensagem de erro para mostrar ao usuário

Cada 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(); }, []);
Por que 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);
    }
  };
O que é 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>
  );
}
O .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.
O que é 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;
}
O que é 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
      },
    },
  },
});
O que o proxy faz? Quando o frontend faz 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>
);
A ordem importa! 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;
O que é <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.
O que é 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;
Exercício: Compare Registro.tsx com Login.tsx. São quase iguais! A diferença: Registro tem 4 campos (nome, email, senha, empresaNome) enquanto Login tem 2 (email, senha). O padrão é o mesmo: estados → handleSubmit → try/catch → navigate. Esse padrão serve para qualquer formulário.

S8.10 Produtos.tsx — CRUD Completo com Select de Categoria

Arquivo: frontend/src/pages/Produtos.tsx
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');
Por que preços são 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(); }, []);
Promise.all — 2 garçons ao mesmo tempo No Dashboard (S8.5) fizemos uma chamada. Aqui precisamos de duas: produtos E categorias (para o <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>
  );
}
O que mudou em relação a Categorias.tsx? 1. Formulário com <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

Arquivo: frontend/src/pages/Movimentacoes.tsx
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>
  );
}
O que é 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.
O que é 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.
Checkpoint — O que você aprendeu neste capítulo ✅ Entendeu a estrutura de pastas do frontend e por que cada arquivo está onde está
✅ 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.
Passo 8 de 8 — Final
Deploy — Colocando no Ar
VPS + Nginx + PM2 + MySQL + SSL — seu sistema acessível pelo mundo
✅ Banco ✅ Auth ✅ Categorias ✅ Produtos ✅ Movimentações ✅ Dashboard ✅ Frontend → Deploy
S9 Deploy — Colocando o Sistema no Ar

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.

Analogia: Do ensaio ao palco Imagine que você montou uma peça de teatro. Até agora, ensaiou na sua sala (localhost). O deploy é alugar um teatro de verdade (VPS), montar o cenário (Nginx), contratar um técnico de palco que garante que o show não para (PM2), vender os ingressos (domínio) e colocar segurança na porta (SSL/HTTPS). Hoje é o dia da estreia.

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.

Analogia: Alugando um escritório Seu computador é a sua casa — você trabalha lá, mas ninguém entra. Uma VPS é um escritório comercial — tem endereço fixo (IP), fica aberto 24h e qualquer cliente pode visitar.
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!)
A senha não aparece! Quando você digita a senha no terminal SSH, nada aparece na tela — nem asteriscos, nem bolinhas. Isso é por segurança. Simplesmente digite a senha e aperte Enter. Se errar, ele pede de novo.
💻 SSH no Windows No Windows 10/11, o SSH já vem instalado — abra o Prompt de Comando ou Windows Terminal e digite 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.
🚀 Dica PRO: Remote - SSH (extensão do VS Code) Em vez de usar o terminal puro, instale a extensão Remote - SSH no VS Code. Com ela, você se conecta à VPS direto pelo VS Code — edita arquivos, abre terminais, navega pelas pastas e usa o IntelliSense, tudo como se fosse local.

Como configurar:
1. Instale a extensão Remote - SSH (Microsoft) no VS Code
2. Aperte Ctrl+Shift+PRemote-SSH: Connect to Host
3. Digite root@SEU_IP e a senha
4. 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.
Como escolher e comprar uma VPS 1. Acesse o site do provedor (Hostinger, DigitalOcean, Contabo)
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_IP

S9.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.

Analogia: Escritório vazio A VPS é um escritório vazio — só tem as paredes (Ubuntu). Precisamos instalar a rede elétrica (Node.js), o cofre (MySQL), a recepcionista (Nginx) e o segurança (PM2). Um de cada vez.

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
O que é 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
O que é LTS? LTS (Long Term Support) significa que essa versão recebe correções de segurança por anos. Em produção, nunca use versões "Current" (mais novas mas instáveis). Sempre use LTS. É como escolher entre um carro novo experimental ou um modelo testado e aprovado — para o trabalho, escolha o testado.

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
O que é 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á rodando
systemctl start mysql — ligar
systemctl stop mysql — desligar
systemctl restart mysql — reiniciar
systemctl enable mysql — ligar automaticamente quando o servidor reinicia

Passo 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
Senha forte em produção! NUNCA use senhas fracas como "123456" ou "root" em produção. Use uma senha com letras maiúsculas, minúsculas, números e símbolos. Se alguém descobrir a senha do MySQL, pode roubar ou apagar todos os dados dos seus clientes.

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
O que o < 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!"
Analogia: Recepcionista do prédio O Nginx é a recepcionista do prédio. Quando alguém chega (requisição HTTP), ela pergunta: "Veio buscar o quê?" Se é um arquivo do site (HTML, CSS, imagem), ela mesma entrega. Se é uma requisição da API (/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
Por que não usar 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.

Analogia: Mudança de casa Você pode levar suas coisas de carro, uma viagem por vez (copiar arquivos manualmente com SCP). Ou pode chamar a transportadora profissional (Git + GitHub) — organizado, rastreável e se perder algo, tem backup.

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
O que é SCP? 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
Por que o node_modules não vai no Git? A pasta 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
Gerando uma chave JWT segura Execute este comando para gerar uma chave aleatória de 64 bytes em hexadecimal:

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.
NUNCA commite o .env no Git! O .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.

Analogia: Impressão vs rascunho Em desenvolvimento, seu código é como um rascunho — fácil de editar, cheio de ferramentas de debug. O build é como mandar para a gráfica — compacta tudo, remove o que não precisa e gera a versão final, otimizada e rápida. Você não edita o livro impresso; edita o rascunho e imprime de novo.
# 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
O que é minificação? O Vite pega todos os seus arquivos .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.
Por que 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   │
└────┴──────────────┴──────┴──────┴────────┴─────────┴────────┘
Lendo a tabela do PM2 id — número do processo (PM2 atribui automaticamente)
name — o nome que demos com --name
mode — fork = processo único (suficiente para nosso caso)
— quantas vezes o PM2 reiniciou (se tiver crash, esse número sobe)
statusonline = 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..."}
Se der erro "Cannot connect" Verifique: pm2 logs estoque-api — mostra os logs do backend. Os erros mais comuns são:

1. ECONNREFUSED no MySQL → senha errada no .env
2. MODULE_NOT_FOUND → esqueceu de rodar npm install
3. 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
O que 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.

O que é um Proxy Reverso? Um proxy reverso é um intermediário que fica entre a internet e o seu servidor. O usuário acessa 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

Usuário digita: meuestoque.com.br │ ▼ ┌─────────────────────────────┐ │ NGINX (:80/:443) │ ← Recebe TUDO da internet │ (proxy reverso + SSL) │ └──────────┬──────────────────┘ │ ┌────────┴────────┐ │ │ ▼ ▼ /dashboard /api/produtos /login /api/auth/login /categorias /api/dashboard │ │ ▼ ▼ ┌──────────┐ ┌──────────────┐ │ dist/ │ │ Node.js │ ← PM2 mantém rodando │ (HTML, │ │ (:3737) │ │ CSS,JS) │ │ Express │ └──────────┘ └──────┬───────┘ │ ▼ ┌──────────────┐ │ MySQL │ ← Banco de dados │ (:3306) │ └──────────────┘

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
O que é 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.
Por que 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
O que é um link simbólico? 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á).
SEMPRE teste com 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.

Analogia: Portas do prédio Imagine que seu prédio tem 65.535 portas (esse é o número total de portas TCP). Se todas estiverem abertas, qualquer pessoa entra. O firewall é o segurança que tranca todas as portas e só abre as que você precisa: 80 (HTTP), 443 (HTTPS) e 22 (SSH).
# 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
CUIDADO: libere a porta 22 ANTES de ativar o firewall! Se você ativar o firewall sem liberar a porta 22 (SSH), você perde acesso ao servidor permanentemente. É como trancar a porta do escritório com a chave dentro. Sempre libere SSH primeiro!
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
E a porta 3737? E a 3306? Não abra a porta 3737 (Node.js) nem a 3306 (MySQL) no firewall! O Nginx acessa o Node.js internamente via 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.

Analogia: IP é o endereço, domínio é o nome fantasia O IP é como o CEP + número do prédio: 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
O que é cada campo? Tipo A — "A de Endereço (Address)" — mapeia um domínio para um IP
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.
DNS demora para propagar! Depois de configurar o DNS, pode demorar de 10 minutos a 48 horas para funcionar no mundo inteiro. Cada provedor de internet tem seu próprio cache DNS. Se não funcionar imediatamente, aguarde. Você pode verificar a propagação em: nslookup meuestoque.com.br

S9.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.

Analogia: Carta aberta vs envelope lacrado Sem SSL, os dados viajam como um cartão postal — qualquer carteiro pode ler. Com SSL, viajam como uma carta em envelope lacrado — só o destinatário abre. O cadeado 🔒 no navegador indica que a comunicação está criptografada.

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)
O que o Certbot faz por baixo dos panos: 1. Entra em contato com Let's Encrypt e prova que você é dono do domínio
2. Gera o certificado SSL (arquivos .pem)
3. Modifica o arquivo do Nginx automaticamente — adiciona listen 443 ssl, caminho dos certificados e redirecionamento HTTP→HTTPS
4. 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
Como a renovação automática funciona? O Certbot cria um timer do sistema (como um cron job) que verifica duas vezes por dia se o certificado está perto de expirar. Se estiver, renova automaticamente. Você não precisa fazer nada — é "configure e esqueça".

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);
Por que funciona igual? Porque usamos caminhos relativos (/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
Script de deploy automatizado (opcional) Se atualiza frequentemente, crie um script para não ter que digitar tudo:

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
O que 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:

Checklist de Deploy — Sistema SaaS Estoque

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 respondendo

Seguranç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 Git

Domí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
VISÃO COMPLETA DO SISTEMA ┌─ Navegador ─────────────────────────────────────────┐ │ React + TypeScript (S8) │ │ ├── Login / Register │ │ ├── Dashboard (estatísticas) │ │ ├── Categorias (CRUD) │ │ ├── Produtos (CRUD) │ │ └── Movimentações (entrada/saída) │ └──────────────┬──────────────────────────────────────┘ │ fetch /api/... ▼ ┌─ Nginx (S9) ─────────────────────────────────────── ┐ │ Proxy Reverso + SSL/HTTPS │ │ / → dist/ (frontend) /api/ → Node.js (:3737) │ └──────────────┬──────────────────────────────────────┘ │ ▼ ┌─ Express.js (S3-S7) ────────────────────────────────┐ │ PM2 mantém rodando 24/7 │ │ ├── authController (S3) → JWT + bcrypt │ │ ├── categoriaController (S4) → CRUD │ │ ├── produtoController (S5) → JOIN, soft delete │ │ ├── movimentacaoController (S6) → Transações │ │ └── dashboardController (S7) → COUNT, SUM │ └──────────────┬──────────────────────────────────────┘ │ mysql2 ▼ ┌─ MySQL (S2) ────────────────────────────────────────┐ │ Multi-tenant: empresa_id em todas as tabelas │ │ ├── empresas │ │ ├── users │ │ ├── categorias │ │ ├── produtos │ │ └── movimentacoes │ └─────────────────────────────────────────────────────┘

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.

Sem backup, não tem sistema profissional. Imagine que um cliente usa o sistema há 6 meses, com 5.000 produtos cadastrados. O disco do servidor queima. Sem backup, esse cliente perdeu tudo. Com backup, você restaura em minutos.

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
O que é $(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
O que é cron? 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
Checkpoint — O que você aprendeu neste capítulo ✅ Entendeu o que é uma VPS e como acessar via SSH (inclusive no Windows)
✅ 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.

PiTech Sistemas

pitech.com.br @pitechsistemas

Desenvolvedor: Paulo Inacio