16 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
236 changed files with 36382 additions and 3027 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

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

View File

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

View File

@@ -7,6 +7,7 @@ import (
"net/http"
_ "github.com/lib/pq"
"github.com/gorilla/mux"
"aggios-app/backend/internal/api/handlers"
"aggios-app/backend/internal/api/middleware"
@@ -17,7 +18,7 @@ import (
func initDB(cfg *config.Config) (*sql.DB, error) {
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.Port,
cfg.Database.User,
@@ -53,20 +54,42 @@ func main() {
userRepo := repository.NewUserRepository(db)
tenantRepo := repository.NewTenantRepository(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
authService := service.NewAuthService(userRepo, tenantRepo, cfg)
agencyService := service.NewAgencyService(userRepo, tenantRepo, cfg)
tenantService := service.NewTenantService(tenantRepo)
agencyService := service.NewAgencyService(userRepo, tenantRepo, cfg, db)
tenantService := service.NewTenantService(tenantRepo, db)
companyService := service.NewCompanyService(companyRepo)
planService := service.NewPlanService(planRepo, subscriptionRepo)
// Initialize handlers
healthHandler := handlers.NewHealthHandler()
authHandler := handlers.NewAuthHandler(authService)
agencyProfileHandler := handlers.NewAgencyHandler(tenantRepo)
agencyProfileHandler := handlers.NewAgencyHandler(tenantRepo, cfg)
agencyHandler := handlers.NewAgencyRegistrationHandler(agencyService, cfg)
tenantHandler := handlers.NewTenantHandler(tenantService)
companyHandler := handlers.NewCompanyHandler(companyService)
planHandler := handlers.NewPlanHandler(planService)
crmHandler := handlers.NewCRMHandler(crmRepo)
solutionHandler := handlers.NewSolutionHandler(solutionRepo)
signupTemplateHandler := handlers.NewSignupTemplateHandler(signupTemplateRepo, userRepo, tenantRepo, agencyService)
agencyTemplateHandler := handlers.NewAgencyTemplateHandler(agencyTemplateRepo, agencyService, userRepo, tenantRepo)
filesHandler := handlers.NewFilesHandler(cfg)
// 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
tenantDetector := middleware.TenantDetector(tenantRepo)
@@ -76,43 +99,200 @@ func main() {
authMiddleware := middleware.Auth(cfg)
// Setup routes
mux := http.NewServeMux()
router := mux.NewRouter()
// Health check (no auth)
mux.HandleFunc("/health", healthHandler.Check)
mux.HandleFunc("/api/health", healthHandler.Check)
// Serve static files (uploads)
fs := http.FileServer(http.Dir("./uploads"))
router.PathPrefix("/uploads/").Handler(http.StripPrefix("/uploads", fs))
// Auth routes (public with rate limiting)
mux.HandleFunc("/api/auth/login", authHandler.Login)
// ==================== PUBLIC ROUTES ====================
// Protected auth routes
mux.Handle("/api/auth/change-password", authMiddleware(http.HandlerFunc(authHandler.ChangePassword)))
// Health check
router.HandleFunc("/health", healthHandler.Check)
router.HandleFunc("/api/health", healthHandler.Check)
// Agency management (SUPERADMIN only)
mux.HandleFunc("/api/admin/agencies/register", agencyHandler.RegisterAgency)
mux.HandleFunc("/api/admin/agencies", tenantHandler.ListAll)
mux.HandleFunc("/api/admin/agencies/", agencyHandler.HandleAgency)
// Auth
router.HandleFunc("/api/auth/login", authHandler.Login)
router.HandleFunc("/api/auth/register", agencyHandler.PublicRegister).Methods("POST")
// Client registration (ADMIN_AGENCIA only - requires auth)
mux.Handle("/api/agencies/clients/register", authMiddleware(http.HandlerFunc(agencyHandler.RegisterClient)))
// Public agency template registration (for creating new agencies)
router.HandleFunc("/api/agency-templates", agencyTemplateHandler.GetTemplateBySlug).Methods("GET")
router.HandleFunc("/api/agency-signup/register", agencyTemplateHandler.PublicRegisterAgency).Methods("POST")
// Public client signup via templates
router.HandleFunc("/api/signup-templates/slug/{slug}", signupTemplateHandler.GetTemplateBySlug).Methods("GET")
router.HandleFunc("/api/signup/register", signupTemplateHandler.PublicRegister).Methods("POST")
// Public plans (for signup flow)
router.HandleFunc("/api/plans", planHandler.ListActivePlans).Methods("GET")
router.HandleFunc("/api/plans/{id}", planHandler.GetActivePlan).Methods("GET")
// File upload (public for signup, will also work with auth)
router.HandleFunc("/api/upload", uploadHandler.Upload).Methods("POST")
// Tenant check (public)
router.HandleFunc("/api/tenant/check", tenantHandler.CheckExists).Methods("GET")
router.HandleFunc("/api/tenant/config", tenantHandler.GetPublicConfig).Methods("GET")
// Hash generator (dev only - remove in production)
router.HandleFunc("/api/hash", handlers.GenerateHash).Methods("POST")
// ==================== PROTECTED ROUTES ====================
// Auth (protected)
router.Handle("/api/auth/change-password", authMiddleware(http.HandlerFunc(authHandler.ChangePassword))).Methods("POST")
// SUPERADMIN: Agency management
router.HandleFunc("/api/admin/agencies/register", agencyHandler.RegisterAgency).Methods("POST")
router.HandleFunc("/api/admin/agencies", tenantHandler.ListAll).Methods("GET")
router.HandleFunc("/api/admin/agencies/{id}", agencyHandler.HandleAgency).Methods("GET", "PATCH", "DELETE")
// SUPERADMIN: Backup & Restore
router.Handle("/api/superadmin/backups", authMiddleware(http.HandlerFunc(backupHandler.ListBackups))).Methods("GET")
router.Handle("/api/superadmin/backup/create", authMiddleware(http.HandlerFunc(backupHandler.CreateBackup))).Methods("POST")
router.Handle("/api/superadmin/backup/restore", authMiddleware(http.HandlerFunc(backupHandler.RestoreBackup))).Methods("POST")
router.Handle("/api/superadmin/backup/download/{filename}", authMiddleware(http.HandlerFunc(backupHandler.DownloadBackup))).Methods("GET")
// SUPERADMIN: Agency template management
router.Handle("/api/admin/agency-templates", authMiddleware(http.HandlerFunc(agencyTemplateHandler.ListTemplates))).Methods("GET")
router.Handle("/api/admin/agency-templates", authMiddleware(http.HandlerFunc(agencyTemplateHandler.CreateTemplate))).Methods("POST")
// SUPERADMIN: Client signup template management
router.Handle("/api/admin/signup-templates", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
signupTemplateHandler.ListTemplates(w, r)
} else if r.Method == http.MethodPost {
signupTemplateHandler.CreateTemplate(w, r)
}
}))).Methods("GET", "POST")
router.Handle("/api/admin/signup-templates/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
signupTemplateHandler.GetTemplateByID(w, r)
case http.MethodPut, http.MethodPatch:
signupTemplateHandler.UpdateTemplate(w, r)
case http.MethodDelete:
signupTemplateHandler.DeleteTemplate(w, r)
}
}))).Methods("GET", "PUT", "PATCH", "DELETE")
// SUPERADMIN: Plans management
planHandler.RegisterRoutes(router)
// SUPERADMIN: Solutions management
router.Handle("/api/admin/solutions", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
solutionHandler.GetAllSolutions(w, r)
case http.MethodPost:
solutionHandler.CreateSolution(w, r)
}
}))).Methods("GET", "POST")
router.Handle("/api/admin/solutions/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
solutionHandler.GetSolution(w, r)
case http.MethodPut, http.MethodPatch:
solutionHandler.UpdateSolution(w, r)
case http.MethodDelete:
solutionHandler.DeleteSolution(w, r)
}
}))).Methods("GET", "PUT", "PATCH", "DELETE")
// SUPERADMIN: Plan <-> Solutions
router.Handle("/api/admin/plans/{plan_id}/solutions", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
solutionHandler.GetPlanSolutions(w, r)
case http.MethodPut:
solutionHandler.SetPlanSolutions(w, r)
}
}))).Methods("GET", "PUT")
// ADMIN_AGENCIA: Client registration
router.Handle("/api/agencies/clients/register", authMiddleware(http.HandlerFunc(agencyHandler.RegisterClient))).Methods("POST")
// Agency profile routes (protected)
mux.Handle("/api/agency/profile", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
router.Handle("/api/agency/profile", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
agencyProfileHandler.GetProfile(w, r)
} else if r.Method == http.MethodPut || r.Method == http.MethodPatch {
case http.MethodPut, http.MethodPatch:
agencyProfileHandler.UpdateProfile(w, r)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})))
}))).Methods("GET", "PUT", "PATCH")
// Protected routes (require authentication)
mux.Handle("/api/companies", authMiddleware(http.HandlerFunc(companyHandler.List)))
mux.Handle("/api/companies/create", authMiddleware(http.HandlerFunc(companyHandler.Create)))
// Agency logo upload (protected)
router.Handle("/api/agency/logo", authMiddleware(http.HandlerFunc(agencyProfileHandler.UploadLogo))).Methods("POST")
// Apply global middlewares: tenant -> cors -> security -> rateLimit -> mux
handler := tenantDetector(corsMiddleware(securityMiddleware(rateLimitMiddleware(mux))))
// File serving route (public - serves files from MinIO through API)
router.PathPrefix("/api/files/{bucket}/").HandlerFunc(filesHandler.ServeFile).Methods("GET")
// Company routes (protected)
router.Handle("/api/companies", authMiddleware(http.HandlerFunc(companyHandler.List))).Methods("GET")
router.Handle("/api/companies/create", authMiddleware(http.HandlerFunc(companyHandler.Create))).Methods("POST")
// ==================== CRM ROUTES (TENANT) ====================
// Tenant solutions (which solutions the tenant has access to)
router.Handle("/api/tenant/solutions", authMiddleware(http.HandlerFunc(solutionHandler.GetTenantSolutions))).Methods("GET")
// Customers
router.Handle("/api/crm/customers", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
crmHandler.GetCustomers(w, r)
case http.MethodPost:
crmHandler.CreateCustomer(w, r)
}
}))).Methods("GET", "POST")
router.Handle("/api/crm/customers/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
crmHandler.GetCustomer(w, r)
case http.MethodPut, http.MethodPatch:
crmHandler.UpdateCustomer(w, r)
case http.MethodDelete:
crmHandler.DeleteCustomer(w, r)
}
}))).Methods("GET", "PUT", "PATCH", "DELETE")
// Lists
router.Handle("/api/crm/lists", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
crmHandler.GetLists(w, r)
case http.MethodPost:
crmHandler.CreateList(w, r)
}
}))).Methods("GET", "POST")
router.Handle("/api/crm/lists/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
crmHandler.GetList(w, r)
case http.MethodPut, http.MethodPatch:
crmHandler.UpdateList(w, r)
case http.MethodDelete:
crmHandler.DeleteList(w, r)
}
}))).Methods("GET", "PUT", "PATCH", "DELETE")
// Customer <-> List relationship
router.Handle("/api/crm/customers/{customer_id}/lists/{list_id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
crmHandler.AddCustomerToList(w, r)
case http.MethodDelete:
crmHandler.RemoveCustomerFromList(w, r)
}
}))).Methods("POST", "DELETE")
// Apply global middlewares: tenant -> cors -> security -> rateLimit -> router
handler := tenantDetector(corsMiddleware(securityMiddleware(rateLimitMiddleware(router))))
// Start server
addr := fmt.Sprintf(":%s", cfg.Server.Port)

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/google/uuid v1.6.0
github.com/lib/pq v1.10.9
github.com/minio/minio-go/v7 v7.0.63
golang.org/x/crypto v0.27.0
)

View File

@@ -5,7 +5,6 @@ import (
"errors"
"log"
"net/http"
"strings"
"time"
"aggios-app/backend/internal/config"
@@ -13,6 +12,7 @@ import (
"aggios-app/backend/internal/service"
"github.com/golang-jwt/jwt/v5"
"github.com/gorilla/mux"
"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("📊 Payload received: RazaoSocial=%s, Phone=%s, City=%s, State=%s, Neighborhood=%s, TeamSize=%s, PrimaryColor=%s, SecondaryColor=%s",
req.RazaoSocial, req.Phone, req.City, req.State, req.Neighborhood, req.TeamSize, req.PrimaryColor, req.SecondaryColor)
tenant, admin, err := h.agencyService.RegisterAgency(req)
if err != nil {
@@ -104,6 +106,112 @@ func (h *AgencyRegistrationHandler) RegisterAgency(w http.ResponseWriter, r *htt
json.NewEncoder(w).Encode(response)
}
// PublicRegister handles public agency registration
func (h *AgencyRegistrationHandler) PublicRegister(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req domain.PublicRegisterAgencyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("❌ Error decoding request: %v", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
log.Printf("📥 Public Registering agency: %s (subdomain: %s)", req.CompanyName, req.Subdomain)
log.Printf("📦 Full Payload: %+v", req)
// Map to internal request
phone := ""
if len(req.Contacts) > 0 {
phone = req.Contacts[0].Whatsapp
}
internalReq := domain.RegisterAgencyRequest{
AgencyName: req.CompanyName,
Subdomain: req.Subdomain,
CNPJ: req.CNPJ,
RazaoSocial: req.RazaoSocial,
Description: req.Description,
Website: req.Website,
Industry: req.Industry,
Phone: phone,
TeamSize: req.TeamSize,
CEP: req.CEP,
State: req.State,
City: req.City,
Neighborhood: req.Neighborhood,
Street: req.Street,
Number: req.Number,
Complement: req.Complement,
PrimaryColor: req.PrimaryColor,
SecondaryColor: req.SecondaryColor,
LogoURL: req.LogoURL,
AdminEmail: req.Email,
AdminPassword: req.Password,
AdminName: req.FullName,
}
tenant, admin, err := h.agencyService.RegisterAgency(internalReq)
if err != nil {
log.Printf("❌ Error registering agency: %v", err)
switch err {
case service.ErrSubdomainTaken:
http.Error(w, err.Error(), http.StatusConflict)
case service.ErrEmailAlreadyExists:
http.Error(w, err.Error(), http.StatusConflict)
case service.ErrWeakPassword:
http.Error(w, err.Error(), http.StatusBadRequest)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
log.Printf("✅ Agency created: %s (ID: %s)", tenant.Name, tenant.ID)
// Generate JWT token for the new admin
claims := jwt.MapClaims{
"user_id": admin.ID.String(),
"email": admin.Email,
"role": admin.Role,
"tenant_id": tenant.ID.String(),
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(h.cfg.JWT.Secret))
if err != nil {
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
return
}
protocol := "http://"
if h.cfg.App.Environment == "production" {
protocol = "https://"
}
response := map[string]interface{}{
"token": tokenString,
"id": admin.ID,
"email": admin.Email,
"name": admin.Name,
"role": admin.Role,
"tenantId": tenant.ID,
"company": tenant.Name,
"subdomain": tenant.Subdomain,
"message": "Agency registered successfully",
"access_url": protocol + tenant.Domain,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(response)
}
// RegisterClient handles client registration (ADMIN_AGENCIA only)
func (h *AgencyRegistrationHandler) RegisterClient(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
@@ -147,9 +255,10 @@ func (h *AgencyRegistrationHandler) HandleAgency(w http.ResponseWriter, r *http.
return
}
agencyID := strings.TrimPrefix(r.URL.Path, "/api/admin/agencies/")
if agencyID == "" || agencyID == r.URL.Path {
http.NotFound(w, r)
vars := mux.Vars(r)
agencyID := vars["id"]
if agencyID == "" {
http.Error(w, "Missing agency ID", http.StatusBadRequest)
return
}
@@ -174,6 +283,27 @@ func (h *AgencyRegistrationHandler) HandleAgency(w http.ResponseWriter, r *http.
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(details)
case http.MethodPatch:
var updateData map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&updateData); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if isActive, ok := updateData["is_active"].(bool); ok {
if err := h.agencyService.UpdateAgencyStatus(id, isActive); err != nil {
if errors.Is(err, service.ErrTenantNotFound) {
http.Error(w, "Agency not found", http.StatusNotFound)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"message": "Status updated"})
case http.MethodDelete:
if err := h.agencyService.DeleteAgency(id); err != nil {
if errors.Is(err, service.ErrTenantNotFound) {

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

View File

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

View File

@@ -3,9 +3,11 @@ package handlers
import (
"encoding/json"
"io"
"log"
"net/http"
"strings"
"aggios-app/backend/internal/api/middleware"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/service"
)
@@ -55,28 +57,38 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
// Login handles user login
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
log.Printf("🔐 LOGIN HANDLER CALLED - Method: %s", r.Method)
if r.Method != http.MethodPost {
log.Printf("❌ Method not allowed: %s", r.Method)
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
log.Printf("❌ Failed to read body: %v", err)
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
log.Printf("📥 Raw body: %s", string(bodyBytes))
// Trim whitespace to avoid decode errors caused by BOM or stray chars
sanitized := strings.TrimSpace(string(bodyBytes))
var req domain.LoginRequest
if err := json.Unmarshal([]byte(sanitized), &req); err != nil {
log.Printf("❌ JSON parse error: %v", err)
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
log.Printf("📧 Login attempt for email: %s", req.Email)
response, err := h.authService.Login(req)
if err != nil {
log.Printf("❌ authService.Login error: %v", err)
if err == service.ErrInvalidCredentials {
http.Error(w, err.Error(), http.StatusUnauthorized)
} else {
@@ -85,6 +97,24 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
return
}
// VALIDAÇÃO DE SEGURANÇA: Verificar se o tenant do usuário corresponde ao subdomain acessado
tenantIDFromContext := ""
if ctxTenantID := r.Context().Value(middleware.TenantIDKey); ctxTenantID != nil {
tenantIDFromContext, _ = ctxTenantID.(string)
}
// Se foi detectado um tenant no contexto (não é superadmin ou site institucional)
if tenantIDFromContext != "" && response.User.TenantID != nil {
userTenantID := response.User.TenantID.String()
if userTenantID != tenantIDFromContext {
log.Printf("❌ LOGIN BLOCKED: User from tenant %s tried to login in tenant %s subdomain", userTenantID, tenantIDFromContext)
http.Error(w, "Forbidden: Invalid credentials for this tenant", http.StatusForbidden)
return
}
log.Printf("✅ TENANT LOGIN VALIDATION PASSED: %s", userTenantID)
}
log.Printf("✅ Login successful for %s, role=%s", response.User.Email, response.User.Role)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}

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 (
"encoding/json"
"log"
"net/http"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/service"
)
@@ -27,16 +27,82 @@ func (h *TenantHandler) ListAll(w http.ResponseWriter, r *http.Request) {
return
}
tenants, err := h.tenantService.ListAll()
tenants, err := h.tenantService.ListAllWithDetails()
if err != nil {
log.Printf("Error listing tenants with details: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if tenants == nil {
tenants = []*domain.Tenant{}
tenants = []map[string]interface{}{}
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
json.NewEncoder(w).Encode(tenants)
}
// CheckExists returns 200 if tenant exists by subdomain, otherwise 404
func (h *TenantHandler) CheckExists(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
subdomain := r.URL.Query().Get("subdomain")
if subdomain == "" {
http.Error(w, "subdomain is required", http.StatusBadRequest)
return
}
_, err := h.tenantService.GetBySubdomain(subdomain)
if err != nil {
if err == service.ErrTenantNotFound {
http.NotFound(w, r)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// GetPublicConfig returns public branding info for a tenant by subdomain
func (h *TenantHandler) GetPublicConfig(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
subdomain := r.URL.Query().Get("subdomain")
if subdomain == "" {
http.Error(w, "subdomain is required", http.StatusBadRequest)
return
}
tenant, err := h.tenantService.GetBySubdomain(subdomain)
if err != nil {
if err == service.ErrTenantNotFound {
http.NotFound(w, r)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Return only public info
response := map[string]string{
"name": tenant.Name,
"primary_color": tenant.PrimaryColor,
"secondary_color": tenant.SecondaryColor,
"logo_url": tenant.LogoURL,
"logo_horizontal_url": tenant.LogoHorizontalURL,
}
log.Printf("📤 Returning tenant config for %s: logo_url=%s", subdomain, tenant.LogoURL)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
json.NewEncoder(w).Encode(response)
}

View File

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

View File

@@ -2,6 +2,7 @@ package middleware
import (
"context"
"log"
"net/http"
"strings"
@@ -13,6 +14,7 @@ import (
type contextKey string
const UserIDKey contextKey = "userID"
const TenantIDKey contextKey = "tenantID"
// Auth validates JWT tokens
func Auth(cfg *config.Config) func(http.Handler) http.Handler {
@@ -39,15 +41,60 @@ func Auth(cfg *config.Config) func(http.Handler) http.Handler {
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
http.Error(w, "Invalid token claims", http.StatusUnauthorized)
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
http.Error(w, "Invalid token claims", http.StatusUnauthorized)
return
}
// Verificar se user_id existe e é do tipo correto
userIDClaim, ok := claims["user_id"]
if !ok || userIDClaim == nil {
http.Error(w, "Missing user_id in token", http.StatusUnauthorized)
return
}
userID, ok := userIDClaim.(string)
if !ok {
http.Error(w, "Invalid user_id format in token", http.StatusUnauthorized)
return
}
// tenant_id pode ser nil para SuperAdmin
var tenantIDFromJWT string
if tenantIDClaim, ok := claims["tenant_id"]; ok && tenantIDClaim != nil {
tenantIDFromJWT, _ = tenantIDClaim.(string)
}
// VALIDAÇÃO DE SEGURANÇA: Verificar se o tenant_id do JWT corresponde ao subdomínio acessado
// Pegar o tenant_id do contexto (detectado pelo TenantDetector middleware ANTES deste)
tenantIDFromContext := ""
if ctxTenantID := r.Context().Value(TenantIDKey); ctxTenantID != nil {
tenantIDFromContext, _ = ctxTenantID.(string)
}
log.Printf("🔐 AUTH VALIDATION: JWT tenant=%s | Context tenant=%s | Path=%s",
tenantIDFromJWT, tenantIDFromContext, r.RequestURI)
// Se o usuário não é SuperAdmin (tem tenant_id) e está acessando uma agência (subdomain detectado)
if tenantIDFromJWT != "" && tenantIDFromContext != "" {
// Validar se o tenant_id do JWT corresponde ao tenant detectado
if tenantIDFromJWT != tenantIDFromContext {
log.Printf("❌ CROSS-TENANT ACCESS BLOCKED: User from tenant %s tried to access tenant %s",
tenantIDFromJWT, tenantIDFromContext)
http.Error(w, "Forbidden: You don't have access to this tenant", http.StatusForbidden)
return
}
log.Printf("✅ TENANT VALIDATION PASSED: %s", tenantIDFromJWT)
}
userID := claims["user_id"].(string)
ctx := context.WithValue(r.Context(), UserIDKey, userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Preservar TODOS os valores do contexto anterior (incluindo o tenantID do TenantDetector)
ctx := r.Context()
ctx = context.WithValue(ctx, UserIDKey, userID)
// Só sobrescrever o TenantIDKey se vier do JWT (para não perder o do TenantDetector)
if tenantIDFromJWT != "" {
ctx = context.WithValue(ctx, TenantIDKey, tenantIDFromJWT)
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}

View File

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

View File

@@ -11,6 +11,7 @@ type Config struct {
JWT JWTConfig
Security SecurityConfig
App AppConfig
Minio MinioConfig
}
// AppConfig holds application-level settings
@@ -45,6 +46,16 @@ type SecurityConfig struct {
PasswordMinLength int
}
// MinioConfig holds MinIO configuration
type MinioConfig struct {
Endpoint string
PublicURL string // URL pública para acesso ao MinIO (para gerar links)
RootUser string
RootPassword string
UseSSL bool
BucketName string
}
// Load loads configuration from environment variables
func Load() *Config {
env := getEnvOrDefault("APP_ENV", "development")
@@ -54,9 +65,9 @@ func Load() *Config {
}
// Rate limit: more lenient in dev, strict in prod
maxAttempts := 30
maxAttempts := 1000 // Aumentado drasticamente para evitar 429 durante debug
if env == "production" {
maxAttempts = 5
maxAttempts = 100 // Mais restritivo em produção
}
return &Config{
@@ -90,6 +101,14 @@ func Load() *Config {
MaxAttemptsPerMin: maxAttempts,
PasswordMinLength: 8,
},
Minio: MinioConfig{
Endpoint: getEnvOrDefault("MINIO_ENDPOINT", "minio:9000"),
PublicURL: getEnvOrDefault("MINIO_PUBLIC_URL", "http://localhost:9000"),
RootUser: getEnvOrDefault("MINIO_ROOT_USER", "minioadmin"),
RootPassword: getEnvOrDefault("MINIO_ROOT_PASSWORD", "changeme"),
UseSSL: getEnvOrDefault("MINIO_USE_SSL", "false") == "true",
BucketName: getEnvOrDefault("MINIO_BUCKET_NAME", "aggios"),
},
}
}

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"`
Website string `json:"website,omitempty" db:"website"`
Address string `json:"address,omitempty" db:"address"`
Neighborhood string `json:"neighborhood,omitempty" db:"neighborhood"`
Number string `json:"number,omitempty" db:"number"`
Complement string `json:"complement,omitempty" db:"complement"`
City string `json:"city,omitempty" db:"city"`
State string `json:"state,omitempty" db:"state"`
Zip string `json:"zip,omitempty" db:"zip"`
Description string `json:"description,omitempty" db:"description"`
Industry string `json:"industry,omitempty" db:"industry"`
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"`
Description string `json:"description,omitempty" db:"description"`
Industry string `json:"industry,omitempty" db:"industry"`
TeamSize string `json:"team_size,omitempty" db:"team_size"`
PrimaryColor string `json:"primary_color,omitempty" db:"primary_color"`
SecondaryColor string `json:"secondary_color,omitempty" db:"secondary_color"`
LogoURL string `json:"logo_url,omitempty" db:"logo_url"`
LogoHorizontalURL string `json:"logo_horizontal_url,omitempty" db:"logo_horizontal_url"`
IsActive bool `json:"is_active" db:"is_active"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// CreateTenantRequest represents the request to create a new tenant
@@ -37,7 +45,15 @@ type CreateTenantRequest struct {
// AgencyDetails aggregates tenant info with its admin user for superadmin view
type AgencyDetails struct {
Tenant *Tenant `json:"tenant"`
Admin *User `json:"admin,omitempty"`
AccessURL string `json:"access_url"`
Tenant *Tenant `json:"tenant"`
Admin *User `json:"admin,omitempty"`
Subscription *AgencySubscriptionInfo `json:"subscription,omitempty"`
AccessURL string `json:"access_url"`
}
type AgencySubscriptionInfo struct {
PlanID string `json:"plan_id"`
PlanName string `json:"plan_name"`
Status string `json:"status"`
Solutions []Solution `json:"solutions"`
}

View File

@@ -36,6 +36,8 @@ type RegisterAgencyRequest struct {
Description string `json:"description"`
Website string `json:"website"`
Industry string `json:"industry"`
Phone string `json:"phone"`
TeamSize string `json:"teamSize"`
// Endereço
CEP string `json:"cep"`
@@ -46,12 +48,59 @@ type RegisterAgencyRequest struct {
Number string `json:"number"`
Complement string `json:"complement"`
// Personalização
PrimaryColor string `json:"primaryColor"`
SecondaryColor string `json:"secondaryColor"`
LogoURL string `json:"logoUrl"`
LogoHorizontalURL string `json:"logoHorizontalUrl"`
// Admin da Agência
AdminEmail string `json:"adminEmail"`
AdminPassword string `json:"adminPassword"`
AdminName string `json:"adminName"`
}
// PublicRegisterAgencyRequest represents the public signup payload
type PublicRegisterAgencyRequest struct {
// User
Email string `json:"email"`
Password string `json:"password"`
FullName string `json:"fullName"`
Newsletter bool `json:"newsletter"`
// Company
CompanyName string `json:"companyName"`
CNPJ string `json:"cnpj"`
RazaoSocial string `json:"razaoSocial"`
Description string `json:"description"`
Website string `json:"website"`
Industry string `json:"industry"`
TeamSize string `json:"teamSize"`
// Address
CEP string `json:"cep"`
State string `json:"state"`
City string `json:"city"`
Neighborhood string `json:"neighborhood"`
Street string `json:"street"`
Number string `json:"number"`
Complement string `json:"complement"`
// Contacts (simplified for now, taking the first one as phone if available)
Contacts []struct {
ID int `json:"id"`
Whatsapp string `json:"whatsapp"`
} `json:"contacts"`
// Domain
Subdomain string `json:"subdomain"`
// Branding
PrimaryColor string `json:"primaryColor"`
SecondaryColor string `json:"secondaryColor"`
LogoURL string `json:"logoUrl"`
}
// RegisterClientRequest represents client registration (ADMIN_AGENCIA only)
type RegisterClientRequest struct {
Email string `json:"email"`

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

View File

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

View File

@@ -4,6 +4,7 @@ import (
"aggios-app/backend/internal/config"
"aggios-app/backend/internal/domain"
"aggios-app/backend/internal/repository"
"database/sql"
"fmt"
"github.com/google/uuid"
@@ -15,14 +16,16 @@ type AgencyService struct {
userRepo *repository.UserRepository
tenantRepo *repository.TenantRepository
cfg *config.Config
db *sql.DB
}
// NewAgencyService creates a new agency service
func NewAgencyService(userRepo *repository.UserRepository, tenantRepo *repository.TenantRepository, cfg *config.Config) *AgencyService {
func NewAgencyService(userRepo *repository.UserRepository, tenantRepo *repository.TenantRepository, cfg *config.Config, db *sql.DB) *AgencyService {
return &AgencyService{
userRepo: userRepo,
tenantRepo: tenantRepo,
cfg: cfg,
db: db,
}
}
@@ -60,24 +63,30 @@ func (s *AgencyService) RegisterAgency(req domain.RegisterAgencyRequest) (*domai
if req.Complement != "" {
address += " - " + req.Complement
}
if req.Neighborhood != "" {
address += " - " + req.Neighborhood
}
tenant := &domain.Tenant{
Name: req.AgencyName,
Domain: fmt.Sprintf("%s.%s", req.Subdomain, s.cfg.App.BaseDomain),
Subdomain: req.Subdomain,
CNPJ: req.CNPJ,
RazaoSocial: req.RazaoSocial,
Email: req.AdminEmail,
Website: req.Website,
Address: address,
City: req.City,
State: req.State,
Zip: req.CEP,
Description: req.Description,
Industry: req.Industry,
Name: req.AgencyName,
Domain: fmt.Sprintf("%s.%s", req.Subdomain, s.cfg.App.BaseDomain),
Subdomain: req.Subdomain,
CNPJ: req.CNPJ,
RazaoSocial: req.RazaoSocial,
Email: req.AdminEmail,
Phone: req.Phone,
Website: req.Website,
Address: address,
Neighborhood: req.Neighborhood,
Number: req.Number,
Complement: req.Complement,
City: req.City,
State: req.State,
Zip: req.CEP,
Description: req.Description,
Industry: req.Industry,
TeamSize: req.TeamSize,
PrimaryColor: req.PrimaryColor,
SecondaryColor: req.SecondaryColor,
LogoURL: req.LogoURL,
LogoHorizontalURL: req.LogoHorizontalURL,
}
if err := s.tenantRepo.Create(tenant); err != nil {
@@ -174,6 +183,43 @@ func (s *AgencyService) GetAgencyDetails(id uuid.UUID) (*domain.AgencyDetails, e
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
}
@@ -189,3 +235,16 @@ func (s *AgencyService) DeleteAgency(id uuid.UUID) error {
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 (
"errors"
"log"
"time"
"aggios-app/backend/internal/config"
@@ -78,14 +79,20 @@ func (s *AuthService) Login(req domain.LoginRequest) (*domain.LoginResponse, err
// Find user by email
user, err := s.userRepo.FindByEmail(req.Email)
if err != nil {
log.Printf("❌ DB error finding user %s: %v", req.Email, err)
return nil, err
}
if user == nil {
log.Printf("❌ User not found: %s", req.Email)
return nil, ErrInvalidCredentials
}
log.Printf("🔍 Attempting login for %s with password_hash: %.10s...", req.Email, user.Password)
log.Printf("🔍 Provided password length: %d", len(req.Password))
// Verify password
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
log.Printf("❌ Password mismatch for %s: %v", req.Email, err)
return nil, ErrInvalidCredentials
}

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
type TenantService struct {
tenantRepo *repository.TenantRepository
db *sql.DB
}
// NewTenantService creates a new tenant service
func NewTenantService(tenantRepo *repository.TenantRepository) *TenantService {
func NewTenantService(tenantRepo *repository.TenantRepository, db *sql.DB) *TenantService {
return &TenantService{
tenantRepo: tenantRepo,
db: db,
}
}
@@ -79,6 +81,84 @@ func (s *TenantService) ListAll() ([]*domain.Tenant, error) {
return s.tenantRepo.FindAll()
}
// ListAllWithDetails retrieves all tenants with their plan and solutions information
func (s *TenantService) ListAllWithDetails() ([]map[string]interface{}, error) {
tenants, err := s.tenantRepo.FindAll()
if err != nil {
return nil, err
}
var result []map[string]interface{}
for _, tenant := range tenants {
tenantData := map[string]interface{}{
"id": tenant.ID,
"name": tenant.Name,
"subdomain": tenant.Subdomain,
"domain": tenant.Domain,
"email": tenant.Email,
"phone": tenant.Phone,
"cnpj": tenant.CNPJ,
"is_active": tenant.IsActive,
"created_at": tenant.CreatedAt,
"logo_url": tenant.LogoURL,
"logo_horizontal_url": tenant.LogoHorizontalURL,
"primary_color": tenant.PrimaryColor,
"secondary_color": tenant.SecondaryColor,
}
// Buscar subscription e soluções
var planName sql.NullString
var planID string
query := `
SELECT
s.plan_id,
p.name as plan_name
FROM agency_subscriptions s
JOIN plans p ON s.plan_id = p.id
WHERE s.agency_id = $1 AND s.status = 'active'
LIMIT 1
`
err = s.db.QueryRow(query, tenant.ID).Scan(&planID, &planName)
if err == nil && planName.Valid {
tenantData["plan_name"] = planName.String
// Buscar soluções do plano
solutionsQuery := `
SELECT sol.id, sol.name, sol.slug, sol.icon
FROM solutions sol
JOIN plan_solutions ps ON sol.id = ps.solution_id
WHERE ps.plan_id = $1
ORDER BY sol.name
`
rows, err := s.db.Query(solutionsQuery, planID)
if err == nil {
defer rows.Close()
var solutions []map[string]interface{}
for rows.Next() {
var id, name, slug string
var icon sql.NullString
if err := rows.Scan(&id, &name, &slug, &icon); err == nil {
solution := map[string]interface{}{
"id": id,
"name": name,
"slug": slug,
}
if icon.Valid {
solution["icon"] = icon.String
}
solutions = append(solutions, solution)
}
}
tenantData["solutions"] = solutions
}
}
result = append(result, tenantData)
}
return result, nil
}
// Delete removes a tenant by ID
func (s *TenantService) Delete(id uuid.UUID) error {
if err := s.tenantRepo.Delete(id); err != nil {

View File

@@ -6,10 +6,6 @@ services:
restart: unless-stopped
command:
- "--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.watch=true"
- "--entrypoints.web.address=:80"
@@ -69,9 +65,24 @@ services:
container_name: aggios-minio
restart: unless-stopped
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:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD:-M1n10_S3cur3_P@ss_2025!}
MINIO_BROWSER_REDIRECT_URL: http://minio.localhost
MINIO_SERVER_URL: http://files.localhost
volumes:
- minio_data:/data
ports:
@@ -93,12 +104,15 @@ services:
dockerfile: Dockerfile
container_name: aggios-backend
restart: unless-stopped
ports:
- "8085:8080"
labels:
- "traefik.enable=true"
- "traefik.http.routers.backend.rule=Host(`api.aggios.local`) || Host(`api.localhost`)"
- "traefik.http.routers.backend.entrypoints=web"
- "traefik.http.services.backend.loadbalancer.server.port=8080"
environment:
TZ: America/Sao_Paulo
SERVER_HOST: 0.0.0.0
SERVER_PORT: 8080
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_PASSWORD: ${REDIS_PASSWORD:-R3d1s_S3cur3_P@ss_2025!}
MINIO_ENDPOINT: minio:9000
MINIO_PUBLIC_URL: http://files.localhost
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD:-M1n10_S3cur3_P@ss_2025!}
volumes:
- ./backups:/backups
depends_on:
postgres:
condition: service_healthy
@@ -171,6 +188,31 @@ services:
networks:
- 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:
postgres_data:
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 [domainAvailable, setDomainAvailable] = useState<boolean | null>(null);
const [checkingDomain, setCheckingDomain] = useState(false);
const [primaryColor, setPrimaryColor] = useState("#FF3A05");
const [secondaryColor, setSecondaryColor] = useState("#FF0080");
const [primaryColor, setPrimaryColor] = useState("#ff3a05");
const [secondaryColor, setSecondaryColor] = useState("#ff0080");
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);
// 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
useEffect(() => {
const saved = localStorage.getItem('cadastroFormData');
@@ -52,8 +104,8 @@ export default function CadastroPage() {
setCepData(data.cepData || { state: "", city: "", neighborhood: "", street: "" });
setSubdomain(data.subdomain || "");
setDomainAvailable(data.domainAvailable ?? null);
setPrimaryColor(data.primaryColor || "#FF3A05");
setSecondaryColor(data.secondaryColor || "#FF0080");
setPrimaryColor(data.primaryColor || "#ff3a05");
setSecondaryColor(data.secondaryColor || "#ff0080");
setLogoUrl(data.logoUrl || "");
} catch (error) {
console.error('Erro ao carregar dados:', error);
@@ -314,6 +366,12 @@ export default function CadastroPage() {
number: formData.number,
complement: formData.complement,
// Personalização
primaryColor: formData.primaryColor,
secondaryColor: formData.secondaryColor,
logoUrl: logoUrl,
logoHorizontalUrl: logoHorizontalUrl,
// Admin
adminEmail: formData.email,
adminPassword: password,
@@ -323,7 +381,7 @@ export default function CadastroPage() {
console.log('📤 Enviando cadastro completo:', payload);
toast.loading('Criando sua conta...', { id: 'register' });
const response = await fetch('/api/admin/agencies', {
const response = await fetch(API_ENDPOINTS.adminAgencyRegister, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -332,8 +390,23 @@ export default function CadastroPage() {
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Erro ao criar conta');
let errorMessage = 'Erro ao criar conta';
try {
const text = await response.text();
// Tentar parsear como JSON primeiro
try {
const error = JSON.parse(text);
errorMessage = error.message || error.error || text;
} catch {
// Se não for JSON, usar o texto direto
errorMessage = text || errorMessage;
}
} catch (e) {
// Erro ao ler resposta
}
toast.error(errorMessage, { id: 'register' });
throw new Error(errorMessage);
}
const data = await response.json();
@@ -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(() => {
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;
}, 2000);
@@ -406,8 +480,8 @@ export default function CadastroPage() {
setContacts([{ id: 1, whatsapp: "(11) 98765-4321" }]);
setSubdomain("idealpages");
setDomainAvailable(true);
setPrimaryColor("#FF3A05");
setSecondaryColor("#FF0080");
setPrimaryColor("#ff3a05");
setSecondaryColor("#ff0080");
// Marcar todos os steps como completos e ir pro step 5
setCompletedSteps([1, 2, 3, 4]);
@@ -529,9 +603,9 @@ export default function CadastroPage() {
const getPasswordStrengthColor = () => {
if (passwordStrength <= 1) return "#EF4444";
if (passwordStrength === 2) return "#F59E0B";
if (passwordStrength === 3) return "#3B82F6";
if (passwordStrength === 4) return "#10B981";
return "#059669";
if (passwordStrength === 3) return "#ff3a05";
if (passwordStrength === 4) return "#ff3a05";
return "#ff3a05";
};
const fetchCnpjData = async (cnpj: string) => {
@@ -565,16 +639,30 @@ export default function CadastroPage() {
if (response.ok) {
const data = await response.json();
if (!data.erro) {
setCepData({
const nextCep = {
state: data.uf || "",
city: data.localidade || "",
neighborhood: data.bairro || "",
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) {
console.error("Erro ao buscar CEP:", error);
toast.error('Erro ao buscar CEP. Tente novamente.');
} finally {
setLoadingCep(false);
}
@@ -607,7 +695,7 @@ export default function CadastroPage() {
error: {
icon: '⚠️',
style: {
background: '#ff3a05',
background: '#ef4444',
color: '#FFFFFF',
border: 'none',
},
@@ -660,8 +748,8 @@ export default function CadastroPage() {
/>
<defs>
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#FF3A05" />
<stop offset="100%" stopColor="#FF0080" />
<stop offset="0%" stopColor="#ff3a05" />
<stop offset="100%" stopColor="#ff0080" />
</linearGradient>
</defs>
</svg>
@@ -763,7 +851,11 @@ export default function CadastroPage() {
label={
<span>
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
</Link>
</span>
@@ -779,7 +871,11 @@ export default function CadastroPage() {
{/* Link para login */}
<p className="text-center mt-6 text-[14px] text-[#7D7D7D]">
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
</Link>
</p>
@@ -829,13 +925,13 @@ export default function CadastroPage() {
disabled
/>
<div>
<label className="block text-[13px] font-semibold text-[#000000] mb-2">
Descrição Breve<span className="text-[#FF3A05] ml-1">*</span>
<label className="block text-[13px] font-semibold text-zinc-900 mb-2">
Descrição Breve<span className="ml-1" style={{ color: 'var(--brand-color)' }}>*</span>
</label>
<textarea
name="description"
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}
maxLength={300}
value={formData.description || ''}
@@ -925,7 +1021,15 @@ export default function CadastroPage() {
// Se campo vazio, limpar dados
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
else if (numbers.length === 8) {
@@ -989,19 +1093,19 @@ export default function CadastroPage() {
</div>
{/* 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="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>
{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 && (
<div className="flex items-center justify-end -mt-2 -mr-2">
<button
type="button"
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]" />
</button>
@@ -1044,8 +1148,8 @@ export default function CadastroPage() {
<div className="space-y-6">
{/* Subdomínio Aggios */}
<div className="space-y-2">
<label className="block text-sm font-medium text-[#000000]">
Subdomínio Aggios <span className="text-[#FF3A05]">*</span>
<label className="block text-sm font-medium text-zinc-900">
Subdomínio Aggios <span style={{ color: 'var(--brand-color)' }}>*</span>
</label>
<div className="relative">
<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)}
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
/>
{checkingDomain && (
<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>
)}
{!checkingDomain && domainAvailable === true && (
@@ -1076,13 +1180,13 @@ export default function CadastroPage() {
)}
{!checkingDomain && domainAvailable === false && (
<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>
<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" />
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>
{domainAvailable === true && (
<p className="text-xs text-[#10B981] flex items-center gap-1">
@@ -1091,7 +1195,7 @@ export default function CadastroPage() {
</p>
)}
{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" />
Indisponível. Este subdomínio está em uso.
</p>
@@ -1100,11 +1204,11 @@ export default function CadastroPage() {
{/* Informações Adicionais */}
<div className="p-6 bg-[#F5F5F5] rounded-md space-y-3">
<h4 className="text-sm font-semibold text-[#000000] flex items-center gap-2">
<i className="ri-lightbulb-line text-[#FF3A05]" />
<h4 className="text-sm font-semibold text-zinc-900 flex items-center gap-2">
<i className="ri-lightbulb-line" style={{ color: 'var(--brand-color)' }} />
Dicas para escolher seu domínio
</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">Evite números e hífens quando possível</li>
<li className="list-disc">Escolha algo fácil de lembrar e digitar</li>
@@ -1121,7 +1225,14 @@ export default function CadastroPage() {
<button
type="button"
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`} />
{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) */}
<div className={showPreviewMobile ? 'hidden lg:block space-y-4' : 'block space-y-4'}>
{/* Upload de Logo */}
{/* Upload de Logo Quadrado (Obrigatório) */}
<div>
<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>
<div className="flex items-center gap-6">
{/* Preview do Logo */}
@@ -1165,50 +1276,119 @@ export default function CadastroPage() {
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
setLogoUrl(reader.result as string);
};
reader.readAsDataURL(file);
handleLogoUpload(file, false);
}
}}
className="hidden"
id="logo-upload"
disabled={uploadingLogo}
/>
<label
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" />
Escolher arquivo
{uploadingLogo ? (
<>
<i className="ri-loader-4-line animate-spin" />
Enviando...
</>
) : (
<>
<i className="ri-upload-2-line" />
Escolher arquivo
</>
)}
</label>
{logoUrl && (
<button
type="button"
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
</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
</p>
</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 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Cor Primária */}
<div>
<label className="block text-sm font-medium text-[#000000] mb-3">
Cor Primária <span className="text-[#FF3A05]">*</span>
<label className="block text-sm font-medium text-zinc-900 mb-3">
Cor Primária <span style={{ color: 'var(--brand-color)' }}>*</span>
</label>
<div className="flex gap-3">
<div className="relative flex-1">
<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>
<input
type="text"
@@ -1219,18 +1399,18 @@ export default function CadastroPage() {
setPrimaryColor(value);
}
}}
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"
placeholder="#ff3a05"
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>
<input
type="color"
value={primaryColor}
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>
<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" />
Usada em menus, botões e destaques
</p>
@@ -1238,13 +1418,13 @@ export default function CadastroPage() {
{/* Cor Secundária */}
<div>
<label className="block text-sm font-medium text-[#000000] mb-3">
Cor Secundária <span className="text-[#7D7D7D]">(opcional)</span>
<label className="block text-sm font-medium text-zinc-900 mb-3">
Cor Secundária <span className="text-zinc-500">(opcional)</span>
</label>
<div className="flex gap-3">
<div className="relative flex-1">
<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>
<input
type="text"
@@ -1255,18 +1435,18 @@ export default function CadastroPage() {
setSecondaryColor(value);
}
}}
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"
placeholder="#ff0080"
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>
<input
type="color"
value={secondaryColor}
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>
<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" />
Usada em cards e elementos secundários
</p>
@@ -1275,10 +1455,10 @@ export default function CadastroPage() {
{/* Paletas Sugeridas */}
<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">
{[
{ name: 'Fogo', primary: '#FF3A05', secondary: '#FF0080' },
{ name: 'Marca', primary: '#FF3A05', secondary: '#FF0080' },
{ name: 'Oceano', primary: '#0EA5E9', secondary: '#3B82F6' },
{ name: 'Natureza', primary: '#10B981', secondary: '#059669' },
{ name: 'Elegante', primary: '#8B5CF6', secondary: '#A78BFA' },
@@ -1294,7 +1474,8 @@ export default function CadastroPage() {
setPrimaryColor(palette.primary);
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
@@ -1306,7 +1487,7 @@ export default function CadastroPage() {
style={{ backgroundColor: palette.secondary }}
/>
</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}
</span>
</button>
@@ -1317,7 +1498,7 @@ export default function CadastroPage() {
{/* Informações */}
<div className="p-6 bg-[#F0F9FF] border border-[#BAE6FD] rounded-md">
<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>
<h4 className="text-sm font-semibold text-[#000000] mb-1">
Você pode alterar depois
@@ -1337,7 +1518,7 @@ export default function CadastroPage() {
</div>
{/* 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 */}
<div className="hidden md:flex items-center justify-between">
{/* Botão voltar à esquerda */}
@@ -1363,21 +1544,21 @@ export default function CadastroPage() {
? "bg-[#10B981] text-white"
: currentStep === step.number
? "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}
</div>
<span className={`text-xs transition-colors ${currentStep === step.number
? "text-[#000000] font-semibold"
: "text-[#7D7D7D] group-hover:text-[#000000]"
? "text-zinc-900 font-semibold"
: "text-zinc-500 group-hover:text-zinc-900"
}`}>
{step.title}
</span>
</button>
{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>
))}
@@ -1407,9 +1588,9 @@ export default function CadastroPage() {
? "w-2 bg-[#10B981]"
: currentStep === step.number
? "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}`}
/>
))}
@@ -1442,7 +1623,7 @@ export default function CadastroPage() {
</div>
{/* 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
currentStep={currentStep}
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}</>;
}

View File

@@ -0,0 +1,120 @@
'use client';
import { useEffect } from 'react';
/**
* LoginBranding - Aplica cor primária da agência na página de login
* Busca cor do localStorage ou da API se não houver cache
*/
export function LoginBranding() {
useEffect(() => {
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 applyTheme = (primary: string) => {
if (!primary) return;
const root = document.documentElement;
const primaryRgb = hexToRgb(primary);
root.style.setProperty('--brand-color', primary);
root.style.setProperty('--gradient', `linear-gradient(135deg, ${primary}, ${primary})`);
if (primaryRgb) {
root.style.setProperty('--brand-rgb', primaryRgb);
root.style.setProperty('--brand-strong-rgb', primaryRgb);
root.style.setProperty('--brand-hover-rgb', primaryRgb);
}
};
const updateFavicon = (url: string) => {
if (typeof window === 'undefined' || typeof document === 'undefined') return;
try {
const newHref = `${url}${url.includes('?') ? '&' : '?'}v=${Date.now()}`;
const existingLinks = document.querySelectorAll("link[rel*='icon']");
if (existingLinks.length > 0) {
existingLinks.forEach(link => {
link.setAttribute('href', newHref);
});
} else {
const newLink = document.createElement('link');
newLink.rel = 'icon';
newLink.type = 'image/x-icon';
newLink.href = newHref;
document.head.appendChild(newLink);
}
} catch (error) {
console.error('❌ Erro ao atualizar favicon:', error);
}
};
const loadBranding = async () => {
if (typeof window === 'undefined') return;
const hostname = window.location.hostname;
const subdomain = hostname.split('.')[0];
// Para dash.localhost ou localhost sem subdomínio, não buscar
if (!subdomain || subdomain === 'localhost' || subdomain === 'www' || subdomain === 'dash') {
return;
}
try {
// 1. Buscar DIRETO do backend (bypass da rota Next.js que está com problema)
console.log('LoginBranding: Buscando cores para:', subdomain);
const apiUrl = `/api/tenant/config?subdomain=${subdomain}`;
console.log('LoginBranding: URL:', apiUrl);
const response = await fetch(apiUrl);
if (response.ok) {
const data = await response.json();
if (data.primary_color) {
applyTheme(data.primary_color);
localStorage.setItem('agency-primary-color', data.primary_color);
}
if (data.logo_url) {
updateFavicon(data.logo_url);
localStorage.setItem('agency-logo-url', data.logo_url);
}
return;
} else {
console.error('LoginBranding: API retornou:', response.status);
}
// 2. Fallback para cache
const cachedPrimary = localStorage.getItem('agency-primary-color');
const cachedLogo = localStorage.getItem('agency-logo-url');
if (cachedPrimary) {
applyTheme(cachedPrimary);
}
if (cachedLogo) {
updateFavicon(cachedLogo);
}
} catch (error) {
console.error('LoginBranding: Erro:', error);
const cachedPrimary = localStorage.getItem('agency-primary-color');
const cachedLogo = localStorage.getItem('agency-logo-url');
if (cachedPrimary) {
applyTheme(cachedPrimary);
}
if (cachedLogo) {
updateFavicon(cachedLogo);
}
}
};
loadBranding();
}, []);
return null;
}

View File

@@ -0,0 +1,74 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { useToast } from '@/components/layout/ToastContext';
interface SolutionGuardProps {
children: React.ReactNode;
requiredSolution: string; // slug da solução (ex: 'crm', 'erp')
}
export function SolutionGuard({ children, requiredSolution }: SolutionGuardProps) {
const router = useRouter();
const pathname = usePathname();
const { error } = useToast();
const [hasAccess, setHasAccess] = useState<boolean | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const checkAccess = async () => {
try {
const response = await fetch('/api/tenant/solutions', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
if (response.ok) {
const data = await response.json();
const solutions = data.solutions || [];
const solutionSlugs = solutions.map((s: any) => s.slug.toLowerCase());
// Dashboard é sempre permitido
if (requiredSolution === 'dashboard') {
setHasAccess(true);
} else {
const hasPermission = solutionSlugs.includes(requiredSolution.toLowerCase());
if (!hasPermission) {
// Mostra toast de aviso
error('Acesso Negado', 'Você não tem acesso a este módulo. Contate o suporte para mais informações.');
// Redireciona imediatamente
router.replace('/dashboard');
return;
}
setHasAccess(hasPermission);
}
} else {
// Em caso de erro, redireciona para segurança
error('Erro de Acesso', 'Não foi possível verificar suas permissões. Contate o suporte.');
router.replace('/dashboard');
return;
}
} catch (err) {
// Em caso de erro, redireciona para segurança
error('Erro de Acesso', 'Não foi possível verificar suas permissões. Contate o suporte.');
router.replace('/dashboard');
return;
} finally {
setLoading(false);
}
};
checkAccess();
}, [requiredSolution, router, pathname, error]);
if (loading || hasAccess === false) {
return null;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,153 @@
"use client";
interface DashboardPreviewProps {
companyName: string;
subdomain: string;
primaryColor: string;
secondaryColor: string;
logoUrl?: string;
}
export default function DashboardPreview({
companyName,
subdomain,
primaryColor,
secondaryColor,
logoUrl
}: DashboardPreviewProps) {
return (
<div className="bg-white rounded-lg border-2 border-[#E5E5E5] overflow-hidden shadow-lg">
{/* Header do Preview */}
<div className="bg-[#F5F5F5] px-3 py-2 border-b border-[#E5E5E5] flex items-center gap-2">
<div className="flex gap-1.5">
<div className="w-3 h-3 rounded-full bg-[#FF5F57]" />
<div className="w-3 h-3 rounded-full bg-[#FFBD2E]" />
<div className="w-3 h-3 rounded-full bg-[#28CA42]" />
</div>
<div className="flex-1 text-center">
<span className="text-xs text-[#7D7D7D]">
{subdomain || 'seu-dominio'}.aggios.app
</span>
</div>
</div>
{/* Conteúdo do Preview - Dashboard */}
<div className="aspect-video bg-[#F8F9FA] relative overflow-hidden">
{/* Sidebar */}
<div
className="absolute left-0 top-0 bottom-0 w-16 flex flex-col items-center py-4 gap-3"
style={{ backgroundColor: primaryColor }}
>
{/* Logo/Initial */}
<div className="w-10 h-10 rounded-lg bg-white/20 flex items-center justify-center text-white font-bold text-sm overflow-hidden">
{logoUrl ? (
<img src={logoUrl} alt="Logo" className="w-full h-full object-cover" />
) : (
<span>{(companyName || 'E')[0].toUpperCase()}</span>
)}
</div>
{/* Menu Icons */}
<div className="w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<i className="ri-dashboard-line text-white text-lg" />
</div>
<div className="w-10 h-10 rounded-lg flex items-center justify-center text-white/60">
<i className="ri-folder-line text-lg" />
</div>
<div className="w-10 h-10 rounded-lg flex items-center justify-center text-white/60">
<i className="ri-team-line text-lg" />
</div>
<div className="w-10 h-10 rounded-lg flex items-center justify-center text-white/60">
<i className="ri-settings-3-line text-lg" />
</div>
</div>
{/* Main Content */}
<div className="ml-16 p-4">
{/* Top Bar */}
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-sm font-bold text-[#000000]">
{companyName || 'Sua Empresa'}
</h2>
<p className="text-xs text-[#7D7D7D]">Dashboard</p>
</div>
<div className="flex gap-2">
<div className="w-6 h-6 rounded-full bg-[#E5E5E5]" />
<div className="w-6 h-6 rounded-full bg-[#E5E5E5]" />
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-3 gap-2 mb-3">
<div className="bg-white rounded-lg p-2 border border-[#E5E5E5]">
<div className="flex items-center gap-2 mb-1">
<div
className="w-6 h-6 rounded flex items-center justify-center"
style={{ backgroundColor: `${primaryColor}20` }}
>
<i className="ri-folder-line text-xs" style={{ color: primaryColor }} />
</div>
<span className="text-[10px] text-[#7D7D7D]">Projetos</span>
</div>
<p className="text-sm font-bold" style={{ color: primaryColor }}>24</p>
</div>
<div className="bg-white rounded-lg p-2 border border-[#E5E5E5]">
<div className="flex items-center gap-2 mb-1">
<div
className="w-6 h-6 rounded flex items-center justify-center"
style={{ backgroundColor: secondaryColor ? `${secondaryColor}20` : '#10B98120' }}
>
<i className="ri-team-line text-xs" style={{ color: secondaryColor || '#10B981' }} />
</div>
<span className="text-[10px] text-[#7D7D7D]">Clientes</span>
</div>
<p className="text-sm font-bold" style={{ color: secondaryColor || '#10B981' }}>15</p>
</div>
<div className="bg-white rounded-lg p-2 border border-[#E5E5E5]">
<div className="flex items-center gap-2 mb-1">
<div className="w-6 h-6 rounded flex items-center justify-center bg-[#7D7D7D]/10">
<i className="ri-money-dollar-circle-line text-xs text-[#7D7D7D]" />
</div>
<span className="text-[10px] text-[#7D7D7D]">Receita</span>
</div>
<p className="text-sm font-bold text-[#7D7D7D]">R$ 45k</p>
</div>
</div>
{/* Chart Area */}
<div className="bg-white rounded-lg p-3 border border-[#E5E5E5]">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-[#000000]">Desempenho</span>
<button
className="px-2 py-0.5 rounded text-[10px] text-white"
style={{ backgroundColor: primaryColor }}
>
Este mês
</button>
</div>
<div className="flex items-end gap-1 h-16">
{[40, 70, 45, 80, 60, 90, 75].map((height, i) => (
<div key={i} className="flex-1 flex flex-col justify-end">
<div
className="w-full rounded-t transition-all"
style={{
height: `${height}%`,
backgroundColor: i === 6 ? primaryColor : `${primaryColor}40`
}}
/>
</div>
))}
</div>
</div>
</div>
</div>
{/* Footer Preview */}
<div className="bg-[#F5F5F5] px-3 py-2 text-center border-t border-[#E5E5E5]">
<p className="text-[10px] text-[#7D7D7D]">
Preview do seu painel As cores e layout podem ser ajustados
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,234 @@
"use client";
import { useEffect, useState } from "react";
import DashboardPreview from "./DashboardPreview";
interface DynamicBrandingProps {
currentStep: number;
companyName?: string;
subdomain?: string;
primaryColor?: string;
secondaryColor?: string;
logoUrl?: string;
}
export default function DynamicBranding({
currentStep,
companyName = '',
subdomain = '',
primaryColor = '#0ea5e9',
secondaryColor = '#0284c7',
logoUrl = ''
}: DynamicBrandingProps) {
const [activeTestimonial, setActiveTestimonial] = useState(0);
const testimonials = [
{
text: "Com o Aggios, nossa produtividade aumentou 40%. Gestão de projetos nunca foi tão simples!",
author: "Maria Silva",
company: "DigitalWorks",
avatar: "MS"
},
{
text: "Reduzi 60% do tempo gasto com controle financeiro. Tudo centralizado em um só lugar.",
author: "João Santos",
company: "TechHub",
avatar: "JS"
},
{
text: "A melhor decisão para nossa agência. Dashboard intuitivo e relatórios incríveis!",
author: "Ana Costa",
company: "CreativeFlow",
avatar: "AC"
}
];
const stepContent = [
{
icon: "ri-user-heart-line",
title: "Bem-vindo ao Aggios!",
description: "Vamos criar sua conta em poucos passos",
benefits: [
"✓ Acesso completo ao painel",
"✓ Gestão ilimitada de projetos",
"✓ Suporte prioritário"
]
},
{
icon: "ri-building-line",
title: "Configure sua Empresa",
description: "Personalize de acordo com seu negócio",
benefits: [
"✓ Dashboard personalizado",
"✓ Gestão de equipe e clientes",
"✓ Controle financeiro integrado"
]
},
{
icon: "ri-map-pin-line",
title: "Quase lá!",
description: "Informações de localização e contato",
benefits: [
"✓ Multi-contatos configuráveis",
"✓ Integração com WhatsApp",
"✓ Notificações em tempo real"
]
},
{
icon: "ri-global-line",
title: "Seu Domínio Exclusivo",
description: "Escolha como acessar seu painel",
benefits: [
"✓ Subdomínio personalizado",
"✓ SSL incluído gratuitamente",
"✓ Domínio próprio (opcional)"
]
},
{
icon: "ri-palette-line",
title: "Personalize as Cores",
description: "Deixe com a cara da sua empresa",
benefits: [
"✓ Preview em tempo real",
"✓ Paleta de cores customizada",
"✓ Identidade visual única"
]
}
];
const content = stepContent[currentStep - 1] || stepContent[0];
// Auto-rotate testimonials
useEffect(() => {
const interval = setInterval(() => {
setActiveTestimonial((prev) => (prev + 1) % testimonials.length);
}, 5000);
return () => clearInterval(interval);
}, [testimonials.length]);
// Se for etapa 5, mostrar preview do dashboard
if (currentStep === 5) {
return (
<div className="relative z-10 flex flex-col justify-center items-center w-full p-12">
{/* 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">
<h2 className="text-3xl font-bold mb-2 text-white">Preview do seu Painel</h2>
<p className="text-white/80 text-lg">Veja como ficará seu dashboard personalizado</p>
</div>
{/* Preview */}
<div className="w-full max-w-3xl">
<DashboardPreview
companyName={companyName}
subdomain={subdomain}
primaryColor={primaryColor}
secondaryColor={secondaryColor}
logoUrl={logoUrl}
/>
</div>
{/* Info */}
<div className="mt-6 text-center">
<p className="text-white/70 text-sm">
As cores e configurações são atualizadas em tempo real
</p>
</div>
{/* Decorative circles */}
<div className="absolute -bottom-32 -left-32 w-96 h-96 rounded-full bg-white/5" />
<div className="absolute -top-16 -right-16 w-64 h-64 rounded-full bg-white/5" />
</div>
);
}
return (
<div className="relative z-10 flex flex-col justify-between w-full p-12 text-white">
{/* Logo e Conteúdo da Etapa */}
<div className="flex flex-col justify-center flex-1">
{/* 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>
{/* Ícone e Título da Etapa */}
<div className="mb-6">
<div className="w-16 h-16 rounded-2xl bg-white/20 flex items-center justify-center mb-4">
<i className={`${content.icon} text-3xl`} />
</div>
<h2 className="text-3xl font-bold mb-2">{content.title}</h2>
<p className="text-white/80 text-lg">{content.description}</p>
</div>
{/* Benefícios */}
<div className="space-y-3 mb-8">
{content.benefits.map((benefit, index) => (
<div
key={index}
className="flex items-center gap-3 text-white/90 animate-fade-in"
style={{ animationDelay: `${index * 100}ms` }}
>
<span className="text-lg">{benefit}</span>
</div>
))}
</div>
</div>
{/* Carrossel de Depoimentos */}
<div className="relative">
<div className="bg-white/10 backdrop-blur-sm rounded-2xl p-6 border border-white/20">
<div className="mb-4">
<i className="ri-double-quotes-l text-3xl text-white/40" />
</div>
<p className="text-white/95 mb-4 min-h-[60px]">
{testimonials[activeTestimonial].text}
</p>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center font-semibold">
{testimonials[activeTestimonial].avatar}
</div>
<div>
<p className="font-semibold text-white">
{testimonials[activeTestimonial].author}
</p>
<p className="text-sm text-white/70">
{testimonials[activeTestimonial].company}
</p>
</div>
</div>
</div>
{/* Indicadores */}
<div className="flex gap-2 justify-center mt-4">
{testimonials.map((_, index) => (
<button
key={index}
onClick={() => setActiveTestimonial(index)}
className={`h-1.5 rounded-full transition-all ${index === activeTestimonial
? "w-8 bg-white"
: "w-1.5 bg-white/40 hover:bg-white/60"
}`}
aria-label={`Ir para depoimento ${index + 1}`}
/>
))}
</div>
</div>
{/* Decorative circles */}
<div className="absolute -bottom-32 -left-32 w-96 h-96 rounded-full bg-white/5" />
<div className="absolute -top-16 -right-16 w-64 h-64 rounded-full bg-white/5" />
</div>
);
}

View File

@@ -0,0 +1,152 @@
'use client';
import { useEffect, useState } from 'react';
interface AgencyBrandingProps {
colors?: {
primary: string;
secondary: string;
} | null;
}
/**
* AgencyBranding - Aplica as cores da agência via CSS Variables
* O favicon é atualizado dinamicamente via DOM
*/
export function AgencyBranding({ colors }: AgencyBrandingProps) {
useEffect(() => {
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 applyTheme = (primary: string, secondary: string) => {
if (!primary || !secondary) return;
const root = document.documentElement;
const primaryRgb = hexToRgb(primary);
const secondaryRgb = hexToRgb(secondary);
const gradient = `linear-gradient(135deg, ${primary}, ${primary})`;
const gradientText = `linear-gradient(to right, ${primary}, ${primary})`;
root.style.setProperty('--gradient', gradient);
root.style.setProperty('--gradient-text', gradientText);
root.style.setProperty('--gradient-primary', gradient);
root.style.setProperty('--color-gradient-brand', gradient);
root.style.setProperty('--brand-color', primary);
root.style.setProperty('--brand-color-strong', secondary);
if (primaryRgb) root.style.setProperty('--brand-rgb', primaryRgb);
if (secondaryRgb) root.style.setProperty('--brand-strong-rgb', secondaryRgb);
// Salvar no localStorage para cache
if (typeof window !== 'undefined') {
const hostname = window.location.hostname;
const sub = hostname.split('.')[0];
if (sub && sub !== 'www') {
localStorage.setItem(`agency-theme:${sub}`, gradient);
localStorage.setItem('agency-primary-color', primary);
localStorage.setItem('agency-secondary-color', secondary);
}
}
};
const updateFavicon = (url: string) => {
if (typeof window === 'undefined' || typeof document === 'undefined') return;
try {
const newHref = `${url}${url.includes('?') ? '&' : '?'}v=${Date.now()}`;
// Buscar TODOS os links de ícone (como estava funcionando antes)
const existingLinks = document.querySelectorAll("link[rel*='icon']");
if (existingLinks.length > 0) {
existingLinks.forEach(link => {
link.setAttribute('href', newHref);
});
console.log(`${existingLinks.length} favicons atualizados`);
} else {
const newLink = document.createElement('link');
newLink.rel = 'icon';
newLink.type = 'image/x-icon';
newLink.href = newHref;
document.head.appendChild(newLink);
console.log('✅ Favicon criado');
}
} catch (error) {
console.error('❌ Erro ao atualizar favicon:', error);
}
};
// Se temos cores do servidor, aplicar imediatamente
if (colors) {
applyTheme(colors.primary, colors.secondary);
} else {
// Fallback: tentar pegar do cache do localStorage
const cachedPrimary = localStorage.getItem('agency-primary-color');
const cachedSecondary = localStorage.getItem('agency-secondary-color');
if (cachedPrimary && cachedSecondary) {
applyTheme(cachedPrimary, cachedSecondary);
}
}
// Atualizar favicon se houver logo salvo
const cachedLogo = localStorage.getItem('agency-logo-url');
if (cachedLogo) {
updateFavicon(cachedLogo);
} else {
// Se não tiver no cache, buscar do backend
const fetchAndUpdateFavicon = async () => {
const token = localStorage.getItem('token');
if (!token) return;
try {
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080/api';
const res = await fetch(`${API_BASE}/agency/profile`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
if (data.logo_url) {
localStorage.setItem('agency-logo-url', data.logo_url);
updateFavicon(data.logo_url);
console.log('✅ Favicon carregado do backend:', data.logo_url);
}
}
} catch (error) {
console.error('❌ Erro ao buscar logo para favicon:', error);
}
};
fetchAndUpdateFavicon();
}
// Listener para atualizações em tempo real
const handleUpdate = () => {
const cachedPrimary = localStorage.getItem('agency-primary-color');
const cachedSecondary = localStorage.getItem('agency-secondary-color');
const cachedLogo = localStorage.getItem('agency-logo-url');
if (cachedPrimary && cachedSecondary) {
applyTheme(cachedPrimary, cachedSecondary);
}
if (cachedLogo) {
updateFavicon(cachedLogo);
}
};
window.addEventListener('branding-update', handleUpdate);
return () => {
window.removeEventListener('branding-update', handleUpdate);
};
}, [colors]);
// Componente não renderiza nada visualmente (apenas efeitos colaterais)
return null;
}

View File

@@ -0,0 +1,123 @@
import { Fragment } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import { ExclamationTriangleIcon, XMarkIcon } from '@heroicons/react/24/outline';
interface ConfirmDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: 'danger' | 'warning' | 'info';
}
export default function ConfirmDialog({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = 'Confirmar',
cancelText = 'Cancelar',
variant = 'danger'
}: ConfirmDialogProps) {
const handleConfirm = () => {
onConfirm();
onClose();
};
const variantStyles = {
danger: {
icon: 'bg-red-100 dark:bg-red-900/20',
iconColor: 'text-red-600 dark:text-red-400',
button: 'bg-red-600 hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-800'
},
warning: {
icon: 'bg-yellow-100 dark:bg-yellow-900/20',
iconColor: 'text-yellow-600 dark:text-yellow-400',
button: 'bg-yellow-600 hover:bg-yellow-700 dark:bg-yellow-700 dark:hover:bg-yellow-800'
},
info: {
icon: 'bg-blue-100 dark:bg-blue-900/20',
iconColor: 'text-blue-600 dark:text-blue-400',
button: 'bg-blue-600 hover:bg-blue-700 dark:bg-blue-700 dark:hover:bg-blue-800'
}
};
const style = variantStyles[variant];
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-zinc-900/40 backdrop-blur-sm transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel 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="p-6">
<div className="flex items-start gap-4">
<div className={`flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl ${style.icon}`}>
<ExclamationTriangleIcon className={`h-6 w-6 ${style.iconColor}`} />
</div>
<div className="flex-1">
<Dialog.Title className="text-lg font-semibold text-zinc-900 dark:text-white">
{title}
</Dialog.Title>
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
{message}
</p>
</div>
<button
onClick={onClose}
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"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
<div className="mt-6 flex gap-3">
<button
type="button"
onClick={onClose}
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"
>
{cancelText}
</button>
<button
type="button"
onClick={handleConfirm}
className={`flex-1 px-4 py-2.5 text-white font-medium rounded-lg transition-colors ${style.button}`}
>
{confirmText}
</button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
}

View File

@@ -0,0 +1,52 @@
'use client';
import React, { useState } from 'react';
import { usePathname } from 'next/navigation';
import { SidebarRail, MenuItem } from './SidebarRail';
import { TopBar } from './TopBar';
import { MobileBottomBar } from './MobileBottomBar';
interface DashboardLayoutProps {
children: React.ReactNode;
menuItems: MenuItem[];
}
export const DashboardLayout: React.FC<DashboardLayoutProps> = ({ children, menuItems }) => {
// Estado centralizado do layout
const [isExpanded, setIsExpanded] = useState(true);
const pathname = usePathname();
return (
<div className="flex h-screen w-full bg-gray-100 dark:bg-zinc-950 text-slate-900 dark:text-slate-100 overflow-hidden md:p-3 md:gap-3 transition-colors duration-300">
{/* Sidebar controla seu próprio estado visual via props - Desktop Only */}
<div className="hidden md:flex">
<SidebarRail
isExpanded={isExpanded}
onToggle={() => setIsExpanded(!isExpanded)}
menuItems={menuItems}
/>
</div>
{/* Área de Conteúdo (Children) */}
<main className="flex-1 h-full min-w-0 overflow-hidden flex flex-col bg-gray-50 dark:bg-zinc-900 md:rounded-2xl shadow-lg relative transition-colors duration-300 border border-transparent dark:border-zinc-800"
style={{
backgroundImage: `radial-gradient(circle, rgb(200 200 200 / 0.15) 1px, transparent 1px)`,
backgroundSize: '24px 24px'
}}
>
{/* TopBar com Breadcrumbs e Search */}
<TopBar />
{/* Conteúdo das páginas */}
<div className="flex-1 overflow-auto pb-20 md:pb-0">
<div className="max-w-7xl mx-auto w-full h-full">
{children}
</div>
</div>
</main>
{/* Mobile Bottom Bar */}
<MobileBottomBar />
</div>
);
};

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