25 Commits

Author SHA1 Message Date
Erik Silva
3be732b1cc docs: corrige nome da branch no README 2025-12-24 18:01:47 -03:00
Erik Silva
21fbdd3692 docs: atualiza README com funcionalidades da v1.5 - CRM Beta 2025-12-24 17:39:20 -03:00
Erik Silva
dfb91c8ba5 feat: versão 1.5 - CRM Beta com leads, funis, campanhas e portal do cliente 2025-12-24 17:36:52 -03:00
Erik Silva
99d828869a chore(release): snapshot 1.4.2 2025-12-17 13:36:23 -03:00
Erik Silva
2a112f169d refactor: redesign planos interface with design system patterns
- Create CreatePlanModal component with Headless UI Dialog
- Implement dark mode support throughout plans UI
- Update plans/page.tsx with professional card layout
- Update plans/[id]/page.tsx with consistent styling
- Add proper spacing, typography, and color consistency
- Implement smooth animations and transitions
- Add success/error message feedback
- Improve form UX with better input styling
2025-12-13 19:26:38 -03:00
Erik Silva
2f1cf2bb2a v1.4: Segurança multi-tenant, file serving via API e UX humanizada
-  Validação cross-tenant no login e rotas protegidas
-  File serving via /api/files/{bucket}/{path} (eliminação DNS)
-  Mensagens de erro humanizadas inline (sem pop-ups)
-  Middleware tenant detection via headers customizados
-  Upload de logos retorna URLs via API
-  README atualizado com changelog v1.4 completo
2025-12-13 15:05:51 -03:00
Erik Silva
04c954c3d9 feat: Implementação de submenus laterais (flyout), correções de UI e proteção de rotas (AuthGuard) 2025-12-12 15:24:38 -03:00
Erik Silva
83ce15bb36 docs: update README with v1.2 features (flat design, advanced filters) 2025-12-11 23:40:39 -03:00
Erik Silva
dc98d5dccc feat: redesign superadmin agencies list, implement flat design, add date filters, and fix UI bugs 2025-12-11 23:39:54 -03:00
Erik Silva
053e180321 chore: snapshot before agency split 2025-12-09 17:21:25 -03:00
Erik Silva
6ec29c7eef chore: remove unused tenant variable 2025-12-09 03:06:06 -03:00
Erik Silva
1ea381224d fix: remove duplicate tenant service method and handle not found 2025-12-09 03:05:38 -03:00
Erik Silva
9e80aa1d70 feat: block unknown subdomains via tenant check 2025-12-09 03:04:28 -03:00
Erik Silva
74857bf106 fix: place suporte dialog inside page render 2025-12-09 02:58:42 -03:00
Erik Silva
0fee59082b feat: lock CNPJ/email edits, add support dialog and CEP-first layout 2025-12-09 02:53:19 -03:00
Erik Silva
331d50e677 fix: unify tenant context keys and load tenant_id from JWT 2025-12-09 02:42:43 -03:00
Erik Silva
00d0793dab fix: update route handler to use async params for Next.js 16 2025-12-09 02:29:03 -03:00
Erik Silva
fc310c0616 fix: add Next.js API route handler to proxy with correct host headers 2025-12-09 02:24:56 -03:00
Erik Silva
9ece6e88fe debug: add tenant context logging to GetProfile 2025-12-09 02:21:06 -03:00
Erik Silva
773172c63c fix: add API proxy via Next rewrites + fix hardcoded localhost URLs 2025-12-09 02:17:00 -03:00
Erik Silva
86e4afb916 docs: adiciona mapa mental completo do projeto 2025-12-09 02:07:21 -03:00
Erik Silva
44db6195f6 fix: increase rate limit for dev (30 attempts/min) 2025-12-09 02:04:10 -03:00
Erik Silva
a33fb2f544 fix: redirect authenticated dash login 2025-12-09 01:58:39 -03:00
Erik Silva
f553114c06 chore: reorganiza init-db do postgres 2025-12-09 01:51:56 -03:00
Erik Silva
190fde20c3 Prepara versao dev 1.0 2025-12-08 21:47:38 -03:00
324 changed files with 59403 additions and 3194 deletions

View File

@@ -0,0 +1,16 @@
{
"permissions": {
"allow": [
"Bash(docker-compose up:*)",
"Bash(docker-compose ps:*)",
"Bash(grep:*)",
"Bash(cat:*)",
"Bash(docker logs:*)",
"Bash(docker exec:*)",
"Bash(npx tsc:*)",
"Bash(docker-compose restart:*)",
"Bash(npm install:*)",
"Bash(docker-compose build:*)"
]
}
}

36
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,36 @@
{
// ============================================
// CONFIGURAÇÕES TAILWIND CSS
// ============================================
"tailwindCSS.validate": false, // DESATIVA validação para remover avisos chatos
"tailwindCSS.showPixelEquivalents": false,
// ⚠️ ATENÇÃO: AVISOS "suggestCanonicalClasses" SÃO BUGS DO PLUGIN
// O Tailwind CSS IntelliSense está bugado e sugere sintaxe ERRADA.
//
// ✅ Sintaxe CORRETA (Tailwind v4):
// - [var(--brand-color)] ← Use isso!
// - bg-gradient-to-r ← Use isso!
//
// ❌ Sintaxe ERRADA (sugestão bugada):
// - (--brand-color) ← NÃO funciona!
// - bg-linear-to-r ← NÃO funciona!
//
// Por isso desativamos a validação acima (tailwindCSS.validate: false)
// ============================================
// CONFIGURAÇÕES CSS
// ============================================
"css.validate": true,
"css.lint.unknownAtRules": "ignore",
// ============================================
// MELHORIAS NO EDITOR
// ============================================
"editor.quickSuggestions": {
"strings": true
}
}

10
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,10 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build-agency-frontend",
"type": "shell",
"command": "docker compose build agency"
}
]
}

View File

@@ -71,7 +71,7 @@ AGGIOS-APP/
│ └─ letsencrypt/ │ └─ letsencrypt/
│ └─ acme.json (auto-generated) │ └─ acme.json (auto-generated)
├─ 📂 postgres/ ← PostgreSQL Setup (NOVO) ├─ 📂 backend/internal/data/postgres/ ← PostgreSQL Setup (NOVO)
│ └─ init-db.sql ✅ Initial schema │ └─ init-db.sql ✅ Initial schema
├─ 📂 scripts/ ← Helper Scripts (NOVO) ├─ 📂 scripts/ ← Helper Scripts (NOVO)

View File

@@ -77,7 +77,7 @@ aggios-app/
│ ├─ dynamic/rules.yml │ ├─ dynamic/rules.yml
│ └─ letsencrypt/ │ └─ letsencrypt/
├─ 📂 postgres/ .............................. PostgreSQL (NOVO) ├─ 📂 backend/internal/data/postgres/ ........ PostgreSQL (NOVO)
│ └─ init-db.sql │ └─ init-db.sql
├─ 📂 scripts/ ............................... Scripts (NOVO) ├─ 📂 scripts/ ............................... Scripts (NOVO)

View File

@@ -106,7 +106,7 @@ aggios-app/
│ ├── dynamic/rules.yml # Dynamic routing rules │ ├── dynamic/rules.yml # Dynamic routing rules
│ └── letsencrypt/ # Certificados (auto-gerado) │ └── letsencrypt/ # Certificados (auto-gerado)
├── postgres/ # Inicialização PostgreSQL ├── backend/internal/data/postgres/ # Inicialização PostgreSQL
│ └── init-db.sql # Schema initial │ └── init-db.sql # Schema initial
├── scripts/ ├── scripts/

View File

@@ -228,7 +228,7 @@ DOCKER:
CONFIGURAÇÃO: CONFIGURAÇÃO:
├─ YAML files: 2 (traefik.yml, rules.yml) ├─ YAML files: 2 (traefik.yml, rules.yml)
├─ SQL files: 1 (init-db.sql) ├─ SQL files: 1 (backend/internal/data/postgres/init-db.sql)
├─ .env example: 1 ├─ .env example: 1
├─ Dockerfiles: 1 ├─ Dockerfiles: 1
└─ Scripts: 2 (start-dev.sh, start-dev.bat) └─ Scripts: 2 (start-dev.sh, start-dev.bat)

View File

@@ -0,0 +1,529 @@
# 🧠 Mapa Mental - Projeto Aggios
## 📌 Visão Geral
**Aggios** é uma plataforma **SaaS multi-tenant** que gerencia agências digitais com controle centralizado, gestão de clientes, soluções integradas (CRM/ERP) e sistema de pagamento.
---
## 🏛️ Arquitetura Geral
```
┌─────────────────────────────────────────────────────────┐
│ AGGIOS PLATFORM │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Super Admin Dashboard (dash.localhost) │ │
│ │ - Gerenciar todas as agências │ │
│ │ - Visualizar cadastros │ │
│ │ - Excluir/arquivar agências │ │
│ │ - Controle de planos e pagamentos │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────┼────────────┐ │
│ │ │ │ │
│ ┌────────▼──┐ ┌─────▼────┐ ┌───▼────────┐ │
│ │ Agência A │ │ Agência B │ │ Agência N │ │
│ │ Subdomain │ │ Subdomain │ │ Subdomain │ │
│ │ A │ │ B │ │ N │ │
│ └─────┬─────┘ └──────┬────┘ └────┬───────┘ │
│ │ │ │ │
│ ┌─────▼──────┐ ┌─────▼──────┐ ┌─▼───────────┐ │
│ │CRM / ERP │ │CRM / ERP │ │CRM / ERP │ │
│ │Clientes │ │Clientes │ │Clientes │ │
│ │Soluções │ │Soluções │ │Soluções │ │
│ └────────────┘ └────────────┘ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
```
---
## 🔐 Sistema de Autenticação
### Níveis de Acesso
```
┌─────────────────────────────────────────┐
│ PERMISSÕES E ROLES │
├─────────────────────────────────────────┤
│ │
│ SUPERADMIN (admin@aggios.app) │
│ ├─ Gerenciar todas as agências │
│ ├─ Visualizar cadastros │
│ ├─ Excluir/arquivar agências │
│ ├─ Controlar planos │
│ └─ Gerenciar pagamentos │
│ │
│ ADMIN_AGENCIA (por agência) │
│ ├─ Gerenciar clientes próprios │
│ ├─ Acessar CRM/ERP │
│ ├─ Visualizar relatórios │
│ └─ Configurar agência │
│ │
│ CLIENTE (por agência) │
│ ├─ Visualizar próprios dados │
│ ├─ Acessar serviços contratados │
│ └─ Submeter solicitações │
│ │
└─────────────────────────────────────────┘
```
### Fluxo de Login
```
Usuário acessa:
dash.localhost
Detecta "dash" no hostname
Busca localStorage (token + user)
┌─ Token válido? → Redireciona para /superadmin
└─ Sem token? → Mostra /login
Submete credenciais
Backend valida contra DB
┌─ Válido → Retorna JWT + user data
│ → Salva em localStorage
│ → Redireciona para /superadmin
└─ Inválido → Toast error
```
---
## 🏢 Estrutura de Tenants
### Multi-Tenant Model
```
┌─────────────────────────────────────────┐
│ TENANT (Agência) │
├─────────────────────────────────────────┤
│ │
│ ID: UUID │
│ name: "Agência Ideal Pages" │
│ subdomain: "idealpages" │
│ domain: "idealpages.aggios.app" │
│ cnpj: "XX.XXX.XXX/XXXX-XX" │
│ razao_social: "Ideal Pages Ltda" │
│ status: active | inactive │
│ │
│ ┌─────────────────────────────────┐ │
│ │ USERS (pertencentes ao tenant) │ │
│ ├─────────────────────────────────┤ │
│ │ - Admin (ADMIN_AGENCIA) │ │
│ │ - Operadores │ │
│ │ - Suporte │ │
│ │ - Clientes │ │
│ └─────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────┐ │
│ │ COMPANIES (clientes) │ │
│ ├─────────────────────────────────┤ │
│ │ - ID, CNPJ, email, telefone │ │
│ │ - Dados de contato │ │
│ │ - Status │ │
│ └─────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────┐ │
│ │ SOLUTIONS (CRM, ERP, etc) │ │
│ ├─────────────────────────────────┤ │
│ │ - Módulos disponíveis │ │
│ │ - Integrações │ │
│ │ - Configurações │ │
│ └─────────────────────────────────┘ │
│ │
└─────────────────────────────────────────┘
```
---
## 🛠️ Tech Stack
### Backend
```
Backend (Go)
├─ HTTP Server (net/http)
├─ JWT Authentication
├─ Password Hashing (Argon2)
├─ PostgreSQL (SQL direto, sem ORM)
├─ Redis (cache/sessions)
├─ MinIO (object storage)
└─ Middleware (CORS, Security, Rate Limit)
```
### Frontend
```
Frontend (Next.js 14)
├─ Dashboard (Superadmin)
│ ├─ Listagem de agências
│ ├─ Detalhes/visualização
│ └─ Excluir/arquivar
├─ Portais de Agência
│ ├─ Login específico por subdomain
│ ├─ Dashboard da agência
│ ├─ Gerenciador de clientes (CRM)
│ ├─ ERP
│ └─ Integrações
└─ Site Institucional (aggios.app)
├─ Landing page
├─ Pricing/Planos
├─ Documentação
└─ Contato
```
### Infraestrutura
```
Docker Compose
├─ PostgreSQL 16 (DB)
├─ Redis 7 (Cache)
├─ MinIO (S3-compatible storage)
├─ Traefik (Reverse Proxy)
├─ Backend (Go)
├─ Dashboard (Next.js)
└─ Institucional (Next.js)
```
---
## 📊 Banco de Dados
### Schema Principal
```
┌──────────────────────────────────────────────────┐
│ DATABASE SCHEMA │
├──────────────────────────────────────────────────┤
│ │
│ TENANTS │
│ ├─ id (UUID) │
│ ├─ name, subdomain, domain │
│ ├─ cnpj, razao_social │
│ ├─ email, phone, website, address │
│ ├─ description, industry │
│ ├─ is_active │
│ └─ timestamps (created_at, updated_at) │
│ ↑ │
│ └─── FK em USERS │
│ └─── FK em COMPANIES │
│ │
│ USERS │
│ ├─ id (UUID) │
│ ├─ tenant_id (FK → TENANTS) │
│ ├─ email (UNIQUE) │
│ ├─ password_hash │
│ ├─ first_name, last_name │
│ ├─ role (SUPERADMIN | ADMIN_AGENCIA | CLIENTE) │
│ ├─ is_active │
│ └─ timestamps │
│ │
│ REFRESH_TOKENS │
│ ├─ id (UUID) │
│ ├─ user_id (FK → USERS) │
│ ├─ token_hash │
│ ├─ expires_at │
│ └─ created_at │
│ │
│ COMPANIES (Clientes das agências) │
│ ├─ id (UUID) │
│ ├─ tenant_id (FK → TENANTS) │
│ ├─ cnpj (UNIQUE por tenant) │
│ ├─ razao_social, nome_fantasia │
│ ├─ email, telefone │
│ ├─ status │
│ ├─ created_by_user_id (FK → USERS) │
│ └─ timestamps │
│ │
└──────────────────────────────────────────────────┘
```
---
## 🔄 Fluxo de Cadastro (Registro de Nova Agência)
```
1. INICIO
├─ Usuário acessa: http://dash.localhost/cadastro
├─ Preenche formulário:
│ ├─ Nome fantasia
│ ├─ Razão social
│ ├─ CNPJ
│ ├─ Email comercial
│ ├─ Telefone
│ ├─ Website
│ ├─ Endereço completo
│ ├─ Cidade/Estado/CEP
│ ├─ Segmento (indústria)
│ ├─ Descrição
│ ├─ Email do admin da agência
│ └─ Senha inicial do admin
├─ Validação Frontend
│ ├─ Campos obrigatórios
│ ├─ Formato de email
│ ├─ Força de senha
│ └─ CNPJ válido?
├─ POST /api/admin/agencies/register (Backend)
│ │
│ ├─ Validação Backend (regras de negócio)
│ │
│ ├─ Transação DB:
│ │ ├─ Criar TENANT (gera UUID, subdomain)
│ │ ├─ Criar USER (ADMIN_AGENCIA)
│ │ ├─ Hash password (Argon2)
│ │ └─ Commit
│ │
│ └─ Retorna: {tenant_id, subdomain, access_url}
├─ Frontend recebe resposta
│ ├─ Exibe toast de sucesso
│ ├─ Salva dados temporários
│ └─ Redireciona para /superadmin
└─ FIM (Agência criada e pronta para uso)
└─ Acesso: {subdomain}.localhost/login
```
---
## 📈 Funcionalidades por Módulo
### 🔷 Superadmin Dashboard
```
dash.localhost/superadmin
├─ Header
│ ├─ Logo Aggios
│ ├─ Título "Painel Administrativo"
│ ├─ Email do admin
│ └─ Botão Sair
├─ Stats (KPIs)
│ ├─ Total de agências
│ ├─ Agências ativas
│ ├─ Agências inativas
│ └─ (Expandível: faturamento, etc)
├─ Listagem de Agências
│ ├─ Tabela com:
│ │ ├─ Nome fantasia
│ │ ├─ Subdomain
│ │ ├─ Status (ativo/inativo)
│ │ ├─ Data de criação
│ │ └─ Ações (Ver detalhes, Deletar)
│ │
│ └─ Busca/Filtro
└─ Modal de Detalhes
├─ Seção: Dados da Agência
│ ├─ Nome fantasia, razão social
│ ├─ CNPJ, segmento
│ ├─ Descrição
│ └─ Status
├─ Seção: Endereço e Contato
│ ├─ Endereço, cidade, estado, CEP
│ ├─ Website
│ ├─ Email comercial
│ └─ Telefone
├─ Seção: Administrador
│ ├─ Nome do admin
│ ├─ Email do admin
│ ├─ Role
│ └─ Data de criação
└─ Botões
├─ Abrir painel da agência (link externo)
├─ Deletar agência
└─ Fechar
```
### 🔶 Dashboard da Agência (Em Desenvolvimento)
```
{subdomain}.localhost/dashboard
├─ Sidebar
│ ├─ Dashboard
│ ├─ Clientes (CRM)
│ ├─ Projetos
│ ├─ Financeiro (ERP)
│ ├─ Configurações
│ └─ Suporte
├─ Stats
│ ├─ Total de clientes
│ ├─ Projetos em andamento
│ ├─ Tarefas pendentes
│ └─ Faturamento
└─ Seções (em construção)
├─ CRM → Gerenciar clientes, pipeline, negociações
├─ ERP → Pedidos, estoque, NF, financeiro
├─ Projetos → Planejamento, execução, entrega
└─ Integrações → API, webhooks, automações
```
---
## 🔌 APIs Principais
### Autenticação
```
POST /api/auth/login
Request: { email, password }
Response: { token, user: { id, email, name, role } }
POST /api/auth/change-password
Request: { old_password, new_password }
Response: { success: true }
POST /api/auth/logout
Request: {}
Response: { success: true }
```
### Agências (Superadmin)
```
GET /api/admin/agencies
Response: [{ id, name, subdomain, status, ... }]
POST /api/admin/agencies/register
Request: { name, cnpj, email, admin_email, admin_password, ... }
Response: { tenant_id, subdomain, access_url }
GET /api/admin/agencies/{id}
Response: { tenant, admin, access_url, ... }
DELETE /api/admin/agencies/{id}
Response: { success: true } | 204 No Content
PATCH /api/admin/agencies/{id}
Request: { status, ... }
Response: { tenant }
```
### Empresas/Clientes
```
GET /api/companies
Response: [{ id, cnpj, razao_social, email, ... }]
POST /api/companies/create
Request: { cnpj, razao_social, email, telefone, ... }
Response: { company }
GET /api/companies/{id}
Response: { company }
PUT /api/companies/{id}
Request: { razao_social, email, ... }
Response: { company }
```
---
## 🚀 Ciclo de Desenvolvimento Atual
### v1.1 (dev-1.1) - Em Progresso
- ✅ Reorganização do banco (init-db em backend/internal/data/postgres)
- ✅ Autenticação de login com redirect automático
- ✅ Aumento de rate limit em dev (30 tentativas/min)
- 🔄 Melhorias na UX do dashboard superadmin
- ⏳ Implementação de CRM (clientes, pipeline)
- ⏳ Implementação de ERP básico (pedidos, financeiro)
### Próximas Versões
- 📅 v1.2: Soft delete, auditoria, trilha de mudanças
- 📅 v1.3: Integrações externas (Zapier, Make, etc)
- 📅 v1.4: Sistema de pagamento (Stripe, PagSeguro)
- 📅 v2.0: Marketplace de templates/extensões
---
## 📋 Checklist de Implementação
### Backend
- [x] Setup inicial (config, database, middleware)
- [x] Autenticação (JWT, refresh tokens)
- [x] Repositórios (sem ORM, SQL direto)
- [x] Serviços (business logic)
- [x] Handlers (endpoints)
- [x] Rate limiting
- [ ] Soft delete & auditoria
- [ ] Logging estruturado
- [ ] Testes unitários
- [ ] Documentação de API
### Frontend
- [x] Login com redirect automático
- [x] Dashboard superadmin (lista, detalhes, delete)
- [x] Site institucional
- [ ] Dashboard da agência (CRM base)
- [ ] Gestão de clientes
- [ ] Formulários avançados
- [ ] Testes e2e
### DevOps
- [x] Docker Compose com todos os serviços
- [x] Traefik reverse proxy
- [x] PostgreSQL com seed data
- [x] Redis e MinIO
- [ ] CI/CD pipeline
- [ ] Monitoramento
- [ ] Backup strategy
---
## 💡 Notas Importantes
### Por Que Sem ORM?
- Controle fino sobre queries
- Performance previsível
- Menos abstrações, mais explícito
- Facilita debugging
- Legível para new devs
**Trade-off:** Mais boilerplate de SQL, mas melhor para equipes experientes.
### Segurança
- JWT + Refresh tokens
- Password hashing (Argon2)
- Rate limiting (5 req/min em prod, 30 em dev)
- CORS configurado
- Security headers
- Input validation em frontend + backend
### Escalabilidade
- Multi-tenant isolado por tenant_id
- Índices em FK e campos frequentes
- Redis para cache de sessions
- MinIO para object storage
- Stateless backend (escalável horizontalmente)
---
## 📞 Contatos & Referências
- **Repository:** https://git.stackbyte.cloud/erik/aggios.app.git
- **Documentação detalhada:** `/1. docs/backend-deployment/`
- **API Reference:** `/1. docs/backend-deployment/API_REFERENCE.md`
- **Deployment Guide:** `/1. docs/backend-deployment/DEPLOYMENT.md`

View File

@@ -0,0 +1,174 @@
# Arquitetura Multi-tenant - Modelo de Negócio Aggios
## Visão Geral da Plataforma
A plataforma Aggios utiliza uma arquitetura multi-tenant em três camadas principais:
```
┌─────────────────────────────────────────────────┐
│ aggios.app (Site Institucional) │
│ - Marketing │
│ - Cadastro de novas agências │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ dash.aggios.app (SuperAdmin) │
│ - Você (dono da plataforma) │
│ - Gerencia TODAS as agências │
│ - Vê analytics globais │
└─────────────────────────────────────────────────┘
┌───────────┴───────────┐
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ idealpages. │ │ outraagencia. │
│ aggios.app │ │ aggios.app │
├──────────────────┤ ├──────────────────┤
│ Painel da │ │ Painel da │
│ IdeaPages │ │ Outra Agência │
│ │ │ │
│ • CRM │ │ • CRM │
│ • ERP │ │ • ERP │
│ • Projetos │ │ • Projetos │
│ • White Label │ │ • White Label │
│ (seu logo) │ │ (logo deles) │
└──────────────────┘ └──────────────────┘
│ │
▼ ▼
Clientes da Clientes da
IdeaPages Outra Agência
```
## Como Funciona na Prática
### 1. Sua Agência (Exemplo: IdeaPages)
- **URL**: `idealpages.aggios.app`
- **White Label**: Logo e cores da IdeaPages
- **Clientes**: Cadastrados DENTRO da agência IdeaPages
- **Isolamento**: Cada cliente é isolado por tenant_id (multi-tenant)
### 2. Quando um Cliente Precisa do CRM
**Você SEMPRE manda a URL da sua agência**, não aggios.app!
- Cliente cria conta em `idealpages.aggios.app`
- Cliente acessa `idealpages.aggios.app` com login próprio
- Cliente vê **SEU logo** (IdeaPages)
- Cliente vê **SEU white label**
- Cliente só vê os dados DELE (isolamento por tenant)
### 3. Estrutura de Clientes
```
IdeaPages (você - agência)
├── Cliente 1 (Empresa ABC)
│ ├── Vê: Logo IdeaPages
│ ├── Acessa: idealpages.aggios.app
│ └── Usa: CRM, ERP, Projetos (dados isolados)
├── Cliente 2 (Tech Solutions)
│ ├── Vê: Logo IdeaPages
│ ├── Acessa: idealpages.aggios.app
│ └── Usa: CRM, ERP, Projetos (dados isolados)
└── Cliente 3 (Marketing Pro)
├── Vê: Logo IdeaPages
├── Acessa: idealpages.aggios.app
└── Usa: CRM, ERP, Projetos (dados isolados)
```
## Benefícios para a Agência
**White Label Completo**: Cliente vê sua marca, não "Aggios"
**Controle Total**: Você gerencia todos os seus clientes
**Isolamento de Dados**: Cada cliente só vê os próprios dados
**Escalável**: Adicione quantos clientes quiser na mesma agência
**Identidade Visual**: Logo e cores personalizadas por agência
## Fluxo de Trabalho
1. **Agência se cadastra** → Cria subdomínio (ex: idealpages.aggios.app)
2. **Agência personaliza** → Upload de logo, cores, identidade visual
3. **Agência adiciona clientes** → Cada cliente recebe credenciais
4. **Cliente acessa** → idealpages.aggios.app (vê marca da agência)
5. **Cliente usa módulos** → CRM, ERP, Projetos (dados isolados)
## Resposta Direta
**Pergunta**: "Cliente precisa do CRM, mando aggios.app ou idealpages.aggios.app?"
**Resposta**: **`idealpages.aggios.app`** ✅
O cliente SEMPRE acessa o painel da sua agência, onde verá sua marca e terá acesso aos módulos que você liberar.
---
## Sistema de Links de Cadastro Personalizados
### Visão Geral
Sistema que permite ao SuperAdmin criar links de cadastro customizados, escolhendo:
- **Campos do formulário**: Quais informações coletar
- **Módulos habilitados**: Quais funcionalidades o cliente terá acesso
- **Branding**: Logo e cores personalizadas
### Fluxo de Uso
1. **SuperAdmin** acessa `dash.aggios.app/superadmin/signup-templates`
2. **Cria template** selecionando:
- Campos: email, senha, subdomínio, CNPJ, telefone, etc.
- Módulos: CRM, ERP, PROJECTS, FINANCIAL, etc.
- Slug: URL amigável (ex: `crm-rapido`)
3. **Compartilha link**: `aggios.app/cadastro/crm-rapido`
4. **Cliente acessa** e vê formulário personalizado
5. **Após cadastro**, tenant criado com módulos específicos
### Exemplo Real: DH Projects
```
Template: "CRM Rápido"
Slug: crm-rapido
Campos: email, senha, subdomínio, nome da empresa
Módulos: CRM
Link gerado: aggios.app/cadastro/crm-rapido
Cliente preenche:
- Email: contato@dhprojects.com
- Senha: ********
- Subdomínio: dhprojects
- Empresa: DH Projects
Resultado:
✅ Tenant criado: dhprojects.aggios.app
✅ Módulo CRM habilitado
✅ Outros módulos desabilitados
```
### Estrutura Técnica
**Backend:**
- Tabela: `signup_templates`
- Repository: `SignupTemplateRepository`
- Handlers: `/api/admin/signup-templates` (CRUD)
- Handler público: `/api/signup-templates/slug/{slug}` (renderiza form)
**Frontend:**
- Gerenciamento: `dash.aggios.app/superadmin/signup-templates`
- Cadastro público: `aggios.app/cadastro/{slug}`
**Campos Disponíveis:**
- email, password, subdomain (obrigatórios)
- company_name, cnpj, phone, address, city, state, zipcode (opcionais)
**Módulos Disponíveis:**
- CRM, ERP, PROJECTS, FINANCIAL, INVENTORY, HR
### Benefícios
✅ Cadastro rápido para clientes específicos
✅ Coleta apenas informações necessárias
✅ Habilita somente módulos contratados
✅ Reduz fricção no onboarding
✅ Personalização por caso de uso

149
1. docs/nova-interface.md Normal file
View File

@@ -0,0 +1,149 @@
# System Instruction: Arquitetura de Layout com Sidebar Expansível
**Role:** Senior React Developer & UI Specialist
**Tech Stack:** React, Tailwind CSS (Sem bibliotecas de ícones ou fontes externas).
**Objetivo:**
Implementar um sistema de layout "Dashboard" composto por um **Menu Lateral (Sidebar)** que expande e colapsa suavemente e uma área de conteúdo principal.
**Requisitos Críticos de Animação:**
1. A transição de largura da sidebar deve ser suave (transition-all duration-300).
2. O texto dos botões **não deve quebrar** ou desaparecer bruscamente. Use a técnica de transição de `max-width` e `opacity` para que o texto deslize suavemente para fora.
3. Não utilize bibliotecas de animação (Framer Motion, etc), apenas Tailwind CSS puro.
---
## 1. Componente: `DashboardLayout.tsx` (Container Principal)
Este componente deve gerenciar o estado global do menu (aberto/fechado) para evitar "prop drilling" desnecessário.
```tsx
import React, { useState } from 'react';
import { SidebarRail } from './SidebarRail';
interface DashboardLayoutProps {
children: React.ReactNode;
}
export const DashboardLayout: React.FC<DashboardLayoutProps> = ({ children }) => {
// Estado centralizado do layout
const [isExpanded, setIsExpanded] = useState(true);
const [activeTab, setActiveTab] = useState('home');
return (
<div className="flex h-screen w-full bg-gray-900 text-slate-900 overflow-hidden p-3 gap-3">
{/* Sidebar controla seu próprio estado visual via props */}
<SidebarRail
activeTab={activeTab}
onTabChange={setActiveTab}
isExpanded={isExpanded}
onToggle={() => setIsExpanded(!isExpanded)}
/>
{/* Área de Conteúdo (Children) */}
<main className="flex-1 h-full min-w-0 overflow-hidden flex flex-col bg-white rounded-3xl shadow-xl relative">
{children}
</main>
</div>
);
};
```
## 2. Componente: `SidebarRail.tsx` (Lógica de Animação)
Aqui reside a lógica visual. Substitua os ícones por `<span>Icon</span>` ou SVGs genéricos para manter o código agnóstico.
**Pontos de atenção no código abaixo:**
* `w-[220px]` vs `w-[72px]`: Define a largura física.
* `max-w-[150px]` vs `max-w-0`: Define a animação do texto.
* `whitespace-nowrap`: Impede que o texto pule de linha enquanto fecha.
```tsx
import React from 'react';
interface SidebarRailProps {
activeTab: string;
onTabChange: (tab: string) => void;
isExpanded: boolean;
onToggle: () => void;
}
export const SidebarRail: React.FC<SidebarRailProps> = ({ activeTab, onTabChange, isExpanded, onToggle }) => {
return (
<div
className={`
h-full bg-zinc-900 rounded-3xl flex flex-col py-6 gap-4 text-gray-400 shrink-0 border border-white/10 shadow-xl
transition-[width] duration-300 ease-[cubic-bezier(0.25,0.1,0.25,1)] px-3
${isExpanded ? 'w-[220px]' : 'w-[72px]'}
`}
>
{/* Header / Toggle */}
<div className={`flex items-center w-full relative transition-all duration-300 mb-4 ${isExpanded ? 'justify-between px-1' : 'justify-center'}`}>
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center text-white font-bold shrink-0 z-10">
Logo
</div>
{/* Título com animação de opacidade e largura */}
<div className={`overflow-hidden transition-all duration-300 ease-in-out whitespace-nowrap absolute left-14 ${isExpanded ? 'opacity-100 max-w-[100px]' : 'opacity-0 max-w-0'}`}>
<span className="font-bold text-white text-lg">App Name</span>
</div>
</div>
{/* Navegação */}
<div className="flex flex-col gap-2 w-full">
<RailButton
label="Dashboard"
active={activeTab === 'home'}
onClick={() => onTabChange('home')}
isExpanded={isExpanded}
/>
<RailButton
label="Settings"
active={activeTab === 'settings'}
onClick={() => onTabChange('settings')}
isExpanded={isExpanded}
/>
</div>
{/* Footer / Toggle Button */}
<div className="mt-auto">
<button
onClick={onToggle}
className="w-full p-2 rounded-xl hover:bg-white/10 text-gray-400 hover:text-white transition-colors flex items-center justify-center"
>
{/* Ícone de Toggle Genérico */}
<span>{isExpanded ? '<<' : '>>'}</span>
</button>
</div>
</div>
);
};
// Subcomponente do Botão (Essencial para a animação do texto)
const RailButton = ({ label, active, onClick, isExpanded }: any) => (
<button
onClick={onClick}
className={`
flex items-center p-2.5 rounded-xl transition-all duration-300 group relative overflow-hidden
${active ? 'bg-white/10 text-white' : 'hover:bg-white/5 hover:text-gray-200'}
${isExpanded ? '' : 'justify-center'}
`}
>
{/* Placeholder do Ícone */}
<div className="shrink-0 flex items-center justify-center w-6 h-6 bg-gray-700/50 rounded text-[10px]">Icon</div>
{/* Lógica Mágica do Texto: Max-Width Transition */}
<div className={`
overflow-hidden whitespace-nowrap transition-all duration-300 ease-in-out
${isExpanded ? 'max-w-[150px] opacity-100 ml-3' : 'max-w-0 opacity-0 ml-0'}
`}>
<span className="font-medium text-sm">{label}</span>
</div>
{/* Indicador de Ativo (Barra lateral pequena quando fechado) */}
{active && !isExpanded && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-3 bg-white rounded-r-full -ml-3" />
)}
</button>
);
```

1046
1. docs/old/projeto.md Normal file

File diff suppressed because it is too large Load Diff

0
1. docs/planos-aggios.md Normal file
View File

173
1. docs/planos-roadmap.md Normal file
View File

@@ -0,0 +1,173 @@
# Sistema de Planos - Roadmap
## Status: Estrutura Frontend Criada ✅
### O que foi criado no Frontend:
1. **Menu Item** adicionado em `/superadmin/layout.tsx`
- Nova rota: `/superadmin/plans`
2. **Página Principal de Planos** (`/superadmin/plans/page.tsx`)
- Lista todos os planos em grid
- Mostra: nome, descrição, faixa de usuários, preços, features, diferenciais
- Botão "Novo Plano"
- Botões Editar e Deletar
- Status visual (ativo/inativo)
3. **Página de Edição de Plano** (`/superadmin/plans/[id]/page.tsx`)
- Formulário completo para editar:
- Informações básicas (nome, slug, descrição)
- Faixa de usuários (min/max)
- Preços (mensal/anual)
- Armazenamento (GB)
- Status (ativo/inativo)
- TODO: Editor de Features e Diferenciais
---
## Próximos Passos - Backend
### 1. Modelo de Dados (Domain)
```go
// internal/domain/plan.go
type Plan struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
MinUsers int `json:"min_users"`
MaxUsers int `json:"max_users"` // -1 = unlimited
MonthlyPrice *decimal.Decimal `json:"monthly_price"`
AnnualPrice *decimal.Decimal `json:"annual_price"`
Features pq.StringArray `json:"features"` // CRM, ERP, etc
Differentiators pq.StringArray `json:"differentiators"`
StorageGB int `json:"storage_gb"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Subscription struct {
ID string `json:"id"`
AgencyID string `json:"agency_id"`
PlanID string `json:"plan_id"`
BillingType string `json:"billing_type"` // monthly/annual
CurrentUsers int `json:"current_users"`
Status string `json:"status"` // active/suspended/cancelled
StartDate time.Time `json:"start_date"`
RenewalDate time.Time `json:"renewal_date"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
```
### 2. Migrations
- `001_create_plans_table.sql`
- `002_create_agency_subscriptions_table.sql`
- `003_add_plan_id_to_agencies.sql`
### 3. Repository
- `PlanRepository` (CRUD)
- `SubscriptionRepository` (CRUD)
### 4. Service
- `PlanService` (validações, lógica)
- `SubscriptionService` (validar limite de usuários, etc)
### 5. Handlers (API)
```
GET /api/admin/plans - Listar planos
POST /api/admin/plans - Criar plano
GET /api/admin/plans/:id - Obter plano
PUT /api/admin/plans/:id - Atualizar plano
DELETE /api/admin/plans/:id - Deletar plano
GET /api/admin/subscriptions - Listar subscrições
```
### 6. Seeds
- Seed dos 4 planos padrão (Ignição, Órbita, Cosmos, Enterprise)
---
## Dados Padrão para Seed
```json
[
{
"name": "Ignição",
"slug": "ignition",
"description": "Ideal para pequenas agências iniciantes",
"min_users": 1,
"max_users": 30,
"monthly_price": 199.99,
"annual_price": 1919.90,
"features": ["CRM", "ERP", "Projetos", "Helpdesk", "Pagamentos", "Contratos", "Documentos"],
"differentiators": [],
"storage_gb": 1,
"is_active": true
},
{
"name": "Órbita",
"slug": "orbit",
"description": "Para agências em crescimento",
"min_users": 31,
"max_users": 100,
"monthly_price": 399.99,
"annual_price": 3839.90,
"features": ["CRM", "ERP", "Projetos", "Helpdesk", "Pagamentos", "Contratos", "Documentos"],
"differentiators": ["Suporte prioritário"],
"storage_gb": 1,
"is_active": true
},
{
"name": "Cosmos",
"slug": "cosmos",
"description": "Para agências consolidadas",
"min_users": 101,
"max_users": 300,
"monthly_price": 799.99,
"annual_price": 7679.90,
"features": ["CRM", "ERP", "Projetos", "Helpdesk", "Pagamentos", "Contratos", "Documentos"],
"differentiators": ["Gerente de conta dedicado", "API integrações"],
"storage_gb": 1,
"is_active": true
},
{
"name": "Enterprise",
"slug": "enterprise",
"description": "Solução customizada para grandes agências",
"min_users": 301,
"max_users": -1,
"monthly_price": null,
"annual_price": null,
"features": ["CRM", "ERP", "Projetos", "Helpdesk", "Pagamentos", "Contratos", "Documentos"],
"differentiators": ["Armazenamento customizado", "Treinamento personalizado"],
"storage_gb": 1,
"is_active": true
}
]
```
---
## Integração com Agências
Quando agência se cadastra:
1. Seleciona um plano
2. Sistema cria `Subscription` com status `active` ou `pending_payment`
3. Agência herda limite de usuários do plano
4. Ao criar usuário: validar se não ultrapassou limite
---
## Features Futuras
- [ ] Editor de Features e Diferenciais (drag-drop no frontend)
- [ ] Planos promocionais (duplicar existente, editar preço)
- [ ] Validações de limite de usuários por plano
- [ ] Dashboard com uso atual vs limite
- [ ] Alertas quando próximo do limite
- [ ] Integração com Stripe/PagSeguro
---
**Pronto para começar?**

File diff suppressed because it is too large Load Diff

199
README.md
View File

@@ -1,19 +1,200 @@
# Aggios App # Aggios App
Aplicação Aggios Plataforma composta por serviços de autenticação, painel administrativo (superadmin) e site institucional da Aggios, orquestrados via Docker Compose.
## Descrição ## Visão geral
- **Objetivo**: permitir que superadministradores cadastrem e gerenciem agências (tenants) enquanto o site institucional apresenta informações públicas da empresa.
- **Stack**: Go (backend), Next.js 16 (dashboard e site), PostgreSQL, Traefik, Docker.
- **Status**: Sistema multi-tenant completo com CRM Beta (leads, funis, campanhas), portal do cliente, segurança cross-tenant validada, branding dinâmico e file serving via API.
Projeto em desenvolvimento. ## Componentes principais
- `backend/`: API Go com serviços de autenticação, operadores e CRUD de agências (endpoints `/api/admin/agencies` e `/api/admin/agencies/{id}`). Inclui handlers para CRM (leads, funis, campanhas), portal do cliente e exportação de dados.
- `front-end-agency/`: Painel Next.js para agências - branding dinâmico, upload de logos, gestão de perfil, CRM completo com Kanban, portal de cadastro de clientes e autenticação tenant-aware.
- `front-end-dash.aggios.app/`: painel Next.js login do superadmin, listagem de agências, exibição detalhada e exclusão definitiva.
- `frontend-aggios.app/`: site institucional Next.js com suporte a temas claro/escuro e compartilhamento de tokens de design.
- `backend/internal/data/postgres/`: scripts de inicialização do banco (estrutura base de tenants e usuários) + migrações para CRM, funis e autenticação de clientes.
- `traefik/`: reverse proxy e certificados automatizados.
### Atualização recente ## Funcionalidades entregues
- 07/12/2025: Site institucional (`frontend-aggios.app`) atualizado com suporte completo a dark mode baseado em Tailwind CSS v4 e `next-themes`. ### **v1.5 - CRM Beta: Leads, Funis e Portal do Cliente (24/12/2025)**
- **🎯 Gestão Completa de Leads**:
- CRUD completo de leads com status, origem e pontuação
- Sistema de importação de leads (CSV/Excel)
- Filtros avançados por status, origem, responsável e cliente
- Associação de leads a clientes específicos
- Timeline de atividades e histórico de interações
## Como Usar - **📊 Funis de Vendas (Sales Funnels)**:
- Criação e gestão de múltiplos funis personalizados
- Board Kanban interativo com drag-and-drop
- Estágios customizáveis com cores e ícones
- Vinculação de funis a campanhas específicas
- Métricas e conversão por estágio
Para configurar e executar o projeto, consulte a documentação em `docs/`. - **🎪 Gestão de Campanhas**:
- Criação de campanhas com período e orçamento
- Vinculação de campanhas a clientes específicos
- Acompanhamento de leads gerados por campanha
- Dashboard de performance de campanhas
- **👥 Portal do Cliente**:
- Sistema de registro público de clientes
- Autenticação dedicada para clientes (JWT separado)
- Dashboard personalizado com estatísticas
- Visualização de leads e listas compartilhadas
- Gestão de perfil e alteração de senha
- **🔗 Compartilhamento de Listas**:
- Tokens únicos para compartilhamento de leads
- URLs públicas para visualização de listas específicas
- Controle de acesso via token com expiração
- **👔 Gestão de Colaboradores**:
- Sistema de permissões (Owner, Admin, Member, Readonly)
- Middleware de autenticação unificada (agência + cliente)
- Controle granular de acesso a funcionalidades
- Atribuição de leads a colaboradores específicos
- **📤 Exportação de Dados**:
- Exportação de leads em CSV
- Filtros aplicados na exportação
- Formatação otimizada para planilhas
### **v1.4 - Segurança Multi-tenant e File Serving (13/12/2025)**
- **🔒 Segurança Cross-Tenant Crítica**:
- Validação de tenant_id em endpoints de login (bloqueio de cross-tenant authentication)
- Validação de tenant em todas rotas protegidas via middleware
- Mensagens de erro genéricas (sem exposição de arquitetura multi-tenant)
- Logs detalhados de tentativas de acesso cross-tenant bloqueadas
- **📁 File Serving via API**:
- Nova rota `/api/files/{bucket}/{path}` para servir arquivos do MinIO através do backend Go
- Eliminação de dependência de DNS (`files.localhost`) - arquivos servidos via `api.localhost`
- Headers de cache otimizados (Cache-Control: public, max-age=31536000)
- CORS e content-type corretos automaticamente
- **🎨 Melhorias de UX**:
- Mensagens de erro humanizadas no formulário de login (sem pop-ups/toasts)
- Erros inline com ícones e cores apropriadas
- Feedback em tempo real ao digitar (limpeza automática de erros)
- Mensagens específicas para cada tipo de erro (401, 403, 404, 429, 5xx)
- **🔧 Melhorias Técnicas**:
- Next.js middleware injetando headers `X-Tenant-Subdomain` para routing correto
- TenantDetector middleware prioriza headers customizados sobre Host
- Upload de logos retorna URLs via API ao invés de MinIO direto
- Configuração MinIO com variáveis de ambiente `MINIO_SERVER_URL` e `MINIO_BROWSER_REDIRECT_URL`
### **v1.3 - Branding Dinâmico e Favicon (12/12/2025)**
- **Branding Multi-tenant**: Logo, favicon e cores personalizadas por agência
- **Favicon Dinâmico**: Atualização em tempo real via localStorage e SSR metadata
- **Upload de Arquivos**: Sistema de upload para MinIO com bucket público
- **Rate Limiting**: 1000 requisições/minuto por IP
### **v1.2 - Redesign Interface Flat**
- Adoção de design "Flat" (sem sombras), focado em bordas e limpeza visual
- Gestão avançada de agências com filtros robustos
- Detalhamento completo com visualização de branding
### **v1.1 - Fundação Multi-tenant**
- Login de Superadmin com JWT
- Cadastro de Agências
- Proxy Interno Next.js para chamadas autenticadas
- Site Institucional com dark mode
## Executando o projeto
1. **Pré-requisitos**: Docker Desktop e Node.js 20+ (para utilitários opcionais).
2. **Variáveis**: ajustar `.env` conforme referências existentes (`docker-compose.yml`, arquivos `config`).
3. **Subir os serviços**:
```powershell
docker-compose up --build
```
4. **Hosts locais**:
- Painel SuperAdmin: `http://dash.localhost`
- Painel Agência: `http://{agencia}.localhost` (ex: `http://idealpages.localhost`)
- Portal do Cliente: `http://{agencia}.localhost/cliente` (cadastro e área logada)
- Site: `http://aggios.app.localhost`
- API: `http://api.localhost`
- Console MinIO: `http://minio.localhost` (admin: minioadmin / M1n10_S3cur3_P@ss_2025!)
5. **Credenciais padrão**: ver `backend/internal/data/postgres/init-db.sql` para usuário superadmin seed.
## Segurança
- ✅ **Cross-Tenant Authentication**: Usuários não podem fazer login em agências que não pertencem
- ✅ **Tenant Isolation**: Todas rotas protegidas validam tenant_id no JWT vs tenant_id do contexto
- ✅ **Erro Handling**: Mensagens genéricas que não expõem arquitetura interna
- ✅ **JWT Validation**: Tokens validados em cada requisição autenticada
- ✅ **Rate Limiting**: 1000 req/min por IP para prevenir brute force
## Estrutura de diretórios (resumo)
```
backend/ API Go (config, domínio, handlers, serviços)
internal/
api/
handlers/
crm.go 🎯 CRUD de leads, funis e campanhas
customer_portal.go 👥 Portal do cliente (auth, dashboard, leads)
export.go 📤 Exportação de dados (CSV)
collaborator.go 👔 Gestão de colaboradores
files.go Handler para servir arquivos via API
auth.go 🔒 Validação cross-tenant no login
middleware/
unified_auth.go 🔐 Autenticação unificada (agência + cliente)
customer_auth.go 🔑 Middleware de autenticação de clientes
collaborator_readonly.go 📖 Controle de permissões readonly
auth.go 🔒 Validação tenant em rotas protegidas
tenant.go 🔧 Detecção de tenant via headers
domain/
auth_unified.go 🆕 Domínios para autenticação unificada
repository/
crm_repository.go 🆕 Repositório de dados do CRM
backend/internal/data/postgres/ Scripts SQL de seed
migrations/
015_create_crm_leads.sql 🆕 Estrutura de leads
020_create_crm_funnels.sql 🆕 Sistema de funis
018_add_customer_auth.sql 🆕 Autenticação de clientes
front-end-agency/ Dashboard Next.js para Agências
app/
(agency)/
crm/
leads/ 🆕 Gestão de leads
funis/[id]/ 🆕 Board Kanban de funis
campanhas/ 🆕 Gestão de campanhas
cliente/
cadastro/ 🆕 Registro público de clientes
(portal)/ 🆕 Portal do cliente autenticado
share/leads/[token]/ 🆕 Compartilhamento de listas
login/page.tsx Login com mensagens humanizadas
components/
crm/
KanbanBoard.tsx 🆕 Board Kanban drag-and-drop
CRMCustomerFilter.tsx 🆕 Filtros avançados de CRM
team/
TeamManagement.tsx 🆕 Gestão de equipe e permissões
middleware.ts Injeção de headers tenant
front-end-dash.aggios.app/ Dashboard Next.js Superadmin
frontend-aggios.app/ Site institucional Next.js
traefik/ Regras de roteamento e TLS
1. docs/ Documentação funcional e técnica
```
## Testes e validação
- Consultar `1. docs/TESTING_GUIDE.md` para cenários funcionais.
- **Testes de Segurança**:
- ✅ Tentativa de login cross-tenant retorna 403
- ✅ JWT de uma agência não funciona em outra agência
- ✅ Logs registram tentativas de acesso cross-tenant
- **Testes de File Serving**:
- ✅ Upload de logo gera URL via API (`http://api.localhost/api/files/...`)
- ✅ Imagens carregam sem problemas de CORS ou DNS
- ✅ Cache headers aplicados corretamente
## Próximos passos sugeridos
- Implementar soft delete e trilhas de auditoria para exclusão de agências
- Adicionar validação de permissões por tenant em rotas de files (se necessário)
- Expandir testes automatizados (unitários e e2e) focados no fluxo do dashboard
- Disponibilizar pipeline CI/CD com validações de lint/build
## Repositório ## Repositório
- Principal: https://git.stackbyte.cloud/erik/aggios.app.git
Repositório oficial: https://git.stackbyte.cloud/erik/aggios.app.git - Branch: 1.5-crm-beta (v1.5 - CRM Beta com leads, funis, campanhas e portal do cliente)

View File

@@ -3,20 +3,23 @@ FROM golang:1.23-alpine AS builder
WORKDIR /build WORKDIR /build
# Copy go.mod and go.sum from cmd/server # Copy go module files
COPY cmd/server/go.mod cmd/server/go.sum ./ COPY go.mod ./
RUN go mod download RUN test -f go.sum && cp go.sum go.sum.bak || true
# Copy source code # Copy entire source tree (internal/, cmd/)
COPY cmd/server/main.go ./ COPY . .
# Build # Ensure go.sum is up to date
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server . RUN go mod tidy
# Build from root (module is defined there)
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server ./cmd/server
# Runtime image # Runtime image
FROM alpine:latest FROM alpine:latest
RUN apk --no-cache add ca-certificates tzdata RUN apk --no-cache add ca-certificates tzdata postgresql-client
WORKDIR /root/ WORKDIR /root/

View File

@@ -1,10 +0,0 @@
module server
go 1.23.12
require (
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/lib/pq v1.10.9
golang.org/x/crypto v0.27.0
)

View File

@@ -1,8 +0,0 @@
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=

View File

@@ -2,576 +2,440 @@ package main
import ( import (
"database/sql" "database/sql"
"encoding/json"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"os"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
_ "github.com/lib/pq" _ "github.com/lib/pq"
"golang.org/x/crypto/bcrypt" "github.com/gorilla/mux"
"aggios-app/backend/internal/api/handlers"
"aggios-app/backend/internal/api/middleware"
"aggios-app/backend/internal/config"
"aggios-app/backend/internal/repository"
"aggios-app/backend/internal/service"
) )
var db *sql.DB func initDB(cfg *config.Config) (*sql.DB, error) {
// jwtSecret carrega o secret do ambiente ou usa fallback (NUNCA use fallback em produção)
var jwtSecret = []byte(getEnvOrDefault("JWT_SECRET", "INSECURE-fallback-secret-CHANGE-THIS"))
// Rate limiting simples (IP -> timestamp das últimas tentativas)
var loginAttempts = make(map[string][]time.Time)
var registerAttempts = make(map[string][]time.Time)
const maxAttemptsPerMinute = 5
// corsMiddleware adiciona headers CORS
func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// CORS - apenas domínios permitidos
allowedOrigins := map[string]bool{
"http://localhost": true, // Dev local
"http://dash.localhost": true, // Dashboard dev
"http://aggios.local": true, // Institucional dev
"http://dash.aggios.local": true, // Dashboard dev alternativo
"https://aggios.app": true, // Institucional prod
"https://dash.aggios.app": true, // Dashboard prod
"https://www.aggios.app": true, // Institucional prod www
}
origin := r.Header.Get("Origin")
if allowedOrigins[origin] {
w.Header().Set("Access-Control-Allow-Origin", origin)
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Allow-Credentials", "true")
// Headers de segurança
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
// Handle preflight
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
// Log da requisição (sem dados sensíveis)
log.Printf("📥 %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
next(w, r)
}
}
// RegisterRequest representa os dados completos de registro
type RegisterRequest struct {
// Step 1 - Dados Pessoais
Email string `json:"email"`
Password string `json:"password"`
FullName string `json:"fullName"`
Newsletter bool `json:"newsletter"`
// Step 2 - Empresa
CompanyName string `json:"companyName"`
CNPJ string `json:"cnpj"`
RazaoSocial string `json:"razaoSocial"`
Description string `json:"description"`
Website string `json:"website"`
Industry string `json:"industry"`
TeamSize string `json:"teamSize"`
// Step 3 - Localização
CEP string `json:"cep"`
State string `json:"state"`
City string `json:"city"`
Neighborhood string `json:"neighborhood"`
Street string `json:"street"`
Number string `json:"number"`
Complement string `json:"complement"`
Contacts []struct {
ID int `json:"id"`
WhatsApp string `json:"whatsapp"`
} `json:"contacts"`
// Step 4 - Domínio
Subdomain string `json:"subdomain"`
// Step 5 - Personalização
PrimaryColor string `json:"primaryColor"`
SecondaryColor string `json:"secondaryColor"`
LogoURL string `json:"logoUrl"`
}
// RegisterResponse representa a resposta do registro
type RegisterResponse struct {
Token string `json:"token"`
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
TenantID string `json:"tenantId"`
Company string `json:"company"`
Subdomain string `json:"subdomain"`
CreatedAt string `json:"created_at"`
}
// ErrorResponse representa uma resposta de erro
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
}
// LoginRequest representa os dados de login
type LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
// LoginResponse representa a resposta do login
type LoginResponse struct {
Token string `json:"token"`
User UserPayload `json:"user"`
}
// UserPayload representa os dados do usuário no token
type UserPayload struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
TenantID string `json:"tenantId"`
Company string `json:"company"`
Subdomain string `json:"subdomain"`
}
// Claims customizado para JWT
type Claims struct {
UserID string `json:"userId"`
Email string `json:"email"`
TenantID string `json:"tenantId"`
jwt.RegisteredClaims
}
// getEnvOrDefault retorna variável de ambiente ou valor padrão
func getEnvOrDefault(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
// checkRateLimit verifica se IP excedeu limite de tentativas
func checkRateLimit(ip string, attempts map[string][]time.Time) bool {
now := time.Now()
cutoff := now.Add(-1 * time.Minute)
// Limpar tentativas antigas
if timestamps, exists := attempts[ip]; exists {
var recent []time.Time
for _, t := range timestamps {
if t.After(cutoff) {
recent = append(recent, t)
}
}
attempts[ip] = recent
// Verificar se excedeu limite
if len(recent) >= maxAttemptsPerMinute {
return false
}
}
// Adicionar nova tentativa
attempts[ip] = append(attempts[ip], now)
return true
}
// validateEmail valida formato de email
func validateEmail(email string) bool {
if len(email) < 3 || len(email) > 254 {
return false
}
// Regex simples para validação
return strings.Contains(email, "@") && strings.Contains(email, ".")
}
func initDB() error {
connStr := fmt.Sprintf( connStr := fmt.Sprintf(
"host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", "host=%s port=%s user=%s password=%s dbname=%s sslmode=disable client_encoding=UTF8",
os.Getenv("DB_HOST"), cfg.Database.Host,
os.Getenv("DB_PORT"), cfg.Database.Port,
os.Getenv("DB_USER"), cfg.Database.User,
os.Getenv("DB_PASSWORD"), cfg.Database.Password,
os.Getenv("DB_NAME"), cfg.Database.Name,
) )
var err error db, err := sql.Open("postgres", connStr)
db, err = sql.Open("postgres", connStr)
if err != nil { if err != nil {
return fmt.Errorf("erro ao abrir conexão: %v", err) return nil, fmt.Errorf("erro ao abrir conexão: %v", err)
} }
if err = db.Ping(); err != nil { if err = db.Ping(); err != nil {
return fmt.Errorf("erro ao conectar ao banco: %v", err) return nil, fmt.Errorf("erro ao conectar ao banco: %v", err)
} }
log.Println("✅ Conectado ao PostgreSQL") log.Println("✅ Conectado ao PostgreSQL")
return nil return db, nil
} }
func main() { func main() {
// Inicializar banco de dados // Load configuration
if err := initDB(); err != nil { cfg := config.Load()
// Initialize database
db, err := initDB(cfg)
if err != nil {
log.Fatalf("❌ Erro ao inicializar banco: %v", err) log.Fatalf("❌ Erro ao inicializar banco: %v", err)
} }
defer db.Close() defer db.Close()
// Health check handlers // Initialize repositories
http.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) { userRepo := repository.NewUserRepository(db)
w.Header().Set("Content-Type", "application/json") tenantRepo := repository.NewTenantRepository(db)
w.WriteHeader(http.StatusOK) companyRepo := repository.NewCompanyRepository(db)
fmt.Fprintf(w, `{"status":"healthy","version":"1.0.0","database":"pending","redis":"pending","minio":"pending"}`) signupTemplateRepo := repository.NewSignupTemplateRepository(db)
}) agencyTemplateRepo := repository.NewAgencyTemplateRepository(db)
planRepo := repository.NewPlanRepository(db)
subscriptionRepo := repository.NewSubscriptionRepository(db)
crmRepo := repository.NewCRMRepository(db)
solutionRepo := repository.NewSolutionRepository(db)
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { // Initialize services
w.Header().Set("Content-Type", "application/json") authService := service.NewAuthService(userRepo, tenantRepo, crmRepo, cfg)
w.WriteHeader(http.StatusOK) agencyService := service.NewAgencyService(userRepo, tenantRepo, cfg, db)
fmt.Fprintf(w, `{"status":"ok"}`) tenantService := service.NewTenantService(tenantRepo, db)
}) companyService := service.NewCompanyService(companyRepo)
planService := service.NewPlanService(planRepo, subscriptionRepo)
// Auth routes (com CORS) // Initialize handlers
http.HandleFunc("/api/auth/register", corsMiddleware(handleRegister)) healthHandler := handlers.NewHealthHandler()
http.HandleFunc("/api/auth/login", corsMiddleware(handleLogin)) authHandler := handlers.NewAuthHandler(authService)
http.HandleFunc("/api/me", corsMiddleware(authMiddleware(handleMe))) agencyProfileHandler := handlers.NewAgencyHandler(tenantRepo, cfg)
agencyHandler := handlers.NewAgencyRegistrationHandler(agencyService, cfg)
collaboratorHandler := handlers.NewCollaboratorHandler(userRepo, agencyService)
tenantHandler := handlers.NewTenantHandler(tenantService)
companyHandler := handlers.NewCompanyHandler(companyService)
planHandler := handlers.NewPlanHandler(planService)
crmHandler := handlers.NewCRMHandler(crmRepo)
solutionHandler := handlers.NewSolutionHandler(solutionRepo)
signupTemplateHandler := handlers.NewSignupTemplateHandler(signupTemplateRepo, userRepo, tenantRepo, agencyService)
agencyTemplateHandler := handlers.NewAgencyTemplateHandler(agencyTemplateRepo, agencyService, userRepo, tenantRepo)
filesHandler := handlers.NewFilesHandler(cfg)
customerPortalHandler := handlers.NewCustomerPortalHandler(crmRepo, authService, cfg)
port := os.Getenv("SERVER_PORT") // Initialize upload handler
if port == "" { uploadHandler, err := handlers.NewUploadHandler(cfg)
port = "8080" if err != nil {
log.Fatalf("❌ Erro ao inicializar upload handler: %v", err)
} }
addr := fmt.Sprintf(":%s", port) // Initialize backup handler
log.Printf("🚀 Server starting on %s", addr) backupHandler := handlers.NewBackupHandler()
log.Printf("📍 Health check: http://localhost:%s/health", port)
log.Printf("🔗 API: http://localhost:%s/api/health", port)
log.Printf("👤 Register: http://localhost:%s/api/auth/register", port)
log.Printf("🔐 Login: http://localhost:%s/api/auth/login", port)
log.Printf("👤 Me: http://localhost:%s/api/me", port)
if err := http.ListenAndServe(addr, nil); err != nil { // Create middleware chain
tenantDetector := middleware.TenantDetector(tenantRepo)
corsMiddleware := middleware.CORS(cfg)
securityMiddleware := middleware.SecurityHeaders
rateLimitMiddleware := middleware.RateLimit(cfg)
authMiddleware := middleware.Auth(cfg)
// Setup routes
router := mux.NewRouter()
// Serve static files (uploads)
fs := http.FileServer(http.Dir("./uploads"))
router.PathPrefix("/uploads/").Handler(http.StripPrefix("/uploads", fs))
// ==================== PUBLIC ROUTES ====================
// Health check
router.HandleFunc("/health", healthHandler.Check)
router.HandleFunc("/api/health", healthHandler.Check)
// Auth
router.HandleFunc("/api/auth/login", authHandler.UnifiedLogin) // Nova rota unificada
router.HandleFunc("/api/auth/login/legacy", authHandler.Login) // Antiga rota (deprecada)
router.HandleFunc("/api/auth/register", agencyHandler.PublicRegister).Methods("POST")
// Public agency template registration (for creating new agencies)
router.HandleFunc("/api/agency-templates", agencyTemplateHandler.GetTemplateBySlug).Methods("GET")
router.HandleFunc("/api/agency-signup/register", agencyTemplateHandler.PublicRegisterAgency).Methods("POST")
// Public client signup via templates
router.HandleFunc("/api/signup-templates/slug/{slug}", signupTemplateHandler.GetTemplateBySlug).Methods("GET")
router.HandleFunc("/api/signup/register", signupTemplateHandler.PublicRegister).Methods("POST")
// Public plans (for signup flow)
router.HandleFunc("/api/plans", planHandler.ListActivePlans).Methods("GET")
router.HandleFunc("/api/plans/{id}", planHandler.GetActivePlan).Methods("GET")
// File upload (public for signup, will also work with auth)
router.HandleFunc("/api/upload", uploadHandler.Upload).Methods("POST")
// Tenant check (public)
router.HandleFunc("/api/tenant/check", tenantHandler.CheckExists).Methods("GET")
router.HandleFunc("/api/tenant/config", tenantHandler.GetPublicConfig).Methods("GET")
router.HandleFunc("/api/tenants/{id}/profile", tenantHandler.GetProfile).Methods("GET")
// Tenant branding (protected - used by both agency and customer portal)
router.Handle("/api/tenant/branding", middleware.RequireAnyAuthenticated(cfg)(http.HandlerFunc(tenantHandler.GetBranding))).Methods("GET")
// Public customer registration (for agency portal signup)
router.HandleFunc("/api/public/customers/register", crmHandler.PublicRegisterCustomer).Methods("POST")
// Hash generator (dev only - remove in production)
router.HandleFunc("/api/hash", handlers.GenerateHash).Methods("POST")
// ==================== PROTECTED ROUTES ====================
// Auth (protected)
router.Handle("/api/auth/change-password", authMiddleware(http.HandlerFunc(authHandler.ChangePassword))).Methods("POST")
// SUPERADMIN: Agency management
router.HandleFunc("/api/admin/agencies/register", agencyHandler.RegisterAgency).Methods("POST")
router.HandleFunc("/api/admin/agencies", tenantHandler.ListAll).Methods("GET")
router.HandleFunc("/api/admin/agencies/{id}", agencyHandler.HandleAgency).Methods("GET", "PATCH", "DELETE")
// SUPERADMIN: Backup & Restore
router.Handle("/api/superadmin/backups", authMiddleware(http.HandlerFunc(backupHandler.ListBackups))).Methods("GET")
router.Handle("/api/superadmin/backup/create", authMiddleware(http.HandlerFunc(backupHandler.CreateBackup))).Methods("POST")
router.Handle("/api/superadmin/backup/restore", authMiddleware(http.HandlerFunc(backupHandler.RestoreBackup))).Methods("POST")
router.Handle("/api/superadmin/backup/download/{filename}", authMiddleware(http.HandlerFunc(backupHandler.DownloadBackup))).Methods("GET")
// SUPERADMIN: Agency template management
router.Handle("/api/admin/agency-templates", authMiddleware(http.HandlerFunc(agencyTemplateHandler.ListTemplates))).Methods("GET")
router.Handle("/api/admin/agency-templates", authMiddleware(http.HandlerFunc(agencyTemplateHandler.CreateTemplate))).Methods("POST")
// SUPERADMIN: Client signup template management
router.Handle("/api/admin/signup-templates", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
signupTemplateHandler.ListTemplates(w, r)
} else if r.Method == http.MethodPost {
signupTemplateHandler.CreateTemplate(w, r)
}
}))).Methods("GET", "POST")
router.Handle("/api/admin/signup-templates/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
signupTemplateHandler.GetTemplateByID(w, r)
case http.MethodPut, http.MethodPatch:
signupTemplateHandler.UpdateTemplate(w, r)
case http.MethodDelete:
signupTemplateHandler.DeleteTemplate(w, r)
}
}))).Methods("GET", "PUT", "PATCH", "DELETE")
// SUPERADMIN: Plans management
planHandler.RegisterRoutes(router)
// SUPERADMIN: Solutions management
router.Handle("/api/admin/solutions", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
solutionHandler.GetAllSolutions(w, r)
case http.MethodPost:
solutionHandler.CreateSolution(w, r)
}
}))).Methods("GET", "POST")
router.Handle("/api/admin/solutions/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
solutionHandler.GetSolution(w, r)
case http.MethodPut, http.MethodPatch:
solutionHandler.UpdateSolution(w, r)
case http.MethodDelete:
solutionHandler.DeleteSolution(w, r)
}
}))).Methods("GET", "PUT", "PATCH", "DELETE")
// SUPERADMIN: Plan <-> Solutions
router.Handle("/api/admin/plans/{plan_id}/solutions", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
solutionHandler.GetPlanSolutions(w, r)
case http.MethodPut:
solutionHandler.SetPlanSolutions(w, r)
}
}))).Methods("GET", "PUT")
// ADMIN_AGENCIA: Client registration
router.Handle("/api/agencies/clients/register", authMiddleware(http.HandlerFunc(agencyHandler.RegisterClient))).Methods("POST")
// Agency profile routes (protected)
router.Handle("/api/agency/profile", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
agencyProfileHandler.GetProfile(w, r)
case http.MethodPut, http.MethodPatch:
agencyProfileHandler.UpdateProfile(w, r)
}
}))).Methods("GET", "PUT", "PATCH")
// Agency logo upload (protected)
router.Handle("/api/agency/logo", authMiddleware(http.HandlerFunc(agencyProfileHandler.UploadLogo))).Methods("POST")
// File serving route (public - serves files from MinIO through API)
router.PathPrefix("/api/files/{bucket}/").HandlerFunc(filesHandler.ServeFile).Methods("GET")
// Company routes (protected)
router.Handle("/api/companies", authMiddleware(http.HandlerFunc(companyHandler.List))).Methods("GET")
router.Handle("/api/companies/create", authMiddleware(http.HandlerFunc(companyHandler.Create))).Methods("POST")
// ==================== CRM ROUTES (TENANT) ====================
// Tenant solutions (which solutions the tenant has access to)
router.Handle("/api/tenant/solutions", authMiddleware(http.HandlerFunc(solutionHandler.GetTenantSolutions))).Methods("GET")
// Dashboard
router.Handle("/api/crm/dashboard", authMiddleware(http.HandlerFunc(crmHandler.GetDashboard))).Methods("GET")
// Customers
router.Handle("/api/crm/customers", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
crmHandler.GetCustomers(w, r)
case http.MethodPost:
crmHandler.CreateCustomer(w, r)
}
}))).Methods("GET", "POST")
router.Handle("/api/crm/customers/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
crmHandler.GetCustomer(w, r)
case http.MethodPut, http.MethodPatch:
crmHandler.UpdateCustomer(w, r)
case http.MethodDelete:
crmHandler.DeleteCustomer(w, r)
}
}))).Methods("GET", "PUT", "PATCH", "DELETE")
// Lists
router.Handle("/api/crm/lists", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
crmHandler.GetLists(w, r)
case http.MethodPost:
crmHandler.CreateList(w, r)
}
}))).Methods("GET", "POST")
router.Handle("/api/crm/lists/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
crmHandler.GetList(w, r)
case http.MethodPut, http.MethodPatch:
crmHandler.UpdateList(w, r)
case http.MethodDelete:
crmHandler.DeleteList(w, r)
}
}))).Methods("GET", "PUT", "PATCH", "DELETE")
router.Handle("/api/crm/lists/{id}/leads", authMiddleware(http.HandlerFunc(crmHandler.GetLeadsByList))).Methods("GET")
// Customer <-> List relationship
router.Handle("/api/crm/customers/{customer_id}/lists/{list_id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
crmHandler.AddCustomerToList(w, r)
case http.MethodDelete:
crmHandler.RemoveCustomerFromList(w, r)
}
}))).Methods("POST", "DELETE")
// Leads
router.Handle("/api/crm/leads", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
crmHandler.GetLeads(w, r)
case http.MethodPost:
crmHandler.CreateLead(w, r)
}
}))).Methods("GET", "POST")
router.Handle("/api/crm/leads/export", authMiddleware(http.HandlerFunc(crmHandler.ExportLeads))).Methods("GET")
router.Handle("/api/crm/leads/import", authMiddleware(http.HandlerFunc(crmHandler.ImportLeads))).Methods("POST")
router.Handle("/api/crm/leads/{leadId}/stage", authMiddleware(http.HandlerFunc(crmHandler.UpdateLeadStage))).Methods("PUT")
router.Handle("/api/crm/leads/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
crmHandler.GetLead(w, r)
case http.MethodPut, http.MethodPatch:
crmHandler.UpdateLead(w, r)
case http.MethodDelete:
crmHandler.DeleteLead(w, r)
}
}))).Methods("GET", "PUT", "PATCH", "DELETE")
// Funnels & Stages
router.Handle("/api/crm/funnels", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
crmHandler.ListFunnels(w, r)
case http.MethodPost:
crmHandler.CreateFunnel(w, r)
}
}))).Methods("GET", "POST")
router.Handle("/api/crm/funnels/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
crmHandler.GetFunnel(w, r)
case http.MethodPut:
crmHandler.UpdateFunnel(w, r)
case http.MethodDelete:
crmHandler.DeleteFunnel(w, r)
}
}))).Methods("GET", "PUT", "DELETE")
router.Handle("/api/crm/funnels/{funnelId}/stages", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
crmHandler.ListStages(w, r)
case http.MethodPost:
crmHandler.CreateStage(w, r)
}
}))).Methods("GET", "POST")
router.Handle("/api/crm/stages/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPut:
crmHandler.UpdateStage(w, r)
case http.MethodDelete:
crmHandler.DeleteStage(w, r)
}
}))).Methods("PUT", "DELETE")
// Lead ingest (integrations)
router.Handle("/api/crm/leads/ingest", authMiddleware(http.HandlerFunc(crmHandler.IngestLead))).Methods("POST")
// Share tokens (generate)
router.Handle("/api/crm/customers/share-token", authMiddleware(http.HandlerFunc(crmHandler.GenerateShareToken))).Methods("POST")
// Share data (public endpoint - no auth required)
router.HandleFunc("/api/crm/share/{token}", crmHandler.GetSharedData).Methods("GET")
// ==================== CUSTOMER PORTAL ====================
// Customer portal login (public endpoint)
router.HandleFunc("/api/portal/login", customerPortalHandler.Login).Methods("POST")
// Customer portal dashboard (requires customer auth)
router.Handle("/api/portal/dashboard", middleware.RequireCustomer(cfg)(http.HandlerFunc(customerPortalHandler.GetPortalDashboard))).Methods("GET")
// Customer portal leads (requires customer auth)
router.Handle("/api/portal/leads", middleware.RequireCustomer(cfg)(http.HandlerFunc(customerPortalHandler.GetPortalLeads))).Methods("GET")
// Customer portal lists (requires customer auth)
router.Handle("/api/portal/lists", middleware.RequireCustomer(cfg)(http.HandlerFunc(customerPortalHandler.GetPortalLists))).Methods("GET")
// Customer portal profile (requires customer auth)
router.Handle("/api/portal/profile", middleware.RequireCustomer(cfg)(http.HandlerFunc(customerPortalHandler.GetPortalProfile))).Methods("GET")
// Customer portal change password (requires customer auth)
router.Handle("/api/portal/change-password", middleware.RequireCustomer(cfg)(http.HandlerFunc(customerPortalHandler.ChangePassword))).Methods("POST")
// Customer portal logo upload (requires customer auth)
router.Handle("/api/portal/logo", middleware.RequireCustomer(cfg)(http.HandlerFunc(customerPortalHandler.UploadLogo))).Methods("POST")
// ==================== AGENCY COLLABORATORS ====================
// List collaborators (requires agency auth, owner only)
router.Handle("/api/agency/collaborators", middleware.RequireAgencyUser(cfg)(http.HandlerFunc(collaboratorHandler.ListCollaborators))).Methods("GET")
// Invite collaborator (requires agency auth, owner only)
router.Handle("/api/agency/collaborators/invite", middleware.RequireAgencyUser(cfg)(http.HandlerFunc(collaboratorHandler.InviteCollaborator))).Methods("POST")
// Remove collaborator (requires agency auth, owner only)
router.Handle("/api/agency/collaborators/{id}", middleware.RequireAgencyUser(cfg)(http.HandlerFunc(collaboratorHandler.RemoveCollaborator))).Methods("DELETE")
// Generate customer portal access (agency staff)
router.Handle("/api/crm/customers/{id}/portal-access", authMiddleware(http.HandlerFunc(crmHandler.GenerateCustomerPortalAccess))).Methods("POST")
// Lead <-> List relationship
router.Handle("/api/crm/leads/{lead_id}/lists/{list_id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
crmHandler.AddLeadToList(w, r)
case http.MethodDelete:
crmHandler.RemoveLeadFromList(w, r)
}
}))).Methods("POST", "DELETE")
// Apply global middlewares: tenant -> cors -> security -> rateLimit -> router
handler := tenantDetector(corsMiddleware(securityMiddleware(rateLimitMiddleware(router))))
// Start server
addr := fmt.Sprintf(":%s", cfg.Server.Port)
log.Printf("🚀 Server starting on %s", addr)
log.Printf("📍 Health check: http://localhost:%s/health", cfg.Server.Port)
log.Printf("🔗 API: http://localhost:%s/api/health", cfg.Server.Port)
log.Printf("🏢 Register Agency (SUPERADMIN): http://localhost:%s/api/admin/agencies/register", cfg.Server.Port)
log.Printf("🔐 Login: http://localhost:%s/api/auth/login", cfg.Server.Port)
if err := http.ListenAndServe(addr, handler); err != nil {
log.Fatalf("❌ Server error: %v", err) log.Fatalf("❌ Server error: %v", err)
} }
} }
// handleRegister handler para criar novo usuário
func handleRegister(w http.ResponseWriter, r *http.Request) {
// Apenas POST
if r.Method != http.MethodPost {
sendError(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Rate limiting
ip := strings.Split(r.RemoteAddr, ":")[0]
if !checkRateLimit(ip, registerAttempts) {
sendError(w, "Too many registration attempts. Please try again later.", http.StatusTooManyRequests)
return
}
// Parse JSON
var req RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendError(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Validações básicas
if !validateEmail(req.Email) {
sendError(w, "Invalid email format", http.StatusBadRequest)
return
}
if req.Password == "" {
sendError(w, "Password is required", http.StatusBadRequest)
return
}
if len(req.Password) < 8 {
sendError(w, "Password must be at least 8 characters", http.StatusBadRequest)
return
}
if req.FullName == "" {
sendError(w, "Full name is required", http.StatusBadRequest)
return
}
if req.CompanyName == "" {
sendError(w, "Company name is required", http.StatusBadRequest)
return
}
if req.Subdomain == "" {
sendError(w, "Subdomain is required", http.StatusBadRequest)
return
}
// Verificar se email já existe
var exists bool
err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)", req.Email).Scan(&exists)
if err != nil {
sendError(w, "Database error", http.StatusInternalServerError)
log.Printf("Erro ao verificar email: %v", err)
return
}
if exists {
sendError(w, "Email already registered", http.StatusConflict)
return
}
// Hash da senha com bcrypt
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
sendError(w, "Error processing password", http.StatusInternalServerError)
log.Printf("Erro ao hash senha: %v", err)
return
}
// Criar Tenant (empresa)
tenantID := uuid.New().String()
domain := fmt.Sprintf("%s.aggios.app", req.Subdomain)
createdAt := time.Now()
_, err = db.Exec(
"INSERT INTO tenants (id, name, domain, subdomain, is_active, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7)",
tenantID, req.CompanyName, domain, req.Subdomain, true, createdAt, createdAt,
)
if err != nil {
sendError(w, "Error creating company", http.StatusInternalServerError)
log.Printf("Erro ao criar tenant: %v", err)
return
}
log.Printf("✅ Tenant criado: %s (%s)", req.CompanyName, tenantID)
// Criar Usuário (administrador do tenant)
userID := uuid.New().String()
firstName := req.FullName
lastName := ""
_, err = db.Exec(
"INSERT INTO users (id, tenant_id, email, password_hash, first_name, last_name, is_active, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
userID, tenantID, req.Email, string(hashedPassword), firstName, lastName, true, createdAt, createdAt,
)
if err != nil {
sendError(w, "Error creating user", http.StatusInternalServerError)
log.Printf("Erro ao inserir usuário: %v", err)
return
}
log.Printf("✅ Usuário criado: %s (%s)", req.Email, userID)
// Gerar token JWT para login automático
token, err := generateToken(userID, req.Email, tenantID)
if err != nil {
sendError(w, "Error generating token", http.StatusInternalServerError)
log.Printf("Erro ao gerar token: %v", err)
return
}
response := RegisterResponse{
Token: token,
ID: userID,
Email: req.Email,
Name: req.FullName,
TenantID: tenantID,
Company: req.CompanyName,
Subdomain: req.Subdomain,
CreatedAt: createdAt.Format(time.RFC3339),
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(response)
}
// sendError envia uma resposta de erro padronizada
func sendError(w http.ResponseWriter, message string, statusCode int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(ErrorResponse{
Error: http.StatusText(statusCode),
Message: message,
})
}
// generateToken gera um JWT token para o usuário
func generateToken(userID, email, tenantID string) (string, error) {
claims := Claims{
UserID: userID,
Email: email,
TenantID: tenantID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "aggios-api",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(jwtSecret)
}
// authMiddleware verifica o token JWT
func authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
sendError(w, "Authorization header required", http.StatusUnauthorized)
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader {
sendError(w, "Invalid authorization format", http.StatusUnauthorized)
return
}
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
return jwtSecret, nil
})
if err != nil || !token.Valid {
sendError(w, "Invalid or expired token", http.StatusUnauthorized)
return
}
// Adicionar claims ao contexto (simplificado: usar headers)
r.Header.Set("X-User-ID", claims.UserID)
r.Header.Set("X-User-Email", claims.Email)
r.Header.Set("X-Tenant-ID", claims.TenantID)
next(w, r)
}
}
// handleLogin handler para fazer login
func handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
sendError(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Rate limiting
ip := strings.Split(r.RemoteAddr, ":")[0]
if !checkRateLimit(ip, loginAttempts) {
sendError(w, "Too many login attempts. Please try again later.", http.StatusTooManyRequests)
return
}
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendError(w, "Invalid JSON", http.StatusBadRequest)
return
}
if !validateEmail(req.Email) || req.Password == "" {
sendError(w, "Invalid credentials", http.StatusBadRequest)
return
}
// Buscar usuário no banco
var userID, email, passwordHash, firstName, tenantID string
var tenantName, subdomain string
err := db.QueryRow(`
SELECT u.id, u.email, u.password_hash, u.first_name, u.tenant_id, t.name, t.subdomain
FROM users u
INNER JOIN tenants t ON u.tenant_id = t.id
WHERE u.email = $1 AND u.is_active = true
`, req.Email).Scan(&userID, &email, &passwordHash, &firstName, &tenantID, &tenantName, &subdomain)
if err == sql.ErrNoRows {
sendError(w, "Invalid credentials", http.StatusUnauthorized)
return
}
if err != nil {
sendError(w, "Database error", http.StatusInternalServerError)
log.Printf("Erro ao buscar usuário: %v", err)
return
}
// Verificar senha
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil {
sendError(w, "Invalid credentials", http.StatusUnauthorized)
return
}
// Gerar token JWT
token, err := generateToken(userID, email, tenantID)
if err != nil {
sendError(w, "Error generating token", http.StatusInternalServerError)
log.Printf("Erro ao gerar token: %v", err)
return
}
log.Printf("✅ Login bem-sucedido: %s", email)
response := LoginResponse{
Token: token,
User: UserPayload{
ID: userID,
Email: email,
Name: firstName,
TenantID: tenantID,
Company: tenantName,
Subdomain: subdomain,
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// handleMe retorna dados do usuário autenticado
func handleMe(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
sendError(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
userID := r.Header.Get("X-User-ID")
tenantID := r.Header.Get("X-Tenant-ID")
var email, firstName, lastName string
var tenantName, subdomain string
err := db.QueryRow(`
SELECT u.email, u.first_name, u.last_name, t.name, t.subdomain
FROM users u
INNER JOIN tenants t ON u.tenant_id = t.id
WHERE u.id = $1 AND u.tenant_id = $2
`, userID, tenantID).Scan(&email, &firstName, &lastName, &tenantName, &subdomain)
if err != nil {
sendError(w, "User not found", http.StatusNotFound)
log.Printf("Erro ao buscar usuário: %v", err)
return
}
fullName := firstName
if lastName != "" {
fullName += " " + lastName
}
response := UserPayload{
ID: userID,
Email: email,
Name: fullName,
TenantID: tenantID,
Company: tenantName,
Subdomain: subdomain,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}

15
backend/generate_hash.go Normal file
View File

@@ -0,0 +1,15 @@
package main
import (
"fmt"
"golang.org/x/crypto/bcrypt"
)
func main() {
password := "Android@2020"
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
panic(err)
}
fmt.Println(string(hash))
}

View File

@@ -1,20 +1,12 @@
module backend module aggios-app/backend
go 1.23 go 1.23
require ( require (
github.com/golang-jwt/jwt/v5 v5.2.0 github.com/golang-jwt/jwt/v5 v5.2.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
github.com/minio/minio-go/v7 v7.0.70 github.com/minio/minio-go/v7 v7.0.63
github.com/redis/go-redis/v9 v9.5.1 github.com/xuri/excelize/v2 v2.8.1
golang.org/x/crypto v0.27.0 golang.org/x/crypto v0.27.0
) )
require (
github.com/cespare/xxhash/v2 v2.2.0
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f
github.com/klauspost/compress v1.17.9
github.com/klauspost/cpuid/v2 v2.2.8
)

View File

@@ -1,12 +1,8 @@
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/minio/minio-go/v7 v7.0.70/go.mod h1:4yBA8v80xGA30cfM3fz0DKYMXunWl/AV/6tWEs9ryzo= golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
github.com/redis/go-redis/v9 v9.5.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View File

@@ -0,0 +1,322 @@
package handlers
import (
"encoding/json"
"errors"
"log"
"net/http"
"time"
"aggios-app/backend/internal/config"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/service"
"github.com/golang-jwt/jwt/v5"
"github.com/gorilla/mux"
"github.com/google/uuid"
)
// AgencyRegistrationHandler handles agency management endpoints
type AgencyRegistrationHandler struct {
agencyService *service.AgencyService
cfg *config.Config
}
// NewAgencyRegistrationHandler creates a new agency registration handler
func NewAgencyRegistrationHandler(agencyService *service.AgencyService, cfg *config.Config) *AgencyRegistrationHandler {
return &AgencyRegistrationHandler{
agencyService: agencyService,
cfg: cfg,
}
}
// RegisterAgency handles agency registration (SUPERADMIN only)
func (h *AgencyRegistrationHandler) RegisterAgency(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req domain.RegisterAgencyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("❌ Error decoding request: %v", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
log.Printf("📥 Registering agency: %s (subdomain: %s)", req.AgencyName, req.Subdomain)
log.Printf("📊 Payload received: RazaoSocial=%s, Phone=%s, City=%s, State=%s, Neighborhood=%s, TeamSize=%s, PrimaryColor=%s, SecondaryColor=%s",
req.RazaoSocial, req.Phone, req.City, req.State, req.Neighborhood, req.TeamSize, req.PrimaryColor, req.SecondaryColor)
tenant, admin, err := h.agencyService.RegisterAgency(req)
if err != nil {
log.Printf("❌ Error registering agency: %v", err)
switch err {
case service.ErrSubdomainTaken:
http.Error(w, err.Error(), http.StatusConflict)
case service.ErrEmailAlreadyExists:
http.Error(w, err.Error(), http.StatusConflict)
case service.ErrWeakPassword:
http.Error(w, err.Error(), http.StatusBadRequest)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
log.Printf("✅ Agency created: %s (ID: %s)", tenant.Name, tenant.ID)
// Generate JWT token for the new admin
claims := jwt.MapClaims{
"user_id": admin.ID.String(),
"email": admin.Email,
"role": admin.Role,
"tenant_id": tenant.ID.String(),
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(h.cfg.JWT.Secret))
if err != nil {
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
return
}
protocol := "http://"
if h.cfg.App.Environment == "production" {
protocol = "https://"
}
response := map[string]interface{}{
"token": tokenString,
"id": admin.ID,
"email": admin.Email,
"name": admin.Name,
"role": admin.Role,
"tenantId": tenant.ID,
"company": tenant.Name,
"subdomain": tenant.Subdomain,
"message": "Agency registered successfully",
"access_url": protocol + tenant.Domain,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(response)
}
// PublicRegister handles public agency registration
func (h *AgencyRegistrationHandler) PublicRegister(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req domain.PublicRegisterAgencyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("❌ Error decoding request: %v", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
log.Printf("📥 Public Registering agency: %s (subdomain: %s)", req.CompanyName, req.Subdomain)
log.Printf("📦 Full Payload: %+v", req)
// Map to internal request
phone := ""
if len(req.Contacts) > 0 {
phone = req.Contacts[0].Whatsapp
}
internalReq := domain.RegisterAgencyRequest{
AgencyName: req.CompanyName,
Subdomain: req.Subdomain,
CNPJ: req.CNPJ,
RazaoSocial: req.RazaoSocial,
Description: req.Description,
Website: req.Website,
Industry: req.Industry,
Phone: phone,
TeamSize: req.TeamSize,
CEP: req.CEP,
State: req.State,
City: req.City,
Neighborhood: req.Neighborhood,
Street: req.Street,
Number: req.Number,
Complement: req.Complement,
PrimaryColor: req.PrimaryColor,
SecondaryColor: req.SecondaryColor,
LogoURL: req.LogoURL,
AdminEmail: req.Email,
AdminPassword: req.Password,
AdminName: req.FullName,
}
tenant, admin, err := h.agencyService.RegisterAgency(internalReq)
if err != nil {
log.Printf("❌ Error registering agency: %v", err)
switch err {
case service.ErrSubdomainTaken:
http.Error(w, err.Error(), http.StatusConflict)
case service.ErrEmailAlreadyExists:
http.Error(w, err.Error(), http.StatusConflict)
case service.ErrWeakPassword:
http.Error(w, err.Error(), http.StatusBadRequest)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
log.Printf("✅ Agency created: %s (ID: %s)", tenant.Name, tenant.ID)
// Generate JWT token for the new admin
claims := jwt.MapClaims{
"user_id": admin.ID.String(),
"email": admin.Email,
"role": admin.Role,
"tenant_id": tenant.ID.String(),
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(h.cfg.JWT.Secret))
if err != nil {
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
return
}
protocol := "http://"
if h.cfg.App.Environment == "production" {
protocol = "https://"
}
response := map[string]interface{}{
"token": tokenString,
"id": admin.ID,
"email": admin.Email,
"name": admin.Name,
"role": admin.Role,
"tenantId": tenant.ID,
"company": tenant.Name,
"subdomain": tenant.Subdomain,
"message": "Agency registered successfully",
"access_url": protocol + tenant.Domain,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(response)
}
// RegisterClient handles client registration (ADMIN_AGENCIA only)
func (h *AgencyRegistrationHandler) RegisterClient(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// TODO: Get tenant_id from authenticated user context
// For now, this would need the auth middleware to set it
var req domain.RegisterClientRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Get tenantID from context (set by middleware)
tenantIDStr := r.Header.Get("X-Tenant-ID")
if tenantIDStr == "" {
http.Error(w, "Tenant not found", http.StatusBadRequest)
return
}
// Parse tenant ID
// tenantID, _ := uuid.Parse(tenantIDStr)
// client, err := h.agencyService.RegisterClient(req, tenantID)
// ... handle response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{
"message": "Client registration endpoint - implementation pending",
})
}
// HandleAgency supports GET (details) and DELETE operations for a specific agency
func (h *AgencyRegistrationHandler) HandleAgency(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/admin/agencies/" {
http.Error(w, "Agency ID required", http.StatusBadRequest)
return
}
vars := mux.Vars(r)
agencyID := vars["id"]
if agencyID == "" {
http.Error(w, "Missing agency ID", http.StatusBadRequest)
return
}
id, err := uuid.Parse(agencyID)
if err != nil {
http.Error(w, "Invalid agency ID", http.StatusBadRequest)
return
}
switch r.Method {
case http.MethodGet:
details, err := h.agencyService.GetAgencyDetails(id)
if err != nil {
if errors.Is(err, service.ErrTenantNotFound) {
http.Error(w, "Agency not found", http.StatusNotFound)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(details)
case http.MethodPatch:
var updateData map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&updateData); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if isActive, ok := updateData["is_active"].(bool); ok {
if err := h.agencyService.UpdateAgencyStatus(id, isActive); err != nil {
if errors.Is(err, service.ErrTenantNotFound) {
http.Error(w, "Agency not found", http.StatusNotFound)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"message": "Status updated"})
case http.MethodDelete:
if err := h.agencyService.DeleteAgency(id); err != nil {
if errors.Is(err, service.ErrTenantNotFound) {
http.Error(w, "Agency not found", http.StatusNotFound)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}

View File

@@ -0,0 +1,238 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"path/filepath"
"strings"
"time"
"aggios-app/backend/internal/api/middleware"
"github.com/google/uuid"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
// UploadLogo handles logo file uploads
func (h *AgencyHandler) UploadLogo(w http.ResponseWriter, r *http.Request) {
// Only accept POST
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
log.Printf("Logo upload request received from tenant")
// Get tenant ID from context
tenantIDVal := r.Context().Value(middleware.TenantIDKey)
if tenantIDVal == nil {
log.Printf("No tenant ID in context")
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Try to get as uuid.UUID first, if that fails try string and parse
var tenantID uuid.UUID
var ok bool
tenantID, ok = tenantIDVal.(uuid.UUID)
if !ok {
// Try as string
tenantIDStr, isString := tenantIDVal.(string)
if !isString {
log.Printf("Invalid tenant ID type: %T", tenantIDVal)
http.Error(w, "Invalid tenant ID", http.StatusBadRequest)
return
}
var err error
tenantID, err = uuid.Parse(tenantIDStr)
if err != nil {
log.Printf("Failed to parse tenant ID: %v", err)
http.Error(w, "Invalid tenant ID format", http.StatusBadRequest)
return
}
}
log.Printf("Processing logo upload for tenant: %s", tenantID)
// Parse multipart form (2MB max)
const maxLogoSize = 2 * 1024 * 1024
if err := r.ParseMultipartForm(maxLogoSize); err != nil {
http.Error(w, "File too large", http.StatusBadRequest)
return
}
file, header, err := r.FormFile("logo")
if err != nil {
http.Error(w, "Failed to read file", http.StatusBadRequest)
return
}
defer file.Close()
// Validate file type
contentType := header.Header.Get("Content-Type")
if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/svg+xml" && contentType != "image/jpg" {
http.Error(w, "Only PNG, JPG or SVG files are allowed", http.StatusBadRequest)
return
}
// Get logo type (logo or horizontal)
logoType := r.FormValue("type")
if logoType != "logo" && logoType != "horizontal" {
logoType = "logo"
}
// Get current logo URL from database to delete old file
var currentLogoURL string
var queryErr error
if logoType == "horizontal" {
queryErr = h.tenantRepo.DB().QueryRow("SELECT logo_horizontal_url FROM tenants WHERE id = $1", tenantID).Scan(&currentLogoURL)
} else {
queryErr = h.tenantRepo.DB().QueryRow("SELECT logo_url FROM tenants WHERE id = $1", tenantID).Scan(&currentLogoURL)
}
if queryErr != nil && queryErr.Error() != "sql: no rows in result set" {
log.Printf("Warning: Failed to get current logo URL: %v", queryErr)
}
// Initialize MinIO client
minioClient, err := minio.New("aggios-minio:9000", &minio.Options{
Creds: credentials.NewStaticV4("minioadmin", "M1n10_S3cur3_P@ss_2025!", ""),
Secure: false,
})
if err != nil {
log.Printf("Failed to create MinIO client: %v", err)
http.Error(w, "Storage service unavailable", http.StatusInternalServerError)
return
}
// Ensure bucket exists
bucketName := "aggios-logos"
ctx := context.Background()
exists, err := minioClient.BucketExists(ctx, bucketName)
if err != nil {
log.Printf("Failed to check bucket: %v", err)
http.Error(w, "Storage error", http.StatusInternalServerError)
return
}
if !exists {
err = minioClient.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{})
if err != nil {
log.Printf("Failed to create bucket: %v", err)
http.Error(w, "Storage error", http.StatusInternalServerError)
return
}
// Set bucket policy to public-read
policy := fmt.Sprintf(`{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"AWS": ["*"]},
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::%s/*"]
}]
}`, bucketName)
err = minioClient.SetBucketPolicy(ctx, bucketName, policy)
if err != nil {
log.Printf("Warning: Failed to set bucket policy: %v", err)
}
}
// Read file content
fileBytes, err := io.ReadAll(file)
if err != nil {
http.Error(w, "Failed to read file", http.StatusInternalServerError)
return
}
// Generate unique filename
ext := filepath.Ext(header.Filename)
filename := fmt.Sprintf("tenants/%s/%s-%d%s", tenantID, logoType, time.Now().Unix(), ext)
// Upload to MinIO
_, err = minioClient.PutObject(
ctx,
bucketName,
filename,
bytes.NewReader(fileBytes),
int64(len(fileBytes)),
minio.PutObjectOptions{
ContentType: contentType,
},
)
if err != nil {
log.Printf("Failed to upload to MinIO: %v", err)
http.Error(w, "Failed to save file", http.StatusInternalServerError)
return
}
// Generate public URL through API (not direct MinIO access)
// This is more secure and doesn't require DNS configuration
logoURL := fmt.Sprintf("http://api.localhost/api/files/%s/%s", bucketName, filename)
log.Printf("Logo uploaded successfully: %s", logoURL)
// Delete old logo file from MinIO if exists
if currentLogoURL != "" && currentLogoURL != "https://via.placeholder.com/150" {
// Extract object key from URL
// Example: http://api.localhost/api/files/aggios-logos/tenants/uuid/logo-123.png -> tenants/uuid/logo-123.png
oldFilename := ""
if len(currentLogoURL) > 0 {
// Split by /api/files/{bucket}/ to get the file path
apiPrefix := fmt.Sprintf("http://api.localhost/api/files/%s/", bucketName)
if strings.HasPrefix(currentLogoURL, apiPrefix) {
oldFilename = strings.TrimPrefix(currentLogoURL, apiPrefix)
} else {
// Fallback for old MinIO URLs
baseURL := fmt.Sprintf("%s/%s/", h.config.Minio.PublicURL, bucketName)
if len(currentLogoURL) > len(baseURL) {
oldFilename = currentLogoURL[len(baseURL):]
}
}
}
if oldFilename != "" {
err = minioClient.RemoveObject(ctx, bucketName, oldFilename, minio.RemoveObjectOptions{})
if err != nil {
log.Printf("Warning: Failed to delete old logo %s: %v", oldFilename, err)
// Don't fail the request if deletion fails
} else {
log.Printf("Old logo deleted successfully: %s", oldFilename)
}
}
}
// Update tenant record in database
var err2 error
log.Printf("Updating database: tenant_id=%s, logo_type=%s, logo_url=%s", tenantID, logoType, logoURL)
if logoType == "horizontal" {
_, err2 = h.tenantRepo.DB().Exec("UPDATE tenants SET logo_horizontal_url = $1, updated_at = NOW() WHERE id = $2", logoURL, tenantID)
} else {
_, err2 = h.tenantRepo.DB().Exec("UPDATE tenants SET logo_url = $1, updated_at = NOW() WHERE id = $2", logoURL, tenantID)
}
if err2 != nil {
log.Printf("ERROR: Failed to update logo in database: %v", err2)
http.Error(w, fmt.Sprintf("Failed to update database: %v", err2), http.StatusInternalServerError)
return
}
log.Printf("SUCCESS: Logo saved to database successfully!")
// Return success response
response := map[string]string{
"logo_url": logoURL,
"message": "Logo uploaded successfully",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}

View File

@@ -0,0 +1,230 @@
package handlers
import (
"encoding/json"
"log"
"net/http"
"aggios-app/backend/internal/api/middleware"
"aggios-app/backend/internal/config"
"aggios-app/backend/internal/repository"
"github.com/google/uuid"
)
type AgencyHandler struct {
tenantRepo *repository.TenantRepository
config *config.Config
}
func NewAgencyHandler(tenantRepo *repository.TenantRepository, cfg *config.Config) *AgencyHandler {
return &AgencyHandler{
tenantRepo: tenantRepo,
config: cfg,
}
}
type AgencyProfileResponse struct {
ID string `json:"id"`
Name string `json:"name"`
CNPJ string `json:"cnpj"`
Email string `json:"email"`
Phone string `json:"phone"`
Website string `json:"website"`
Address string `json:"address"`
Neighborhood string `json:"neighborhood"`
Number string `json:"number"`
Complement string `json:"complement"`
City string `json:"city"`
State string `json:"state"`
Zip string `json:"zip"`
RazaoSocial string `json:"razao_social"`
Description string `json:"description"`
Industry string `json:"industry"`
TeamSize string `json:"team_size"`
PrimaryColor string `json:"primary_color"`
SecondaryColor string `json:"secondary_color"`
LogoURL string `json:"logo_url"`
LogoHorizontalURL string `json:"logo_horizontal_url"`
}
type UpdateAgencyProfileRequest struct {
Name string `json:"name"`
CNPJ string `json:"cnpj"`
Email string `json:"email"`
Phone string `json:"phone"`
Website string `json:"website"`
Address string `json:"address"`
Neighborhood string `json:"neighborhood"`
Number string `json:"number"`
Complement string `json:"complement"`
City string `json:"city"`
State string `json:"state"`
Zip string `json:"zip"`
RazaoSocial string `json:"razao_social"`
Description string `json:"description"`
Industry string `json:"industry"`
TeamSize string `json:"team_size"`
PrimaryColor string `json:"primary_color"`
SecondaryColor string `json:"secondary_color"`
LogoURL string `json:"logo_url"`
LogoHorizontalURL string `json:"logo_horizontal_url"`
}
// GetProfile returns the current agency profile
func (h *AgencyHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get tenant from context (set by auth middleware)
tenantID := r.Context().Value(middleware.TenantIDKey)
if tenantID == nil {
http.Error(w, "Tenant not found in context", http.StatusUnauthorized)
return
}
// Parse tenant ID
tid, err := uuid.Parse(tenantID.(string))
if err != nil {
http.Error(w, "Invalid tenant ID", http.StatusBadRequest)
return
}
// Get tenant from database
tenant, err := h.tenantRepo.FindByID(tid)
if err != nil {
http.Error(w, "Error fetching profile", http.StatusInternalServerError)
return
}
if tenant == nil {
http.Error(w, "Tenant not found", http.StatusNotFound)
return
}
log.Printf("🔍 GetProfile for tenant %s: Found %s", tid, tenant.Name)
log.Printf("📄 Tenant Data: Address=%s, Number=%s, TeamSize=%s, RazaoSocial=%s",
tenant.Address, tenant.Number, tenant.TeamSize, tenant.RazaoSocial)
response := AgencyProfileResponse{
ID: tenant.ID.String(),
Name: tenant.Name,
CNPJ: tenant.CNPJ,
Email: tenant.Email,
Phone: tenant.Phone,
Website: tenant.Website,
Address: tenant.Address,
Neighborhood: tenant.Neighborhood,
Number: tenant.Number,
Complement: tenant.Complement,
City: tenant.City,
State: tenant.State,
Zip: tenant.Zip,
RazaoSocial: tenant.RazaoSocial,
Description: tenant.Description,
Industry: tenant.Industry,
TeamSize: tenant.TeamSize,
PrimaryColor: tenant.PrimaryColor,
SecondaryColor: tenant.SecondaryColor,
LogoURL: tenant.LogoURL,
LogoHorizontalURL: tenant.LogoHorizontalURL,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// UpdateProfile updates the current agency profile
func (h *AgencyHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut && r.Method != http.MethodPatch {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get tenant from context (set by auth middleware)
tenantID := r.Context().Value(middleware.TenantIDKey)
if tenantID == nil {
http.Error(w, "Tenant not found", http.StatusUnauthorized)
return
}
var req UpdateAgencyProfileRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Parse tenant ID
tid, err := uuid.Parse(tenantID.(string))
if err != nil {
http.Error(w, "Invalid tenant ID", http.StatusBadRequest)
return
}
// Prepare updates
updates := map[string]interface{}{
"name": req.Name,
"cnpj": req.CNPJ,
"razao_social": req.RazaoSocial,
"email": req.Email,
"phone": req.Phone,
"website": req.Website,
"address": req.Address,
"neighborhood": req.Neighborhood,
"number": req.Number,
"complement": req.Complement,
"city": req.City,
"state": req.State,
"zip": req.Zip,
"description": req.Description,
"industry": req.Industry,
"team_size": req.TeamSize,
"primary_color": req.PrimaryColor,
"secondary_color": req.SecondaryColor,
"logo_url": req.LogoURL,
"logo_horizontal_url": req.LogoHorizontalURL,
}
// Update in database
if err := h.tenantRepo.UpdateProfile(tid, updates); err != nil {
http.Error(w, "Error updating profile", http.StatusInternalServerError)
return
}
// Fetch updated data
tenant, err := h.tenantRepo.FindByID(tid)
if err != nil {
http.Error(w, "Error fetching updated profile", http.StatusInternalServerError)
return
}
response := AgencyProfileResponse{
ID: tenant.ID.String(),
Name: tenant.Name,
CNPJ: tenant.CNPJ,
Email: tenant.Email,
Phone: tenant.Phone,
Website: tenant.Website,
Address: tenant.Address,
Neighborhood: tenant.Neighborhood,
Number: tenant.Number,
Complement: tenant.Complement,
City: tenant.City,
State: tenant.State,
Zip: tenant.Zip,
RazaoSocial: tenant.RazaoSocial,
Description: tenant.Description,
Industry: tenant.Industry,
TeamSize: tenant.TeamSize,
PrimaryColor: tenant.PrimaryColor,
SecondaryColor: tenant.SecondaryColor,
LogoURL: tenant.LogoURL,
LogoHorizontalURL: tenant.LogoHorizontalURL,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}

View File

@@ -0,0 +1,239 @@
package handlers
import (
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/repository"
"aggios-app/backend/internal/service"
"encoding/json"
"log"
"net/http"
"golang.org/x/crypto/bcrypt"
)
type AgencyTemplateHandler struct {
templateRepo *repository.AgencyTemplateRepository
agencyService *service.AgencyService
userRepo *repository.UserRepository
tenantRepo *repository.TenantRepository
}
func NewAgencyTemplateHandler(
templateRepo *repository.AgencyTemplateRepository,
agencyService *service.AgencyService,
userRepo *repository.UserRepository,
tenantRepo *repository.TenantRepository,
) *AgencyTemplateHandler {
return &AgencyTemplateHandler{
templateRepo: templateRepo,
agencyService: agencyService,
userRepo: userRepo,
tenantRepo: tenantRepo,
}
}
// GetTemplateBySlug - Public endpoint to get template details
func (h *AgencyTemplateHandler) GetTemplateBySlug(w http.ResponseWriter, r *http.Request) {
slug := r.URL.Query().Get("slug")
if slug == "" {
http.Error(w, "Missing slug parameter", http.StatusBadRequest)
return
}
template, err := h.templateRepo.FindBySlug(slug)
if err != nil {
log.Printf("Template not found: %v", err)
http.Error(w, "Template not found or expired", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(template)
}
// PublicRegisterAgency - Public endpoint for agency registration via template
func (h *AgencyTemplateHandler) PublicRegisterAgency(w http.ResponseWriter, r *http.Request) {
var req domain.AgencyRegistrationViaTemplate
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// 1. Validar template
template, err := h.templateRepo.FindBySlug(req.TemplateSlug)
if err != nil {
log.Printf("Template error: %v", err)
http.Error(w, "Invalid or expired template", http.StatusBadRequest)
return
}
// 2. Validar campos obrigatórios
if req.AgencyName == "" || req.Subdomain == "" || req.AdminEmail == "" || req.AdminPassword == "" {
http.Error(w, "Missing required fields", http.StatusBadRequest)
return
}
// 3. Validar senha
if len(req.AdminPassword) < 8 {
http.Error(w, "Password must be at least 8 characters", http.StatusBadRequest)
return
}
// 4. Verificar se email já existe
existingUser, _ := h.userRepo.FindByEmail(req.AdminEmail)
if existingUser != nil {
http.Error(w, "Email already registered", http.StatusConflict)
return
}
// 5. Verificar se subdomain já existe
existingTenant, _ := h.tenantRepo.FindBySubdomain(req.Subdomain)
if existingTenant != nil {
http.Error(w, "Subdomain already taken", http.StatusConflict)
return
}
// 6. Hash da senha
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.AdminPassword), bcrypt.DefaultCost)
if err != nil {
log.Printf("Error hashing password: %v", err)
http.Error(w, "Error processing password", http.StatusInternalServerError)
return
}
// 7. Criar tenant (agência)
tenant := &domain.Tenant{
Name: req.AgencyName,
Domain: req.Subdomain + ".aggios.app",
Subdomain: req.Subdomain,
CNPJ: req.CNPJ,
RazaoSocial: req.RazaoSocial,
Website: req.Website,
Phone: req.Phone,
Description: req.Description,
Industry: req.Industry,
TeamSize: req.TeamSize,
}
// Endereço (se fornecido)
if req.Address != nil {
tenant.Address = req.Address["street"]
tenant.Number = req.Address["number"]
tenant.Complement = req.Address["complement"]
tenant.Neighborhood = req.Address["neighborhood"]
tenant.City = req.Address["city"]
tenant.State = req.Address["state"]
tenant.Zip = req.Address["cep"]
}
// Personalização do template
if template.CustomPrimaryColor.Valid {
tenant.PrimaryColor = template.CustomPrimaryColor.String
}
if template.CustomLogoURL.Valid {
tenant.LogoURL = template.CustomLogoURL.String
}
if err := h.tenantRepo.Create(tenant); err != nil {
log.Printf("Error creating tenant: %v", err)
http.Error(w, "Error creating agency", http.StatusInternalServerError)
return
}
// 8. Criar usuário admin da agência
user := &domain.User{
Email: req.AdminEmail,
Password: string(hashedPassword),
Name: req.AdminName,
Role: "ADMIN_AGENCIA",
TenantID: &tenant.ID,
}
if err := h.userRepo.Create(user); err != nil {
log.Printf("Error creating user: %v", err)
http.Error(w, "Error creating admin user", http.StatusInternalServerError)
return
}
// 9. Incrementar contador de uso do template
if err := h.templateRepo.IncrementUsageCount(template.ID.String()); err != nil {
log.Printf("Warning: failed to increment usage count: %v", err)
}
// 10. Preparar resposta com redirect
redirectURL := template.RedirectURL.String
if redirectURL == "" {
redirectURL = "http://" + req.Subdomain + ".localhost/login"
}
response := map[string]interface{}{
"success": true,
"message": template.SuccessMessage.String,
"tenant_id": tenant.ID,
"user_id": user.ID,
"redirect_url": redirectURL,
"subdomain": req.Subdomain,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// CreateTemplate - SUPERADMIN only
func (h *AgencyTemplateHandler) CreateTemplate(w http.ResponseWriter, r *http.Request) {
var req domain.CreateAgencyTemplateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
formFieldsJSON, _ := repository.FormFieldsToJSON(req.FormFields)
modulesJSON, _ := json.Marshal(req.AvailableModules)
template := &domain.AgencySignupTemplate{
Name: req.Name,
Slug: req.Slug,
Description: req.Description,
FormFields: formFieldsJSON,
AvailableModules: modulesJSON,
IsActive: true,
}
if req.CustomPrimaryColor != "" {
template.CustomPrimaryColor.Valid = true
template.CustomPrimaryColor.String = req.CustomPrimaryColor
}
if req.CustomLogoURL != "" {
template.CustomLogoURL.Valid = true
template.CustomLogoURL.String = req.CustomLogoURL
}
if req.RedirectURL != "" {
template.RedirectURL.Valid = true
template.RedirectURL.String = req.RedirectURL
}
if req.SuccessMessage != "" {
template.SuccessMessage.Valid = true
template.SuccessMessage.String = req.SuccessMessage
}
if err := h.templateRepo.Create(template); err != nil {
log.Printf("Error creating template: %v", err)
http.Error(w, "Error creating template", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(template)
}
// ListTemplates - SUPERADMIN only
func (h *AgencyTemplateHandler) ListTemplates(w http.ResponseWriter, r *http.Request) {
templates, err := h.templateRepo.List()
if err != nil {
http.Error(w, "Error fetching templates", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(templates)
}

View File

@@ -0,0 +1,260 @@
package handlers
import (
"encoding/json"
"io"
"log"
"net/http"
"strings"
"aggios-app/backend/internal/api/middleware"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/service"
)
// AuthHandler handles authentication endpoints
type AuthHandler struct {
authService *service.AuthService
}
// NewAuthHandler creates a new auth handler
func NewAuthHandler(authService *service.AuthService) *AuthHandler {
return &AuthHandler{
authService: authService,
}
}
// Register handles user registration
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req domain.CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
user, err := h.authService.Register(req)
if err != nil {
switch err {
case service.ErrEmailAlreadyExists:
http.Error(w, err.Error(), http.StatusConflict)
case service.ErrWeakPassword:
http.Error(w, err.Error(), http.StatusBadRequest)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(user)
}
// Login handles user login
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
log.Printf("🔐 LOGIN HANDLER CALLED - Method: %s", r.Method)
if r.Method != http.MethodPost {
log.Printf("❌ Method not allowed: %s", r.Method)
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
log.Printf("❌ Failed to read body: %v", err)
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
log.Printf("📥 Raw body: %s", string(bodyBytes))
// Trim whitespace to avoid decode errors caused by BOM or stray chars
sanitized := strings.TrimSpace(string(bodyBytes))
var req domain.LoginRequest
if err := json.Unmarshal([]byte(sanitized), &req); err != nil {
log.Printf("❌ JSON parse error: %v", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
log.Printf("📧 Login attempt for email: %s", req.Email)
response, err := h.authService.Login(req)
if err != nil {
log.Printf("❌ authService.Login error: %v", err)
if err == service.ErrInvalidCredentials {
http.Error(w, err.Error(), http.StatusUnauthorized)
} else {
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
// VALIDAÇÃO DE SEGURANÇA: Verificar se o tenant do usuário corresponde ao subdomain acessado
tenantIDFromContext := ""
if ctxTenantID := r.Context().Value(middleware.TenantIDKey); ctxTenantID != nil {
tenantIDFromContext, _ = ctxTenantID.(string)
}
// Se foi detectado um tenant no contexto (não é superadmin ou site institucional)
if tenantIDFromContext != "" && response.User.TenantID != nil {
userTenantID := response.User.TenantID.String()
if userTenantID != tenantIDFromContext {
log.Printf("❌ LOGIN BLOCKED: User from tenant %s tried to login in tenant %s subdomain", userTenantID, tenantIDFromContext)
http.Error(w, "Forbidden: Invalid credentials for this tenant", http.StatusForbidden)
return
}
log.Printf("✅ TENANT LOGIN VALIDATION PASSED: %s", userTenantID)
}
log.Printf("✅ Login successful for %s, role=%s", response.User.Email, response.User.Role)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// ChangePasswordRequest represents a password change request
type ChangePasswordRequest struct {
CurrentPassword string `json:"currentPassword"`
NewPassword string `json:"newPassword"`
}
// ChangePassword handles password change
func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get user ID from context (set by auth middleware)
userID, ok := r.Context().Value("userID").(string)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
var req ChangePasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.CurrentPassword == "" || req.NewPassword == "" {
http.Error(w, "Current password and new password are required", http.StatusBadRequest)
return
}
// Call auth service to change password
if err := h.authService.ChangePassword(userID, req.CurrentPassword, req.NewPassword); err != nil {
if err == service.ErrInvalidCredentials {
http.Error(w, "Current password is incorrect", http.StatusUnauthorized)
} else if err == service.ErrWeakPassword {
http.Error(w, "New password is too weak", http.StatusBadRequest)
} else {
http.Error(w, "Error changing password", http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "Password changed successfully",
})
}
// UnifiedLogin handles login for all user types (agency, customer, superadmin)
func (h *AuthHandler) UnifiedLogin(w http.ResponseWriter, r *http.Request) {
log.Printf("🔐 UNIFIED LOGIN HANDLER CALLED - Method: %s", r.Method)
if r.Method != http.MethodPost {
log.Printf("❌ Method not allowed: %s", r.Method)
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
log.Printf("❌ Failed to read body: %v", err)
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
log.Printf("📥 Raw body: %s", string(bodyBytes))
sanitized := strings.TrimSpace(string(bodyBytes))
var req domain.UnifiedLoginRequest
if err := json.Unmarshal([]byte(sanitized), &req); err != nil {
log.Printf("❌ JSON parse error: %v", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
log.Printf("📧 Unified login attempt for email: %s", req.Email)
response, err := h.authService.UnifiedLogin(req)
if err != nil {
log.Printf("❌ authService.UnifiedLogin error: %v", err)
if err == service.ErrInvalidCredentials || strings.Contains(err.Error(), "não autorizado") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{
"error": err.Error(),
})
} else {
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
// VALIDAÇÃO DE SEGURANÇA: Verificar se o tenant corresponde ao subdomain acessado
tenantIDFromContext := ""
if ctxTenantID := r.Context().Value(middleware.TenantIDKey); ctxTenantID != nil {
tenantIDFromContext, _ = ctxTenantID.(string)
}
// Se foi detectado um tenant no contexto E o usuário tem tenant
if tenantIDFromContext != "" && response.TenantID != "" {
if response.TenantID != tenantIDFromContext {
log.Printf("❌ LOGIN BLOCKED: User from tenant %s tried to login in tenant %s subdomain",
response.TenantID, tenantIDFromContext)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
json.NewEncoder(w).Encode(map[string]string{
"error": "Credenciais inválidas para esta agência",
})
return
}
log.Printf("✅ TENANT LOGIN VALIDATION PASSED: %s", response.TenantID)
}
log.Printf("✅ Unified login successful: email=%s, type=%s, role=%s",
response.Email, response.UserType, response.Role)
// Montar resposta compatível com frontend antigo E com novos campos
compatibleResponse := map[string]interface{}{
"token": response.Token,
"user": map[string]interface{}{
"id": response.UserID,
"email": response.Email,
"name": response.Name,
"role": response.Role,
"tenant_id": response.TenantID,
"user_type": response.UserType,
},
// Campos adicionais do sistema unificado
"user_type": response.UserType,
"user_id": response.UserID,
"subdomain": response.Subdomain,
"tenant_id": response.TenantID,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(compatibleResponse)
}

View File

@@ -0,0 +1,264 @@
package handlers
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
)
type BackupHandler struct {
backupDir string
}
type BackupInfo struct {
Filename string `json:"filename"`
Size string `json:"size"`
Date string `json:"date"`
Timestamp string `json:"timestamp"`
}
func NewBackupHandler() *BackupHandler {
// Usa o caminho montado no container
backupDir := "/backups"
// Garante que o diretório existe
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
os.MkdirAll(backupDir, 0755)
}
return &BackupHandler{
backupDir: backupDir,
}
}
// ListBackups lista todos os backups disponíveis
func (h *BackupHandler) ListBackups(w http.ResponseWriter, r *http.Request) {
files, err := ioutil.ReadDir(h.backupDir)
if err != nil {
http.Error(w, "Error reading backups directory", http.StatusInternalServerError)
return
}
var backups []BackupInfo
for _, file := range files {
if strings.HasPrefix(file.Name(), "aggios_backup_") && strings.HasSuffix(file.Name(), ".sql") {
// Extrai timestamp do nome do arquivo
timestamp := strings.TrimPrefix(file.Name(), "aggios_backup_")
timestamp = strings.TrimSuffix(timestamp, ".sql")
// Formata a data
t, _ := time.Parse("2006-01-02_15-04-05", timestamp)
dateStr := t.Format("02/01/2006 15:04:05")
// Formata o tamanho
sizeMB := float64(file.Size()) / 1024
sizeStr := fmt.Sprintf("%.2f KB", sizeMB)
backups = append(backups, BackupInfo{
Filename: file.Name(),
Size: sizeStr,
Date: dateStr,
Timestamp: timestamp,
})
}
}
// Ordena por data (mais recente primeiro)
sort.Slice(backups, func(i, j int) bool {
return backups[i].Timestamp > backups[j].Timestamp
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"backups": backups,
})
}
// CreateBackup cria um novo backup do banco de dados
func (h *BackupHandler) CreateBackup(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
timestamp := time.Now().Format("2006-01-02_15-04-05")
filename := fmt.Sprintf("aggios_backup_%s.sql", timestamp)
filepath := filepath.Join(h.backupDir, filename)
// Usa pg_dump diretamente (backend e postgres estão na mesma rede docker)
dbPassword := os.Getenv("DB_PASSWORD")
if dbPassword == "" {
dbPassword = "A9g10s_S3cur3_P@ssw0rd_2025!"
}
cmd := exec.Command("pg_dump",
"-h", "postgres",
"-U", "aggios",
"-d", "aggios_db",
"--no-password")
// Define a variável de ambiente para a senha
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbPassword))
output, err := cmd.Output()
if err != nil {
http.Error(w, fmt.Sprintf("Error creating backup: %v", err), http.StatusInternalServerError)
return
}
// Salva o backup no arquivo
err = ioutil.WriteFile(filepath, output, 0644)
if err != nil {
http.Error(w, fmt.Sprintf("Error saving backup: %v", err), http.StatusInternalServerError)
return
}
// Limpa backups antigos (mantém apenas os últimos 10)
h.cleanOldBackups()
fileInfo, _ := os.Stat(filepath)
sizeMB := float64(fileInfo.Size()) / 1024
sizeStr := fmt.Sprintf("%.2f KB", sizeMB)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Backup created successfully",
"filename": filename,
"size": sizeStr,
})
}
// RestoreBackup restaura um backup específico
func (h *BackupHandler) RestoreBackup(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Filename string `json:"filename"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if req.Filename == "" {
http.Error(w, "Filename is required", http.StatusBadRequest)
return
}
// Valida que o arquivo existe e está no diretório correto
backupPath := filepath.Join(h.backupDir, req.Filename)
if !strings.HasPrefix(backupPath, h.backupDir) {
http.Error(w, "Invalid filename", http.StatusBadRequest)
return
}
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
http.Error(w, "Backup file not found", http.StatusNotFound)
return
}
// Lê o conteúdo do backup
backupContent, err := ioutil.ReadFile(backupPath)
if err != nil {
http.Error(w, fmt.Sprintf("Error reading backup: %v", err), http.StatusInternalServerError)
return
}
// Restaura o backup usando psql diretamente
dbPassword := os.Getenv("DB_PASSWORD")
if dbPassword == "" {
dbPassword = "A9g10s_S3cur3_P@ssw0rd_2025!"
}
cmd := exec.Command("psql",
"-h", "postgres",
"-U", "aggios",
"-d", "aggios_db",
"--no-password")
cmd.Stdin = strings.NewReader(string(backupContent))
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbPassword))
if err := cmd.Run(); err != nil {
http.Error(w, fmt.Sprintf("Error restoring backup: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Backup restored successfully",
})
}
// DownloadBackup permite fazer download de um backup
func (h *BackupHandler) DownloadBackup(w http.ResponseWriter, r *http.Request) {
// Extrai o filename da URL
parts := strings.Split(r.URL.Path, "/")
filename := parts[len(parts)-1]
if filename == "" {
http.Error(w, "Filename is required", http.StatusBadRequest)
return
}
// Valida que o arquivo existe e está no diretório correto
backupPath := filepath.Join(h.backupDir, filename)
if !strings.HasPrefix(backupPath, h.backupDir) {
http.Error(w, "Invalid filename", http.StatusBadRequest)
return
}
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
http.Error(w, "Backup file not found", http.StatusNotFound)
return
}
// Lê o arquivo
data, err := ioutil.ReadFile(backupPath)
if err != nil {
http.Error(w, "Error reading file", http.StatusInternalServerError)
return
}
// Define headers para download
w.Header().Set("Content-Type", "application/sql")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
w.Write(data)
}
// cleanOldBackups mantém apenas os últimos 10 backups
func (h *BackupHandler) cleanOldBackups() {
files, err := ioutil.ReadDir(h.backupDir)
if err != nil {
return
}
var backupFiles []os.FileInfo
for _, file := range files {
if strings.HasPrefix(file.Name(), "aggios_backup_") && strings.HasSuffix(file.Name(), ".sql") {
backupFiles = append(backupFiles, file)
}
}
// Ordena por data de modificação (mais recente primeiro)
sort.Slice(backupFiles, func(i, j int) bool {
return backupFiles[i].ModTime().After(backupFiles[j].ModTime())
})
// Remove backups antigos (mantém os 10 mais recentes)
if len(backupFiles) > 10 {
for _, file := range backupFiles[10:] {
os.Remove(filepath.Join(h.backupDir, file.Name()))
}
}
}

View File

@@ -0,0 +1,271 @@
package handlers
import (
"aggios-app/backend/internal/api/middleware"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/repository"
"aggios-app/backend/internal/service"
"encoding/json"
"log"
"net/http"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
// CollaboratorHandler handles agency collaborator management
type CollaboratorHandler struct {
userRepo *repository.UserRepository
agencyServ *service.AgencyService
}
// NewCollaboratorHandler creates a new collaborator handler
func NewCollaboratorHandler(userRepo *repository.UserRepository, agencyServ *service.AgencyService) *CollaboratorHandler {
return &CollaboratorHandler{
userRepo: userRepo,
agencyServ: agencyServ,
}
}
// AddCollaboratorRequest representa a requisição para adicionar um colaborador
type AddCollaboratorRequest struct {
Email string `json:"email"`
Name string `json:"name"`
}
// CollaboratorResponse representa um colaborador
type CollaboratorResponse struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
AgencyRole string `json:"agency_role"` // owner ou collaborator
CreatedAt time.Time `json:"created_at"`
CollaboratorCreatedAt *time.Time `json:"collaborator_created_at,omitempty"`
}
// ListCollaborators lista todos os colaboradores da agência (apenas owner pode ver)
func (h *CollaboratorHandler) ListCollaborators(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
ownerID, _ := r.Context().Value(middleware.UserIDKey).(string)
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
agencyRole, _ := r.Context().Value("agency_role").(string)
// Apenas owner pode listar colaboradores
if agencyRole != "owner" {
log.Printf("❌ COLLABORATOR ACCESS BLOCKED: User %s tried to list collaborators", ownerID)
http.Error(w, "Only agency owners can manage collaborators", http.StatusForbidden)
return
}
// Buscar todos os usuários da agência
tenantUUID := parseUUID(tenantID)
if tenantUUID == nil {
http.Error(w, "Invalid tenant ID", http.StatusBadRequest)
return
}
users, err := h.userRepo.ListByTenantID(*tenantUUID)
if err != nil {
log.Printf("Error fetching collaborators: %v", err)
http.Error(w, "Error fetching collaborators", http.StatusInternalServerError)
return
}
// Formatar resposta
collaborators := make([]CollaboratorResponse, 0)
for _, user := range users {
collaborators = append(collaborators, CollaboratorResponse{
ID: user.ID.String(),
Email: user.Email,
Name: user.Name,
AgencyRole: user.AgencyRole,
CreatedAt: user.CreatedAt,
CollaboratorCreatedAt: user.CollaboratorCreatedAt,
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"collaborators": collaborators,
})
}
// InviteCollaborator convida um novo colaborador para a agência (apenas owner pode fazer isso)
func (h *CollaboratorHandler) InviteCollaborator(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
ownerID, _ := r.Context().Value(middleware.UserIDKey).(string)
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
agencyRole, _ := r.Context().Value("agency_role").(string)
// Apenas owner pode convidar colaboradores
if agencyRole != "owner" {
log.Printf("❌ COLLABORATOR INVITE BLOCKED: User %s tried to invite collaborator", ownerID)
http.Error(w, "Only agency owners can invite collaborators", http.StatusForbidden)
return
}
var req AddCollaboratorRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Validar email
if req.Email == "" {
http.Error(w, "Email is required", http.StatusBadRequest)
return
}
// Validar se email já existe
exists, err := h.userRepo.EmailExists(req.Email)
if err != nil {
log.Printf("Error checking email: %v", err)
http.Error(w, "Error processing request", http.StatusInternalServerError)
return
}
if exists {
http.Error(w, "Email already registered", http.StatusConflict)
return
}
// Gerar senha temporária (8 caracteres aleatórios)
tempPassword := generateTempPassword()
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(tempPassword), bcrypt.DefaultCost)
if err != nil {
log.Printf("Error hashing password: %v", err)
http.Error(w, "Error processing request", http.StatusInternalServerError)
return
}
// Criar novo colaborador
ownerUUID := parseUUID(ownerID)
tenantUUID := parseUUID(tenantID)
now := time.Now()
collaborator := &domain.User{
TenantID: tenantUUID,
Email: req.Email,
Password: string(hashedPassword),
Name: req.Name,
Role: "ADMIN_AGENCIA",
AgencyRole: "collaborator",
CreatedBy: ownerUUID,
CollaboratorCreatedAt: &now,
}
if err := h.userRepo.Create(collaborator); err != nil {
log.Printf("Error creating collaborator: %v", err)
http.Error(w, "Error creating collaborator", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Collaborator invited successfully",
"temporary_password": tempPassword,
"collaborator": CollaboratorResponse{
ID: collaborator.ID.String(),
Email: collaborator.Email,
Name: collaborator.Name,
AgencyRole: collaborator.AgencyRole,
CreatedAt: collaborator.CreatedAt,
CollaboratorCreatedAt: collaborator.CollaboratorCreatedAt,
},
})
}
// RemoveCollaborator remove um colaborador da agência (apenas owner pode fazer isso)
func (h *CollaboratorHandler) RemoveCollaborator(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
ownerID, _ := r.Context().Value(middleware.UserIDKey).(string)
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
agencyRole, _ := r.Context().Value("agency_role").(string)
// Apenas owner pode remover colaboradores
if agencyRole != "owner" {
log.Printf("❌ COLLABORATOR REMOVE BLOCKED: User %s tried to remove collaborator", ownerID)
http.Error(w, "Only agency owners can remove collaborators", http.StatusForbidden)
return
}
collaboratorID := r.URL.Query().Get("id")
if collaboratorID == "" {
http.Error(w, "Collaborator ID is required", http.StatusBadRequest)
return
}
// Converter ID para UUID
collaboratorUUID := parseUUID(collaboratorID)
if collaboratorUUID == nil {
http.Error(w, "Invalid collaborator ID", http.StatusBadRequest)
return
}
// Buscar o colaborador
collaborator, err := h.userRepo.GetByID(*collaboratorUUID)
if err != nil {
http.Error(w, "Collaborator not found", http.StatusNotFound)
return
}
// Verificar se o colaborador pertence à mesma agência
if collaborator.TenantID == nil || collaborator.TenantID.String() != tenantID {
http.Error(w, "Collaborator not found in this agency", http.StatusForbidden)
return
}
// Não permitir remover o owner
if collaborator.AgencyRole == "owner" {
http.Error(w, "Cannot remove the agency owner", http.StatusBadRequest)
return
}
// Remover colaborador
if err := h.userRepo.Delete(*collaboratorUUID); err != nil {
log.Printf("Error removing collaborator: %v", err)
http.Error(w, "Error removing collaborator", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "Collaborator removed successfully",
})
}
// generateTempPassword gera uma senha temporária
func generateTempPassword() string {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*"
return randomString(12, charset)
}
// randomString gera uma string aleatória
func randomString(length int, charset string) string {
b := make([]byte, length)
for i := range b {
b[i] = charset[i%len(charset)]
}
return string(b)
}
// parseUUID converte string para UUID
func parseUUID(s string) *uuid.UUID {
u, err := uuid.Parse(s)
if err != nil {
return nil
}
return &u
}

View File

@@ -0,0 +1,90 @@
package handlers
import (
"encoding/json"
"net/http"
"aggios-app/backend/internal/api/middleware"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/service"
"github.com/google/uuid"
)
// CompanyHandler handles company endpoints
type CompanyHandler struct {
companyService *service.CompanyService
}
// NewCompanyHandler creates a new company handler
func NewCompanyHandler(companyService *service.CompanyService) *CompanyHandler {
return &CompanyHandler{
companyService: companyService,
}
}
// Create handles company creation
func (h *CompanyHandler) Create(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get user ID from context (set by auth middleware)
userIDStr, ok := r.Context().Value(middleware.UserIDKey).(string)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
userID, err := uuid.Parse(userIDStr)
if err != nil {
http.Error(w, "Invalid user ID", http.StatusBadRequest)
return
}
var req domain.CreateCompanyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// TODO: Get tenantID from user context
// For now, this is a placeholder - you'll need to get the tenant from the authenticated user
tenantID := uuid.New() // Replace with actual tenant from user
company, err := h.companyService.Create(req, tenantID, userID)
if err != nil {
switch err {
case service.ErrCNPJAlreadyExists:
http.Error(w, err.Error(), http.StatusConflict)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(company)
}
// List handles listing companies for a tenant
func (h *CompanyHandler) List(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// TODO: Get tenantID from authenticated user
tenantID := uuid.New() // Replace with actual tenant from user
companies, err := h.companyService.ListByTenant(tenantID)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(companies)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,465 @@
package handlers
import (
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/repository"
"aggios-app/backend/internal/service"
"aggios-app/backend/internal/config"
"aggios-app/backend/internal/api/middleware"
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"golang.org/x/crypto/bcrypt"
)
type CustomerPortalHandler struct {
crmRepo *repository.CRMRepository
authService *service.AuthService
cfg *config.Config
minioClient *minio.Client
}
func NewCustomerPortalHandler(crmRepo *repository.CRMRepository, authService *service.AuthService, cfg *config.Config) *CustomerPortalHandler {
// Initialize MinIO client
minioClient, err := minio.New(cfg.Minio.Endpoint, &minio.Options{
Creds: credentials.NewStaticV4(cfg.Minio.RootUser, cfg.Minio.RootPassword, ""),
Secure: cfg.Minio.UseSSL,
})
if err != nil {
log.Printf("❌ Failed to create MinIO client for CustomerPortalHandler: %v", err)
}
return &CustomerPortalHandler{
crmRepo: crmRepo,
authService: authService,
cfg: cfg,
minioClient: minioClient,
}
}
// CustomerLoginRequest representa a requisição de login do cliente
type CustomerLoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
// CustomerLoginResponse representa a resposta de login do cliente
type CustomerLoginResponse struct {
Token string `json:"token"`
Customer *CustomerPortalInfo `json:"customer"`
}
// CustomerPortalInfo representa informações seguras do cliente para o portal
type CustomerPortalInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Company string `json:"company"`
HasPortalAccess bool `json:"has_portal_access"`
TenantID string `json:"tenant_id"`
}
// Login autentica um cliente e retorna um token JWT
func (h *CustomerPortalHandler) Login(w http.ResponseWriter, r *http.Request) {
var req CustomerLoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Invalid request body",
})
return
}
// Validar entrada
if req.Email == "" || req.Password == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Email e senha são obrigatórios",
})
return
}
// Buscar cliente por email
customer, err := h.crmRepo.GetCustomerByEmail(req.Email)
if err != nil {
if err == sql.ErrNoRows {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{
"error": "Credenciais inválidas",
})
return
}
log.Printf("Error fetching customer: %v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Erro ao processar login",
})
return
}
// Verificar se tem acesso ao portal
if !customer.HasPortalAccess {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
json.NewEncoder(w).Encode(map[string]string{
"error": "Acesso ao portal não autorizado. Entre em contato com o administrador.",
})
return
}
// Verificar senha
if err := bcrypt.CompareHashAndPassword([]byte(customer.PasswordHash), []byte(req.Password)); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{
"error": "Credenciais inválidas",
})
return
}
// Atualizar último login
if err := h.crmRepo.UpdateCustomerLastLogin(customer.ID); err != nil {
log.Printf("Warning: Failed to update last login for customer %s: %v", customer.ID, err)
}
// Gerar token JWT
token, err := h.authService.GenerateCustomerToken(customer.ID, customer.TenantID, customer.Email)
if err != nil {
log.Printf("Error generating token: %v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Erro ao gerar token de autenticação",
})
return
}
// Resposta de sucesso
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(CustomerLoginResponse{
Token: token,
Customer: &CustomerPortalInfo{
ID: customer.ID,
Name: customer.Name,
Email: customer.Email,
Company: customer.Company,
HasPortalAccess: customer.HasPortalAccess,
TenantID: customer.TenantID,
},
})
}
// GetPortalDashboard retorna dados do dashboard para o cliente autenticado
func (h *CustomerPortalHandler) GetPortalDashboard(w http.ResponseWriter, r *http.Request) {
customerID, _ := r.Context().Value(middleware.CustomerIDKey).(string)
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
// Buscar leads do cliente
leads, err := h.crmRepo.GetLeadsByCustomerID(customerID)
if err != nil {
log.Printf("Error fetching leads: %v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Erro ao buscar leads",
})
return
}
// Buscar informações do cliente
customer, err := h.crmRepo.GetCustomerByID(customerID, tenantID)
if err != nil {
log.Printf("Error fetching customer: %v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Erro ao buscar informações do cliente",
})
return
}
// Calcular estatísticas
rawStats := calculateLeadStats(leads)
stats := map[string]interface{}{
"total_leads": rawStats["total"],
"active_leads": rawStats["novo"].(int) + rawStats["qualificado"].(int) + rawStats["negociacao"].(int),
"converted": rawStats["convertido"],
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"customer": CustomerPortalInfo{
ID: customer.ID,
Name: customer.Name,
Email: customer.Email,
Company: customer.Company,
HasPortalAccess: customer.HasPortalAccess,
TenantID: customer.TenantID,
},
"leads": leads,
"stats": stats,
})
}
// GetPortalLeads retorna apenas os leads do cliente
func (h *CustomerPortalHandler) GetPortalLeads(w http.ResponseWriter, r *http.Request) {
customerID, _ := r.Context().Value(middleware.CustomerIDKey).(string)
leads, err := h.crmRepo.GetLeadsByCustomerID(customerID)
if err != nil {
log.Printf("Error fetching leads: %v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Erro ao buscar leads",
})
return
}
if leads == nil {
leads = []domain.CRMLead{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"leads": leads,
})
}
// GetPortalLists retorna as listas que possuem leads do cliente
func (h *CustomerPortalHandler) GetPortalLists(w http.ResponseWriter, r *http.Request) {
customerID, _ := r.Context().Value(middleware.CustomerIDKey).(string)
lists, err := h.crmRepo.GetListsByCustomerID(customerID)
if err != nil {
log.Printf("Error fetching portal lists: %v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Erro ao buscar listas",
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"lists": lists,
})
}
// GetPortalProfile retorna o perfil completo do cliente
func (h *CustomerPortalHandler) GetPortalProfile(w http.ResponseWriter, r *http.Request) {
customerID, _ := r.Context().Value(middleware.CustomerIDKey).(string)
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
// Buscar informações do cliente
customer, err := h.crmRepo.GetCustomerByID(customerID, tenantID)
if err != nil {
log.Printf("Error fetching customer: %v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Erro ao buscar perfil",
})
return
}
// Buscar leads para estatísticas
leads, err := h.crmRepo.GetLeadsByCustomerID(customerID)
if err != nil {
log.Printf("Error fetching leads for stats: %v", err)
leads = []domain.CRMLead{}
}
// Calcular estatísticas
stats := calculateLeadStats(leads)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"customer": map[string]interface{}{
"id": customer.ID,
"name": customer.Name,
"email": customer.Email,
"phone": customer.Phone,
"company": customer.Company,
"logo_url": customer.LogoURL,
"portal_last_login": customer.PortalLastLogin,
"created_at": customer.CreatedAt,
"total_leads": len(leads),
"converted_leads": stats["convertido"].(int),
},
})
}
// ChangePasswordRequest representa a requisição de troca de senha
type CustomerChangePasswordRequest struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
// ChangePassword altera a senha do cliente
func (h *CustomerPortalHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
customerID, _ := r.Context().Value(middleware.CustomerIDKey).(string)
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
var req CustomerChangePasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Invalid request body",
})
return
}
// Validar entrada
if req.CurrentPassword == "" || req.NewPassword == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Senha atual e nova senha são obrigatórias",
})
return
}
if len(req.NewPassword) < 6 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "A nova senha deve ter no mínimo 6 caracteres",
})
return
}
// Buscar cliente
customer, err := h.crmRepo.GetCustomerByID(customerID, tenantID)
if err != nil {
log.Printf("Error fetching customer: %v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Erro ao processar solicitação",
})
return
}
// Verificar senha atual
if err := bcrypt.CompareHashAndPassword([]byte(customer.PasswordHash), []byte(req.CurrentPassword)); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{
"error": "Senha atual incorreta",
})
return
}
// Gerar hash da nova senha
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
log.Printf("Error hashing password: %v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Erro ao processar nova senha",
})
return
}
// Atualizar senha no banco
if err := h.crmRepo.UpdateCustomerPassword(customerID, string(hashedPassword)); err != nil {
log.Printf("Error updating password: %v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Erro ao atualizar senha",
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "Senha alterada com sucesso",
})
}
// UploadLogo faz o upload do logo do cliente
func (h *CustomerPortalHandler) UploadLogo(w http.ResponseWriter, r *http.Request) {
customerID, _ := r.Context().Value(middleware.CustomerIDKey).(string)
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
if h.minioClient == nil {
http.Error(w, "Storage service unavailable", http.StatusServiceUnavailable)
return
}
// Parse multipart form (2MB max)
const maxLogoSize = 2 * 1024 * 1024
if err := r.ParseMultipartForm(maxLogoSize); err != nil {
http.Error(w, "File too large", http.StatusBadRequest)
return
}
file, header, err := r.FormFile("logo")
if err != nil {
http.Error(w, "Failed to read file", http.StatusBadRequest)
return
}
defer file.Close()
// Validate file type
contentType := header.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "image/") {
http.Error(w, "Only images are allowed", http.StatusBadRequest)
return
}
// Generate unique filename
ext := filepath.Ext(header.Filename)
if ext == "" {
ext = ".png" // Default extension
}
filename := fmt.Sprintf("logo-%d%s", time.Now().Unix(), ext)
objectPath := fmt.Sprintf("customers/%s/%s", customerID, filename)
// Upload to MinIO
ctx := context.Background()
bucketName := h.cfg.Minio.BucketName
_, err = h.minioClient.PutObject(ctx, bucketName, objectPath, file, header.Size, minio.PutObjectOptions{
ContentType: contentType,
})
if err != nil {
log.Printf("Error uploading to MinIO: %v", err)
http.Error(w, "Failed to upload file", http.StatusInternalServerError)
return
}
// Generate public URL
logoURL := fmt.Sprintf("%s/api/files/%s/%s", h.cfg.Minio.PublicURL, bucketName, objectPath)
// Update customer in database
err = h.crmRepo.UpdateCustomerLogo(customerID, tenantID, logoURL)
if err != nil {
log.Printf("Error updating customer logo in DB: %v", err)
http.Error(w, "Failed to update profile", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"logo_url": logoURL,
})
}

View File

@@ -0,0 +1,210 @@
package handlers
import (
"aggios-app/backend/internal/api/middleware"
"aggios-app/backend/internal/domain"
"encoding/csv"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"github.com/xuri/excelize/v2"
)
// ExportLeads handles exporting leads in different formats
func (h *CRMHandler) ExportLeads(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
if tenantID == "" {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "Missing tenant_id"})
return
}
format := r.URL.Query().Get("format")
if format == "" {
format = "csv"
}
customerID := r.URL.Query().Get("customer_id")
campaignID := r.URL.Query().Get("campaign_id")
var leads []domain.CRMLead
var err error
if campaignID != "" {
leads, err = h.repo.GetLeadsByListID(campaignID)
} else if customerID != "" {
leads, err = h.repo.GetLeadsByTenant(tenantID)
// Filter by customer manually
filtered := []domain.CRMLead{}
for _, lead := range leads {
if lead.CustomerID != nil && *lead.CustomerID == customerID {
filtered = append(filtered, lead)
}
}
leads = filtered
} else {
leads, err = h.repo.GetLeadsByTenant(tenantID)
}
if err != nil {
log.Printf("ExportLeads: Error fetching leads: %v", err)
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": "Failed to fetch leads"})
return
}
switch strings.ToLower(format) {
case "json":
exportJSON(w, leads)
case "xlsx", "excel":
exportXLSX(w, leads)
default:
exportCSV(w, leads)
}
}
func exportJSON(w http.ResponseWriter, leads []domain.CRMLead) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", "attachment; filename=leads.json")
json.NewEncoder(w).Encode(map[string]interface{}{
"leads": leads,
"count": len(leads),
})
}
func exportCSV(w http.ResponseWriter, leads []domain.CRMLead) {
w.Header().Set("Content-Type", "text/csv")
w.Header().Set("Content-Disposition", "attachment; filename=leads.csv")
writer := csv.NewWriter(w)
defer writer.Flush()
// Header
header := []string{"ID", "Nome", "Email", "Telefone", "Status", "Origem", "Notas", "Tags", "Criado Em"}
writer.Write(header)
// Data
for _, lead := range leads {
tags := ""
if len(lead.Tags) > 0 {
tags = strings.Join(lead.Tags, ", ")
}
phone := ""
if lead.Phone != "" {
phone = lead.Phone
}
notes := ""
if lead.Notes != "" {
notes = lead.Notes
}
row := []string{
lead.ID,
lead.Name,
lead.Email,
phone,
lead.Status,
lead.Source,
notes,
tags,
lead.CreatedAt.Format("02/01/2006 15:04"),
}
writer.Write(row)
}
}
func exportXLSX(w http.ResponseWriter, leads []domain.CRMLead) {
f := excelize.NewFile()
defer f.Close()
sheetName := "Leads"
index, err := f.NewSheet(sheetName)
if err != nil {
log.Printf("Error creating sheet: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
// Set active sheet
f.SetActiveSheet(index)
// Header style
headerStyle, _ := f.NewStyle(&excelize.Style{
Font: &excelize.Font{
Bold: true,
Size: 12,
},
Fill: excelize.Fill{
Type: "pattern",
Color: []string{"#4472C4"},
Pattern: 1,
},
Alignment: &excelize.Alignment{
Horizontal: "center",
Vertical: "center",
},
})
// Headers
headers := []string{"ID", "Nome", "Email", "Telefone", "Status", "Origem", "Notas", "Tags", "Criado Em"}
for i, header := range headers {
cell := fmt.Sprintf("%s1", string(rune('A'+i)))
f.SetCellValue(sheetName, cell, header)
f.SetCellStyle(sheetName, cell, cell, headerStyle)
}
// Data
for i, lead := range leads {
row := i + 2
tags := ""
if len(lead.Tags) > 0 {
tags = strings.Join(lead.Tags, ", ")
}
phone := ""
if lead.Phone != "" {
phone = lead.Phone
}
notes := ""
if lead.Notes != "" {
notes = lead.Notes
}
f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), lead.ID)
f.SetCellValue(sheetName, fmt.Sprintf("B%d", row), lead.Name)
f.SetCellValue(sheetName, fmt.Sprintf("C%d", row), lead.Email)
f.SetCellValue(sheetName, fmt.Sprintf("D%d", row), phone)
f.SetCellValue(sheetName, fmt.Sprintf("E%d", row), lead.Status)
f.SetCellValue(sheetName, fmt.Sprintf("F%d", row), lead.Source)
f.SetCellValue(sheetName, fmt.Sprintf("G%d", row), notes)
f.SetCellValue(sheetName, fmt.Sprintf("H%d", row), tags)
f.SetCellValue(sheetName, fmt.Sprintf("I%d", row), lead.CreatedAt.Format("02/01/2006 15:04"))
}
// Auto-adjust column widths
for i := 0; i < len(headers); i++ {
col := string(rune('A' + i))
f.SetColWidth(sheetName, col, col, 15)
}
f.SetColWidth(sheetName, "B", "B", 25) // Nome
f.SetColWidth(sheetName, "C", "C", 30) // Email
f.SetColWidth(sheetName, "G", "G", 40) // Notas
// Delete default sheet if exists
f.DeleteSheet("Sheet1")
w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
w.Header().Set("Content-Disposition", "attachment; filename=leads.xlsx")
if err := f.Write(w); err != nil {
log.Printf("Error writing xlsx: %v", err)
}
}

View File

@@ -0,0 +1,104 @@
package handlers
import (
"context"
"fmt"
"io"
"log"
"net/http"
"strings"
"aggios-app/backend/internal/config"
"github.com/gorilla/mux"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
type FilesHandler struct {
config *config.Config
}
func NewFilesHandler(cfg *config.Config) *FilesHandler {
return &FilesHandler{
config: cfg,
}
}
// ServeFile serves files from MinIO through the API
func (h *FilesHandler) ServeFile(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
bucket := vars["bucket"]
// Get the file path (everything after /api/files/{bucket}/)
prefix := fmt.Sprintf("/api/files/%s/", bucket)
filePath := strings.TrimPrefix(r.URL.Path, prefix)
if filePath == "" {
http.Error(w, "File path is required", http.StatusBadRequest)
return
}
// Whitelist de buckets públicos permitidos
allowedBuckets := map[string]bool{
"aggios-logos": true,
}
if !allowedBuckets[bucket] {
log.Printf("🚫 Access denied to bucket: %s", bucket)
http.Error(w, "Access denied", http.StatusForbidden)
return
}
// Proteção contra path traversal
if strings.Contains(filePath, "..") {
log.Printf("🚫 Path traversal attempt detected: %s", filePath)
http.Error(w, "Invalid path", http.StatusBadRequest)
return
}
log.Printf("📁 Serving file: bucket=%s, path=%s", bucket, filePath)
// Initialize MinIO client
minioClient, err := minio.New("aggios-minio:9000", &minio.Options{
Creds: credentials.NewStaticV4("minioadmin", "M1n10_S3cur3_P@ss_2025!", ""),
Secure: false,
})
if err != nil {
log.Printf("Failed to create MinIO client: %v", err)
http.Error(w, "Storage service unavailable", http.StatusInternalServerError)
return
}
// Get object from MinIO
ctx := context.Background()
object, err := minioClient.GetObject(ctx, bucket, filePath, minio.GetObjectOptions{})
if err != nil {
log.Printf("Failed to get object: %v", err)
http.Error(w, "File not found", http.StatusNotFound)
return
}
defer object.Close()
// Get object info for content type and size
objInfo, err := object.Stat()
if err != nil {
log.Printf("Failed to stat object: %v", err)
http.Error(w, "File not found", http.StatusNotFound)
return
}
// Set appropriate headers
w.Header().Set("Content-Type", objInfo.ContentType)
w.Header().Set("Content-Length", fmt.Sprintf("%d", objInfo.Size))
w.Header().Set("Cache-Control", "public, max-age=31536000") // Cache for 1 year
w.Header().Set("Access-Control-Allow-Origin", "*")
// Copy file content to response
_, err = io.Copy(w, object)
if err != nil {
log.Printf("Failed to copy object content: %v", err)
return
}
log.Printf("✅ File served successfully: %s", filePath)
}

View File

@@ -0,0 +1,38 @@
package handlers
import (
"encoding/json"
"net/http"
"golang.org/x/crypto/bcrypt"
)
type HashRequest struct {
Password string `json:"password"`
}
type HashResponse struct {
Hash string `json:"hash"`
}
func GenerateHash(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req HashRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
http.Error(w, "Failed to generate hash", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(HashResponse{Hash: string(hash)})
}

View File

@@ -0,0 +1,31 @@
package handlers
import (
"encoding/json"
"net/http"
)
// HealthHandler handles health check endpoint
type HealthHandler struct{}
// NewHealthHandler creates a new health handler
func NewHealthHandler() *HealthHandler {
return &HealthHandler{}
}
// Check returns API health status
func (h *HealthHandler) Check(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
response := map[string]interface{}{
"status": "healthy",
"service": "aggios-api",
"version": "1.0.0",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}

View File

@@ -0,0 +1,274 @@
package handlers
import (
"encoding/json"
"log"
"net/http"
"strconv"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/service"
"github.com/google/uuid"
"github.com/gorilla/mux"
)
// PlanHandler handles plan-related endpoints
type PlanHandler struct {
planService *service.PlanService
}
// NewPlanHandler creates a new plan handler
func NewPlanHandler(planService *service.PlanService) *PlanHandler {
return &PlanHandler{
planService: planService,
}
}
// RegisterRoutes registers plan routes
func (h *PlanHandler) RegisterRoutes(r *mux.Router) {
// Note: Route protection is done in main.go with authMiddleware wrapper
r.HandleFunc("/api/admin/plans", h.CreatePlan).Methods(http.MethodPost)
r.HandleFunc("/api/admin/plans", h.ListPlans).Methods(http.MethodGet)
r.HandleFunc("/api/admin/plans/{id}", h.GetPlan).Methods(http.MethodGet)
r.HandleFunc("/api/admin/plans/{id}", h.UpdatePlan).Methods(http.MethodPut)
r.HandleFunc("/api/admin/plans/{id}", h.DeletePlan).Methods(http.MethodDelete)
// Public routes (for signup flow)
r.HandleFunc("/api/plans", h.ListActivePlans).Methods(http.MethodGet)
r.HandleFunc("/api/plans/{id}", h.GetActivePlan).Methods(http.MethodGet)
}
// CreatePlan creates a new plan (admin only)
func (h *PlanHandler) CreatePlan(w http.ResponseWriter, r *http.Request) {
log.Printf("📋 CREATE PLAN - Method: %s", r.Method)
var req domain.CreatePlanRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("❌ Invalid request body: %v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "Invalid request body", "message": err.Error()})
return
}
plan, err := h.planService.CreatePlan(&req)
if err != nil {
log.Printf("❌ Error creating plan: %v", err)
w.Header().Set("Content-Type", "application/json")
switch err {
case service.ErrPlanSlugTaken:
w.WriteHeader(http.StatusConflict)
json.NewEncoder(w).Encode(map[string]string{"error": "Slug already taken", "message": err.Error()})
case service.ErrInvalidUserRange:
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "Invalid user range", "message": err.Error()})
default:
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": "Internal server error", "message": err.Error()})
}
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Plan created successfully",
"plan": plan,
})
log.Printf("✅ Plan created: %s", plan.ID)
}
// GetPlan retrieves a plan by ID (admin only)
func (h *PlanHandler) GetPlan(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
idStr := vars["id"]
id, err := uuid.Parse(idStr)
if err != nil {
http.Error(w, "Invalid plan ID", http.StatusBadRequest)
return
}
plan, err := h.planService.GetPlan(id)
if err != nil {
if err == service.ErrPlanNotFound {
http.Error(w, "Plan not found", http.StatusNotFound)
} else {
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"plan": plan,
})
}
// ListPlans retrieves all plans (admin only)
func (h *PlanHandler) ListPlans(w http.ResponseWriter, r *http.Request) {
plans, err := h.planService.ListPlans()
if err != nil {
log.Printf("❌ Error listing plans: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"plans": plans,
})
log.Printf("✅ Listed %d plans", len(plans))
}
// ListActivePlans retrieves all active plans (public)
func (h *PlanHandler) ListActivePlans(w http.ResponseWriter, r *http.Request) {
plans, err := h.planService.ListActivePlans()
if err != nil {
log.Printf("❌ Error listing active plans: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"plans": plans,
})
}
// GetActivePlan retrieves an active plan by ID (public)
func (h *PlanHandler) GetActivePlan(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
idStr := vars["id"]
id, err := uuid.Parse(idStr)
if err != nil {
http.Error(w, "Invalid plan ID", http.StatusBadRequest)
return
}
plan, err := h.planService.GetPlan(id)
if err != nil {
if err == service.ErrPlanNotFound {
http.Error(w, "Plan not found", http.StatusNotFound)
} else {
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
// Check if plan is active
if !plan.IsActive {
http.Error(w, "Plan not available", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"plan": plan,
})
}
// UpdatePlan updates a plan (admin only)
func (h *PlanHandler) UpdatePlan(w http.ResponseWriter, r *http.Request) {
log.Printf("📋 UPDATE PLAN - Method: %s", r.Method)
vars := mux.Vars(r)
idStr := vars["id"]
id, err := uuid.Parse(idStr)
if err != nil {
http.Error(w, "Invalid plan ID", http.StatusBadRequest)
return
}
var req domain.UpdatePlanRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("❌ Invalid request body: %v", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
plan, err := h.planService.UpdatePlan(id, &req)
if err != nil {
log.Printf("❌ Error updating plan: %v", err)
switch err {
case service.ErrPlanNotFound:
http.Error(w, "Plan not found", http.StatusNotFound)
case service.ErrPlanSlugTaken:
http.Error(w, err.Error(), http.StatusConflict)
case service.ErrInvalidUserRange:
http.Error(w, err.Error(), http.StatusBadRequest)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Plan updated successfully",
"plan": plan,
})
log.Printf("✅ Plan updated: %s", plan.ID)
}
// DeletePlan deletes a plan (admin only)
func (h *PlanHandler) DeletePlan(w http.ResponseWriter, r *http.Request) {
log.Printf("📋 DELETE PLAN - Method: %s", r.Method)
vars := mux.Vars(r)
idStr := vars["id"]
id, err := uuid.Parse(idStr)
if err != nil {
http.Error(w, "Invalid plan ID", http.StatusBadRequest)
return
}
err = h.planService.DeletePlan(id)
if err != nil {
log.Printf("❌ Error deleting plan: %v", err)
switch err {
case service.ErrPlanNotFound:
http.Error(w, "Plan not found", http.StatusNotFound)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Plan deleted successfully",
})
log.Printf("✅ Plan deleted: %s", idStr)
}
// GetPlanByUserCount returns a plan for a given user count
func (h *PlanHandler) GetPlanByUserCount(w http.ResponseWriter, r *http.Request) {
userCountStr := r.URL.Query().Get("user_count")
if userCountStr == "" {
http.Error(w, "user_count parameter required", http.StatusBadRequest)
return
}
userCount, err := strconv.Atoi(userCountStr)
if err != nil {
http.Error(w, "Invalid user_count", http.StatusBadRequest)
return
}
plan, err := h.planService.GetPlanByUserCount(userCount)
if err != nil {
http.Error(w, "No plan available for this user count", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"plan": plan,
})
}

View File

@@ -0,0 +1,180 @@
package handlers
import (
"aggios-app/backend/internal/api/middleware"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/repository"
"aggios-app/backend/internal/service"
"context"
"encoding/json"
"log"
"net/http"
"github.com/google/uuid"
"github.com/gorilla/mux"
)
type SignupTemplateHandler struct {
repo *repository.SignupTemplateRepository
userRepo *repository.UserRepository
tenantRepo *repository.TenantRepository
agencyService *service.AgencyService
}
func NewSignupTemplateHandler(
repo *repository.SignupTemplateRepository,
userRepo *repository.UserRepository,
tenantRepo *repository.TenantRepository,
agencyService *service.AgencyService,
) *SignupTemplateHandler {
return &SignupTemplateHandler{
repo: repo,
userRepo: userRepo,
tenantRepo: tenantRepo,
agencyService: agencyService,
}
}
// CreateTemplate cria um novo template (SuperAdmin)
func (h *SignupTemplateHandler) CreateTemplate(w http.ResponseWriter, r *http.Request) {
var template domain.SignupTemplate
if err := json.NewDecoder(r.Body).Decode(&template); err != nil {
log.Printf("Error decoding request body: %v", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Pegar user_id do contexto (do middleware de autenticação)
userIDStr, ok := r.Context().Value(middleware.UserIDKey).(string)
if !ok || userIDStr == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
userID, err := uuid.Parse(userIDStr)
if err != nil {
log.Printf("Error parsing user_id: %v", err)
http.Error(w, "Invalid user ID", http.StatusUnauthorized)
return
}
template.CreatedBy = userID
template.IsActive = true
ctx := context.Background()
if err := h.repo.Create(ctx, &template); err != nil {
log.Printf("Error creating signup template: %v", err)
http.Error(w, "Error creating template", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(template)
}
// ListTemplates lista todos os templates (SuperAdmin)
func (h *SignupTemplateHandler) ListTemplates(w http.ResponseWriter, r *http.Request) {
ctx := context.Background()
templates, err := h.repo.List(ctx)
if err != nil {
log.Printf("Error listing signup templates: %v", err)
http.Error(w, "Error listing templates", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(templates)
}
// GetTemplateBySlug retorna um template pelo slug (público)
func (h *SignupTemplateHandler) GetTemplateBySlug(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
slug := vars["slug"]
ctx := context.Background()
template, err := h.repo.FindBySlug(ctx, slug)
if err != nil {
log.Printf("Error finding signup template by slug %s: %v", slug, err)
http.Error(w, "Template not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(template)
}
// GetTemplateByID retorna um template pelo ID (SuperAdmin)
func (h *SignupTemplateHandler) GetTemplateByID(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
idStr := vars["id"]
id, err := uuid.Parse(idStr)
if err != nil {
http.Error(w, "Invalid template ID", http.StatusBadRequest)
return
}
ctx := context.Background()
template, err := h.repo.FindByID(ctx, id)
if err != nil {
log.Printf("Error finding signup template by ID %s: %v", idStr, err)
http.Error(w, "Template not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(template)
}
// UpdateTemplate atualiza um template (SuperAdmin)
func (h *SignupTemplateHandler) UpdateTemplate(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
idStr := vars["id"]
id, err := uuid.Parse(idStr)
if err != nil {
http.Error(w, "Invalid template ID", http.StatusBadRequest)
return
}
var template domain.SignupTemplate
if err := json.NewDecoder(r.Body).Decode(&template); err != nil {
log.Printf("Error decoding request body: %v", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
template.ID = id
ctx := context.Background()
if err := h.repo.Update(ctx, &template); err != nil {
log.Printf("Error updating signup template: %v", err)
http.Error(w, "Error updating template", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(template)
}
// DeleteTemplate deleta um template (SuperAdmin)
func (h *SignupTemplateHandler) DeleteTemplate(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
idStr := vars["id"]
id, err := uuid.Parse(idStr)
if err != nil {
http.Error(w, "Invalid template ID", http.StatusBadRequest)
return
}
ctx := context.Background()
if err := h.repo.Delete(ctx, id); err != nil {
log.Printf("Error deleting signup template: %v", err)
http.Error(w, "Error deleting template", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -0,0 +1,121 @@
package handlers
import (
"aggios-app/backend/internal/domain"
"context"
"encoding/json"
"log"
"net/http"
"golang.org/x/crypto/bcrypt"
)
// PublicSignupRequest representa o cadastro público via template
type PublicSignupRequest struct {
TemplateSlug string `json:"template_slug"`
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
Subdomain string `json:"subdomain"`
CompanyName string `json:"company_name"`
}
// PublicRegister handles public registration via template
func (h *SignupTemplateHandler) PublicRegister(w http.ResponseWriter, r *http.Request) {
var req PublicSignupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("Error decoding request body: %v", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
ctx := context.Background()
// 1. Buscar o template
template, err := h.repo.FindBySlug(ctx, req.TemplateSlug)
if err != nil {
log.Printf("Error finding template: %v", err)
http.Error(w, "Template not found", http.StatusNotFound)
return
}
// 2. Incrementar usage_count
if err := h.repo.IncrementUsageCount(ctx, template.ID); err != nil {
log.Printf("Error incrementing usage count: %v", err)
}
// 3. Verificar se email já existe
emailExists, err := h.userRepo.EmailExists(req.Email)
if err != nil {
log.Printf("Error checking email: %v", err)
http.Error(w, "Error processing registration", http.StatusInternalServerError)
return
}
if emailExists {
http.Error(w, "Email already registered", http.StatusBadRequest)
return
}
// 4. Verificar se subdomain já existe (se fornecido)
if req.Subdomain != "" {
exists, err := h.tenantRepo.SubdomainExists(req.Subdomain)
if err != nil {
log.Printf("Error checking subdomain: %v", err)
http.Error(w, "Error processing registration", http.StatusInternalServerError)
return
}
if exists {
http.Error(w, "Subdomain already taken", http.StatusBadRequest)
return
}
}
// 5. Hash da senha
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
log.Printf("Error hashing password: %v", err)
http.Error(w, "Error processing registration", http.StatusInternalServerError)
return
}
// 6. Criar tenant (empresa/cliente)
tenant := &domain.Tenant{
Name: req.CompanyName,
Domain: req.Subdomain + ".aggios.app",
Subdomain: req.Subdomain,
Description: "Registered via " + template.Name,
}
if err := h.tenantRepo.Create(tenant); err != nil {
log.Printf("Error creating tenant: %v", err)
http.Error(w, "Error creating account", http.StatusInternalServerError)
return
}
// 7. Criar usuário admin do tenant
user := &domain.User{
Email: req.Email,
Password: string(hashedPassword),
Name: req.Name,
Role: "CLIENTE",
TenantID: &tenant.ID,
}
if err := h.userRepo.Create(user); err != nil {
log.Printf("Error creating user: %v", err)
http.Error(w, "Error creating user", http.StatusInternalServerError)
return
}
// 8. Resposta de sucesso
response := map[string]interface{}{
"success": true,
"message": template.SuccessMessage,
"tenant_id": tenant.ID,
"user_id": user.ID,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(response)
}

View File

@@ -0,0 +1,252 @@
package handlers
import (
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/repository"
"aggios-app/backend/internal/api/middleware"
"encoding/json"
"log"
"net/http"
"github.com/google/uuid"
"github.com/gorilla/mux"
)
type SolutionHandler struct {
repo *repository.SolutionRepository
}
func NewSolutionHandler(repo *repository.SolutionRepository) *SolutionHandler {
return &SolutionHandler{repo: repo}
}
// ==================== CRUD SOLUTIONS (SUPERADMIN) ====================
func (h *SolutionHandler) CreateSolution(w http.ResponseWriter, r *http.Request) {
var solution domain.Solution
if err := json.NewDecoder(r.Body).Decode(&solution); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Invalid request body",
"message": err.Error(),
})
return
}
solution.ID = uuid.New().String()
if err := h.repo.CreateSolution(&solution); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Failed to create solution",
"message": err.Error(),
})
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]interface{}{
"solution": solution,
})
}
func (h *SolutionHandler) GetAllSolutions(w http.ResponseWriter, r *http.Request) {
solutions, err := h.repo.GetAllSolutions()
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Failed to fetch solutions",
"message": err.Error(),
})
return
}
if solutions == nil {
solutions = []domain.Solution{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"solutions": solutions,
})
}
func (h *SolutionHandler) GetSolution(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
solutionID := vars["id"]
solution, err := h.repo.GetSolutionByID(solutionID)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{
"error": "Solution not found",
"message": err.Error(),
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"solution": solution,
})
}
func (h *SolutionHandler) UpdateSolution(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
solutionID := vars["id"]
var solution domain.Solution
if err := json.NewDecoder(r.Body).Decode(&solution); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Invalid request body",
"message": err.Error(),
})
return
}
solution.ID = solutionID
if err := h.repo.UpdateSolution(&solution); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Failed to update solution",
"message": err.Error(),
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "Solution updated successfully",
})
}
func (h *SolutionHandler) DeleteSolution(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
solutionID := vars["id"]
if err := h.repo.DeleteSolution(solutionID); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Failed to delete solution",
"message": err.Error(),
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "Solution deleted successfully",
})
}
// ==================== TENANT SOLUTIONS (AGENCY) ====================
func (h *SolutionHandler) GetTenantSolutions(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
log.Printf("🔍 GetTenantSolutions: tenantID=%s", tenantID)
if tenantID == "" {
log.Printf("❌ GetTenantSolutions: Missing tenant_id")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Missing tenant_id",
})
return
}
solutions, err := h.repo.GetTenantSolutions(tenantID)
if err != nil {
log.Printf("❌ GetTenantSolutions: Error fetching solutions: %v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Failed to fetch solutions",
"message": err.Error(),
})
return
}
log.Printf("✅ GetTenantSolutions: Found %d solutions for tenant %s", len(solutions), tenantID)
if solutions == nil {
solutions = []domain.Solution{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"solutions": solutions,
})
}
// ==================== PLAN SOLUTIONS ====================
func (h *SolutionHandler) GetPlanSolutions(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
planID := vars["plan_id"]
solutions, err := h.repo.GetPlanSolutions(planID)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Failed to fetch plan solutions",
"message": err.Error(),
})
return
}
if solutions == nil {
solutions = []domain.Solution{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"solutions": solutions,
})
}
func (h *SolutionHandler) SetPlanSolutions(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
planID := vars["plan_id"]
var req struct {
SolutionIDs []string `json:"solution_ids"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Invalid request body",
"message": err.Error(),
})
return
}
if err := h.repo.SetPlanSolutions(planID, req.SolutionIDs); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Failed to update plan solutions",
"message": err.Error(),
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "Plan solutions updated successfully",
})
}

View File

@@ -0,0 +1,197 @@
package handlers
import (
"encoding/json"
"log"
"net/http"
"aggios-app/backend/internal/api/middleware"
"aggios-app/backend/internal/service"
"github.com/google/uuid"
)
// TenantHandler handles tenant/agency listing endpoints
type TenantHandler struct {
tenantService *service.TenantService
}
// NewTenantHandler creates a new tenant handler
func NewTenantHandler(tenantService *service.TenantService) *TenantHandler {
return &TenantHandler{
tenantService: tenantService,
}
}
// ListAll lists all agencies/tenants (SUPERADMIN only)
func (h *TenantHandler) ListAll(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
tenants, err := h.tenantService.ListAllWithDetails()
if err != nil {
log.Printf("Error listing tenants with details: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if tenants == nil {
tenants = []map[string]interface{}{}
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
json.NewEncoder(w).Encode(tenants)
}
// CheckExists returns 200 if tenant exists by subdomain, otherwise 404
func (h *TenantHandler) CheckExists(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
subdomain := r.URL.Query().Get("subdomain")
if subdomain == "" {
http.Error(w, "subdomain is required", http.StatusBadRequest)
return
}
_, err := h.tenantService.GetBySubdomain(subdomain)
if err != nil {
if err == service.ErrTenantNotFound {
http.NotFound(w, r)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// GetPublicConfig returns public branding info for a tenant by subdomain
func (h *TenantHandler) GetPublicConfig(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
subdomain := r.URL.Query().Get("subdomain")
if subdomain == "" {
http.Error(w, "subdomain is required", http.StatusBadRequest)
return
}
tenant, err := h.tenantService.GetBySubdomain(subdomain)
if err != nil {
if err == service.ErrTenantNotFound {
http.NotFound(w, r)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Return only public info
response := map[string]interface{}{
"id": tenant.ID.String(),
"name": tenant.Name,
"primary_color": tenant.PrimaryColor,
"secondary_color": tenant.SecondaryColor,
"logo_url": tenant.LogoURL,
"logo_horizontal_url": tenant.LogoHorizontalURL,
}
log.Printf("📤 Returning tenant config for %s: logo_url=%s", subdomain, tenant.LogoURL)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
json.NewEncoder(w).Encode(response)
}
// GetBranding returns branding info for the current authenticated tenant
func (h *TenantHandler) GetBranding(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get tenant from context (set by auth middleware)
tenantID := r.Context().Value(middleware.TenantIDKey)
if tenantID == nil {
http.Error(w, "Tenant not found in context", http.StatusUnauthorized)
return
}
// Parse tenant ID
tid, err := uuid.Parse(tenantID.(string))
if err != nil {
http.Error(w, "Invalid tenant ID", http.StatusBadRequest)
return
}
// Get tenant from database
tenant, err := h.tenantService.GetByID(tid)
if err != nil {
http.Error(w, "Error fetching branding", http.StatusInternalServerError)
return
}
// Return branding info
response := map[string]interface{}{
"id": tenant.ID.String(),
"name": tenant.Name,
"primary_color": tenant.PrimaryColor,
"secondary_color": tenant.SecondaryColor,
"logo_url": tenant.LogoURL,
"logo_horizontal_url": tenant.LogoHorizontalURL,
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
json.NewEncoder(w).Encode(response)
}
// GetProfile returns public tenant information by tenant ID
func (h *TenantHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Extract tenant ID from URL path
// URL format: /api/tenants/{id}/profile
tenantIDStr := r.URL.Path[len("/api/tenants/"):]
if idx := len(tenantIDStr) - len("/profile"); idx > 0 {
tenantIDStr = tenantIDStr[:idx]
}
if tenantIDStr == "" {
http.Error(w, "tenant_id is required", http.StatusBadRequest)
return
}
// Para compatibilidade, aceitar tanto UUID quanto ID numérico
// Primeiro tentar como UUID, se falhar buscar tenant diretamente
tenant, err := h.tenantService.GetBySubdomain(tenantIDStr)
if err != nil {
log.Printf("Error getting tenant: %v", err)
http.Error(w, "Tenant not found", http.StatusNotFound)
return
}
// Return public info
response := map[string]interface{}{
"tenant": map[string]string{
"company": tenant.Name,
"primary_color": tenant.PrimaryColor,
"secondary_color": tenant.SecondaryColor,
"logo_url": tenant.LogoURL,
"logo_horizontal_url": tenant.LogoHorizontalURL,
},
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
json.NewEncoder(w).Encode(response)
}

View File

@@ -0,0 +1,130 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"path/filepath"
"strings"
"aggios-app/backend/internal/api/middleware"
"aggios-app/backend/internal/config"
"github.com/google/uuid"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
// UploadHandler handles file upload endpoints
type UploadHandler struct {
minioClient *minio.Client
cfg *config.Config
}
// NewUploadHandler creates a new upload handler
func NewUploadHandler(cfg *config.Config) (*UploadHandler, error) {
// Initialize MinIO client
minioClient, err := minio.New(cfg.Minio.Endpoint, &minio.Options{
Creds: credentials.NewStaticV4(cfg.Minio.RootUser, cfg.Minio.RootPassword, ""),
Secure: cfg.Minio.UseSSL,
})
if err != nil {
return nil, fmt.Errorf("failed to create MinIO client: %w", err)
}
// Ensure bucket exists
ctx := context.Background()
bucketName := cfg.Minio.BucketName
exists, err := minioClient.BucketExists(ctx, bucketName)
if err != nil {
return nil, fmt.Errorf("failed to check bucket existence: %w", err)
}
if !exists {
err = minioClient.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{})
if err != nil {
return nil, fmt.Errorf("failed to create bucket: %w", err)
}
}
return &UploadHandler{
minioClient: minioClient,
cfg: cfg,
}, nil
}
// UploadResponse represents the upload response
type UploadResponse struct {
FileID string `json:"file_id"`
FileName string `json:"file_name"`
FileURL string `json:"file_url"`
FileSize int64 `json:"file_size"`
}
// Upload handles file upload
func (h *UploadHandler) Upload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Try to get user ID from context (optional for signup flow)
userIDStr, _ := r.Context().Value(middleware.UserIDKey).(string)
// Use temp tenant for unauthenticated uploads (signup flow)
tenantID := uuid.MustParse("00000000-0000-0000-0000-000000000000")
if userIDStr != "" {
// TODO: Query database to get tenant_id from user_id when authenticated
}
// Parse multipart form (max 10MB)
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Error(w, "File too large (max 10MB)", http.StatusBadRequest)
return
}
// Get file from form
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, "Failed to read file", http.StatusBadRequest)
return
}
defer file.Close()
// Validate file type (images only)
contentType := header.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "image/") {
http.Error(w, "Only images are allowed", http.StatusBadRequest)
return
}
// Generate unique file ID
fileID := uuid.New()
ext := filepath.Ext(header.Filename)
objectName := fmt.Sprintf("tenants/%s/logos/%s%s", tenantID.String(), fileID.String(), ext)
// Upload to MinIO
ctx := context.Background()
_, err = h.minioClient.PutObject(ctx, h.cfg.Minio.BucketName, objectName, file, header.Size, minio.PutObjectOptions{
ContentType: contentType,
})
if err != nil {
http.Error(w, "Failed to upload file", http.StatusInternalServerError)
return
}
// Generate public URL (replace internal hostname with localhost for browser access)
fileURL := fmt.Sprintf("http://localhost:9000/%s/%s", h.cfg.Minio.BucketName, objectName)
// Return response
response := UploadResponse{
FileID: fileID.String(),
FileName: header.Filename,
FileURL: fileURL,
FileSize: header.Size,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}

View File

@@ -0,0 +1,110 @@
package middleware
import (
"context"
"log"
"net/http"
"strings"
"aggios-app/backend/internal/config"
"github.com/golang-jwt/jwt/v5"
)
type contextKey string
const UserIDKey contextKey = "userID"
const TenantIDKey contextKey = "tenantID"
// Auth validates JWT tokens
func Auth(cfg *config.Config) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
bearerToken := strings.Split(authHeader, " ")
if len(bearerToken) != 2 || bearerToken[0] != "Bearer" {
http.Error(w, "Invalid token format", http.StatusUnauthorized)
return
}
token, err := jwt.Parse(bearerToken[1], func(token *jwt.Token) (interface{}, error) {
return []byte(cfg.JWT.Secret), nil
})
if err != nil || !token.Valid {
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
http.Error(w, "Invalid token claims", http.StatusUnauthorized)
return
}
// Verificar se user_id existe e é do tipo correto
userIDClaim, ok := claims["user_id"]
if !ok || userIDClaim == nil {
http.Error(w, "Missing user_id in token", http.StatusUnauthorized)
return
}
userID, ok := userIDClaim.(string)
if !ok {
http.Error(w, "Invalid user_id format in token", http.StatusUnauthorized)
return
}
// tenant_id pode ser nil para SuperAdmin
var tenantIDFromJWT string
if tenantIDClaim, ok := claims["tenant_id"]; ok && tenantIDClaim != nil {
tenantIDFromJWT, _ = tenantIDClaim.(string)
}
// VALIDAÇÃO DE SEGURANÇA: Verificar user_type para impedir clientes de acessarem rotas de agência
if userTypeClaim, ok := claims["user_type"]; ok && userTypeClaim != nil {
userType, _ := userTypeClaim.(string)
if userType == "customer" {
log.Printf("❌ CUSTOMER ACCESS BLOCKED: Customer %s tried to access agency route %s", userID, r.RequestURI)
http.Error(w, "Forbidden: Customers cannot access agency routes", http.StatusForbidden)
return
}
}
// VALIDAÇÃO DE SEGURANÇA: Verificar se o tenant_id do JWT corresponde ao subdomínio acessado
// Pegar o tenant_id do contexto (detectado pelo TenantDetector middleware ANTES deste)
tenantIDFromContext := ""
if ctxTenantID := r.Context().Value(TenantIDKey); ctxTenantID != nil {
tenantIDFromContext, _ = ctxTenantID.(string)
}
log.Printf("🔐 AUTH VALIDATION: JWT tenant=%s | Context tenant=%s | Path=%s",
tenantIDFromJWT, tenantIDFromContext, r.RequestURI)
// Se o usuário não é SuperAdmin (tem tenant_id) e está acessando uma agência (subdomain detectado)
if tenantIDFromJWT != "" && tenantIDFromContext != "" {
// Validar se o tenant_id do JWT corresponde ao tenant detectado
if tenantIDFromJWT != tenantIDFromContext {
log.Printf("❌ CROSS-TENANT ACCESS BLOCKED: User from tenant %s tried to access tenant %s",
tenantIDFromJWT, tenantIDFromContext)
http.Error(w, "Forbidden: You don't have access to this tenant", http.StatusForbidden)
return
}
log.Printf("✅ TENANT VALIDATION PASSED: %s", tenantIDFromJWT)
}
// Preservar TODOS os valores do contexto anterior (incluindo o tenantID do TenantDetector)
ctx := r.Context()
ctx = context.WithValue(ctx, UserIDKey, userID)
// Só sobrescrever o TenantIDKey se vier do JWT (para não perder o do TenantDetector)
if tenantIDFromJWT != "" {
ctx = context.WithValue(ctx, TenantIDKey, tenantIDFromJWT)
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}

View File

@@ -0,0 +1,44 @@
package middleware
import (
"log"
"net/http"
"strings"
)
// CheckCollaboratorReadOnly verifica se um colaborador está tentando fazer operações de escrita
// Se sim, bloqueia com 403
func CheckCollaboratorReadOnly(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verificar agency_role do contexto
agencyRole, ok := r.Context().Value("agency_role").(string)
if !ok {
// Se não houver agency_role no contexto, é um customer, deixa passar
next.ServeHTTP(w, r)
return
}
// Apenas colaboradores têm restrição de read-only
if agencyRole != "collaborator" {
next.ServeHTTP(w, r)
return
}
// Verificar se é uma operação de escrita
method := r.Method
if method == http.MethodPost || method == http.MethodPut || method == http.MethodDelete {
// Verificar a rota
path := r.URL.Path
// Bloquear operações de escrita em CRM
if strings.Contains(path, "/api/crm/") {
userID, _ := r.Context().Value(UserIDKey).(string)
log.Printf("❌ COLLABORATOR WRITE BLOCKED: User %s (collaborator) tried %s %s", userID, method, path)
http.Error(w, "Colaboradores têm acesso somente leitura", http.StatusForbidden)
return
}
}
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,34 @@
package middleware
import (
"net/http"
"aggios-app/backend/internal/config"
)
// CORS adds CORS headers to responses
func CORS(cfg *config.Config) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
// Allow all localhost origins for development
if origin != "" {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, Host")
w.Header().Set("Access-Control-Max-Age", "3600")
// Handle preflight request
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
}

View File

@@ -0,0 +1,85 @@
package middleware
import (
"aggios-app/backend/internal/config"
"context"
"log"
"net/http"
"strings"
"github.com/golang-jwt/jwt/v5"
)
const (
CustomerIDKey contextKey = "customer_id"
)
// CustomerAuthMiddleware valida tokens JWT de clientes do portal
func CustomerAuthMiddleware(cfg *config.Config) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Extrair token do header Authorization
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Authorization header required", http.StatusUnauthorized)
return
}
// Remover "Bearer " prefix
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader {
http.Error(w, "Invalid authorization format", http.StatusUnauthorized)
return
}
// Parse e validar token
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// Verificar método de assinatura
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return []byte(cfg.JWT.Secret), nil
})
if err != nil || !token.Valid {
log.Printf("Invalid token: %v", err)
http.Error(w, "Invalid or expired token", http.StatusUnauthorized)
return
}
// Extrair claims
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
http.Error(w, "Invalid token claims", http.StatusUnauthorized)
return
}
// Verificar se é token de customer
tokenType, _ := claims["type"].(string)
if tokenType != "customer_portal" {
http.Error(w, "Invalid token type", http.StatusUnauthorized)
return
}
// Extrair customer_id e tenant_id
customerID, ok := claims["customer_id"].(string)
if !ok {
http.Error(w, "Invalid customer_id in token", http.StatusUnauthorized)
return
}
tenantID, ok := claims["tenant_id"].(string)
if !ok {
http.Error(w, "Invalid tenant_id in token", http.StatusUnauthorized)
return
}
// Adicionar ao contexto
ctx := context.WithValue(r.Context(), CustomerIDKey, customerID)
ctx = context.WithValue(ctx, TenantIDKey, tenantID)
// Prosseguir com a requisição
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}

View File

@@ -0,0 +1,96 @@
package middleware
import (
"net/http"
"sync"
"time"
"aggios-app/backend/internal/config"
)
type rateLimiter struct {
mu sync.Mutex
attempts map[string][]time.Time
maxAttempts int
}
func newRateLimiter(maxAttempts int) *rateLimiter {
rl := &rateLimiter{
attempts: make(map[string][]time.Time),
maxAttempts: maxAttempts,
}
// Clean old entries every minute
go func() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for range ticker.C {
rl.cleanup()
}
}()
return rl
}
func (rl *rateLimiter) cleanup() {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
for ip, attempts := range rl.attempts {
var valid []time.Time
for _, t := range attempts {
if now.Sub(t) < time.Minute {
valid = append(valid, t)
}
}
if len(valid) == 0 {
delete(rl.attempts, ip)
} else {
rl.attempts[ip] = valid
}
}
}
func (rl *rateLimiter) isAllowed(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
attempts := rl.attempts[ip]
// Filter attempts within the last minute
var validAttempts []time.Time
for _, t := range attempts {
if now.Sub(t) < time.Minute {
validAttempts = append(validAttempts, t)
}
}
if len(validAttempts) >= rl.maxAttempts {
return false
}
validAttempts = append(validAttempts, now)
rl.attempts[ip] = validAttempts
return true
}
// RateLimit limits requests per IP address
func RateLimit(cfg *config.Config) func(http.Handler) http.Handler {
limiter := newRateLimiter(cfg.Security.MaxAttemptsPerMin)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := r.RemoteAddr
if !limiter.isAllowed(ip) {
http.Error(w, "Too many requests", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}

View File

@@ -0,0 +1,17 @@
package middleware
import (
"net/http"
)
// SecurityHeaders adds security headers to responses
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,89 @@
package middleware
import (
"context"
"log"
"net/http"
"strings"
"aggios-app/backend/internal/repository"
)
const SubdomainKey contextKey = "subdomain"
// TenantDetector detects tenant from subdomain
func TenantDetector(tenantRepo *repository.TenantRepository) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Get host from X-Forwarded-Host header (set by Next.js proxy) or Host header
// Priority order: X-Tenant-Subdomain (set by Next.js middleware) > X-Forwarded-Host > X-Original-Host > Host
tenantSubdomain := r.Header.Get("X-Tenant-Subdomain")
var host string
if tenantSubdomain != "" {
// Use direct subdomain from Next.js middleware
host = tenantSubdomain
log.Printf("TenantDetector: using X-Tenant-Subdomain = %s", tenantSubdomain)
} else {
// Fallback to extracting from host headers
host = r.Header.Get("X-Forwarded-Host")
if host == "" {
host = r.Header.Get("X-Original-Host")
}
if host == "" {
host = r.Host
}
log.Printf("TenantDetector: host = %s (from headers), path = %s", host, r.RequestURI)
}
// Extract subdomain
// Examples:
// - agencia-xyz.localhost -> agencia-xyz
// - agencia-xyz.aggios.app -> agencia-xyz
// - dash.localhost -> dash (master admin)
// - localhost -> (institutional site)
var subdomain string
// If we got the subdomain directly from X-Tenant-Subdomain, use it
if tenantSubdomain != "" {
subdomain = tenantSubdomain
// Remove port if present
if strings.Contains(subdomain, ":") {
subdomain = strings.Split(subdomain, ":")[0]
}
} else {
// Extract from host
parts := strings.Split(host, ".")
if len(parts) >= 2 {
// Has subdomain
subdomain = parts[0]
// Remove port if present
if strings.Contains(subdomain, ":") {
subdomain = strings.Split(subdomain, ":")[0]
}
}
}
log.Printf("TenantDetector: extracted subdomain = %s", subdomain)
// Add subdomain to context
ctx := context.WithValue(r.Context(), SubdomainKey, subdomain)
// If subdomain is not empty and not "dash" or "api", try to find tenant
if subdomain != "" && subdomain != "dash" && subdomain != "api" && subdomain != "localhost" {
tenant, err := tenantRepo.FindBySubdomain(subdomain)
if err == nil && tenant != nil {
log.Printf("TenantDetector: found tenant %s for subdomain %s", tenant.ID.String(), subdomain)
ctx = context.WithValue(ctx, TenantIDKey, tenant.ID.String())
} else {
log.Printf("TenantDetector: tenant not found for subdomain %s (err=%v)", subdomain, err)
}
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}

View File

@@ -0,0 +1,104 @@
package middleware
import (
"aggios-app/backend/internal/config"
"aggios-app/backend/internal/domain"
"context"
"log"
"net/http"
"strings"
"github.com/golang-jwt/jwt/v5"
)
// UnifiedAuthMiddleware valida JWT unificado e permite múltiplos tipos de usuários
func UnifiedAuthMiddleware(cfg *config.Config, allowedTypes ...domain.UserType) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Extrair token do header Authorization
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
log.Printf("🚫 UnifiedAuth: Missing Authorization header")
http.Error(w, "Unauthorized: Missing token", http.StatusUnauthorized)
return
}
// Formato esperado: "Bearer <token>"
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
log.Printf("🚫 UnifiedAuth: Invalid Authorization format")
http.Error(w, "Unauthorized: Invalid token format", http.StatusUnauthorized)
return
}
tokenString := parts[1]
// Parsear e validar token
token, err := jwt.ParseWithClaims(tokenString, &domain.UnifiedClaims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(cfg.JWT.Secret), nil
})
if err != nil {
log.Printf("🚫 UnifiedAuth: Token parse error: %v", err)
http.Error(w, "Unauthorized: Invalid token", http.StatusUnauthorized)
return
}
claims, ok := token.Claims.(*domain.UnifiedClaims)
if !ok || !token.Valid {
log.Printf("🚫 UnifiedAuth: Invalid token claims")
http.Error(w, "Unauthorized: Invalid token", http.StatusUnauthorized)
return
}
// Verificar se o tipo de usuário é permitido
if len(allowedTypes) > 0 {
allowed := false
for _, allowedType := range allowedTypes {
if claims.UserType == allowedType {
allowed = true
break
}
}
if !allowed {
log.Printf("🚫 UnifiedAuth: User type %s not allowed (allowed: %v)", claims.UserType, allowedTypes)
http.Error(w, "Forbidden: Insufficient permissions", http.StatusForbidden)
return
}
}
// Adicionar informações ao contexto
ctx := r.Context()
ctx = context.WithValue(ctx, UserIDKey, claims.UserID)
ctx = context.WithValue(ctx, TenantIDKey, claims.TenantID)
ctx = context.WithValue(ctx, "email", claims.Email)
ctx = context.WithValue(ctx, "user_type", string(claims.UserType))
ctx = context.WithValue(ctx, "role", claims.Role)
// Para compatibilidade com handlers de portal que esperam CustomerIDKey
if claims.UserType == domain.UserTypeCustomer {
ctx = context.WithValue(ctx, CustomerIDKey, claims.UserID)
}
log.Printf("✅ UnifiedAuth: Authenticated user_id=%s, type=%s, role=%s, tenant=%s",
claims.UserID, claims.UserType, claims.Role, claims.TenantID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// RequireAgencyUser middleware que permite apenas usuários de agência (admin, colaborador)
func RequireAgencyUser(cfg *config.Config) func(http.Handler) http.Handler {
return UnifiedAuthMiddleware(cfg, domain.UserTypeAgency)
}
// RequireCustomer middleware que permite apenas clientes
func RequireCustomer(cfg *config.Config) func(http.Handler) http.Handler {
return UnifiedAuthMiddleware(cfg, domain.UserTypeCustomer)
}
// RequireAnyAuthenticated middleware que permite qualquer usuário autenticado
func RequireAnyAuthenticated(cfg *config.Config) func(http.Handler) http.Handler {
return UnifiedAuthMiddleware(cfg) // Sem filtro de tipo
}

View File

@@ -0,0 +1,121 @@
package config
import (
"os"
)
// Config holds all application configuration
type Config struct {
Server ServerConfig
Database DatabaseConfig
JWT JWTConfig
Security SecurityConfig
App AppConfig
Minio MinioConfig
}
// AppConfig holds application-level settings
type AppConfig struct {
Environment string // "development" or "production"
BaseDomain string // "localhost" or "aggios.app"
}
// ServerConfig holds server-specific configuration
type ServerConfig struct {
Port string
}
// DatabaseConfig holds database connection settings
type DatabaseConfig struct {
Host string
Port string
User string
Password string
Name string
}
// JWTConfig holds JWT configuration
type JWTConfig struct {
Secret string
}
// SecurityConfig holds security settings
type SecurityConfig struct {
AllowedOrigins []string
MaxAttemptsPerMin int
PasswordMinLength int
}
// MinioConfig holds MinIO configuration
type MinioConfig struct {
Endpoint string
PublicURL string // URL pública para acesso ao MinIO (para gerar links)
RootUser string
RootPassword string
UseSSL bool
BucketName string
}
// Load loads configuration from environment variables
func Load() *Config {
env := getEnvOrDefault("APP_ENV", "development")
baseDomain := "localhost"
if env == "production" {
baseDomain = "aggios.app"
}
// Rate limit: more lenient in dev, strict in prod
maxAttempts := 1000 // Aumentado drasticamente para evitar 429 durante debug
if env == "production" {
maxAttempts = 100 // Mais restritivo em produção
}
return &Config{
Server: ServerConfig{
Port: getEnvOrDefault("SERVER_PORT", "8080"),
},
Database: DatabaseConfig{
Host: getEnvOrDefault("DB_HOST", "localhost"),
Port: getEnvOrDefault("DB_PORT", "5432"),
User: getEnvOrDefault("DB_USER", "postgres"),
Password: getEnvOrDefault("DB_PASSWORD", "postgres"),
Name: getEnvOrDefault("DB_NAME", "aggios"),
},
JWT: JWTConfig{
Secret: getEnvOrDefault("JWT_SECRET", "INSECURE-fallback-secret-CHANGE-THIS"),
},
App: AppConfig{
Environment: env,
BaseDomain: baseDomain,
},
Security: SecurityConfig{
AllowedOrigins: []string{
"http://localhost",
"http://dash.localhost",
"http://aggios.local",
"http://dash.aggios.local",
"https://aggios.app",
"https://dash.aggios.app",
"https://www.aggios.app",
},
MaxAttemptsPerMin: maxAttempts,
PasswordMinLength: 8,
},
Minio: MinioConfig{
Endpoint: getEnvOrDefault("MINIO_ENDPOINT", "minio:9000"),
PublicURL: getEnvOrDefault("MINIO_PUBLIC_URL", "http://localhost:9000"),
RootUser: getEnvOrDefault("MINIO_ROOT_USER", "minioadmin"),
RootPassword: getEnvOrDefault("MINIO_ROOT_PASSWORD", "changeme"),
UseSSL: getEnvOrDefault("MINIO_USE_SSL", "false") == "true",
BucketName: getEnvOrDefault("MINIO_BUCKET_NAME", "aggios"),
},
}
}
// getEnvOrDefault returns environment variable or default value
func getEnvOrDefault(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}

View File

@@ -9,6 +9,17 @@ CREATE TABLE IF NOT EXISTS tenants (
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
domain VARCHAR(255) UNIQUE NOT NULL, domain VARCHAR(255) UNIQUE NOT NULL,
subdomain VARCHAR(63) UNIQUE NOT NULL, subdomain VARCHAR(63) UNIQUE NOT NULL,
cnpj VARCHAR(18),
razao_social VARCHAR(255),
email VARCHAR(255),
phone VARCHAR(20),
website VARCHAR(255),
address TEXT,
city VARCHAR(100),
state VARCHAR(2),
zip VARCHAR(10),
description TEXT,
industry VARCHAR(100),
is_active BOOLEAN DEFAULT true, is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
@@ -17,15 +28,15 @@ CREATE TABLE IF NOT EXISTS tenants (
-- Users table -- Users table
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
email VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL,
first_name VARCHAR(128), first_name VARCHAR(128),
last_name VARCHAR(128), last_name VARCHAR(128),
role VARCHAR(50) DEFAULT 'CLIENTE' CHECK (role IN ('SUPERADMIN', 'ADMIN_AGENCIA', 'CLIENTE')),
is_active BOOLEAN DEFAULT true, is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
UNIQUE(tenant_id, email)
); );
-- Refresh tokens table -- Refresh tokens table
@@ -45,6 +56,31 @@ CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires_at ON refresh_tokens(expir
CREATE INDEX IF NOT EXISTS idx_tenants_subdomain ON tenants(subdomain); CREATE INDEX IF NOT EXISTS idx_tenants_subdomain ON tenants(subdomain);
CREATE INDEX IF NOT EXISTS idx_tenants_domain ON tenants(domain); CREATE INDEX IF NOT EXISTS idx_tenants_domain ON tenants(domain);
-- Companies table
CREATE TABLE IF NOT EXISTS companies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
cnpj VARCHAR(18) NOT NULL,
razao_social VARCHAR(255) NOT NULL,
nome_fantasia VARCHAR(255),
email VARCHAR(255),
telefone VARCHAR(20),
status VARCHAR(50) DEFAULT 'active',
created_by_user_id UUID REFERENCES users(id),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE(tenant_id, cnpj)
);
-- Create indexes
CREATE INDEX IF NOT EXISTS idx_companies_tenant_id ON companies(tenant_id);
CREATE INDEX IF NOT EXISTS idx_companies_cnpj ON companies(cnpj);
-- Insert SUPERADMIN user (você - admin master da AGGIOS)
INSERT INTO users (email, password_hash, first_name, role, is_active)
VALUES ('admin@aggios.app', '$2a$10$YourHashedPasswordHere', 'Admin Master', 'SUPERADMIN', true)
ON CONFLICT (email) DO NOTHING;
-- Insert sample tenant for testing -- Insert sample tenant for testing
INSERT INTO tenants (name, domain, subdomain, is_active) INSERT INTO tenants (name, domain, subdomain, is_active)
VALUES ('Agência Teste', 'agencia-teste.aggios.app', 'agencia-teste', true) VALUES ('Agência Teste', 'agencia-teste.aggios.app', 'agencia-teste', true)

View File

@@ -0,0 +1,18 @@
-- Migration: Add agency user roles and collaborator tracking
-- Purpose: Support owner/collaborator hierarchy for agency users
-- 1. Add agency_role column to users table (owner or collaborator)
ALTER TABLE users ADD COLUMN IF NOT EXISTS agency_role VARCHAR(50) DEFAULT 'owner' CHECK (agency_role IN ('owner', 'collaborator'));
-- 2. Add created_by column to track which user created this collaborator
ALTER TABLE users ADD COLUMN IF NOT EXISTS created_by UUID REFERENCES users(id) ON DELETE SET NULL;
-- 3. Update existing ADMIN_AGENCIA users to have 'owner' agency_role
UPDATE users SET agency_role = 'owner' WHERE role = 'ADMIN_AGENCIA' AND agency_role IS NULL;
-- 4. Add collaborator_created_at to track when the collaborator was added
ALTER TABLE users ADD COLUMN IF NOT EXISTS collaborator_created_at TIMESTAMP WITH TIME ZONE;
-- 5. Create index for faster queries
CREATE INDEX IF NOT EXISTS idx_users_agency_role ON users(tenant_id, agency_role);
CREATE INDEX IF NOT EXISTS idx_users_created_by ON users(created_by);

View File

@@ -0,0 +1,66 @@
package domain
import (
"database/sql"
"time"
"github.com/google/uuid"
)
// AgencySignupTemplate represents a signup template for agencies (SuperAdmin → Agency)
type AgencySignupTemplate struct {
ID uuid.UUID `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Slug string `json:"slug" db:"slug"`
Description string `json:"description" db:"description"`
FormFields []byte `json:"form_fields" db:"form_fields"` // JSONB
AvailableModules []byte `json:"available_modules" db:"available_modules"` // JSONB
CustomPrimaryColor sql.NullString `json:"custom_primary_color" db:"custom_primary_color"`
CustomLogoURL sql.NullString `json:"custom_logo_url" db:"custom_logo_url"`
RedirectURL sql.NullString `json:"redirect_url" db:"redirect_url"`
SuccessMessage sql.NullString `json:"success_message" db:"success_message"`
IsActive bool `json:"is_active" db:"is_active"`
UsageCount int `json:"usage_count" db:"usage_count"`
MaxUses sql.NullInt64 `json:"max_uses" db:"max_uses"`
ExpiresAt sql.NullTime `json:"expires_at" db:"expires_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// CreateAgencyTemplateRequest for creating a new agency template
type CreateAgencyTemplateRequest struct {
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
FormFields []string `json:"form_fields"`
AvailableModules []string `json:"available_modules"`
CustomPrimaryColor string `json:"custom_primary_color"`
CustomLogoURL string `json:"custom_logo_url"`
RedirectURL string `json:"redirect_url"`
SuccessMessage string `json:"success_message"`
MaxUses int `json:"max_uses"`
}
// AgencyRegistrationViaTemplate for public registration via template
type AgencyRegistrationViaTemplate struct {
TemplateSlug string `json:"template_slug"`
// Agency info
AgencyName string `json:"agencyName"`
Subdomain string `json:"subdomain"`
CNPJ string `json:"cnpj"`
RazaoSocial string `json:"razaoSocial"`
Website string `json:"website"`
Phone string `json:"phone"`
// Admin
AdminEmail string `json:"adminEmail"`
AdminPassword string `json:"adminPassword"`
AdminName string `json:"adminName"`
// Optional fields
Description string `json:"description"`
Industry string `json:"industry"`
TeamSize string `json:"teamSize"`
Address map[string]string `json:"address"`
}

View File

@@ -0,0 +1,42 @@
package domain
import "github.com/golang-jwt/jwt/v5"
// UserType representa os diferentes tipos de usuários do sistema
type UserType string
const (
UserTypeAgency UserType = "agency_user" // Usuários das agências (admin, colaborador)
UserTypeCustomer UserType = "customer" // Clientes do CRM
// SUPERADMIN usa endpoint próprio /api/admin/*, não usa autenticação unificada
)
// UnifiedClaims representa as claims do JWT unificado
type UnifiedClaims struct {
UserID string `json:"user_id"` // ID do usuário (user.id ou customer.id)
UserType UserType `json:"user_type"` // Tipo de usuário
TenantID string `json:"tenant_id,omitempty"` // ID do tenant (agência)
Email string `json:"email"` // Email do usuário
Role string `json:"role,omitempty"` // Role (para agency_user: ADMIN_AGENCIA, CLIENTE)
AgencyRole string `json:"agency_role,omitempty"` // Agency role (owner ou collaborator)
jwt.RegisteredClaims
}
// UnifiedLoginRequest representa uma requisição de login unificada
type UnifiedLoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
// UnifiedLoginResponse representa a resposta de login unificada
type UnifiedLoginResponse struct {
Token string `json:"token"`
UserType UserType `json:"user_type"`
UserID string `json:"user_id"`
Email string `json:"email"`
Name string `json:"name"`
Role string `json:"role,omitempty"` // Apenas para agency_user
AgencyRole string `json:"agency_role,omitempty"` // owner ou collaborator
TenantID string `json:"tenant_id,omitempty"` // ID do tenant
Subdomain string `json:"subdomain,omitempty"` // Subdomínio da agência
}

View File

@@ -0,0 +1,31 @@
package domain
import (
"time"
"github.com/google/uuid"
)
// Company represents a company in the system
type Company struct {
ID uuid.UUID `json:"id" db:"id"`
CNPJ string `json:"cnpj" db:"cnpj"`
RazaoSocial string `json:"razao_social" db:"razao_social"`
NomeFantasia string `json:"nome_fantasia" db:"nome_fantasia"`
Email string `json:"email" db:"email"`
Telefone string `json:"telefone" db:"telefone"`
Status string `json:"status" db:"status"`
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"`
CreatedByUserID *uuid.UUID `json:"created_by_user_id,omitempty" db:"created_by_user_id"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// CreateCompanyRequest represents the request to create a new company
type CreateCompanyRequest struct {
CNPJ string `json:"cnpj"`
RazaoSocial string `json:"razao_social"`
NomeFantasia string `json:"nome_fantasia"`
Email string `json:"email"`
Telefone string `json:"telefone"`
}

View File

@@ -0,0 +1,135 @@
package domain
import (
"encoding/json"
"time"
)
type CRMCustomer struct {
ID string `json:"id" db:"id"`
TenantID string `json:"tenant_id" db:"tenant_id"`
Name string `json:"name" db:"name"`
Email string `json:"email" db:"email"`
Phone string `json:"phone" db:"phone"`
Company string `json:"company" db:"company"`
Position string `json:"position" db:"position"`
Address string `json:"address" db:"address"`
City string `json:"city" db:"city"`
State string `json:"state" db:"state"`
ZipCode string `json:"zip_code" db:"zip_code"`
Country string `json:"country" db:"country"`
Notes string `json:"notes" db:"notes"`
Tags []string `json:"tags" db:"tags"`
LogoURL string `json:"logo_url" db:"logo_url"`
IsActive bool `json:"is_active" db:"is_active"`
CreatedBy string `json:"created_by" db:"created_by"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
PasswordHash string `json:"-" db:"password_hash"`
HasPortalAccess bool `json:"has_portal_access" db:"has_portal_access"`
PortalLastLogin *time.Time `json:"portal_last_login,omitempty" db:"portal_last_login"`
PortalCreatedAt *time.Time `json:"portal_created_at,omitempty" db:"portal_created_at"`
}
type CRMList struct {
ID string `json:"id" db:"id"`
TenantID string `json:"tenant_id" db:"tenant_id"`
CustomerID *string `json:"customer_id" db:"customer_id"`
FunnelID *string `json:"funnel_id" db:"funnel_id"`
Name string `json:"name" db:"name"`
Description string `json:"description" db:"description"`
Color string `json:"color" db:"color"`
CreatedBy string `json:"created_by" db:"created_by"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type CRMCustomerList struct {
CustomerID string `json:"customer_id" db:"customer_id"`
ListID string `json:"list_id" db:"list_id"`
AddedAt time.Time `json:"added_at" db:"added_at"`
AddedBy string `json:"added_by" db:"added_by"`
}
// DTO com informações extras
type CRMCustomerWithLists struct {
CRMCustomer
Lists []CRMList `json:"lists"`
}
type CRMListWithCustomers struct {
CRMList
CustomerName string `json:"customer_name"`
CustomerCount int `json:"customer_count"`
LeadCount int `json:"lead_count"`
}
// ==================== LEADS ====================
type CRMLead struct {
ID string `json:"id" db:"id"`
TenantID string `json:"tenant_id" db:"tenant_id"`
CustomerID *string `json:"customer_id" db:"customer_id"`
FunnelID *string `json:"funnel_id" db:"funnel_id"`
StageID *string `json:"stage_id" db:"stage_id"`
Name string `json:"name" db:"name"`
Email string `json:"email" db:"email"`
Phone string `json:"phone" db:"phone"`
Source string `json:"source" db:"source"`
SourceMeta json.RawMessage `json:"source_meta" db:"source_meta"`
Status string `json:"status" db:"status"`
Notes string `json:"notes" db:"notes"`
Tags []string `json:"tags" db:"tags"`
IsActive bool `json:"is_active" db:"is_active"`
CreatedBy string `json:"created_by" db:"created_by"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type CRMFunnel struct {
ID string `json:"id" db:"id"`
TenantID string `json:"tenant_id" db:"tenant_id"`
Name string `json:"name" db:"name"`
Description string `json:"description" db:"description"`
IsDefault bool `json:"is_default" db:"is_default"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type CRMFunnelStage struct {
ID string `json:"id" db:"id"`
FunnelID string `json:"funnel_id" db:"funnel_id"`
Name string `json:"name" db:"name"`
Description string `json:"description" db:"description"`
Color string `json:"color" db:"color"`
OrderIndex int `json:"order_index" db:"order_index"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type CRMFunnelWithStages struct {
CRMFunnel
Stages []CRMFunnelStage `json:"stages"`
}
type CRMLeadList struct {
LeadID string `json:"lead_id" db:"lead_id"`
ListID string `json:"list_id" db:"list_id"`
AddedAt time.Time `json:"added_at" db:"added_at"`
AddedBy string `json:"added_by" db:"added_by"`
}
type CRMLeadWithLists struct {
CRMLead
Lists []CRMList `json:"lists"`
}
type CRMShareToken struct {
ID string `json:"id" db:"id"`
TenantID string `json:"tenant_id" db:"tenant_id"`
CustomerID string `json:"customer_id" db:"customer_id"`
Token string `json:"token" db:"token"`
ExpiresAt *time.Time `json:"expires_at" db:"expires_at"`
CreatedBy string `json:"created_by" db:"created_by"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}

View File

@@ -0,0 +1,78 @@
package domain
import (
"time"
"github.com/google/uuid"
"github.com/lib/pq"
"github.com/shopspring/decimal"
)
// Plan represents a subscription plan in the system
type Plan struct {
ID uuid.UUID `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Slug string `json:"slug" db:"slug"`
Description string `json:"description" db:"description"`
MinUsers int `json:"min_users" db:"min_users"`
MaxUsers int `json:"max_users" db:"max_users"` // -1 means unlimited
MonthlyPrice *decimal.Decimal `json:"monthly_price" db:"monthly_price"`
AnnualPrice *decimal.Decimal `json:"annual_price" db:"annual_price"`
Features pq.StringArray `json:"features" db:"features"`
Differentiators pq.StringArray `json:"differentiators" db:"differentiators"`
StorageGB int `json:"storage_gb" db:"storage_gb"`
IsActive bool `json:"is_active" db:"is_active"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// CreatePlanRequest represents the request to create a new plan
type CreatePlanRequest struct {
Name string `json:"name" validate:"required"`
Slug string `json:"slug" validate:"required"`
Description string `json:"description"`
MinUsers int `json:"min_users" validate:"required,min=1"`
MaxUsers int `json:"max_users" validate:"required"` // -1 for unlimited
MonthlyPrice *float64 `json:"monthly_price"`
AnnualPrice *float64 `json:"annual_price"`
Features []string `json:"features"`
Differentiators []string `json:"differentiators"`
StorageGB int `json:"storage_gb" validate:"required,min=1"`
IsActive bool `json:"is_active"`
}
// UpdatePlanRequest represents the request to update a plan
type UpdatePlanRequest struct {
Name *string `json:"name"`
Slug *string `json:"slug"`
Description *string `json:"description"`
MinUsers *int `json:"min_users"`
MaxUsers *int `json:"max_users"`
MonthlyPrice *float64 `json:"monthly_price"`
AnnualPrice *float64 `json:"annual_price"`
Features []string `json:"features"`
Differentiators []string `json:"differentiators"`
StorageGB *int `json:"storage_gb"`
IsActive *bool `json:"is_active"`
}
// Subscription represents an agency's subscription to a plan
type Subscription struct {
ID uuid.UUID `json:"id" db:"id"`
AgencyID uuid.UUID `json:"agency_id" db:"agency_id"`
PlanID uuid.UUID `json:"plan_id" db:"plan_id"`
BillingType string `json:"billing_type" db:"billing_type"` // monthly or annual
CurrentUsers int `json:"current_users" db:"current_users"`
Status string `json:"status" db:"status"` // active, suspended, cancelled
StartDate time.Time `json:"start_date" db:"start_date"`
RenewalDate time.Time `json:"renewal_date" db:"renewal_date"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// CreateSubscriptionRequest represents the request to create a subscription
type CreateSubscriptionRequest struct {
AgencyID uuid.UUID `json:"agency_id" validate:"required"`
PlanID uuid.UUID `json:"plan_id" validate:"required"`
BillingType string `json:"billing_type" validate:"required,oneof=monthly annual"`
}

View File

@@ -0,0 +1,35 @@
package domain
import (
"time"
"github.com/google/uuid"
)
// FormField representa um campo do formulário de cadastro
type FormField struct {
Name string `json:"name"`
Label string `json:"label"`
Type string `json:"type"` // email, password, text, tel, etc
Required bool `json:"required"`
Order int `json:"order"`
}
// SignupTemplate representa um template de cadastro personalizado
type SignupTemplate struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Slug string `json:"slug"`
FormFields []FormField `json:"form_fields"`
EnabledModules []string `json:"enabled_modules"` // ["CRM", "ERP", "PROJECTS"]
RedirectURL string `json:"redirect_url,omitempty"`
SuccessMessage string `json:"success_message,omitempty"`
CustomLogoURL string `json:"custom_logo_url,omitempty"`
CustomPrimaryColor string `json:"custom_primary_color,omitempty"`
IsActive bool `json:"is_active"`
UsageCount int `json:"usage_count"`
CreatedBy uuid.UUID `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@@ -0,0 +1,20 @@
package domain
import "time"
type Solution struct {
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Slug string `json:"slug" db:"slug"`
Icon string `json:"icon" db:"icon"`
Description string `json:"description" db:"description"`
IsActive bool `json:"is_active" db:"is_active"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type PlanSolution struct {
PlanID string `json:"plan_id" db:"plan_id"`
SolutionID string `json:"solution_id" db:"solution_id"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}

View File

@@ -0,0 +1,59 @@
package domain
import (
"time"
"github.com/google/uuid"
)
// Tenant represents a tenant (agency) in the system
type Tenant struct {
ID uuid.UUID `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Domain string `json:"domain" db:"domain"`
Subdomain string `json:"subdomain" db:"subdomain"`
CNPJ string `json:"cnpj,omitempty" db:"cnpj"`
RazaoSocial string `json:"razao_social,omitempty" db:"razao_social"`
Email string `json:"email,omitempty" db:"email"`
Phone string `json:"phone,omitempty" db:"phone"`
Website string `json:"website,omitempty" db:"website"`
Address string `json:"address,omitempty" db:"address"`
Neighborhood string `json:"neighborhood,omitempty" db:"neighborhood"`
Number string `json:"number,omitempty" db:"number"`
Complement string `json:"complement,omitempty" db:"complement"`
City string `json:"city,omitempty" db:"city"`
State string `json:"state,omitempty" db:"state"`
Zip string `json:"zip,omitempty" db:"zip"`
Description string `json:"description,omitempty" db:"description"`
Industry string `json:"industry,omitempty" db:"industry"`
TeamSize string `json:"team_size,omitempty" db:"team_size"`
PrimaryColor string `json:"primary_color,omitempty" db:"primary_color"`
SecondaryColor string `json:"secondary_color,omitempty" db:"secondary_color"`
LogoURL string `json:"logo_url,omitempty" db:"logo_url"`
LogoHorizontalURL string `json:"logo_horizontal_url,omitempty" db:"logo_horizontal_url"`
IsActive bool `json:"is_active" db:"is_active"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// CreateTenantRequest represents the request to create a new tenant
type CreateTenantRequest struct {
Name string `json:"name"`
Domain string `json:"domain"`
Subdomain string `json:"subdomain"`
}
// AgencyDetails aggregates tenant info with its admin user for superadmin view
type AgencyDetails struct {
Tenant *Tenant `json:"tenant"`
Admin *User `json:"admin,omitempty"`
Subscription *AgencySubscriptionInfo `json:"subscription,omitempty"`
AccessURL string `json:"access_url"`
}
type AgencySubscriptionInfo struct {
PlanID string `json:"plan_id"`
PlanName string `json:"plan_name"`
Status string `json:"status"`
Solutions []Solution `json:"solutions"`
}

View File

@@ -0,0 +1,125 @@
package domain
import (
"time"
"github.com/google/uuid"
)
// User represents a user in the system
type User struct {
ID uuid.UUID `json:"id" db:"id"`
TenantID *uuid.UUID `json:"tenant_id,omitempty" db:"tenant_id"`
Email string `json:"email" db:"email"`
Password string `json:"-" db:"password_hash"`
Name string `json:"name" db:"first_name"`
Role string `json:"role" db:"role"` // SUPERADMIN, ADMIN_AGENCIA, CLIENTE
AgencyRole string `json:"agency_role" db:"agency_role"` // owner or collaborator (only for ADMIN_AGENCIA)
CreatedBy *uuid.UUID `json:"created_by,omitempty" db:"created_by"` // Which owner created this collaborator
CollaboratorCreatedAt *time.Time `json:"collaborator_created_at,omitempty" db:"collaborator_created_at"` // When collaborator was added
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// CreateUserRequest represents the request to create a new user
type CreateUserRequest struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
Role string `json:"role,omitempty"` // Optional, defaults to CLIENTE
}
// RegisterAgencyRequest represents agency registration (SUPERADMIN only)
type RegisterAgencyRequest struct {
// Agência
AgencyName string `json:"agencyName"`
Subdomain string `json:"subdomain"`
CNPJ string `json:"cnpj"`
RazaoSocial string `json:"razaoSocial"`
Description string `json:"description"`
Website string `json:"website"`
Industry string `json:"industry"`
Phone string `json:"phone"`
TeamSize string `json:"teamSize"`
// Endereço
CEP string `json:"cep"`
State string `json:"state"`
City string `json:"city"`
Neighborhood string `json:"neighborhood"`
Street string `json:"street"`
Number string `json:"number"`
Complement string `json:"complement"`
// Personalização
PrimaryColor string `json:"primaryColor"`
SecondaryColor string `json:"secondaryColor"`
LogoURL string `json:"logoUrl"`
LogoHorizontalURL string `json:"logoHorizontalUrl"`
// Admin da Agência
AdminEmail string `json:"adminEmail"`
AdminPassword string `json:"adminPassword"`
AdminName string `json:"adminName"`
}
// PublicRegisterAgencyRequest represents the public signup payload
type PublicRegisterAgencyRequest struct {
// User
Email string `json:"email"`
Password string `json:"password"`
FullName string `json:"fullName"`
Newsletter bool `json:"newsletter"`
// Company
CompanyName string `json:"companyName"`
CNPJ string `json:"cnpj"`
RazaoSocial string `json:"razaoSocial"`
Description string `json:"description"`
Website string `json:"website"`
Industry string `json:"industry"`
TeamSize string `json:"teamSize"`
// Address
CEP string `json:"cep"`
State string `json:"state"`
City string `json:"city"`
Neighborhood string `json:"neighborhood"`
Street string `json:"street"`
Number string `json:"number"`
Complement string `json:"complement"`
// Contacts (simplified for now, taking the first one as phone if available)
Contacts []struct {
ID int `json:"id"`
Whatsapp string `json:"whatsapp"`
} `json:"contacts"`
// Domain
Subdomain string `json:"subdomain"`
// Branding
PrimaryColor string `json:"primaryColor"`
SecondaryColor string `json:"secondaryColor"`
LogoURL string `json:"logoUrl"`
}
// RegisterClientRequest represents client registration (ADMIN_AGENCIA only)
type RegisterClientRequest struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
}
// LoginRequest represents the login request
type LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
// LoginResponse represents the login response
type LoginResponse struct {
Token string `json:"token"`
User User `json:"user"`
Subdomain *string `json:"subdomain,omitempty"`
}

View File

@@ -0,0 +1,168 @@
package repository
import (
"aggios-app/backend/internal/domain"
"database/sql"
"encoding/json"
"fmt"
)
type AgencyTemplateRepository struct {
db *sql.DB
}
func NewAgencyTemplateRepository(db *sql.DB) *AgencyTemplateRepository {
return &AgencyTemplateRepository{db: db}
}
func (r *AgencyTemplateRepository) Create(template *domain.AgencySignupTemplate) error {
query := `
INSERT INTO agency_signup_templates (
name, slug, description, form_fields, available_modules,
custom_primary_color, custom_logo_url, redirect_url, success_message, max_uses
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, created_at, updated_at
`
return r.db.QueryRow(
query,
template.Name,
template.Slug,
template.Description,
template.FormFields,
template.AvailableModules,
template.CustomPrimaryColor,
template.CustomLogoURL,
template.RedirectURL,
template.SuccessMessage,
template.MaxUses,
).Scan(&template.ID, &template.CreatedAt, &template.UpdatedAt)
}
func (r *AgencyTemplateRepository) FindBySlug(slug string) (*domain.AgencySignupTemplate, error) {
var template domain.AgencySignupTemplate
query := `
SELECT id, name, slug, description, form_fields, available_modules,
custom_primary_color, custom_logo_url, redirect_url, success_message,
is_active, usage_count, max_uses, expires_at, created_at, updated_at
FROM agency_signup_templates
WHERE slug = $1 AND is_active = true
`
err := r.db.QueryRow(query, slug).Scan(
&template.ID, &template.Name, &template.Slug, &template.Description,
&template.FormFields, &template.AvailableModules,
&template.CustomPrimaryColor, &template.CustomLogoURL,
&template.RedirectURL, &template.SuccessMessage,
&template.IsActive, &template.UsageCount, &template.MaxUses,
&template.ExpiresAt, &template.CreatedAt, &template.UpdatedAt,
)
if err != nil {
return nil, err
}
// Validar se expirou
if template.ExpiresAt.Valid && template.ExpiresAt.Time.Before(sql.NullTime{}.Time) {
return nil, fmt.Errorf("template expired")
}
// Validar limite de usos
if template.MaxUses.Valid && template.UsageCount >= int(template.MaxUses.Int64) {
return nil, fmt.Errorf("template usage limit reached")
}
return &template, nil
}
func (r *AgencyTemplateRepository) List() ([]domain.AgencySignupTemplate, error) {
var templates []domain.AgencySignupTemplate
query := `
SELECT id, name, slug, description, form_fields, available_modules,
custom_primary_color, custom_logo_url, redirect_url, success_message,
is_active, usage_count, max_uses, expires_at, created_at, updated_at
FROM agency_signup_templates
ORDER BY created_at DESC
`
rows, err := r.db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var t domain.AgencySignupTemplate
if err := rows.Scan(
&t.ID, &t.Name, &t.Slug, &t.Description,
&t.FormFields, &t.AvailableModules,
&t.CustomPrimaryColor, &t.CustomLogoURL,
&t.RedirectURL, &t.SuccessMessage,
&t.IsActive, &t.UsageCount, &t.MaxUses,
&t.ExpiresAt, &t.CreatedAt, &t.UpdatedAt,
); err != nil {
return nil, err
}
templates = append(templates, t)
}
return templates, rows.Err()
}
func (r *AgencyTemplateRepository) IncrementUsageCount(id string) error {
query := `UPDATE agency_signup_templates SET usage_count = usage_count + 1 WHERE id = $1`
_, err := r.db.Exec(query, id)
return err
}
func (r *AgencyTemplateRepository) Update(template *domain.AgencySignupTemplate) error {
query := `
UPDATE agency_signup_templates
SET name = $1, description = $2, form_fields = $3, available_modules = $4,
custom_primary_color = $5, custom_logo_url = $6, redirect_url = $7,
success_message = $8, is_active = $9, max_uses = $10, updated_at = CURRENT_TIMESTAMP
WHERE id = $11
`
_, err := r.db.Exec(
query,
template.Name,
template.Description,
template.FormFields,
template.AvailableModules,
template.CustomPrimaryColor,
template.CustomLogoURL,
template.RedirectURL,
template.SuccessMessage,
template.IsActive,
template.MaxUses,
template.ID,
)
return err
}
func (r *AgencyTemplateRepository) Delete(id string) error {
query := `DELETE FROM agency_signup_templates WHERE id = $1`
_, err := r.db.Exec(query, id)
return err
}
// Helper: Convert form fields to JSON
func FormFieldsToJSON(fields []string) ([]byte, error) {
type FormField struct {
Name string `json:"name"`
Required bool `json:"required"`
Enabled bool `json:"enabled"`
}
var formFields []FormField
for _, field := range fields {
formFields = append(formFields, FormField{
Name: field,
Required: field == "agencyName" || field == "subdomain" || field == "adminEmail" || field == "adminPassword",
Enabled: true,
})
}
return json.Marshal(formFields)
}

View File

@@ -0,0 +1,127 @@
package repository
import (
"database/sql"
"time"
"aggios-app/backend/internal/domain"
"github.com/google/uuid"
)
// CompanyRepository handles database operations for companies
type CompanyRepository struct {
db *sql.DB
}
// NewCompanyRepository creates a new company repository
func NewCompanyRepository(db *sql.DB) *CompanyRepository {
return &CompanyRepository{db: db}
}
// Create creates a new company
func (r *CompanyRepository) Create(company *domain.Company) error {
query := `
INSERT INTO companies (id, cnpj, razao_social, nome_fantasia, email, telefone, status, tenant_id, created_by_user_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id, created_at, updated_at
`
now := time.Now()
company.ID = uuid.New()
company.CreatedAt = now
company.UpdatedAt = now
return r.db.QueryRow(
query,
company.ID,
company.CNPJ,
company.RazaoSocial,
company.NomeFantasia,
company.Email,
company.Telefone,
company.Status,
company.TenantID,
company.CreatedByUserID,
company.CreatedAt,
company.UpdatedAt,
).Scan(&company.ID, &company.CreatedAt, &company.UpdatedAt)
}
// FindByID finds a company by ID
func (r *CompanyRepository) FindByID(id uuid.UUID) (*domain.Company, error) {
query := `
SELECT id, cnpj, razao_social, nome_fantasia, email, telefone, status, tenant_id, created_by_user_id, created_at, updated_at
FROM companies
WHERE id = $1
`
company := &domain.Company{}
err := r.db.QueryRow(query, id).Scan(
&company.ID,
&company.CNPJ,
&company.RazaoSocial,
&company.NomeFantasia,
&company.Email,
&company.Telefone,
&company.Status,
&company.TenantID,
&company.CreatedByUserID,
&company.CreatedAt,
&company.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
return company, err
}
// FindByTenantID finds all companies for a tenant
func (r *CompanyRepository) FindByTenantID(tenantID uuid.UUID) ([]*domain.Company, error) {
query := `
SELECT id, cnpj, razao_social, nome_fantasia, email, telefone, status, tenant_id, created_by_user_id, created_at, updated_at
FROM companies
WHERE tenant_id = $1
ORDER BY created_at DESC
`
rows, err := r.db.Query(query, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var companies []*domain.Company
for rows.Next() {
company := &domain.Company{}
err := rows.Scan(
&company.ID,
&company.CNPJ,
&company.RazaoSocial,
&company.NomeFantasia,
&company.Email,
&company.Telefone,
&company.Status,
&company.TenantID,
&company.CreatedByUserID,
&company.CreatedAt,
&company.UpdatedAt,
)
if err != nil {
return nil, err
}
companies = append(companies, company)
}
return companies, nil
}
// CNPJExists checks if a CNPJ is already registered for a tenant
func (r *CompanyRepository) CNPJExists(cnpj string, tenantID uuid.UUID) (bool, error) {
var exists bool
query := `SELECT EXISTS(SELECT 1 FROM companies WHERE cnpj = $1 AND tenant_id = $2)`
err := r.db.QueryRow(query, cnpj, tenantID).Scan(&exists)
return exists, err
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,283 @@
package repository
import (
"database/sql"
"time"
"aggios-app/backend/internal/domain"
"github.com/google/uuid"
"github.com/lib/pq"
)
// PlanRepository handles database operations for plans
type PlanRepository struct {
db *sql.DB
}
// NewPlanRepository creates a new plan repository
func NewPlanRepository(db *sql.DB) *PlanRepository {
return &PlanRepository{db: db}
}
// Create creates a new plan
func (r *PlanRepository) Create(plan *domain.Plan) error {
query := `
INSERT INTO plans (id, name, slug, description, min_users, max_users, monthly_price, annual_price, features, differentiators, storage_gb, is_active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING id, created_at, updated_at
`
now := time.Now()
plan.ID = uuid.New()
plan.CreatedAt = now
plan.UpdatedAt = now
features := pq.Array(plan.Features)
differentiators := pq.Array(plan.Differentiators)
return r.db.QueryRow(
query,
plan.ID,
plan.Name,
plan.Slug,
plan.Description,
plan.MinUsers,
plan.MaxUsers,
plan.MonthlyPrice,
plan.AnnualPrice,
features,
differentiators,
plan.StorageGB,
plan.IsActive,
plan.CreatedAt,
plan.UpdatedAt,
).Scan(&plan.ID, &plan.CreatedAt, &plan.UpdatedAt)
}
// GetByID retrieves a plan by ID
func (r *PlanRepository) GetByID(id uuid.UUID) (*domain.Plan, error) {
query := `
SELECT id, name, slug, description, min_users, max_users, monthly_price, annual_price, features, differentiators, storage_gb, is_active, created_at, updated_at
FROM plans
WHERE id = $1
`
plan := &domain.Plan{}
var features, differentiators pq.StringArray
err := r.db.QueryRow(query, id).Scan(
&plan.ID,
&plan.Name,
&plan.Slug,
&plan.Description,
&plan.MinUsers,
&plan.MaxUsers,
&plan.MonthlyPrice,
&plan.AnnualPrice,
&features,
&differentiators,
&plan.StorageGB,
&plan.IsActive,
&plan.CreatedAt,
&plan.UpdatedAt,
)
if err != nil {
return nil, err
}
plan.Features = []string(features)
plan.Differentiators = []string(differentiators)
return plan, nil
}
// GetBySlug retrieves a plan by slug
func (r *PlanRepository) GetBySlug(slug string) (*domain.Plan, error) {
query := `
SELECT id, name, slug, description, min_users, max_users, monthly_price, annual_price, features, differentiators, storage_gb, is_active, created_at, updated_at
FROM plans
WHERE slug = $1
`
plan := &domain.Plan{}
var features, differentiators pq.StringArray
err := r.db.QueryRow(query, slug).Scan(
&plan.ID,
&plan.Name,
&plan.Slug,
&plan.Description,
&plan.MinUsers,
&plan.MaxUsers,
&plan.MonthlyPrice,
&plan.AnnualPrice,
&features,
&differentiators,
&plan.StorageGB,
&plan.IsActive,
&plan.CreatedAt,
&plan.UpdatedAt,
)
if err != nil {
return nil, err
}
plan.Features = []string(features)
plan.Differentiators = []string(differentiators)
return plan, nil
}
// ListAll retrieves all plans
func (r *PlanRepository) ListAll() ([]*domain.Plan, error) {
query := `
SELECT id, name, slug, description, min_users, max_users, monthly_price, annual_price, features, differentiators, storage_gb, is_active, created_at, updated_at
FROM plans
ORDER BY min_users ASC
`
rows, err := r.db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var plans []*domain.Plan
for rows.Next() {
plan := &domain.Plan{}
var features, differentiators pq.StringArray
err := rows.Scan(
&plan.ID,
&plan.Name,
&plan.Slug,
&plan.Description,
&plan.MinUsers,
&plan.MaxUsers,
&plan.MonthlyPrice,
&plan.AnnualPrice,
&features,
&differentiators,
&plan.StorageGB,
&plan.IsActive,
&plan.CreatedAt,
&plan.UpdatedAt,
)
if err != nil {
return nil, err
}
plan.Features = []string(features)
plan.Differentiators = []string(differentiators)
plans = append(plans, plan)
}
return plans, rows.Err()
}
// ListActive retrieves all active plans
func (r *PlanRepository) ListActive() ([]*domain.Plan, error) {
query := `
SELECT id, name, slug, description, min_users, max_users, monthly_price, annual_price, features, differentiators, storage_gb, is_active, created_at, updated_at
FROM plans
WHERE is_active = true
ORDER BY min_users ASC
`
rows, err := r.db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var plans []*domain.Plan
for rows.Next() {
plan := &domain.Plan{}
var features, differentiators pq.StringArray
err := rows.Scan(
&plan.ID,
&plan.Name,
&plan.Slug,
&plan.Description,
&plan.MinUsers,
&plan.MaxUsers,
&plan.MonthlyPrice,
&plan.AnnualPrice,
&features,
&differentiators,
&plan.StorageGB,
&plan.IsActive,
&plan.CreatedAt,
&plan.UpdatedAt,
)
if err != nil {
return nil, err
}
plan.Features = []string(features)
plan.Differentiators = []string(differentiators)
plans = append(plans, plan)
}
return plans, rows.Err()
}
// Update updates a plan
func (r *PlanRepository) Update(plan *domain.Plan) error {
query := `
UPDATE plans
SET name = $2, slug = $3, description = $4, min_users = $5, max_users = $6, monthly_price = $7, annual_price = $8, features = $9, differentiators = $10, storage_gb = $11, is_active = $12, updated_at = $13
WHERE id = $1
RETURNING updated_at
`
plan.UpdatedAt = time.Now()
features := pq.Array(plan.Features)
differentiators := pq.Array(plan.Differentiators)
return r.db.QueryRow(
query,
plan.ID,
plan.Name,
plan.Slug,
plan.Description,
plan.MinUsers,
plan.MaxUsers,
plan.MonthlyPrice,
plan.AnnualPrice,
features,
differentiators,
plan.StorageGB,
plan.IsActive,
plan.UpdatedAt,
).Scan(&plan.UpdatedAt)
}
// Delete deletes a plan
func (r *PlanRepository) Delete(id uuid.UUID) error {
query := `DELETE FROM plans WHERE id = $1`
result, err := r.db.Exec(query, id)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return sql.ErrNoRows
}
return nil
}

View File

@@ -0,0 +1,280 @@
package repository
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"aggios-app/backend/internal/domain"
"github.com/google/uuid"
)
type SignupTemplateRepository struct {
db *sql.DB
}
func NewSignupTemplateRepository(db *sql.DB) *SignupTemplateRepository {
return &SignupTemplateRepository{db: db}
}
// Create cria um novo template de cadastro
func (r *SignupTemplateRepository) Create(ctx context.Context, template *domain.SignupTemplate) error {
formFieldsJSON, err := json.Marshal(template.FormFields)
if err != nil {
return fmt.Errorf("error marshaling form_fields: %w", err)
}
modulesJSON, err := json.Marshal(template.EnabledModules)
if err != nil {
return fmt.Errorf("error marshaling enabled_modules: %w", err)
}
query := `
INSERT INTO signup_templates (
name, description, slug, form_fields, enabled_modules,
redirect_url, success_message, custom_logo_url, custom_primary_color,
is_active, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id, created_at, updated_at
`
err = r.db.QueryRowContext(
ctx, query,
template.Name, template.Description, template.Slug,
formFieldsJSON, modulesJSON,
template.RedirectURL, template.SuccessMessage,
template.CustomLogoURL, template.CustomPrimaryColor,
template.IsActive, template.CreatedBy,
).Scan(&template.ID, &template.CreatedAt, &template.UpdatedAt)
if err != nil {
return fmt.Errorf("error creating signup template: %w", err)
}
return nil
}
// FindBySlug busca um template pelo slug
func (r *SignupTemplateRepository) FindBySlug(ctx context.Context, slug string) (*domain.SignupTemplate, error) {
query := `
SELECT id, name, description, slug, form_fields, enabled_modules,
redirect_url, success_message, custom_logo_url, custom_primary_color,
is_active, usage_count, created_by, created_at, updated_at
FROM signup_templates
WHERE slug = $1 AND is_active = true
`
var template domain.SignupTemplate
var formFieldsJSON, modulesJSON []byte
var redirectURL, successMessage, customLogoURL, customPrimaryColor sql.NullString
err := r.db.QueryRowContext(ctx, query, slug).Scan(
&template.ID, &template.Name, &template.Description, &template.Slug,
&formFieldsJSON, &modulesJSON,
&redirectURL, &successMessage,
&customLogoURL, &customPrimaryColor,
&template.IsActive, &template.UsageCount, &template.CreatedBy,
&template.CreatedAt, &template.UpdatedAt,
)
if redirectURL.Valid {
template.RedirectURL = redirectURL.String
}
if successMessage.Valid {
template.SuccessMessage = successMessage.String
}
if customLogoURL.Valid {
template.CustomLogoURL = customLogoURL.String
}
if customPrimaryColor.Valid {
template.CustomPrimaryColor = customPrimaryColor.String
}
if err == sql.ErrNoRows {
return nil, fmt.Errorf("signup template not found")
}
if err != nil {
return nil, fmt.Errorf("error finding signup template: %w", err)
}
if err := json.Unmarshal(formFieldsJSON, &template.FormFields); err != nil {
return nil, fmt.Errorf("error unmarshaling form_fields: %w", err)
}
if err := json.Unmarshal(modulesJSON, &template.EnabledModules); err != nil {
return nil, fmt.Errorf("error unmarshaling enabled_modules: %w", err)
}
return &template, nil
}
// FindByID busca um template pelo ID
func (r *SignupTemplateRepository) FindByID(ctx context.Context, id uuid.UUID) (*domain.SignupTemplate, error) {
query := `
SELECT id, name, description, slug, form_fields, enabled_modules,
redirect_url, success_message, custom_logo_url, custom_primary_color,
is_active, usage_count, created_by, created_at, updated_at
FROM signup_templates
WHERE id = $1
`
var template domain.SignupTemplate
var formFieldsJSON, modulesJSON []byte
var redirectURL, successMessage, customLogoURL, customPrimaryColor sql.NullString
err := r.db.QueryRowContext(ctx, query, id).Scan(
&template.ID, &template.Name, &template.Description, &template.Slug,
&formFieldsJSON, &modulesJSON,
&redirectURL, &successMessage,
&customLogoURL, &customPrimaryColor,
&template.IsActive, &template.UsageCount, &template.CreatedBy,
&template.CreatedAt, &template.UpdatedAt,
)
if redirectURL.Valid {
template.RedirectURL = redirectURL.String
}
if successMessage.Valid {
template.SuccessMessage = successMessage.String
}
if customLogoURL.Valid {
template.CustomLogoURL = customLogoURL.String
}
if customPrimaryColor.Valid {
template.CustomPrimaryColor = customPrimaryColor.String
}
if err == sql.ErrNoRows {
return nil, fmt.Errorf("signup template not found")
}
if err != nil {
return nil, fmt.Errorf("error finding signup template: %w", err)
}
if err := json.Unmarshal(formFieldsJSON, &template.FormFields); err != nil {
return nil, fmt.Errorf("error unmarshaling form_fields: %w", err)
}
if err := json.Unmarshal(modulesJSON, &template.EnabledModules); err != nil {
return nil, fmt.Errorf("error unmarshaling enabled_modules: %w", err)
}
return &template, nil
}
// List lista todos os templates
func (r *SignupTemplateRepository) List(ctx context.Context) ([]*domain.SignupTemplate, error) {
query := `
SELECT id, name, description, slug, form_fields, enabled_modules,
redirect_url, success_message, custom_logo_url, custom_primary_color,
is_active, usage_count, created_by, created_at, updated_at
FROM signup_templates
ORDER BY created_at DESC
`
rows, err := r.db.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("error listing signup templates: %w", err)
}
defer rows.Close()
var templates []*domain.SignupTemplate
for rows.Next() {
var template domain.SignupTemplate
var formFieldsJSON, modulesJSON []byte
var redirectURL, successMessage, customLogoURL, customPrimaryColor sql.NullString
err := rows.Scan(
&template.ID, &template.Name, &template.Description, &template.Slug,
&formFieldsJSON, &modulesJSON,
&redirectURL, &successMessage,
&customLogoURL, &customPrimaryColor,
&template.IsActive, &template.UsageCount, &template.CreatedBy,
&template.CreatedAt, &template.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("error scanning signup template: %w", err)
}
if redirectURL.Valid {
template.RedirectURL = redirectURL.String
}
if successMessage.Valid {
template.SuccessMessage = successMessage.String
}
if customLogoURL.Valid {
template.CustomLogoURL = customLogoURL.String
}
if customPrimaryColor.Valid {
template.CustomPrimaryColor = customPrimaryColor.String
}
if err := json.Unmarshal(formFieldsJSON, &template.FormFields); err != nil {
return nil, fmt.Errorf("error unmarshaling form_fields: %w", err)
}
if err := json.Unmarshal(modulesJSON, &template.EnabledModules); err != nil {
return nil, fmt.Errorf("error unmarshaling enabled_modules: %w", err)
}
templates = append(templates, &template)
}
return templates, nil
}
// IncrementUsageCount incrementa o contador de uso
func (r *SignupTemplateRepository) IncrementUsageCount(ctx context.Context, id uuid.UUID) error {
query := `UPDATE signup_templates SET usage_count = usage_count + 1 WHERE id = $1`
_, err := r.db.ExecContext(ctx, query, id)
return err
}
// Update atualiza um template
func (r *SignupTemplateRepository) Update(ctx context.Context, template *domain.SignupTemplate) error {
formFieldsJSON, err := json.Marshal(template.FormFields)
if err != nil {
return fmt.Errorf("error marshaling form_fields: %w", err)
}
modulesJSON, err := json.Marshal(template.EnabledModules)
if err != nil {
return fmt.Errorf("error marshaling enabled_modules: %w", err)
}
query := `
UPDATE signup_templates SET
name = $1, description = $2, slug = $3, form_fields = $4, enabled_modules = $5,
redirect_url = $6, success_message = $7, custom_logo_url = $8, custom_primary_color = $9,
is_active = $10
WHERE id = $11
`
_, err = r.db.ExecContext(
ctx, query,
template.Name, template.Description, template.Slug,
formFieldsJSON, modulesJSON,
template.RedirectURL, template.SuccessMessage,
template.CustomLogoURL, template.CustomPrimaryColor,
template.IsActive, template.ID,
)
if err != nil {
return fmt.Errorf("error updating signup template: %w", err)
}
return nil
}
// Delete deleta um template
func (r *SignupTemplateRepository) Delete(ctx context.Context, id uuid.UUID) error {
query := `DELETE FROM signup_templates WHERE id = $1`
_, err := r.db.ExecContext(ctx, query, id)
if err != nil {
return fmt.Errorf("error deleting signup template: %w", err)
}
return nil
}

View File

@@ -0,0 +1,300 @@
package repository
import (
"aggios-app/backend/internal/domain"
"database/sql"
"fmt"
)
type SolutionRepository struct {
db *sql.DB
}
func NewSolutionRepository(db *sql.DB) *SolutionRepository {
return &SolutionRepository{db: db}
}
// ==================== SOLUTIONS ====================
func (r *SolutionRepository) CreateSolution(solution *domain.Solution) error {
query := `
INSERT INTO solutions (id, name, slug, icon, description, is_active)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING created_at, updated_at
`
return r.db.QueryRow(
query,
solution.ID, solution.Name, solution.Slug, solution.Icon,
solution.Description, solution.IsActive,
).Scan(&solution.CreatedAt, &solution.UpdatedAt)
}
func (r *SolutionRepository) GetAllSolutions() ([]domain.Solution, error) {
query := `
SELECT id, name, slug, icon, description, is_active, created_at, updated_at
FROM solutions
ORDER BY created_at DESC
`
rows, err := r.db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var solutions []domain.Solution
for rows.Next() {
var s domain.Solution
err := rows.Scan(
&s.ID, &s.Name, &s.Slug, &s.Icon, &s.Description,
&s.IsActive, &s.CreatedAt, &s.UpdatedAt,
)
if err != nil {
return nil, err
}
solutions = append(solutions, s)
}
return solutions, nil
}
func (r *SolutionRepository) GetActiveSolutions() ([]domain.Solution, error) {
query := `
SELECT id, name, slug, icon, description, is_active, created_at, updated_at
FROM solutions
WHERE is_active = true
ORDER BY name
`
rows, err := r.db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var solutions []domain.Solution
for rows.Next() {
var s domain.Solution
err := rows.Scan(
&s.ID, &s.Name, &s.Slug, &s.Icon, &s.Description,
&s.IsActive, &s.CreatedAt, &s.UpdatedAt,
)
if err != nil {
return nil, err
}
solutions = append(solutions, s)
}
return solutions, nil
}
func (r *SolutionRepository) GetSolutionByID(id string) (*domain.Solution, error) {
query := `
SELECT id, name, slug, icon, description, is_active, created_at, updated_at
FROM solutions
WHERE id = $1
`
var s domain.Solution
err := r.db.QueryRow(query, id).Scan(
&s.ID, &s.Name, &s.Slug, &s.Icon, &s.Description,
&s.IsActive, &s.CreatedAt, &s.UpdatedAt,
)
if err != nil {
return nil, err
}
return &s, nil
}
func (r *SolutionRepository) GetSolutionBySlug(slug string) (*domain.Solution, error) {
query := `
SELECT id, name, slug, icon, description, is_active, created_at, updated_at
FROM solutions
WHERE slug = $1
`
var s domain.Solution
err := r.db.QueryRow(query, slug).Scan(
&s.ID, &s.Name, &s.Slug, &s.Icon, &s.Description,
&s.IsActive, &s.CreatedAt, &s.UpdatedAt,
)
if err != nil {
return nil, err
}
return &s, nil
}
func (r *SolutionRepository) UpdateSolution(solution *domain.Solution) error {
query := `
UPDATE solutions SET
name = $1, slug = $2, icon = $3, description = $4, is_active = $5, updated_at = CURRENT_TIMESTAMP
WHERE id = $6
`
result, err := r.db.Exec(
query,
solution.Name, solution.Slug, solution.Icon, solution.Description,
solution.IsActive, solution.ID,
)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return fmt.Errorf("solution not found")
}
return nil
}
func (r *SolutionRepository) DeleteSolution(id string) error {
query := `DELETE FROM solutions WHERE id = $1`
result, err := r.db.Exec(query, id)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return fmt.Errorf("solution not found")
}
return nil
}
// ==================== PLAN <-> SOLUTION ====================
func (r *SolutionRepository) AddSolutionToPlan(planID, solutionID string) error {
query := `
INSERT INTO plan_solutions (plan_id, solution_id)
VALUES ($1, $2)
ON CONFLICT (plan_id, solution_id) DO NOTHING
`
_, err := r.db.Exec(query, planID, solutionID)
return err
}
func (r *SolutionRepository) RemoveSolutionFromPlan(planID, solutionID string) error {
query := `DELETE FROM plan_solutions WHERE plan_id = $1 AND solution_id = $2`
_, err := r.db.Exec(query, planID, solutionID)
return err
}
func (r *SolutionRepository) GetPlanSolutions(planID string) ([]domain.Solution, error) {
query := `
SELECT s.id, s.name, s.slug, s.icon, s.description, s.is_active, s.created_at, s.updated_at
FROM solutions s
INNER JOIN plan_solutions ps ON s.id = ps.solution_id
WHERE ps.plan_id = $1
ORDER BY s.name
`
rows, err := r.db.Query(query, planID)
if err != nil {
return nil, err
}
defer rows.Close()
var solutions []domain.Solution
for rows.Next() {
var s domain.Solution
err := rows.Scan(
&s.ID, &s.Name, &s.Slug, &s.Icon, &s.Description,
&s.IsActive, &s.CreatedAt, &s.UpdatedAt,
)
if err != nil {
return nil, err
}
solutions = append(solutions, s)
}
return solutions, nil
}
func (r *SolutionRepository) SetPlanSolutions(planID string, solutionIDs []string) error {
// Inicia transação
tx, err := r.db.Begin()
if err != nil {
return err
}
// Remove todas as soluções antigas do plano
_, err = tx.Exec(`DELETE FROM plan_solutions WHERE plan_id = $1`, planID)
if err != nil {
tx.Rollback()
return err
}
// Adiciona as novas soluções
stmt, err := tx.Prepare(`INSERT INTO plan_solutions (plan_id, solution_id) VALUES ($1, $2)`)
if err != nil {
tx.Rollback()
return err
}
defer stmt.Close()
for _, solutionID := range solutionIDs {
_, err = stmt.Exec(planID, solutionID)
if err != nil {
tx.Rollback()
return err
}
}
return tx.Commit()
}
func (r *SolutionRepository) GetTenantSolutions(tenantID string) ([]domain.Solution, error) {
query := `
SELECT DISTINCT s.id, s.name, s.slug, s.icon, s.description, s.is_active, s.created_at, s.updated_at
FROM solutions s
INNER JOIN plan_solutions ps ON s.id = ps.solution_id
INNER JOIN agency_subscriptions asub ON ps.plan_id = asub.plan_id
WHERE asub.agency_id = $1 AND s.is_active = true AND asub.status = 'active'
ORDER BY s.name
`
rows, err := r.db.Query(query, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var solutions []domain.Solution
for rows.Next() {
var s domain.Solution
err := rows.Scan(
&s.ID, &s.Name, &s.Slug, &s.Icon, &s.Description,
&s.IsActive, &s.CreatedAt, &s.UpdatedAt,
)
if err != nil {
return nil, err
}
solutions = append(solutions, s)
}
// Se não encontrou via subscription, retorna array vazio
if solutions == nil {
solutions = []domain.Solution{}
}
return solutions, nil
}

View File

@@ -0,0 +1,203 @@
package repository
import (
"database/sql"
"time"
"aggios-app/backend/internal/domain"
"github.com/google/uuid"
)
// SubscriptionRepository handles database operations for subscriptions
type SubscriptionRepository struct {
db *sql.DB
}
// NewSubscriptionRepository creates a new subscription repository
func NewSubscriptionRepository(db *sql.DB) *SubscriptionRepository {
return &SubscriptionRepository{db: db}
}
// Create creates a new subscription
func (r *SubscriptionRepository) Create(subscription *domain.Subscription) error {
query := `
INSERT INTO agency_subscriptions (id, agency_id, plan_id, billing_type, current_users, status, start_date, renewal_date, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, created_at, updated_at
`
now := time.Now()
subscription.ID = uuid.New()
subscription.CreatedAt = now
subscription.UpdatedAt = now
subscription.StartDate = now
// Set renewal date based on billing type
if subscription.BillingType == "annual" {
subscription.RenewalDate = now.AddDate(1, 0, 0)
} else {
subscription.RenewalDate = now.AddDate(0, 1, 0)
}
return r.db.QueryRow(
query,
subscription.ID,
subscription.AgencyID,
subscription.PlanID,
subscription.BillingType,
subscription.CurrentUsers,
subscription.Status,
subscription.StartDate,
subscription.RenewalDate,
subscription.CreatedAt,
subscription.UpdatedAt,
).Scan(&subscription.ID, &subscription.CreatedAt, &subscription.UpdatedAt)
}
// GetByID retrieves a subscription by ID
func (r *SubscriptionRepository) GetByID(id uuid.UUID) (*domain.Subscription, error) {
query := `
SELECT id, agency_id, plan_id, billing_type, current_users, status, start_date, renewal_date, created_at, updated_at
FROM agency_subscriptions
WHERE id = $1
`
subscription := &domain.Subscription{}
err := r.db.QueryRow(query, id).Scan(
&subscription.ID,
&subscription.AgencyID,
&subscription.PlanID,
&subscription.BillingType,
&subscription.CurrentUsers,
&subscription.Status,
&subscription.StartDate,
&subscription.RenewalDate,
&subscription.CreatedAt,
&subscription.UpdatedAt,
)
return subscription, err
}
// GetByAgencyID retrieves a subscription by agency ID
func (r *SubscriptionRepository) GetByAgencyID(agencyID uuid.UUID) (*domain.Subscription, error) {
query := `
SELECT id, agency_id, plan_id, billing_type, current_users, status, start_date, renewal_date, created_at, updated_at
FROM agency_subscriptions
WHERE agency_id = $1 AND status = 'active'
LIMIT 1
`
subscription := &domain.Subscription{}
err := r.db.QueryRow(query, agencyID).Scan(
&subscription.ID,
&subscription.AgencyID,
&subscription.PlanID,
&subscription.BillingType,
&subscription.CurrentUsers,
&subscription.Status,
&subscription.StartDate,
&subscription.RenewalDate,
&subscription.CreatedAt,
&subscription.UpdatedAt,
)
return subscription, err
}
// ListAll retrieves all subscriptions
func (r *SubscriptionRepository) ListAll() ([]*domain.Subscription, error) {
query := `
SELECT id, agency_id, plan_id, billing_type, current_users, status, start_date, renewal_date, created_at, updated_at
FROM agency_subscriptions
ORDER BY created_at DESC
`
rows, err := r.db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var subscriptions []*domain.Subscription
for rows.Next() {
subscription := &domain.Subscription{}
err := rows.Scan(
&subscription.ID,
&subscription.AgencyID,
&subscription.PlanID,
&subscription.BillingType,
&subscription.CurrentUsers,
&subscription.Status,
&subscription.StartDate,
&subscription.RenewalDate,
&subscription.CreatedAt,
&subscription.UpdatedAt,
)
if err != nil {
return nil, err
}
subscriptions = append(subscriptions, subscription)
}
return subscriptions, rows.Err()
}
// Update updates a subscription
func (r *SubscriptionRepository) Update(subscription *domain.Subscription) error {
query := `
UPDATE agency_subscriptions
SET plan_id = $2, billing_type = $3, current_users = $4, status = $5, renewal_date = $6, updated_at = $7
WHERE id = $1
RETURNING updated_at
`
subscription.UpdatedAt = time.Now()
return r.db.QueryRow(
query,
subscription.ID,
subscription.PlanID,
subscription.BillingType,
subscription.CurrentUsers,
subscription.Status,
subscription.RenewalDate,
subscription.UpdatedAt,
).Scan(&subscription.UpdatedAt)
}
// Delete deletes a subscription
func (r *SubscriptionRepository) Delete(id uuid.UUID) error {
query := `DELETE FROM agency_subscriptions WHERE id = $1`
result, err := r.db.Exec(query, id)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return sql.ErrNoRows
}
return nil
}
// UpdateUserCount updates the current user count for a subscription
func (r *SubscriptionRepository) UpdateUserCount(agencyID uuid.UUID, userCount int) error {
query := `
UPDATE agency_subscriptions
SET current_users = $2, updated_at = $3
WHERE agency_id = $1 AND status = 'active'
`
_, err := r.db.Exec(query, agencyID, userCount, time.Now())
return err
}

View File

@@ -0,0 +1,399 @@
package repository
import (
"database/sql"
"time"
"aggios-app/backend/internal/domain"
"github.com/google/uuid"
)
// TenantRepository handles database operations for tenants
type TenantRepository struct {
db *sql.DB
}
// NewTenantRepository creates a new tenant repository
func NewTenantRepository(db *sql.DB) *TenantRepository {
return &TenantRepository{db: db}
}
// DB returns the underlying database connection
func (r *TenantRepository) DB() *sql.DB {
return r.db
}
// Create creates a new tenant
func (r *TenantRepository) Create(tenant *domain.Tenant) error {
query := `
INSERT INTO tenants (
id, name, domain, subdomain, cnpj, razao_social, email, phone, website,
address, neighborhood, number, complement, city, state, zip,
description, industry, team_size, primary_color, secondary_color,
logo_url, logo_horizontal_url, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25)
RETURNING id, created_at, updated_at
`
now := time.Now()
tenant.ID = uuid.New()
tenant.CreatedAt = now
tenant.UpdatedAt = now
return r.db.QueryRow(
query,
tenant.ID,
tenant.Name,
tenant.Domain,
tenant.Subdomain,
tenant.CNPJ,
tenant.RazaoSocial,
tenant.Email,
tenant.Phone,
tenant.Website,
tenant.Address,
tenant.Neighborhood,
tenant.Number,
tenant.Complement,
tenant.City,
tenant.State,
tenant.Zip,
tenant.Description,
tenant.Industry,
tenant.TeamSize,
tenant.PrimaryColor,
tenant.SecondaryColor,
tenant.LogoURL,
tenant.LogoHorizontalURL,
tenant.CreatedAt,
tenant.UpdatedAt,
).Scan(&tenant.ID, &tenant.CreatedAt, &tenant.UpdatedAt)
}
// FindByID finds a tenant by ID
func (r *TenantRepository) FindByID(id uuid.UUID) (*domain.Tenant, error) {
query := `
SELECT id, name, domain, subdomain, cnpj, razao_social, email, phone, website,
address, neighborhood, number, complement, city, state, zip, description, industry, team_size,
primary_color, secondary_color, logo_url, logo_horizontal_url,
is_active, created_at, updated_at
FROM tenants
WHERE id = $1
`
tenant := &domain.Tenant{}
var cnpj, razaoSocial, email, phone, website, address, neighborhood, number, complement, city, state, zip, description, industry, teamSize, primaryColor, secondaryColor, logoURL, logoHorizontalURL sql.NullString
err := r.db.QueryRow(query, id).Scan(
&tenant.ID,
&tenant.Name,
&tenant.Domain,
&tenant.Subdomain,
&cnpj,
&razaoSocial,
&email,
&phone,
&website,
&address,
&neighborhood,
&number,
&complement,
&city,
&state,
&zip,
&description,
&industry,
&teamSize,
&primaryColor,
&secondaryColor,
&logoURL,
&logoHorizontalURL,
&tenant.IsActive,
&tenant.CreatedAt,
&tenant.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
// Handle nullable fields
if cnpj.Valid {
tenant.CNPJ = cnpj.String
}
if razaoSocial.Valid {
tenant.RazaoSocial = razaoSocial.String
}
if email.Valid {
tenant.Email = email.String
}
if phone.Valid {
tenant.Phone = phone.String
}
if website.Valid {
tenant.Website = website.String
}
if address.Valid {
tenant.Address = address.String
}
if neighborhood.Valid {
tenant.Neighborhood = neighborhood.String
}
if number.Valid {
tenant.Number = number.String
}
if complement.Valid {
tenant.Complement = complement.String
}
if city.Valid {
tenant.City = city.String
}
if state.Valid {
tenant.State = state.String
}
if zip.Valid {
tenant.Zip = zip.String
}
if description.Valid {
tenant.Description = description.String
}
if industry.Valid {
tenant.Industry = industry.String
}
if teamSize.Valid {
tenant.TeamSize = teamSize.String
}
if primaryColor.Valid {
tenant.PrimaryColor = primaryColor.String
}
if secondaryColor.Valid {
tenant.SecondaryColor = secondaryColor.String
}
if logoURL.Valid {
tenant.LogoURL = logoURL.String
}
if logoHorizontalURL.Valid {
tenant.LogoHorizontalURL = logoHorizontalURL.String
}
return tenant, nil
}
// FindBySubdomain finds a tenant by subdomain
func (r *TenantRepository) FindBySubdomain(subdomain string) (*domain.Tenant, error) {
query := `
SELECT id, name, domain, subdomain, primary_color, secondary_color, logo_url, logo_horizontal_url, created_at, updated_at
FROM tenants
WHERE subdomain = $1
`
tenant := &domain.Tenant{}
var primaryColor, secondaryColor, logoURL, logoHorizontalURL sql.NullString
err := r.db.QueryRow(query, subdomain).Scan(
&tenant.ID,
&tenant.Name,
&tenant.Domain,
&tenant.Subdomain,
&primaryColor,
&secondaryColor,
&logoURL,
&logoHorizontalURL,
&tenant.CreatedAt,
&tenant.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if primaryColor.Valid {
tenant.PrimaryColor = primaryColor.String
}
if secondaryColor.Valid {
tenant.SecondaryColor = secondaryColor.String
}
if logoURL.Valid {
tenant.LogoURL = logoURL.String
}
if logoHorizontalURL.Valid {
tenant.LogoHorizontalURL = logoHorizontalURL.String
}
return tenant, nil
}
// SubdomainExists checks if a subdomain is already taken
func (r *TenantRepository) SubdomainExists(subdomain string) (bool, error) {
var exists bool
query := `SELECT EXISTS(SELECT 1 FROM tenants WHERE subdomain = $1)`
err := r.db.QueryRow(query, subdomain).Scan(&exists)
return exists, err
}
// FindAll returns all tenants
func (r *TenantRepository) FindAll() ([]*domain.Tenant, error) {
query := `
SELECT id, name, domain, subdomain, email, phone, cnpj, logo_url, is_active, created_at, updated_at
FROM tenants
ORDER BY created_at DESC
`
rows, err := r.db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var tenants []*domain.Tenant
for rows.Next() {
tenant := &domain.Tenant{}
var email, phone, cnpj, logoURL sql.NullString
err := rows.Scan(
&tenant.ID,
&tenant.Name,
&tenant.Domain,
&tenant.Subdomain,
&email,
&phone,
&cnpj,
&logoURL,
&tenant.IsActive,
&tenant.CreatedAt,
&tenant.UpdatedAt,
)
if err != nil {
return nil, err
}
if email.Valid {
tenant.Email = email.String
}
if phone.Valid {
tenant.Phone = phone.String
}
if cnpj.Valid {
tenant.CNPJ = cnpj.String
}
if logoURL.Valid {
tenant.LogoURL = logoURL.String
}
tenants = append(tenants, tenant)
}
if tenants == nil {
return []*domain.Tenant{}, nil
}
return tenants, nil
}
// Delete removes a tenant (and cascades to related data)
func (r *TenantRepository) Delete(id uuid.UUID) error {
// Start transaction
tx, err := r.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// Delete all users associated with this tenant first
_, err = tx.Exec(`DELETE FROM users WHERE tenant_id = $1`, id)
if err != nil {
return err
}
// Delete the tenant
result, err := tx.Exec(`DELETE FROM tenants WHERE id = $1`, id)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return sql.ErrNoRows
}
// Commit transaction
return tx.Commit()
}
// UpdateProfile updates tenant profile information
func (r *TenantRepository) UpdateProfile(id uuid.UUID, updates map[string]interface{}) error {
query := `
UPDATE tenants SET
name = COALESCE($1, name),
cnpj = COALESCE($2, cnpj),
razao_social = COALESCE($3, razao_social),
email = COALESCE($4, email),
phone = COALESCE($5, phone),
website = COALESCE($6, website),
address = COALESCE($7, address),
neighborhood = COALESCE($8, neighborhood),
number = COALESCE($9, number),
complement = COALESCE($10, complement),
city = COALESCE($11, city),
state = COALESCE($12, state),
zip = COALESCE($13, zip),
description = COALESCE($14, description),
industry = COALESCE($15, industry),
team_size = COALESCE($16, team_size),
primary_color = COALESCE($17, primary_color),
secondary_color = COALESCE($18, secondary_color),
logo_url = COALESCE($19, logo_url),
logo_horizontal_url = COALESCE($20, logo_horizontal_url),
updated_at = $21
WHERE id = $22
`
_, err := r.db.Exec(
query,
updates["name"],
updates["cnpj"],
updates["razao_social"],
updates["email"],
updates["phone"],
updates["website"],
updates["address"],
updates["neighborhood"],
updates["number"],
updates["complement"],
updates["city"],
updates["state"],
updates["zip"],
updates["description"],
updates["industry"],
updates["team_size"],
updates["primary_color"],
updates["secondary_color"],
updates["logo_url"],
updates["logo_horizontal_url"],
time.Now(),
id,
)
return err
}
// UpdateStatus updates the is_active status of a tenant
func (r *TenantRepository) UpdateStatus(id uuid.UUID, isActive bool) error {
query := `UPDATE tenants SET is_active = $1, updated_at = $2 WHERE id = $3`
_, err := r.db.Exec(query, isActive, time.Now(), id)
return err
}

View File

@@ -0,0 +1,233 @@
package repository
import (
"database/sql"
"log"
"time"
"aggios-app/backend/internal/domain"
"github.com/google/uuid"
)
// UserRepository handles database operations for users
type UserRepository struct {
db *sql.DB
}
// NewUserRepository creates a new user repository
func NewUserRepository(db *sql.DB) *UserRepository {
return &UserRepository{db: db}
}
// Create creates a new user
func (r *UserRepository) Create(user *domain.User) error {
query := `
INSERT INTO users (id, tenant_id, email, password_hash, first_name, role, is_active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, created_at, updated_at
`
now := time.Now()
user.ID = uuid.New()
user.CreatedAt = now
user.UpdatedAt = now
// Default role to CLIENTE if not specified
if user.Role == "" {
user.Role = "CLIENTE"
}
return r.db.QueryRow(
query,
user.ID,
user.TenantID,
user.Email,
user.Password,
user.Name,
user.Role,
true, // is_active
user.CreatedAt,
user.UpdatedAt,
).Scan(&user.ID, &user.CreatedAt, &user.UpdatedAt)
}
// FindByEmail finds a user by email
func (r *UserRepository) FindByEmail(email string) (*domain.User, error) {
log.Printf("🔍 FindByEmail called with: %s", email)
query := `
SELECT id, tenant_id, email, password_hash, first_name, role, created_at, updated_at
FROM users
WHERE email = $1 AND is_active = true
`
user := &domain.User{}
err := r.db.QueryRow(query, email).Scan(
&user.ID,
&user.TenantID,
&user.Email,
&user.Password,
&user.Name,
&user.Role,
&user.CreatedAt,
&user.UpdatedAt,
)
if err == sql.ErrNoRows {
log.Printf("❌ User not found: %s", email)
return nil, nil
}
if err != nil {
log.Printf("❌ DB error finding user %s: %v", email, err)
return nil, err
}
log.Printf("✅ Found user: %s, role: %s", user.Email, user.Role)
return user, nil
}
// FindByID finds a user by ID
func (r *UserRepository) FindByID(id uuid.UUID) (*domain.User, error) {
query := `
SELECT id, tenant_id, email, password_hash, first_name, role, created_at, updated_at
FROM users
WHERE id = $1 AND is_active = true
`
user := &domain.User{}
err := r.db.QueryRow(query, id).Scan(
&user.ID,
&user.TenantID,
&user.Email,
&user.Password,
&user.Name,
&user.Role,
&user.CreatedAt,
&user.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
return user, err
}
// EmailExists checks if an email is already registered
func (r *UserRepository) EmailExists(email string) (bool, error) {
var exists bool
query := `SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)`
err := r.db.QueryRow(query, email).Scan(&exists)
return exists, err
}
// UpdatePassword updates a user's password
func (r *UserRepository) UpdatePassword(userID, hashedPassword string) error {
query := `UPDATE users SET password_hash = $1, updated_at = $2 WHERE id = $3`
_, err := r.db.Exec(query, hashedPassword, time.Now(), userID)
return err
}
// FindAdminByTenantID returns the primary admin user for a tenant
func (r *UserRepository) FindAdminByTenantID(tenantID uuid.UUID) (*domain.User, error) {
query := `
SELECT id, tenant_id, email, password_hash, first_name, role, created_at, updated_at
FROM users
WHERE tenant_id = $1 AND role = 'ADMIN_AGENCIA' AND is_active = true
ORDER BY created_at ASC
LIMIT 1
`
user := &domain.User{}
err := r.db.QueryRow(query, tenantID).Scan(
&user.ID,
&user.TenantID,
&user.Email,
&user.Password,
&user.Name,
&user.Role,
&user.CreatedAt,
&user.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return user, nil
}
// ListByTenantID returns all users for a tenant (excluding the tenant admin)
func (r *UserRepository) ListByTenantID(tenantID uuid.UUID) ([]domain.User, error) {
query := `
SELECT id, tenant_id, email, password_hash, first_name, role, created_at, updated_at,
agency_role, created_by, collaborator_created_at
FROM users
WHERE tenant_id = $1 AND is_active = true AND role != 'SUPERADMIN'
ORDER BY created_at DESC
`
rows, err := r.db.Query(query, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var users []domain.User
for rows.Next() {
user := domain.User{}
err := rows.Scan(
&user.ID,
&user.TenantID,
&user.Email,
&user.Password,
&user.Name,
&user.Role,
&user.CreatedAt,
&user.UpdatedAt,
&user.AgencyRole,
&user.CreatedBy,
&user.CollaboratorCreatedAt,
)
if err != nil {
return nil, err
}
users = append(users, user)
}
return users, rows.Err()
}
// GetByID returns a user by ID
func (r *UserRepository) GetByID(id uuid.UUID) (*domain.User, error) {
return r.FindByID(id)
}
// Delete marks a user as inactive
func (r *UserRepository) Delete(id uuid.UUID) error {
query := `
UPDATE users
SET is_active = false, updated_at = NOW()
WHERE id = $1
`
result, err := r.db.Exec(query, id)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return sql.ErrNoRows
}
return nil
}

View File

@@ -0,0 +1,250 @@
package service
import (
"aggios-app/backend/internal/config"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/repository"
"database/sql"
"fmt"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
// AgencyService handles agency registration and management
type AgencyService struct {
userRepo *repository.UserRepository
tenantRepo *repository.TenantRepository
cfg *config.Config
db *sql.DB
}
// NewAgencyService creates a new agency service
func NewAgencyService(userRepo *repository.UserRepository, tenantRepo *repository.TenantRepository, cfg *config.Config, db *sql.DB) *AgencyService {
return &AgencyService{
userRepo: userRepo,
tenantRepo: tenantRepo,
cfg: cfg,
db: db,
}
}
// RegisterAgency creates a new agency (tenant) and its admin user
// Only SUPERADMIN can call this
func (s *AgencyService) RegisterAgency(req domain.RegisterAgencyRequest) (*domain.Tenant, *domain.User, error) {
// Validate password
if len(req.AdminPassword) < s.cfg.Security.PasswordMinLength {
return nil, nil, ErrWeakPassword
}
// Check if subdomain is available
exists, err := s.tenantRepo.SubdomainExists(req.Subdomain)
if err != nil {
return nil, nil, err
}
if exists {
return nil, nil, ErrSubdomainTaken
}
// Check if admin email already exists
emailExists, err := s.userRepo.EmailExists(req.AdminEmail)
if err != nil {
return nil, nil, err
}
if emailExists {
return nil, nil, ErrEmailAlreadyExists
}
// Create tenant
address := req.Street
if req.Number != "" {
address += ", " + req.Number
}
if req.Complement != "" {
address += " - " + req.Complement
}
tenant := &domain.Tenant{
Name: req.AgencyName,
Domain: fmt.Sprintf("%s.%s", req.Subdomain, s.cfg.App.BaseDomain),
Subdomain: req.Subdomain,
CNPJ: req.CNPJ,
RazaoSocial: req.RazaoSocial,
Email: req.AdminEmail,
Phone: req.Phone,
Website: req.Website,
Address: address,
Neighborhood: req.Neighborhood,
Number: req.Number,
Complement: req.Complement,
City: req.City,
State: req.State,
Zip: req.CEP,
Description: req.Description,
Industry: req.Industry,
TeamSize: req.TeamSize,
PrimaryColor: req.PrimaryColor,
SecondaryColor: req.SecondaryColor,
LogoURL: req.LogoURL,
LogoHorizontalURL: req.LogoHorizontalURL,
}
if err := s.tenantRepo.Create(tenant); err != nil {
return nil, nil, err
}
// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.AdminPassword), bcrypt.DefaultCost)
if err != nil {
return nil, nil, err
}
// Create admin user for the agency
adminUser := &domain.User{
TenantID: &tenant.ID,
Email: req.AdminEmail,
Password: string(hashedPassword),
Name: req.AdminName,
Role: "ADMIN_AGENCIA",
}
if err := s.userRepo.Create(adminUser); err != nil {
return nil, nil, err
}
return tenant, adminUser, nil
}
// RegisterClient creates a new client user for a specific agency
// Only ADMIN_AGENCIA can call this
func (s *AgencyService) RegisterClient(req domain.RegisterClientRequest, tenantID uuid.UUID) (*domain.User, error) {
// Validate password
if len(req.Password) < s.cfg.Security.PasswordMinLength {
return nil, ErrWeakPassword
}
// Check if email already exists
exists, err := s.userRepo.EmailExists(req.Email)
if err != nil {
return nil, err
}
if exists {
return nil, ErrEmailAlreadyExists
}
// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
// Create client user
client := &domain.User{
TenantID: &tenantID,
Email: req.Email,
Password: string(hashedPassword),
Name: req.Name,
Role: "CLIENTE",
}
if err := s.userRepo.Create(client); err != nil {
return nil, err
}
return client, nil
}
// GetAgencyDetails returns tenant and admin information for superadmin view
func (s *AgencyService) GetAgencyDetails(id uuid.UUID) (*domain.AgencyDetails, error) {
tenant, err := s.tenantRepo.FindByID(id)
if err != nil {
return nil, err
}
if tenant == nil {
return nil, ErrTenantNotFound
}
admin, err := s.userRepo.FindAdminByTenantID(id)
if err != nil {
return nil, err
}
protocol := "http://"
if s.cfg.App.Environment == "production" {
protocol = "https://"
}
details := &domain.AgencyDetails{
Tenant: tenant,
AccessURL: fmt.Sprintf("%s%s", protocol, tenant.Domain),
}
if admin != nil {
details.Admin = admin
}
// Buscar subscription e soluções
var subscription domain.AgencySubscriptionInfo
query := `
SELECT
s.plan_id,
p.name as plan_name,
s.status
FROM agency_subscriptions s
JOIN plans p ON s.plan_id = p.id
WHERE s.agency_id = $1
LIMIT 1
`
err = s.db.QueryRow(query, id).Scan(&subscription.PlanID, &subscription.PlanName, &subscription.Status)
if err == nil {
// Buscar soluções do plano
solutionsQuery := `
SELECT sol.id, sol.name, sol.slug, sol.icon
FROM solutions sol
JOIN plan_solutions ps ON sol.id = ps.solution_id
WHERE ps.plan_id = $1
ORDER BY sol.name
`
rows, err := s.db.Query(solutionsQuery, subscription.PlanID)
if err == nil {
defer rows.Close()
var solutions []domain.Solution
for rows.Next() {
var solution domain.Solution
if err := rows.Scan(&solution.ID, &solution.Name, &solution.Slug, &solution.Icon); err == nil {
solutions = append(solutions, solution)
}
}
subscription.Solutions = solutions
details.Subscription = &subscription
}
}
return details, nil
}
// DeleteAgency removes a tenant and its related resources
func (s *AgencyService) DeleteAgency(id uuid.UUID) error {
tenant, err := s.tenantRepo.FindByID(id)
if err != nil {
return err
}
if tenant == nil {
return ErrTenantNotFound
}
return s.tenantRepo.Delete(id)
}
// UpdateAgencyStatus updates the is_active status of a tenant
func (s *AgencyService) UpdateAgencyStatus(id uuid.UUID, isActive bool) error {
tenant, err := s.tenantRepo.FindByID(id)
if err != nil {
return err
}
if tenant == nil {
return ErrTenantNotFound
}
return s.tenantRepo.UpdateStatus(id, isActive)
}

View File

@@ -0,0 +1,334 @@
package service
import (
"errors"
"log"
"time"
"aggios-app/backend/internal/config"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/repository"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
var (
ErrEmailAlreadyExists = errors.New("email already registered")
ErrInvalidCredentials = errors.New("invalid email or password")
ErrWeakPassword = errors.New("password too weak")
ErrSubdomainTaken = errors.New("subdomain already taken")
ErrUnauthorized = errors.New("unauthorized access")
)
// AuthService handles authentication business logic
type AuthService struct {
userRepo *repository.UserRepository
tenantRepo *repository.TenantRepository
crmRepo *repository.CRMRepository
cfg *config.Config
}
// NewAuthService creates a new auth service
func NewAuthService(userRepo *repository.UserRepository, tenantRepo *repository.TenantRepository, crmRepo *repository.CRMRepository, cfg *config.Config) *AuthService {
return &AuthService{
userRepo: userRepo,
tenantRepo: tenantRepo,
crmRepo: crmRepo,
cfg: cfg,
}
}
// Register creates a new user account
func (s *AuthService) Register(req domain.CreateUserRequest) (*domain.User, error) {
// Validate password strength
if len(req.Password) < s.cfg.Security.PasswordMinLength {
return nil, ErrWeakPassword
}
// Check if email already exists
exists, err := s.userRepo.EmailExists(req.Email)
if err != nil {
return nil, err
}
if exists {
return nil, ErrEmailAlreadyExists
}
// Hash password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
// Create user
user := &domain.User{
Email: req.Email,
Password: string(hashedPassword),
Name: req.Name,
}
if err := s.userRepo.Create(user); err != nil {
return nil, err
}
return user, nil
}
// Login authenticates a user and returns a JWT token
func (s *AuthService) Login(req domain.LoginRequest) (*domain.LoginResponse, error) {
// Find user by email
user, err := s.userRepo.FindByEmail(req.Email)
if err != nil {
log.Printf("❌ DB error finding user %s: %v", req.Email, err)
return nil, err
}
if user == nil {
log.Printf("❌ User not found: %s", req.Email)
return nil, ErrInvalidCredentials
}
log.Printf("🔍 Attempting login for %s with password_hash: %.10s...", req.Email, user.Password)
log.Printf("🔍 Provided password length: %d", len(req.Password))
// Verify password
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
log.Printf("❌ Password mismatch for %s: %v", req.Email, err)
return nil, ErrInvalidCredentials
}
// Generate JWT token
token, err := s.generateToken(user)
if err != nil {
return nil, err
}
response := &domain.LoginResponse{
Token: token,
User: *user,
}
// If user has a tenant, get the subdomain
if user.TenantID != nil {
tenant, err := s.tenantRepo.FindByID(*user.TenantID)
if err == nil && tenant != nil {
response.Subdomain = &tenant.Subdomain
}
}
return response, nil
}
func (s *AuthService) generateToken(user *domain.User) (string, error) {
claims := jwt.MapClaims{
"user_id": user.ID.String(),
"email": user.Email,
"role": user.Role,
"tenant_id": nil,
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days
}
if user.TenantID != nil {
claims["tenant_id"] = user.TenantID.String()
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(s.cfg.JWT.Secret))
}
// ChangePassword changes a user's password
func (s *AuthService) ChangePassword(userID string, currentPassword, newPassword string) error {
// Validate new password strength
if len(newPassword) < s.cfg.Security.PasswordMinLength {
return ErrWeakPassword
}
// Parse userID
uid, err := parseUUID(userID)
if err != nil {
return ErrInvalidCredentials
}
// Find user
user, err := s.userRepo.FindByID(uid)
if err != nil {
return err
}
if user == nil {
return ErrInvalidCredentials
}
// Verify current password
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(currentPassword)); err != nil {
return ErrInvalidCredentials
}
// Hash new password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
// Update password
return s.userRepo.UpdatePassword(userID, string(hashedPassword))
}
func parseUUID(s string) (uuid.UUID, error) {
return uuid.Parse(s)
}
// GenerateCustomerToken gera um token JWT para um cliente do CRM
func (s *AuthService) GenerateCustomerToken(customerID, tenantID, email string) (string, error) {
claims := jwt.MapClaims{
"customer_id": customerID,
"tenant_id": tenantID,
"email": email,
"type": "customer_portal",
"exp": time.Now().Add(time.Hour * 24 * 30).Unix(), // 30 dias
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(s.cfg.JWT.Secret))
}
// UnifiedLogin autentica qualquer tipo de usuário (agência ou cliente) e retorna token unificado
func (s *AuthService) UnifiedLogin(req domain.UnifiedLoginRequest) (*domain.UnifiedLoginResponse, error) {
email := req.Email
password := req.Password
// TENTATIVA 1: Buscar em users (agência)
user, err := s.userRepo.FindByEmail(email)
if err == nil && user != nil {
// Verificar senha
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
log.Printf("❌ Password mismatch for agency user %s", email)
return nil, ErrInvalidCredentials
}
// SUPERADMIN usa login próprio em outro domínio, não deve usar esta rota
if user.Role == "SUPERADMIN" {
log.Printf("🚫 SUPERADMIN attempted unified login - redirecting to proper endpoint")
return nil, errors.New("superadmins devem usar o painel administrativo")
}
// Gerar token unificado para agency_user
token, err := s.generateUnifiedToken(user.ID.String(), domain.UserTypeAgency, email, user.Role, user.AgencyRole, user.TenantID)
if err != nil {
log.Printf("❌ Error generating unified token: %v", err)
return nil, err
}
// Buscar subdomain se tiver tenant
subdomain := ""
tenantID := ""
if user.TenantID != nil {
tenantID = user.TenantID.String()
tenant, err := s.tenantRepo.FindByID(*user.TenantID)
if err == nil && tenant != nil {
subdomain = tenant.Subdomain
}
}
log.Printf("✅ Agency user logged in: %s (type=agency_user, role=%s, agency_role=%s)", email, user.Role, user.AgencyRole)
return &domain.UnifiedLoginResponse{
Token: token,
UserType: domain.UserTypeAgency,
UserID: user.ID.String(),
Email: email,
Name: user.Name,
Role: user.Role,
AgencyRole: user.AgencyRole,
TenantID: tenantID,
Subdomain: subdomain,
}, nil
}
// TENTATIVA 2: Buscar em crm_customers
log.Printf("🔍 Attempting to find customer in CRM: %s", email)
customer, err := s.crmRepo.GetCustomerByEmail(email)
log.Printf("🔍 CRM GetCustomerByEmail result: customer=%v, err=%v", customer != nil, err)
if err == nil && customer != nil {
// Verificar se tem acesso ao portal
if !customer.HasPortalAccess {
log.Printf("🚫 Customer %s has no portal access", email)
return nil, errors.New("acesso ao portal não autorizado. Entre em contato com o administrador")
}
// Verificar senha
if customer.PasswordHash == "" {
log.Printf("❌ Customer %s has no password set", email)
return nil, ErrInvalidCredentials
}
if err := bcrypt.CompareHashAndPassword([]byte(customer.PasswordHash), []byte(password)); err != nil {
log.Printf("❌ Password mismatch for customer %s", email)
return nil, ErrInvalidCredentials
}
// Atualizar último login
if err := s.crmRepo.UpdateCustomerLastLogin(customer.ID); err != nil {
log.Printf("⚠️ Warning: Failed to update last login for customer %s: %v", customer.ID, err)
}
// Gerar token unificado
tenantUUID, _ := uuid.Parse(customer.TenantID)
token, err := s.generateUnifiedToken(customer.ID, domain.UserTypeCustomer, email, "", "", &tenantUUID)
if err != nil {
log.Printf("❌ Error generating unified token: %v", err)
return nil, err
}
// Buscar subdomain do tenant
subdomain := ""
if tenantUUID != uuid.Nil {
tenant, err := s.tenantRepo.FindByID(tenantUUID)
if err == nil && tenant != nil {
subdomain = tenant.Subdomain
}
}
log.Printf("✅ Customer logged in: %s (tenant=%s)", email, customer.TenantID)
return &domain.UnifiedLoginResponse{
Token: token,
UserType: domain.UserTypeCustomer,
UserID: customer.ID,
Email: email,
Name: customer.Name,
TenantID: customer.TenantID,
Subdomain: subdomain,
}, nil
}
// Não encontrou em nenhuma tabela
log.Printf("❌ User not found: %s", email)
return nil, ErrInvalidCredentials
}
// generateUnifiedToken cria um JWT com claims unificadas
func (s *AuthService) generateUnifiedToken(userID string, userType domain.UserType, email, role, agencyRole string, tenantID *uuid.UUID) (string, error) {
tenantIDStr := ""
if tenantID != nil {
tenantIDStr = tenantID.String()
}
claims := domain.UnifiedClaims{
UserID: userID,
UserType: userType,
TenantID: tenantIDStr,
Email: email,
Role: role,
AgencyRole: agencyRole,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24 * 30)), // 30 dias
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(s.cfg.JWT.Secret))
}

View File

@@ -0,0 +1,73 @@
package service
import (
"errors"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/repository"
"github.com/google/uuid"
)
var (
ErrCompanyNotFound = errors.New("company not found")
ErrCNPJAlreadyExists = errors.New("CNPJ already registered")
)
// CompanyService handles company business logic
type CompanyService struct {
companyRepo *repository.CompanyRepository
}
// NewCompanyService creates a new company service
func NewCompanyService(companyRepo *repository.CompanyRepository) *CompanyService {
return &CompanyService{
companyRepo: companyRepo,
}
}
// Create creates a new company
func (s *CompanyService) Create(req domain.CreateCompanyRequest, tenantID, userID uuid.UUID) (*domain.Company, error) {
// Check if CNPJ already exists for this tenant
exists, err := s.companyRepo.CNPJExists(req.CNPJ, tenantID)
if err != nil {
return nil, err
}
if exists {
return nil, ErrCNPJAlreadyExists
}
company := &domain.Company{
CNPJ: req.CNPJ,
RazaoSocial: req.RazaoSocial,
NomeFantasia: req.NomeFantasia,
Email: req.Email,
Telefone: req.Telefone,
Status: "active",
TenantID: tenantID,
CreatedByUserID: &userID,
}
if err := s.companyRepo.Create(company); err != nil {
return nil, err
}
return company, nil
}
// GetByID retrieves a company by ID
func (s *CompanyService) GetByID(id uuid.UUID) (*domain.Company, error) {
company, err := s.companyRepo.FindByID(id)
if err != nil {
return nil, err
}
if company == nil {
return nil, ErrCompanyNotFound
}
return company, nil
}
// ListByTenant retrieves all companies for a tenant
func (s *CompanyService) ListByTenant(tenantID uuid.UUID) ([]*domain.Company, error) {
return s.companyRepo.FindByTenantID(tenantID)
}

View File

@@ -0,0 +1,286 @@
package service
import (
"database/sql"
"errors"
"fmt"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/repository"
"github.com/google/uuid"
"github.com/shopspring/decimal"
)
var (
ErrPlanNotFound = errors.New("plan not found")
ErrPlanSlugTaken = errors.New("plan slug already exists")
ErrInvalidUserRange = errors.New("invalid user range: min_users must be less than or equal to max_users")
ErrSubscriptionNotFound = errors.New("subscription not found")
ErrUserLimitExceeded = errors.New("user limit exceeded for this plan")
ErrSubscriptionExists = errors.New("agency already has an active subscription")
)
// PlanService handles plan business logic
type PlanService struct {
planRepo *repository.PlanRepository
subscriptionRepo *repository.SubscriptionRepository
}
// NewPlanService creates a new plan service
func NewPlanService(planRepo *repository.PlanRepository, subscriptionRepo *repository.SubscriptionRepository) *PlanService {
return &PlanService{
planRepo: planRepo,
subscriptionRepo: subscriptionRepo,
}
}
// CreatePlan creates a new plan
func (s *PlanService) CreatePlan(req *domain.CreatePlanRequest) (*domain.Plan, error) {
// Validate user range
if req.MinUsers > req.MaxUsers && req.MaxUsers != -1 {
return nil, ErrInvalidUserRange
}
// Check if slug is unique
existing, _ := s.planRepo.GetBySlug(req.Slug)
if existing != nil {
return nil, ErrPlanSlugTaken
}
plan := &domain.Plan{
Name: req.Name,
Slug: req.Slug,
Description: req.Description,
MinUsers: req.MinUsers,
MaxUsers: req.MaxUsers,
Features: req.Features,
Differentiators: req.Differentiators,
StorageGB: req.StorageGB,
IsActive: req.IsActive,
}
// Convert prices if provided
if req.MonthlyPrice != nil {
price := decimal.NewFromFloat(*req.MonthlyPrice)
plan.MonthlyPrice = &price
}
if req.AnnualPrice != nil {
price := decimal.NewFromFloat(*req.AnnualPrice)
plan.AnnualPrice = &price
}
if err := s.planRepo.Create(plan); err != nil {
return nil, err
}
return plan, nil
}
// GetPlan retrieves a plan by ID
func (s *PlanService) GetPlan(id uuid.UUID) (*domain.Plan, error) {
plan, err := s.planRepo.GetByID(id)
if err != nil {
if err == sql.ErrNoRows {
return nil, ErrPlanNotFound
}
return nil, err
}
return plan, nil
}
// ListPlans retrieves all plans
func (s *PlanService) ListPlans() ([]*domain.Plan, error) {
return s.planRepo.ListAll()
}
// ListActivePlans retrieves all active plans
func (s *PlanService) ListActivePlans() ([]*domain.Plan, error) {
return s.planRepo.ListActive()
}
// UpdatePlan updates a plan
func (s *PlanService) UpdatePlan(id uuid.UUID, req *domain.UpdatePlanRequest) (*domain.Plan, error) {
plan, err := s.planRepo.GetByID(id)
if err != nil {
if err == sql.ErrNoRows {
return nil, ErrPlanNotFound
}
return nil, err
}
// Update fields if provided
if req.Name != nil {
plan.Name = *req.Name
}
if req.Slug != nil {
// Check if new slug is unique
existing, _ := s.planRepo.GetBySlug(*req.Slug)
if existing != nil && existing.ID != plan.ID {
return nil, ErrPlanSlugTaken
}
plan.Slug = *req.Slug
}
if req.Description != nil {
plan.Description = *req.Description
}
if req.MinUsers != nil {
plan.MinUsers = *req.MinUsers
}
if req.MaxUsers != nil {
plan.MaxUsers = *req.MaxUsers
}
if req.MonthlyPrice != nil {
price := decimal.NewFromFloat(*req.MonthlyPrice)
plan.MonthlyPrice = &price
}
if req.AnnualPrice != nil {
price := decimal.NewFromFloat(*req.AnnualPrice)
plan.AnnualPrice = &price
}
if req.Features != nil {
plan.Features = req.Features
}
if req.Differentiators != nil {
plan.Differentiators = req.Differentiators
}
if req.StorageGB != nil {
plan.StorageGB = *req.StorageGB
}
if req.IsActive != nil {
plan.IsActive = *req.IsActive
}
// Validate user range
if plan.MinUsers > plan.MaxUsers && plan.MaxUsers != -1 {
return nil, ErrInvalidUserRange
}
if err := s.planRepo.Update(plan); err != nil {
return nil, err
}
return plan, nil
}
// DeletePlan deletes a plan
func (s *PlanService) DeletePlan(id uuid.UUID) error {
// Check if plan exists
if _, err := s.planRepo.GetByID(id); err != nil {
if err == sql.ErrNoRows {
return ErrPlanNotFound
}
return err
}
return s.planRepo.Delete(id)
}
// CreateSubscription creates a new subscription for an agency
func (s *PlanService) CreateSubscription(req *domain.CreateSubscriptionRequest) (*domain.Subscription, error) {
// Check if plan exists
plan, err := s.planRepo.GetByID(req.PlanID)
if err != nil {
if err == sql.ErrNoRows {
return nil, ErrPlanNotFound
}
return nil, err
}
// Check if agency already has active subscription
existing, err := s.subscriptionRepo.GetByAgencyID(req.AgencyID)
if err != nil && err != sql.ErrNoRows {
return nil, err
}
if existing != nil {
return nil, ErrSubscriptionExists
}
subscription := &domain.Subscription{
AgencyID: req.AgencyID,
PlanID: req.PlanID,
BillingType: req.BillingType,
Status: "active",
CurrentUsers: 0,
}
if err := s.subscriptionRepo.Create(subscription); err != nil {
return nil, err
}
// Load plan details
subscription.PlanID = plan.ID
return subscription, nil
}
// GetSubscription retrieves a subscription by ID
func (s *PlanService) GetSubscription(id uuid.UUID) (*domain.Subscription, error) {
subscription, err := s.subscriptionRepo.GetByID(id)
if err != nil {
if err == sql.ErrNoRows {
return nil, ErrSubscriptionNotFound
}
return nil, err
}
return subscription, nil
}
// GetAgencySubscription retrieves an agency's active subscription
func (s *PlanService) GetAgencySubscription(agencyID uuid.UUID) (*domain.Subscription, error) {
subscription, err := s.subscriptionRepo.GetByAgencyID(agencyID)
if err != nil {
if err == sql.ErrNoRows {
return nil, ErrSubscriptionNotFound
}
return nil, err
}
return subscription, nil
}
// ListSubscriptions retrieves all subscriptions
func (s *PlanService) ListSubscriptions() ([]*domain.Subscription, error) {
return s.subscriptionRepo.ListAll()
}
// ValidateUserLimit checks if adding a user would exceed plan limit
func (s *PlanService) ValidateUserLimit(agencyID uuid.UUID, newUserCount int) error {
subscription, err := s.subscriptionRepo.GetByAgencyID(agencyID)
if err != nil {
if err == sql.ErrNoRows {
return ErrSubscriptionNotFound
}
return err
}
plan, err := s.planRepo.GetByID(subscription.PlanID)
if err != nil {
if err == sql.ErrNoRows {
return ErrPlanNotFound
}
return err
}
if plan.MaxUsers != -1 && newUserCount > plan.MaxUsers {
return fmt.Errorf("%w (limit: %d, requested: %d)", ErrUserLimitExceeded, plan.MaxUsers, newUserCount)
}
return nil
}
// GetPlanByUserCount returns the appropriate plan for a given user count
func (s *PlanService) GetPlanByUserCount(userCount int) (*domain.Plan, error) {
plans, err := s.planRepo.ListActive()
if err != nil {
return nil, err
}
// Find the plan that fits the user count
for _, plan := range plans {
if userCount >= plan.MinUsers && (plan.MaxUsers == -1 || userCount <= plan.MaxUsers) {
return plan, nil
}
}
return nil, fmt.Errorf("no plan found for user count: %d", userCount)
}

View File

@@ -0,0 +1,171 @@
package service
import (
"database/sql"
"errors"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/repository"
"github.com/google/uuid"
)
var (
ErrTenantNotFound = errors.New("tenant not found")
)
// TenantService handles tenant business logic
type TenantService struct {
tenantRepo *repository.TenantRepository
db *sql.DB
}
// NewTenantService creates a new tenant service
func NewTenantService(tenantRepo *repository.TenantRepository, db *sql.DB) *TenantService {
return &TenantService{
tenantRepo: tenantRepo,
db: db,
}
}
// Create creates a new tenant
func (s *TenantService) Create(req domain.CreateTenantRequest) (*domain.Tenant, error) {
// Check if subdomain already exists
exists, err := s.tenantRepo.SubdomainExists(req.Subdomain)
if err != nil {
return nil, err
}
if exists {
return nil, ErrSubdomainTaken
}
tenant := &domain.Tenant{
Name: req.Name,
Domain: req.Domain,
Subdomain: req.Subdomain,
}
if err := s.tenantRepo.Create(tenant); err != nil {
return nil, err
}
return tenant, nil
}
// GetByID retrieves a tenant by ID
func (s *TenantService) GetByID(id uuid.UUID) (*domain.Tenant, error) {
tenant, err := s.tenantRepo.FindByID(id)
if err != nil {
return nil, err
}
if tenant == nil {
return nil, ErrTenantNotFound
}
return tenant, nil
}
// GetBySubdomain retrieves a tenant by subdomain
func (s *TenantService) GetBySubdomain(subdomain string) (*domain.Tenant, error) {
tenant, err := s.tenantRepo.FindBySubdomain(subdomain)
if err != nil {
return nil, err
}
if tenant == nil {
return nil, ErrTenantNotFound
}
return tenant, nil
}
// ListAll retrieves all tenants
func (s *TenantService) ListAll() ([]*domain.Tenant, error) {
return s.tenantRepo.FindAll()
}
// ListAllWithDetails retrieves all tenants with their plan and solutions information
func (s *TenantService) ListAllWithDetails() ([]map[string]interface{}, error) {
tenants, err := s.tenantRepo.FindAll()
if err != nil {
return nil, err
}
var result []map[string]interface{}
for _, tenant := range tenants {
tenantData := map[string]interface{}{
"id": tenant.ID,
"name": tenant.Name,
"subdomain": tenant.Subdomain,
"domain": tenant.Domain,
"email": tenant.Email,
"phone": tenant.Phone,
"cnpj": tenant.CNPJ,
"is_active": tenant.IsActive,
"created_at": tenant.CreatedAt,
"logo_url": tenant.LogoURL,
"logo_horizontal_url": tenant.LogoHorizontalURL,
"primary_color": tenant.PrimaryColor,
"secondary_color": tenant.SecondaryColor,
}
// Buscar subscription e soluções
var planName sql.NullString
var planID string
query := `
SELECT
s.plan_id,
p.name as plan_name
FROM agency_subscriptions s
JOIN plans p ON s.plan_id = p.id
WHERE s.agency_id = $1 AND s.status = 'active'
LIMIT 1
`
err = s.db.QueryRow(query, tenant.ID).Scan(&planID, &planName)
if err == nil && planName.Valid {
tenantData["plan_name"] = planName.String
// Buscar soluções do plano
solutionsQuery := `
SELECT sol.id, sol.name, sol.slug, sol.icon
FROM solutions sol
JOIN plan_solutions ps ON sol.id = ps.solution_id
WHERE ps.plan_id = $1
ORDER BY sol.name
`
rows, err := s.db.Query(solutionsQuery, planID)
if err == nil {
defer rows.Close()
var solutions []map[string]interface{}
for rows.Next() {
var id, name, slug string
var icon sql.NullString
if err := rows.Scan(&id, &name, &slug, &icon); err == nil {
solution := map[string]interface{}{
"id": id,
"name": name,
"slug": slug,
}
if icon.Valid {
solution["icon"] = icon.String
}
solutions = append(solutions, solution)
}
}
tenantData["solutions"] = solutions
}
}
result = append(result, tenantData)
}
return result, nil
}
// Delete removes a tenant by ID
func (s *TenantService) Delete(id uuid.UUID) error {
if err := s.tenantRepo.Delete(id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrTenantNotFound
}
return err
}
return nil
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,343 @@
--
-- PostgreSQL database dump
--
\restrict mUKTWCYeXvRf2SKhMr352J1jYiouAP5fsYPxvQjxn9xhEgk8BrOSEtYCYQoFicQ
-- Dumped from database version 16.11
-- Dumped by pg_dump version 18.1
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
SET transaction_timeout = 0;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
SELECT pg_catalog.set_config('search_path', '', false);
SET check_function_bodies = false;
SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
--
-- Name: pgcrypto; Type: EXTENSION; Schema: -; Owner: -
--
CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public;
--
-- Name: EXTENSION pgcrypto; Type: COMMENT; Schema: -; Owner:
--
COMMENT ON EXTENSION pgcrypto IS 'cryptographic functions';
--
-- Name: uuid-ossp; Type: EXTENSION; Schema: -; Owner: -
--
CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA public;
--
-- Name: EXTENSION "uuid-ossp"; Type: COMMENT; Schema: -; Owner:
--
COMMENT ON EXTENSION "uuid-ossp" IS 'generate universally unique identifiers (UUIDs)';
SET default_tablespace = '';
SET default_table_access_method = heap;
--
-- Name: companies; Type: TABLE; Schema: public; Owner: aggios
--
CREATE TABLE public.companies (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
cnpj character varying(18) NOT NULL,
razao_social character varying(255) NOT NULL,
nome_fantasia character varying(255),
email character varying(255),
telefone character varying(20),
status character varying(50) DEFAULT 'active'::character varying,
created_by_user_id uuid,
created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE public.companies OWNER TO aggios;
--
-- Name: refresh_tokens; Type: TABLE; Schema: public; Owner: aggios
--
CREATE TABLE public.refresh_tokens (
id uuid DEFAULT gen_random_uuid() NOT NULL,
user_id uuid NOT NULL,
token_hash character varying(255) NOT NULL,
expires_at timestamp with time zone NOT NULL,
created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE public.refresh_tokens OWNER TO aggios;
--
-- Name: tenants; Type: TABLE; Schema: public; Owner: aggios
--
CREATE TABLE public.tenants (
id uuid DEFAULT gen_random_uuid() NOT NULL,
name character varying(255) NOT NULL,
domain character varying(255) NOT NULL,
subdomain character varying(63) NOT NULL,
cnpj character varying(18),
razao_social character varying(255),
email character varying(255),
phone character varying(20),
website character varying(255),
address text,
city character varying(100),
state character varying(2),
zip character varying(10),
description text,
industry character varying(100),
is_active boolean DEFAULT true,
created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
neighborhood character varying(100),
street character varying(100),
number character varying(20),
complement character varying(100),
team_size character varying(20),
primary_color character varying(7),
secondary_color character varying(7),
logo_url text,
logo_horizontal_url text
);
ALTER TABLE public.tenants OWNER TO aggios;
--
-- Name: users; Type: TABLE; Schema: public; Owner: aggios
--
CREATE TABLE public.users (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid,
email character varying(255) NOT NULL,
password_hash character varying(255) NOT NULL,
first_name character varying(128),
last_name character varying(128),
role character varying(50) DEFAULT 'CLIENTE'::character varying,
is_active boolean DEFAULT true,
created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT users_role_check CHECK (((role)::text = ANY ((ARRAY['SUPERADMIN'::character varying, 'ADMIN_AGENCIA'::character varying, 'CLIENTE'::character varying])::text[])))
);
ALTER TABLE public.users OWNER TO aggios;
--
-- Data for Name: companies; Type: TABLE DATA; Schema: public; Owner: aggios
--
COPY public.companies (id, tenant_id, cnpj, razao_social, nome_fantasia, email, telefone, status, created_by_user_id, created_at, updated_at) FROM stdin;
\.
--
-- Data for Name: refresh_tokens; Type: TABLE DATA; Schema: public; Owner: aggios
--
COPY public.refresh_tokens (id, user_id, token_hash, expires_at, created_at) FROM stdin;
\.
--
-- Data for Name: tenants; Type: TABLE DATA; Schema: public; Owner: aggios
--
COPY public.tenants (id, name, domain, subdomain, cnpj, razao_social, email, phone, website, address, city, state, zip, description, industry, is_active, created_at, updated_at, neighborhood, street, number, complement, team_size, primary_color, secondary_color, logo_url, logo_horizontal_url) FROM stdin;
d351e725-1428-45f3-b2e3-ca767e9b952c Agência Teste agencia-teste.aggios.app agencia-teste \N \N \N \N \N \N \N \N \N \N \N t 2025-12-13 22:31:35.818953+00 2025-12-13 22:31:35.818953+00 \N \N \N \N \N \N \N \N \N
13d32cc3-0490-4557-96a3-7a38da194185 Empresa Teste teste-empresa.localhost teste-empresa 12.345.678/0001-90 EMPRESA TESTE LTDA teste@teste.com (11) 99999-9999 teste.com.br Avenida Paulista, 1000 - Andar 10 S<EFBFBD>o Paulo SP 01310-100 Empresa de teste tecnologia t 2025-12-13 23:22:58.406376+00 2025-12-13 23:22:58.406376+00 Bela Vista \N 1000 Andar 10 1-10 #8B5CF6 #A78BFA
ae271be0-a63c-407f-9cf6-a4a8c8a0a4dc IdealPages idealpages.localhost idealpages 31.091.190/0001-23 ERIK DA SILVA SANTOS 36615318830 erik@idealpages.com.br (13) 92000-4392 idealpages.com.br Rua Quatorze, 150 - Casa Guarujá SP 11436-575 Empresa de contrucao de marca e desenvolvimento de software agencia-digital t 2025-12-13 23:23:35.508285+00 2025-12-13 23:26:40.947714+00 Vila Zilda \N 150 Casa 1-10 #8B5CF6 #A78BFA http://api.localhost/api/files/aggios-logos/tenants/ae271be0-a63c-407f-9cf6-a4a8c8a0a4dc/logo-1765668400.png
\.
--
-- Data for Name: users; Type: TABLE DATA; Schema: public; Owner: aggios
--
COPY public.users (id, tenant_id, email, password_hash, first_name, last_name, role, is_active, created_at, updated_at) FROM stdin;
7b51ae6e-6fb0-42c4-8473-a98cbfcda6a4 \N admin@aggios.app $2a$10$yhCREFqXL7FA4zveCFcl4eYODNTSyt/swuYjS0nXkEq8pzqJo.BwO Super Admin SUPERADMIN t 2025-12-13 23:02:33.124444+00 2025-12-13 23:02:33.124444+00
488351e7-4ddc-41a4-9cd3-5c3dec833c44 13d32cc3-0490-4557-96a3-7a38da194185 teste@teste.com $2a$10$fx3bQqL01A9UqJwSwKpdLuVCq8M/1L9CvcQhx5tTkdinsvCpPsh4a Teste Silva \N ADMIN_AGENCIA t 2025-12-13 23:22:58.446011+00 2025-12-13 23:22:58.446011+00
8742c1a1-5f1a-4df3-aa53-dcf94a2a2591 ae271be0-a63c-407f-9cf6-a4a8c8a0a4dc erik@idealpages.com.br $2a$10$tD8Kq/ZW0fbmW3Ga5JsKbOUy0nzsIZwkXJKaf43gFDVnRxjaf63Em Erik da Silva Santos \N ADMIN_AGENCIA t 2025-12-13 23:23:35.551192+00 2025-12-13 23:23:35.551192+00
\.
--
-- Name: companies companies_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios
--
ALTER TABLE ONLY public.companies
ADD CONSTRAINT companies_pkey PRIMARY KEY (id);
--
-- Name: companies companies_tenant_id_cnpj_key; Type: CONSTRAINT; Schema: public; Owner: aggios
--
ALTER TABLE ONLY public.companies
ADD CONSTRAINT companies_tenant_id_cnpj_key UNIQUE (tenant_id, cnpj);
--
-- Name: refresh_tokens refresh_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios
--
ALTER TABLE ONLY public.refresh_tokens
ADD CONSTRAINT refresh_tokens_pkey PRIMARY KEY (id);
--
-- Name: tenants tenants_domain_key; Type: CONSTRAINT; Schema: public; Owner: aggios
--
ALTER TABLE ONLY public.tenants
ADD CONSTRAINT tenants_domain_key UNIQUE (domain);
--
-- Name: tenants tenants_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios
--
ALTER TABLE ONLY public.tenants
ADD CONSTRAINT tenants_pkey PRIMARY KEY (id);
--
-- Name: tenants tenants_subdomain_key; Type: CONSTRAINT; Schema: public; Owner: aggios
--
ALTER TABLE ONLY public.tenants
ADD CONSTRAINT tenants_subdomain_key UNIQUE (subdomain);
--
-- Name: users users_email_key; Type: CONSTRAINT; Schema: public; Owner: aggios
--
ALTER TABLE ONLY public.users
ADD CONSTRAINT users_email_key UNIQUE (email);
--
-- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios
--
ALTER TABLE ONLY public.users
ADD CONSTRAINT users_pkey PRIMARY KEY (id);
--
-- Name: idx_companies_cnpj; Type: INDEX; Schema: public; Owner: aggios
--
CREATE INDEX idx_companies_cnpj ON public.companies USING btree (cnpj);
--
-- Name: idx_companies_tenant_id; Type: INDEX; Schema: public; Owner: aggios
--
CREATE INDEX idx_companies_tenant_id ON public.companies USING btree (tenant_id);
--
-- Name: idx_refresh_tokens_expires_at; Type: INDEX; Schema: public; Owner: aggios
--
CREATE INDEX idx_refresh_tokens_expires_at ON public.refresh_tokens USING btree (expires_at);
--
-- Name: idx_refresh_tokens_user_id; Type: INDEX; Schema: public; Owner: aggios
--
CREATE INDEX idx_refresh_tokens_user_id ON public.refresh_tokens USING btree (user_id);
--
-- Name: idx_tenants_domain; Type: INDEX; Schema: public; Owner: aggios
--
CREATE INDEX idx_tenants_domain ON public.tenants USING btree (domain);
--
-- Name: idx_tenants_subdomain; Type: INDEX; Schema: public; Owner: aggios
--
CREATE INDEX idx_tenants_subdomain ON public.tenants USING btree (subdomain);
--
-- Name: idx_users_email; Type: INDEX; Schema: public; Owner: aggios
--
CREATE INDEX idx_users_email ON public.users USING btree (email);
--
-- Name: idx_users_tenant_id; Type: INDEX; Schema: public; Owner: aggios
--
CREATE INDEX idx_users_tenant_id ON public.users USING btree (tenant_id);
--
-- Name: companies companies_created_by_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios
--
ALTER TABLE ONLY public.companies
ADD CONSTRAINT companies_created_by_user_id_fkey FOREIGN KEY (created_by_user_id) REFERENCES public.users(id);
--
-- Name: companies companies_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios
--
ALTER TABLE ONLY public.companies
ADD CONSTRAINT companies_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
--
-- Name: refresh_tokens refresh_tokens_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios
--
ALTER TABLE ONLY public.refresh_tokens
ADD CONSTRAINT refresh_tokens_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
--
-- Name: users users_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios
--
ALTER TABLE ONLY public.users
ADD CONSTRAINT users_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
--
-- PostgreSQL database dump complete
--
\unrestrict mUKTWCYeXvRf2SKhMr352J1jYiouAP5fsYPxvQjxn9xhEgk8BrOSEtYCYQoFicQ

View File

@@ -0,0 +1,343 @@
--
-- PostgreSQL database dump
--
\restrict ZSl79LbDN89EVihiEgzYdjR8EV38YLVYgKFBBZX4jKNuTBgFyc2DCZ8bFM5F42n
-- Dumped from database version 16.11
-- Dumped by pg_dump version 18.1
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
SET transaction_timeout = 0;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
SELECT pg_catalog.set_config('search_path', '', false);
SET check_function_bodies = false;
SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
--
-- Name: pgcrypto; Type: EXTENSION; Schema: -; Owner: -
--
CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public;
--
-- Name: EXTENSION pgcrypto; Type: COMMENT; Schema: -; Owner:
--
COMMENT ON EXTENSION pgcrypto IS 'cryptographic functions';
--
-- Name: uuid-ossp; Type: EXTENSION; Schema: -; Owner: -
--
CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA public;
--
-- Name: EXTENSION "uuid-ossp"; Type: COMMENT; Schema: -; Owner:
--
COMMENT ON EXTENSION "uuid-ossp" IS 'generate universally unique identifiers (UUIDs)';
SET default_tablespace = '';
SET default_table_access_method = heap;
--
-- Name: companies; Type: TABLE; Schema: public; Owner: aggios
--
CREATE TABLE public.companies (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
cnpj character varying(18) NOT NULL,
razao_social character varying(255) NOT NULL,
nome_fantasia character varying(255),
email character varying(255),
telefone character varying(20),
status character varying(50) DEFAULT 'active'::character varying,
created_by_user_id uuid,
created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE public.companies OWNER TO aggios;
--
-- Name: refresh_tokens; Type: TABLE; Schema: public; Owner: aggios
--
CREATE TABLE public.refresh_tokens (
id uuid DEFAULT gen_random_uuid() NOT NULL,
user_id uuid NOT NULL,
token_hash character varying(255) NOT NULL,
expires_at timestamp with time zone NOT NULL,
created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE public.refresh_tokens OWNER TO aggios;
--
-- Name: tenants; Type: TABLE; Schema: public; Owner: aggios
--
CREATE TABLE public.tenants (
id uuid DEFAULT gen_random_uuid() NOT NULL,
name character varying(255) NOT NULL,
domain character varying(255) NOT NULL,
subdomain character varying(63) NOT NULL,
cnpj character varying(18),
razao_social character varying(255),
email character varying(255),
phone character varying(20),
website character varying(255),
address text,
city character varying(100),
state character varying(2),
zip character varying(10),
description text,
industry character varying(100),
is_active boolean DEFAULT true,
created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
neighborhood character varying(100),
street character varying(100),
number character varying(20),
complement character varying(100),
team_size character varying(20),
primary_color character varying(7),
secondary_color character varying(7),
logo_url text,
logo_horizontal_url text
);
ALTER TABLE public.tenants OWNER TO aggios;
--
-- Name: users; Type: TABLE; Schema: public; Owner: aggios
--
CREATE TABLE public.users (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid,
email character varying(255) NOT NULL,
password_hash character varying(255) NOT NULL,
first_name character varying(128),
last_name character varying(128),
role character varying(50) DEFAULT 'CLIENTE'::character varying,
is_active boolean DEFAULT true,
created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT users_role_check CHECK (((role)::text = ANY ((ARRAY['SUPERADMIN'::character varying, 'ADMIN_AGENCIA'::character varying, 'CLIENTE'::character varying])::text[])))
);
ALTER TABLE public.users OWNER TO aggios;
--
-- Data for Name: companies; Type: TABLE DATA; Schema: public; Owner: aggios
--
COPY public.companies (id, tenant_id, cnpj, razao_social, nome_fantasia, email, telefone, status, created_by_user_id, created_at, updated_at) FROM stdin;
\.
--
-- Data for Name: refresh_tokens; Type: TABLE DATA; Schema: public; Owner: aggios
--
COPY public.refresh_tokens (id, user_id, token_hash, expires_at, created_at) FROM stdin;
\.
--
-- Data for Name: tenants; Type: TABLE DATA; Schema: public; Owner: aggios
--
COPY public.tenants (id, name, domain, subdomain, cnpj, razao_social, email, phone, website, address, city, state, zip, description, industry, is_active, created_at, updated_at, neighborhood, street, number, complement, team_size, primary_color, secondary_color, logo_url, logo_horizontal_url) FROM stdin;
d351e725-1428-45f3-b2e3-ca767e9b952c Agência Teste agencia-teste.aggios.app agencia-teste \N \N \N \N \N \N \N \N \N \N \N t 2025-12-13 22:31:35.818953+00 2025-12-13 22:31:35.818953+00 \N \N \N \N \N \N \N \N \N
13d32cc3-0490-4557-96a3-7a38da194185 Empresa Teste teste-empresa.localhost teste-empresa 12.345.678/0001-90 EMPRESA TESTE LTDA teste@teste.com (11) 99999-9999 teste.com.br Avenida Paulista, 1000 - Andar 10 S<EFBFBD>o Paulo SP 01310-100 Empresa de teste tecnologia t 2025-12-13 23:22:58.406376+00 2025-12-13 23:22:58.406376+00 Bela Vista \N 1000 Andar 10 1-10 #8B5CF6 #A78BFA
ae271be0-a63c-407f-9cf6-a4a8c8a0a4dc IdealPages idealpages.localhost idealpages 31.091.190/0001-23 ERIK DA SILVA SANTOS 36615318830 erik@idealpages.com.br (13) 92000-4392 idealpages.com.br Rua Quatorze, 150 - Casa Guarujá SP 11436-575 Empresa de contrucao de marca e desenvolvimento de software agencia-digital t 2025-12-13 23:23:35.508285+00 2025-12-13 23:26:40.947714+00 Vila Zilda \N 150 Casa 1-10 #8B5CF6 #A78BFA http://api.localhost/api/files/aggios-logos/tenants/ae271be0-a63c-407f-9cf6-a4a8c8a0a4dc/logo-1765668400.png
\.
--
-- Data for Name: users; Type: TABLE DATA; Schema: public; Owner: aggios
--
COPY public.users (id, tenant_id, email, password_hash, first_name, last_name, role, is_active, created_at, updated_at) FROM stdin;
7b51ae6e-6fb0-42c4-8473-a98cbfcda6a4 \N admin@aggios.app $2a$10$yhCREFqXL7FA4zveCFcl4eYODNTSyt/swuYjS0nXkEq8pzqJo.BwO Super Admin SUPERADMIN t 2025-12-13 23:02:33.124444+00 2025-12-13 23:02:33.124444+00
488351e7-4ddc-41a4-9cd3-5c3dec833c44 13d32cc3-0490-4557-96a3-7a38da194185 teste@teste.com $2a$10$fx3bQqL01A9UqJwSwKpdLuVCq8M/1L9CvcQhx5tTkdinsvCpPsh4a Teste Silva \N ADMIN_AGENCIA t 2025-12-13 23:22:58.446011+00 2025-12-13 23:22:58.446011+00
8742c1a1-5f1a-4df3-aa53-dcf94a2a2591 ae271be0-a63c-407f-9cf6-a4a8c8a0a4dc erik@idealpages.com.br $2a$10$tD8Kq/ZW0fbmW3Ga5JsKbOUy0nzsIZwkXJKaf43gFDVnRxjaf63Em Erik da Silva Santos \N ADMIN_AGENCIA t 2025-12-13 23:23:35.551192+00 2025-12-13 23:23:35.551192+00
\.
--
-- Name: companies companies_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios
--
ALTER TABLE ONLY public.companies
ADD CONSTRAINT companies_pkey PRIMARY KEY (id);
--
-- Name: companies companies_tenant_id_cnpj_key; Type: CONSTRAINT; Schema: public; Owner: aggios
--
ALTER TABLE ONLY public.companies
ADD CONSTRAINT companies_tenant_id_cnpj_key UNIQUE (tenant_id, cnpj);
--
-- Name: refresh_tokens refresh_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios
--
ALTER TABLE ONLY public.refresh_tokens
ADD CONSTRAINT refresh_tokens_pkey PRIMARY KEY (id);
--
-- Name: tenants tenants_domain_key; Type: CONSTRAINT; Schema: public; Owner: aggios
--
ALTER TABLE ONLY public.tenants
ADD CONSTRAINT tenants_domain_key UNIQUE (domain);
--
-- Name: tenants tenants_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios
--
ALTER TABLE ONLY public.tenants
ADD CONSTRAINT tenants_pkey PRIMARY KEY (id);
--
-- Name: tenants tenants_subdomain_key; Type: CONSTRAINT; Schema: public; Owner: aggios
--
ALTER TABLE ONLY public.tenants
ADD CONSTRAINT tenants_subdomain_key UNIQUE (subdomain);
--
-- Name: users users_email_key; Type: CONSTRAINT; Schema: public; Owner: aggios
--
ALTER TABLE ONLY public.users
ADD CONSTRAINT users_email_key UNIQUE (email);
--
-- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios
--
ALTER TABLE ONLY public.users
ADD CONSTRAINT users_pkey PRIMARY KEY (id);
--
-- Name: idx_companies_cnpj; Type: INDEX; Schema: public; Owner: aggios
--
CREATE INDEX idx_companies_cnpj ON public.companies USING btree (cnpj);
--
-- Name: idx_companies_tenant_id; Type: INDEX; Schema: public; Owner: aggios
--
CREATE INDEX idx_companies_tenant_id ON public.companies USING btree (tenant_id);
--
-- Name: idx_refresh_tokens_expires_at; Type: INDEX; Schema: public; Owner: aggios
--
CREATE INDEX idx_refresh_tokens_expires_at ON public.refresh_tokens USING btree (expires_at);
--
-- Name: idx_refresh_tokens_user_id; Type: INDEX; Schema: public; Owner: aggios
--
CREATE INDEX idx_refresh_tokens_user_id ON public.refresh_tokens USING btree (user_id);
--
-- Name: idx_tenants_domain; Type: INDEX; Schema: public; Owner: aggios
--
CREATE INDEX idx_tenants_domain ON public.tenants USING btree (domain);
--
-- Name: idx_tenants_subdomain; Type: INDEX; Schema: public; Owner: aggios
--
CREATE INDEX idx_tenants_subdomain ON public.tenants USING btree (subdomain);
--
-- Name: idx_users_email; Type: INDEX; Schema: public; Owner: aggios
--
CREATE INDEX idx_users_email ON public.users USING btree (email);
--
-- Name: idx_users_tenant_id; Type: INDEX; Schema: public; Owner: aggios
--
CREATE INDEX idx_users_tenant_id ON public.users USING btree (tenant_id);
--
-- Name: companies companies_created_by_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios
--
ALTER TABLE ONLY public.companies
ADD CONSTRAINT companies_created_by_user_id_fkey FOREIGN KEY (created_by_user_id) REFERENCES public.users(id);
--
-- Name: companies companies_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios
--
ALTER TABLE ONLY public.companies
ADD CONSTRAINT companies_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
--
-- Name: refresh_tokens refresh_tokens_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios
--
ALTER TABLE ONLY public.refresh_tokens
ADD CONSTRAINT refresh_tokens_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
--
-- Name: users users_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios
--
ALTER TABLE ONLY public.users
ADD CONSTRAINT users_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
--
-- PostgreSQL database dump complete
--
\unrestrict ZSl79LbDN89EVihiEgzYdjR8EV38YLVYgKFBBZX4jKNuTBgFyc2DCZ8bFM5F42n

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -46,7 +46,7 @@ services:
POSTGRES_DB: ${DB_NAME:-aggios_db} POSTGRES_DB: ${DB_NAME:-aggios_db}
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
- ./postgres/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql - ./backend/internal/data/postgres/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql
healthcheck: healthcheck:
test: [ "CMD-SHELL", "pg_isready -U aggios -d aggios_db" ] test: [ "CMD-SHELL", "pg_isready -U aggios -d aggios_db" ]
interval: 10s interval: 10s

View File

@@ -1,21 +1,23 @@
services: services:
# Traefik - Reverse Proxy # Traefik - Reverse Proxy
traefik: traefik:
image: traefik:latest image: traefik:v3.2
container_name: aggios-traefik container_name: aggios-traefik
restart: unless-stopped restart: unless-stopped
command: command:
- "--api.insecure=true" - "--api.insecure=true"
- "--providers.docker=true" - "--providers.file.directory=/etc/traefik/dynamic"
- "--providers.docker.endpoint=tcp://host.docker.internal:2375" - "--providers.file.watch=true"
- "--providers.docker.exposedbydefault=false"
- "--providers.docker.network=aggios-network"
- "--entrypoints.web.address=:80" - "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443" - "--entrypoints.websecure.address=:443"
- "--log.level=DEBUG"
- "--accesslog=true"
ports: ports:
- "80:80" - "80:80"
- "443:443" - "443:443"
- "8080:8080" # Dashboard Traefik - "8080:8080" # Dashboard Traefik
volumes:
- ./traefik/dynamic:/etc/traefik/dynamic:ro
networks: networks:
- aggios-network - aggios-network
@@ -32,7 +34,7 @@ services:
POSTGRES_DB: aggios_db POSTGRES_DB: aggios_db
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
- ./postgres/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql - ./backend/internal/data/postgres/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql
healthcheck: healthcheck:
test: [ "CMD-SHELL", "pg_isready -U aggios -d aggios_db" ] test: [ "CMD-SHELL", "pg_isready -U aggios -d aggios_db" ]
interval: 10s interval: 10s
@@ -41,24 +43,6 @@ services:
networks: networks:
- aggios-network - aggios-network
# pgAdmin - PostgreSQL Web Interface
pgadmin:
image: dpage/pgadmin4:latest
container_name: aggios-pgadmin
restart: unless-stopped
ports:
- "5050:80"
environment:
PGADMIN_DEFAULT_EMAIL: admin@aggios.app
PGADMIN_DEFAULT_PASSWORD: admin123
PGADMIN_CONFIG_SERVER_MODE: 'False'
volumes:
- pgadmin_data:/var/lib/pgadmin
depends_on:
- postgres
networks:
- aggios-network
# Redis Cache # Redis Cache
redis: redis:
image: redis:7-alpine image: redis:7-alpine
@@ -81,9 +65,24 @@ services:
container_name: aggios-minio container_name: aggios-minio
restart: unless-stopped restart: unless-stopped
command: server /data --console-address ":9001" command: server /data --console-address ":9001"
labels:
- "traefik.enable=true"
# Router para acesso aos arquivos (API S3)
- "traefik.http.routers.minio.rule=Host(`files.aggios.local`) || Host(`files.localhost`)"
- "traefik.http.routers.minio.entrypoints=web"
- "traefik.http.routers.minio.priority=100" # Prioridade alta para evitar captura pelo wildcard
- "traefik.http.services.minio.loadbalancer.server.port=9000"
- "traefik.http.services.minio.loadbalancer.passhostheader=true"
# Router para o Console do MinIO
- "traefik.http.routers.minio-console.rule=Host(`minio.aggios.local`) || Host(`minio.localhost`)"
- "traefik.http.routers.minio-console.entrypoints=web"
- "traefik.http.routers.minio-console.priority=100"
- "traefik.http.services.minio-console.loadbalancer.server.port=9001"
environment: environment:
MINIO_ROOT_USER: minioadmin MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD:-M1n10_S3cur3_P@ss_2025!} MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD:-M1n10_S3cur3_P@ss_2025!}
MINIO_BROWSER_REDIRECT_URL: http://minio.localhost
MINIO_SERVER_URL: http://files.localhost
volumes: volumes:
- minio_data:/data - minio_data:/data
ports: ports:
@@ -105,12 +104,15 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: aggios-backend container_name: aggios-backend
restart: unless-stopped restart: unless-stopped
ports:
- "8085:8080"
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.backend.rule=Host(`api.aggios.local`) || Host(`api.localhost`)" - "traefik.http.routers.backend.rule=Host(`api.aggios.local`) || Host(`api.localhost`)"
- "traefik.http.routers.backend.entrypoints=web" - "traefik.http.routers.backend.entrypoints=web"
- "traefik.http.services.backend.loadbalancer.server.port=8080" - "traefik.http.services.backend.loadbalancer.server.port=8080"
environment: environment:
TZ: America/Sao_Paulo
SERVER_HOST: 0.0.0.0 SERVER_HOST: 0.0.0.0
SERVER_PORT: 8080 SERVER_PORT: 8080
JWT_SECRET: ${JWT_SECRET:-Th1s_1s_A_V3ry_S3cur3_JWT_S3cr3t_K3y_2025_Ch@ng3_In_Pr0d!} JWT_SECRET: ${JWT_SECRET:-Th1s_1s_A_V3ry_S3cur3_JWT_S3cr3t_K3y_2025_Ch@ng3_In_Pr0d!}
@@ -123,8 +125,11 @@ services:
REDIS_PORT: 6379 REDIS_PORT: 6379
REDIS_PASSWORD: ${REDIS_PASSWORD:-R3d1s_S3cur3_P@ss_2025!} REDIS_PASSWORD: ${REDIS_PASSWORD:-R3d1s_S3cur3_P@ss_2025!}
MINIO_ENDPOINT: minio:9000 MINIO_ENDPOINT: minio:9000
MINIO_PUBLIC_URL: http://files.localhost
MINIO_ROOT_USER: minioadmin MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD:-M1n10_S3cur3_P@ss_2025!} MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD:-M1n10_S3cur3_P@ss_2025!}
volumes:
- ./backups:/backups
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -138,7 +143,7 @@ services:
# Frontend - Institucional (aggios.app) # Frontend - Institucional (aggios.app)
institucional: institucional:
build: build:
context: ./front-end-aggios.app-institucional context: ./frontend-aggios.app
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: aggios-institucional container_name: aggios-institucional
restart: unless-stopped restart: unless-stopped
@@ -183,11 +188,34 @@ services:
networks: networks:
- aggios-network - aggios-network
# Frontend - Agency (tenant-only)
agency:
build:
context: ./front-end-agency
dockerfile: Dockerfile
container_name: aggios-agency
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.agency.rule=Host(`agency.aggios.local`) || Host(`agency.localhost`) || HostRegexp(`^.+\\.localhost$`)"
- "traefik.http.routers.agency.entrypoints=web"
- "traefik.http.routers.agency.priority=1" # Prioridade baixa para não conflitar com files/minio
environment:
- NODE_ENV=production
- NEXT_PUBLIC_API_URL=http://api.localhost
- API_INTERNAL_URL=http://backend:8080
healthcheck:
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000" ]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- aggios-network
volumes: volumes:
postgres_data: postgres_data:
driver: local driver: local
pgadmin_data:
driver: local
redis_data: redis_data:
driver: local driver: local
minio_data: minio_data:

159
docs/COLABORADORES_SETUP.md Normal file
View File

@@ -0,0 +1,159 @@
# Sistema de Hierarquia de Usuários - Guia de Configuração
## Visão Geral
O sistema implementa dois tipos de usuários para agências:
1. **Dono da Agência (owner)** - Acesso total
- Pode convidar colaboradores
- Pode remover colaboradores
- Tem acesso completo ao CRM
2. **Colaborador (collaborator)** - Acesso Restrito
- Pode VER leads e clientes
- **NÃO pode** editar ou remover dados
- Acesso somente leitura (read-only)
## Configuração Inicial
### Passo 1: Configurar o primeiro usuário como "owner"
Após criar a primeira agência e seu usuário admin, execute o script SQL:
```bash
docker exec aggios-postgres psql -U postgres -d aggios < /docker-entrypoint-initdb.d/../setup_owner_role.sql
```
Ou manualmente:
```sql
UPDATE users
SET agency_role = 'owner'
WHERE email = 'seu-email@exemplo.com' AND role = 'ADMIN_AGENCIA';
```
### Passo 2: Login e acessar o gerenciamento de colaboradores
1. Faça login com o usuário owner
2. Vá em **Configurações > Equipe**
3. Clique em "Convidar Colaborador"
### Passo 3: Convidar um colaborador
- Preencha Nome e Email
- Clique em "Convidar"
- Copie a senha temporária (16 caracteres)
- Compartilhe com o colaborador
## Fluxo de Funcionamento
### Quando um Colaborador é Convidado
1. Novo usuário é criado com `agency_role = 'collaborator'`
2. Recebe uma **senha temporária aleatória**
3. Email é adicionado à agência do owner
### Quando um Colaborador Faz Login
1. JWT contém `"agency_role": "collaborator"`
2. Frontend detecta a restrição
- Botões de editar/deletar desabilitados
- Mensagens de acesso restrito
3. Backend bloqueia POST/PUT/DELETE em `/api/crm/*`
- Retorna 403 Forbidden se tentar
### Dados no JWT
```json
{
"user_id": "uuid",
"user_type": "agency_user",
"agency_role": "owner", // ou "collaborator"
"email": "usuario@exemplo.com",
"role": "ADMIN_AGENCIA",
"tenant_id": "uuid",
"exp": 1234567890
}
```
## Banco de Dados
### Novos Campos na Tabela `users`
```sql
- agency_role VARCHAR(50) -- 'owner' ou 'collaborator'
- created_by UUID REFERENCES users -- Quem criou este colaborador
- collaborator_created_at TIMESTAMP -- Quando foi adicionado
```
## Endpoints da API
### Listar Colaboradores
```
GET /api/agency/collaborators
Headers: Authorization: Bearer <token>
Resposta: Array de Collaborators
Restrição: Apenas owner pode usar
```
### Convidar Colaborador
```
POST /api/agency/collaborators/invite
Body: { "email": "...", "name": "..." }
Resposta: { "temporary_password": "..." }
Restrição: Apenas owner pode usar
```
### Remover Colaborador
```
DELETE /api/agency/collaborators/{id}
Restrição: Apenas owner pode usar
```
## Página de Interface
**Localização:** `/configuracoes` → Aba "Equipe"
### Funcionalidades
- ✅ Ver lista de colaboradores (dono apenas)
- ✅ Convidar novo colaborador
- ✅ Copiar senha temporária
- ✅ Remover colaborador (com confirmação)
- ✅ Ver data de adição de cada colaborador
- ✅ Indicador visual (badge) do tipo de usuário
## Troubleshooting
### "Apenas o dono da agência pode gerenciar colaboradores"
**Causa:** O usuário não tem `agency_role = 'owner'`
**Solução:**
```sql
UPDATE users
SET agency_role = 'owner'
WHERE id = 'seu-user-id';
```
### Colaborador consegue editar dados (bug)
**Causa:** A middleware de read-only não está ativa
**Status:** Implementada em `backend/internal/api/middleware/collaborator_readonly.go`
**Para ativar:** Descomente a linha em `main.go` que aplica `CheckCollaboratorReadOnly`
### Senha temporária não aparece
**Verificar:**
1. API `/api/agency/collaborators/invite` retorna 200?
2. Response JSON tem o campo `temporary_password`?
3. Verificar logs do backend para erros
## Próximas Melhorias
- [ ] Permitir editar nome/email do colaborador
- [ ] Definir permissões granulares por colaborador
- [ ] Histórico de ações feitas por cada colaborador
- [ ] 2FA para owners
- [ ] Auditoria de quem removeu quem

186
docs/backup-system.md Normal file
View File

@@ -0,0 +1,186 @@
# 📦 Sistema de Backup & Restore - Aggios
## 🎯 Funcionalidades Implementadas
### Interface Web (Superadmin)
**URL:** `http://dash.localhost/superadmin/backup`
Disponível apenas para usuários com role `superadmin`.
#### Recursos:
1. **Criar Backup**
- Botão para criar novo backup instantâneo
- Mostra nome do arquivo e tamanho
- Mantém automaticamente apenas os últimos 10 backups
2. **Listar Backups**
- Exibe todos os backups disponíveis
- Informações: nome, data, tamanho
- Seleção visual do backup ativo
3. **Restaurar Backup**
- Seleção de backup na lista
- Confirmação de segurança (alerta de sobrescrita)
- Recarrega a página após restauração
4. **Download de Backup**
- Botão de download em cada backup
- Download direto do arquivo .sql
### API Endpoints
#### 1. Listar Backups
```
GET /api/superadmin/backups
Authorization: Bearer {token}
```
**Resposta:**
```json
{
"backups": [
{
"filename": "aggios_backup_2025-12-13_20-23-08.sql",
"size": "20.49 KB",
"date": "13/12/2025 20:23:08",
"timestamp": "2025-12-13_20-23-08"
}
]
}
```
#### 2. Criar Backup
```
POST /api/superadmin/backup/create
Authorization: Bearer {token}
```
**Resposta:**
```json
{
"message": "Backup created successfully",
"filename": "aggios_backup_2025-12-13_20-30-15.sql",
"size": "20.52 KB"
}
```
#### 3. Restaurar Backup
```
POST /api/superadmin/backup/restore
Authorization: Bearer {token}
Content-Type: application/json
{
"filename": "aggios_backup_2025-12-13_20-23-08.sql"
}
```
**Resposta:**
```json
{
"message": "Backup restored successfully"
}
```
#### 4. Download de Backup
```
GET /api/superadmin/backup/download/{filename}
Authorization: Bearer {token}
```
**Resposta:** Arquivo .sql para download
## 📂 Estrutura de Arquivos
```
backups/
├── aggios_backup_2025-12-13_19-56-18.sql
├── aggios_backup_2025-12-13_20-12-49.sql
├── aggios_backup_2025-12-13_20-17-59.sql
└── aggios_backup_2025-12-13_20-23-08.sql (mais recente)
```
## ⚙️ Scripts PowerShell (ainda funcionam!)
### Backup Manual
```powershell
cd g:\Projetos\aggios-app\scripts
.\backup-db.ps1
```
### Restaurar Último Backup
```powershell
cd g:\Projetos\aggios-app\scripts
.\restore-db.ps1
```
## 🔒 Segurança
1. ✅ Apenas superadmins podem acessar
2. ✅ Validação de arquivos (apenas .sql na pasta backups/)
3. ✅ Proteção contra path traversal
4. ✅ Autenticação JWT obrigatória
5. ✅ Confirmação dupla antes de restaurar
## ⚠️ Avisos Importantes
1. **Backup Automático:**
- Ainda não configurado
- Por enquanto, fazer backups manuais antes de `docker-compose down -v`
2. **Limite de Backups:**
- Sistema mantém apenas os **últimos 10 backups**
- Backups antigos são deletados automaticamente
3. **Restauração:**
- ⚠️ **SOBRESCREVE TODOS OS DADOS ATUAIS**
- Sempre peça confirmação dupla
- Cria um backup automático antes? (implementar depois)
## 🚀 Como Usar
1. **Acesse o Superadmin:**
- Login: admin@aggios.app
- Senha: Ag@}O%}Z;if)97o*JOgNMbP2025!
2. **No Menu Lateral:**
- Clique em "Backup & Restore" (ícone de servidor)
3. **Criar Backup:**
- Clique em "Criar Novo Backup"
- Aguarde confirmação
4. **Restaurar:**
- Selecione o backup desejado na lista
- Clique em "Restaurar Backup"
- Confirme o alerta
- Aguarde reload da página
## 🐛 Troubleshooting
### Erro ao criar backup
```bash
# Verificar se o container está rodando
docker ps | grep aggios-postgres
# Verificar logs
docker logs aggios-backend --tail 50
```
### Erro ao restaurar
```bash
# Verificar permissões
ls -la g:\Projetos\aggios-app\backups\
# Testar manualmente
docker exec -i aggios-postgres psql -U aggios aggios_db < backup.sql
```
## 📝 TODO Futuro
- [ ] Backup automático agendado (diário)
- [ ] Backup antes de restaurar (safety)
- [ ] Upload de backup externo
- [ ] Exportar/importar apenas tabelas específicas
- [ ] Histórico de restaurações
- [ ] Notificações por email

41
front-end-agency/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,47 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package.json package-lock.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build Next.js
RUN npm run build
# Runtime stage
FROM node:20-alpine
WORKDIR /app
# Copy package files
COPY package.json package-lock.json ./
# Install only production dependencies
RUN npm ci --omit=dev
# Copy built app from builder
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
# Create uploads directory
RUN mkdir -p ./public/uploads/logos && chown -R node:node ./public/uploads
# Switch to node user
USER node
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})"
# Start app
CMD ["npm", "start"]

View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -0,0 +1,105 @@
'use client';
import { DashboardLayout } from '@/components/layout/DashboardLayout';
import { AgencyBranding } from '@/components/layout/AgencyBranding';
import AuthGuard from '@/components/auth/AuthGuard';
import { CRMFilterProvider } from '@/contexts/CRMFilterContext';
import { useState, useEffect } from 'react';
import {
HomeIcon,
RocketLaunchIcon,
UserPlusIcon,
RectangleStackIcon,
UsersIcon,
MegaphoneIcon,
} from '@heroicons/react/24/outline';
const AGENCY_MENU_ITEMS = [
{ id: 'dashboard', label: 'Dashboard', href: '/dashboard', icon: HomeIcon },
{
id: 'crm',
label: 'CRM',
href: '/crm',
icon: RocketLaunchIcon,
requiredSolution: 'crm',
subItems: [
{ label: 'Visão Geral', href: '/crm', icon: HomeIcon },
{ label: 'Funis de Vendas', href: '/crm/funis', icon: RectangleStackIcon },
{ label: 'Clientes', href: '/crm/clientes', icon: UsersIcon },
{ label: 'Campanhas', href: '/crm/campanhas', icon: MegaphoneIcon },
{ label: 'Leads', href: '/crm/leads', icon: UserPlusIcon },
]
},
];
interface AgencyLayoutClientProps {
children: React.ReactNode;
colors?: {
primary: string;
secondary: string;
} | null;
}
export function AgencyLayoutClient({ children, colors }: AgencyLayoutClientProps) {
const [filteredMenuItems, setFilteredMenuItems] = useState(AGENCY_MENU_ITEMS);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchTenantSolutions = async () => {
try {
console.log('🔍 Buscando soluções do tenant...');
const response = await fetch('/api/tenant/solutions', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
console.log('📡 Response status:', response.status);
if (response.ok) {
const data = await response.json();
console.log('📦 Dados recebidos:', data);
const solutions = data.solutions || [];
console.log('✅ Soluções:', solutions);
// Mapear slugs de solutions para IDs de menu
const solutionSlugs = solutions.map((s: any) => s.slug.toLowerCase());
console.log('🏷️ Slugs das soluções:', solutionSlugs);
// Sempre mostrar dashboard + soluções disponíveis
const filtered = AGENCY_MENU_ITEMS.filter(item => {
if (item.id === 'dashboard') return true;
const requiredSolution = (item as any).requiredSolution;
return solutionSlugs.includes((requiredSolution || item.id).toLowerCase());
});
console.log('📋 Menu filtrado:', filtered.map(i => i.id));
setFilteredMenuItems(filtered);
} else {
console.error('❌ Erro na resposta:', response.status);
// Em caso de erro, mostrar todos (fallback)
setFilteredMenuItems(AGENCY_MENU_ITEMS);
}
} catch (error) {
console.error('❌ Error fetching solutions:', error);
// Em caso de erro, mostrar todos (fallback)
setFilteredMenuItems(AGENCY_MENU_ITEMS);
} finally {
setLoading(false);
}
};
fetchTenantSolutions();
}, []);
return (
<AuthGuard allowedTypes={['agency_user']}>
<CRMFilterProvider>
<AgencyBranding colors={colors} />
<DashboardLayout menuItems={loading ? [AGENCY_MENU_ITEMS[0]] : filteredMenuItems}>
{children}
</DashboardLayout>
</CRMFilterProvider>
</AuthGuard>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
'use client';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
export default function ContratosPage() {
return (
<SolutionGuard requiredSolution="contratos">
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">Contratos</h1>
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-8 text-center">
<p className="text-gray-500">Gestão de Contratos e Assinaturas em breve</p>
</div>
</div>
</SolutionGuard>
);
}

Some files were not shown because too many files have changed in this diff Show More