22 Commits

Author SHA1 Message Date
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
270 changed files with 42537 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
}
}

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,8 +106,8 @@ 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/
│ ├── start-dev.sh # Start em Linux/macOS │ ├── start-dev.sh # Start em Linux/macOS

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

123
README.md
View File

@@ -1,19 +1,124 @@
# 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 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}`).
- `front-end-agency/`: Painel Next.js para agências - branding dinâmico, upload de logos, gestão de perfil 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).
- `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.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`
## Como Usar ### **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
Para configurar e executar o projeto, consulte a documentação em `docs/`. ### **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`)
- 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/
files.go 🆕 Handler para servir arquivos via API
auth.go 🔒 Validação cross-tenant no login
middleware/
auth.go 🔒 Validação tenant em rotas protegidas
tenant.go 🔧 Detecção de tenant via headers
backend/internal/data/postgres/ Scripts SQL de seed
front-end-agency/ 🆕 Dashboard Next.js para Agências
app/login/page.tsx 🎨 Login com mensagens humanizadas
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: dev-1.4 (Segurança Multi-tenant + File Serving)

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,307 @@ 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, 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)
port := os.Getenv("SERVER_PORT") tenantHandler := handlers.NewTenantHandler(tenantService)
if port == "" { companyHandler := handlers.NewCompanyHandler(companyService)
port = "8080" 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)
// Initialize upload handler
uploadHandler, err := handlers.NewUploadHandler(cfg)
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.Login)
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")
// 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")
// 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")
// 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")
// 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,11 @@
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
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,169 @@
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",
})
}

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,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)
}

View File

@@ -0,0 +1,470 @@
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 CRMHandler struct {
repo *repository.CRMRepository
}
func NewCRMHandler(repo *repository.CRMRepository) *CRMHandler {
return &CRMHandler{repo: repo}
}
// ==================== CUSTOMERS ====================
func (h *CRMHandler) CreateCustomer(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
userID, _ := r.Context().Value(middleware.UserIDKey).(string)
if tenantID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Missing tenant_id",
})
return
}
var customer domain.CRMCustomer
if err := json.NewDecoder(r.Body).Decode(&customer); 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
}
customer.ID = uuid.New().String()
customer.TenantID = tenantID
customer.CreatedBy = userID
customer.IsActive = true
if err := h.repo.CreateCustomer(&customer); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Failed to create customer",
"message": err.Error(),
})
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]interface{}{
"customer": customer,
})
}
func (h *CRMHandler) GetCustomers(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
if tenantID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Missing tenant_id",
})
return
}
customers, err := h.repo.GetCustomersByTenant(tenantID)
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 customers",
"message": err.Error(),
})
return
}
if customers == nil {
customers = []domain.CRMCustomer{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"customers": customers,
})
}
func (h *CRMHandler) GetCustomer(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
if tenantID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Missing tenant_id",
})
return
}
vars := mux.Vars(r)
customerID := vars["id"]
customer, err := h.repo.GetCustomerByID(customerID, tenantID)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{
"error": "Customer not found",
"message": err.Error(),
})
return
}
// Buscar listas do cliente
lists, _ := h.repo.GetCustomerLists(customerID)
if lists == nil {
lists = []domain.CRMList{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"customer": customer,
"lists": lists,
})
}
func (h *CRMHandler) UpdateCustomer(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
if tenantID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Missing tenant_id",
})
return
}
vars := mux.Vars(r)
customerID := vars["id"]
var customer domain.CRMCustomer
if err := json.NewDecoder(r.Body).Decode(&customer); 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
}
customer.ID = customerID
customer.TenantID = tenantID
if err := h.repo.UpdateCustomer(&customer); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Failed to update customer",
"message": err.Error(),
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "Customer updated successfully",
})
}
func (h *CRMHandler) DeleteCustomer(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
if tenantID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Missing tenant_id",
})
return
}
vars := mux.Vars(r)
customerID := vars["id"]
if err := h.repo.DeleteCustomer(customerID, tenantID); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Failed to delete customer",
"message": err.Error(),
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "Customer deleted successfully",
})
}
// ==================== LISTS ====================
func (h *CRMHandler) CreateList(w http.ResponseWriter, r *http.Request) {
tenantIDVal := r.Context().Value(middleware.TenantIDKey)
userIDVal := r.Context().Value(middleware.UserIDKey)
log.Printf("🔍 CreateList DEBUG: tenantID type=%T value=%v | userID type=%T value=%v",
tenantIDVal, tenantIDVal, userIDVal, userIDVal)
tenantID, ok := tenantIDVal.(string)
if !ok || tenantID == "" {
log.Printf("❌ CreateList: Missing or invalid 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
}
userID, _ := userIDVal.(string)
var list domain.CRMList
if err := json.NewDecoder(r.Body).Decode(&list); 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
}
list.ID = uuid.New().String()
list.TenantID = tenantID
list.CreatedBy = userID
if list.Color == "" {
list.Color = "#3b82f6"
}
if err := h.repo.CreateList(&list); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Failed to create list",
"message": err.Error(),
})
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]interface{}{
"list": list,
})
}
func (h *CRMHandler) GetLists(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
if tenantID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Missing tenant_id",
})
return
}
lists, err := h.repo.GetListsByTenant(tenantID)
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 lists",
"message": err.Error(),
})
return
}
if lists == nil {
lists = []domain.CRMListWithCustomers{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"lists": lists,
})
}
func (h *CRMHandler) GetList(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
if tenantID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Missing tenant_id",
})
return
}
vars := mux.Vars(r)
listID := vars["id"]
list, err := h.repo.GetListByID(listID, tenantID)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{
"error": "List not found",
"message": err.Error(),
})
return
}
// Buscar clientes da lista
customers, _ := h.repo.GetListCustomers(listID, tenantID)
if customers == nil {
customers = []domain.CRMCustomer{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"list": list,
"customers": customers,
})
}
func (h *CRMHandler) UpdateList(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
if tenantID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Missing tenant_id",
})
return
}
vars := mux.Vars(r)
listID := vars["id"]
var list domain.CRMList
if err := json.NewDecoder(r.Body).Decode(&list); 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
}
list.ID = listID
list.TenantID = tenantID
if err := h.repo.UpdateList(&list); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Failed to update list",
"message": err.Error(),
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "List updated successfully",
})
}
func (h *CRMHandler) DeleteList(w http.ResponseWriter, r *http.Request) {
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
if tenantID == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{
"error": "Missing tenant_id",
})
return
}
vars := mux.Vars(r)
listID := vars["id"]
if err := h.repo.DeleteList(listID, tenantID); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Failed to delete list",
"message": err.Error(),
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "List deleted successfully",
})
}
// ==================== CUSTOMER <-> LIST ====================
func (h *CRMHandler) AddCustomerToList(w http.ResponseWriter, r *http.Request) {
userID, _ := r.Context().Value(middleware.UserIDKey).(string)
vars := mux.Vars(r)
customerID := vars["customer_id"]
listID := vars["list_id"]
if err := h.repo.AddCustomerToList(customerID, listID, userID); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Failed to add customer to list",
"message": err.Error(),
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "Customer added to list successfully",
})
}
func (h *CRMHandler) RemoveCustomerFromList(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
customerID := vars["customer_id"]
listID := vars["list_id"]
if err := h.repo.RemoveCustomerFromList(customerID, listID); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "Failed to remove customer from list",
"message": err.Error(),
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "Customer removed from list successfully",
})
}

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,108 @@
package handlers
import (
"encoding/json"
"log"
"net/http"
"aggios-app/backend/internal/service"
)
// 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]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)
}

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,100 @@
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 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,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,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,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,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,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,53 @@
package domain
import "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"`
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 CRMList 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"`
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
CustomerCount int `json:"customer_count"`
}

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,122 @@
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
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
}

View File

@@ -0,0 +1,346 @@
package repository
import (
"aggios-app/backend/internal/domain"
"database/sql"
"fmt"
"github.com/lib/pq"
)
type CRMRepository struct {
db *sql.DB
}
func NewCRMRepository(db *sql.DB) *CRMRepository {
return &CRMRepository{db: db}
}
// ==================== CUSTOMERS ====================
func (r *CRMRepository) CreateCustomer(customer *domain.CRMCustomer) error {
query := `
INSERT INTO crm_customers (
id, tenant_id, name, email, phone, company, position,
address, city, state, zip_code, country, notes, tags,
is_active, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
RETURNING created_at, updated_at
`
return r.db.QueryRow(
query,
customer.ID, customer.TenantID, customer.Name, customer.Email, customer.Phone,
customer.Company, customer.Position, customer.Address, customer.City, customer.State,
customer.ZipCode, customer.Country, customer.Notes, pq.Array(customer.Tags),
customer.IsActive, customer.CreatedBy,
).Scan(&customer.CreatedAt, &customer.UpdatedAt)
}
func (r *CRMRepository) GetCustomersByTenant(tenantID string) ([]domain.CRMCustomer, error) {
query := `
SELECT id, tenant_id, name, email, phone, company, position,
address, city, state, zip_code, country, notes, tags,
is_active, created_by, created_at, updated_at
FROM crm_customers
WHERE tenant_id = $1 AND is_active = true
ORDER BY created_at DESC
`
rows, err := r.db.Query(query, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var customers []domain.CRMCustomer
for rows.Next() {
var c domain.CRMCustomer
err := rows.Scan(
&c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position,
&c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags),
&c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt,
)
if err != nil {
return nil, err
}
customers = append(customers, c)
}
return customers, nil
}
func (r *CRMRepository) GetCustomerByID(id string, tenantID string) (*domain.CRMCustomer, error) {
query := `
SELECT id, tenant_id, name, email, phone, company, position,
address, city, state, zip_code, country, notes, tags,
is_active, created_by, created_at, updated_at
FROM crm_customers
WHERE id = $1 AND tenant_id = $2
`
var c domain.CRMCustomer
err := r.db.QueryRow(query, id, tenantID).Scan(
&c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position,
&c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags),
&c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt,
)
if err != nil {
return nil, err
}
return &c, nil
}
func (r *CRMRepository) UpdateCustomer(customer *domain.CRMCustomer) error {
query := `
UPDATE crm_customers SET
name = $1, email = $2, phone = $3, company = $4, position = $5,
address = $6, city = $7, state = $8, zip_code = $9, country = $10,
notes = $11, tags = $12, is_active = $13
WHERE id = $14 AND tenant_id = $15
`
result, err := r.db.Exec(
query,
customer.Name, customer.Email, customer.Phone, customer.Company, customer.Position,
customer.Address, customer.City, customer.State, customer.ZipCode, customer.Country,
customer.Notes, pq.Array(customer.Tags), customer.IsActive,
customer.ID, customer.TenantID,
)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return fmt.Errorf("customer not found")
}
return nil
}
func (r *CRMRepository) DeleteCustomer(id string, tenantID string) error {
query := `DELETE FROM crm_customers WHERE id = $1 AND tenant_id = $2`
result, err := r.db.Exec(query, id, tenantID)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return fmt.Errorf("customer not found")
}
return nil
}
// ==================== LISTS ====================
func (r *CRMRepository) CreateList(list *domain.CRMList) error {
query := `
INSERT INTO crm_lists (id, tenant_id, name, description, color, created_by)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING created_at, updated_at
`
return r.db.QueryRow(
query,
list.ID, list.TenantID, list.Name, list.Description, list.Color, list.CreatedBy,
).Scan(&list.CreatedAt, &list.UpdatedAt)
}
func (r *CRMRepository) GetListsByTenant(tenantID string) ([]domain.CRMListWithCustomers, error) {
query := `
SELECT l.id, l.tenant_id, l.name, l.description, l.color, l.created_by,
l.created_at, l.updated_at,
COUNT(cl.customer_id) as customer_count
FROM crm_lists l
LEFT JOIN crm_customer_lists cl ON l.id = cl.list_id
WHERE l.tenant_id = $1
GROUP BY l.id
ORDER BY l.created_at DESC
`
rows, err := r.db.Query(query, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var lists []domain.CRMListWithCustomers
for rows.Next() {
var l domain.CRMListWithCustomers
err := rows.Scan(
&l.ID, &l.TenantID, &l.Name, &l.Description, &l.Color, &l.CreatedBy,
&l.CreatedAt, &l.UpdatedAt, &l.CustomerCount,
)
if err != nil {
return nil, err
}
lists = append(lists, l)
}
return lists, nil
}
func (r *CRMRepository) GetListByID(id string, tenantID string) (*domain.CRMList, error) {
query := `
SELECT id, tenant_id, name, description, color, created_by, created_at, updated_at
FROM crm_lists
WHERE id = $1 AND tenant_id = $2
`
var l domain.CRMList
err := r.db.QueryRow(query, id, tenantID).Scan(
&l.ID, &l.TenantID, &l.Name, &l.Description, &l.Color, &l.CreatedBy,
&l.CreatedAt, &l.UpdatedAt,
)
if err != nil {
return nil, err
}
return &l, nil
}
func (r *CRMRepository) UpdateList(list *domain.CRMList) error {
query := `
UPDATE crm_lists SET
name = $1, description = $2, color = $3
WHERE id = $4 AND tenant_id = $5
`
result, err := r.db.Exec(query, list.Name, list.Description, list.Color, list.ID, list.TenantID)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return fmt.Errorf("list not found")
}
return nil
}
func (r *CRMRepository) DeleteList(id string, tenantID string) error {
query := `DELETE FROM crm_lists WHERE id = $1 AND tenant_id = $2`
result, err := r.db.Exec(query, id, tenantID)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return fmt.Errorf("list not found")
}
return nil
}
// ==================== CUSTOMER <-> LIST ====================
func (r *CRMRepository) AddCustomerToList(customerID, listID, addedBy string) error {
query := `
INSERT INTO crm_customer_lists (customer_id, list_id, added_by)
VALUES ($1, $2, $3)
ON CONFLICT (customer_id, list_id) DO NOTHING
`
_, err := r.db.Exec(query, customerID, listID, addedBy)
return err
}
func (r *CRMRepository) RemoveCustomerFromList(customerID, listID string) error {
query := `DELETE FROM crm_customer_lists WHERE customer_id = $1 AND list_id = $2`
_, err := r.db.Exec(query, customerID, listID)
return err
}
func (r *CRMRepository) GetCustomerLists(customerID string) ([]domain.CRMList, error) {
query := `
SELECT l.id, l.tenant_id, l.name, l.description, l.color, l.created_by,
l.created_at, l.updated_at
FROM crm_lists l
INNER JOIN crm_customer_lists cl ON l.id = cl.list_id
WHERE cl.customer_id = $1
ORDER BY l.name
`
rows, err := r.db.Query(query, customerID)
if err != nil {
return nil, err
}
defer rows.Close()
var lists []domain.CRMList
for rows.Next() {
var l domain.CRMList
err := rows.Scan(
&l.ID, &l.TenantID, &l.Name, &l.Description, &l.Color, &l.CreatedBy,
&l.CreatedAt, &l.UpdatedAt,
)
if err != nil {
return nil, err
}
lists = append(lists, l)
}
return lists, nil
}
func (r *CRMRepository) GetListCustomers(listID string, tenantID string) ([]domain.CRMCustomer, error) {
query := `
SELECT c.id, c.tenant_id, c.name, c.email, c.phone, c.company, c.position,
c.address, c.city, c.state, c.zip_code, c.country, c.notes, c.tags,
c.is_active, c.created_by, c.created_at, c.updated_at
FROM crm_customers c
INNER JOIN crm_customer_lists cl ON c.id = cl.customer_id
WHERE cl.list_id = $1 AND c.tenant_id = $2 AND c.is_active = true
ORDER BY c.name
`
rows, err := r.db.Query(query, listID, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var customers []domain.CRMCustomer
for rows.Next() {
var c domain.CRMCustomer
err := rows.Scan(
&c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position,
&c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags),
&c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt,
)
if err != nil {
return nil, err
}
customers = append(customers, c)
}
return customers, nil
}

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,163 @@
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
}

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,177 @@
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
cfg *config.Config
}
// NewAuthService creates a new auth service
func NewAuthService(userRepo *repository.UserRepository, tenantRepo *repository.TenantRepository, cfg *config.Config) *AuthService {
return &AuthService{
userRepo: userRepo,
tenantRepo: tenantRepo,
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)
}

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
}

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:

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,41 @@
# 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
# 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,181 @@
'use client';
import { DashboardLayout } from '@/components/layout/DashboardLayout';
import { AgencyBranding } from '@/components/layout/AgencyBranding';
import AuthGuard from '@/components/auth/AuthGuard';
import { useState, useEffect } from 'react';
import {
HomeIcon,
RocketLaunchIcon,
ChartBarIcon,
BriefcaseIcon,
LifebuoyIcon,
CreditCardIcon,
DocumentTextIcon,
FolderIcon,
ShareIcon,
} from '@heroicons/react/24/outline';
const AGENCY_MENU_ITEMS = [
{ id: 'dashboard', label: 'Visão Geral', href: '/dashboard', icon: HomeIcon },
{
id: 'crm',
label: 'CRM',
href: '/crm',
icon: RocketLaunchIcon,
subItems: [
{ label: 'Dashboard', href: '/crm' },
{ label: 'Clientes', href: '/crm/clientes' },
{ label: 'Funis', href: '/crm/funis' },
{ label: 'Negociações', href: '/crm/negociacoes' },
]
},
{
id: 'erp',
label: 'ERP',
href: '/erp',
icon: ChartBarIcon,
subItems: [
{ label: 'Dashboard', href: '/erp' },
{ label: 'Fluxo de Caixa', href: '/erp/fluxo-caixa' },
{ label: 'Contas a Pagar', href: '/erp/contas-pagar' },
{ label: 'Contas a Receber', href: '/erp/contas-receber' },
]
},
{
id: 'projetos',
label: 'Projetos',
href: '/projetos',
icon: BriefcaseIcon,
subItems: [
{ label: 'Dashboard', href: '/projetos' },
{ label: 'Meus Projetos', href: '/projetos/lista' },
{ label: 'Tarefas', href: '/projetos/tarefas' },
{ label: 'Cronograma', href: '/projetos/cronograma' },
]
},
{
id: 'helpdesk',
label: 'Helpdesk',
href: '/helpdesk',
icon: LifebuoyIcon,
subItems: [
{ label: 'Dashboard', href: '/helpdesk' },
{ label: 'Chamados', href: '/helpdesk/chamados' },
{ label: 'Base de Conhecimento', href: '/helpdesk/kb' },
]
},
{
id: 'pagamentos',
label: 'Pagamentos',
href: '/pagamentos',
icon: CreditCardIcon,
subItems: [
{ label: 'Dashboard', href: '/pagamentos' },
{ label: 'Cobranças', href: '/pagamentos/cobrancas' },
{ label: 'Assinaturas', href: '/pagamentos/assinaturas' },
]
},
{
id: 'contratos',
label: 'Contratos',
href: '/contratos',
icon: DocumentTextIcon,
subItems: [
{ label: 'Dashboard', href: '/contratos' },
{ label: 'Ativos', href: '/contratos/ativos' },
{ label: 'Modelos', href: '/contratos/modelos' },
]
},
{
id: 'documentos',
label: 'Documentos',
href: '/documentos',
icon: FolderIcon,
subItems: [
{ label: 'Meus Arquivos', href: '/documentos' },
{ label: 'Compartilhados', href: '/documentos/compartilhados' },
{ label: 'Lixeira', href: '/documentos/lixeira' },
]
},
{
id: 'social',
label: 'Redes Sociais',
href: '/social',
icon: ShareIcon,
subItems: [
{ label: 'Dashboard', href: '/social' },
{ label: 'Agendamento', href: '/social/agendamento' },
{ label: 'Relatórios', href: '/social/relatorios' },
]
},
];
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;
return solutionSlugs.includes(item.id);
});
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>
<AgencyBranding colors={colors} />
<DashboardLayout menuItems={loading ? [AGENCY_MENU_ITEMS[0]] : filteredMenuItems}>
{children}
</DashboardLayout>
</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>
);
}

View File

@@ -0,0 +1,548 @@
"use client";
import { Fragment, useEffect, useState } from 'react';
import { Menu, Transition } from '@headlessui/react';
import ConfirmDialog from '@/components/layout/ConfirmDialog';
import { useToast } from '@/components/layout/ToastContext';
import {
UserIcon,
TrashIcon,
PencilIcon,
EllipsisVerticalIcon,
MagnifyingGlassIcon,
PlusIcon,
XMarkIcon,
PhoneIcon,
EnvelopeIcon,
MapPinIcon,
TagIcon,
} from '@heroicons/react/24/outline';
interface Customer {
id: string;
tenant_id: string;
name: string;
email: string;
phone: string;
company: string;
position: string;
address: string;
city: string;
state: string;
zip_code: string;
country: string;
tags: string[];
notes: string;
created_at: string;
updated_at: string;
}
export default function CustomersPage() {
const toast = useToast();
const [customers, setCustomers] = useState<Customer[]>([]);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [customerToDelete, setCustomerToDelete] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [formData, setFormData] = useState({
name: '',
email: '',
phone: '',
company: '',
position: '',
address: '',
city: '',
state: '',
zip_code: '',
country: 'Brasil',
tags: '',
notes: '',
});
useEffect(() => {
fetchCustomers();
}, []);
const fetchCustomers = async () => {
try {
const response = await fetch('/api/crm/customers', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
if (response.ok) {
const data = await response.json();
setCustomers(data.customers || []);
}
} catch (error) {
console.error('Error fetching customers:', error);
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const url = editingCustomer
? `/api/crm/customers/${editingCustomer.id}`
: '/api/crm/customers';
const method = editingCustomer ? 'PUT' : 'POST';
const payload = {
...formData,
tags: formData.tags.split(',').map(t => t.trim()).filter(t => t.length > 0),
};
try {
const response = await fetch(url, {
method,
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (response.ok) {
toast.success(
editingCustomer ? 'Cliente atualizado' : 'Cliente criado',
editingCustomer ? 'O cliente foi atualizado com sucesso.' : 'O novo cliente foi criado com sucesso.'
);
fetchCustomers();
handleCloseModal();
} else {
const error = await response.json();
toast.error('Erro', error.message || 'Não foi possível salvar o cliente.');
}
} catch (error) {
console.error('Error saving customer:', error);
toast.error('Erro', 'Ocorreu um erro ao salvar o cliente.');
}
};
const handleEdit = (customer: Customer) => {
setEditingCustomer(customer);
setFormData({
name: customer.name,
email: customer.email,
phone: customer.phone,
company: customer.company,
position: customer.position,
address: customer.address,
city: customer.city,
state: customer.state,
zip_code: customer.zip_code,
country: customer.country,
tags: customer.tags?.join(', ') || '',
notes: customer.notes,
});
setIsModalOpen(true);
};
const handleDeleteClick = (id: string) => {
setCustomerToDelete(id);
setConfirmOpen(true);
};
const handleConfirmDelete = async () => {
if (!customerToDelete) return;
try {
const response = await fetch(`/api/crm/customers/${customerToDelete}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
if (response.ok) {
setCustomers(customers.filter(c => c.id !== customerToDelete));
toast.success('Cliente excluído', 'O cliente foi excluído com sucesso.');
} else {
toast.error('Erro ao excluir', 'Não foi possível excluir o cliente.');
}
} catch (error) {
console.error('Error deleting customer:', error);
toast.error('Erro ao excluir', 'Ocorreu um erro ao excluir o cliente.');
} finally {
setConfirmOpen(false);
setCustomerToDelete(null);
}
};
const handleCloseModal = () => {
setIsModalOpen(false);
setEditingCustomer(null);
setFormData({
name: '',
email: '',
phone: '',
company: '',
position: '',
address: '',
city: '',
state: '',
zip_code: '',
country: 'Brasil',
tags: '',
notes: '',
});
};
const filteredCustomers = customers.filter((customer) => {
const searchLower = searchTerm.toLowerCase();
return (
(customer.name?.toLowerCase() || '').includes(searchLower) ||
(customer.email?.toLowerCase() || '').includes(searchLower) ||
(customer.company?.toLowerCase() || '').includes(searchLower) ||
(customer.phone?.toLowerCase() || '').includes(searchLower)
);
});
return (
<div className="p-6 max-w-[1600px] mx-auto space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white tracking-tight">Clientes</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
Gerencie seus clientes e contatos
</p>
</div>
<button
onClick={() => setIsModalOpen(true)}
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium text-white rounded-lg hover:opacity-90 transition-opacity"
style={{ background: 'var(--gradient)' }}
>
<PlusIcon className="w-4 h-4" />
Novo Cliente
</button>
</div>
{/* Search */}
<div className="relative w-full lg:w-96">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<MagnifyingGlassIcon className="h-5 w-5 text-zinc-400" aria-hidden="true" />
</div>
<input
type="text"
className="block w-full pl-10 pr-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg leading-5 bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:outline-none focus:ring-1 focus:ring-[var(--brand-color)] focus:border-[var(--brand-color)] sm:text-sm transition duration-150 ease-in-out"
placeholder="Buscar por nome, email, empresa..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
{/* Table */}
{loading ? (
<div className="flex items-center justify-center h-64 bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--brand-color)]"></div>
</div>
) : filteredCustomers.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 text-center p-8">
<div className="w-16 h-16 bg-zinc-50 dark:bg-zinc-800 rounded-full flex items-center justify-center mb-4">
<UserIcon className="w-8 h-8 text-zinc-400" />
</div>
<h3 className="text-lg font-medium text-zinc-900 dark:text-white mb-1">
Nenhum cliente encontrado
</h3>
<p className="text-zinc-500 dark:text-zinc-400 max-w-sm mx-auto">
{searchTerm ? 'Nenhum cliente corresponde à sua busca.' : 'Comece adicionando seu primeiro cliente.'}
</p>
</div>
) : (
<div className="bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-zinc-50/50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-800">
<th className="px-6 py-4 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Cliente</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Empresa</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Contato</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Tags</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
{filteredCustomers.map((customer) => (
<tr key={customer.id} className="group hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold text-sm"
style={{ background: 'var(--gradient)' }}
>
{customer.name.substring(0, 2).toUpperCase()}
</div>
<div>
<div className="text-sm font-semibold text-zinc-900 dark:text-white">
{customer.name}
</div>
{customer.position && (
<div className="text-xs text-zinc-500 dark:text-zinc-400">
{customer.position}
</div>
)}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-zinc-700 dark:text-zinc-300">
{customer.company || '-'}
</td>
<td className="px-6 py-4 text-sm text-zinc-600 dark:text-zinc-400">
<div className="space-y-1">
{customer.email && (
<div className="flex items-center gap-2">
<EnvelopeIcon className="w-4 h-4 text-zinc-400" />
<span>{customer.email}</span>
</div>
)}
{customer.phone && (
<div className="flex items-center gap-2">
<PhoneIcon className="w-4 h-4 text-zinc-400" />
<span>{customer.phone}</span>
</div>
)}
</div>
</td>
<td className="px-6 py-4">
<div className="flex flex-wrap gap-1">
{customer.tags && customer.tags.length > 0 ? (
customer.tags.slice(0, 3).map((tag, idx) => (
<span
key={idx}
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
>
{tag}
</span>
))
) : (
<span className="text-xs text-zinc-400">-</span>
)}
{customer.tags && customer.tags.length > 3 && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400">
+{customer.tags.length - 3}
</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<Menu as="div" className="relative inline-block text-left">
<Menu.Button className="p-2 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 transition-colors outline-none">
<EllipsisVerticalIcon className="w-5 h-5" />
</Menu.Button>
<Menu.Items
transition
portal
anchor="bottom end"
className="w-48 origin-top-right divide-y divide-zinc-100 dark:divide-zinc-800 rounded-xl bg-white dark:bg-zinc-900 focus:outline-none z-50 border border-zinc-200 dark:border-zinc-800 [--anchor-gap:8px] transition duration-100 ease-out data-[closed]:scale-95 data-[closed]:opacity-0"
>
<div className="px-1 py-1">
<Menu.Item>
{({ active }) => (
<button
onClick={() => handleEdit(customer)}
className={`${active ? 'bg-zinc-50 dark:bg-zinc-800' : ''
} group flex w-full items-center rounded-lg px-2 py-2 text-sm text-zinc-700 dark:text-zinc-300`}
>
<PencilIcon className="mr-2 h-4 w-4 text-zinc-400" />
Editar
</button>
)}
</Menu.Item>
</div>
<div className="px-1 py-1">
<Menu.Item>
{({ active }) => (
<button
onClick={() => handleDeleteClick(customer.id)}
className={`${active ? 'bg-red-50 dark:bg-red-900/20' : ''
} group flex w-full items-center rounded-lg px-2 py-2 text-sm text-red-600 dark:text-red-400`}
>
<TrashIcon className="mr-2 h-4 w-4" />
Excluir
</button>
)}
</Menu.Item>
</div>
</Menu.Items>
</Menu>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<div className="fixed inset-0 bg-zinc-900/40 backdrop-blur-sm transition-opacity" onClick={handleCloseModal}></div>
<div className="relative transform overflow-hidden rounded-2xl bg-white dark:bg-zinc-900 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl border border-zinc-200 dark:border-zinc-800">
<div className="absolute right-0 top-0 pr-6 pt-6">
<button
type="button"
className="rounded-lg p-1.5 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
onClick={handleCloseModal}
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 sm:p-8">
<div className="flex items-start gap-4 mb-6">
<div
className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl shadow-lg"
style={{ background: 'var(--gradient)' }}
>
<UserIcon className="h-6 w-6 text-white" />
</div>
<div>
<h3 className="text-xl font-bold text-zinc-900 dark:text-white">
{editingCustomer ? 'Editar Cliente' : 'Novo Cliente'}
</h3>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
{editingCustomer ? 'Atualize as informações do cliente.' : 'Adicione um novo cliente ao seu CRM.'}
</p>
</div>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Nome Completo *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Email
</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Telefone
</label>
<input
type="tel"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Empresa
</label>
<input
type="text"
value={formData.company}
onChange={(e) => setFormData({ ...formData, company: e.target.value })}
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Cargo
</label>
<input
type="text"
value={formData.position}
onChange={(e) => setFormData({ ...formData, position: e.target.value })}
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Tags <span className="text-xs font-normal text-zinc-500">(separadas por vírgula)</span>
</label>
<input
type="text"
value={formData.tags}
onChange={(e) => setFormData({ ...formData, tags: e.target.value })}
placeholder="vip, premium, lead-quente"
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Observações
</label>
<textarea
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
rows={3}
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent resize-none transition-all"
/>
</div>
</div>
</div>
<div className="mt-8 pt-6 border-t border-zinc-200 dark:border-zinc-700 flex gap-3">
<button
type="button"
onClick={handleCloseModal}
className="flex-1 px-4 py-2.5 border border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 font-medium rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
Cancelar
</button>
<button
type="submit"
className="flex-1 px-4 py-2.5 text-white font-medium rounded-lg transition-all shadow-lg hover:shadow-xl"
style={{ background: 'var(--gradient)' }}
>
{editingCustomer ? 'Atualizar' : 'Criar Cliente'}
</button>
</div>
</form>
</div>
</div>
</div>
)}
<ConfirmDialog
isOpen={confirmOpen}
onClose={() => {
setConfirmOpen(false);
setCustomerToDelete(null);
}}
onConfirm={handleConfirmDelete}
title="Excluir Cliente"
message="Tem certeza que deseja excluir este cliente? Esta ação não pode ser desfeita."
confirmText="Excluir"
cancelText="Cancelar"
variant="danger"
/>
</div>
);
}

View File

@@ -0,0 +1,31 @@
"use client";
import { FunnelIcon } from '@heroicons/react/24/outline';
export default function FunisPage() {
return (
<div className="p-6 h-full flex items-center justify-center">
<div className="text-center max-w-md">
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-blue-500 to-purple-600">
<FunnelIcon className="h-10 w-10 text-white" />
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Funis de Vendas
</h1>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Esta funcionalidade está em desenvolvimento
</p>
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
<div className="flex gap-1">
<span className="animate-bounce inline-block h-2 w-2 rounded-full bg-blue-600" style={{ animationDelay: '0ms' }}></span>
<span className="animate-bounce inline-block h-2 w-2 rounded-full bg-blue-600" style={{ animationDelay: '150ms' }}></span>
<span className="animate-bounce inline-block h-2 w-2 rounded-full bg-blue-600" style={{ animationDelay: '300ms' }}></span>
</div>
<span className="text-sm font-medium text-blue-600 dark:text-blue-400">
Em breve
</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,432 @@
"use client";
import { Fragment, useEffect, useState } from 'react';
import { Menu, Transition } from '@headlessui/react';
import ConfirmDialog from '@/components/layout/ConfirmDialog';
import { useToast } from '@/components/layout/ToastContext';
import {
ListBulletIcon,
TrashIcon,
PencilIcon,
EllipsisVerticalIcon,
MagnifyingGlassIcon,
PlusIcon,
XMarkIcon,
UserGroupIcon,
} from '@heroicons/react/24/outline';
interface List {
id: string;
tenant_id: string;
name: string;
description: string;
color: string;
customer_count: number;
created_at: string;
updated_at: string;
}
const COLORS = [
{ name: 'Azul', value: '#3B82F6' },
{ name: 'Verde', value: '#10B981' },
{ name: 'Roxo', value: '#8B5CF6' },
{ name: 'Rosa', value: '#EC4899' },
{ name: 'Laranja', value: '#F97316' },
{ name: 'Amarelo', value: '#EAB308' },
{ name: 'Vermelho', value: '#EF4444' },
{ name: 'Cinza', value: '#6B7280' },
];
export default function ListsPage() {
const toast = useToast();
const [lists, setLists] = useState<List[]>([]);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingList, setEditingList] = useState<List | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [listToDelete, setListToDelete] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [formData, setFormData] = useState({
name: '',
description: '',
color: COLORS[0].value,
});
useEffect(() => {
fetchLists();
}, []);
const fetchLists = async () => {
try {
const response = await fetch('/api/crm/lists', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
if (response.ok) {
const data = await response.json();
setLists(data.lists || []);
}
} catch (error) {
console.error('Error fetching lists:', error);
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const url = editingList
? `/api/crm/lists/${editingList.id}`
: '/api/crm/lists';
const method = editingList ? 'PUT' : 'POST';
try {
const response = await fetch(url, {
method,
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(formData),
});
if (response.ok) {
toast.success(
editingList ? 'Lista atualizada' : 'Lista criada',
editingList ? 'A lista foi atualizada com sucesso.' : 'A nova lista foi criada com sucesso.'
);
fetchLists();
handleCloseModal();
} else {
const error = await response.json();
toast.error('Erro', error.message || 'Não foi possível salvar a lista.');
}
} catch (error) {
console.error('Error saving list:', error);
toast.error('Erro', 'Ocorreu um erro ao salvar a lista.');
}
};
const handleEdit = (list: List) => {
setEditingList(list);
setFormData({
name: list.name,
description: list.description,
color: list.color,
});
setIsModalOpen(true);
};
const handleDeleteClick = (id: string) => {
setListToDelete(id);
setConfirmOpen(true);
};
const handleConfirmDelete = async () => {
if (!listToDelete) return;
try {
const response = await fetch(`/api/crm/lists/${listToDelete}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
if (response.ok) {
setLists(lists.filter(l => l.id !== listToDelete));
toast.success('Lista excluída', 'A lista foi excluída com sucesso.');
} else {
toast.error('Erro ao excluir', 'Não foi possível excluir a lista.');
}
} catch (error) {
console.error('Error deleting list:', error);
toast.error('Erro ao excluir', 'Ocorreu um erro ao excluir a lista.');
} finally {
setConfirmOpen(false);
setListToDelete(null);
}
};
const handleCloseModal = () => {
setIsModalOpen(false);
setEditingList(null);
setFormData({
name: '',
description: '',
color: COLORS[0].value,
});
};
const filteredLists = lists.filter((list) => {
const searchLower = searchTerm.toLowerCase();
return (
(list.name?.toLowerCase() || '').includes(searchLower) ||
(list.description?.toLowerCase() || '').includes(searchLower)
);
});
return (
<div className="p-6 max-w-[1600px] mx-auto space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white tracking-tight">Listas</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
Organize seus clientes em listas personalizadas
</p>
</div>
<button
onClick={() => setIsModalOpen(true)}
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium text-white rounded-lg hover:opacity-90 transition-opacity"
style={{ background: 'var(--gradient)' }}
>
<PlusIcon className="w-4 h-4" />
Nova Lista
</button>
</div>
{/* Search */}
<div className="relative w-full lg:w-96">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<MagnifyingGlassIcon className="h-5 w-5 text-zinc-400" aria-hidden="true" />
</div>
<input
type="text"
className="block w-full pl-10 pr-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg leading-5 bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:outline-none focus:ring-1 focus:ring-[var(--brand-color)] focus:border-[var(--brand-color)] sm:text-sm transition duration-150 ease-in-out"
placeholder="Buscar listas..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
{/* Grid */}
{loading ? (
<div className="flex items-center justify-center h-64 bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--brand-color)]"></div>
</div>
) : filteredLists.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 text-center p-8">
<div className="w-16 h-16 bg-zinc-50 dark:bg-zinc-800 rounded-full flex items-center justify-center mb-4">
<ListBulletIcon className="w-8 h-8 text-zinc-400" />
</div>
<h3 className="text-lg font-medium text-zinc-900 dark:text-white mb-1">
Nenhuma lista encontrada
</h3>
<p className="text-zinc-500 dark:text-zinc-400 max-w-sm mx-auto">
{searchTerm ? 'Nenhuma lista corresponde à sua busca.' : 'Comece criando sua primeira lista.'}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredLists.map((list) => (
<div
key={list.id}
className="group relative bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-800 p-6 hover:shadow-lg transition-all"
>
{/* Color indicator */}
<div
className="absolute top-0 left-0 w-1 h-full rounded-l-xl"
style={{ backgroundColor: list.color }}
/>
<div className="flex items-start justify-between mb-4 pl-3">
<div className="flex items-center gap-3">
<div
className="w-12 h-12 rounded-lg flex items-center justify-center text-white"
style={{ backgroundColor: list.color }}
>
<ListBulletIcon className="w-6 h-6" />
</div>
<div>
<h3 className="text-lg font-semibold text-zinc-900 dark:text-white">
{list.name}
</h3>
<div className="flex items-center gap-1 mt-1 text-sm text-zinc-500 dark:text-zinc-400">
<UserGroupIcon className="w-4 h-4" />
<span>{list.customer_count || 0} clientes</span>
</div>
</div>
</div>
<Menu as="div" className="relative">
<Menu.Button className="p-1.5 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 transition-colors outline-none opacity-0 group-hover:opacity-100">
<EllipsisVerticalIcon className="w-5 h-5" />
</Menu.Button>
<Menu.Items
transition
portal
anchor="bottom end"
className="w-48 origin-top-right divide-y divide-zinc-100 dark:divide-zinc-800 rounded-xl bg-white dark:bg-zinc-900 focus:outline-none z-50 border border-zinc-200 dark:border-zinc-800 [--anchor-gap:8px] transition duration-100 ease-out data-[closed]:scale-95 data-[closed]:opacity-0"
>
<div className="px-1 py-1">
<Menu.Item>
{({ active }) => (
<button
onClick={() => handleEdit(list)}
className={`${active ? 'bg-zinc-50 dark:bg-zinc-800' : ''
} group flex w-full items-center rounded-lg px-2 py-2 text-sm text-zinc-700 dark:text-zinc-300`}
>
<PencilIcon className="mr-2 h-4 w-4 text-zinc-400" />
Editar
</button>
)}
</Menu.Item>
</div>
<div className="px-1 py-1">
<Menu.Item>
{({ active }) => (
<button
onClick={() => handleDeleteClick(list.id)}
className={`${active ? 'bg-red-50 dark:bg-red-900/20' : ''
} group flex w-full items-center rounded-lg px-2 py-2 text-sm text-red-600 dark:text-red-400`}
>
<TrashIcon className="mr-2 h-4 w-4" />
Excluir
</button>
)}
</Menu.Item>
</div>
</Menu.Items>
</Menu>
</div>
{list.description && (
<p className="text-sm text-zinc-600 dark:text-zinc-400 pl-3 line-clamp-2">
{list.description}
</p>
)}
</div>
))}
</div>
)}
{/* Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<div className="fixed inset-0 bg-zinc-900/40 backdrop-blur-sm transition-opacity" onClick={handleCloseModal}></div>
<div className="relative transform overflow-hidden rounded-2xl bg-white dark:bg-zinc-900 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg border border-zinc-200 dark:border-zinc-800">
<div className="absolute right-0 top-0 pr-6 pt-6">
<button
type="button"
className="rounded-lg p-1.5 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
onClick={handleCloseModal}
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 sm:p-8">
<div className="flex items-start gap-4 mb-6">
<div
className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl shadow-lg"
style={{ backgroundColor: formData.color }}
>
<ListBulletIcon className="h-6 w-6 text-white" />
</div>
<div>
<h3 className="text-xl font-bold text-zinc-900 dark:text-white">
{editingList ? 'Editar Lista' : 'Nova Lista'}
</h3>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
{editingList ? 'Atualize as informações da lista.' : 'Crie uma nova lista para organizar seus clientes.'}
</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Nome da Lista *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Ex: Clientes VIP"
required
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Descrição
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Descreva o propósito desta lista"
rows={3}
className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent resize-none transition-all"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-3">
Cor
</label>
<div className="grid grid-cols-8 gap-2">
{COLORS.map((color) => (
<button
key={color.value}
type="button"
onClick={() => setFormData({ ...formData, color: color.value })}
className={`w-10 h-10 rounded-lg transition-all ${formData.color === color.value
? 'ring-2 ring-offset-2 ring-zinc-400 dark:ring-zinc-600 scale-110'
: 'hover:scale-105'
}`}
style={{ backgroundColor: color.value }}
title={color.name}
/>
))}
</div>
</div>
</div>
<div className="mt-8 pt-6 border-t border-zinc-200 dark:border-zinc-700 flex gap-3">
<button
type="button"
onClick={handleCloseModal}
className="flex-1 px-4 py-2.5 border border-zinc-200 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 font-medium rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
Cancelar
</button>
<button
type="submit"
className="flex-1 px-4 py-2.5 text-white font-medium rounded-lg transition-all shadow-lg hover:shadow-xl"
style={{ background: 'var(--gradient)' }}
>
{editingList ? 'Atualizar' : 'Criar Lista'}
</button>
</div>
</form>
</div>
</div>
</div>
)}
<ConfirmDialog
isOpen={confirmOpen}
onClose={() => {
setConfirmOpen(false);
setListToDelete(null);
}}
onConfirm={handleConfirmDelete}
title="Excluir Lista"
message="Tem certeza que deseja excluir esta lista? Os clientes não serão excluídos, apenas removidos da lista."
confirmText="Excluir"
cancelText="Cancelar"
variant="danger"
/>
</div>
);
}

View File

@@ -0,0 +1,31 @@
"use client";
import { CurrencyDollarIcon } from '@heroicons/react/24/outline';
export default function NegociacoesPage() {
return (
<div className="p-6 h-full flex items-center justify-center">
<div className="text-center max-w-md">
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-green-500 to-emerald-600">
<CurrencyDollarIcon className="h-10 w-10 text-white" />
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Negociações
</h1>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Esta funcionalidade está em desenvolvimento
</p>
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
<div className="flex gap-1">
<span className="animate-bounce inline-block h-2 w-2 rounded-full bg-green-600" style={{ animationDelay: '0ms' }}></span>
<span className="animate-bounce inline-block h-2 w-2 rounded-full bg-green-600" style={{ animationDelay: '150ms' }}></span>
<span className="animate-bounce inline-block h-2 w-2 rounded-full bg-green-600" style={{ animationDelay: '300ms' }}></span>
</div>
<span className="text-sm font-medium text-green-600 dark:text-green-400">
Em breve
</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,134 @@
"use client";
import Link from 'next/link';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
import {
UsersIcon,
CurrencyDollarIcon,
ChartPieIcon,
ArrowTrendingUpIcon,
ListBulletIcon,
ArrowRightIcon,
} from '@heroicons/react/24/outline';
export default function CRMPage() {
const stats = [
{ name: 'Leads Totais', value: '124', icon: UsersIcon, color: 'blue' },
{ name: 'Oportunidades', value: 'R$ 450k', icon: CurrencyDollarIcon, color: 'green' },
{ name: 'Taxa de Conversão', value: '24%', icon: ChartPieIcon, color: 'purple' },
{ name: 'Crescimento', value: '+12%', icon: ArrowTrendingUpIcon, color: 'orange' },
];
const quickLinks = [
{
name: 'Clientes',
description: 'Gerencie seus contatos e clientes',
icon: UsersIcon,
href: '/crm/clientes',
color: 'blue',
},
{
name: 'Listas',
description: 'Organize clientes em listas',
icon: ListBulletIcon,
href: '/crm/listas',
color: 'purple',
},
];
return (
<SolutionGuard requiredSolution="crm">
<div className="p-6 h-full overflow-auto">
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
CRM
</h1>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
Visão geral do relacionamento com clientes
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{stats.map((stat) => {
const Icon = stat.icon;
return (
<div
key={stat.name}
className="group relative overflow-hidden rounded-xl bg-white dark:bg-gray-900 p-4 border border-gray-200 dark:border-gray-800 transition-all"
>
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-gray-600 dark:text-gray-400">
{stat.name}
</p>
<p className="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
{stat.value}
</p>
</div>
<div
className={`rounded-lg p-2 bg-${stat.color}-100 dark:bg-${stat.color}-900/20`}
>
<Icon
className={`h-5 w-5 text-${stat.color}-600 dark:text-${stat.color}-400`}
/>
</div>
</div>
</div>
);
})}
</div>
{/* Quick Links */}
<div className="mb-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Acesso Rápido
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{quickLinks.map((link) => {
const Icon = link.icon;
return (
<Link
key={link.name}
href={link.href}
className="group relative overflow-hidden rounded-xl bg-white dark:bg-gray-900 p-6 border border-gray-200 dark:border-gray-800 hover:border-gray-300 dark:hover:border-gray-700 transition-all hover:shadow-lg"
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div
className={`rounded-lg p-3 bg-${link.color}-100 dark:bg-${link.color}-900/20`}
>
<Icon
className={`h-6 w-6 text-${link.color}-600 dark:text-${link.color}-400`}
/>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">
{link.name}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{link.description}
</p>
</div>
</div>
<ArrowRightIcon className="w-5 h-5 text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300 group-hover:translate-x-1 transition-all" />
</div>
</Link>
);
})}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="rounded-xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-6 h-64 flex items-center justify-center">
<p className="text-gray-500">Funil de Vendas (Em breve)</p>
</div>
<div className="rounded-xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 p-6 h-64 flex items-center justify-center">
<p className="text-gray-500">Atividades Recentes (Em breve)</p>
</div>
</div>
</div>
</div>
</SolutionGuard>
);
}

View File

@@ -0,0 +1,236 @@
"use client";
import { useEffect, useState } from 'react';
import { getUser } from '@/lib/auth';
import {
RocketLaunchIcon,
ChartBarIcon,
BriefcaseIcon,
LifebuoyIcon,
CreditCardIcon,
DocumentTextIcon,
FolderIcon,
ShareIcon,
ArrowTrendingUpIcon,
ArrowTrendingDownIcon,
CheckCircleIcon,
ClockIcon,
} from '@heroicons/react/24/outline';
export default function DashboardPage() {
const [userName, setUserName] = useState('');
const [greeting, setGreeting] = useState('');
useEffect(() => {
const user = getUser();
if (user) {
setUserName(user.name.split(' ')[0]); // Primeiro nome
}
const hour = new Date().getHours();
if (hour >= 5 && hour < 12) setGreeting('Bom dia');
else if (hour >= 12 && hour < 18) setGreeting('Boa tarde');
else setGreeting('Boa noite');
}, []);
const overviewStats = [
{ name: 'Receita Total (Mês)', value: 'R$ 124.500', change: '+12%', changeType: 'increase', icon: ChartBarIcon, color: 'green' },
{ name: 'Novos Leads', value: '45', change: '+5%', changeType: 'increase', icon: RocketLaunchIcon, color: 'blue' },
{ name: 'Projetos Ativos', value: '12', change: '-1', changeType: 'decrease', icon: BriefcaseIcon, color: 'purple' },
{ name: 'Chamados Abertos', value: '3', change: '-2', changeType: 'decrease', icon: LifebuoyIcon, color: 'orange' },
];
const modules = [
{
title: 'CRM & Vendas',
icon: RocketLaunchIcon,
color: 'blue',
stats: [
{ label: 'Propostas Enviadas', value: '8' },
{ label: 'Aguardando Aprovação', value: '3' },
{ label: 'Taxa de Conversão', value: '24%' },
]
},
{
title: 'Financeiro & ERP',
icon: ChartBarIcon,
color: 'green',
stats: [
{ label: 'A Receber', value: 'R$ 45.200' },
{ label: 'A Pagar', value: 'R$ 12.800' },
{ label: 'Fluxo de Caixa', value: 'Positivo' },
]
},
{
title: 'Projetos & Tarefas',
icon: BriefcaseIcon,
color: 'purple',
stats: [
{ label: 'Em Andamento', value: '12' },
{ label: 'Atrasados', value: '1' },
{ label: 'Concluídos (Mês)', value: '4' },
]
},
{
title: 'Helpdesk',
icon: LifebuoyIcon,
color: 'orange',
stats: [
{ label: 'Novos Chamados', value: '3' },
{ label: 'Tempo Médio Resposta', value: '2h' },
{ label: 'Satisfação', value: '4.8/5' },
]
},
{
title: 'Documentos & Contratos',
icon: DocumentTextIcon,
color: 'indigo',
stats: [
{ label: 'Contratos Ativos', value: '28' },
{ label: 'A Vencer (30 dias)', value: '2' },
{ label: 'Docs Armazenados', value: '1.2GB' },
]
},
{
title: 'Redes Sociais',
icon: ShareIcon,
color: 'pink',
stats: [
{ label: 'Posts Agendados', value: '14' },
{ label: 'Engajamento', value: '+8.5%' },
{ label: 'Novos Seguidores', value: '120' },
]
}
];
return (
<div className="p-6 h-full overflow-auto">
<div className="space-y-8">
{/* Header Personalizado */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-heading font-bold text-gray-900 dark:text-white">
{greeting}, {userName || 'Administrador'}! 👋
</h1>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
Aqui está o resumo da sua agência hoje. Tudo parece estar sob controle.
</p>
</div>
<div className="flex items-center gap-3">
<span className="text-xs font-medium px-3 py-1 rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 border border-green-200 dark:border-green-800 flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span>
Sistema Operacional
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' })}
</span>
</div>
</div>
{/* Top Stats */}
<div>
{/* Mobile: Scroll Horizontal */}
<div className="md:hidden overflow-x-auto scrollbar-hide">
<div className="flex gap-4 min-w-max">
{overviewStats.map((stat) => {
const Icon = stat.icon;
return (
<div
key={stat.name}
className="relative overflow-hidden rounded-xl bg-white dark:bg-zinc-900 p-4 border border-gray-200 dark:border-zinc-800 shadow-sm w-[280px] flex-shrink-0"
>
<div className="flex items-center justify-between">
<div className={`rounded-lg p-2 bg-${stat.color}-50 dark:bg-${stat.color}-900/20`}>
<Icon className={`h-6 w-6 text-${stat.color}-600 dark:text-${stat.color}-400`} />
</div>
<div className={`flex items-baseline text-sm font-semibold ${stat.changeType === 'increase' ? 'text-green-600' : 'text-red-600'
}`}>
{stat.changeType === 'increase' ? (
<ArrowTrendingUpIcon className="h-4 w-4 mr-1" />
) : (
<ArrowTrendingDownIcon className="h-4 w-4 mr-1" />
)}
{stat.change}
</div>
</div>
<div className="mt-4">
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{stat.name}</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">{stat.value}</p>
</div>
</div>
);
})}
</div>
</div>
{/* Desktop: Grid */}
<div className="hidden md:grid md:grid-cols-2 lg:grid-cols-4 gap-4">
{overviewStats.map((stat) => {
const Icon = stat.icon;
return (
<div
key={stat.name}
className="relative overflow-hidden rounded-xl bg-white dark:bg-zinc-900 p-4 border border-gray-200 dark:border-zinc-800 shadow-sm"
>
<div className="flex items-center justify-between">
<div className={`rounded-lg p-2 bg-${stat.color}-50 dark:bg-${stat.color}-900/20`}>
<Icon className={`h-6 w-6 text-${stat.color}-600 dark:text-${stat.color}-400`} />
</div>
<div className={`flex items-baseline text-sm font-semibold ${stat.changeType === 'increase' ? 'text-green-600' : 'text-red-600'
}`}>
{stat.changeType === 'increase' ? (
<ArrowTrendingUpIcon className="h-4 w-4 mr-1" />
) : (
<ArrowTrendingDownIcon className="h-4 w-4 mr-1" />
)}
{stat.change}
</div>
</div>
<div className="mt-4">
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{stat.name}</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">{stat.value}</p>
</div>
</div>
);
})}
</div>
</div>
{/* Modules Grid */}
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Performance por Módulo
</h2>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{modules.map((module) => {
const Icon = module.icon;
return (
<div
key={module.title}
className="rounded-xl bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 p-6 hover:border-gray-200 dark:hover:border-zinc-700 transition-colors"
>
<div className="flex items-center gap-3 mb-6">
<div className={`p-2 rounded-lg bg-${module.color}-50 dark:bg-${module.color}-900/20`}>
<Icon className={`h-5 w-5 text-${module.color}-600 dark:text-${module.color}-400`} />
</div>
<h3 className="font-semibold text-gray-900 dark:text-white">
{module.title}
</h3>
</div>
<div className="space-y-4">
{module.stats.map((stat, idx) => (
<div key={idx} className="flex items-center justify-between text-sm">
<span className="text-gray-500 dark:text-gray-400">{stat.label}</span>
<span className="font-medium text-gray-900 dark:text-white">{stat.value}</span>
</div>
))}
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
'use client';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
export default function DocumentosPage() {
return (
<SolutionGuard requiredSolution="documentos">
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">Documentos</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 Eletrônica de Documentos (GED) em breve</p>
</div>
</div>
</SolutionGuard>
);
}

View File

@@ -0,0 +1,16 @@
'use client';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
export default function ERPPage() {
return (
<SolutionGuard requiredSolution="erp">
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">ERP</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">Sistema Integrado de Gestão Empresarial em breve</p>
</div>
</div>
</SolutionGuard>
);
}

View File

@@ -0,0 +1,16 @@
'use client';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
export default function HelpdeskPage() {
return (
<SolutionGuard requiredSolution="helpdesk">
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">Helpdesk</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">Central de Suporte e Chamados em breve</p>
</div>
</div>
</SolutionGuard>
);
}

View File

@@ -0,0 +1,34 @@
import { Metadata } from 'next';
import { getAgencyLogo, getAgencyColors } from '@/lib/server-api';
import { AgencyLayoutClient } from './AgencyLayoutClient';
// Forçar renderização dinâmica (não estática) para este layout
// Necessário porque usamos headers() para pegar o subdomínio
export const dynamic = 'force-dynamic';
/**
* generateMetadata - Executado no servidor antes do render
* Define o favicon dinamicamente baseado no subdomínio da agência
*/
export async function generateMetadata(): Promise<Metadata> {
const logoUrl = await getAgencyLogo();
return {
icons: {
icon: logoUrl || '/favicon.ico',
shortcut: logoUrl || '/favicon.ico',
apple: logoUrl || '/favicon.ico',
},
};
}
export default async function AgencyLayout({
children,
}: {
children: React.ReactNode;
}) {
// Buscar cores da agência no servidor
const colors = await getAgencyColors();
return <AgencyLayoutClient colors={colors}>{children}</AgencyLayoutClient>;
}

View File

@@ -0,0 +1,16 @@
'use client';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
export default function PagamentosPage() {
return (
<SolutionGuard requiredSolution="pagamentos">
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">Pagamentos</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 Pagamentos e Cobranças em breve</p>
</div>
</div>
</SolutionGuard>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function AgencyRootPage() {
redirect('/dashboard');
}

View File

@@ -0,0 +1,16 @@
'use client';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
export default function ProjetosPage() {
return (
<SolutionGuard requiredSolution="projetos">
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">Projetos</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 Projetos em breve</p>
</div>
</div>
</SolutionGuard>
);
}

View File

@@ -0,0 +1,16 @@
'use client';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
export default function SocialPage() {
return (
<SolutionGuard requiredSolution="social">
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">Gestão de Redes Sociais</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">Planejamento e Publicação de Posts em breve</p>
</div>
</div>
</SolutionGuard>
);
}

View File

@@ -0,0 +1,7 @@
'use client';
import { ReactNode } from 'react';
export default function AuthLayoutWrapper({ children }: { children: ReactNode }) {
return <>{children}</>;
}

View File

@@ -31,26 +31,65 @@ export default function CadastroPage() {
const [subdomain, setSubdomain] = useState(""); const [subdomain, setSubdomain] = useState("");
const [domainAvailable, setDomainAvailable] = useState<boolean | null>(null); const [domainAvailable, setDomainAvailable] = useState<boolean | null>(null);
const [checkingDomain, setCheckingDomain] = useState(false); const [checkingDomain, setCheckingDomain] = useState(false);
const [primaryColor, setPrimaryColor] = useState("#FF3A05"); const [primaryColor, setPrimaryColor] = useState("#ff3a05");
const [secondaryColor, setSecondaryColor] = useState("#FF0080"); const [secondaryColor, setSecondaryColor] = useState("#ff0080");
const [logoUrl, setLogoUrl] = useState<string>(""); const [logoUrl, setLogoUrl] = useState<string>("");
const [logoHorizontalUrl, setLogoHorizontalUrl] = useState<string>("");
const [uploadingLogo, setUploadingLogo] = useState(false);
const [uploadingLogoHorizontal, setUploadingLogoHorizontal] = useState(false);
const [showPreviewMobile, setShowPreviewMobile] = useState(false); const [showPreviewMobile, setShowPreviewMobile] = useState(false);
// Função para upload de logo
const handleLogoUpload = async (file: File, isHorizontal: boolean = false) => {
if (file.size > 10 * 1024 * 1024) {
toast.error('Arquivo muito grande. Máximo: 10MB');
return;
}
if (isHorizontal) {
setUploadingLogoHorizontal(true);
} else {
setUploadingLogo(true);
}
const formData = new FormData();
formData.append('file', file);
try {
const token = localStorage.getItem('token');
const headers: HeadersInit = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch('/api/upload', {
method: 'POST',
headers,
body: formData,
});
if (!response.ok) throw new Error('Upload failed');
const data = await response.json();
if (isHorizontal) {
setLogoHorizontalUrl(data.file_url);
} else {
setLogoUrl(data.file_url);
}
toast.success('Logo enviado com sucesso!');
} catch (error) {
console.error('Erro no upload:', error);
toast.error('Falha ao enviar logo. Tente novamente.');
} finally {
if (isHorizontal) {
setUploadingLogoHorizontal(false);
} else {
setUploadingLogo(false);
}
}
};
// Carregar dados do localStorage ao montar // Carregar dados do localStorage ao montar
useEffect(() => { useEffect(() => {
// Mostrar dica de atalho
setTimeout(() => {
toast('💡 Dica: Pressione a tecla T para preencher dados de teste automaticamente!', {
duration: 5000,
icon: '⚡',
style: {
background: '#FFA500',
color: '#fff',
fontWeight: 'bold',
},
});
}, 1000);
const saved = localStorage.getItem('cadastroFormData'); const saved = localStorage.getItem('cadastroFormData');
if (saved) { if (saved) {
try { try {
@@ -65,8 +104,8 @@ export default function CadastroPage() {
setCepData(data.cepData || { state: "", city: "", neighborhood: "", street: "" }); setCepData(data.cepData || { state: "", city: "", neighborhood: "", street: "" });
setSubdomain(data.subdomain || ""); setSubdomain(data.subdomain || "");
setDomainAvailable(data.domainAvailable ?? null); setDomainAvailable(data.domainAvailable ?? null);
setPrimaryColor(data.primaryColor || "#FF3A05"); setPrimaryColor(data.primaryColor || "#ff3a05");
setSecondaryColor(data.secondaryColor || "#FF0080"); setSecondaryColor(data.secondaryColor || "#ff0080");
setLogoUrl(data.logoUrl || ""); setLogoUrl(data.logoUrl || "");
} catch (error) { } catch (error) {
console.error('Erro ao carregar dados:', error); console.error('Erro ao carregar dados:', error);
@@ -94,20 +133,6 @@ export default function CadastroPage() {
localStorage.setItem('cadastroFormData', JSON.stringify(dataToSave)); localStorage.setItem('cadastroFormData', JSON.stringify(dataToSave));
}, [currentStep, completedSteps, formData, contacts, password, passwordStrength, cnpjData, cepData, subdomain, domainAvailable, primaryColor, secondaryColor, logoUrl]); }, [currentStep, completedSteps, formData, contacts, password, passwordStrength, cnpjData, cepData, subdomain, domainAvailable, primaryColor, secondaryColor, logoUrl]);
// ATALHO DE TECLADO - Pressione T para preencher dados de teste
useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
if (e.key === 't' || e.key === 'T') {
if (confirm('🚀 PREENCHER DADOS DE TESTE?\n\nIsso vai preencher todos os campos automaticamente e ir pro Step 5.\n\nClique OK para continuar.')) {
fillTestData();
}
}
};
window.addEventListener('keydown', handleKeyPress);
return () => window.removeEventListener('keydown', handleKeyPress);
}, []);
// Função para atualizar formData // Função para atualizar formData
const updateFormData = (name: string, value: any) => { const updateFormData = (name: string, value: any) => {
setFormData(prev => ({ ...prev, [name]: value })); setFormData(prev => ({ ...prev, [name]: value }));
@@ -323,48 +348,69 @@ export default function CadastroPage() {
const handleSubmitRegistration = async () => { const handleSubmitRegistration = async () => {
try { try {
const payload = { const payload = {
// Step 1 - Dados Pessoais // Dados da agência
email: formData.email, agencyName: formData.companyName,
password: password, subdomain: subdomain,
fullName: formData.fullName,
newsletter: formData.newsletter || false,
// Step 2 - Empresa
companyName: formData.companyName,
cnpj: formData.cnpj, cnpj: formData.cnpj,
razaoSocial: cnpjData.razaoSocial, razaoSocial: formData.razaoSocial,
description: formData.description, description: formData.description,
website: formData.website, website: formData.website,
industry: formData.industry, industry: formData.industry,
teamSize: formData.teamSize,
// Step 3 - Localização e Contato // Endereço
cep: formData.cep, cep: formData.cep,
state: cepData.state, state: formData.state,
city: cepData.city, city: formData.city,
neighborhood: cepData.neighborhood, neighborhood: formData.neighborhood,
street: cepData.street, street: formData.street,
number: formData.number, number: formData.number,
complement: formData.complement, complement: formData.complement,
contacts: contacts,
// Step 4 - Domínio // Personalização
subdomain: subdomain, primaryColor: formData.primaryColor,
secondaryColor: formData.secondaryColor,
// Step 5 - Personalização
primaryColor: primaryColor,
secondaryColor: secondaryColor,
logoUrl: logoUrl, logoUrl: logoUrl,
logoHorizontalUrl: logoHorizontalUrl,
// Admin
adminEmail: formData.email,
adminPassword: password,
adminName: formData.fullName,
}; };
console.log('📤 Enviando cadastro completo:', payload); console.log('📤 Enviando cadastro completo:', payload);
toast.loading('Criando sua conta...', { id: 'register' }); toast.loading('Criando sua conta...', { id: 'register' });
const data = await apiRequest(API_ENDPOINTS.register, { const response = await fetch(API_ENDPOINTS.adminAgencyRegister, {
method: 'POST', method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (!response.ok) {
let errorMessage = 'Erro ao criar conta';
try {
const text = await response.text();
// Tentar parsear como JSON primeiro
try {
const error = JSON.parse(text);
errorMessage = error.message || error.error || text;
} catch {
// Se não for JSON, usar o texto direto
errorMessage = text || errorMessage;
}
} catch (e) {
// Erro ao ler resposta
}
toast.error(errorMessage, { id: 'register' });
throw new Error(errorMessage);
}
const data = await response.json();
console.log('📥 Resposta data:', data); console.log('📥 Resposta data:', data);
// Salvar autenticação // Salvar autenticação
@@ -373,6 +419,7 @@ export default function CadastroPage() {
id: data.id, id: data.id,
email: data.email, email: data.email,
name: data.name, name: data.name,
role: data.role || 'ADMIN_AGENCIA',
tenantId: data.tenantId, tenantId: data.tenantId,
company: data.company, company: data.company,
subdomain: data.subdomain subdomain: data.subdomain
@@ -382,7 +429,7 @@ export default function CadastroPage() {
// Sucesso - limpar localStorage do form // Sucesso - limpar localStorage do form
localStorage.removeItem('cadastroFormData'); localStorage.removeItem('cadastroFormData');
toast.success('Conta criada com sucesso! Redirecionando para o painel...', { toast.success('Conta criada com sucesso! Redirecionando para seu painel...', {
id: 'register', id: 'register',
duration: 2000, duration: 2000,
style: { style: {
@@ -391,9 +438,11 @@ export default function CadastroPage() {
}, },
}); });
// Aguardar 2 segundos e redirecionar para o painel // Redirecionar para o painel da agência no subdomínio, enviando o gradiente escolhido
setTimeout(() => { setTimeout(() => {
window.location.href = '/painel'; const gradient = `linear-gradient(135deg, ${primaryColor}, ${secondaryColor})`;
const agencyUrl = `http://${data.subdomain}.localhost/login?theme=${encodeURIComponent(gradient)}`;
window.location.href = agencyUrl;
}, 2000); }, 2000);
} catch (error: any) { } catch (error: any) {
@@ -431,8 +480,8 @@ export default function CadastroPage() {
setContacts([{ id: 1, whatsapp: "(11) 98765-4321" }]); setContacts([{ id: 1, whatsapp: "(11) 98765-4321" }]);
setSubdomain("idealpages"); setSubdomain("idealpages");
setDomainAvailable(true); setDomainAvailable(true);
setPrimaryColor("#FF3A05"); setPrimaryColor("#ff3a05");
setSecondaryColor("#FF0080"); setSecondaryColor("#ff0080");
// Marcar todos os steps como completos e ir pro step 5 // Marcar todos os steps como completos e ir pro step 5
setCompletedSteps([1, 2, 3, 4]); setCompletedSteps([1, 2, 3, 4]);
@@ -554,9 +603,9 @@ export default function CadastroPage() {
const getPasswordStrengthColor = () => { const getPasswordStrengthColor = () => {
if (passwordStrength <= 1) return "#EF4444"; if (passwordStrength <= 1) return "#EF4444";
if (passwordStrength === 2) return "#F59E0B"; if (passwordStrength === 2) return "#F59E0B";
if (passwordStrength === 3) return "#3B82F6"; if (passwordStrength === 3) return "#ff3a05";
if (passwordStrength === 4) return "#10B981"; if (passwordStrength === 4) return "#ff3a05";
return "#059669"; return "#ff3a05";
}; };
const fetchCnpjData = async (cnpj: string) => { const fetchCnpjData = async (cnpj: string) => {
@@ -590,16 +639,30 @@ export default function CadastroPage() {
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
if (!data.erro) { if (!data.erro) {
setCepData({ const nextCep = {
state: data.uf || "", state: data.uf || "",
city: data.localidade || "", city: data.localidade || "",
neighborhood: data.bairro || "", neighborhood: data.bairro || "",
street: data.logradouro || "" street: data.logradouro || ""
}); };
setCepData(nextCep);
setFormData(prev => ({
...prev,
state: nextCep.state,
city: nextCep.city,
neighborhood: nextCep.neighborhood,
street: nextCep.street,
}));
} else {
toast.error('CEP não encontrado. Verifique o número.');
setCepData({ state: "", city: "", neighborhood: "", street: "" });
} }
} else {
toast.error('Não foi possível consultar o CEP agora.');
} }
} catch (error) { } catch (error) {
console.error("Erro ao buscar CEP:", error); console.error("Erro ao buscar CEP:", error);
toast.error('Erro ao buscar CEP. Tente novamente.');
} finally { } finally {
setLoadingCep(false); setLoadingCep(false);
} }
@@ -632,7 +695,7 @@ export default function CadastroPage() {
error: { error: {
icon: '⚠️', icon: '⚠️',
style: { style: {
background: '#ff3a05', background: '#ef4444',
color: '#FFFFFF', color: '#FFFFFF',
border: 'none', border: 'none',
}, },
@@ -685,8 +748,8 @@ export default function CadastroPage() {
/> />
<defs> <defs>
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="0%"> <linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#FF3A05" /> <stop offset="0%" stopColor="#ff3a05" />
<stop offset="100%" stopColor="#FF0080" /> <stop offset="100%" stopColor="#ff0080" />
</linearGradient> </linearGradient>
</defs> </defs>
</svg> </svg>
@@ -702,35 +765,12 @@ export default function CadastroPage() {
{currentStepData?.description} {currentStepData?.description}
</p> </p>
</div> </div>
{/* BOTÃO TESTE RÁPIDO - GRANDE E VISÍVEL */}
<button
type="button"
onClick={fillTestData}
className="px-8 py-4 text-xl font-bold text-white bg-yellow-500 hover:bg-yellow-600 rounded-lg shadow-2xl border-4 border-yellow-700 animate-pulse"
style={{ minWidth: '250px' }}
>
TESTE RÁPIDO
</button>
</div> </div>
</div> </div>
{/* Formulário */} {/* Formulário */}
<div className="flex-1 overflow-y-auto bg-[#FDFDFC] px-6 sm:px-12 py-6"> <div className="flex-1 overflow-y-auto bg-[#FDFDFC] px-6 sm:px-12 py-6">
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
{/* Botão Teste Rápido GRANDE */}
<div className="mb-6 p-4 bg-yellow-50 border-2 border-yellow-400 rounded-lg">
<button
type="button"
onClick={fillTestData}
className="w-full px-6 py-4 text-lg font-bold text-white bg-gradient-to-r from-[#FF3A05] to-[#FF0080] rounded-lg hover:opacity-90 transition-opacity shadow-lg"
>
CLIQUE AQUI - PREENCHER DADOS DE TESTE AUTOMATICAMENTE
</button>
<p className="text-sm text-yellow-800 mt-2 text-center">
Preenche todos os campos e vai direto pro Step 5 para você clicar em Finalizar
</p>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleNext(e); }} className="space-y-6"> <form onSubmit={(e) => { e.preventDefault(); handleNext(e); }} className="space-y-6">
{currentStep === 1 && ( {currentStep === 1 && (
<div className="space-y-5"> <div className="space-y-5">
@@ -811,7 +851,11 @@ export default function CadastroPage() {
label={ label={
<span> <span>
Concordo com os{" "} Concordo com os{" "}
<Link href="/termos" className="bg-linear-to-r from-[#FF3A05] to-[#FF0080] bg-clip-text text-transparent hover:underline cursor-pointer font-medium"> <Link
href="/termos"
className="font-medium hover:underline cursor-pointer"
style={{ color: 'var(--brand-color)' }}
>
Termos de Uso Termos de Uso
</Link> </Link>
</span> </span>
@@ -827,7 +871,11 @@ export default function CadastroPage() {
{/* Link para login */} {/* Link para login */}
<p className="text-center mt-6 text-[14px] text-[#7D7D7D]"> <p className="text-center mt-6 text-[14px] text-[#7D7D7D]">
possui uma conta?{" "} possui uma conta?{" "}
<Link href="/login" className="bg-linear-to-r from-[#FF3A05] to-[#FF0080] bg-clip-text text-transparent font-medium hover:underline cursor-pointer"> <Link
href="/login"
className="font-medium hover:underline cursor-pointer"
style={{ color: 'var(--brand-color)' }}
>
Fazer login Fazer login
</Link> </Link>
</p> </p>
@@ -877,13 +925,13 @@ export default function CadastroPage() {
disabled disabled
/> />
<div> <div>
<label className="block text-[13px] font-semibold text-[#000000] mb-2"> <label className="block text-[13px] font-semibold text-zinc-900 mb-2">
Descrição Breve<span className="text-[#FF3A05] ml-1">*</span> Descrição Breve<span className="ml-1" style={{ color: 'var(--brand-color)' }}>*</span>
</label> </label>
<textarea <textarea
name="description" name="description"
placeholder="Apresente sua empresa em poucas palavras (máx 300 caracteres)" placeholder="Apresente sua empresa em poucas palavras (máx 300 caracteres)"
className="w-full px-3.5 py-3 text-[14px] font-normal border rounded-md bg-white placeholder:text-[#7D7D7D] border-[#E5E5E5] focus:border-[#FF3A05] outline-none ring-0 focus:ring-0 shadow-none focus:shadow-none resize-none" className="w-full px-3.5 py-3 text-[14px] font-normal border rounded-md bg-white placeholder:text-zinc-500 border-zinc-200 outline-none ring-0 shadow-none focus:shadow-none resize-none focus:border-(--brand-color)"
rows={4} rows={4}
maxLength={300} maxLength={300}
value={formData.description || ''} value={formData.description || ''}
@@ -973,7 +1021,15 @@ export default function CadastroPage() {
// Se campo vazio, limpar dados // Se campo vazio, limpar dados
if (numbers.length === 0) { if (numbers.length === 0) {
setCepData({ state: "", city: "", neighborhood: "", street: "" }); const emptyCep = { state: "", city: "", neighborhood: "", street: "" };
setCepData(emptyCep);
setFormData(prev => ({
...prev,
state: "",
city: "",
neighborhood: "",
street: "",
}));
} }
// Se CEP completo, buscar dados // Se CEP completo, buscar dados
else if (numbers.length === 8) { else if (numbers.length === 8) {
@@ -1037,19 +1093,19 @@ export default function CadastroPage() {
</div> </div>
{/* Contatos da Empresa */} {/* Contatos da Empresa */}
<div className="pt-4 border-t border-[#E5E5E5]"> <div className="pt-4 border-t border-zinc-200">
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-[#000000]">Contatos da Empresa</h3> <h3 className="text-sm font-semibold text-zinc-900">Contatos da Empresa</h3>
</div> </div>
{contacts.map((contact, index) => ( {contacts.map((contact, index) => (
<div key={contact.id} className="space-y-4 p-4 border border-[#E5E5E5] rounded-md bg-white"> <div key={contact.id} className="space-y-4 p-4 border border-zinc-200 rounded-md bg-white">
{contacts.length > 1 && ( {contacts.length > 1 && (
<div className="flex items-center justify-end -mt-2 -mr-2"> <div className="flex items-center justify-end -mt-2 -mr-2">
<button <button
type="button" type="button"
onClick={() => removeContact(contact.id)} onClick={() => removeContact(contact.id)}
className="text-[#7D7D7D] hover:text-[#FF3A05] transition-colors" className="text-zinc-500 transition-colors hover:text-[var(--brand-color)]"
> >
<i className="ri-close-line text-[18px]" /> <i className="ri-close-line text-[18px]" />
</button> </button>
@@ -1092,8 +1148,8 @@ export default function CadastroPage() {
<div className="space-y-6"> <div className="space-y-6">
{/* Subdomínio Aggios */} {/* Subdomínio Aggios */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-[#000000]"> <label className="block text-sm font-medium text-zinc-900">
Subdomínio Aggios <span className="text-[#FF3A05]">*</span> Subdomínio Aggios <span style={{ color: 'var(--brand-color)' }}>*</span>
</label> </label>
<div className="relative"> <div className="relative">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none"> <div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
@@ -1109,12 +1165,12 @@ export default function CadastroPage() {
}} }}
onBlur={() => subdomain && checkDomainAvailability(subdomain)} onBlur={() => subdomain && checkDomainAvailability(subdomain)}
placeholder="minhaempresa" placeholder="minhaempresa"
className="w-full pl-10 pr-4 py-2 text-sm border border-[#E5E5E5] rounded-md focus:border-[#FF3A05] transition-colors" className="w-full pl-10 pr-4 py-2 text-sm border border-zinc-200 rounded-md transition-colors focus:border-[var(--brand-color)]"
required required
/> />
{checkingDomain && ( {checkingDomain && (
<div className="absolute inset-y-0 right-0 flex items-center pr-3"> <div className="absolute inset-y-0 right-0 flex items-center pr-3">
<div className="w-4 h-4 border-2 border-[#FF3A05] border-t-transparent rounded-full animate-spin" /> <div className="w-4 h-4 border-2 border-[var(--brand-color)] border-t-transparent rounded-full animate-spin" />
</div> </div>
)} )}
{!checkingDomain && domainAvailable === true && ( {!checkingDomain && domainAvailable === true && (
@@ -1124,13 +1180,13 @@ export default function CadastroPage() {
)} )}
{!checkingDomain && domainAvailable === false && ( {!checkingDomain && domainAvailable === false && (
<div className="absolute inset-y-0 right-0 flex items-center pr-3"> <div className="absolute inset-y-0 right-0 flex items-center pr-3">
<i className="ri-close-circle-fill text-[#FF3A05] text-[20px]" /> <i className="ri-close-circle-fill text-red-500 text-[20px]" />
</div> </div>
)} )}
</div> </div>
<p className="text-xs text-[#7D7D7D] flex items-center gap-1"> <p className="text-xs text-zinc-600 flex items-center gap-1">
<i className="ri-information-line" /> <i className="ri-information-line" />
Seu painel ficará em: <span className="font-medium text-[#000000]">{subdomain || 'seu-dominio'}.aggios.app</span> Seu painel ficará em: <span className="font-medium text-zinc-900">{subdomain || 'seu-dominio'}.aggios.app</span>
</p> </p>
{domainAvailable === true && ( {domainAvailable === true && (
<p className="text-xs text-[#10B981] flex items-center gap-1"> <p className="text-xs text-[#10B981] flex items-center gap-1">
@@ -1139,7 +1195,7 @@ export default function CadastroPage() {
</p> </p>
)} )}
{domainAvailable === false && ( {domainAvailable === false && (
<p className="text-xs text-[#FF3A05] flex items-center gap-1"> <p className="text-xs text-red-500 flex items-center gap-1">
<i className="ri-error-warning-line" /> <i className="ri-error-warning-line" />
Indisponível. Este subdomínio está em uso. Indisponível. Este subdomínio está em uso.
</p> </p>
@@ -1148,11 +1204,11 @@ export default function CadastroPage() {
{/* Informações Adicionais */} {/* Informações Adicionais */}
<div className="p-6 bg-[#F5F5F5] rounded-md space-y-3"> <div className="p-6 bg-[#F5F5F5] rounded-md space-y-3">
<h4 className="text-sm font-semibold text-[#000000] flex items-center gap-2"> <h4 className="text-sm font-semibold text-zinc-900 flex items-center gap-2">
<i className="ri-lightbulb-line text-[#FF3A05]" /> <i className="ri-lightbulb-line" style={{ color: 'var(--brand-color)' }} />
Dicas para escolher seu domínio Dicas para escolher seu domínio
</h4> </h4>
<ul className="text-xs text-[#7D7D7D] space-y-1 ml-6"> <ul className="text-xs text-zinc-600 space-y-1 ml-6">
<li className="list-disc">Use o nome da sua empresa</li> <li className="list-disc">Use o nome da sua empresa</li>
<li className="list-disc">Evite números e hífens quando possível</li> <li className="list-disc">Evite números e hífens quando possível</li>
<li className="list-disc">Escolha algo fácil de lembrar e digitar</li> <li className="list-disc">Escolha algo fácil de lembrar e digitar</li>
@@ -1169,7 +1225,14 @@ export default function CadastroPage() {
<button <button
type="button" type="button"
onClick={() => setShowPreviewMobile(!showPreviewMobile)} onClick={() => setShowPreviewMobile(!showPreviewMobile)}
className="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg border-2 border-[#FF3A05] text-[#FF3A05] font-medium hover:bg-[#FF3A05]/5 transition-colors" className="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg border-2 font-medium transition-colors"
style={{
borderColor: 'var(--brand-color)',
color: 'var(--brand-color)',
backgroundColor: showPreviewMobile ? 'transparent' : undefined
}}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--brand-color) 10%, transparent)')}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
> >
<i className={`${showPreviewMobile ? 'ri-edit-line' : 'ri-eye-line'} text-xl`} /> <i className={`${showPreviewMobile ? 'ri-edit-line' : 'ri-eye-line'} text-xl`} />
{showPreviewMobile ? 'Voltar ao Formulário' : 'Ver Preview do Painel'} {showPreviewMobile ? 'Voltar ao Formulário' : 'Ver Preview do Painel'}
@@ -1191,10 +1254,10 @@ export default function CadastroPage() {
{/* Formulário (oculto quando preview ativo no mobile) */} {/* Formulário (oculto quando preview ativo no mobile) */}
<div className={showPreviewMobile ? 'hidden lg:block space-y-4' : 'block space-y-4'}> <div className={showPreviewMobile ? 'hidden lg:block space-y-4' : 'block space-y-4'}>
{/* Upload de Logo */} {/* Upload de Logo Quadrado (Obrigatório) */}
<div> <div>
<label className="block text-sm font-medium text-[#000000] mb-3"> <label className="block text-sm font-medium text-[#000000] mb-3">
Logo da Empresa <span className="text-[#7D7D7D]">(opcional)</span> Logo Quadrado <span className="text-red-500">*</span>
</label> </label>
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
{/* Preview do Logo */} {/* Preview do Logo */}
@@ -1213,50 +1276,119 @@ export default function CadastroPage() {
onChange={(e) => { onChange={(e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
const reader = new FileReader(); handleLogoUpload(file, false);
reader.onloadend = () => {
setLogoUrl(reader.result as string);
};
reader.readAsDataURL(file);
} }
}} }}
className="hidden" className="hidden"
id="logo-upload" id="logo-upload"
disabled={uploadingLogo}
/> />
<label <label
htmlFor="logo-upload" htmlFor="logo-upload"
className="inline-flex items-center gap-2 px-4 py-2 border border-[#E5E5E5] rounded-md text-sm font-medium text-[#000000] hover:bg-[#F5F5F5] transition-colors cursor-pointer" className={`inline-flex items-center gap-2 px-4 py-2 border border-zinc-200 rounded-md text-sm font-medium text-zinc-900 transition-colors ${uploadingLogo ? 'opacity-50 cursor-not-allowed' : 'hover:bg-zinc-50 cursor-pointer'}`}
> >
<i className="ri-upload-2-line" /> {uploadingLogo ? (
Escolher arquivo <>
<i className="ri-loader-4-line animate-spin" />
Enviando...
</>
) : (
<>
<i className="ri-upload-2-line" />
Escolher arquivo
</>
)}
</label> </label>
{logoUrl && ( {logoUrl && (
<button <button
type="button" type="button"
onClick={() => setLogoUrl('')} onClick={() => setLogoUrl('')}
className="ml-2 text-sm bg-linear-to-r from-[#FF3A05] to-[#FF0080] bg-clip-text text-transparent hover:underline font-medium" className="ml-2 text-sm hover:underline font-medium"
style={{ color: 'var(--brand-color)' }}
> >
Remover Remover
</button> </button>
)} )}
<p className="text-xs text-[#7D7D7D] mt-2"> <p className="text-xs text-zinc-600 mt-2">
PNG, JPG ou SVG. Tamanho recomendado: 200x200px PNG, JPG ou SVG. Tamanho recomendado: 200x200px
</p> </p>
</div> </div>
</div> </div>
</div> </div>
{/* Upload de Logo Horizontal (Opcional) */}
<div>
<label className="block text-sm font-medium text-[#000000] mb-3">
Logo Horizontal <span className="text-[#7D7D7D]">(opcional)</span>
</label>
<div className="flex items-center gap-6">
{/* Preview do Logo Horizontal */}
<div className="w-32 h-20 rounded-lg border-2 border-dashed border-[#E5E5E5] flex items-center justify-center overflow-hidden bg-[#F5F5F5]">
{logoHorizontalUrl ? (
<img src={logoHorizontalUrl} alt="Logo horizontal preview" className="w-full h-full object-contain" />
) : (
<i className="ri-image-line text-3xl text-[#7D7D7D]" />
)}
</div>
{/* Input de Upload */}
<div className="flex-1">
<input
type="file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
handleLogoUpload(file, true);
}
}}
className="hidden"
id="logo-horizontal-upload"
disabled={uploadingLogoHorizontal}
/>
<label
htmlFor="logo-horizontal-upload"
className={`inline-flex items-center gap-2 px-4 py-2 border border-zinc-200 rounded-md text-sm font-medium text-zinc-900 transition-colors ${uploadingLogoHorizontal ? 'opacity-50 cursor-not-allowed' : 'hover:bg-zinc-50 cursor-pointer'}`}
>
{uploadingLogoHorizontal ? (
<>
<i className="ri-loader-4-line animate-spin" />
Enviando...
</>
) : (
<>
<i className="ri-upload-2-line" />
Escolher arquivo
</>
)}
</label>
{logoHorizontalUrl && (
<button
type="button"
onClick={() => setLogoHorizontalUrl('')}
className="ml-2 text-sm hover:underline font-medium"
style={{ color: 'var(--brand-color)' }}
>
Remover
</button>
)}
<p className="text-xs text-zinc-600 mt-2">
PNG, JPG ou SVG. Formato horizontal. Ex: 400x100px
</p>
</div>
</div>
</div>
{/* Cores do Painel */} {/* Cores do Painel */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Cor Primária */} {/* Cor Primária */}
<div> <div>
<label className="block text-sm font-medium text-[#000000] mb-3"> <label className="block text-sm font-medium text-zinc-900 mb-3">
Cor Primária <span className="text-[#FF3A05]">*</span> Cor Primária <span style={{ color: 'var(--brand-color)' }}>*</span>
</label> </label>
<div className="flex gap-3"> <div className="flex gap-3">
<div className="relative flex-1"> <div className="relative flex-1">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none"> <div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<i className="ri-palette-line text-[#7D7D7D] text-[18px]" /> <i className="ri-palette-line text-zinc-500 text-[18px]" />
</div> </div>
<input <input
type="text" type="text"
@@ -1267,18 +1399,18 @@ export default function CadastroPage() {
setPrimaryColor(value); setPrimaryColor(value);
} }
}} }}
placeholder="#FF3A05" placeholder="#ff3a05"
className="w-full pl-10 pr-4 py-2 text-sm border border-[#E5E5E5] rounded-md focus:border-[#FF3A05] transition-colors font-mono" className="w-full pl-10 pr-4 py-2 text-sm border border-zinc-200 rounded-md transition-colors font-mono focus:border-[var(--brand-color)]"
/> />
</div> </div>
<input <input
type="color" type="color"
value={primaryColor} value={primaryColor}
onChange={(e) => setPrimaryColor(e.target.value)} onChange={(e) => setPrimaryColor(e.target.value)}
className="w-14 h-10 border-2 border-[#E5E5E5] rounded-md cursor-pointer" className="w-14 h-10 border-2 border-zinc-200 rounded-md cursor-pointer"
/> />
</div> </div>
<p className="text-xs text-[#7D7D7D] mt-1 flex items-center gap-1"> <p className="text-xs text-zinc-600 mt-1 flex items-center gap-1">
<i className="ri-information-line" /> <i className="ri-information-line" />
Usada em menus, botões e destaques Usada em menus, botões e destaques
</p> </p>
@@ -1286,13 +1418,13 @@ export default function CadastroPage() {
{/* Cor Secundária */} {/* Cor Secundária */}
<div> <div>
<label className="block text-sm font-medium text-[#000000] mb-3"> <label className="block text-sm font-medium text-zinc-900 mb-3">
Cor Secundária <span className="text-[#7D7D7D]">(opcional)</span> Cor Secundária <span className="text-zinc-500">(opcional)</span>
</label> </label>
<div className="flex gap-3"> <div className="flex gap-3">
<div className="relative flex-1"> <div className="relative flex-1">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none"> <div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<i className="ri-brush-line text-[#7D7D7D] text-[18px]" /> <i className="ri-brush-line text-zinc-500 text-[18px]" />
</div> </div>
<input <input
type="text" type="text"
@@ -1303,18 +1435,18 @@ export default function CadastroPage() {
setSecondaryColor(value); setSecondaryColor(value);
} }
}} }}
placeholder="#FF0080" placeholder="#ff0080"
className="w-full pl-10 pr-4 py-2 text-sm border border-[#E5E5E5] rounded-md focus:border-[#FF3A05] transition-colors font-mono" className="w-full pl-10 pr-4 py-2 text-sm border border-zinc-200 rounded-md transition-colors font-mono focus:border-[var(--brand-color)]"
/> />
</div> </div>
<input <input
type="color" type="color"
value={secondaryColor} value={secondaryColor}
onChange={(e) => setSecondaryColor(e.target.value)} onChange={(e) => setSecondaryColor(e.target.value)}
className="w-14 h-10 border-2 border-[#E5E5E5] rounded-md cursor-pointer" className="w-14 h-10 border-2 border-zinc-200 rounded-md cursor-pointer"
/> />
</div> </div>
<p className="text-xs text-[#7D7D7D] mt-1 flex items-center gap-1"> <p className="text-xs text-zinc-600 mt-1 flex items-center gap-1">
<i className="ri-information-line" /> <i className="ri-information-line" />
Usada em cards e elementos secundários Usada em cards e elementos secundários
</p> </p>
@@ -1323,10 +1455,10 @@ export default function CadastroPage() {
{/* Paletas Sugeridas */} {/* Paletas Sugeridas */}
<div> <div>
<h4 className="text-sm font-semibold text-[#000000] mb-4">Paletas Sugeridas</h4> <h4 className="text-sm font-semibold text-zinc-900 mb-4">Paletas Sugeridas</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[ {[
{ name: 'Fogo', primary: '#FF3A05', secondary: '#FF0080' }, { name: 'Marca', primary: '#FF3A05', secondary: '#FF0080' },
{ name: 'Oceano', primary: '#0EA5E9', secondary: '#3B82F6' }, { name: 'Oceano', primary: '#0EA5E9', secondary: '#3B82F6' },
{ name: 'Natureza', primary: '#10B981', secondary: '#059669' }, { name: 'Natureza', primary: '#10B981', secondary: '#059669' },
{ name: 'Elegante', primary: '#8B5CF6', secondary: '#A78BFA' }, { name: 'Elegante', primary: '#8B5CF6', secondary: '#A78BFA' },
@@ -1342,7 +1474,8 @@ export default function CadastroPage() {
setPrimaryColor(palette.primary); setPrimaryColor(palette.primary);
setSecondaryColor(palette.secondary); setSecondaryColor(palette.secondary);
}} }}
className="flex items-center gap-2 p-2 rounded-md border border-[#E5E5E5] hover:border-[#FF3A05] transition-colors group cursor-pointer" className="flex items-center gap-2 p-2 rounded-md border border-zinc-200 transition-colors group cursor-pointer"
style={{ borderColor: palette.name === 'Marca' ? 'var(--brand-color)' : undefined }}
> >
<div className="flex gap-1"> <div className="flex gap-1">
<div <div
@@ -1354,7 +1487,7 @@ export default function CadastroPage() {
style={{ backgroundColor: palette.secondary }} style={{ backgroundColor: palette.secondary }}
/> />
</div> </div>
<span className="text-xs font-medium text-[#7D7D7D] group-hover:text-[#000000]"> <span className="text-xs font-medium text-zinc-600 group-hover:text-zinc-900">
{palette.name} {palette.name}
</span> </span>
</button> </button>
@@ -1365,7 +1498,7 @@ export default function CadastroPage() {
{/* Informações */} {/* Informações */}
<div className="p-6 bg-[#F0F9FF] border border-[#BAE6FD] rounded-md"> <div className="p-6 bg-[#F0F9FF] border border-[#BAE6FD] rounded-md">
<div className="flex gap-4"> <div className="flex gap-4">
<i className="ri-information-line text-[#0EA5E9] text-xl mt-0.5" /> <i className="ri-information-line text-[#ff3a05] text-xl mt-0.5" />
<div> <div>
<h4 className="text-sm font-semibold text-[#000000] mb-1"> <h4 className="text-sm font-semibold text-[#000000] mb-1">
Você pode alterar depois Você pode alterar depois
@@ -1385,7 +1518,7 @@ export default function CadastroPage() {
</div> </div>
{/* Rodapé - botão voltar à esquerda, etapas e botão ação à direita */} {/* Rodapé - botão voltar à esquerda, etapas e botão ação à direita */}
<div className="border-t border-[#E5E5E5] bg-white px-4 sm:px-12 py-4"> <div className="border-t border-zinc-200 bg-white px-4 sm:px-12 py-4">
{/* Desktop: Linha única com tudo */} {/* Desktop: Linha única com tudo */}
<div className="hidden md:flex items-center justify-between"> <div className="hidden md:flex items-center justify-between">
{/* Botão voltar à esquerda */} {/* Botão voltar à esquerda */}
@@ -1411,21 +1544,21 @@ export default function CadastroPage() {
? "bg-[#10B981] text-white" ? "bg-[#10B981] text-white"
: currentStep === step.number : currentStep === step.number
? "text-white" ? "text-white"
: "bg-[#E5E5E5] text-[#7D7D7D] group-hover:bg-[#D5D5D5]" : "bg-zinc-200 text-zinc-500 group-hover:bg-zinc-300"
}`} }`}
style={currentStep === step.number ? { background: 'linear-gradient(90deg, #FF3A05, #FF0080)' } : undefined} style={currentStep === step.number ? { background: 'var(--gradient-primary)' } : undefined}
> >
{step.number} {step.number}
</div> </div>
<span className={`text-xs transition-colors ${currentStep === step.number <span className={`text-xs transition-colors ${currentStep === step.number
? "text-[#000000] font-semibold" ? "text-zinc-900 font-semibold"
: "text-[#7D7D7D] group-hover:text-[#000000]" : "text-zinc-500 group-hover:text-zinc-900"
}`}> }`}>
{step.title} {step.title}
</span> </span>
</button> </button>
{index < steps.length - 1 && ( {index < steps.length - 1 && (
<div className="w-12 h-0.5 bg-[#E5E5E5] mb-5" /> <div className="w-12 h-0.5 bg-zinc-200 mb-5" />
)} )}
</div> </div>
))} ))}
@@ -1455,9 +1588,9 @@ export default function CadastroPage() {
? "w-2 bg-[#10B981]" ? "w-2 bg-[#10B981]"
: currentStep === step.number : currentStep === step.number
? "w-8" ? "w-8"
: "w-2 bg-[#E5E5E5] hover:bg-[#D5D5D5]" : "w-2 bg-zinc-200 hover:bg-zinc-300"
}`} }`}
style={currentStep === step.number ? { background: 'linear-gradient(90deg, #FF3A05, #FF0080)' } : undefined} style={currentStep === step.number ? { background: 'var(--gradient-primary)' } : undefined}
aria-label={`Ir para ${step.title}`} aria-label={`Ir para ${step.title}`}
/> />
))} ))}
@@ -1490,7 +1623,7 @@ export default function CadastroPage() {
</div> </div>
{/* Lado Direito - Branding Dinâmico */} {/* Lado Direito - Branding Dinâmico */}
<div className="hidden lg:flex lg:w-[50%] relative overflow-hidden" style={{ background: 'linear-gradient(90deg, #FF3A05, #FF0080)' }}> <div className="hidden lg:flex lg:w-[50%] relative overflow-hidden" style={{ background: 'var(--gradient-primary)' }}>
<DynamicBranding <DynamicBranding
currentStep={currentStep} currentStep={currentStep}
companyName={formData.companyName} companyName={formData.companyName}

View File

@@ -0,0 +1,13 @@
"use client";
export default function LoginLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen bg-[#FDFDFC] dark:bg-gray-900">
{children}
</div>
);
}

View File

@@ -0,0 +1,193 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { Button, Input } from "@/components/ui";
import toast, { Toaster } from 'react-hot-toast';
import { EnvelopeIcon } from "@heroicons/react/24/outline";
export default function RecuperarSenhaPage() {
const [isLoading, setIsLoading] = useState(false);
const [email, setEmail] = useState("");
const [emailSent, setEmailSent] = useState(false);
const [subdomain, setSubdomain] = useState<string>('');
const [isSuperAdmin, setIsSuperAdmin] = useState(false);
useEffect(() => {
if (typeof window !== 'undefined') {
const hostname = window.location.hostname;
const sub = hostname.split('.')[0];
setSubdomain(sub);
setIsSuperAdmin(sub === 'dash');
}
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validações básicas
if (!email) {
toast.error('Por favor, insira seu email');
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
toast.error('Por favor, insira um email válido');
return;
}
setIsLoading(true);
try {
// Simular envio de email
await new Promise((resolve) => setTimeout(resolve, 2000));
setEmailSent(true);
toast.success('Email de recuperação enviado com sucesso!');
} catch (error) {
toast.error('Erro ao enviar email. Tente novamente.');
} finally {
setIsLoading(false);
}
};
return (
<>
<Toaster
position="top-center"
toastOptions={{
duration: 5000,
style: {
background: '#FFFFFF',
color: '#000000',
padding: '16px',
borderRadius: '8px',
border: '1px solid #E5E5E5',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
},
error: {
icon: '⚠️',
style: {
background: '#ef4444',
color: '#FFFFFF',
border: 'none',
},
},
success: {
icon: '✓',
style: {
background: '#10B981',
color: '#FFFFFF',
border: 'none',
},
},
}}
/>
<div className="flex min-h-screen">
{/* Lado Esquerdo - Formulário */}
<div className="w-full lg:w-1/2 flex items-center justify-center px-6 sm:px-12 py-12">
<div className="w-full max-w-md">
{/* Logo mobile */}
<div className="lg:hidden text-center mb-8">
<div className="inline-block px-6 py-3 rounded-2xl" style={{ background: 'var(--brand-color)' }}>
<h1 className="text-3xl font-bold text-white">
{isSuperAdmin ? 'aggios' : subdomain}
</h1>
</div>
</div>
{!emailSent ? (
<>
{/* Header */}
<div className="mb-8">
<h2 className="text-[28px] font-bold text-zinc-900 dark:text-white">
Recuperar Senha
</h2>
<p className="text-[14px] text-zinc-600 dark:text-zinc-400 mt-2">
Digite seu email e enviaremos um link para redefinir sua senha
</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-5">
<Input
label="Email"
type="email"
placeholder="seu@email.com"
leftIcon={<EnvelopeIcon className="w-5 h-5" />}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<Button
type="submit"
variant="primary"
size="lg"
className="w-full"
isLoading={isLoading}
>
Enviar link de recuperação
</Button>
<div className="text-center">
<Link
href="/login"
className="text-[14px] font-medium hover:opacity-80 transition-opacity"
style={{ color: 'var(--brand-color)' }}
>
Voltar para o login
</Link>
</div>
</form>
</>
) : (
<div className="text-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-6">
<i className="ri-mail-check-line text-3xl text-green-600"></i>
</div>
<h2 className="text-[24px] font-bold text-zinc-900 dark:text-white mb-2">
Verifique seu email
</h2>
<p className="text-zinc-600 dark:text-zinc-400 mb-8">
Enviamos um link de recuperação para <strong>{email}</strong>
</p>
<Button
variant="outline"
className="w-full"
onClick={() => setEmailSent(false)}
>
Tentar outro email
</Button>
<div className="mt-6">
<Link
href="/login"
className="text-[14px] font-medium hover:opacity-80 transition-opacity"
style={{ color: 'var(--brand-color)' }}
>
Voltar para o login
</Link>
</div>
</div>
)}
</div>
</div>
{/* Lado Direito - Branding */}
<div className="hidden lg:flex lg:w-1/2 relative overflow-hidden" style={{ background: 'var(--brand-color)' }}>
<div className="absolute inset-0 flex flex-col items-center justify-center p-12 text-white">
<div className="max-w-md text-center">
<h1 className="text-5xl font-bold mb-6">
{isSuperAdmin ? 'aggios' : subdomain}
</h1>
<p className="text-xl opacity-90">
Recupere o acesso à sua conta de forma segura e rápida.
</p>
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,7 @@
'use client';
import { ToastProvider } from '@/components/layout/ToastContext';
export function ClientProviders({ children }: { children: React.ReactNode }) {
return <ToastProvider>{children}</ToastProvider>;
}

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