Arquitetura
O TermoPortal segue o mesmo padrão dos outros sistemas da Termotubos: backend em Rust organizado por feature, com as mesmas 7 operações em toda feature, e frontend em SvelteKit. A regra de negócio, a autorização, o escopo por empresa, a sessão (cookie httpOnly) e os arquivos enviados ficam no Rust; o frontend é um site estático, sem servidor, que chama a API direto do navegador.
Stack
| Camada | Tecnologia |
|---|---|
| Backend | Rust, Axum 0.8 e Tokio |
| Banco | PostgreSQL com SQLx 0.8 (macros checadas em tempo de compilação e migrations), no schema termoportal |
| Frontend | SvelteKit com Svelte 5, TypeScript, bits-ui e Tailwind CSS 4, site estático (adapter-static, SPA) no Cloudflare Pages |
| Autenticação | TermoAuth, o login central da Termotubos (app portal-b2b), com JWT validado offline pelo JWKS |
| Catálogo e cadastros externos | API Termotubos (produtos, clientes do Tiny e colaboradores do RH) |
| Atendimento humano | Chatwoot, pela Application API |
Como uma requisição anda
navegador (site estático, portal.termotubos.com.br) ──► API Rust (api-portal.termotubos.com.br) ──► PostgreSQL (schema termoportal)
cookie httpOnly "sessao" + CORS - O navegador chama a API direto (endereço em
PUBLIC_API_URL). A sessão é o cookie httpOnlysessao, gravado pela API no login (POST /sessao) e mandado em toda chamada; o JavaScript nunca vê o token. - Site e API no mesmo site (
*.termotubos.com.br): o cookie éSameSite=Lax, a API só responde via CORS às origens deFRONTEND_URLe recusa escrita pelo cookie semOrigindo frontend (CSRF). - A API continua ouvindo em
127.0.0.1; a exposição para o navegador (proxy ou túnel) é da infra. - Arquivos enviados (imagens de produto, PDFs de boletim, anexos de demanda) ficam no disco da API, na pasta
UPLOAD_DIR, e são servidos em/arquivo/...(com sessão). Os boletins técnicos também abrem por/boletins/<slug>no portal, que redireciona para a API.
Organização do repositório
├── backend/
│ ├── main.rs
│ ├── router/
│ │ └── cotacao.rs
│ ├── controller/
│ │ ├── cotacao/
│ │ │ ├── criar.rs
│ │ │ ├── buscarPeloId.rs
│ │ │ ├── buscarPeloFiltro.rs
│ │ │ ├── buscarPaginador.rs
│ │ │ ├── buscarAutoComplete.rs
│ │ │ ├── atualizarPeloId.rs
│ │ │ └── deletarPeloId.rs
│ │ └── sessao/
│ │ └── exigirSessao.rs
│ ├── model/
│ │ ├── cotacao/
│ │ └── sessao/
│ ├── integrations/
│ │ ├── termoauth/
│ │ ├── termotubos/
│ │ └── chatwoot/
│ ├── migrations/
│ └── seeds/
├── frontend/
│ ├── src/hooks.client.ts
│ ├── src/lib/
│ │ ├── api/client.ts
│ │ └── auth/session.ts
│ └── src/routes/
│ ├── (portal)/
│ ├── login/
│ ├── app/
│ └── convite/
├── docs/
│ └── projeto/
└── .github/workflows/ Backend: 7 operações por feature
Cada feature tem um arquivo em router/, uma pasta em controller/ com um handler por operação e uma pasta em model/, que é a única camada que fala com o banco.
| Operação | Arquivo | HTTP |
|---|---|---|
| Criar | criar | POST |
| Buscar por id | buscarPeloId | GET /{id} |
| Buscar por filtro | buscarPeloFiltro | GET /filtro |
| Listar paginado | buscarPaginador | GET |
| Autocomplete | buscarAutoComplete | GET /autocomplete |
| Atualizar por id | atualizarPeloId | PATCH /{id} |
| Deletar por id | deletarPeloId | DELETE /{id} |
Features que não são cadastros expõem só o que faz sentido. Por exemplo, sessao tem POST /sessao (entrar) e GET/DELETE /sessao/atual; produto_termotubos tem só a busca na API e a importação; chatwoot_entrega_webhook tem só o POST que o Chatwoot chama.
| Grupo | Features |
|---|---|
| Acesso | sessao, convite_acesso, perfil, pessoa, pessoa_email, empresa, grupo_cliente, endereco, contato |
| Integração de cadastros | empresa_tiny, colaborador_rh, colaborador_termotubos |
| Catálogo | produto, produto_termotubos, produto_conflito_sincronizacao, marca, departamento, categoria, catalogo_arvore_no, catalogo_atributo, boletim_tecnico, grupo_atacado, kit, especificacao, compre_junto, preco_oculto, estoque_forcado, alerta_estoque, avaliacao, favorito |
| Venda | cotacao, frete, cupom, cupom_uso, configuracao |
| Pós-venda | pedido, fatura |
| Atendimento | demanda, atendente_duvida, chatwoot_caixa_entrada, chatwoot_conversa, chatwoot_mensagem, chatwoot_entrega_webhook |
Banco de dados
- PostgreSQL com SQLx, sem ORM: a fonte da verdade é o banco, e o histórico dele são as migrations em
backend/migrations/. O backend aplica as migrations sozinho ao subir. - Schema próprio. Tudo do portal, inclusive a tabela
_sqlx_migrations, fica no schematermoportal(constanteESQUEMA_BANCOemmain.rs). O backend cria o schema se ele não existir e conecta comsearch_pathsó nele, porque os apps da Termotubos podem dividir o mesmo banco, como acontece na sandbox. - Exclusão lógica. Toda tabela tem
deletado boolean NOT NULL DEFAULT false, os índices únicos são parciais (WHERE deletado = false) e as leituras filtram os deletados. - Dinheiro e quantidades fracionárias são
numeric. Enumerações viramtextcomCHECK. Dados aninhados, como os itens de uma cotação, sãojsonb. - Snapshots. Cotação, pedido e fatura guardam uma cópia dos dados do momento (itens com preço, frete aplicado, cupom), para que mudanças posteriores no catálogo não reescrevam o passado.
Autenticação e autorização
O portal não guarda senha. O login é do TermoAuth; o portal só troca o JWT dele por uma sessão local.
- Validação do JWT offline em
integrations/termoauth/: algoritmo EdDSA, chave pública buscada pelokidno JWKS deTERMOAUTH_API_URL(/.well-known/jwks.json),isseaudiguais aTERMOAUTH_URL, eexp. As chaves ficam em cache; umkiddesconhecido só dispara nova busca depois de 30 segundos da anterior. - Sessão local.
POST /sessaoacha a pessoa pelosubdo token (pessoa.identificador_termoauth) e grava na tabelasessaosó o resumo SHA-256 de um token opaco, válido por 5 dias. - Middleware único.
controller/sessao/exigirSessao.rsroda em toda rota. Ele consulta o maparequisito()demodel/sessao/mod.rs, que decide por feature e método HTTP o que a rota exige, e injeta oContextoda sessão (pessoa, se é interna, se é administradora, permissões por módulo, permissões administrativas e empresas).
| Requisito | Quem passa |
|---|---|
Publico | Qualquer um. Só POST /sessao e POST /chatwoot_entrega_webhook |
Sessao | Qualquer pessoa logada |
Modulo(modulo, acao) | Quem tem a ação (ver, criar, editar, excluir) no módulo, pelo perfil |
Administrativa(permissao) | Quem tem a permissão administrativa, como usuarios.gerenciar |
Interno | Só colaborador Termotubos |
InternoComPermissao(permissao) | Colaborador Termotubos com a permissão administrativa |
Administrador | Só perfil master ou administrador |
Negado | Ninguém. É o padrão de toda rota sem entrada no mapa |
O escopo por empresa e por pessoa fica nos controllers: listagem de cliente exige uma empresa das dele, registro de outra empresa responde 404, e o autor de uma cotação ou demanda criada por cliente é sempre quem está logado. O controle por página no frontend só mostra e esconde telas; nunca é a barreira real. O fluxo completo está em Entrada e acesso .
Integrações
| Integração | Pasta | O que o portal usa |
|---|---|---|
| TermoAuth | integrations/termoauth/ | buscarChaves (JWKS) e validarToken. O frontend leva a pessoa para /handoff, /sign-out e /settings do TermoAuth, sempre com ?app=portal-b2b |
| API Termotubos | integrations/termotubos/ | GET /product/search para buscar e importar produtos (buscarProdutos, buscarProdutosPorPrefixo, buscarProdutoPeloSku); GET /customer/contact/search para verificar um CPF ou CNPJ no Tiny (buscarClienteTiny); GET /hr/user-profile/search e GET /hr/user-profile/{id} para colaboradores do RH (buscarColaboradores, buscarColaboradorPeloId) |
| Chatwoot | integrations/chatwoot/ | criarContato, criarConversa, enviarMensagem (com anexos), resolverConversa e buscarCaixaEntrada. O retorno do Chatwoot chega por webhook assinado |
A verificação de cliente no Tiny passa pela API Termotubos (/customer/contact/search). Pedidos e faturas não têm integração automática hoje: são registros gravados no próprio portal.
Variáveis de ambiente
| Onde | Variável | Para quê |
|---|---|---|
| Backend | DATABASE_URL | Obrigatória. Conexão com o PostgreSQL |
| Backend | PORT, HOST | Porta e interface da API (padrão 51011 e 127.0.0.1) |
| Backend | TERMOAUTH_URL, TERMOAUTH_API_URL | Emissor do JWT e endereço do JWKS do TermoAuth |
| Backend | TERMOTUBOS_API_URL, TERMOTUBOS_API_AUTH | Endereço e cabeçalho Authorization da API Termotubos |
| Backend | CHATWOOT_BASE_URL, CHATWOOT_ACCOUNT_ID, CHATWOOT_INBOX_ID, CHATWOOT_API_ACCESS_TOKEN | Conta e caixa de entrada do Chatwoot; sem elas o atendimento humano fica indisponível |
| Backend | CHATWOOT_WEBHOOK_SECRET | Segredo que confere a assinatura do webhook do Chatwoot |
| Backend | FRONTEND_URL | Origens do frontend (CORS e CSRF); com todas em https, o cookie sai com Secure |
| Backend | UPLOAD_DIR | Pasta dos arquivos enviados |
| Frontend | PUBLIC_API_URL | Endereço da API Rust, chamada pelo navegador |
| Frontend | PUBLIC_TERMOAUTH_URL | Endereço do TermoAuth para os links de entrada e saída |
| Frontend | PUBLIC_SITE_URL, PUBLIC_WHATSAPP_URL | Endereço do site institucional e link do WhatsApp |
| Frontend | PUBLIC_CHATWOOT_* | Só exibição na tela de integração do Chatwoot |
No frontend só existem variáveis públicas: todas vão para o navegador, então nunca guardam segredo.
Portas de desenvolvimento
O TermoPortal usa a faixa 51010–51019 do padrão de portas da Termotubos. O TermoAuth, que devolve a pessoa ao portal, roda sempre em http://localhost:51000 e tem o portal-b2b registrado com o frontend em 51010.
| Porta | Serviço |
|---|---|
51010 | frontend: o portal em SvelteKit |
51011 | backend: a API em Rust |
51012 | docs-projeto: esta documentação |
51013 | database-erd: diagrama do banco |
51014 | database-studio: Drizzle Studio |
51019 | PostgreSQL do Docker (backend/docker-compose.yml, banco termo_portal) |
Rodando localmente
1. Banco
Em backend/, docker compose up --detach sobe o PostgreSQL em localhost:51019. O DATABASE_URL do backend/.env.local termina em ?options=-c%20search_path%3Dtermoportal para o sqlx CLI enxergar o mesmo schema do backend.
2. Backend
cargo run aplica as migrations e sobe a API. cargo run --bin seeds cria os dados padrão (perfis, configurações, fretes, departamentos, categorias, alertas de estoque e a pessoa master ti.04@termotubos.com.br); com -- --teste, cria também grupos, empresas e pessoas de teste.
3. Primeiro acesso
A pessoa master entra pelo TermoAuth com o Google, que confirma o e-mail e liga a conta sozinho, ou por convite: cargo run --bin seeds -- --convite ti.04@termotubos.com.br imprime o caminho /convite?token=... para abrir em http://localhost:51010.
4. Frontend
Em frontend/, npm run dev sobe o portal em http://localhost:51010, com PUBLIC_API_URL apontando para a API (e o FRONTEND_URL do backend com http://localhost:51010).
Deploy da sandbox
O repositório oficial é github.com/OrgTermotubos/TermoPortal. Um push na branch sandbox dispara os workflows deploy-sandbox-backend.yml e deploy-sandbox-frontend.yml do GitHub Actions, conforme a pasta alterada.
- Backend: compila em release, monta o
.envcom os segredosSANDBOX_*, envia por SSH para/opt/termoportal/backende reinicia o serviçotermoportal-backend, que escuta em127.0.0.1:51011. - Frontend: site estático no Cloudflare Pages (
npm run build, pastabuild, variáveisPUBLIC_*no ambiente do build). O workflow antigodeploy-sandbox-frontend.yml(SSH + serviço Node) ficou obsoleto com o frontend estático. - Fila única. Os dois workflows usam o grupo de concorrência
deploy-sandboxsem cancelar o anterior: quando um push mexe nos dois lados, os deploys rodam um depois do outro. - TermoAuth da sandbox:
https://sandbox-auth.navsoftware.com.br, com a API emhttps://api-sandbox-auth.navsoftware.com.br.