21 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
243 changed files with 36948 additions and 3043 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>
);
```

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?**

115
README.md
View File

@@ -4,23 +4,60 @@ Plataforma composta por serviços de autenticação, painel administrativo (supe
## Visão geral ## Visão geral
- **Objetivo**: permitir que superadministradores cadastrem e gerenciem agências (tenants) enquanto o site institucional apresenta informações públicas da empresa. - **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 14 (dashboard e site), PostgreSQL, Traefik, Docker. - **Stack**: Go (backend), Next.js 16 (dashboard e site), PostgreSQL, Traefik, Docker.
- **Status**: fluxo de autenticação e gestão de agências concluído; ambiente dockerizável pronto para uso local. - **Status**: Sistema multi-tenant completo com segurança cross-tenant validada, branding dinâmico e file serving via API.
## Componentes principais ## 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}`). - `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. - `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. - `frontend-aggios.app/`: site institucional Next.js com suporte a temas claro/escuro e compartilhamento de tokens de design.
- `postgres/`: scripts de inicialização do banco (estrutura base de tenants e usuários). - `backend/internal/data/postgres/`: scripts de inicialização do banco (estrutura base de tenants e usuários).
- `traefik/`: reverse proxy e certificados automatizados. - `traefik/`: reverse proxy e certificados automatizados.
## Funcionalidades entregues ## Funcionalidades entregues
- Login de superadmin via JWT e restrição de rotas protegidas no dashboard.
- Cadastro de agências: criação de tenant e usuário administrador atrelado. ### **v1.4 - Segurança Multi-tenant e File Serving (13/12/2025)**
- Listagem, detalhamento e exclusão de agências diretamente pelo painel superadmin. - **🔒 Segurança Cross-Tenant Crítica**:
- Proxy interno (`app/api/admin/agencies/[id]/route.ts`) garantindo chamadas autenticadas do Next para o backend. - Validação de tenant_id em endpoints de login (bloqueio de cross-tenant authentication)
- Site institucional com dark mode, componentes compartilhados e tokens de design centralizados. - Validação de tenant em todas rotas protegidas via middleware
- Documentação atualizada em `1. docs/` com fluxos, arquiteturas e changelog. - Mensagens de erro genéricas (sem exposição de arquitetura multi-tenant)
- Logs detalhados de tentativas de acesso cross-tenant bloqueadas
- **📁 File Serving via API**:
- Nova rota `/api/files/{bucket}/{path}` para servir arquivos do MinIO através do backend Go
- Eliminação de dependência de DNS (`files.localhost`) - arquivos servidos via `api.localhost`
- Headers de cache otimizados (Cache-Control: public, max-age=31536000)
- CORS e content-type corretos automaticamente
- **🎨 Melhorias de UX**:
- Mensagens de erro humanizadas no formulário de login (sem pop-ups/toasts)
- Erros inline com ícones e cores apropriadas
- Feedback em tempo real ao digitar (limpeza automática de erros)
- Mensagens específicas para cada tipo de erro (401, 403, 404, 429, 5xx)
- **🔧 Melhorias Técnicas**:
- Next.js middleware injetando headers `X-Tenant-Subdomain` para routing correto
- TenantDetector middleware prioriza headers customizados sobre Host
- Upload de logos retorna URLs via API ao invés de MinIO direto
- Configuração MinIO com variáveis de ambiente `MINIO_SERVER_URL` e `MINIO_BROWSER_REDIRECT_URL`
### **v1.3 - Branding Dinâmico e Favicon (12/12/2025)**
- **Branding Multi-tenant**: Logo, favicon e cores personalizadas por agência
- **Favicon Dinâmico**: Atualização em tempo real via localStorage e SSR metadata
- **Upload de Arquivos**: Sistema de upload para MinIO com bucket público
- **Rate Limiting**: 1000 requisições/minuto por IP
### **v1.2 - Redesign Interface Flat**
- Adoção de design "Flat" (sem sombras), focado em bordas e limpeza visual
- Gestão avançada de agências com filtros robustos
- Detalhamento completo com visualização de branding
### **v1.1 - Fundação Multi-tenant**
- Login de Superadmin com JWT
- Cadastro de Agências
- Proxy Interno Next.js para chamadas autenticadas
- Site Institucional com dark mode
## Executando o projeto ## Executando o projeto
1. **Pré-requisitos**: Docker Desktop e Node.js 20+ (para utilitários opcionais). 1. **Pré-requisitos**: Docker Desktop e Node.js 20+ (para utilitários opcionais).
@@ -30,32 +67,58 @@ Plataforma composta por serviços de autenticação, painel administrativo (supe
docker-compose up --build docker-compose up --build
``` ```
4. **Hosts locais**: 4. **Hosts locais**:
- Painel: `https://dash.localhost` - Painel SuperAdmin: `http://dash.localhost`
- Site: `https://aggios.app.localhost` - Painel Agência: `http://{agencia}.localhost` (ex: `http://idealpages.localhost`)
- API: `https://api.localhost` - Site: `http://aggios.app.localhost`
5. **Credenciais padrão**: ver `postgres/init-db.sql` para usuário superadmin seed. - 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) ## Estrutura de diretórios (resumo)
``` ```
backend/ API Go (config, domínio, handlers, serviços) backend/ API Go (config, domínio, handlers, serviços)
front-end-dash.aggios.app/ Dashboard Next.js Superadmin internal/
frontend-aggios.app/ Site institucional Next.js api/
postgres/ Scripts SQL de seed handlers/
traefik/ Regras de roteamento e TLS files.go 🆕 Handler para servir arquivos via API
1. docs/ Documentação funcional e técnica 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 ## Testes e validação
- Consultar `1. docs/TESTING_GUIDE.md` para cenários funcionais. - Consultar `1. docs/TESTING_GUIDE.md` para cenários funcionais.
- Requisições de verificação recomendadas: - **Testes de Segurança**:
- `curl http://api.localhost/api/admin/agencies` (lista) requer token JWT válido. - ✅ Tentativa de login cross-tenant retorna 403
- `curl http://dash.localhost/api/admin/agencies` (proxy Next) usado pelo painel. - ✅ JWT de uma agência não funciona em outra agência
- Fluxo manual via painel `dash.localhost/superadmin`. - ✅ 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 ## Próximos passos sugeridos
- Implementar soft delete e trilhas de auditoria para exclusão de agências. - Implementar soft delete e trilhas de auditoria para exclusão de agências
- Expandir testes automatizados (unitários e e2e) focados no fluxo do dashboard. - Adicionar validação de permissões por tenant em rotas de files (se necessário)
- Disponibilizar pipeline CI/CD com validações de lint/build. - 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 - Principal: https://git.stackbyte.cloud/erik/aggios.app.git
- Branch: dev-1.4 (Segurança Multi-tenant + File Serving)

View File

@@ -19,7 +19,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server ./cmd/serv
# 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

@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
_ "github.com/lib/pq" _ "github.com/lib/pq"
"github.com/gorilla/mux"
"aggios-app/backend/internal/api/handlers" "aggios-app/backend/internal/api/handlers"
"aggios-app/backend/internal/api/middleware" "aggios-app/backend/internal/api/middleware"
@@ -17,7 +18,7 @@ import (
func initDB(cfg *config.Config) (*sql.DB, error) { func initDB(cfg *config.Config) (*sql.DB, 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",
cfg.Database.Host, cfg.Database.Host,
cfg.Database.Port, cfg.Database.Port,
cfg.Database.User, cfg.Database.User,
@@ -53,20 +54,42 @@ func main() {
userRepo := repository.NewUserRepository(db) userRepo := repository.NewUserRepository(db)
tenantRepo := repository.NewTenantRepository(db) tenantRepo := repository.NewTenantRepository(db)
companyRepo := repository.NewCompanyRepository(db) companyRepo := repository.NewCompanyRepository(db)
signupTemplateRepo := repository.NewSignupTemplateRepository(db)
agencyTemplateRepo := repository.NewAgencyTemplateRepository(db)
planRepo := repository.NewPlanRepository(db)
subscriptionRepo := repository.NewSubscriptionRepository(db)
crmRepo := repository.NewCRMRepository(db)
solutionRepo := repository.NewSolutionRepository(db)
// Initialize services // Initialize services
authService := service.NewAuthService(userRepo, tenantRepo, cfg) authService := service.NewAuthService(userRepo, tenantRepo, cfg)
agencyService := service.NewAgencyService(userRepo, tenantRepo, cfg) agencyService := service.NewAgencyService(userRepo, tenantRepo, cfg, db)
tenantService := service.NewTenantService(tenantRepo) tenantService := service.NewTenantService(tenantRepo, db)
companyService := service.NewCompanyService(companyRepo) companyService := service.NewCompanyService(companyRepo)
planService := service.NewPlanService(planRepo, subscriptionRepo)
// Initialize handlers // Initialize handlers
healthHandler := handlers.NewHealthHandler() healthHandler := handlers.NewHealthHandler()
authHandler := handlers.NewAuthHandler(authService) authHandler := handlers.NewAuthHandler(authService)
agencyProfileHandler := handlers.NewAgencyHandler(tenantRepo) agencyProfileHandler := handlers.NewAgencyHandler(tenantRepo, cfg)
agencyHandler := handlers.NewAgencyRegistrationHandler(agencyService, cfg) agencyHandler := handlers.NewAgencyRegistrationHandler(agencyService, cfg)
tenantHandler := handlers.NewTenantHandler(tenantService) tenantHandler := handlers.NewTenantHandler(tenantService)
companyHandler := handlers.NewCompanyHandler(companyService) companyHandler := handlers.NewCompanyHandler(companyService)
planHandler := handlers.NewPlanHandler(planService)
crmHandler := handlers.NewCRMHandler(crmRepo)
solutionHandler := handlers.NewSolutionHandler(solutionRepo)
signupTemplateHandler := handlers.NewSignupTemplateHandler(signupTemplateRepo, userRepo, tenantRepo, agencyService)
agencyTemplateHandler := handlers.NewAgencyTemplateHandler(agencyTemplateRepo, agencyService, userRepo, tenantRepo)
filesHandler := handlers.NewFilesHandler(cfg)
// Initialize upload handler
uploadHandler, err := handlers.NewUploadHandler(cfg)
if err != nil {
log.Fatalf("❌ Erro ao inicializar upload handler: %v", err)
}
// Initialize backup handler
backupHandler := handlers.NewBackupHandler()
// Create middleware chain // Create middleware chain
tenantDetector := middleware.TenantDetector(tenantRepo) tenantDetector := middleware.TenantDetector(tenantRepo)
@@ -76,43 +99,200 @@ func main() {
authMiddleware := middleware.Auth(cfg) authMiddleware := middleware.Auth(cfg)
// Setup routes // Setup routes
mux := http.NewServeMux() router := mux.NewRouter()
// Health check (no auth) // Serve static files (uploads)
mux.HandleFunc("/health", healthHandler.Check) fs := http.FileServer(http.Dir("./uploads"))
mux.HandleFunc("/api/health", healthHandler.Check) router.PathPrefix("/uploads/").Handler(http.StripPrefix("/uploads", fs))
// Auth routes (public with rate limiting) // ==================== PUBLIC ROUTES ====================
mux.HandleFunc("/api/auth/login", authHandler.Login)
// Protected auth routes // Health check
mux.Handle("/api/auth/change-password", authMiddleware(http.HandlerFunc(authHandler.ChangePassword))) router.HandleFunc("/health", healthHandler.Check)
router.HandleFunc("/api/health", healthHandler.Check)
// Agency management (SUPERADMIN only) // Auth
mux.HandleFunc("/api/admin/agencies/register", agencyHandler.RegisterAgency) router.HandleFunc("/api/auth/login", authHandler.Login)
mux.HandleFunc("/api/admin/agencies", tenantHandler.ListAll) router.HandleFunc("/api/auth/register", agencyHandler.PublicRegister).Methods("POST")
mux.HandleFunc("/api/admin/agencies/", agencyHandler.HandleAgency)
// Client registration (ADMIN_AGENCIA only - requires auth) // Public agency template registration (for creating new agencies)
mux.Handle("/api/agencies/clients/register", authMiddleware(http.HandlerFunc(agencyHandler.RegisterClient))) 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) // Agency profile routes (protected)
mux.Handle("/api/agency/profile", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { router.Handle("/api/agency/profile", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet { switch r.Method {
case http.MethodGet:
agencyProfileHandler.GetProfile(w, r) agencyProfileHandler.GetProfile(w, r)
} else if r.Method == http.MethodPut || r.Method == http.MethodPatch { case http.MethodPut, http.MethodPatch:
agencyProfileHandler.UpdateProfile(w, r) agencyProfileHandler.UpdateProfile(w, r)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
} }
}))) }))).Methods("GET", "PUT", "PATCH")
// Protected routes (require authentication) // Agency logo upload (protected)
mux.Handle("/api/companies", authMiddleware(http.HandlerFunc(companyHandler.List))) router.Handle("/api/agency/logo", authMiddleware(http.HandlerFunc(agencyProfileHandler.UploadLogo))).Methods("POST")
mux.Handle("/api/companies/create", authMiddleware(http.HandlerFunc(companyHandler.Create)))
// Apply global middlewares: tenant -> cors -> security -> rateLimit -> mux // File serving route (public - serves files from MinIO through API)
handler := tenantDetector(corsMiddleware(securityMiddleware(rateLimitMiddleware(mux)))) 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 // Start server
addr := fmt.Sprintf(":%s", cfg.Server.Port) addr := fmt.Sprintf(":%s", cfg.Server.Port)

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

@@ -6,5 +6,6 @@ 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/lib/pq v1.10.9 github.com/lib/pq v1.10.9
github.com/minio/minio-go/v7 v7.0.63
golang.org/x/crypto v0.27.0 golang.org/x/crypto v0.27.0
) )

View File

@@ -5,7 +5,6 @@ import (
"errors" "errors"
"log" "log"
"net/http" "net/http"
"strings"
"time" "time"
"aggios-app/backend/internal/config" "aggios-app/backend/internal/config"
@@ -13,6 +12,7 @@ import (
"aggios-app/backend/internal/service" "aggios-app/backend/internal/service"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/gorilla/mux"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -45,6 +45,8 @@ func (h *AgencyRegistrationHandler) RegisterAgency(w http.ResponseWriter, r *htt
} }
log.Printf("📥 Registering agency: %s (subdomain: %s)", req.AgencyName, req.Subdomain) 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) tenant, admin, err := h.agencyService.RegisterAgency(req)
if err != nil { if err != nil {
@@ -104,6 +106,112 @@ func (h *AgencyRegistrationHandler) RegisterAgency(w http.ResponseWriter, r *htt
json.NewEncoder(w).Encode(response) 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) // RegisterClient handles client registration (ADMIN_AGENCIA only)
func (h *AgencyRegistrationHandler) RegisterClient(w http.ResponseWriter, r *http.Request) { func (h *AgencyRegistrationHandler) RegisterClient(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
@@ -147,9 +255,10 @@ func (h *AgencyRegistrationHandler) HandleAgency(w http.ResponseWriter, r *http.
return return
} }
agencyID := strings.TrimPrefix(r.URL.Path, "/api/admin/agencies/") vars := mux.Vars(r)
if agencyID == "" || agencyID == r.URL.Path { agencyID := vars["id"]
http.NotFound(w, r) if agencyID == "" {
http.Error(w, "Missing agency ID", http.StatusBadRequest)
return return
} }
@@ -174,6 +283,27 @@ func (h *AgencyRegistrationHandler) HandleAgency(w http.ResponseWriter, r *http.
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(details) 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: case http.MethodDelete:
if err := h.agencyService.DeleteAgency(id); err != nil { if err := h.agencyService.DeleteAgency(id); err != nil {
if errors.Is(err, service.ErrTenantNotFound) { if errors.Is(err, service.ErrTenantNotFound) {

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

@@ -2,8 +2,11 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"log"
"net/http" "net/http"
"aggios-app/backend/internal/api/middleware"
"aggios-app/backend/internal/config"
"aggios-app/backend/internal/repository" "aggios-app/backend/internal/repository"
"github.com/google/uuid" "github.com/google/uuid"
@@ -11,43 +14,61 @@ import (
type AgencyHandler struct { type AgencyHandler struct {
tenantRepo *repository.TenantRepository tenantRepo *repository.TenantRepository
config *config.Config
} }
func NewAgencyHandler(tenantRepo *repository.TenantRepository) *AgencyHandler { func NewAgencyHandler(tenantRepo *repository.TenantRepository, cfg *config.Config) *AgencyHandler {
return &AgencyHandler{ return &AgencyHandler{
tenantRepo: tenantRepo, tenantRepo: tenantRepo,
config: cfg,
} }
} }
type AgencyProfileResponse struct { type AgencyProfileResponse struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
CNPJ string `json:"cnpj"` CNPJ string `json:"cnpj"`
Email string `json:"email"` Email string `json:"email"`
Phone string `json:"phone"` Phone string `json:"phone"`
Website string `json:"website"` Website string `json:"website"`
Address string `json:"address"` Address string `json:"address"`
City string `json:"city"` Neighborhood string `json:"neighborhood"`
State string `json:"state"` Number string `json:"number"`
Zip string `json:"zip"` Complement string `json:"complement"`
RazaoSocial string `json:"razao_social"` City string `json:"city"`
Description string `json:"description"` State string `json:"state"`
Industry string `json:"industry"` 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 { type UpdateAgencyProfileRequest struct {
Name string `json:"name"` Name string `json:"name"`
CNPJ string `json:"cnpj"` CNPJ string `json:"cnpj"`
Email string `json:"email"` Email string `json:"email"`
Phone string `json:"phone"` Phone string `json:"phone"`
Website string `json:"website"` Website string `json:"website"`
Address string `json:"address"` Address string `json:"address"`
City string `json:"city"` Neighborhood string `json:"neighborhood"`
State string `json:"state"` Number string `json:"number"`
Zip string `json:"zip"` Complement string `json:"complement"`
RazaoSocial string `json:"razao_social"` City string `json:"city"`
Description string `json:"description"` State string `json:"state"`
Industry string `json:"industry"` 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 // GetProfile returns the current agency profile
@@ -57,10 +78,11 @@ func (h *AgencyHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
return return
} }
// Get tenant from context (set by middleware) // Get tenant from context (set by auth middleware)
tenantID := r.Context().Value("tenantID") tenantID := r.Context().Value(middleware.TenantIDKey)
if tenantID == nil { if tenantID == nil {
http.Error(w, "Tenant not found", http.StatusUnauthorized) http.Error(w, "Tenant not found in context", http.StatusUnauthorized)
return return
} }
@@ -82,20 +104,32 @@ func (h *AgencyHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
return 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{ response := AgencyProfileResponse{
ID: tenant.ID.String(), ID: tenant.ID.String(),
Name: tenant.Name, Name: tenant.Name,
CNPJ: tenant.CNPJ, CNPJ: tenant.CNPJ,
Email: tenant.Email, Email: tenant.Email,
Phone: tenant.Phone, Phone: tenant.Phone,
Website: tenant.Website, Website: tenant.Website,
Address: tenant.Address, Address: tenant.Address,
City: tenant.City, Neighborhood: tenant.Neighborhood,
State: tenant.State, Number: tenant.Number,
Zip: tenant.Zip, Complement: tenant.Complement,
RazaoSocial: tenant.RazaoSocial, City: tenant.City,
Description: tenant.Description, State: tenant.State,
Industry: tenant.Industry, 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") w.Header().Set("Content-Type", "application/json")
@@ -109,8 +143,8 @@ func (h *AgencyHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
return return
} }
// Get tenant from context // Get tenant from context (set by auth middleware)
tenantID := r.Context().Value("tenantID") tenantID := r.Context().Value(middleware.TenantIDKey)
if tenantID == nil { if tenantID == nil {
http.Error(w, "Tenant not found", http.StatusUnauthorized) http.Error(w, "Tenant not found", http.StatusUnauthorized)
return return
@@ -131,18 +165,26 @@ func (h *AgencyHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
// Prepare updates // Prepare updates
updates := map[string]interface{}{ updates := map[string]interface{}{
"name": req.Name, "name": req.Name,
"cnpj": req.CNPJ, "cnpj": req.CNPJ,
"razao_social": req.RazaoSocial, "razao_social": req.RazaoSocial,
"email": req.Email, "email": req.Email,
"phone": req.Phone, "phone": req.Phone,
"website": req.Website, "website": req.Website,
"address": req.Address, "address": req.Address,
"city": req.City, "neighborhood": req.Neighborhood,
"state": req.State, "number": req.Number,
"zip": req.Zip, "complement": req.Complement,
"description": req.Description, "city": req.City,
"industry": req.Industry, "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 // Update in database
@@ -159,21 +201,30 @@ func (h *AgencyHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
} }
response := AgencyProfileResponse{ response := AgencyProfileResponse{
ID: tenant.ID.String(), ID: tenant.ID.String(),
Name: tenant.Name, Name: tenant.Name,
CNPJ: tenant.CNPJ, CNPJ: tenant.CNPJ,
Email: tenant.Email, Email: tenant.Email,
Phone: tenant.Phone, Phone: tenant.Phone,
Website: tenant.Website, Website: tenant.Website,
Address: tenant.Address, Address: tenant.Address,
City: tenant.City, Neighborhood: tenant.Neighborhood,
State: tenant.State, Number: tenant.Number,
Zip: tenant.Zip, Complement: tenant.Complement,
RazaoSocial: tenant.RazaoSocial, City: tenant.City,
Description: tenant.Description, State: tenant.State,
Industry: tenant.Industry, 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") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response) 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

@@ -3,9 +3,11 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"io" "io"
"log"
"net/http" "net/http"
"strings" "strings"
"aggios-app/backend/internal/api/middleware"
"aggios-app/backend/internal/domain" "aggios-app/backend/internal/domain"
"aggios-app/backend/internal/service" "aggios-app/backend/internal/service"
) )
@@ -55,28 +57,38 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
// Login handles user login // Login handles user login
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
log.Printf("🔐 LOGIN HANDLER CALLED - Method: %s", r.Method)
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
log.Printf("❌ Method not allowed: %s", r.Method)
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return return
} }
bodyBytes, err := io.ReadAll(r.Body) bodyBytes, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
log.Printf("❌ Failed to read body: %v", err)
http.Error(w, "Failed to read request body", http.StatusBadRequest) http.Error(w, "Failed to read request body", http.StatusBadRequest)
return return
} }
defer r.Body.Close() defer r.Body.Close()
log.Printf("📥 Raw body: %s", string(bodyBytes))
// Trim whitespace to avoid decode errors caused by BOM or stray chars // Trim whitespace to avoid decode errors caused by BOM or stray chars
sanitized := strings.TrimSpace(string(bodyBytes)) sanitized := strings.TrimSpace(string(bodyBytes))
var req domain.LoginRequest var req domain.LoginRequest
if err := json.Unmarshal([]byte(sanitized), &req); err != nil { if err := json.Unmarshal([]byte(sanitized), &req); err != nil {
log.Printf("❌ JSON parse error: %v", err)
http.Error(w, "Invalid request body", http.StatusBadRequest) http.Error(w, "Invalid request body", http.StatusBadRequest)
return return
} }
log.Printf("📧 Login attempt for email: %s", req.Email)
response, err := h.authService.Login(req) response, err := h.authService.Login(req)
if err != nil { if err != nil {
log.Printf("❌ authService.Login error: %v", err)
if err == service.ErrInvalidCredentials { if err == service.ErrInvalidCredentials {
http.Error(w, err.Error(), http.StatusUnauthorized) http.Error(w, err.Error(), http.StatusUnauthorized)
} else { } else {
@@ -85,6 +97,24 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
return 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") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response) json.NewEncoder(w).Encode(response)
} }

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

@@ -2,9 +2,9 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"log"
"net/http" "net/http"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/service" "aggios-app/backend/internal/service"
) )
@@ -27,16 +27,82 @@ func (h *TenantHandler) ListAll(w http.ResponseWriter, r *http.Request) {
return return
} }
tenants, err := h.tenantService.ListAll() tenants, err := h.tenantService.ListAllWithDetails()
if err != nil { if err != nil {
log.Printf("Error listing tenants with details: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
} }
if tenants == nil { if tenants == nil {
tenants = []*domain.Tenant{} tenants = []map[string]interface{}{}
} }
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
json.NewEncoder(w).Encode(tenants) 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

@@ -2,6 +2,7 @@ package middleware
import ( import (
"context" "context"
"log"
"net/http" "net/http"
"strings" "strings"
@@ -13,6 +14,7 @@ import (
type contextKey string type contextKey string
const UserIDKey contextKey = "userID" const UserIDKey contextKey = "userID"
const TenantIDKey contextKey = "tenantID"
// Auth validates JWT tokens // Auth validates JWT tokens
func Auth(cfg *config.Config) func(http.Handler) http.Handler { func Auth(cfg *config.Config) func(http.Handler) http.Handler {
@@ -39,15 +41,60 @@ func Auth(cfg *config.Config) func(http.Handler) http.Handler {
return return
} }
claims, ok := token.Claims.(jwt.MapClaims) claims, ok := token.Claims.(jwt.MapClaims)
if !ok { if !ok {
http.Error(w, "Invalid token claims", http.StatusUnauthorized) 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 return
} }
log.Printf("✅ TENANT VALIDATION PASSED: %s", tenantIDFromJWT)
}
userID := claims["user_id"].(string) // Preservar TODOS os valores do contexto anterior (incluindo o tenantID do TenantDetector)
ctx := context.WithValue(r.Context(), UserIDKey, userID) ctx := r.Context()
next.ServeHTTP(w, r.WithContext(ctx)) 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

@@ -2,22 +2,39 @@ package middleware
import ( import (
"context" "context"
"log"
"net/http" "net/http"
"strings" "strings"
"aggios-app/backend/internal/repository" "aggios-app/backend/internal/repository"
) )
type tenantContextKey string const SubdomainKey contextKey = "subdomain"
const TenantIDKey tenantContextKey = "tenantID"
const SubdomainKey tenantContextKey = "subdomain"
// TenantDetector detects tenant from subdomain // TenantDetector detects tenant from subdomain
func TenantDetector(tenantRepo *repository.TenantRepository) func(http.Handler) http.Handler { func TenantDetector(tenantRepo *repository.TenantRepository) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host := r.Host // 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 // Extract subdomain
// Examples: // Examples:
@@ -26,19 +43,32 @@ func TenantDetector(tenantRepo *repository.TenantRepository) func(http.Handler)
// - dash.localhost -> dash (master admin) // - dash.localhost -> dash (master admin)
// - localhost -> (institutional site) // - localhost -> (institutional site)
parts := strings.Split(host, ".")
var subdomain string var subdomain string
if len(parts) >= 2 { // If we got the subdomain directly from X-Tenant-Subdomain, use it
// Has subdomain if tenantSubdomain != "" {
subdomain = parts[0] subdomain = tenantSubdomain
// Remove port if present // Remove port if present
if strings.Contains(subdomain, ":") { if strings.Contains(subdomain, ":") {
subdomain = strings.Split(subdomain, ":")[0] 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 // Add subdomain to context
ctx := context.WithValue(r.Context(), SubdomainKey, subdomain) ctx := context.WithValue(r.Context(), SubdomainKey, subdomain)
@@ -46,7 +76,10 @@ func TenantDetector(tenantRepo *repository.TenantRepository) func(http.Handler)
if subdomain != "" && subdomain != "dash" && subdomain != "api" && subdomain != "localhost" { if subdomain != "" && subdomain != "dash" && subdomain != "api" && subdomain != "localhost" {
tenant, err := tenantRepo.FindBySubdomain(subdomain) tenant, err := tenantRepo.FindBySubdomain(subdomain)
if err == nil && tenant != nil { 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()) ctx = context.WithValue(ctx, TenantIDKey, tenant.ID.String())
} else {
log.Printf("TenantDetector: tenant not found for subdomain %s (err=%v)", subdomain, err)
} }
} }

View File

@@ -11,6 +11,7 @@ type Config struct {
JWT JWTConfig JWT JWTConfig
Security SecurityConfig Security SecurityConfig
App AppConfig App AppConfig
Minio MinioConfig
} }
// AppConfig holds application-level settings // AppConfig holds application-level settings
@@ -45,6 +46,16 @@ type SecurityConfig struct {
PasswordMinLength 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 // Load loads configuration from environment variables
func Load() *Config { func Load() *Config {
env := getEnvOrDefault("APP_ENV", "development") env := getEnvOrDefault("APP_ENV", "development")
@@ -53,6 +64,12 @@ func Load() *Config {
baseDomain = "aggios.app" 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{ return &Config{
Server: ServerConfig{ Server: ServerConfig{
Port: getEnvOrDefault("SERVER_PORT", "8080"), Port: getEnvOrDefault("SERVER_PORT", "8080"),
@@ -81,9 +98,17 @@ func Load() *Config {
"https://dash.aggios.app", "https://dash.aggios.app",
"https://www.aggios.app", "https://www.aggios.app",
}, },
MaxAttemptsPerMin: 5, MaxAttemptsPerMin: maxAttempts,
PasswordMinLength: 8, 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"),
},
} }
} }

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

@@ -18,14 +18,22 @@ type Tenant struct {
Phone string `json:"phone,omitempty" db:"phone"` Phone string `json:"phone,omitempty" db:"phone"`
Website string `json:"website,omitempty" db:"website"` Website string `json:"website,omitempty" db:"website"`
Address string `json:"address,omitempty" db:"address"` 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"` City string `json:"city,omitempty" db:"city"`
State string `json:"state,omitempty" db:"state"` State string `json:"state,omitempty" db:"state"`
Zip string `json:"zip,omitempty" db:"zip"` Zip string `json:"zip,omitempty" db:"zip"`
Description string `json:"description,omitempty" db:"description"` Description string `json:"description,omitempty" db:"description"`
Industry string `json:"industry,omitempty" db:"industry"` Industry string `json:"industry,omitempty" db:"industry"`
IsActive bool `json:"is_active" db:"is_active"` TeamSize string `json:"team_size,omitempty" db:"team_size"`
CreatedAt time.Time `json:"created_at" db:"created_at"` PrimaryColor string `json:"primary_color,omitempty" db:"primary_color"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"` 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 // CreateTenantRequest represents the request to create a new tenant
@@ -37,7 +45,15 @@ type CreateTenantRequest struct {
// AgencyDetails aggregates tenant info with its admin user for superadmin view // AgencyDetails aggregates tenant info with its admin user for superadmin view
type AgencyDetails struct { type AgencyDetails struct {
Tenant *Tenant `json:"tenant"` Tenant *Tenant `json:"tenant"`
Admin *User `json:"admin,omitempty"` Admin *User `json:"admin,omitempty"`
AccessURL string `json:"access_url"` 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

@@ -36,6 +36,8 @@ type RegisterAgencyRequest struct {
Description string `json:"description"` Description string `json:"description"`
Website string `json:"website"` Website string `json:"website"`
Industry string `json:"industry"` Industry string `json:"industry"`
Phone string `json:"phone"`
TeamSize string `json:"teamSize"`
// Endereço // Endereço
CEP string `json:"cep"` CEP string `json:"cep"`
@@ -46,12 +48,59 @@ type RegisterAgencyRequest struct {
Number string `json:"number"` Number string `json:"number"`
Complement string `json:"complement"` 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 // Admin da Agência
AdminEmail string `json:"adminEmail"` AdminEmail string `json:"adminEmail"`
AdminPassword string `json:"adminPassword"` AdminPassword string `json:"adminPassword"`
AdminName string `json:"adminName"` 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) // RegisterClientRequest represents client registration (ADMIN_AGENCIA only)
type RegisterClientRequest struct { type RegisterClientRequest struct {
Email string `json:"email"` Email string `json:"email"`

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

@@ -19,14 +19,21 @@ func NewTenantRepository(db *sql.DB) *TenantRepository {
return &TenantRepository{db: db} return &TenantRepository{db: db}
} }
// DB returns the underlying database connection
func (r *TenantRepository) DB() *sql.DB {
return r.db
}
// Create creates a new tenant // Create creates a new tenant
func (r *TenantRepository) Create(tenant *domain.Tenant) error { func (r *TenantRepository) Create(tenant *domain.Tenant) error {
query := ` query := `
INSERT INTO tenants ( INSERT INTO tenants (
id, name, domain, subdomain, cnpj, razao_social, email, website, id, name, domain, subdomain, cnpj, razao_social, email, phone, website,
address, city, state, zip, description, industry, created_at, updated_at 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) 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 RETURNING id, created_at, updated_at
` `
@@ -44,13 +51,22 @@ func (r *TenantRepository) Create(tenant *domain.Tenant) error {
tenant.CNPJ, tenant.CNPJ,
tenant.RazaoSocial, tenant.RazaoSocial,
tenant.Email, tenant.Email,
tenant.Phone,
tenant.Website, tenant.Website,
tenant.Address, tenant.Address,
tenant.Neighborhood,
tenant.Number,
tenant.Complement,
tenant.City, tenant.City,
tenant.State, tenant.State,
tenant.Zip, tenant.Zip,
tenant.Description, tenant.Description,
tenant.Industry, tenant.Industry,
tenant.TeamSize,
tenant.PrimaryColor,
tenant.SecondaryColor,
tenant.LogoURL,
tenant.LogoHorizontalURL,
tenant.CreatedAt, tenant.CreatedAt,
tenant.UpdatedAt, tenant.UpdatedAt,
).Scan(&tenant.ID, &tenant.CreatedAt, &tenant.UpdatedAt) ).Scan(&tenant.ID, &tenant.CreatedAt, &tenant.UpdatedAt)
@@ -60,13 +76,15 @@ func (r *TenantRepository) Create(tenant *domain.Tenant) error {
func (r *TenantRepository) FindByID(id uuid.UUID) (*domain.Tenant, error) { func (r *TenantRepository) FindByID(id uuid.UUID) (*domain.Tenant, error) {
query := ` query := `
SELECT id, name, domain, subdomain, cnpj, razao_social, email, phone, website, SELECT id, name, domain, subdomain, cnpj, razao_social, email, phone, website,
address, city, state, zip, description, industry, is_active, created_at, updated_at 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 FROM tenants
WHERE id = $1 WHERE id = $1
` `
tenant := &domain.Tenant{} tenant := &domain.Tenant{}
var cnpj, razaoSocial, email, phone, website, address, city, state, zip, description, industry sql.NullString 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( err := r.db.QueryRow(query, id).Scan(
&tenant.ID, &tenant.ID,
@@ -79,11 +97,19 @@ func (r *TenantRepository) FindByID(id uuid.UUID) (*domain.Tenant, error) {
&phone, &phone,
&website, &website,
&address, &address,
&neighborhood,
&number,
&complement,
&city, &city,
&state, &state,
&zip, &zip,
&description, &description,
&industry, &industry,
&teamSize,
&primaryColor,
&secondaryColor,
&logoURL,
&logoHorizontalURL,
&tenant.IsActive, &tenant.IsActive,
&tenant.CreatedAt, &tenant.CreatedAt,
&tenant.UpdatedAt, &tenant.UpdatedAt,
@@ -116,6 +142,15 @@ func (r *TenantRepository) FindByID(id uuid.UUID) (*domain.Tenant, error) {
if address.Valid { if address.Valid {
tenant.Address = address.String 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 { if city.Valid {
tenant.City = city.String tenant.City = city.String
} }
@@ -131,6 +166,21 @@ func (r *TenantRepository) FindByID(id uuid.UUID) (*domain.Tenant, error) {
if industry.Valid { if industry.Valid {
tenant.Industry = industry.String 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 return tenant, nil
} }
@@ -138,17 +188,23 @@ func (r *TenantRepository) FindByID(id uuid.UUID) (*domain.Tenant, error) {
// FindBySubdomain finds a tenant by subdomain // FindBySubdomain finds a tenant by subdomain
func (r *TenantRepository) FindBySubdomain(subdomain string) (*domain.Tenant, error) { func (r *TenantRepository) FindBySubdomain(subdomain string) (*domain.Tenant, error) {
query := ` query := `
SELECT id, name, domain, subdomain, created_at, updated_at SELECT id, name, domain, subdomain, primary_color, secondary_color, logo_url, logo_horizontal_url, created_at, updated_at
FROM tenants FROM tenants
WHERE subdomain = $1 WHERE subdomain = $1
` `
tenant := &domain.Tenant{} tenant := &domain.Tenant{}
var primaryColor, secondaryColor, logoURL, logoHorizontalURL sql.NullString
err := r.db.QueryRow(query, subdomain).Scan( err := r.db.QueryRow(query, subdomain).Scan(
&tenant.ID, &tenant.ID,
&tenant.Name, &tenant.Name,
&tenant.Domain, &tenant.Domain,
&tenant.Subdomain, &tenant.Subdomain,
&primaryColor,
&secondaryColor,
&logoURL,
&logoHorizontalURL,
&tenant.CreatedAt, &tenant.CreatedAt,
&tenant.UpdatedAt, &tenant.UpdatedAt,
) )
@@ -157,7 +213,24 @@ func (r *TenantRepository) FindBySubdomain(subdomain string) (*domain.Tenant, er
return nil, nil return nil, nil
} }
return tenant, err 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 // SubdomainExists checks if a subdomain is already taken
@@ -171,7 +244,7 @@ func (r *TenantRepository) SubdomainExists(subdomain string) (bool, error) {
// FindAll returns all tenants // FindAll returns all tenants
func (r *TenantRepository) FindAll() ([]*domain.Tenant, error) { func (r *TenantRepository) FindAll() ([]*domain.Tenant, error) {
query := ` query := `
SELECT id, name, domain, subdomain, is_active, created_at, updated_at SELECT id, name, domain, subdomain, email, phone, cnpj, logo_url, is_active, created_at, updated_at
FROM tenants FROM tenants
ORDER BY created_at DESC ORDER BY created_at DESC
` `
@@ -185,11 +258,17 @@ func (r *TenantRepository) FindAll() ([]*domain.Tenant, error) {
var tenants []*domain.Tenant var tenants []*domain.Tenant
for rows.Next() { for rows.Next() {
tenant := &domain.Tenant{} tenant := &domain.Tenant{}
var email, phone, cnpj, logoURL sql.NullString
err := rows.Scan( err := rows.Scan(
&tenant.ID, &tenant.ID,
&tenant.Name, &tenant.Name,
&tenant.Domain, &tenant.Domain,
&tenant.Subdomain, &tenant.Subdomain,
&email,
&phone,
&cnpj,
&logoURL,
&tenant.IsActive, &tenant.IsActive,
&tenant.CreatedAt, &tenant.CreatedAt,
&tenant.UpdatedAt, &tenant.UpdatedAt,
@@ -197,6 +276,20 @@ func (r *TenantRepository) FindAll() ([]*domain.Tenant, error) {
if err != nil { if err != nil {
return nil, err 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) tenants = append(tenants, tenant)
} }
@@ -209,7 +302,21 @@ func (r *TenantRepository) FindAll() ([]*domain.Tenant, error) {
// Delete removes a tenant (and cascades to related data) // Delete removes a tenant (and cascades to related data)
func (r *TenantRepository) Delete(id uuid.UUID) error { func (r *TenantRepository) Delete(id uuid.UUID) error {
result, err := r.db.Exec(`DELETE FROM tenants WHERE id = $1`, id) // 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 { if err != nil {
return err return err
} }
@@ -223,7 +330,8 @@ func (r *TenantRepository) Delete(id uuid.UUID) error {
return sql.ErrNoRows return sql.ErrNoRows
} }
return nil // Commit transaction
return tx.Commit()
} }
// UpdateProfile updates tenant profile information // UpdateProfile updates tenant profile information
@@ -237,13 +345,21 @@ func (r *TenantRepository) UpdateProfile(id uuid.UUID, updates map[string]interf
phone = COALESCE($5, phone), phone = COALESCE($5, phone),
website = COALESCE($6, website), website = COALESCE($6, website),
address = COALESCE($7, address), address = COALESCE($7, address),
city = COALESCE($8, city), neighborhood = COALESCE($8, neighborhood),
state = COALESCE($9, state), number = COALESCE($9, number),
zip = COALESCE($10, zip), complement = COALESCE($10, complement),
description = COALESCE($11, description), city = COALESCE($11, city),
industry = COALESCE($12, industry), state = COALESCE($12, state),
updated_at = $13 zip = COALESCE($13, zip),
WHERE id = $14 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( _, err := r.db.Exec(
@@ -255,14 +371,29 @@ func (r *TenantRepository) UpdateProfile(id uuid.UUID, updates map[string]interf
updates["phone"], updates["phone"],
updates["website"], updates["website"],
updates["address"], updates["address"],
updates["neighborhood"],
updates["number"],
updates["complement"],
updates["city"], updates["city"],
updates["state"], updates["state"],
updates["zip"], updates["zip"],
updates["description"], updates["description"],
updates["industry"], updates["industry"],
updates["team_size"],
updates["primary_color"],
updates["secondary_color"],
updates["logo_url"],
updates["logo_horizontal_url"],
time.Now(), time.Now(),
id, id,
) )
return err 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

@@ -2,6 +2,7 @@ package repository
import ( import (
"database/sql" "database/sql"
"log"
"time" "time"
"aggios-app/backend/internal/domain" "aggios-app/backend/internal/domain"
@@ -53,6 +54,8 @@ func (r *UserRepository) Create(user *domain.User) error {
// FindByEmail finds a user by email // FindByEmail finds a user by email
func (r *UserRepository) FindByEmail(email string) (*domain.User, error) { func (r *UserRepository) FindByEmail(email string) (*domain.User, error) {
log.Printf("🔍 FindByEmail called with: %s", email)
query := ` query := `
SELECT id, tenant_id, email, password_hash, first_name, role, created_at, updated_at SELECT id, tenant_id, email, password_hash, first_name, role, created_at, updated_at
FROM users FROM users
@@ -72,10 +75,16 @@ func (r *UserRepository) FindByEmail(email string) (*domain.User, error) {
) )
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
log.Printf("❌ User not found: %s", email)
return nil, nil return nil, nil
} }
if err != nil {
log.Printf("❌ DB error finding user %s: %v", email, err)
return nil, err
}
return user, err log.Printf("✅ Found user: %s, role: %s", user.Email, user.Role)
return user, nil
} }
// FindByID finds a user by ID // FindByID finds a user by ID

View File

@@ -4,6 +4,7 @@ import (
"aggios-app/backend/internal/config" "aggios-app/backend/internal/config"
"aggios-app/backend/internal/domain" "aggios-app/backend/internal/domain"
"aggios-app/backend/internal/repository" "aggios-app/backend/internal/repository"
"database/sql"
"fmt" "fmt"
"github.com/google/uuid" "github.com/google/uuid"
@@ -15,14 +16,16 @@ type AgencyService struct {
userRepo *repository.UserRepository userRepo *repository.UserRepository
tenantRepo *repository.TenantRepository tenantRepo *repository.TenantRepository
cfg *config.Config cfg *config.Config
db *sql.DB
} }
// NewAgencyService creates a new agency service // NewAgencyService creates a new agency service
func NewAgencyService(userRepo *repository.UserRepository, tenantRepo *repository.TenantRepository, cfg *config.Config) *AgencyService { func NewAgencyService(userRepo *repository.UserRepository, tenantRepo *repository.TenantRepository, cfg *config.Config, db *sql.DB) *AgencyService {
return &AgencyService{ return &AgencyService{
userRepo: userRepo, userRepo: userRepo,
tenantRepo: tenantRepo, tenantRepo: tenantRepo,
cfg: cfg, cfg: cfg,
db: db,
} }
} }
@@ -60,24 +63,30 @@ func (s *AgencyService) RegisterAgency(req domain.RegisterAgencyRequest) (*domai
if req.Complement != "" { if req.Complement != "" {
address += " - " + req.Complement address += " - " + req.Complement
} }
if req.Neighborhood != "" {
address += " - " + req.Neighborhood
}
tenant := &domain.Tenant{ tenant := &domain.Tenant{
Name: req.AgencyName, Name: req.AgencyName,
Domain: fmt.Sprintf("%s.%s", req.Subdomain, s.cfg.App.BaseDomain), Domain: fmt.Sprintf("%s.%s", req.Subdomain, s.cfg.App.BaseDomain),
Subdomain: req.Subdomain, Subdomain: req.Subdomain,
CNPJ: req.CNPJ, CNPJ: req.CNPJ,
RazaoSocial: req.RazaoSocial, RazaoSocial: req.RazaoSocial,
Email: req.AdminEmail, Email: req.AdminEmail,
Website: req.Website, Phone: req.Phone,
Address: address, Website: req.Website,
City: req.City, Address: address,
State: req.State, Neighborhood: req.Neighborhood,
Zip: req.CEP, Number: req.Number,
Description: req.Description, Complement: req.Complement,
Industry: req.Industry, 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 { if err := s.tenantRepo.Create(tenant); err != nil {
@@ -174,6 +183,43 @@ func (s *AgencyService) GetAgencyDetails(id uuid.UUID) (*domain.AgencyDetails, e
details.Admin = admin 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 return details, nil
} }
@@ -189,3 +235,16 @@ func (s *AgencyService) DeleteAgency(id uuid.UUID) error {
return s.tenantRepo.Delete(id) 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

@@ -2,6 +2,7 @@ package service
import ( import (
"errors" "errors"
"log"
"time" "time"
"aggios-app/backend/internal/config" "aggios-app/backend/internal/config"
@@ -78,14 +79,20 @@ func (s *AuthService) Login(req domain.LoginRequest) (*domain.LoginResponse, err
// Find user by email // Find user by email
user, err := s.userRepo.FindByEmail(req.Email) user, err := s.userRepo.FindByEmail(req.Email)
if err != nil { if err != nil {
log.Printf("❌ DB error finding user %s: %v", req.Email, err)
return nil, err return nil, err
} }
if user == nil { if user == nil {
log.Printf("❌ User not found: %s", req.Email)
return nil, ErrInvalidCredentials 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 // Verify password
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil { 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 return nil, ErrInvalidCredentials
} }

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

@@ -17,12 +17,14 @@ var (
// TenantService handles tenant business logic // TenantService handles tenant business logic
type TenantService struct { type TenantService struct {
tenantRepo *repository.TenantRepository tenantRepo *repository.TenantRepository
db *sql.DB
} }
// NewTenantService creates a new tenant service // NewTenantService creates a new tenant service
func NewTenantService(tenantRepo *repository.TenantRepository) *TenantService { func NewTenantService(tenantRepo *repository.TenantRepository, db *sql.DB) *TenantService {
return &TenantService{ return &TenantService{
tenantRepo: tenantRepo, tenantRepo: tenantRepo,
db: db,
} }
} }
@@ -79,6 +81,84 @@ func (s *TenantService) ListAll() ([]*domain.Tenant, error) {
return s.tenantRepo.FindAll() 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 // Delete removes a tenant by ID
func (s *TenantService) Delete(id uuid.UUID) error { func (s *TenantService) Delete(id uuid.UUID) error {
if err := s.tenantRepo.Delete(id); err != nil { if err := s.tenantRepo.Delete(id); err != 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

@@ -6,10 +6,6 @@ services:
restart: unless-stopped restart: unless-stopped
command: command:
- "--api.insecure=true" - "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.endpoint=tcp://host.docker.internal:2375"
- "--providers.docker.exposedbydefault=false"
- "--providers.docker.network=aggios-network"
- "--providers.file.directory=/etc/traefik/dynamic" - "--providers.file.directory=/etc/traefik/dynamic"
- "--providers.file.watch=true" - "--providers.file.watch=true"
- "--entrypoints.web.address=:80" - "--entrypoints.web.address=:80"
@@ -38,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
@@ -69,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:
@@ -93,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!}
@@ -111,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
@@ -171,6 +188,31 @@ 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

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

@@ -31,11 +31,63 @@ 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(() => {
const saved = localStorage.getItem('cadastroFormData'); const saved = localStorage.getItem('cadastroFormData');
@@ -52,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);
@@ -314,6 +366,12 @@ export default function CadastroPage() {
number: formData.number, number: formData.number,
complement: formData.complement, complement: formData.complement,
// Personalização
primaryColor: formData.primaryColor,
secondaryColor: formData.secondaryColor,
logoUrl: logoUrl,
logoHorizontalUrl: logoHorizontalUrl,
// Admin // Admin
adminEmail: formData.email, adminEmail: formData.email,
adminPassword: password, adminPassword: password,
@@ -323,7 +381,7 @@ export default function CadastroPage() {
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 response = await fetch('/api/admin/agencies', { const response = await fetch(API_ENDPOINTS.adminAgencyRegister, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -332,8 +390,23 @@ export default function CadastroPage() {
}); });
if (!response.ok) { if (!response.ok) {
const error = await response.json(); let errorMessage = 'Erro ao criar conta';
throw new Error(error.message || '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(); const data = await response.json();
@@ -365,9 +438,10 @@ export default function CadastroPage() {
}, },
}); });
// Redirecionar para o painel da agência no subdomínio // Redirecionar para o painel da agência no subdomínio, enviando o gradiente escolhido
setTimeout(() => { setTimeout(() => {
const agencyUrl = `http://${data.subdomain}.localhost/login`; const gradient = `linear-gradient(135deg, ${primaryColor}, ${secondaryColor})`;
const agencyUrl = `http://${data.subdomain}.localhost/login?theme=${encodeURIComponent(gradient)}`;
window.location.href = agencyUrl; window.location.href = agencyUrl;
}, 2000); }, 2000);
@@ -406,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]);
@@ -529,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) => {
@@ -565,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);
} }
@@ -607,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',
}, },
@@ -660,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>
@@ -763,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>
@@ -779,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>
@@ -829,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 || ''}
@@ -925,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) {
@@ -989,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>
@@ -1044,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">
@@ -1061,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 && (
@@ -1076,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">
@@ -1091,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>
@@ -1100,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>
@@ -1121,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'}
@@ -1143,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 */}
@@ -1165,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"
@@ -1219,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>
@@ -1238,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"
@@ -1255,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>
@@ -1275,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' },
@@ -1294,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
@@ -1306,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>
@@ -1317,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
@@ -1337,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 */}
@@ -1363,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>
))} ))}
@@ -1407,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}`}
/> />
))} ))}
@@ -1442,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,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>;
}

View File

@@ -0,0 +1,53 @@
'use client';
import { ReactNode } from 'react';
// Helper to lighten color
const lightenColor = (color: string, percent: number) => {
const num = parseInt(color.replace("#", ""), 16),
amt = Math.round(2.55 * percent),
R = (num >> 16) + amt,
B = ((num >> 8) & 0x00ff) + amt,
G = (num & 0x0000ff) + amt;
return (
"#" +
(
0x1000000 +
(R < 255 ? (R < 1 ? 0 : R) : 255) * 0x10000 +
(B < 255 ? (B < 1 ? 0 : B) : 255) * 0x100 +
(G < 255 ? (G < 1 ? 0 : G) : 255)
)
.toString(16)
.slice(1)
);
};
const setBrandColors = (primary: string, secondary: string) => {
document.documentElement.style.setProperty('--brand-color', primary);
document.documentElement.style.setProperty('--brand-color-strong', secondary);
// Create a lighter version of primary for hover
const primaryLight = lightenColor(primary, 20); // Lighten by 20%
document.documentElement.style.setProperty('--brand-color-hover', primaryLight);
// Set RGB variables if needed by other components
const hexToRgb = (hex: string) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? `${parseInt(result[1], 16)} ${parseInt(result[2], 16)} ${parseInt(result[3], 16)}` : null;
};
const primaryRgb = hexToRgb(primary);
const secondaryRgb = hexToRgb(secondary);
const primaryLightRgb = hexToRgb(primaryLight);
if (primaryRgb) document.documentElement.style.setProperty('--brand-rgb', primaryRgb);
if (secondaryRgb) document.documentElement.style.setProperty('--brand-strong-rgb', secondaryRgb);
if (primaryLightRgb) document.documentElement.style.setProperty('--brand-hover-rgb', primaryLightRgb);
};
export default function LayoutWrapper({ children }: { children: ReactNode }) {
// Temporariamente desativado o carregamento dinâmico de cores/tema para eliminar possíveis
// efeitos colaterais de hidratação e 429 no middleware/backend. Se precisar reativar, mover
// para nível de servidor (next/head ou metadata) para evitar mutações de DOM no cliente.
return <>{children}</>;
}

View File

@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from "next/server";
export async function GET(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path: pathArray } = await params;
const path = pathArray?.join("/") || "";
const token = req.headers.get("authorization");
const host = req.headers.get("host");
try {
const response = await fetch(`http://backend:8080/api/${path}${req.nextUrl.search}`, {
method: "GET",
headers: {
"Authorization": token || "",
"Content-Type": "application/json",
"X-Forwarded-Host": host || "",
"X-Original-Host": host || "",
},
});
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (error) {
console.error("API proxy error:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}
export async function PUT(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path: pathArray } = await params;
const path = pathArray?.join("/") || "";
const token = req.headers.get("authorization");
const host = req.headers.get("host");
const body = await req.json();
try {
const response = await fetch(`http://backend:8080/api/${path}${req.nextUrl.search}`, {
method: "PUT",
headers: {
"Authorization": token || "",
"Content-Type": "application/json",
"X-Forwarded-Host": host || "",
"X-Original-Host": host || "",
},
body: JSON.stringify(body),
});
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (error) {
console.error("API proxy error:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}
export async function POST(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path: pathArray } = await params;
const path = pathArray?.join("/") || "";
const token = req.headers.get("authorization");
const host = req.headers.get("host");
const body = await req.json();
try {
const response = await fetch(`http://backend:8080/api/${path}${req.nextUrl.search}`, {
method: "POST",
headers: {
"Authorization": token || "",
"Content-Type": "application/json",
"X-Forwarded-Host": host || "",
"X-Original-Host": host || "",
},
body: JSON.stringify(body),
});
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (error) {
console.error("API proxy error:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from 'next/server';
const BACKEND_URL = process.env.API_INTERNAL_URL || 'http://aggios-backend:8080';
export async function POST(request: NextRequest) {
try {
console.log('🔵 [Next.js] Logo upload route called');
const authorization = request.headers.get('authorization');
if (!authorization) {
console.log('❌ [Next.js] No authorization header');
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
console.log('✅ [Next.js] Authorization header present');
// Get form data from request
const formData = await request.formData();
const logo = formData.get('logo');
const type = formData.get('type');
console.log('📦 [Next.js] FormData received:', {
hasLogo: !!logo,
logoType: logo ? (logo as File).type : null,
logoSize: logo ? (logo as File).size : null,
type: type
});
console.log('🚀 [Next.js] Forwarding to backend:', BACKEND_URL);
// Forward to backend
const response = await fetch(`${BACKEND_URL}/api/agency/logo`, {
method: 'POST',
headers: {
'Authorization': authorization,
},
body: formData,
});
console.log('📡 [Next.js] Backend response status:', response.status);
if (!response.ok) {
const errorText = await response.text();
console.error('Backend error:', errorText);
return NextResponse.json(
{ error: errorText || 'Failed to upload logo' },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('Logo upload error:', error);
return NextResponse.json(
{ error: 'Internal server error: ' + (error instanceof Error ? error.message : String(error)) },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const response = await fetch('http://aggios-backend:8080/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) {
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
console.error('Login error:', error);
return NextResponse.json(
{ error: 'Erro ao processar login' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.API_INTERNAL_URL || 'http://backend:8080';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const subdomain = searchParams.get('subdomain');
if (!subdomain) {
return NextResponse.json(
{ error: 'Subdomain is required' },
{ status: 400 }
);
}
// Buscar configuração pública do tenant
const response = await fetch(
`${API_BASE_URL}/api/tenant/config?subdomain=${subdomain}`,
{
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
},
}
);
if (!response.ok) {
return NextResponse.json(
{ error: 'Tenant not found' },
{ status: 404 }
);
}
const data = await response.json();
// Retornar apenas dados públicos
return NextResponse.json({
name: data.name,
primary_color: data.primary_color,
secondary_color: data.secondary_color,
logo_url: data.logo_url,
});
} catch (error) {
console.error('Error fetching tenant config:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,194 @@
@config "../tailwind.config.js";
@import "tailwindcss";
@import "./tokens.css";
@variant dark (&:where(.dark, .dark *));
:root {
color-scheme: light;
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
html.dark {
color-scheme: dark;
}
@layer base {
* {
font-family: var(--font-arimo), ui-sans-serif, system-ui, sans-serif;
}
a,
button,
[role="button"],
input[type="submit"],
input[type="reset"],
input[type="button"],
label[for] {
cursor: pointer;
}
body {
background-color: var(--color-surface-muted);
color: var(--color-text-primary);
transition: background-color 0.25s ease, color 0.25s ease;
}
::selection {
background-color: var(--color-brand-100);
color: var(--color-text-primary);
}
/* Seleção em campos de formulário usa cor mais visível */
input::selection,
textarea::selection,
select::selection {
background-color: var(--color-brand-200);
color: var(--color-text-primary);
}
.surface-card {
background-color: var(--color-surface-card);
border: 1px solid var(--color-border-strong);
box-shadow: 0 20px 80px rgba(15, 23, 42, 0.08);
}
.glass-panel {
background: linear-gradient(120deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.05));
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 25px 50px -12px rgba(15, 23, 42, 0.25);
backdrop-filter: blur(20px);
}
.gradient-text {
background: var(--color-gradient-brand);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
@layer utilities {
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
}

View File

@@ -0,0 +1,67 @@
import type { Metadata } from "next";
import { Arimo, Open_Sans, Fira_Code } from "next/font/google";
import "./globals.css";
import LayoutWrapper from "./LayoutWrapper";
import { ThemeProvider } from "next-themes";
import { getAgencyLogo } from "@/lib/server-api";
import { ClientProviders } from "./ClientProviders";
const arimo = Arimo({
variable: "--font-arimo",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
});
const openSans = Open_Sans({
variable: "--font-open-sans",
subsets: ["latin"],
weight: ["600", "700"],
});
const firaCode = Fira_Code({
variable: "--font-fira-code",
subsets: ["latin"],
weight: ["400", "600"],
});
export async function generateMetadata(): Promise<Metadata> {
const logoUrl = await getAgencyLogo();
// Adicionar timestamp para forçar atualização do favicon
const faviconUrl = logoUrl
? `${logoUrl}?v=${Date.now()}`
: '/favicon.ico';
return {
title: "Aggios - Dashboard",
description: "Plataforma SaaS para agências digitais",
icons: {
icon: faviconUrl,
shortcut: faviconUrl,
apple: faviconUrl,
},
};
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="pt-BR" suppressHydrationWarning>
<head>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/remixicon@4.3.0/fonts/remixicon.css" />
</head>
<body className={`${arimo.variable} ${openSans.variable} ${firaCode.variable} antialiased`} suppressHydrationWarning>
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
<ClientProviders>
<LayoutWrapper>
{children}
</LayoutWrapper>
</ClientProviders>
</ThemeProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,352 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { Button, Input, Checkbox } from "@/components/ui";
import { saveAuth, isAuthenticated, getToken, clearAuth } from '@/lib/auth';
import { API_ENDPOINTS } from '@/lib/api';
import dynamic from 'next/dynamic';
import { LoginBranding } from '@/components/auth/LoginBranding';
import {
EnvelopeIcon,
LockClosedIcon,
ShieldCheckIcon,
BoltIcon,
UserGroupIcon,
ChartBarIcon,
ExclamationCircleIcon,
CheckCircleIcon
} from "@heroicons/react/24/outline";
const ThemeToggle = dynamic(() => import('@/components/ThemeToggle'), { ssr: false });
export default function LoginPage() {
const [isLoading, setIsLoading] = useState(false);
const [isSuperAdmin, setIsSuperAdmin] = useState(false);
const [subdomain, setSubdomain] = useState<string>('');
const [errorMessage, setErrorMessage] = useState<string>('');
const [successMessage, setSuccessMessage] = useState<string>('');
const [formData, setFormData] = useState({
email: "",
password: "",
rememberMe: false,
});
useEffect(() => {
if (typeof window !== 'undefined') {
const hostname = window.location.hostname;
const sub = hostname.split('.')[0];
const superAdmin = sub === 'dash';
setSubdomain(sub);
setIsSuperAdmin(superAdmin);
// Verificar se tem parâmetro de erro de tenant não encontrado
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('error') === 'tenant_not_found') {
console.log('⚠️ Tenant não encontrado, limpando autenticação...');
clearAuth();
localStorage.removeItem('agency-logo-url');
localStorage.removeItem('agency-primary-color');
localStorage.removeItem('agency-secondary-color');
setErrorMessage('Esta agência não existe mais ou foi desativada.');
return;
}
if (isAuthenticated()) {
// Validar token antes de redirecionar para evitar loops
const token = getToken();
fetch(API_ENDPOINTS.me, {
headers: {
'Authorization': `Bearer ${token}`
}
})
.then(res => {
if (res.ok) {
const target = superAdmin ? '/superadmin' : '/dashboard';
window.location.href = target;
} else {
// Token inválido ou expirado
clearAuth();
}
})
.catch((err) => {
console.error('Erro ao validar sessão:', err);
// Em caso de erro de rede, não redireciona nem limpa, deixa o usuário tentar logar
});
}
}
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrorMessage('');
setSuccessMessage('');
// Validações do lado do cliente
if (!formData.email) {
setErrorMessage('Por favor, insira seu email para continuar.');
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
setErrorMessage('Ops! O formato do email não parece correto. Por favor, verifique e tente novamente.');
return;
}
if (!formData.password) {
setErrorMessage('Por favor, insira sua senha para acessar sua conta.');
return;
}
if (formData.password.length < 3) {
setErrorMessage('A senha parece muito curta. Por favor, verifique se digitou corretamente.');
return;
}
setIsLoading(true);
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: formData.email,
password: formData.password,
}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
// Mensagens humanizadas para cada tipo de erro
if (response.status === 401 || response.status === 403) {
setErrorMessage('Email ou senha incorretos. Por favor, verifique seus dados e tente novamente.');
} else if (response.status >= 500) {
setErrorMessage('Estamos com problemas no servidor no momento. Por favor, tente novamente em alguns instantes.');
} else {
setErrorMessage(error.message || 'Algo deu errado ao tentar fazer login. Por favor, tente novamente.');
}
setIsLoading(false);
return;
}
const data = await response.json();
saveAuth(data.token, data.user);
console.log('Login successful:', data.user);
setSuccessMessage('Login realizado com sucesso! Redirecionando você agora...');
setTimeout(() => {
const target = isSuperAdmin ? '/superadmin' : '/dashboard';
window.location.href = target;
}, 1000);
} catch (error: any) {
console.error('Login error:', error);
setErrorMessage('Não conseguimos conectar ao servidor. Verifique sua conexão com a internet e tente novamente.');
setIsLoading(false);
}
};
return (
<>
{/* Script inline para aplicar cor primária ANTES do React */}
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
const cachedPrimary = localStorage.getItem('agency-primary-color');
if (cachedPrimary) {
function hexToRgb(hex) {
const result = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex);
return result
? parseInt(result[1], 16) + ' ' + parseInt(result[2], 16) + ' ' + parseInt(result[3], 16)
: null;
}
const primaryRgb = hexToRgb(cachedPrimary);
if (primaryRgb) {
const root = document.documentElement;
root.style.setProperty('--brand-color', cachedPrimary);
root.style.setProperty('--gradient', 'linear-gradient(135deg, ' + cachedPrimary + ', ' + cachedPrimary + ')');
root.style.setProperty('--brand-rgb', primaryRgb);
root.style.setProperty('--brand-strong-rgb', primaryRgb);
root.style.setProperty('--brand-hover-rgb', primaryRgb);
}
}
} catch(e) {}
})();
`,
}}
/>
<LoginBranding />
<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>
{/* Theme Toggle */}
<div className="flex justify-end mb-4">
<ThemeToggle />
</div>
{/* Header */}
<div className="mb-8">
<h2 className="text-[28px] font-bold text-[#000000] dark:text-white">
{isSuperAdmin ? 'Painel Administrativo' : 'Bem-vindo de volta'}
</h2>
<p className="text-[14px] text-[#7D7D7D] dark:text-gray-400 mt-2">
{isSuperAdmin
? 'Acesso exclusivo para administradores Aggios'
: 'Entre com suas credenciais para acessar o painel'
}
</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-5">
{/* Mensagem de Erro */}
{errorMessage && (
<div className="flex items-start gap-3 p-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
<ExclamationCircleIcon className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-800 dark:text-red-300 leading-relaxed">
{errorMessage}
</p>
</div>
)}
{/* Mensagem de Sucesso */}
{successMessage && (
<div className="flex items-start gap-3 p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
<CheckCircleIcon className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-green-800 dark:text-green-300 leading-relaxed">
{successMessage}
</p>
</div>
)}
<Input
label="Email"
type="email"
placeholder="seu@email.com"
leftIcon={<EnvelopeIcon className="w-5 h-5" />}
value={formData.email}
onChange={(e) => {
setFormData({ ...formData, email: e.target.value });
setErrorMessage(''); // Limpa o erro ao digitar
}}
required
/>
<Input
label="Senha"
type="password"
placeholder="Digite sua senha"
leftIcon={<LockClosedIcon className="w-5 h-5" />}
value={formData.password}
onChange={(e) => {
setFormData({ ...formData, password: e.target.value });
setErrorMessage(''); // Limpa o erro ao digitar
}}
required
/>
<div className="flex items-center justify-between">
<Checkbox
id="rememberMe"
label="Lembrar de mim"
checked={formData.rememberMe}
onChange={(e) => setFormData({ ...formData, rememberMe: e.target.checked })}
/>
<Link
href="/recuperar-senha"
className="text-[14px] font-medium hover:opacity-80 transition-opacity"
style={{ color: 'var(--brand-color)' }}
>
Esqueceu a senha?
</Link>
</div>
<Button
type="submit"
variant="primary"
size="lg"
className="w-full"
disabled={isLoading}
>
{isLoading ? 'Entrando...' : 'Entrar'}
</Button>
{/* Link para cadastro - apenas para agências */}
{!isSuperAdmin && (
<p className="text-center text-[14px] text-[#7D7D7D] dark:text-gray-400">
Ainda não tem conta?{' '}
<a
href="http://dash.localhost/cadastro"
className="font-medium hover:opacity-80 transition-opacity"
style={{ color: 'var(--brand-color)' }}
>
Cadastre sua agência
</a>
</p>
)}
</form>
</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 mb-8">
{isSuperAdmin
? 'Gerencie todas as agências em um só lugar'
: 'Gerencie seus clientes com eficiência'
}
</p>
<div className="grid grid-cols-2 gap-6 text-left">
<div>
<ShieldCheckIcon className="w-8 h-8 mb-2" />
<h3 className="font-semibold mb-1">Seguro</h3>
<p className="text-sm opacity-80">Proteção de dados</p>
</div>
<div>
<BoltIcon className="w-8 h-8 mb-2" />
<h3 className="font-semibold mb-1">Rápido</h3>
<p className="text-sm opacity-80">Performance otimizada</p>
</div>
<div>
<UserGroupIcon className="w-8 h-8 mb-2" />
<h3 className="font-semibold mb-1">Colaborativo</h3>
<p className="text-sm opacity-80">Trabalho em equipe</p>
</div>
<div>
<ChartBarIcon className="w-8 h-8 mb-2" />
<h3 className="font-semibold mb-1">Insights</h3>
<p className="text-sm opacity-80">Relatórios detalhados</p>
</div>
</div>
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,146 @@
"use client";
import Link from "next/link";
import { Button } from "@/components/ui";
export default function NotFound() {
return (
<div className="flex min-h-screen">
{/* Lado Esquerdo - Conteúdo 404 */}
<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 text-center">
{/* Logo mobile */}
<div className="lg:hidden mb-8">
<div className="inline-block px-6 py-3 rounded-2xl bg-linear-to-r from-brand-500 to-brand-700">
<h1 className="text-3xl font-bold text-white">aggios</h1>
</div>
</div>
{/* 404 Number */}
<div className="mb-6">
<h1 className="text-[120px] font-bold leading-none gradient-text">
404
</h1>
</div>
{/* Message */}
<div className="mb-6">
<h2 className="text-[28px] font-bold text-[#000000] mb-2">
Página não encontrada
</h2>
<p className="text-[14px] text-[#7D7D7D] leading-relaxed">
Desculpe, a página que você está procurando não existe ou foi movida.
Verifique a URL ou volte para a página inicial.
</p>
</div>
{/* Actions */}
<div className="space-y-3">
<Button
variant="primary"
className="w-full"
size="lg"
leftIcon="ri-login-box-line"
onClick={() => window.location.href = '/login'}
>
Fazer login
</Button>
<Button
variant="outline"
className="w-full"
size="lg"
leftIcon="ri-user-add-line"
onClick={() => window.location.href = '/cadastro'}
>
Criar conta
</Button>
</div>
{/* Help Section */}
<div className="mt-8 p-5 bg-[#F5F5F5] rounded-lg text-left">
<h4 className="text-[13px] font-semibold text-[#000000] mb-3 flex items-center gap-2">
<i className="ri-questionnaire-line text-[16px] gradient-text" />
Precisa de ajuda?
</h4>
<ul className="text-[13px] text-[#7D7D7D] space-y-2">
<li className="flex items-start gap-2">
<i className="ri-arrow-right-s-line text-[16px] gradient-text mt-0.5" />
<span>Verifique se a URL está correta</span>
</li>
<li className="flex items-start gap-2">
<i className="ri-arrow-right-s-line text-[16px] gradient-text mt-0.5" />
<span>Tente buscar no menu principal</span>
</li>
<li className="flex items-start gap-2">
<i className="ri-arrow-right-s-line text-[16px] gradient-text mt-0.5" />
<span>Entre em contato com o suporte se o problema persistir</span>
</li>
</ul>
</div>
</div>
</div>
{/* Lado Direito - Branding */}
<div className="hidden lg:flex lg:w-1/2 relative overflow-hidden" style={{ background: 'var(--gradient-primary)' }}>
<div className="relative z-10 flex flex-col justify-center items-center w-full p-12 text-white">
{/* Logo */}
<div className="mb-8">
<div className="inline-block px-6 py-3 rounded-2xl bg-white/10 backdrop-blur-sm border border-white/20">
<h1 className="text-5xl font-bold tracking-tight bg-linear-to-r from-white to-white/80 bg-clip-text text-transparent">
aggios
</h1>
</div>
</div>
{/* Conteúdo */}
<div className="max-w-lg text-center">
<div className="w-20 h-20 rounded-2xl bg-white/20 flex items-center justify-center mb-6 mx-auto">
<i className="ri-compass-3-line text-4xl" />
</div>
<h2 className="text-4xl font-bold mb-4">Perdido? Estamos aqui!</h2>
<p className="text-white/80 text-lg mb-8">
Mesmo que esta página não exista, temos muitas outras funcionalidades incríveis
esperando por você no Aggios.
</p>
{/* Features */}
<div className="space-y-4 text-left">
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-white/20 flex items-center justify-center shrink-0 mt-0.5">
<i className="ri-dashboard-line text-sm" />
</div>
<div>
<h4 className="font-semibold mb-1">Dashboard Completo</h4>
<p className="text-white/70 text-sm">Visualize todos os seus projetos e métricas</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-white/20 flex items-center justify-center shrink-0 mt-0.5">
<i className="ri-team-line text-sm" />
</div>
<div>
<h4 className="font-semibold mb-1">Gestão de Equipe</h4>
<p className="text-white/70 text-sm">Organize e acompanhe sua equipe</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-white/20 flex items-center justify-center shrink-0 mt-0.5">
<i className="ri-customer-service-line text-sm" />
</div>
<div>
<h4 className="font-semibold mb-1">Suporte 24/7</h4>
<p className="text-white/70 text-sm">Estamos sempre disponíveis para ajudar</p>
</div>
</div>
</div>
</div>
</div>
{/* Círculos decorativos */}
<div className="absolute top-0 right-0 w-96 h-96 bg-white/5 rounded-full blur-3xl" />
<div className="absolute bottom-0 left-0 w-96 h-96 bg-white/5 rounded-full blur-3xl" />
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/login");
}

View File

@@ -0,0 +1,80 @@
@layer theme {
:root {
/* Gradientes */
--gradient: linear-gradient(135deg, #ff3a05, #ff0080);
--gradient-text: linear-gradient(to right, #ff3a05, #ff0080);
--gradient-primary: linear-gradient(135deg, #ff3a05, #ff0080);
--color-gradient-brand: linear-gradient(135deg, #ff3a05, #ff0080);
/* Cores sólidas de marca (usadas em textos/bordas) */
--brand-color: #ff3a05;
--brand-color-strong: #ff0080;
--brand-rgb: 255 58 5;
--brand-strong-rgb: 255 0 128;
/* Escala de cores da marca */
--color-brand-50: #fff1f0;
--color-brand-100: #ffe0dd;
--color-brand-200: #ffc7c0;
--color-brand-300: #ffa094;
--color-brand-400: #ff6b57;
--color-brand-500: #ff3a05;
--color-brand-600: #ff0080;
--color-brand-700: #d6006a;
--color-brand-800: #ad0058;
--color-brand-900: #8a004a;
/* Superfícies e tipografia */
--color-surface-light: #ffffff;
--color-surface-dark: #0a0a0a;
--color-surface-muted: #f5f7fb;
--color-surface-card: #ffffff;
--color-border-strong: rgba(15, 23, 42, 0.08);
--color-text-primary: #0f172a;
--color-text-secondary: #475569;
--color-text-inverse: #f8fafc;
--color-gray-50: #f9fafb;
--color-gray-100: #f3f4f6;
--color-gray-200: #e5e7eb;
--color-gray-300: #d1d5db;
--color-gray-400: #9ca3af;
--color-gray-500: #6b7280;
--color-gray-600: #4b5563;
--color-gray-700: #374151;
--color-gray-800: #1f2937;
--color-gray-900: #111827;
--color-gray-950: #030712;
/* Espaçamento */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
}
.dark {
/* Invertendo superfícies e texto para dark mode */
--color-surface-light: #020617;
--color-surface-dark: #f8fafc;
--color-surface-muted: #0b1220;
--color-surface-card: #0f172a;
--color-border-strong: rgba(148, 163, 184, 0.25);
--color-text-primary: #f8fafc;
--color-text-secondary: #cbd5f5;
--color-text-inverse: #0f172a;
/* Cores da marca com maior contraste para dark mode */
--color-brand-50: #4a0029;
--color-brand-100: #660037;
--color-brand-200: #8a004a;
--color-brand-300: #ad0058;
--color-brand-400: #d6006a;
--color-brand-500: #ff0080;
--color-brand-600: #ff3a05;
--color-brand-700: #ff6b57;
--color-brand-800: #ffa094;
--color-brand-900: #ffc7c0;
}
}

View File

@@ -0,0 +1,42 @@
"use client";
import { useEffect, useState } from 'react';
interface DynamicFaviconProps {
logoUrl?: string;
}
export default function DynamicFavicon({ logoUrl }: DynamicFaviconProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!mounted || !logoUrl) return;
// Usar requestAnimationFrame para garantir que a hidratação terminou
requestAnimationFrame(() => {
// Remove favicons antigos
const existingLinks = document.querySelectorAll("link[rel*='icon']");
existingLinks.forEach(link => link.remove());
// Adiciona novo favicon
const link = document.createElement('link');
link.type = 'image/x-icon';
link.rel = 'shortcut icon';
link.href = logoUrl;
document.getElementsByTagName('head')[0].appendChild(link);
// Adiciona Apple touch icon
const appleLink = document.createElement('link');
appleLink.rel = 'apple-touch-icon';
appleLink.href = logoUrl;
document.getElementsByTagName('head')[0].appendChild(appleLink);
});
}, [mounted, logoUrl]);
return null;
}

View File

@@ -0,0 +1,100 @@
"use client";
import { useState } from 'react';
import { SwatchIcon } from '@heroicons/react/24/outline';
const themePresets = [
{
name: 'Azul (Marca)',
gradient: 'linear-gradient(135deg, #0ea5e9, #0284c7)',
},
{
name: 'Azul/Roxo',
gradient: 'linear-gradient(135deg, #0066FF, #9333EA)',
},
{
name: 'Verde/Esmeralda',
gradient: 'linear-gradient(135deg, #10B981, #059669)',
},
{
name: 'Ciano/Azul',
gradient: 'linear-gradient(135deg, #06B6D4, #3B82F6)',
},
{
name: 'Rosa/Roxo',
gradient: 'linear-gradient(135deg, #EC4899, #A855F7)',
},
{
name: 'Vermelho/Laranja',
gradient: 'linear-gradient(135deg, #EF4444, #F97316)',
},
{
name: 'Índigo/Violeta',
gradient: 'linear-gradient(135deg, #6366F1, #8B5CF6)',
},
{
name: 'Âmbar/Amarelo',
gradient: 'linear-gradient(135deg, #F59E0B, #EAB308)',
},
];
export default function ThemeTester() {
const [isOpen, setIsOpen] = useState(false);
const applyTheme = (gradient: string) => {
document.documentElement.style.setProperty('--gradient-primary', gradient);
document.documentElement.style.setProperty('--gradient', gradient);
document.documentElement.style.setProperty('--gradient-text', gradient);
document.documentElement.style.setProperty('--color-gradient-brand', gradient);
};
return (
<div className="fixed bottom-4 right-4 z-50">
{/* Botão flutuante */}
<button
onClick={() => setIsOpen(!isOpen)}
className="w-14 h-14 rounded-full shadow-lg flex items-center justify-center transition-all hover:scale-110"
style={{ background: 'var(--gradient-primary)' }}
title="Testar Temas"
>
<SwatchIcon className="w-6 h-6 text-white" />
</button>
{/* Painel de temas */}
{isOpen && (
<div className="absolute bottom-16 right-0 w-80 bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="font-semibold text-gray-900 dark:text-white">Testar Gradientes</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Clique para aplicar temporariamente
</p>
</div>
<div className="p-3 max-h-96 overflow-y-auto space-y-2">
{themePresets.map((theme) => (
<button
key={theme.name}
onClick={() => applyTheme(theme.gradient)}
className="w-full flex items-center space-x-3 p-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors group"
>
<div
className="w-12 h-12 rounded-lg shrink-0"
style={{ background: theme.gradient }}
/>
<span className="text-sm font-medium text-gray-900 dark:text-white text-left">
{theme.name}
</span>
</button>
))}
</div>
<div className="p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
<p className="text-xs text-gray-500 dark:text-gray-400 text-center">
💡 Recarregue a página para voltar ao tema original
</p>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,37 @@
'use client';
import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';
import { MoonIcon, SunIcon } from '@heroicons/react/24/outline';
export default function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return <div className="w-9 h-9 rounded-lg bg-gray-100 dark:bg-gray-800" />;
}
const isDark = resolvedTheme === 'dark';
return (
<button
type="button"
onClick={() => setTheme(isDark ? 'light' : 'dark')}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
aria-label={isDark ? 'Ativar tema claro' : 'Ativar tema escuro'}
title={isDark ? 'Alterar para modo claro' : 'Alterar para modo escuro'}
>
{isDark ? (
<SunIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
) : (
<MoonIcon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
)}
</button>
);
}

View File

@@ -0,0 +1,65 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { isAuthenticated, clearAuth } from '@/lib/auth';
export default function AuthGuard({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [authorized, setAuthorized] = useState<boolean | null>(null);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!mounted) return;
const checkAuth = () => {
const isAuth = isAuthenticated();
if (!isAuth) {
setAuthorized(false);
// Evitar redirect loop se já estiver no login
if (pathname !== '/login') {
router.push('/login?error=unauthorized');
}
} else {
setAuthorized(true);
}
};
checkAuth();
// Listener para logout em outras abas
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'token' || e.key === 'user') {
checkAuth();
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [router, pathname, mounted]);
// Enquanto verifica, mostra loading
if (!mounted || authorized === null) {
return (
<div className="flex h-screen w-full items-center justify-center bg-gray-100 dark:bg-zinc-950">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-purple-600" />
</div>
);
}
if (!authorized) {
return (
<div className="flex h-screen w-full items-center justify-center bg-gray-100 dark:bg-zinc-950">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-purple-600" />
</div>
);
}
return <>{children}</>;
}

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