From 190fde20c31a2275dc233d80d3177a07badfac78 Mon Sep 17 00:00:00 2001 From: Erik Silva Date: Mon, 8 Dec 2025 21:47:38 -0300 Subject: [PATCH] Prepara versao dev 1.0 --- 1. docs/{ => old}/HOSTS.md | 0 1. docs/{ => old}/README.md | 0 1. docs/{ => old}/info-cadastro-agencia.md | 0 1. docs/{ => old}/instrucoes-ia.md | 0 1. docs/{ => old}/plano.md | 0 1. docs/old/projeto.md | 1046 ++++++++++++++++ 1. docs/projeto.md | 1062 +---------------- README.md | 60 +- backend/Dockerfile | 17 +- backend/cmd/server/go.mod | 10 - backend/cmd/server/go.sum | 8 - backend/cmd/server/main.go | 629 ++-------- backend/go.mod | 12 +- backend/go.sum | 12 +- backend/internal/api/handlers/agency.go | 192 +++ .../internal/api/handlers/agency_profile.go | 179 +++ backend/internal/api/handlers/auth.go | 139 +++ backend/internal/api/handlers/company.go | 90 ++ backend/internal/api/handlers/health.go | 31 + backend/internal/api/handlers/tenant.go | 42 + backend/internal/api/middleware/auth.go | 53 + backend/internal/api/middleware/cors.go | 34 + backend/internal/api/middleware/ratelimit.go | 96 ++ backend/internal/api/middleware/security.go | 17 + backend/internal/api/middleware/tenant.go | 56 + backend/internal/config/config.go | 96 ++ backend/internal/domain/company.go | 31 + backend/internal/domain/tenant.go | 43 + backend/internal/domain/user.go | 73 ++ .../internal/repository/company_repository.go | 127 ++ .../internal/repository/tenant_repository.go | 268 +++++ .../internal/repository/user_repository.go | 154 +++ backend/internal/service/agency_service.go | 191 +++ backend/internal/service/auth_service.go | 170 +++ backend/internal/service/company_service.go | 73 ++ backend/internal/service/tenant_service.go | 91 ++ docker-compose.yml | 30 +- .../app/(agency)/clientes/page.tsx | 26 + .../app/(agency)/configuracoes/page.tsx | 713 +++++++++++ .../app/(agency)/dashboard/page.tsx | 181 +++ .../app/(agency)/layout.tsx | 569 +++++++++ .../app/(auth)/LayoutWrapper.tsx | 7 +- .../app/(auth)/cadastro/page.tsx | 106 +- .../app/(auth)/layout.tsx | 10 +- .../app/(auth)/recuperar-senha/page.tsx | 22 +- .../app/LayoutWrapper.tsx | 7 +- .../app/api/admin/agencies/[id]/route.ts | 45 + .../app/api/admin/agencies/route.ts | 54 + .../app/api/auth/login/route.ts | 29 + front-end-dash.aggios.app/app/globals.css | 223 ++-- front-end-dash.aggios.app/app/layout.tsx | 36 +- .../app/{(auth) => }/login/page.tsx | 185 ++- front-end-dash.aggios.app/app/painel/page.tsx | 175 --- .../app/superadmin/page.tsx | 485 ++++++++ front-end-dash.aggios.app/app/tokens.css | 50 + .../components/ThemeTester.tsx | 100 ++ .../components/ThemeToggle.tsx | 31 +- .../components/ui/Button.tsx | 2 +- .../components/ui/Checkbox.tsx | 2 +- .../components/ui/Dialog.tsx | 95 ++ .../components/ui/SearchableSelect.tsx | 30 +- .../components/ui/index.ts | 1 + .../contexts/ThemeContext.tsx | 45 - front-end-dash.aggios.app/lib/auth.ts | 7 +- front-end-dash.aggios.app/middleware.ts | 33 + front-end-dash.aggios.app/next.config.ts | 4 +- front-end-dash.aggios.app/package-lock.json | 936 ++++++++++++++- front-end-dash.aggios.app/package.json | 5 + front-end-dash.aggios.app/tailwind.config.js | 12 + front-end-dash.aggios.app/tailwind.preset.js | 35 + frontend-aggios.app/Dockerfile | 28 + frontend-aggios.app/app/globals.css | 66 +- frontend-aggios.app/app/layout.tsx | 24 +- frontend-aggios.app/app/page.tsx | 207 +++- frontend-aggios.app/app/tokens.css | 67 ++ frontend-aggios.app/components/Header.tsx | 148 +++ frontend-aggios.app/next.config.ts | 4 +- frontend-aggios.app/package-lock.json | 15 +- frontend-aggios.app/package.json | 3 +- frontend-aggios.app/tailwind.config.js | 5 +- frontend-aggios.app/tailwind.preset.js | 35 + postgres/init-db.sql | 44 +- test_agency_payload.json | 1 + traefik/dynamic/rules.yml | 29 +- traefik/traefik.yml | 3 + 85 files changed, 7755 insertions(+), 2317 deletions(-) rename 1. docs/{ => old}/HOSTS.md (100%) rename 1. docs/{ => old}/README.md (100%) rename 1. docs/{ => old}/info-cadastro-agencia.md (100%) rename 1. docs/{ => old}/instrucoes-ia.md (100%) rename 1. docs/{ => old}/plano.md (100%) create mode 100644 1. docs/old/projeto.md delete mode 100644 backend/cmd/server/go.mod delete mode 100644 backend/cmd/server/go.sum create mode 100644 backend/internal/api/handlers/agency.go create mode 100644 backend/internal/api/handlers/agency_profile.go create mode 100644 backend/internal/api/handlers/auth.go create mode 100644 backend/internal/api/handlers/company.go create mode 100644 backend/internal/api/handlers/health.go create mode 100644 backend/internal/api/handlers/tenant.go create mode 100644 backend/internal/api/middleware/auth.go create mode 100644 backend/internal/api/middleware/cors.go create mode 100644 backend/internal/api/middleware/ratelimit.go create mode 100644 backend/internal/api/middleware/security.go create mode 100644 backend/internal/api/middleware/tenant.go create mode 100644 backend/internal/config/config.go create mode 100644 backend/internal/domain/company.go create mode 100644 backend/internal/domain/tenant.go create mode 100644 backend/internal/domain/user.go create mode 100644 backend/internal/repository/company_repository.go create mode 100644 backend/internal/repository/tenant_repository.go create mode 100644 backend/internal/repository/user_repository.go create mode 100644 backend/internal/service/agency_service.go create mode 100644 backend/internal/service/auth_service.go create mode 100644 backend/internal/service/company_service.go create mode 100644 backend/internal/service/tenant_service.go create mode 100644 front-end-dash.aggios.app/app/(agency)/clientes/page.tsx create mode 100644 front-end-dash.aggios.app/app/(agency)/configuracoes/page.tsx create mode 100644 front-end-dash.aggios.app/app/(agency)/dashboard/page.tsx create mode 100644 front-end-dash.aggios.app/app/(agency)/layout.tsx create mode 100644 front-end-dash.aggios.app/app/api/admin/agencies/[id]/route.ts create mode 100644 front-end-dash.aggios.app/app/api/admin/agencies/route.ts create mode 100644 front-end-dash.aggios.app/app/api/auth/login/route.ts rename front-end-dash.aggios.app/app/{(auth) => }/login/page.tsx (50%) delete mode 100644 front-end-dash.aggios.app/app/painel/page.tsx create mode 100644 front-end-dash.aggios.app/app/superadmin/page.tsx create mode 100644 front-end-dash.aggios.app/app/tokens.css create mode 100644 front-end-dash.aggios.app/components/ThemeTester.tsx create mode 100644 front-end-dash.aggios.app/components/ui/Dialog.tsx delete mode 100644 front-end-dash.aggios.app/contexts/ThemeContext.tsx create mode 100644 front-end-dash.aggios.app/middleware.ts create mode 100644 front-end-dash.aggios.app/tailwind.config.js create mode 100644 front-end-dash.aggios.app/tailwind.preset.js create mode 100644 frontend-aggios.app/Dockerfile create mode 100644 frontend-aggios.app/app/tokens.css create mode 100644 frontend-aggios.app/components/Header.tsx create mode 100644 frontend-aggios.app/tailwind.preset.js create mode 100644 test_agency_payload.json diff --git a/1. docs/HOSTS.md b/1. docs/old/HOSTS.md similarity index 100% rename from 1. docs/HOSTS.md rename to 1. docs/old/HOSTS.md diff --git a/1. docs/README.md b/1. docs/old/README.md similarity index 100% rename from 1. docs/README.md rename to 1. docs/old/README.md diff --git a/1. docs/info-cadastro-agencia.md b/1. docs/old/info-cadastro-agencia.md similarity index 100% rename from 1. docs/info-cadastro-agencia.md rename to 1. docs/old/info-cadastro-agencia.md diff --git a/1. docs/instrucoes-ia.md b/1. docs/old/instrucoes-ia.md similarity index 100% rename from 1. docs/instrucoes-ia.md rename to 1. docs/old/instrucoes-ia.md diff --git a/1. docs/plano.md b/1. docs/old/plano.md similarity index 100% rename from 1. docs/plano.md rename to 1. docs/old/plano.md diff --git a/1. docs/old/projeto.md b/1. docs/old/projeto.md new file mode 100644 index 0000000..11deabf --- /dev/null +++ b/1. docs/old/projeto.md @@ -0,0 +1,1046 @@ +# 🚀 AGGIOS - PLATAFORMA SAAS MULTITENANT + +**Documento Completo de Especificações e Arquitetura** +**Versão**: 1.0 +**Data**: 04/12/2025 +**Status**: Pronto para Desenvolvimento (Code Agent) + +--- + +## 📋 Índice + +1. [Visão Geral](#visão-geral) +2. [Estrutura de Domínios](#estrutura-de-domínios) +3. [Usuários e Roles](#usuários-e-roles) +4. [Módulos Principais](#módulos-principais) +5. [Modelo de Negócio](#modelo-de-negócio) +6. [Stack Técnico](#stack-técnico) +7. [Arquitetura](#arquitetura) +8. [Fluxo de Cadastro (5 Steps)](#fluxo-de-cadastro-5-steps) +9. [CRM - MVP](#crm---mvp) +10. [Desenvolvimento Local](#desenvolvimento-local) +11. [Deploy Produção](#deploy-produção) +12. [Roadmap](#roadmap) +13. [Segurança](#segurança) +14. [Escalabilidade](#escalabilidade) + +--- + +## 🎯 Visão Geral + +**Aggios** é uma plataforma SaaS multitenant que unifica ferramentas de gestão para agências digitais. + +**Problema Resolvido**: Agências precisam gerenciar clientes, projetos, contratos, pagamentos e documentos em uma única plataforma integrada. + +**Solução**: Dashboard personalizado por agência onde: +- Admin da agência gerencia equipe, CRM, projetos +- Clientes acessam portal para acompanhar seus projetos +- Tudo integrado: CRM, ERP, Helpdesk, Pagamentos, Contratos + +**Público-alvo**: Agências digitais de 1-50 pessoas + +**Modelo**: SaaS subscription com planos diferentes + +--- + +## 🌐 Estrutura de Domínios + +### Domínios Principais + +**aggios.app** - Site Institucional +- Marketing da plataforma +- Pricing (mostra planos) +- Blog/Recursos +- Landing pages +- SEO e público geral + +**dash.aggios.app** - Admin Interno Aggios +- Super admin da plataforma +- Ver todas as agências cadastradas +- Gerenciar assinaturas e faturamento +- Impersonate (logar como admin de outra agência) +- Analytics da plataforma +- Suporte (abrir tickets para clientes) + +**{agencia}.aggios.app** - Painel da Agência (Multitenant) +- Exemplo: `idealpages.aggios.app` +- Admin e equipe da agência acessam aqui +- Personalizado com logo, cores, dados da agência +- Todos os módulos: CRM, ERP, Projetos, etc +- Subpath do portal cliente: `{agencia}.aggios.app/portal/{cliente}` + +### Portal Cliente + +**URL**: `{agencia}.aggios.app/portal/{cliente-id}?token=xyz` +- Cliente acessa seus projetos específicos +- Read-only + comentários +- Acompanha progresso +- Download de arquivos +- Sem acesso a dados de outras agências + +--- + +## 👥 Usuários e Roles + +### Tipos de Usuários + +**Admin Aggios** (Plataforma) +- Acessa: `dash.aggios.app` +- Permissões: ver todas agências, faturamento, assinaturas, analytics, impersonate +- Uso: time Aggios (suporte, vendas, admin) + +**Admin da Agência** +- Acessa: `{agencia}.aggios.app` +- Permissões: gerenciar equipe, configurar agência, todos os módulos, relatórios +- Uso: dono/gerente geral da agência + +**Gerente/Operacional** +- Acessa: `{agencia}.aggios.app` +- Permissões: CRM, projetos, helpdesk, mas sem financeiro/admin +- Uso: coordenador de projetos, gerente de equipe + +**Membro da Equipe** +- Acessa: `{agencia}.aggios.app` +- Permissões: módulos específicos conforme papel (desenvolvedores, designers, etc) +- Uso: equipe interna + +**Cliente (Portal)** +- Acessa: `{agencia}.aggios.app/portal/{cliente}` +- Permissões: ver próprios projetos, comentar, download de arquivos +- Uso: clientes da agência + +--- + +## 📦 Módulos Principais + +### MVP (Versão Inicial) + +**CRM** (Customer Relationship Management) +- Gerenciar clientes (lista, adicionar, editar, deletar) +- Status: prospect, client, inactive +- Dashboard com estatísticas +- Filtros e busca +- Histórico de interações (futuro) + +**Projetos** +- Criar projeto (nome, cliente, data de entrega) +- Kanban (To Do, In Progress, Done) +- Timeline com marcos +- Atribuir ao membro da equipe +- Anexar arquivos + +**Pagamentos** +- Integração com Stripe +- Cobranças automáticas +- Faturas geradas +- Histórico de pagamentos + +**Portal Cliente** +- Compartilhar projeto via link +- Cliente faz login/cria conta +- Visualiza progresso +- Faz comentários + +### Módulos Futuros (v1.0+) + +**ERP** +- Recursos (materiais, ferramentas) +- Financeiro avançado (custos, lucro) +- Relatórios financeiros +- Exportação de dados + +**Helpdesk** +- Tickets de suporte +- SLA (tempo de resposta) +- Base de conhecimento +- Automação + +**Contratos** +- Templates de contratos +- E-signature (assinatura digital) +- Versionamento +- Histórico de assinaturas + +**Integrações** +- Zapier +- Slack +- Google Workspace +- GitHub +- Jira + +--- + +## 💰 Modelo de Negócio + +### Planos de Assinatura + +**STARTER** - R$99/mês +- CRM + Contratos + Pagamentos +- 3 usuários +- 10 projetos simultâneos +- 5GB de armazenamento +- Suporte por email + +**PROFESSIONAL** - R$299/mês +- Starter + ERP + Helpdesk + Projetos +- 10 usuários +- Projetos ilimitados +- 100GB de armazenamento +- Suporte prioritário +- API básica + +**ENTERPRISE** - R$999+/mês +- Professional + Integrações customizadas +- Usuários ilimitados +- Armazenamento ilimitado +- Suporte 24/7 +- API completa +- Webhooks +- SSO (Single Sign-On) + +### À La Carte (Futuro) + +- CRM: R$49/mês +- ERP: R$79/mês +- Helpdesk: R$59/mês +- Contratos: R$29/mês +- Projetos: R$39/mês + +### Modelo de Receita + +- Subscription mensal (principal) +- Taxa de setup: R$99 (primeira agência) +- Integrações customizadas: R$1000+ +- Suporte dedicado: R$500/mês + +--- + +## 🛠️ Stack Técnico + +### Frontend + +**Next.js** (React) +- App router (não pages router) +- Server components quando possível +- TypeScript +- Tailwind CSS para estilos +- ShadcN UI ou Radix UI para componentes + +**Interfaces Principais**: +- Landing page (aggios.app) +- Admin internal (dash.aggios.app) +- Painel da agência ({agencia}.aggios.app) +- Portal do cliente + +### Backend + +**NestJS** (Node.js) +- Arquitetura modular +- TypeScript nativo +- Guards, Pipes, Interceptors +- Validation com class-validator +- Documentation com Swagger + +**Principais Módulos**: +- Authentication (JWT, refresh tokens) +- Tenants (multi-tenancy) +- CRM +- Projetos +- Pagamentos (Stripe integration) + +### Banco de Dados + +**PostgreSQL** +- Schema por tenant (isolamento de dados) +- Row-Level Security (RLS) para segurança +- Migrations com Typeorm ou Knex +- Backup automático diário + +### Cache + +**Redis** +- Sessions de usuário +- Cache de dados frequentes +- Rate limiting +- Fila de jobs + +### Storage + +**Minio** (S3-compatible) +- Upload de logos +- Arquivos de projetos +- Documentos/contratos +- Backup de dados + +### Roteamento + +**Traefik** +- Load balancer +- Roteamento por hostname +- HTTPS/SSL automático + +### CI/CD + +**GitHub Actions** +- Testes automáticos +- Build da aplicação +- Deploy automático em merge para main + +--- + +## 🏗️ Arquitetura + +### Visão Geral + +``` +┌─────────────────────────────────────────────┐ +│ Frontend (Next.js) │ +│ ├─ Landing: aggios.app │ +│ ├─ Admin: dash.aggios.app │ +│ ├─ App: {agencia}.aggios.app │ +│ └─ Portal: {agencia}.aggios.app/portal │ +└────────────────┬────────────────────────────┘ + │ HTTPS +┌────────────────▼────────────────────────────┐ +│ Traefik │ +│ (Roteamento por hostname) │ +└────────────────┬────────────────────────────┘ + │ +┌────────────────▼────────────────────────────┐ +│ Backend (NestJS API) │ +│ ├─ Auth Module │ +│ ├─ Tenants Module │ +│ ├─ CRM Module │ +│ ├─ Projetos Module │ +│ └─ Pagamentos Module │ +└────────────────┬────────────────────────────┘ + │ + ┌────────────┼────────────┬──────────┐ + │ │ │ │ + ▼ ▼ ▼ ▼ +┌─────────┐ ┌────────┐ ┌─────────┐ ┌──────┐ +│PostgreSQL│ │ Redis │ │ Minio │ │Stripe│ +│ │ │ │ │ (S3) │ │ │ +└──────────┘ └────────┘ └─────────┘ └──────┘ +``` + +### Isolamento de Dados + +**Row-Level Security (RLS)** no PostgreSQL: +- Cada tenant tem suas próprias linhas +- Política: usuário só vê dados do seu tenant +- Impossível vazar dados entre clientes + +**Schema Compartilhado**: +- Uma tabela "tenants" com todas as agências +- Foreign key tenant_id em todas as tabelas +- Sem criar schemas separados (simples) + +--- + +## 📝 Fluxo de Cadastro (5 Steps) + +### Resumo dos Steps + +**Step 1**: Dados Pessoais (5 campos) +- Nome, email, telefone, whatsapp, senha +- Tempo: 2-3 minutos + +**Step 2**: Empresa Básico (6 campos) +- Nome, CNPJ, descrição, website, indústria, tamanho equipe +- Tempo: 3-4 minutos + +**Step 3**: Localização e Contato (10 campos) +- CEP (com busca ViaCEP), estado, cidade, bairro, rua, número, complemento +- Email comercial, telefone, whatsapp +- Tempo: 3-4 minutos + +**Step 4**: Escolher Domínio (1 campo) +- Slug com sugestão automática e validação em tempo real +- Tempo: 1-2 minutos + +**Step 5**: Personalização (3 campos) +- Logo (upload), cor primária, cor secundária +- Checkboxes: permitir upload cliente, portal automático +- Tempo: 2-3 minutos + +### Fluxo Técnico + +``` +User acessa: dash.aggios.app/cadastro + ↓ +Step 1: Preenche dados pessoais → POST /auth/signup/step-1 + ↓ +Backend valida + INSERT signup_temp (temporário, 24h) + ↓ +User redireciona para Step 2 + ↓ +Step 2: Preenche dados empresa → POST /auth/signup/step-2 + ↓ +Backend valida + UPDATE signup_temp + ↓ +Continua para Step 3, 4, 5... + ↓ +Step 5: Upload logo + cores → POST /auth/signup/step-5 + ↓ +Backend executa TRANSAÇÃO: +├─ CREATE tenant (agência) +├─ CREATE user (admin) +├─ DELETE signup_temp +├─ Gera JWT +└─ Retorna token + redirectUrl + ↓ +Frontend redireciona para: {slug}.aggios.app/welcome +``` + +### Dados Coletados + +**Pessoal** (Step 1): +- Nome completo +- Email (login) +- Telefone +- WhatsApp +- Senha (hashed) + +**Empresa** (Step 2): +- Nome agência +- CNPJ (14 dígitos) +- Descrição +- Website +- Indústria +- Tamanho equipe + +**Localização** (Step 3): +- CEP (integração ViaCEP) +- Estado/UF +- Cidade +- Bairro +- Rua/Avenida +- Número +- Complemento + +**Contato** (Step 3): +- Email comercial +- Telefone empresa +- WhatsApp empresa + +**Domínio** (Step 4): +- Slug ({agencia}.aggios.app) + +**Personalização** (Step 5): +- Logo URL (Minio) +- Cor primária (hex) - Padrão: #FF3A05 +- Cor secundária (hex) - Padrão: #FF0080 +- Gradiente de branding: `linear-gradient(90deg, #FF3A05, #FF0080)` +- Permitir upload cliente (sim/não) +- Portal automático (sim/não) + +### Validações Importantes + +**CNPJ**: +- 14 dígitos válidos +- Dígitos verificadores corretos +- Não duplicado no banco + +**CEP**: +- 8 dígitos válidos +- Busca em ViaCEP +- Se não encontrado: preenchimento manual + +**Slug**: +- 3-50 caracteres +- Apenas a-z, 0-9, hífen +- Não é palavra reservada +- Não duplicado + +**Senha**: +- 8+ caracteres +- 1 maiúscula, 1 número, 1 especial + +--- + +## 💻 CRM - MVP + +### Funcionalidades Incluídas (MVP) + +**Lista de Clientes** +- Paginação (20 por página) +- Busca por nome/email +- Filtro por status (prospect, client, inactive) +- Ordenação (nome, data criação, status) + +**Adicionar Cliente** +- Form simples: nome, email, telefone, empresa +- Validação de email +- Cliente criado com status "prospect" + +**Editar Cliente** +- Atualizar dados +- Mudar status +- Salvar histórico de mudanças + +**Deletar Cliente** +- Soft delete (marca como inactive) +- Ou hard delete (remove do banco) + +**Dashboard Stats** +- Total de clientes +- Clientes por status (prospect, client, inactive) +- Gráficos simples + +### Funcionalidades Futuras (Deixar para depois) + +- Pipeline de vendas (funil) +- Histórico de interações +- Tags/categorias +- Atividades (calls, emails) +- Emails integrados +- Relatórios avançados +- Import/export CSV +- Automação + +### Dados Armazenados + +**Clientes**: +- ID (UUID) +- Tenant ID (qual agência) +- Nome (obrigatório) +- Email (obrigatório) +- Telefone +- Empresa +- Status (prospect/client/inactive) +- Data criação +- Data última atualização +- Criado por (user ID) + +**Histórico** (futuro): +- ID +- Cliente ID +- O que mudou +- Valor anterior +- Valor novo +- Quem mudou +- Data + +### URLs + +- `{agencia}.aggios.app/crm/customers` - Lista +- `{agencia}.aggios.app/crm/customers/new` - Novo +- `{agencia}.aggios.app/crm/customers/[id]` - Detalhe/editar +- `{agencia}.aggios.app/crm/dashboard` - Dashboard stats + +### Telas "Em Breve" + +Todos os outros módulos mostram tela "Este módulo será lançado em breve": +- ERP +- Helpdesk +- Contratos +- Pagamentos +- Projetos + +--- + +## 🏗️ Desenvolvimento Local + +### Ambiente Local + +**Ferramentas Necessárias**: +- Docker + Docker Compose +- Node.js 18+ +- Git + +### Serviços Locais (Docker) + +``` +PostgreSQL +├─ Host: localhost:5432 +├─ User: aggios +├─ Password: password +└─ Database: aggios_dev + +Redis +├─ Host: localhost:6379 +└─ Port: 6379 + +Minio (S3-compatible) +├─ Host: localhost:9000 +├─ User: minioadmin +├─ Password: minioadmin +└─ Bucket: aggios + +Traefik +├─ Host: localhost (com config /etc/hosts para *.localhost) +├─ Dashboard: localhost:8080 +└─ Router automático por hostname +``` + +### Hosts Locais + +Configure em `/etc/hosts`: +``` +127.0.0.1 aggios.localhost +127.0.0.1 dash.aggios.localhost +127.0.0.1 idealpages.aggios.localhost +127.0.0.1 test.aggios.localhost +``` + +### Iniciar Ambiente + +``` +1. Clone repositório +2. Configure .env (cópiar de .env.example) +3. docker-compose up -d +4. npm install +5. npm run db:migrate +6. npm run dev +``` + +### URLs de Acesso + +- Landing: http://aggios.localhost +- Admin: http://dash.aggios.localhost +- Agência teste: http://idealpages.aggios.localhost +- Minio: http://localhost:9000 +- Traefik: http://localhost:8080 + +--- + +## 🚀 Deploy Produção + +### Infraestrutura + +**VPS Recomendado**: +- 2GB RAM +- 20GB SSD +- Ubuntu 24 LTS +- IP público estático + +**Provedor**: Linode, DigitalOcean, Vultr, AWS EC2 + +### Setup Produção (Dokploy) + +**Passo 1**: Comprar VPS + domínio + +**Passo 2**: Instalar Dokploy +- SSH no VPS +- Executar script instalação +- Interface web no `http://ip-vps:3000` + +**Passo 3**: Configurar DNS +- Apontar domínio para IP do VPS +- Subdomínios: dash.aggios.app, *.aggios.app (wildcard) + +**Passo 4**: Cadastrar 6 Serviços em Dokploy + +1. **Landing** (Next.js - aggios.app) + - Port: 3000 + - Build: `npm run build` + - Start: `npm start` + +2. **Admin** (Next.js - dash.aggios.app) + - Port: 3001 + - Build: `npm run build` + - Start: `npm start` + +3. **API** (NestJS - *.aggios.app wildcard) + - Port: 3333 + - Build: `npm run build` + - Start: `npm run start:prod` + +4. **PostgreSQL** + - Port: 5432 + - Backup automático (daily) + +5. **Redis** + - Port: 6379 + +6. **Minio** + - Port: 9000 + +**Passo 5**: GitHub Integration +- Conectar repositório GitHub +- Push em main → Deploy automático +- Webhook automático + +### O que Dokploy Cuida + +- Traefik integrado (não configurar manual) +- SSL automático (Let's Encrypt) +- HTTPS forçado +- Backup automático de banco +- Monitoramento básico +- CI/CD nativo +- Logs centralizados + +### Diferenças Local vs Produção + +| Aspecto | Local | Produção | +|---------|-------|----------| +| Traefik | Manual config | Dokploy nativo | +| HTTPS | HTTP (.localhost) | HTTPS automático | +| Domínios | *.localhost | Domínios reais | +| SSL | Não | Let's Encrypt | +| Backup | Manual | Automático | +| CI/CD | Manual | GitHub webhook | +| Monitoramento | Não | Básico | +| Logs | Docker | Dokploy | + +--- + +## 📅 Roadmap + +### MVP (Mês 1-2) + +**Prioridades**: +- Setup infraestrutura (Docker, Traefik, Dokploy) +- Autenticação (JWT, refresh tokens) +- Cadastro 5 steps +- Tenant middleware + RLS +- CRM básico (CRUD) +- Painel inicial +- Upload de logo (Minio) +- Stripe integration básica +- Deploy no Dokploy + +**Resultado**: Agência consegue se cadastrar e gerenciar clientes + +### v1.0 (Mês 2-3) + +**Adições**: +- Módulo Projetos (Kanban, timeline) +- Portal cliente (compartilhar projeto) +- Contratos com e-signature +- Helpdesk básico +- Relatórios simples +- Email notifications +- Onboarding/tour + +**Resultado**: Plataforma funcional com todos módulos principais + +### v1.1 (Mês 3-4) + +**Adições**: +- ERP básico (recursos, financeiro) +- Integrações (Zapier, Slack) +- API documentada +- Webhooks +- Backup automático na nuvem +- Autenticação 2FA + +**Resultado**: Mais poderoso, integrações com outros serviços + +### v2.0 (Futuro) + +**Inovação**: +- IA para sugestões (recomendações de tarefas) +- Mobile app (iOS/Android) +- Marketplace de integrações +- Multi-região +- Chatbot suporte automático + +--- + +## 🔒 Segurança + +### Autenticação + +- JWT (JSON Web Tokens) +- Refresh tokens (7 dias) +- Access tokens (15 minutos) +- Refresh token rotation (novo token a cada uso) +- Logout (blacklist token) + +### Autorização + +- Row-Level Security (PostgreSQL RLS) +- Cada user só vê dados do seu tenant +- Middleware valida tenant_id em cada requisição +- Guards em endpoints críticos + +### Dados + +- Criptografia de senhas (bcrypt) +- HTTPS/SSL em produção (Let's Encrypt) +- SGBD com autenticação +- Redis com password +- Minio com credenciais + +### Conformidade + +- LGPD compliance (lei brasileira de dados) +- Termos de uso + Privacy policy +- Consentimento antes de cadastro +- Direito de dados (export/delete) +- Audit logs (quem fez o quê, quando) + +### Backup + +- Backup diário do PostgreSQL +- Retenção: 30 dias +- Armazenamento: Minio ou nuvem +- Restore automático testado semanalmente + +--- + +## 📊 Escalabilidade + +### Fase 1: Até 100 Agências + +**Infraestrutura**: +- 1 VPS (2GB RAM) +- PostgreSQL com plano starter +- Redis com cache básico +- Minio com 50GB + +**Capacidade**: +- ~10k usuários +- ~100k clientes +- Resposta <500ms + +--- + +### Fase 2: 100-500 Agências + +**Infraestrutura**: +- 1 VPS principal (4GB RAM) +- 1 VPS para banco (2GB RAM) +- PostgreSQL Read Replicas +- Redis Cluster +- Minio distribuído + +**Mudanças**: +- Load balancer entre servidores API +- Connection pooling no banco +- Cache distribuído + +--- + +### Fase 3: 500-2k Agências + +**Infraestrutura**: +- Múltiplas instâncias API +- RDS (AWS managed database) +- ElastiCache (Redis managed) +- S3 (AWS S3 real) +- CloudFront (CDN) +- ALB (Application Load Balancer) + +**Mudanças**: +- Sharding de banco (por tenant) +- Cache camadas múltiplas +- Workers para jobs longos + +--- + +### Fase 4: 5k+ Agências + +**Infraestrutura**: +- Kubernetes (EKS) +- Autoscaling horizontal +- Multi-region replication +- Microsserviços +- Event-driven architecture + +--- + +## 📊 Database Schema (Estrutura) + +### Tabelas Principais + +**tenants** +- Agências cadastradas +- Campos: id, slug, name, cnpj, description, website, email, phone, whatsapp, logo_url, primary_color, secondary_color, industry, team_size, allow_client_uploads, enable_customer_portal, plan, status, created_at, updated_at + +**users** +- Usuários (admins, gerentes, equipe) +- Campos: id, tenant_id, first_name, last_name, email, password_hash, phone, whatsapp, role, status, last_login_at, created_at, updated_at + +**signup_temp** +- Dados temporários do cadastro (24h expiration) +- Campos: id, temp_user_id, [todos os dados coletados nos 5 steps], status, created_at, expires_at + +**customers** (CRM) +- Clientes das agências +- Campos: id, tenant_id, name, email, phone, company, status, created_at, updated_at + +**projects** (futuro) +- Projetos por agência +- Campos: id, tenant_id, name, client_id, status, start_date, due_date, created_at, updated_at + +**tasks** (futuro) +- Tarefas dentro de projetos +- Campos: id, project_id, title, description, status, assigned_to, due_date, created_at, updated_at + +**subscriptions** +- Assinaturas (plano + pagamento) +- Campos: id, tenant_id, plan, status, stripe_subscription_id, current_period_start, current_period_end, created_at + +**audit_logs** +- Log de ações (segurança) +- Campos: id, tenant_id, user_id, action, resource, changes, created_at + +--- + +## 📁 Estrutura de Pasta (Monorepo) + +``` +aggios/ +├── apps/ +│ ├── api-core/ (NestJS backend) +│ │ ├── src/ +│ │ │ ├── modules/ +│ │ │ │ ├── auth/ +│ │ │ │ ├── tenants/ +│ │ │ │ ├── crm/ +│ │ │ │ ├── projects/ +│ │ │ │ └── payments/ +│ │ │ ├── common/ +│ │ │ │ ├── guards/ +│ │ │ │ ├── decorators/ +│ │ │ │ ├── middleware/ +│ │ │ │ └── filters/ +│ │ │ ├── config/ +│ │ │ └── main.ts +│ │ ├── migrations/ +│ │ ├── Dockerfile +│ │ └── package.json +│ │ +│ ├── landing/ (Next.js - aggios.app) +│ │ ├── app/ +│ │ ├── components/ +│ │ ├── public/ +│ │ └── package.json +│ │ +│ ├── dashboard/ (Next.js - dash.aggios.app) +│ │ ├── app/ +│ │ │ ├── cadastro/ +│ │ │ └── [admin pages] +│ │ ├── components/ +│ │ └── package.json +│ │ +│ └── tenant-app/ (Next.js - {agencia}.aggios.app) +│ ├── app/ +│ │ ├── welcome/ +│ │ ├── crm/ +│ │ ├── dashboard/ +│ │ └── [tenant pages] +│ ├── components/ +│ └── package.json +│ +├── packages/ +│ ├── shared-types/ +│ │ └── DTOs, interfaces, types +│ └── shared-ui/ +│ └── Componentes reutilizáveis +│ +├── docker-compose.yml +├── .env.example +└── README.md +``` + +--- + +## 🎯 Próximos Passos + +**Semana 1**: +1. Estruturar monorepo (turborepo) +2. Setup Docker local +3. Setup PostgreSQL + migrations +4. Setup NestJS (modules auth, tenants) + +**Semana 2**: +1. Setup Next.js (landing + dashboard + tenant-app) +2. Cadastro 5 steps (frontend + backend) +3. Autenticação JWT + +**Semana 3**: +1. CRM básico (CRUD) +2. Dashboard inicial +3. Integração Minio (logo) + +**Semana 4**: +1. Integração Stripe +2. Stripe webhook +3. Deploy Dokploy teste + +**Semana 5**: +1. Testes +2. Bugfixes +3. Deploy produção + +--- + +## 📊 Métricas de Sucesso + +### Para Agência + +- Tempo cadastro: < 10 minutos +- Taxa conclusão: > 60% +- Retenção 30 dias: > 70% +- NPS (satisfação): > 8/10 + +### Para Usuário + +- Login: < 2 segundos +- Carregar página: < 3 segundos +- Criar cliente: < 1 minuto +- Compartilhar projeto: < 2 minutos + +### Para Negócio + +- Churn: < 5% mensal +- CAC (custo adquisição): < R$50 +- LTV (lifetime value): > R$1000 +- MRR (receita mensal): crescimento 10% mês + +--- + +## 📞 Suporte e Manutenção + +### Canais de Suporte + +- Email: support@aggios.app +- Chat no painel +- Ticket system (Helpdesk) +- Community (Discord/Slack) + +### SLA por Plano + +- STARTER: 24-48h resposta +- PROFESSIONAL: 12h resposta +- ENTERPRISE: 2-4h resposta + 24/7 + +### Manutenção + +- Atualizações: terça-feira 2h da manhã +- Downtime esperado: 15 minutos +- Backup: diário às 3h da manhã +- Monitoramento: 24/7 + +--- + +## 🎓 Documentação Futura + +**Deve ser criada**: +- API Documentation (Swagger) +- User Guide (como usar cada módulo) +- Admin Guide (como gerenciar agência) +- Developer Guide (como integrar) +- Changelog (versões) +- FAQ (perguntas comuns) + +--- + +**Versão**: 1.0 +**Data Criação**: 04/12/2025 +**Status**: Pronto para Code Agent +**Uso**: Compartilhar com code agent (Claude Code, ou outra IA) para gerar código + +--- + +**Notas**: +- Este documento é SEM CÓDIGO +- Ideal para planejamento, requisitos, arquitetura +- Code agent usará isso como especificação técnica +- Todos os detalhes lógicos estão aqui +- Falta apenas implementação (qual o trabalho do code agent) \ No newline at end of file diff --git a/1. docs/projeto.md b/1. docs/projeto.md index 11deabf..6c4e069 100644 --- a/1. docs/projeto.md +++ b/1. docs/projeto.md @@ -1,1046 +1,40 @@ -# 🚀 AGGIOS - PLATAFORMA SAAS MULTITENANT +Aggios platforma que ira controla agencias > lista agencias, tem controle do pagamento das agencias, planos e afins -**Documento Completo de Especificações e Arquitetura** -**Versão**: 1.0 -**Data**: 04/12/2025 -**Status**: Pronto para Desenvolvimento (Code Agent) +topo ---- +agencias > terao clientes e solucoes como crm,erp e outros -## 📋 Índice -1. [Visão Geral](#visão-geral) -2. [Estrutura de Domínios](#estrutura-de-domínios) -3. [Usuários e Roles](#usuários-e-roles) -4. [Módulos Principais](#módulos-principais) -5. [Modelo de Negócio](#modelo-de-negócio) -6. [Stack Técnico](#stack-técnico) -7. [Arquitetura](#arquitetura) -8. [Fluxo de Cadastro (5 Steps)](#fluxo-de-cadastro-5-steps) -9. [CRM - MVP](#crm---mvp) -10. [Desenvolvimento Local](#desenvolvimento-local) -11. [Deploy Produção](#deploy-produção) -12. [Roadmap](#roadmap) -13. [Segurança](#segurança) -14. [Escalabilidade](#escalabilidade) ---- -## 🎯 Visão Geral +dash.localhost ou dash.aggios.app > acesso super admin da aggios -**Aggios** é uma plataforma SaaS multitenant que unifica ferramentas de gestão para agências digitais. +{agencia}.localhost ou {agencia}.aggios.app > acesso super admin da agencia -**Problema Resolvido**: Agências precisam gerenciar clientes, projetos, contratos, pagamentos e documentos em uma única plataforma integrada. - -**Solução**: Dashboard personalizado por agência onde: -- Admin da agência gerencia equipe, CRM, projetos -- Clientes acessam portal para acompanhar seus projetos -- Tudo integrado: CRM, ERP, Helpdesk, Pagamentos, Contratos - -**Público-alvo**: Agências digitais de 1-50 pessoas - -**Modelo**: SaaS subscription com planos diferentes - ---- - -## 🌐 Estrutura de Domínios - -### Domínios Principais - -**aggios.app** - Site Institucional -- Marketing da plataforma -- Pricing (mostra planos) -- Blog/Recursos -- Landing pages -- SEO e público geral - -**dash.aggios.app** - Admin Interno Aggios -- Super admin da plataforma -- Ver todas as agências cadastradas -- Gerenciar assinaturas e faturamento -- Impersonate (logar como admin de outra agência) -- Analytics da plataforma -- Suporte (abrir tickets para clientes) - -**{agencia}.aggios.app** - Painel da Agência (Multitenant) -- Exemplo: `idealpages.aggios.app` -- Admin e equipe da agência acessam aqui -- Personalizado com logo, cores, dados da agência -- Todos os módulos: CRM, ERP, Projetos, etc -- Subpath do portal cliente: `{agencia}.aggios.app/portal/{cliente}` - -### Portal Cliente - -**URL**: `{agencia}.aggios.app/portal/{cliente-id}?token=xyz` -- Cliente acessa seus projetos específicos -- Read-only + comentários -- Acompanha progresso -- Download de arquivos -- Sem acesso a dados de outras agências - ---- - -## 👥 Usuários e Roles - -### Tipos de Usuários - -**Admin Aggios** (Plataforma) -- Acessa: `dash.aggios.app` -- Permissões: ver todas agências, faturamento, assinaturas, analytics, impersonate -- Uso: time Aggios (suporte, vendas, admin) - -**Admin da Agência** -- Acessa: `{agencia}.aggios.app` -- Permissões: gerenciar equipe, configurar agência, todos os módulos, relatórios -- Uso: dono/gerente geral da agência - -**Gerente/Operacional** -- Acessa: `{agencia}.aggios.app` -- Permissões: CRM, projetos, helpdesk, mas sem financeiro/admin -- Uso: coordenador de projetos, gerente de equipe - -**Membro da Equipe** -- Acessa: `{agencia}.aggios.app` -- Permissões: módulos específicos conforme papel (desenvolvedores, designers, etc) -- Uso: equipe interna - -**Cliente (Portal)** -- Acessa: `{agencia}.aggios.app/portal/{cliente}` -- Permissões: ver próprios projetos, comentar, download de arquivos -- Uso: clientes da agência - ---- - -## 📦 Módulos Principais - -### MVP (Versão Inicial) - -**CRM** (Customer Relationship Management) -- Gerenciar clientes (lista, adicionar, editar, deletar) -- Status: prospect, client, inactive -- Dashboard com estatísticas -- Filtros e busca -- Histórico de interações (futuro) - -**Projetos** -- Criar projeto (nome, cliente, data de entrega) -- Kanban (To Do, In Progress, Done) -- Timeline com marcos -- Atribuir ao membro da equipe -- Anexar arquivos - -**Pagamentos** -- Integração com Stripe -- Cobranças automáticas -- Faturas geradas -- Histórico de pagamentos - -**Portal Cliente** -- Compartilhar projeto via link -- Cliente faz login/cria conta -- Visualiza progresso -- Faz comentários - -### Módulos Futuros (v1.0+) - -**ERP** -- Recursos (materiais, ferramentas) -- Financeiro avançado (custos, lucro) -- Relatórios financeiros -- Exportação de dados - -**Helpdesk** -- Tickets de suporte -- SLA (tempo de resposta) -- Base de conhecimento -- Automação - -**Contratos** -- Templates de contratos -- E-signature (assinatura digital) -- Versionamento -- Histórico de assinaturas - -**Integrações** -- Zapier -- Slack -- Google Workspace -- GitHub -- Jira - ---- - -## 💰 Modelo de Negócio - -### Planos de Assinatura - -**STARTER** - R$99/mês -- CRM + Contratos + Pagamentos -- 3 usuários -- 10 projetos simultâneos -- 5GB de armazenamento -- Suporte por email - -**PROFESSIONAL** - R$299/mês -- Starter + ERP + Helpdesk + Projetos -- 10 usuários -- Projetos ilimitados -- 100GB de armazenamento -- Suporte prioritário -- API básica - -**ENTERPRISE** - R$999+/mês -- Professional + Integrações customizadas -- Usuários ilimitados -- Armazenamento ilimitado -- Suporte 24/7 -- API completa -- Webhooks -- SSO (Single Sign-On) - -### À La Carte (Futuro) - -- CRM: R$49/mês -- ERP: R$79/mês -- Helpdesk: R$59/mês -- Contratos: R$29/mês -- Projetos: R$39/mês - -### Modelo de Receita - -- Subscription mensal (principal) -- Taxa de setup: R$99 (primeira agência) -- Integrações customizadas: R$1000+ -- Suporte dedicado: R$500/mês - ---- - -## 🛠️ Stack Técnico - -### Frontend - -**Next.js** (React) -- App router (não pages router) -- Server components quando possível -- TypeScript -- Tailwind CSS para estilos -- ShadcN UI ou Radix UI para componentes - -**Interfaces Principais**: -- Landing page (aggios.app) -- Admin internal (dash.aggios.app) -- Painel da agência ({agencia}.aggios.app) -- Portal do cliente - -### Backend - -**NestJS** (Node.js) -- Arquitetura modular -- TypeScript nativo -- Guards, Pipes, Interceptors -- Validation com class-validator -- Documentation com Swagger - -**Principais Módulos**: -- Authentication (JWT, refresh tokens) -- Tenants (multi-tenancy) -- CRM -- Projetos -- Pagamentos (Stripe integration) - -### Banco de Dados - -**PostgreSQL** -- Schema por tenant (isolamento de dados) -- Row-Level Security (RLS) para segurança -- Migrations com Typeorm ou Knex -- Backup automático diário - -### Cache - -**Redis** -- Sessions de usuário -- Cache de dados frequentes -- Rate limiting -- Fila de jobs - -### Storage - -**Minio** (S3-compatible) -- Upload de logos -- Arquivos de projetos -- Documentos/contratos -- Backup de dados - -### Roteamento - -**Traefik** -- Load balancer -- Roteamento por hostname -- HTTPS/SSL automático - -### CI/CD - -**GitHub Actions** -- Testes automáticos -- Build da aplicação -- Deploy automático em merge para main - ---- - -## 🏗️ Arquitetura - -### Visão Geral +Fluxo de autenticação: +- `admin@aggios.app` acessa somente `dash.localhost`/`dash.aggios.app` para gerenciar o painel global (lista de agências, etc.). +- Cada agência criada recebe um admin próprio (`ADMIN_AGENCIA`) que faz login no subdomínio dela (`{subdominio}.localhost/login`, por exemplo `idealpages.localhost/login`) e não tem permissão para o dashboard global. ``` -┌─────────────────────────────────────────────┐ -│ Frontend (Next.js) │ -│ ├─ Landing: aggios.app │ -│ ├─ Admin: dash.aggios.app │ -│ ├─ App: {agencia}.aggios.app │ -│ └─ Portal: {agencia}.aggios.app/portal │ -└────────────────┬────────────────────────────┘ - │ HTTPS -┌────────────────▼────────────────────────────┐ -│ Traefik │ -│ (Roteamento por hostname) │ -└────────────────┬────────────────────────────┘ - │ -┌────────────────▼────────────────────────────┐ -│ Backend (NestJS API) │ -│ ├─ Auth Module │ -│ ├─ Tenants Module │ -│ ├─ CRM Module │ -│ ├─ Projetos Module │ -│ └─ Pagamentos Module │ -└────────────────┬────────────────────────────┘ - │ - ┌────────────┼────────────┬──────────┐ - │ │ │ │ - ▼ ▼ ▼ ▼ -┌─────────┐ ┌────────┐ ┌─────────┐ ┌──────┐ -│PostgreSQL│ │ Redis │ │ Minio │ │Stripe│ -│ │ │ │ │ (S3) │ │ │ -└──────────┘ └────────┘ └─────────┘ └──────┘ + +----------------+ + | Super Admin | + | admin@aggios | + +--------+-------+ + | + dash.localhost / dash.aggios.app + | + +----------------+----------------+ + | | + +------+-------+ +-------+------+ + | Agência A | | Agência B | + | subdomínio A | | subdomínio B | + +------+-------+ +-------+------+ + | | + agencia-a.localhost/login agencia-b.localhost/login + (admin específico) (admin específico) ``` -### Isolamento de Dados - -**Row-Level Security (RLS)** no PostgreSQL: -- Cada tenant tem suas próprias linhas -- Política: usuário só vê dados do seu tenant -- Impossível vazar dados entre clientes - -**Schema Compartilhado**: -- Uma tabela "tenants" com todas as agências -- Foreign key tenant_id em todas as tabelas -- Sem criar schemas separados (simples) - ---- - -## 📝 Fluxo de Cadastro (5 Steps) - -### Resumo dos Steps - -**Step 1**: Dados Pessoais (5 campos) -- Nome, email, telefone, whatsapp, senha -- Tempo: 2-3 minutos - -**Step 2**: Empresa Básico (6 campos) -- Nome, CNPJ, descrição, website, indústria, tamanho equipe -- Tempo: 3-4 minutos - -**Step 3**: Localização e Contato (10 campos) -- CEP (com busca ViaCEP), estado, cidade, bairro, rua, número, complemento -- Email comercial, telefone, whatsapp -- Tempo: 3-4 minutos - -**Step 4**: Escolher Domínio (1 campo) -- Slug com sugestão automática e validação em tempo real -- Tempo: 1-2 minutos - -**Step 5**: Personalização (3 campos) -- Logo (upload), cor primária, cor secundária -- Checkboxes: permitir upload cliente, portal automático -- Tempo: 2-3 minutos - -### Fluxo Técnico - -``` -User acessa: dash.aggios.app/cadastro - ↓ -Step 1: Preenche dados pessoais → POST /auth/signup/step-1 - ↓ -Backend valida + INSERT signup_temp (temporário, 24h) - ↓ -User redireciona para Step 2 - ↓ -Step 2: Preenche dados empresa → POST /auth/signup/step-2 - ↓ -Backend valida + UPDATE signup_temp - ↓ -Continua para Step 3, 4, 5... - ↓ -Step 5: Upload logo + cores → POST /auth/signup/step-5 - ↓ -Backend executa TRANSAÇÃO: -├─ CREATE tenant (agência) -├─ CREATE user (admin) -├─ DELETE signup_temp -├─ Gera JWT -└─ Retorna token + redirectUrl - ↓ -Frontend redireciona para: {slug}.aggios.app/welcome -``` - -### Dados Coletados - -**Pessoal** (Step 1): -- Nome completo -- Email (login) -- Telefone -- WhatsApp -- Senha (hashed) - -**Empresa** (Step 2): -- Nome agência -- CNPJ (14 dígitos) -- Descrição -- Website -- Indústria -- Tamanho equipe - -**Localização** (Step 3): -- CEP (integração ViaCEP) -- Estado/UF -- Cidade -- Bairro -- Rua/Avenida -- Número -- Complemento - -**Contato** (Step 3): -- Email comercial -- Telefone empresa -- WhatsApp empresa - -**Domínio** (Step 4): -- Slug ({agencia}.aggios.app) - -**Personalização** (Step 5): -- Logo URL (Minio) -- Cor primária (hex) - Padrão: #FF3A05 -- Cor secundária (hex) - Padrão: #FF0080 -- Gradiente de branding: `linear-gradient(90deg, #FF3A05, #FF0080)` -- Permitir upload cliente (sim/não) -- Portal automático (sim/não) - -### Validações Importantes - -**CNPJ**: -- 14 dígitos válidos -- Dígitos verificadores corretos -- Não duplicado no banco - -**CEP**: -- 8 dígitos válidos -- Busca em ViaCEP -- Se não encontrado: preenchimento manual - -**Slug**: -- 3-50 caracteres -- Apenas a-z, 0-9, hífen -- Não é palavra reservada -- Não duplicado - -**Senha**: -- 8+ caracteres -- 1 maiúscula, 1 número, 1 especial - ---- - -## 💻 CRM - MVP - -### Funcionalidades Incluídas (MVP) - -**Lista de Clientes** -- Paginação (20 por página) -- Busca por nome/email -- Filtro por status (prospect, client, inactive) -- Ordenação (nome, data criação, status) - -**Adicionar Cliente** -- Form simples: nome, email, telefone, empresa -- Validação de email -- Cliente criado com status "prospect" - -**Editar Cliente** -- Atualizar dados -- Mudar status -- Salvar histórico de mudanças - -**Deletar Cliente** -- Soft delete (marca como inactive) -- Ou hard delete (remove do banco) - -**Dashboard Stats** -- Total de clientes -- Clientes por status (prospect, client, inactive) -- Gráficos simples - -### Funcionalidades Futuras (Deixar para depois) - -- Pipeline de vendas (funil) -- Histórico de interações -- Tags/categorias -- Atividades (calls, emails) -- Emails integrados -- Relatórios avançados -- Import/export CSV -- Automação - -### Dados Armazenados - -**Clientes**: -- ID (UUID) -- Tenant ID (qual agência) -- Nome (obrigatório) -- Email (obrigatório) -- Telefone -- Empresa -- Status (prospect/client/inactive) -- Data criação -- Data última atualização -- Criado por (user ID) - -**Histórico** (futuro): -- ID -- Cliente ID -- O que mudou -- Valor anterior -- Valor novo -- Quem mudou -- Data - -### URLs - -- `{agencia}.aggios.app/crm/customers` - Lista -- `{agencia}.aggios.app/crm/customers/new` - Novo -- `{agencia}.aggios.app/crm/customers/[id]` - Detalhe/editar -- `{agencia}.aggios.app/crm/dashboard` - Dashboard stats - -### Telas "Em Breve" - -Todos os outros módulos mostram tela "Este módulo será lançado em breve": -- ERP -- Helpdesk -- Contratos -- Pagamentos -- Projetos - ---- - -## 🏗️ Desenvolvimento Local - -### Ambiente Local - -**Ferramentas Necessárias**: -- Docker + Docker Compose -- Node.js 18+ -- Git - -### Serviços Locais (Docker) - -``` -PostgreSQL -├─ Host: localhost:5432 -├─ User: aggios -├─ Password: password -└─ Database: aggios_dev - -Redis -├─ Host: localhost:6379 -└─ Port: 6379 - -Minio (S3-compatible) -├─ Host: localhost:9000 -├─ User: minioadmin -├─ Password: minioadmin -└─ Bucket: aggios - -Traefik -├─ Host: localhost (com config /etc/hosts para *.localhost) -├─ Dashboard: localhost:8080 -└─ Router automático por hostname -``` - -### Hosts Locais - -Configure em `/etc/hosts`: -``` -127.0.0.1 aggios.localhost -127.0.0.1 dash.aggios.localhost -127.0.0.1 idealpages.aggios.localhost -127.0.0.1 test.aggios.localhost -``` - -### Iniciar Ambiente - -``` -1. Clone repositório -2. Configure .env (cópiar de .env.example) -3. docker-compose up -d -4. npm install -5. npm run db:migrate -6. npm run dev -``` - -### URLs de Acesso - -- Landing: http://aggios.localhost -- Admin: http://dash.aggios.localhost -- Agência teste: http://idealpages.aggios.localhost -- Minio: http://localhost:9000 -- Traefik: http://localhost:8080 - ---- - -## 🚀 Deploy Produção - -### Infraestrutura - -**VPS Recomendado**: -- 2GB RAM -- 20GB SSD -- Ubuntu 24 LTS -- IP público estático - -**Provedor**: Linode, DigitalOcean, Vultr, AWS EC2 - -### Setup Produção (Dokploy) - -**Passo 1**: Comprar VPS + domínio - -**Passo 2**: Instalar Dokploy -- SSH no VPS -- Executar script instalação -- Interface web no `http://ip-vps:3000` - -**Passo 3**: Configurar DNS -- Apontar domínio para IP do VPS -- Subdomínios: dash.aggios.app, *.aggios.app (wildcard) - -**Passo 4**: Cadastrar 6 Serviços em Dokploy - -1. **Landing** (Next.js - aggios.app) - - Port: 3000 - - Build: `npm run build` - - Start: `npm start` - -2. **Admin** (Next.js - dash.aggios.app) - - Port: 3001 - - Build: `npm run build` - - Start: `npm start` - -3. **API** (NestJS - *.aggios.app wildcard) - - Port: 3333 - - Build: `npm run build` - - Start: `npm run start:prod` - -4. **PostgreSQL** - - Port: 5432 - - Backup automático (daily) - -5. **Redis** - - Port: 6379 - -6. **Minio** - - Port: 9000 - -**Passo 5**: GitHub Integration -- Conectar repositório GitHub -- Push em main → Deploy automático -- Webhook automático - -### O que Dokploy Cuida - -- Traefik integrado (não configurar manual) -- SSL automático (Let's Encrypt) -- HTTPS forçado -- Backup automático de banco -- Monitoramento básico -- CI/CD nativo -- Logs centralizados - -### Diferenças Local vs Produção - -| Aspecto | Local | Produção | -|---------|-------|----------| -| Traefik | Manual config | Dokploy nativo | -| HTTPS | HTTP (.localhost) | HTTPS automático | -| Domínios | *.localhost | Domínios reais | -| SSL | Não | Let's Encrypt | -| Backup | Manual | Automático | -| CI/CD | Manual | GitHub webhook | -| Monitoramento | Não | Básico | -| Logs | Docker | Dokploy | - ---- - -## 📅 Roadmap - -### MVP (Mês 1-2) - -**Prioridades**: -- Setup infraestrutura (Docker, Traefik, Dokploy) -- Autenticação (JWT, refresh tokens) -- Cadastro 5 steps -- Tenant middleware + RLS -- CRM básico (CRUD) -- Painel inicial -- Upload de logo (Minio) -- Stripe integration básica -- Deploy no Dokploy - -**Resultado**: Agência consegue se cadastrar e gerenciar clientes - -### v1.0 (Mês 2-3) - -**Adições**: -- Módulo Projetos (Kanban, timeline) -- Portal cliente (compartilhar projeto) -- Contratos com e-signature -- Helpdesk básico -- Relatórios simples -- Email notifications -- Onboarding/tour - -**Resultado**: Plataforma funcional com todos módulos principais - -### v1.1 (Mês 3-4) - -**Adições**: -- ERP básico (recursos, financeiro) -- Integrações (Zapier, Slack) -- API documentada -- Webhooks -- Backup automático na nuvem -- Autenticação 2FA - -**Resultado**: Mais poderoso, integrações com outros serviços - -### v2.0 (Futuro) - -**Inovação**: -- IA para sugestões (recomendações de tarefas) -- Mobile app (iOS/Android) -- Marketplace de integrações -- Multi-região -- Chatbot suporte automático - ---- - -## 🔒 Segurança - -### Autenticação - -- JWT (JSON Web Tokens) -- Refresh tokens (7 dias) -- Access tokens (15 minutos) -- Refresh token rotation (novo token a cada uso) -- Logout (blacklist token) - -### Autorização - -- Row-Level Security (PostgreSQL RLS) -- Cada user só vê dados do seu tenant -- Middleware valida tenant_id em cada requisição -- Guards em endpoints críticos - -### Dados - -- Criptografia de senhas (bcrypt) -- HTTPS/SSL em produção (Let's Encrypt) -- SGBD com autenticação -- Redis com password -- Minio com credenciais - -### Conformidade - -- LGPD compliance (lei brasileira de dados) -- Termos de uso + Privacy policy -- Consentimento antes de cadastro -- Direito de dados (export/delete) -- Audit logs (quem fez o quê, quando) - -### Backup - -- Backup diário do PostgreSQL -- Retenção: 30 dias -- Armazenamento: Minio ou nuvem -- Restore automático testado semanalmente - ---- - -## 📊 Escalabilidade - -### Fase 1: Até 100 Agências - -**Infraestrutura**: -- 1 VPS (2GB RAM) -- PostgreSQL com plano starter -- Redis com cache básico -- Minio com 50GB - -**Capacidade**: -- ~10k usuários -- ~100k clientes -- Resposta <500ms - ---- - -### Fase 2: 100-500 Agências - -**Infraestrutura**: -- 1 VPS principal (4GB RAM) -- 1 VPS para banco (2GB RAM) -- PostgreSQL Read Replicas -- Redis Cluster -- Minio distribuído - -**Mudanças**: -- Load balancer entre servidores API -- Connection pooling no banco -- Cache distribuído - ---- - -### Fase 3: 500-2k Agências - -**Infraestrutura**: -- Múltiplas instâncias API -- RDS (AWS managed database) -- ElastiCache (Redis managed) -- S3 (AWS S3 real) -- CloudFront (CDN) -- ALB (Application Load Balancer) - -**Mudanças**: -- Sharding de banco (por tenant) -- Cache camadas múltiplas -- Workers para jobs longos - ---- - -### Fase 4: 5k+ Agências - -**Infraestrutura**: -- Kubernetes (EKS) -- Autoscaling horizontal -- Multi-region replication -- Microsserviços -- Event-driven architecture - ---- - -## 📊 Database Schema (Estrutura) - -### Tabelas Principais - -**tenants** -- Agências cadastradas -- Campos: id, slug, name, cnpj, description, website, email, phone, whatsapp, logo_url, primary_color, secondary_color, industry, team_size, allow_client_uploads, enable_customer_portal, plan, status, created_at, updated_at - -**users** -- Usuários (admins, gerentes, equipe) -- Campos: id, tenant_id, first_name, last_name, email, password_hash, phone, whatsapp, role, status, last_login_at, created_at, updated_at - -**signup_temp** -- Dados temporários do cadastro (24h expiration) -- Campos: id, temp_user_id, [todos os dados coletados nos 5 steps], status, created_at, expires_at - -**customers** (CRM) -- Clientes das agências -- Campos: id, tenant_id, name, email, phone, company, status, created_at, updated_at - -**projects** (futuro) -- Projetos por agência -- Campos: id, tenant_id, name, client_id, status, start_date, due_date, created_at, updated_at - -**tasks** (futuro) -- Tarefas dentro de projetos -- Campos: id, project_id, title, description, status, assigned_to, due_date, created_at, updated_at - -**subscriptions** -- Assinaturas (plano + pagamento) -- Campos: id, tenant_id, plan, status, stripe_subscription_id, current_period_start, current_period_end, created_at - -**audit_logs** -- Log de ações (segurança) -- Campos: id, tenant_id, user_id, action, resource, changes, created_at - ---- - -## 📁 Estrutura de Pasta (Monorepo) - -``` -aggios/ -├── apps/ -│ ├── api-core/ (NestJS backend) -│ │ ├── src/ -│ │ │ ├── modules/ -│ │ │ │ ├── auth/ -│ │ │ │ ├── tenants/ -│ │ │ │ ├── crm/ -│ │ │ │ ├── projects/ -│ │ │ │ └── payments/ -│ │ │ ├── common/ -│ │ │ │ ├── guards/ -│ │ │ │ ├── decorators/ -│ │ │ │ ├── middleware/ -│ │ │ │ └── filters/ -│ │ │ ├── config/ -│ │ │ └── main.ts -│ │ ├── migrations/ -│ │ ├── Dockerfile -│ │ └── package.json -│ │ -│ ├── landing/ (Next.js - aggios.app) -│ │ ├── app/ -│ │ ├── components/ -│ │ ├── public/ -│ │ └── package.json -│ │ -│ ├── dashboard/ (Next.js - dash.aggios.app) -│ │ ├── app/ -│ │ │ ├── cadastro/ -│ │ │ └── [admin pages] -│ │ ├── components/ -│ │ └── package.json -│ │ -│ └── tenant-app/ (Next.js - {agencia}.aggios.app) -│ ├── app/ -│ │ ├── welcome/ -│ │ ├── crm/ -│ │ ├── dashboard/ -│ │ └── [tenant pages] -│ ├── components/ -│ └── package.json -│ -├── packages/ -│ ├── shared-types/ -│ │ └── DTOs, interfaces, types -│ └── shared-ui/ -│ └── Componentes reutilizáveis -│ -├── docker-compose.yml -├── .env.example -└── README.md -``` - ---- - -## 🎯 Próximos Passos - -**Semana 1**: -1. Estruturar monorepo (turborepo) -2. Setup Docker local -3. Setup PostgreSQL + migrations -4. Setup NestJS (modules auth, tenants) - -**Semana 2**: -1. Setup Next.js (landing + dashboard + tenant-app) -2. Cadastro 5 steps (frontend + backend) -3. Autenticação JWT - -**Semana 3**: -1. CRM básico (CRUD) -2. Dashboard inicial -3. Integração Minio (logo) - -**Semana 4**: -1. Integração Stripe -2. Stripe webhook -3. Deploy Dokploy teste - -**Semana 5**: -1. Testes -2. Bugfixes -3. Deploy produção - ---- - -## 📊 Métricas de Sucesso - -### Para Agência - -- Tempo cadastro: < 10 minutos -- Taxa conclusão: > 60% -- Retenção 30 dias: > 70% -- NPS (satisfação): > 8/10 - -### Para Usuário - -- Login: < 2 segundos -- Carregar página: < 3 segundos -- Criar cliente: < 1 minuto -- Compartilhar projeto: < 2 minutos - -### Para Negócio - -- Churn: < 5% mensal -- CAC (custo adquisição): < R$50 -- LTV (lifetime value): > R$1000 -- MRR (receita mensal): crescimento 10% mês - ---- - -## 📞 Suporte e Manutenção - -### Canais de Suporte - -- Email: support@aggios.app -- Chat no painel -- Ticket system (Helpdesk) -- Community (Discord/Slack) - -### SLA por Plano - -- STARTER: 24-48h resposta -- PROFESSIONAL: 12h resposta -- ENTERPRISE: 2-4h resposta + 24/7 - -### Manutenção - -- Atualizações: terça-feira 2h da manhã -- Downtime esperado: 15 minutos -- Backup: diário às 3h da manhã -- Monitoramento: 24/7 - ---- - -## 🎓 Documentação Futura - -**Deve ser criada**: -- API Documentation (Swagger) -- User Guide (como usar cada módulo) -- Admin Guide (como gerenciar agência) -- Developer Guide (como integrar) -- Changelog (versões) -- FAQ (perguntas comuns) - ---- - -**Versão**: 1.0 -**Data Criação**: 04/12/2025 -**Status**: Pronto para Code Agent -**Uso**: Compartilhar com code agent (Claude Code, ou outra IA) para gerar código - ---- - -**Notas**: -- Este documento é SEM CÓDIGO -- Ideal para planejamento, requisitos, arquitetura -- Code agent usará isso como especificação técnica -- Todos os detalhes lógicos estão aqui -- Falta apenas implementação (qual o trabalho do code agent) \ No newline at end of file +Painel do superadmin (dash.localhost): +- Visualizar (read-only) todos os dados enviados no fluxo de cadastro (`dash.localhost/cadastro`). +- Excluir/arquivar agências quando necessário. +- Nenhuma ação de "acessar" ou "editar" direta; a inspeção completa é feita na tela de visualização. \ No newline at end of file diff --git a/README.md b/README.md index 50439dc..2c78757 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,61 @@ # Aggios App -Aplicação Aggios +Plataforma composta por serviços de autenticação, painel administrativo (superadmin) e site institucional da Aggios, orquestrados via Docker Compose. -## Descrição +## Visão geral +- **Objetivo**: permitir que superadministradores cadastrem e gerenciem agências (tenants) enquanto o site institucional apresenta informações públicas da empresa. +- **Stack**: Go (backend), Next.js 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. -Projeto em desenvolvimento. +## Componentes principais +- `backend/`: API Go com serviços de autenticação, operadores e CRUD de agências (endpoints `/api/admin/agencies` e `/api/admin/agencies/{id}`). +- `front-end-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. +- `postgres/`: scripts de inicialização do banco (estrutura base de tenants e usuários). +- `traefik/`: reverse proxy e certificados automatizados. -### Atualização recente +## Funcionalidades entregues +- 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. -- 07/12/2025: Site institucional (`frontend-aggios.app`) atualizado com suporte completo a dark mode baseado em Tailwind CSS v4 e `next-themes`. +## Executando o projeto +1. **Pré-requisitos**: Docker Desktop e Node.js 20+ (para utilitários opcionais). +2. **Variáveis**: ajustar `.env` conforme referências existentes (`docker-compose.yml`, arquivos `config`). +3. **Subir os serviços**: + ```powershell + docker-compose up --build + ``` +4. **Hosts locais**: + - Painel: `https://dash.localhost` + - Site: `https://aggios.app.localhost` + - API: `https://api.localhost` +5. **Credenciais padrão**: ver `postgres/init-db.sql` para usuário superadmin seed. -## Como Usar +## Estrutura de diretórios (resumo) +``` +backend/ API Go (config, domínio, handlers, serviços) +front-end-dash.aggios.app/ Dashboard Next.js Superadmin +frontend-aggios.app/ Site institucional Next.js +postgres/ Scripts SQL de seed +traefik/ Regras de roteamento e TLS +1. docs/ Documentação funcional e técnica +``` -Para configurar e executar o projeto, consulte a documentação em `docs/`. +## 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`. + +## 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. ## Repositório - -Repositório oficial: https://git.stackbyte.cloud/erik/aggios.app.git +- Principal: https://git.stackbyte.cloud/erik/aggios.app.git diff --git a/backend/Dockerfile b/backend/Dockerfile index a63ed1a..e9ea472 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -3,15 +3,18 @@ FROM golang:1.23-alpine AS builder WORKDIR /build -# Copy go.mod and go.sum from cmd/server -COPY cmd/server/go.mod cmd/server/go.sum ./ -RUN go mod download +# Copy go module files +COPY go.mod ./ +RUN test -f go.sum && cp go.sum go.sum.bak || true -# Copy source code -COPY cmd/server/main.go ./ +# Copy entire source tree (internal/, cmd/) +COPY . . -# Build -RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server . +# Ensure go.sum is up to date +RUN go mod tidy + +# Build from root (module is defined there) +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server ./cmd/server # Runtime image FROM alpine:latest diff --git a/backend/cmd/server/go.mod b/backend/cmd/server/go.mod deleted file mode 100644 index efe1399..0000000 --- a/backend/cmd/server/go.mod +++ /dev/null @@ -1,10 +0,0 @@ -module server - -go 1.23.12 - -require ( - github.com/golang-jwt/jwt/v5 v5.2.1 - github.com/google/uuid v1.6.0 - github.com/lib/pq v1.10.9 - golang.org/x/crypto v0.27.0 -) diff --git a/backend/cmd/server/go.sum b/backend/cmd/server/go.sum deleted file mode 100644 index 969d322..0000000 --- a/backend/cmd/server/go.sum +++ /dev/null @@ -1,8 +0,0 @@ -github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= -github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= -golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 581391f..df04750 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -2,576 +2,127 @@ package main import ( "database/sql" - "encoding/json" "fmt" "log" "net/http" - "os" - "strings" - "time" - "github.com/golang-jwt/jwt/v5" - "github.com/google/uuid" _ "github.com/lib/pq" - "golang.org/x/crypto/bcrypt" + + "aggios-app/backend/internal/api/handlers" + "aggios-app/backend/internal/api/middleware" + "aggios-app/backend/internal/config" + "aggios-app/backend/internal/repository" + "aggios-app/backend/internal/service" ) -var db *sql.DB - -// jwtSecret carrega o secret do ambiente ou usa fallback (NUNCA use fallback em produção) -var jwtSecret = []byte(getEnvOrDefault("JWT_SECRET", "INSECURE-fallback-secret-CHANGE-THIS")) - -// Rate limiting simples (IP -> timestamp das últimas tentativas) -var loginAttempts = make(map[string][]time.Time) -var registerAttempts = make(map[string][]time.Time) - -const maxAttemptsPerMinute = 5 - -// corsMiddleware adiciona headers CORS -func corsMiddleware(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - // CORS - apenas domínios permitidos - allowedOrigins := map[string]bool{ - "http://localhost": true, // Dev local - "http://dash.localhost": true, // Dashboard dev - "http://aggios.local": true, // Institucional dev - "http://dash.aggios.local": true, // Dashboard dev alternativo - "https://aggios.app": true, // Institucional prod - "https://dash.aggios.app": true, // Dashboard prod - "https://www.aggios.app": true, // Institucional prod www - } - - origin := r.Header.Get("Origin") - if allowedOrigins[origin] { - w.Header().Set("Access-Control-Allow-Origin", origin) - } - - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") - w.Header().Set("Access-Control-Allow-Credentials", "true") - - // Headers de segurança - w.Header().Set("X-Content-Type-Options", "nosniff") - w.Header().Set("X-Frame-Options", "DENY") - w.Header().Set("X-XSS-Protection", "1; mode=block") - w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") - - // Handle preflight - if r.Method == "OPTIONS" { - w.WriteHeader(http.StatusOK) - return - } - - // Log da requisição (sem dados sensíveis) - log.Printf("📥 %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr) - - next(w, r) - } -} - -// RegisterRequest representa os dados completos de registro -type RegisterRequest struct { - // Step 1 - Dados Pessoais - Email string `json:"email"` - Password string `json:"password"` - FullName string `json:"fullName"` - Newsletter bool `json:"newsletter"` - - // Step 2 - Empresa - CompanyName string `json:"companyName"` - CNPJ string `json:"cnpj"` - RazaoSocial string `json:"razaoSocial"` - Description string `json:"description"` - Website string `json:"website"` - Industry string `json:"industry"` - TeamSize string `json:"teamSize"` - - // Step 3 - Localização - CEP string `json:"cep"` - State string `json:"state"` - City string `json:"city"` - Neighborhood string `json:"neighborhood"` - Street string `json:"street"` - Number string `json:"number"` - Complement string `json:"complement"` - Contacts []struct { - ID int `json:"id"` - WhatsApp string `json:"whatsapp"` - } `json:"contacts"` - - // Step 4 - Domínio - Subdomain string `json:"subdomain"` - - // Step 5 - Personalização - PrimaryColor string `json:"primaryColor"` - SecondaryColor string `json:"secondaryColor"` - LogoURL string `json:"logoUrl"` -} - -// RegisterResponse representa a resposta do registro -type RegisterResponse struct { - Token string `json:"token"` - ID string `json:"id"` - Email string `json:"email"` - Name string `json:"name"` - TenantID string `json:"tenantId"` - Company string `json:"company"` - Subdomain string `json:"subdomain"` - CreatedAt string `json:"created_at"` -} - -// ErrorResponse representa uma resposta de erro -type ErrorResponse struct { - Error string `json:"error"` - Message string `json:"message"` -} - -// LoginRequest representa os dados de login -type LoginRequest struct { - Email string `json:"email"` - Password string `json:"password"` -} - -// LoginResponse representa a resposta do login -type LoginResponse struct { - Token string `json:"token"` - User UserPayload `json:"user"` -} - -// UserPayload representa os dados do usuário no token -type UserPayload struct { - ID string `json:"id"` - Email string `json:"email"` - Name string `json:"name"` - TenantID string `json:"tenantId"` - Company string `json:"company"` - Subdomain string `json:"subdomain"` -} - -// Claims customizado para JWT -type Claims struct { - UserID string `json:"userId"` - Email string `json:"email"` - TenantID string `json:"tenantId"` - jwt.RegisteredClaims -} - -// getEnvOrDefault retorna variável de ambiente ou valor padrão -func getEnvOrDefault(key, defaultValue string) string { - if value := os.Getenv(key); value != "" { - return value - } - return defaultValue -} - -// checkRateLimit verifica se IP excedeu limite de tentativas -func checkRateLimit(ip string, attempts map[string][]time.Time) bool { - now := time.Now() - cutoff := now.Add(-1 * time.Minute) - - // Limpar tentativas antigas - if timestamps, exists := attempts[ip]; exists { - var recent []time.Time - for _, t := range timestamps { - if t.After(cutoff) { - recent = append(recent, t) - } - } - attempts[ip] = recent - - // Verificar se excedeu limite - if len(recent) >= maxAttemptsPerMinute { - return false - } - } - - // Adicionar nova tentativa - attempts[ip] = append(attempts[ip], now) - return true -} - -// validateEmail valida formato de email -func validateEmail(email string) bool { - if len(email) < 3 || len(email) > 254 { - return false - } - // Regex simples para validação - return strings.Contains(email, "@") && strings.Contains(email, ".") -} - -func initDB() error { +func initDB(cfg *config.Config) (*sql.DB, error) { connStr := fmt.Sprintf( "host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", - os.Getenv("DB_HOST"), - os.Getenv("DB_PORT"), - os.Getenv("DB_USER"), - os.Getenv("DB_PASSWORD"), - os.Getenv("DB_NAME"), + cfg.Database.Host, + cfg.Database.Port, + cfg.Database.User, + cfg.Database.Password, + cfg.Database.Name, ) - var err error - db, err = sql.Open("postgres", connStr) + db, err := sql.Open("postgres", connStr) if err != nil { - return fmt.Errorf("erro ao abrir conexão: %v", err) + return nil, fmt.Errorf("erro ao abrir conexão: %v", err) } if err = db.Ping(); err != nil { - return fmt.Errorf("erro ao conectar ao banco: %v", err) + return nil, fmt.Errorf("erro ao conectar ao banco: %v", err) } log.Println("✅ Conectado ao PostgreSQL") - return nil + return db, nil } func main() { - // Inicializar banco de dados - if err := initDB(); err != nil { + // Load configuration + cfg := config.Load() + + // Initialize database + db, err := initDB(cfg) + if err != nil { log.Fatalf("❌ Erro ao inicializar banco: %v", err) } defer db.Close() - // Health check handlers - http.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - fmt.Fprintf(w, `{"status":"healthy","version":"1.0.0","database":"pending","redis":"pending","minio":"pending"}`) - }) + // Initialize repositories + userRepo := repository.NewUserRepository(db) + tenantRepo := repository.NewTenantRepository(db) + companyRepo := repository.NewCompanyRepository(db) - http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - fmt.Fprintf(w, `{"status":"ok"}`) - }) + // Initialize services + authService := service.NewAuthService(userRepo, tenantRepo, cfg) + agencyService := service.NewAgencyService(userRepo, tenantRepo, cfg) + tenantService := service.NewTenantService(tenantRepo) + companyService := service.NewCompanyService(companyRepo) - // Auth routes (com CORS) - http.HandleFunc("/api/auth/register", corsMiddleware(handleRegister)) - http.HandleFunc("/api/auth/login", corsMiddleware(handleLogin)) - http.HandleFunc("/api/me", corsMiddleware(authMiddleware(handleMe))) + // Initialize handlers + healthHandler := handlers.NewHealthHandler() + authHandler := handlers.NewAuthHandler(authService) + agencyProfileHandler := handlers.NewAgencyHandler(tenantRepo) + agencyHandler := handlers.NewAgencyRegistrationHandler(agencyService, cfg) + tenantHandler := handlers.NewTenantHandler(tenantService) + companyHandler := handlers.NewCompanyHandler(companyService) - port := os.Getenv("SERVER_PORT") - if port == "" { - port = "8080" - } + // Create middleware chain + tenantDetector := middleware.TenantDetector(tenantRepo) + corsMiddleware := middleware.CORS(cfg) + securityMiddleware := middleware.SecurityHeaders + rateLimitMiddleware := middleware.RateLimit(cfg) + authMiddleware := middleware.Auth(cfg) - addr := fmt.Sprintf(":%s", port) + // Setup routes + mux := http.NewServeMux() + + // Health check (no auth) + mux.HandleFunc("/health", healthHandler.Check) + mux.HandleFunc("/api/health", healthHandler.Check) + + // Auth routes (public with rate limiting) + mux.HandleFunc("/api/auth/login", authHandler.Login) + + // Protected auth routes + mux.Handle("/api/auth/change-password", authMiddleware(http.HandlerFunc(authHandler.ChangePassword))) + + // 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) + + // Client registration (ADMIN_AGENCIA only - requires auth) + mux.Handle("/api/agencies/clients/register", authMiddleware(http.HandlerFunc(agencyHandler.RegisterClient))) + + // Agency profile routes (protected) + mux.Handle("/api/agency/profile", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + agencyProfileHandler.GetProfile(w, r) + } else if r.Method == http.MethodPut || r.Method == http.MethodPatch { + agencyProfileHandler.UpdateProfile(w, r) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }))) + + // Protected routes (require authentication) + mux.Handle("/api/companies", authMiddleware(http.HandlerFunc(companyHandler.List))) + mux.Handle("/api/companies/create", authMiddleware(http.HandlerFunc(companyHandler.Create))) + + // Apply global middlewares: tenant -> cors -> security -> rateLimit -> mux + handler := tenantDetector(corsMiddleware(securityMiddleware(rateLimitMiddleware(mux)))) + + // Start server + addr := fmt.Sprintf(":%s", cfg.Server.Port) log.Printf("🚀 Server starting on %s", addr) - log.Printf("📍 Health check: http://localhost:%s/health", port) - log.Printf("🔗 API: http://localhost:%s/api/health", port) - log.Printf("👤 Register: http://localhost:%s/api/auth/register", port) - log.Printf("🔐 Login: http://localhost:%s/api/auth/login", port) - log.Printf("👤 Me: http://localhost:%s/api/me", port) + log.Printf("📍 Health check: http://localhost:%s/health", cfg.Server.Port) + log.Printf("🔗 API: http://localhost:%s/api/health", cfg.Server.Port) + log.Printf("🏢 Register Agency (SUPERADMIN): http://localhost:%s/api/admin/agencies/register", cfg.Server.Port) + log.Printf("🔐 Login: http://localhost:%s/api/auth/login", cfg.Server.Port) - if err := http.ListenAndServe(addr, nil); err != nil { + if err := http.ListenAndServe(addr, handler); err != nil { log.Fatalf("❌ Server error: %v", err) } } - -// handleRegister handler para criar novo usuário -func handleRegister(w http.ResponseWriter, r *http.Request) { - // Apenas POST - if r.Method != http.MethodPost { - sendError(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - // Rate limiting - ip := strings.Split(r.RemoteAddr, ":")[0] - if !checkRateLimit(ip, registerAttempts) { - sendError(w, "Too many registration attempts. Please try again later.", http.StatusTooManyRequests) - return - } - - // Parse JSON - var req RegisterRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - sendError(w, "Invalid JSON", http.StatusBadRequest) - return - } - - // Validações básicas - if !validateEmail(req.Email) { - sendError(w, "Invalid email format", http.StatusBadRequest) - return - } - if req.Password == "" { - sendError(w, "Password is required", http.StatusBadRequest) - return - } - if len(req.Password) < 8 { - sendError(w, "Password must be at least 8 characters", http.StatusBadRequest) - return - } - if req.FullName == "" { - sendError(w, "Full name is required", http.StatusBadRequest) - return - } - if req.CompanyName == "" { - sendError(w, "Company name is required", http.StatusBadRequest) - return - } - if req.Subdomain == "" { - sendError(w, "Subdomain is required", http.StatusBadRequest) - return - } - - // Verificar se email já existe - var exists bool - err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)", req.Email).Scan(&exists) - if err != nil { - sendError(w, "Database error", http.StatusInternalServerError) - log.Printf("Erro ao verificar email: %v", err) - return - } - if exists { - sendError(w, "Email already registered", http.StatusConflict) - return - } - - // Hash da senha com bcrypt - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) - if err != nil { - sendError(w, "Error processing password", http.StatusInternalServerError) - log.Printf("Erro ao hash senha: %v", err) - return - } - - // Criar Tenant (empresa) - tenantID := uuid.New().String() - domain := fmt.Sprintf("%s.aggios.app", req.Subdomain) - createdAt := time.Now() - - _, err = db.Exec( - "INSERT INTO tenants (id, name, domain, subdomain, is_active, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7)", - tenantID, req.CompanyName, domain, req.Subdomain, true, createdAt, createdAt, - ) - if err != nil { - sendError(w, "Error creating company", http.StatusInternalServerError) - log.Printf("Erro ao criar tenant: %v", err) - return - } - - log.Printf("✅ Tenant criado: %s (%s)", req.CompanyName, tenantID) - - // Criar Usuário (administrador do tenant) - userID := uuid.New().String() - firstName := req.FullName - lastName := "" - - _, err = db.Exec( - "INSERT INTO users (id, tenant_id, email, password_hash, first_name, last_name, is_active, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", - userID, tenantID, req.Email, string(hashedPassword), firstName, lastName, true, createdAt, createdAt, - ) - if err != nil { - sendError(w, "Error creating user", http.StatusInternalServerError) - log.Printf("Erro ao inserir usuário: %v", err) - return - } - - log.Printf("✅ Usuário criado: %s (%s)", req.Email, userID) - - // Gerar token JWT para login automático - token, err := generateToken(userID, req.Email, tenantID) - if err != nil { - sendError(w, "Error generating token", http.StatusInternalServerError) - log.Printf("Erro ao gerar token: %v", err) - return - } - - response := RegisterResponse{ - Token: token, - ID: userID, - Email: req.Email, - Name: req.FullName, - TenantID: tenantID, - Company: req.CompanyName, - Subdomain: req.Subdomain, - CreatedAt: createdAt.Format(time.RFC3339), - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(response) -} - -// sendError envia uma resposta de erro padronizada -func sendError(w http.ResponseWriter, message string, statusCode int) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(statusCode) - json.NewEncoder(w).Encode(ErrorResponse{ - Error: http.StatusText(statusCode), - Message: message, - }) -} - -// generateToken gera um JWT token para o usuário -func generateToken(userID, email, tenantID string) (string, error) { - claims := Claims{ - UserID: userID, - Email: email, - TenantID: tenantID, - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), - IssuedAt: jwt.NewNumericDate(time.Now()), - Issuer: "aggios-api", - }, - } - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - return token.SignedString(jwtSecret) -} - -// authMiddleware verifica o token JWT -func authMiddleware(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - sendError(w, "Authorization header required", http.StatusUnauthorized) - return - } - - tokenString := strings.TrimPrefix(authHeader, "Bearer ") - if tokenString == authHeader { - sendError(w, "Invalid authorization format", http.StatusUnauthorized) - return - } - - claims := &Claims{} - token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { - return jwtSecret, nil - }) - - if err != nil || !token.Valid { - sendError(w, "Invalid or expired token", http.StatusUnauthorized) - return - } - - // Adicionar claims ao contexto (simplificado: usar headers) - r.Header.Set("X-User-ID", claims.UserID) - r.Header.Set("X-User-Email", claims.Email) - r.Header.Set("X-Tenant-ID", claims.TenantID) - - next(w, r) - } -} - -// handleLogin handler para fazer login -func handleLogin(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - sendError(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - // Rate limiting - ip := strings.Split(r.RemoteAddr, ":")[0] - if !checkRateLimit(ip, loginAttempts) { - sendError(w, "Too many login attempts. Please try again later.", http.StatusTooManyRequests) - return - } - - var req LoginRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - sendError(w, "Invalid JSON", http.StatusBadRequest) - return - } - - if !validateEmail(req.Email) || req.Password == "" { - sendError(w, "Invalid credentials", http.StatusBadRequest) - return - } - - // Buscar usuário no banco - var userID, email, passwordHash, firstName, tenantID string - var tenantName, subdomain string - - err := db.QueryRow(` - SELECT u.id, u.email, u.password_hash, u.first_name, u.tenant_id, t.name, t.subdomain - FROM users u - INNER JOIN tenants t ON u.tenant_id = t.id - WHERE u.email = $1 AND u.is_active = true - `, req.Email).Scan(&userID, &email, &passwordHash, &firstName, &tenantID, &tenantName, &subdomain) - - if err == sql.ErrNoRows { - sendError(w, "Invalid credentials", http.StatusUnauthorized) - return - } - if err != nil { - sendError(w, "Database error", http.StatusInternalServerError) - log.Printf("Erro ao buscar usuário: %v", err) - return - } - - // Verificar senha - if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil { - sendError(w, "Invalid credentials", http.StatusUnauthorized) - return - } - - // Gerar token JWT - token, err := generateToken(userID, email, tenantID) - if err != nil { - sendError(w, "Error generating token", http.StatusInternalServerError) - log.Printf("Erro ao gerar token: %v", err) - return - } - - log.Printf("✅ Login bem-sucedido: %s", email) - - response := LoginResponse{ - Token: token, - User: UserPayload{ - ID: userID, - Email: email, - Name: firstName, - TenantID: tenantID, - Company: tenantName, - Subdomain: subdomain, - }, - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) -} - -// handleMe retorna dados do usuário autenticado -func handleMe(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - sendError(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - userID := r.Header.Get("X-User-ID") - tenantID := r.Header.Get("X-Tenant-ID") - - var email, firstName, lastName string - var tenantName, subdomain string - - err := db.QueryRow(` - SELECT u.email, u.first_name, u.last_name, t.name, t.subdomain - FROM users u - INNER JOIN tenants t ON u.tenant_id = t.id - WHERE u.id = $1 AND u.tenant_id = $2 - `, userID, tenantID).Scan(&email, &firstName, &lastName, &tenantName, &subdomain) - - if err != nil { - sendError(w, "User not found", http.StatusNotFound) - log.Printf("Erro ao buscar usuário: %v", err) - return - } - - fullName := firstName - if lastName != "" { - fullName += " " + lastName - } - - response := UserPayload{ - ID: userID, - Email: email, - Name: fullName, - TenantID: tenantID, - Company: tenantName, - Subdomain: subdomain, - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) -} diff --git a/backend/go.mod b/backend/go.mod index 9a13b47..69b2ba9 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,20 +1,10 @@ -module backend +module aggios-app/backend go 1.23 require ( github.com/golang-jwt/jwt/v5 v5.2.0 github.com/google/uuid v1.6.0 - github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.10.9 - github.com/minio/minio-go/v7 v7.0.70 - github.com/redis/go-redis/v9 v9.5.1 golang.org/x/crypto v0.27.0 ) - -require ( - github.com/cespare/xxhash/v2 v2.2.0 - github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f - github.com/klauspost/compress v1.17.9 - github.com/klauspost/cpuid/v2 v2.2.8 -) diff --git a/backend/go.sum b/backend/go.sum index 1407188..000fd26 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,12 +1,8 @@ -github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= -github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/minio/minio-go/v7 v7.0.70/go.mod h1:4yBA8v80xGA30cfM3fz0DKYMXunWl/AV/6tWEs9ryzo= -github.com/redis/go-redis/v9 v9.5.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= +golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/backend/internal/api/handlers/agency.go b/backend/internal/api/handlers/agency.go new file mode 100644 index 0000000..793c777 --- /dev/null +++ b/backend/internal/api/handlers/agency.go @@ -0,0 +1,192 @@ +package handlers + +import ( + "encoding/json" + "errors" + "log" + "net/http" + "strings" + "time" + + "aggios-app/backend/internal/config" + "aggios-app/backend/internal/domain" + "aggios-app/backend/internal/service" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +// AgencyRegistrationHandler handles agency management endpoints +type AgencyRegistrationHandler struct { + agencyService *service.AgencyService + cfg *config.Config +} + +// NewAgencyRegistrationHandler creates a new agency registration handler +func NewAgencyRegistrationHandler(agencyService *service.AgencyService, cfg *config.Config) *AgencyRegistrationHandler { + return &AgencyRegistrationHandler{ + agencyService: agencyService, + cfg: cfg, + } +} + +// RegisterAgency handles agency registration (SUPERADMIN only) +func (h *AgencyRegistrationHandler) RegisterAgency(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req domain.RegisterAgencyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Printf("❌ Error decoding request: %v", err) + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + log.Printf("📥 Registering agency: %s (subdomain: %s)", req.AgencyName, req.Subdomain) + + tenant, admin, err := h.agencyService.RegisterAgency(req) + if err != nil { + log.Printf("❌ Error registering agency: %v", err) + switch err { + case service.ErrSubdomainTaken: + http.Error(w, err.Error(), http.StatusConflict) + case service.ErrEmailAlreadyExists: + http.Error(w, err.Error(), http.StatusConflict) + case service.ErrWeakPassword: + http.Error(w, err.Error(), http.StatusBadRequest) + default: + http.Error(w, "Internal server error", http.StatusInternalServerError) + } + return + } + + log.Printf("✅ Agency created: %s (ID: %s)", tenant.Name, tenant.ID) + + // Generate JWT token for the new admin + claims := jwt.MapClaims{ + "user_id": admin.ID.String(), + "email": admin.Email, + "role": admin.Role, + "tenant_id": tenant.ID.String(), + "exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days + "iat": time.Now().Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString([]byte(h.cfg.JWT.Secret)) + if err != nil { + http.Error(w, "Failed to generate token", http.StatusInternalServerError) + return + } + + protocol := "http://" + if h.cfg.App.Environment == "production" { + protocol = "https://" + } + + response := map[string]interface{}{ + "token": tokenString, + "id": admin.ID, + "email": admin.Email, + "name": admin.Name, + "role": admin.Role, + "tenantId": tenant.ID, + "company": tenant.Name, + "subdomain": tenant.Subdomain, + "message": "Agency registered successfully", + "access_url": protocol + tenant.Domain, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(response) +} + +// RegisterClient handles client registration (ADMIN_AGENCIA only) +func (h *AgencyRegistrationHandler) RegisterClient(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // TODO: Get tenant_id from authenticated user context + // For now, this would need the auth middleware to set it + + var req domain.RegisterClientRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Get tenantID from context (set by middleware) + tenantIDStr := r.Header.Get("X-Tenant-ID") + if tenantIDStr == "" { + http.Error(w, "Tenant not found", http.StatusBadRequest) + return + } + + // Parse tenant ID + // tenantID, _ := uuid.Parse(tenantIDStr) + + // client, err := h.agencyService.RegisterClient(req, tenantID) + // ... handle response + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{ + "message": "Client registration endpoint - implementation pending", + }) +} + +// HandleAgency supports GET (details) and DELETE operations for a specific agency +func (h *AgencyRegistrationHandler) HandleAgency(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/admin/agencies/" { + http.Error(w, "Agency ID required", http.StatusBadRequest) + return + } + + agencyID := strings.TrimPrefix(r.URL.Path, "/api/admin/agencies/") + if agencyID == "" || agencyID == r.URL.Path { + http.NotFound(w, r) + return + } + + id, err := uuid.Parse(agencyID) + if err != nil { + http.Error(w, "Invalid agency ID", http.StatusBadRequest) + return + } + + switch r.Method { + case http.MethodGet: + details, err := h.agencyService.GetAgencyDetails(id) + if err != nil { + if errors.Is(err, service.ErrTenantNotFound) { + http.Error(w, "Agency not found", http.StatusNotFound) + return + } + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(details) + + case http.MethodDelete: + if err := h.agencyService.DeleteAgency(id); err != nil { + if errors.Is(err, service.ErrTenantNotFound) { + http.Error(w, "Agency not found", http.StatusNotFound) + return + } + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) + + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} diff --git a/backend/internal/api/handlers/agency_profile.go b/backend/internal/api/handlers/agency_profile.go new file mode 100644 index 0000000..1ff6c18 --- /dev/null +++ b/backend/internal/api/handlers/agency_profile.go @@ -0,0 +1,179 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "aggios-app/backend/internal/repository" + + "github.com/google/uuid" +) + +type AgencyHandler struct { + tenantRepo *repository.TenantRepository +} + +func NewAgencyHandler(tenantRepo *repository.TenantRepository) *AgencyHandler { + return &AgencyHandler{ + tenantRepo: tenantRepo, + } +} + +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"` +} + +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"` +} + +// GetProfile returns the current agency profile +func (h *AgencyHandler) GetProfile(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Get tenant from context (set by middleware) + tenantID := r.Context().Value("tenantID") + if tenantID == nil { + http.Error(w, "Tenant not found", http.StatusUnauthorized) + return + } + + // Parse tenant ID + tid, err := uuid.Parse(tenantID.(string)) + if err != nil { + http.Error(w, "Invalid tenant ID", http.StatusBadRequest) + return + } + + // Get tenant from database + tenant, err := h.tenantRepo.FindByID(tid) + if err != nil { + http.Error(w, "Error fetching profile", http.StatusInternalServerError) + return + } + if tenant == nil { + http.Error(w, "Tenant not found", http.StatusNotFound) + return + } + + 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, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// UpdateProfile updates the current agency profile +func (h *AgencyHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut && r.Method != http.MethodPatch { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Get tenant from context + tenantID := r.Context().Value("tenantID") + if tenantID == nil { + http.Error(w, "Tenant not found", http.StatusUnauthorized) + return + } + + var req UpdateAgencyProfileRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Parse tenant ID + tid, err := uuid.Parse(tenantID.(string)) + if err != nil { + http.Error(w, "Invalid tenant ID", http.StatusBadRequest) + return + } + + // Prepare updates + updates := map[string]interface{}{ + "name": req.Name, + "cnpj": req.CNPJ, + "razao_social": req.RazaoSocial, + "email": req.Email, + "phone": req.Phone, + "website": req.Website, + "address": req.Address, + "city": req.City, + "state": req.State, + "zip": req.Zip, + "description": req.Description, + "industry": req.Industry, + } + + // Update in database + if err := h.tenantRepo.UpdateProfile(tid, updates); err != nil { + http.Error(w, "Error updating profile", http.StatusInternalServerError) + return + } + + // Fetch updated data + tenant, err := h.tenantRepo.FindByID(tid) + if err != nil { + http.Error(w, "Error fetching updated profile", http.StatusInternalServerError) + return + } + + response := AgencyProfileResponse{ + ID: tenant.ID.String(), + Name: tenant.Name, + CNPJ: tenant.CNPJ, + Email: tenant.Email, + Phone: tenant.Phone, + Website: tenant.Website, + Address: tenant.Address, + City: tenant.City, + State: tenant.State, + Zip: tenant.Zip, + RazaoSocial: tenant.RazaoSocial, + Description: tenant.Description, + Industry: tenant.Industry, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} diff --git a/backend/internal/api/handlers/auth.go b/backend/internal/api/handlers/auth.go new file mode 100644 index 0000000..92496ef --- /dev/null +++ b/backend/internal/api/handlers/auth.go @@ -0,0 +1,139 @@ +package handlers + +import ( + "encoding/json" + "io" + "net/http" + "strings" + + "aggios-app/backend/internal/domain" + "aggios-app/backend/internal/service" +) + +// AuthHandler handles authentication endpoints +type AuthHandler struct { + authService *service.AuthService +} + +// NewAuthHandler creates a new auth handler +func NewAuthHandler(authService *service.AuthService) *AuthHandler { + return &AuthHandler{ + authService: authService, + } +} + +// Register handles user registration +func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req domain.CreateUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + user, err := h.authService.Register(req) + if err != nil { + switch err { + case service.ErrEmailAlreadyExists: + http.Error(w, err.Error(), http.StatusConflict) + case service.ErrWeakPassword: + http.Error(w, err.Error(), http.StatusBadRequest) + default: + http.Error(w, "Internal server error", http.StatusInternalServerError) + } + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(user) +} + +// Login handles user login +func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + // 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 { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + response, err := h.authService.Login(req) + if err != nil { + if err == service.ErrInvalidCredentials { + http.Error(w, err.Error(), http.StatusUnauthorized) + } else { + http.Error(w, "Internal server error", http.StatusInternalServerError) + } + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// ChangePasswordRequest represents a password change request +type ChangePasswordRequest struct { + CurrentPassword string `json:"currentPassword"` + NewPassword string `json:"newPassword"` +} + +// ChangePassword handles password change +func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Get user ID from context (set by auth middleware) + userID, ok := r.Context().Value("userID").(string) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + var req ChangePasswordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.CurrentPassword == "" || req.NewPassword == "" { + http.Error(w, "Current password and new password are required", http.StatusBadRequest) + return + } + + // Call auth service to change password + if err := h.authService.ChangePassword(userID, req.CurrentPassword, req.NewPassword); err != nil { + if err == service.ErrInvalidCredentials { + http.Error(w, "Current password is incorrect", http.StatusUnauthorized) + } else if err == service.ErrWeakPassword { + http.Error(w, "New password is too weak", http.StatusBadRequest) + } else { + http.Error(w, "Error changing password", http.StatusInternalServerError) + } + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "message": "Password changed successfully", + }) +} diff --git a/backend/internal/api/handlers/company.go b/backend/internal/api/handlers/company.go new file mode 100644 index 0000000..589e16e --- /dev/null +++ b/backend/internal/api/handlers/company.go @@ -0,0 +1,90 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "aggios-app/backend/internal/api/middleware" + "aggios-app/backend/internal/domain" + "aggios-app/backend/internal/service" + + "github.com/google/uuid" +) + +// CompanyHandler handles company endpoints +type CompanyHandler struct { + companyService *service.CompanyService +} + +// NewCompanyHandler creates a new company handler +func NewCompanyHandler(companyService *service.CompanyService) *CompanyHandler { + return &CompanyHandler{ + companyService: companyService, + } +} + +// Create handles company creation +func (h *CompanyHandler) Create(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Get user ID from context (set by auth middleware) + userIDStr, ok := r.Context().Value(middleware.UserIDKey).(string) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + userID, err := uuid.Parse(userIDStr) + if err != nil { + http.Error(w, "Invalid user ID", http.StatusBadRequest) + return + } + + var req domain.CreateCompanyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // TODO: Get tenantID from user context + // For now, this is a placeholder - you'll need to get the tenant from the authenticated user + tenantID := uuid.New() // Replace with actual tenant from user + + company, err := h.companyService.Create(req, tenantID, userID) + if err != nil { + switch err { + case service.ErrCNPJAlreadyExists: + http.Error(w, err.Error(), http.StatusConflict) + default: + http.Error(w, "Internal server error", http.StatusInternalServerError) + } + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(company) +} + +// List handles listing companies for a tenant +func (h *CompanyHandler) List(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // TODO: Get tenantID from authenticated user + tenantID := uuid.New() // Replace with actual tenant from user + + companies, err := h.companyService.ListByTenant(tenantID) + if err != nil { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(companies) +} diff --git a/backend/internal/api/handlers/health.go b/backend/internal/api/handlers/health.go new file mode 100644 index 0000000..e8d224e --- /dev/null +++ b/backend/internal/api/handlers/health.go @@ -0,0 +1,31 @@ +package handlers + +import ( + "encoding/json" + "net/http" +) + +// HealthHandler handles health check endpoint +type HealthHandler struct{} + +// NewHealthHandler creates a new health handler +func NewHealthHandler() *HealthHandler { + return &HealthHandler{} +} + +// Check returns API health status +func (h *HealthHandler) Check(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + response := map[string]interface{}{ + "status": "healthy", + "service": "aggios-api", + "version": "1.0.0", + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} diff --git a/backend/internal/api/handlers/tenant.go b/backend/internal/api/handlers/tenant.go new file mode 100644 index 0000000..2c60c56 --- /dev/null +++ b/backend/internal/api/handlers/tenant.go @@ -0,0 +1,42 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "aggios-app/backend/internal/domain" + "aggios-app/backend/internal/service" +) + +// TenantHandler handles tenant/agency listing endpoints +type TenantHandler struct { + tenantService *service.TenantService +} + +// NewTenantHandler creates a new tenant handler +func NewTenantHandler(tenantService *service.TenantService) *TenantHandler { + return &TenantHandler{ + tenantService: tenantService, + } +} + +// ListAll lists all agencies/tenants (SUPERADMIN only) +func (h *TenantHandler) ListAll(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + tenants, err := h.tenantService.ListAll() + if err != nil { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + if tenants == nil { + tenants = []*domain.Tenant{} + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + json.NewEncoder(w).Encode(tenants) +} diff --git a/backend/internal/api/middleware/auth.go b/backend/internal/api/middleware/auth.go new file mode 100644 index 0000000..730d68b --- /dev/null +++ b/backend/internal/api/middleware/auth.go @@ -0,0 +1,53 @@ +package middleware + +import ( + "context" + "net/http" + "strings" + + "aggios-app/backend/internal/config" + + "github.com/golang-jwt/jwt/v5" +) + +type contextKey string + +const UserIDKey contextKey = "userID" + +// Auth validates JWT tokens +func Auth(cfg *config.Config) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + bearerToken := strings.Split(authHeader, " ") + if len(bearerToken) != 2 || bearerToken[0] != "Bearer" { + http.Error(w, "Invalid token format", http.StatusUnauthorized) + return + } + + token, err := jwt.Parse(bearerToken[1], func(token *jwt.Token) (interface{}, error) { + return []byte(cfg.JWT.Secret), nil + }) + + if err != nil || !token.Valid { + http.Error(w, "Invalid token", http.StatusUnauthorized) + return + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + http.Error(w, "Invalid token claims", http.StatusUnauthorized) + return + } + + userID := claims["user_id"].(string) + ctx := context.WithValue(r.Context(), UserIDKey, userID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/backend/internal/api/middleware/cors.go b/backend/internal/api/middleware/cors.go new file mode 100644 index 0000000..311fc70 --- /dev/null +++ b/backend/internal/api/middleware/cors.go @@ -0,0 +1,34 @@ +package middleware + +import ( + "net/http" + + "aggios-app/backend/internal/config" +) + +// CORS adds CORS headers to responses +func CORS(cfg *config.Config) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + + // Allow all localhost origins for development + if origin != "" { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Credentials", "true") + } + + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, Host") + w.Header().Set("Access-Control-Max-Age", "3600") + + // Handle preflight request + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/backend/internal/api/middleware/ratelimit.go b/backend/internal/api/middleware/ratelimit.go new file mode 100644 index 0000000..ecf85b7 --- /dev/null +++ b/backend/internal/api/middleware/ratelimit.go @@ -0,0 +1,96 @@ +package middleware + +import ( + "net/http" + "sync" + "time" + + "aggios-app/backend/internal/config" +) + +type rateLimiter struct { + mu sync.Mutex + attempts map[string][]time.Time + maxAttempts int +} + +func newRateLimiter(maxAttempts int) *rateLimiter { + rl := &rateLimiter{ + attempts: make(map[string][]time.Time), + maxAttempts: maxAttempts, + } + + // Clean old entries every minute + go func() { + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + for range ticker.C { + rl.cleanup() + } + }() + + return rl +} + +func (rl *rateLimiter) cleanup() { + rl.mu.Lock() + defer rl.mu.Unlock() + + now := time.Now() + for ip, attempts := range rl.attempts { + var valid []time.Time + for _, t := range attempts { + if now.Sub(t) < time.Minute { + valid = append(valid, t) + } + } + if len(valid) == 0 { + delete(rl.attempts, ip) + } else { + rl.attempts[ip] = valid + } + } +} + +func (rl *rateLimiter) isAllowed(ip string) bool { + rl.mu.Lock() + defer rl.mu.Unlock() + + now := time.Now() + attempts := rl.attempts[ip] + + // Filter attempts within the last minute + var validAttempts []time.Time + for _, t := range attempts { + if now.Sub(t) < time.Minute { + validAttempts = append(validAttempts, t) + } + } + + if len(validAttempts) >= rl.maxAttempts { + return false + } + + validAttempts = append(validAttempts, now) + rl.attempts[ip] = validAttempts + + return true +} + +// RateLimit limits requests per IP address +func RateLimit(cfg *config.Config) func(http.Handler) http.Handler { + limiter := newRateLimiter(cfg.Security.MaxAttemptsPerMin) + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip := r.RemoteAddr + + if !limiter.isAllowed(ip) { + http.Error(w, "Too many requests", http.StatusTooManyRequests) + return + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/backend/internal/api/middleware/security.go b/backend/internal/api/middleware/security.go new file mode 100644 index 0000000..7f5affb --- /dev/null +++ b/backend/internal/api/middleware/security.go @@ -0,0 +1,17 @@ +package middleware + +import ( + "net/http" +) + +// SecurityHeaders adds security headers to responses +func SecurityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("X-XSS-Protection", "1; mode=block") + w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") + + next.ServeHTTP(w, r) + }) +} diff --git a/backend/internal/api/middleware/tenant.go b/backend/internal/api/middleware/tenant.go new file mode 100644 index 0000000..bd29737 --- /dev/null +++ b/backend/internal/api/middleware/tenant.go @@ -0,0 +1,56 @@ +package middleware + +import ( + "context" + "net/http" + "strings" + + "aggios-app/backend/internal/repository" +) + +type tenantContextKey string + +const TenantIDKey tenantContextKey = "tenantID" +const SubdomainKey tenantContextKey = "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 + + // Extract subdomain + // Examples: + // - agencia-xyz.localhost -> agencia-xyz + // - agencia-xyz.aggios.app -> agencia-xyz + // - dash.localhost -> dash (master admin) + // - localhost -> (institutional site) + + parts := strings.Split(host, ".") + var subdomain string + + if len(parts) >= 2 { + // Has subdomain + subdomain = parts[0] + + // Remove port if present + if strings.Contains(subdomain, ":") { + subdomain = strings.Split(subdomain, ":")[0] + } + } + + // Add subdomain to context + ctx := context.WithValue(r.Context(), SubdomainKey, subdomain) + + // If subdomain is not empty and not "dash" or "api", try to find tenant + if subdomain != "" && subdomain != "dash" && subdomain != "api" && subdomain != "localhost" { + tenant, err := tenantRepo.FindBySubdomain(subdomain) + if err == nil && tenant != nil { + ctx = context.WithValue(ctx, TenantIDKey, tenant.ID.String()) + } + } + + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..262837f --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,96 @@ +package config + +import ( + "os" +) + +// Config holds all application configuration +type Config struct { + Server ServerConfig + Database DatabaseConfig + JWT JWTConfig + Security SecurityConfig + App AppConfig +} + +// AppConfig holds application-level settings +type AppConfig struct { + Environment string // "development" or "production" + BaseDomain string // "localhost" or "aggios.app" +} + +// ServerConfig holds server-specific configuration +type ServerConfig struct { + Port string +} + +// DatabaseConfig holds database connection settings +type DatabaseConfig struct { + Host string + Port string + User string + Password string + Name string +} + +// JWTConfig holds JWT configuration +type JWTConfig struct { + Secret string +} + +// SecurityConfig holds security settings +type SecurityConfig struct { + AllowedOrigins []string + MaxAttemptsPerMin int + PasswordMinLength int +} + +// Load loads configuration from environment variables +func Load() *Config { + env := getEnvOrDefault("APP_ENV", "development") + baseDomain := "localhost" + if env == "production" { + baseDomain = "aggios.app" + } + + return &Config{ + Server: ServerConfig{ + Port: getEnvOrDefault("SERVER_PORT", "8080"), + }, + Database: DatabaseConfig{ + Host: getEnvOrDefault("DB_HOST", "localhost"), + Port: getEnvOrDefault("DB_PORT", "5432"), + User: getEnvOrDefault("DB_USER", "postgres"), + Password: getEnvOrDefault("DB_PASSWORD", "postgres"), + Name: getEnvOrDefault("DB_NAME", "aggios"), + }, + JWT: JWTConfig{ + Secret: getEnvOrDefault("JWT_SECRET", "INSECURE-fallback-secret-CHANGE-THIS"), + }, + App: AppConfig{ + Environment: env, + BaseDomain: baseDomain, + }, + Security: SecurityConfig{ + AllowedOrigins: []string{ + "http://localhost", + "http://dash.localhost", + "http://aggios.local", + "http://dash.aggios.local", + "https://aggios.app", + "https://dash.aggios.app", + "https://www.aggios.app", + }, + MaxAttemptsPerMin: 5, + PasswordMinLength: 8, + }, + } +} + +// getEnvOrDefault returns environment variable or default value +func getEnvOrDefault(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} diff --git a/backend/internal/domain/company.go b/backend/internal/domain/company.go new file mode 100644 index 0000000..bdbd3e5 --- /dev/null +++ b/backend/internal/domain/company.go @@ -0,0 +1,31 @@ +package domain + +import ( + "time" + + "github.com/google/uuid" +) + +// Company represents a company in the system +type Company struct { + ID uuid.UUID `json:"id" db:"id"` + CNPJ string `json:"cnpj" db:"cnpj"` + RazaoSocial string `json:"razao_social" db:"razao_social"` + NomeFantasia string `json:"nome_fantasia" db:"nome_fantasia"` + Email string `json:"email" db:"email"` + Telefone string `json:"telefone" db:"telefone"` + Status string `json:"status" db:"status"` + TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"` + CreatedByUserID *uuid.UUID `json:"created_by_user_id,omitempty" db:"created_by_user_id"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// CreateCompanyRequest represents the request to create a new company +type CreateCompanyRequest struct { + CNPJ string `json:"cnpj"` + RazaoSocial string `json:"razao_social"` + NomeFantasia string `json:"nome_fantasia"` + Email string `json:"email"` + Telefone string `json:"telefone"` +} diff --git a/backend/internal/domain/tenant.go b/backend/internal/domain/tenant.go new file mode 100644 index 0000000..4013253 --- /dev/null +++ b/backend/internal/domain/tenant.go @@ -0,0 +1,43 @@ +package domain + +import ( + "time" + + "github.com/google/uuid" +) + +// Tenant represents a tenant (agency) in the system +type Tenant struct { + ID uuid.UUID `json:"id" db:"id"` + Name string `json:"name" db:"name"` + Domain string `json:"domain" db:"domain"` + Subdomain string `json:"subdomain" db:"subdomain"` + CNPJ string `json:"cnpj,omitempty" db:"cnpj"` + RazaoSocial string `json:"razao_social,omitempty" db:"razao_social"` + Email string `json:"email,omitempty" db:"email"` + Phone string `json:"phone,omitempty" db:"phone"` + Website string `json:"website,omitempty" db:"website"` + Address string `json:"address,omitempty" db:"address"` + 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"` +} + +// CreateTenantRequest represents the request to create a new tenant +type CreateTenantRequest struct { + Name string `json:"name"` + Domain string `json:"domain"` + Subdomain string `json:"subdomain"` +} + +// AgencyDetails aggregates tenant info with its admin user for superadmin view +type AgencyDetails struct { + Tenant *Tenant `json:"tenant"` + Admin *User `json:"admin,omitempty"` + AccessURL string `json:"access_url"` +} diff --git a/backend/internal/domain/user.go b/backend/internal/domain/user.go new file mode 100644 index 0000000..1aa9a23 --- /dev/null +++ b/backend/internal/domain/user.go @@ -0,0 +1,73 @@ +package domain + +import ( + "time" + + "github.com/google/uuid" +) + +// User represents a user in the system +type User struct { + ID uuid.UUID `json:"id" db:"id"` + TenantID *uuid.UUID `json:"tenant_id,omitempty" db:"tenant_id"` + Email string `json:"email" db:"email"` + Password string `json:"-" db:"password_hash"` + Name string `json:"name" db:"first_name"` + Role string `json:"role" db:"role"` // SUPERADMIN, ADMIN_AGENCIA, CLIENTE + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// CreateUserRequest represents the request to create a new user +type CreateUserRequest struct { + Email string `json:"email"` + Password string `json:"password"` + Name string `json:"name"` + Role string `json:"role,omitempty"` // Optional, defaults to CLIENTE +} + +// RegisterAgencyRequest represents agency registration (SUPERADMIN only) +type RegisterAgencyRequest struct { + // Agência + AgencyName string `json:"agencyName"` + Subdomain string `json:"subdomain"` + CNPJ string `json:"cnpj"` + RazaoSocial string `json:"razaoSocial"` + Description string `json:"description"` + Website string `json:"website"` + Industry string `json:"industry"` + + // Endereço + CEP string `json:"cep"` + State string `json:"state"` + City string `json:"city"` + Neighborhood string `json:"neighborhood"` + Street string `json:"street"` + Number string `json:"number"` + Complement string `json:"complement"` + + // Admin da Agência + AdminEmail string `json:"adminEmail"` + AdminPassword string `json:"adminPassword"` + AdminName string `json:"adminName"` +} + +// RegisterClientRequest represents client registration (ADMIN_AGENCIA only) +type RegisterClientRequest struct { + Email string `json:"email"` + Password string `json:"password"` + Name string `json:"name"` +} + +// LoginRequest represents the login request +type LoginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +// LoginResponse represents the login response +type LoginResponse struct { + Token string `json:"token"` + User User `json:"user"` + Subdomain *string `json:"subdomain,omitempty"` +} diff --git a/backend/internal/repository/company_repository.go b/backend/internal/repository/company_repository.go new file mode 100644 index 0000000..f8b0be2 --- /dev/null +++ b/backend/internal/repository/company_repository.go @@ -0,0 +1,127 @@ +package repository + +import ( + "database/sql" + "time" + + "aggios-app/backend/internal/domain" + + "github.com/google/uuid" +) + +// CompanyRepository handles database operations for companies +type CompanyRepository struct { + db *sql.DB +} + +// NewCompanyRepository creates a new company repository +func NewCompanyRepository(db *sql.DB) *CompanyRepository { + return &CompanyRepository{db: db} +} + +// Create creates a new company +func (r *CompanyRepository) Create(company *domain.Company) error { + query := ` + INSERT INTO companies (id, cnpj, razao_social, nome_fantasia, email, telefone, status, tenant_id, created_by_user_id, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING id, created_at, updated_at + ` + + now := time.Now() + company.ID = uuid.New() + company.CreatedAt = now + company.UpdatedAt = now + + return r.db.QueryRow( + query, + company.ID, + company.CNPJ, + company.RazaoSocial, + company.NomeFantasia, + company.Email, + company.Telefone, + company.Status, + company.TenantID, + company.CreatedByUserID, + company.CreatedAt, + company.UpdatedAt, + ).Scan(&company.ID, &company.CreatedAt, &company.UpdatedAt) +} + +// FindByID finds a company by ID +func (r *CompanyRepository) FindByID(id uuid.UUID) (*domain.Company, error) { + query := ` + SELECT id, cnpj, razao_social, nome_fantasia, email, telefone, status, tenant_id, created_by_user_id, created_at, updated_at + FROM companies + WHERE id = $1 + ` + + company := &domain.Company{} + err := r.db.QueryRow(query, id).Scan( + &company.ID, + &company.CNPJ, + &company.RazaoSocial, + &company.NomeFantasia, + &company.Email, + &company.Telefone, + &company.Status, + &company.TenantID, + &company.CreatedByUserID, + &company.CreatedAt, + &company.UpdatedAt, + ) + + if err == sql.ErrNoRows { + return nil, nil + } + + return company, err +} + +// FindByTenantID finds all companies for a tenant +func (r *CompanyRepository) FindByTenantID(tenantID uuid.UUID) ([]*domain.Company, error) { + query := ` + SELECT id, cnpj, razao_social, nome_fantasia, email, telefone, status, tenant_id, created_by_user_id, created_at, updated_at + FROM companies + WHERE tenant_id = $1 + ORDER BY created_at DESC + ` + + rows, err := r.db.Query(query, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + + var companies []*domain.Company + for rows.Next() { + company := &domain.Company{} + err := rows.Scan( + &company.ID, + &company.CNPJ, + &company.RazaoSocial, + &company.NomeFantasia, + &company.Email, + &company.Telefone, + &company.Status, + &company.TenantID, + &company.CreatedByUserID, + &company.CreatedAt, + &company.UpdatedAt, + ) + if err != nil { + return nil, err + } + companies = append(companies, company) + } + + return companies, nil +} + +// CNPJExists checks if a CNPJ is already registered for a tenant +func (r *CompanyRepository) CNPJExists(cnpj string, tenantID uuid.UUID) (bool, error) { + var exists bool + query := `SELECT EXISTS(SELECT 1 FROM companies WHERE cnpj = $1 AND tenant_id = $2)` + err := r.db.QueryRow(query, cnpj, tenantID).Scan(&exists) + return exists, err +} diff --git a/backend/internal/repository/tenant_repository.go b/backend/internal/repository/tenant_repository.go new file mode 100644 index 0000000..49ce561 --- /dev/null +++ b/backend/internal/repository/tenant_repository.go @@ -0,0 +1,268 @@ +package repository + +import ( + "database/sql" + "time" + + "aggios-app/backend/internal/domain" + + "github.com/google/uuid" +) + +// TenantRepository handles database operations for tenants +type TenantRepository struct { + db *sql.DB +} + +// NewTenantRepository creates a new tenant repository +func NewTenantRepository(db *sql.DB) *TenantRepository { + return &TenantRepository{db: db} +} + +// 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 + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + RETURNING id, created_at, updated_at + ` + + now := time.Now() + tenant.ID = uuid.New() + tenant.CreatedAt = now + tenant.UpdatedAt = now + + return r.db.QueryRow( + query, + tenant.ID, + tenant.Name, + tenant.Domain, + tenant.Subdomain, + tenant.CNPJ, + tenant.RazaoSocial, + tenant.Email, + tenant.Website, + tenant.Address, + tenant.City, + tenant.State, + tenant.Zip, + tenant.Description, + tenant.Industry, + tenant.CreatedAt, + tenant.UpdatedAt, + ).Scan(&tenant.ID, &tenant.CreatedAt, &tenant.UpdatedAt) +} + +// FindByID finds a tenant by ID +func (r *TenantRepository) FindByID(id uuid.UUID) (*domain.Tenant, error) { + query := ` + SELECT id, name, domain, subdomain, cnpj, razao_social, email, phone, website, + address, city, state, zip, description, industry, 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 + + err := r.db.QueryRow(query, id).Scan( + &tenant.ID, + &tenant.Name, + &tenant.Domain, + &tenant.Subdomain, + &cnpj, + &razaoSocial, + &email, + &phone, + &website, + &address, + &city, + &state, + &zip, + &description, + &industry, + &tenant.IsActive, + &tenant.CreatedAt, + &tenant.UpdatedAt, + ) + + if err == sql.ErrNoRows { + return nil, nil + } + + if err != nil { + return nil, err + } + + // Handle nullable fields + if cnpj.Valid { + tenant.CNPJ = cnpj.String + } + if razaoSocial.Valid { + tenant.RazaoSocial = razaoSocial.String + } + if email.Valid { + tenant.Email = email.String + } + if phone.Valid { + tenant.Phone = phone.String + } + if website.Valid { + tenant.Website = website.String + } + if address.Valid { + tenant.Address = address.String + } + if city.Valid { + tenant.City = city.String + } + if state.Valid { + tenant.State = state.String + } + if zip.Valid { + tenant.Zip = zip.String + } + if description.Valid { + tenant.Description = description.String + } + if industry.Valid { + tenant.Industry = industry.String + } + + return tenant, nil +} + +// 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 + FROM tenants + WHERE subdomain = $1 + ` + + tenant := &domain.Tenant{} + err := r.db.QueryRow(query, subdomain).Scan( + &tenant.ID, + &tenant.Name, + &tenant.Domain, + &tenant.Subdomain, + &tenant.CreatedAt, + &tenant.UpdatedAt, + ) + + if err == sql.ErrNoRows { + return nil, nil + } + + return tenant, err +} + +// SubdomainExists checks if a subdomain is already taken +func (r *TenantRepository) SubdomainExists(subdomain string) (bool, error) { + var exists bool + query := `SELECT EXISTS(SELECT 1 FROM tenants WHERE subdomain = $1)` + err := r.db.QueryRow(query, subdomain).Scan(&exists) + return exists, err +} + +// FindAll returns all tenants +func (r *TenantRepository) FindAll() ([]*domain.Tenant, error) { + query := ` + SELECT id, name, domain, subdomain, is_active, created_at, updated_at + FROM tenants + ORDER BY created_at DESC + ` + + rows, err := r.db.Query(query) + if err != nil { + return nil, err + } + defer rows.Close() + + var tenants []*domain.Tenant + for rows.Next() { + tenant := &domain.Tenant{} + err := rows.Scan( + &tenant.ID, + &tenant.Name, + &tenant.Domain, + &tenant.Subdomain, + &tenant.IsActive, + &tenant.CreatedAt, + &tenant.UpdatedAt, + ) + if err != nil { + return nil, err + } + tenants = append(tenants, tenant) + } + + if tenants == nil { + return []*domain.Tenant{}, nil + } + + return tenants, nil +} + +// Delete removes a tenant (and cascades to related data) +func (r *TenantRepository) Delete(id uuid.UUID) error { + result, err := r.db.Exec(`DELETE FROM tenants WHERE id = $1`, id) + if err != nil { + return err + } + + rows, err := result.RowsAffected() + if err != nil { + return err + } + + if rows == 0 { + return sql.ErrNoRows + } + + return nil +} + +// UpdateProfile updates tenant profile information +func (r *TenantRepository) UpdateProfile(id uuid.UUID, updates map[string]interface{}) error { + query := ` + UPDATE tenants SET + name = COALESCE($1, name), + cnpj = COALESCE($2, cnpj), + razao_social = COALESCE($3, razao_social), + email = COALESCE($4, email), + phone = COALESCE($5, phone), + website = COALESCE($6, website), + address = COALESCE($7, address), + 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 + ` + + _, err := r.db.Exec( + query, + updates["name"], + updates["cnpj"], + updates["razao_social"], + updates["email"], + updates["phone"], + updates["website"], + updates["address"], + updates["city"], + updates["state"], + updates["zip"], + updates["description"], + updates["industry"], + time.Now(), + id, + ) + + return err +} diff --git a/backend/internal/repository/user_repository.go b/backend/internal/repository/user_repository.go new file mode 100644 index 0000000..4b663f7 --- /dev/null +++ b/backend/internal/repository/user_repository.go @@ -0,0 +1,154 @@ +package repository + +import ( + "database/sql" + "time" + + "aggios-app/backend/internal/domain" + + "github.com/google/uuid" +) + +// UserRepository handles database operations for users +type UserRepository struct { + db *sql.DB +} + +// NewUserRepository creates a new user repository +func NewUserRepository(db *sql.DB) *UserRepository { + return &UserRepository{db: db} +} + +// Create creates a new user +func (r *UserRepository) Create(user *domain.User) error { + query := ` + INSERT INTO users (id, tenant_id, email, password_hash, first_name, role, is_active, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id, created_at, updated_at + ` + + now := time.Now() + user.ID = uuid.New() + user.CreatedAt = now + user.UpdatedAt = now + + // Default role to CLIENTE if not specified + if user.Role == "" { + user.Role = "CLIENTE" + } + + return r.db.QueryRow( + query, + user.ID, + user.TenantID, + user.Email, + user.Password, + user.Name, + user.Role, + true, // is_active + user.CreatedAt, + user.UpdatedAt, + ).Scan(&user.ID, &user.CreatedAt, &user.UpdatedAt) +} + +// FindByEmail finds a user by email +func (r *UserRepository) FindByEmail(email string) (*domain.User, error) { + query := ` + SELECT id, tenant_id, email, password_hash, first_name, role, created_at, updated_at + FROM users + WHERE email = $1 AND is_active = true + ` + + user := &domain.User{} + err := r.db.QueryRow(query, email).Scan( + &user.ID, + &user.TenantID, + &user.Email, + &user.Password, + &user.Name, + &user.Role, + &user.CreatedAt, + &user.UpdatedAt, + ) + + if err == sql.ErrNoRows { + return nil, nil + } + + return user, err +} + +// FindByID finds a user by ID +func (r *UserRepository) FindByID(id uuid.UUID) (*domain.User, error) { + query := ` + SELECT id, tenant_id, email, password_hash, first_name, role, created_at, updated_at + FROM users + WHERE id = $1 AND is_active = true + ` + + user := &domain.User{} + err := r.db.QueryRow(query, id).Scan( + &user.ID, + &user.TenantID, + &user.Email, + &user.Password, + &user.Name, + &user.Role, + &user.CreatedAt, + &user.UpdatedAt, + ) + + if err == sql.ErrNoRows { + return nil, nil + } + + return user, err +} + +// EmailExists checks if an email is already registered +func (r *UserRepository) EmailExists(email string) (bool, error) { + var exists bool + query := `SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)` + err := r.db.QueryRow(query, email).Scan(&exists) + return exists, err +} + +// UpdatePassword updates a user's password +func (r *UserRepository) UpdatePassword(userID, hashedPassword string) error { + query := `UPDATE users SET password_hash = $1, updated_at = $2 WHERE id = $3` + _, err := r.db.Exec(query, hashedPassword, time.Now(), userID) + return err +} + +// FindAdminByTenantID returns the primary admin user for a tenant +func (r *UserRepository) FindAdminByTenantID(tenantID uuid.UUID) (*domain.User, error) { + query := ` + SELECT id, tenant_id, email, password_hash, first_name, role, created_at, updated_at + FROM users + WHERE tenant_id = $1 AND role = 'ADMIN_AGENCIA' AND is_active = true + ORDER BY created_at ASC + LIMIT 1 + ` + + user := &domain.User{} + err := r.db.QueryRow(query, tenantID).Scan( + &user.ID, + &user.TenantID, + &user.Email, + &user.Password, + &user.Name, + &user.Role, + &user.CreatedAt, + &user.UpdatedAt, + ) + + if err == sql.ErrNoRows { + return nil, nil + } + + if err != nil { + return nil, err + } + + return user, nil +} diff --git a/backend/internal/service/agency_service.go b/backend/internal/service/agency_service.go new file mode 100644 index 0000000..8071c15 --- /dev/null +++ b/backend/internal/service/agency_service.go @@ -0,0 +1,191 @@ +package service + +import ( + "aggios-app/backend/internal/config" + "aggios-app/backend/internal/domain" + "aggios-app/backend/internal/repository" + "fmt" + + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +// AgencyService handles agency registration and management +type AgencyService struct { + userRepo *repository.UserRepository + tenantRepo *repository.TenantRepository + cfg *config.Config +} + +// NewAgencyService creates a new agency service +func NewAgencyService(userRepo *repository.UserRepository, tenantRepo *repository.TenantRepository, cfg *config.Config) *AgencyService { + return &AgencyService{ + userRepo: userRepo, + tenantRepo: tenantRepo, + cfg: cfg, + } +} + +// RegisterAgency creates a new agency (tenant) and its admin user +// Only SUPERADMIN can call this +func (s *AgencyService) RegisterAgency(req domain.RegisterAgencyRequest) (*domain.Tenant, *domain.User, error) { + // Validate password + if len(req.AdminPassword) < s.cfg.Security.PasswordMinLength { + return nil, nil, ErrWeakPassword + } + + // Check if subdomain is available + exists, err := s.tenantRepo.SubdomainExists(req.Subdomain) + if err != nil { + return nil, nil, err + } + if exists { + return nil, nil, ErrSubdomainTaken + } + + // Check if admin email already exists + emailExists, err := s.userRepo.EmailExists(req.AdminEmail) + if err != nil { + return nil, nil, err + } + if emailExists { + return nil, nil, ErrEmailAlreadyExists + } + + // Create tenant + address := req.Street + if req.Number != "" { + address += ", " + req.Number + } + if req.Complement != "" { + address += " - " + req.Complement + } + 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, + } + + if err := s.tenantRepo.Create(tenant); err != nil { + return nil, nil, err + } + + // Hash password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.AdminPassword), bcrypt.DefaultCost) + if err != nil { + return nil, nil, err + } + + // Create admin user for the agency + adminUser := &domain.User{ + TenantID: &tenant.ID, + Email: req.AdminEmail, + Password: string(hashedPassword), + Name: req.AdminName, + Role: "ADMIN_AGENCIA", + } + + if err := s.userRepo.Create(adminUser); err != nil { + return nil, nil, err + } + + return tenant, adminUser, nil +} + +// RegisterClient creates a new client user for a specific agency +// Only ADMIN_AGENCIA can call this +func (s *AgencyService) RegisterClient(req domain.RegisterClientRequest, tenantID uuid.UUID) (*domain.User, error) { + // Validate password + if len(req.Password) < s.cfg.Security.PasswordMinLength { + return nil, ErrWeakPassword + } + + // Check if email already exists + exists, err := s.userRepo.EmailExists(req.Email) + if err != nil { + return nil, err + } + if exists { + return nil, ErrEmailAlreadyExists + } + + // Hash password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + + // Create client user + client := &domain.User{ + TenantID: &tenantID, + Email: req.Email, + Password: string(hashedPassword), + Name: req.Name, + Role: "CLIENTE", + } + + if err := s.userRepo.Create(client); err != nil { + return nil, err + } + + return client, nil +} + +// GetAgencyDetails returns tenant and admin information for superadmin view +func (s *AgencyService) GetAgencyDetails(id uuid.UUID) (*domain.AgencyDetails, error) { + tenant, err := s.tenantRepo.FindByID(id) + if err != nil { + return nil, err + } + if tenant == nil { + return nil, ErrTenantNotFound + } + + admin, err := s.userRepo.FindAdminByTenantID(id) + if err != nil { + return nil, err + } + + protocol := "http://" + if s.cfg.App.Environment == "production" { + protocol = "https://" + } + + details := &domain.AgencyDetails{ + Tenant: tenant, + AccessURL: fmt.Sprintf("%s%s", protocol, tenant.Domain), + } + + if admin != nil { + details.Admin = admin + } + + return details, nil +} + +// DeleteAgency removes a tenant and its related resources +func (s *AgencyService) DeleteAgency(id uuid.UUID) error { + tenant, err := s.tenantRepo.FindByID(id) + if err != nil { + return err + } + if tenant == nil { + return ErrTenantNotFound + } + + return s.tenantRepo.Delete(id) +} diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go new file mode 100644 index 0000000..b1ede41 --- /dev/null +++ b/backend/internal/service/auth_service.go @@ -0,0 +1,170 @@ +package service + +import ( + "errors" + "time" + + "aggios-app/backend/internal/config" + "aggios-app/backend/internal/domain" + "aggios-app/backend/internal/repository" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +var ( + ErrEmailAlreadyExists = errors.New("email already registered") + ErrInvalidCredentials = errors.New("invalid email or password") + ErrWeakPassword = errors.New("password too weak") + ErrSubdomainTaken = errors.New("subdomain already taken") + ErrUnauthorized = errors.New("unauthorized access") +) + +// AuthService handles authentication business logic +type AuthService struct { + userRepo *repository.UserRepository + tenantRepo *repository.TenantRepository + cfg *config.Config +} + +// NewAuthService creates a new auth service +func NewAuthService(userRepo *repository.UserRepository, tenantRepo *repository.TenantRepository, cfg *config.Config) *AuthService { + return &AuthService{ + userRepo: userRepo, + tenantRepo: tenantRepo, + cfg: cfg, + } +} + +// Register creates a new user account +func (s *AuthService) Register(req domain.CreateUserRequest) (*domain.User, error) { + // Validate password strength + if len(req.Password) < s.cfg.Security.PasswordMinLength { + return nil, ErrWeakPassword + } + + // Check if email already exists + exists, err := s.userRepo.EmailExists(req.Email) + if err != nil { + return nil, err + } + if exists { + return nil, ErrEmailAlreadyExists + } + + // Hash password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + + // Create user + user := &domain.User{ + Email: req.Email, + Password: string(hashedPassword), + Name: req.Name, + } + + if err := s.userRepo.Create(user); err != nil { + return nil, err + } + + return user, nil +} + +// Login authenticates a user and returns a JWT token +func (s *AuthService) Login(req domain.LoginRequest) (*domain.LoginResponse, error) { + // Find user by email + user, err := s.userRepo.FindByEmail(req.Email) + if err != nil { + return nil, err + } + if user == nil { + return nil, ErrInvalidCredentials + } + + // Verify password + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil { + return nil, ErrInvalidCredentials + } + + // Generate JWT token + token, err := s.generateToken(user) + if err != nil { + return nil, err + } + + response := &domain.LoginResponse{ + Token: token, + User: *user, + } + + // If user has a tenant, get the subdomain + if user.TenantID != nil { + tenant, err := s.tenantRepo.FindByID(*user.TenantID) + if err == nil && tenant != nil { + response.Subdomain = &tenant.Subdomain + } + } + + return response, nil +} + +func (s *AuthService) generateToken(user *domain.User) (string, error) { + claims := jwt.MapClaims{ + "user_id": user.ID.String(), + "email": user.Email, + "role": user.Role, + "tenant_id": nil, + "exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days + } + + if user.TenantID != nil { + claims["tenant_id"] = user.TenantID.String() + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(s.cfg.JWT.Secret)) +} + +// ChangePassword changes a user's password +func (s *AuthService) ChangePassword(userID string, currentPassword, newPassword string) error { + // Validate new password strength + if len(newPassword) < s.cfg.Security.PasswordMinLength { + return ErrWeakPassword + } + + // Parse userID + uid, err := parseUUID(userID) + if err != nil { + return ErrInvalidCredentials + } + + // Find user + user, err := s.userRepo.FindByID(uid) + if err != nil { + return err + } + if user == nil { + return ErrInvalidCredentials + } + + // Verify current password + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(currentPassword)); err != nil { + return ErrInvalidCredentials + } + + // Hash new password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + return err + } + + // Update password + return s.userRepo.UpdatePassword(userID, string(hashedPassword)) +} + +func parseUUID(s string) (uuid.UUID, error) { + return uuid.Parse(s) +} diff --git a/backend/internal/service/company_service.go b/backend/internal/service/company_service.go new file mode 100644 index 0000000..d85e6d5 --- /dev/null +++ b/backend/internal/service/company_service.go @@ -0,0 +1,73 @@ +package service + +import ( + "errors" + + "aggios-app/backend/internal/domain" + "aggios-app/backend/internal/repository" + + "github.com/google/uuid" +) + +var ( + ErrCompanyNotFound = errors.New("company not found") + ErrCNPJAlreadyExists = errors.New("CNPJ already registered") +) + +// CompanyService handles company business logic +type CompanyService struct { + companyRepo *repository.CompanyRepository +} + +// NewCompanyService creates a new company service +func NewCompanyService(companyRepo *repository.CompanyRepository) *CompanyService { + return &CompanyService{ + companyRepo: companyRepo, + } +} + +// Create creates a new company +func (s *CompanyService) Create(req domain.CreateCompanyRequest, tenantID, userID uuid.UUID) (*domain.Company, error) { + // Check if CNPJ already exists for this tenant + exists, err := s.companyRepo.CNPJExists(req.CNPJ, tenantID) + if err != nil { + return nil, err + } + if exists { + return nil, ErrCNPJAlreadyExists + } + + company := &domain.Company{ + CNPJ: req.CNPJ, + RazaoSocial: req.RazaoSocial, + NomeFantasia: req.NomeFantasia, + Email: req.Email, + Telefone: req.Telefone, + Status: "active", + TenantID: tenantID, + CreatedByUserID: &userID, + } + + if err := s.companyRepo.Create(company); err != nil { + return nil, err + } + + return company, nil +} + +// GetByID retrieves a company by ID +func (s *CompanyService) GetByID(id uuid.UUID) (*domain.Company, error) { + company, err := s.companyRepo.FindByID(id) + if err != nil { + return nil, err + } + if company == nil { + return nil, ErrCompanyNotFound + } + return company, nil +} + +// ListByTenant retrieves all companies for a tenant +func (s *CompanyService) ListByTenant(tenantID uuid.UUID) ([]*domain.Company, error) { + return s.companyRepo.FindByTenantID(tenantID) +} diff --git a/backend/internal/service/tenant_service.go b/backend/internal/service/tenant_service.go new file mode 100644 index 0000000..b3d971e --- /dev/null +++ b/backend/internal/service/tenant_service.go @@ -0,0 +1,91 @@ +package service + +import ( + "database/sql" + "errors" + + "aggios-app/backend/internal/domain" + "aggios-app/backend/internal/repository" + + "github.com/google/uuid" +) + +var ( + ErrTenantNotFound = errors.New("tenant not found") +) + +// TenantService handles tenant business logic +type TenantService struct { + tenantRepo *repository.TenantRepository +} + +// NewTenantService creates a new tenant service +func NewTenantService(tenantRepo *repository.TenantRepository) *TenantService { + return &TenantService{ + tenantRepo: tenantRepo, + } +} + +// Create creates a new tenant +func (s *TenantService) Create(req domain.CreateTenantRequest) (*domain.Tenant, error) { + // Check if subdomain already exists + exists, err := s.tenantRepo.SubdomainExists(req.Subdomain) + if err != nil { + return nil, err + } + if exists { + return nil, ErrSubdomainTaken + } + + tenant := &domain.Tenant{ + Name: req.Name, + Domain: req.Domain, + Subdomain: req.Subdomain, + } + + if err := s.tenantRepo.Create(tenant); err != nil { + return nil, err + } + + return tenant, nil +} + +// GetByID retrieves a tenant by ID +func (s *TenantService) GetByID(id uuid.UUID) (*domain.Tenant, error) { + tenant, err := s.tenantRepo.FindByID(id) + if err != nil { + return nil, err + } + if tenant == nil { + return nil, ErrTenantNotFound + } + return tenant, nil +} + +// GetBySubdomain retrieves a tenant by subdomain +func (s *TenantService) GetBySubdomain(subdomain string) (*domain.Tenant, error) { + tenant, err := s.tenantRepo.FindBySubdomain(subdomain) + if err != nil { + return nil, err + } + if tenant == nil { + return nil, ErrTenantNotFound + } + return tenant, nil +} + +// ListAll retrieves all tenants +func (s *TenantService) ListAll() ([]*domain.Tenant, error) { + return s.tenantRepo.FindAll() +} + +// Delete removes a tenant by ID +func (s *TenantService) Delete(id uuid.UUID) error { + if err := s.tenantRepo.Delete(id); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrTenantNotFound + } + return err + } + return nil +} diff --git a/docker-compose.yml b/docker-compose.yml index 210a82a..ffcbff9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ services: # Traefik - Reverse Proxy traefik: - image: traefik:latest + image: traefik:v3.2 container_name: aggios-traefik restart: unless-stopped command: @@ -10,12 +10,18 @@ services: - "--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" - "--entrypoints.websecure.address=:443" + - "--log.level=DEBUG" + - "--accesslog=true" ports: - "80:80" - "443:443" - "8080:8080" # Dashboard Traefik + volumes: + - ./traefik/dynamic:/etc/traefik/dynamic:ro networks: - aggios-network @@ -41,24 +47,6 @@ services: networks: - aggios-network - # pgAdmin - PostgreSQL Web Interface - pgadmin: - image: dpage/pgadmin4:latest - container_name: aggios-pgadmin - restart: unless-stopped - ports: - - "5050:80" - environment: - PGADMIN_DEFAULT_EMAIL: admin@aggios.app - PGADMIN_DEFAULT_PASSWORD: admin123 - PGADMIN_CONFIG_SERVER_MODE: 'False' - volumes: - - pgadmin_data:/var/lib/pgadmin - depends_on: - - postgres - networks: - - aggios-network - # Redis Cache redis: image: redis:7-alpine @@ -138,7 +126,7 @@ services: # Frontend - Institucional (aggios.app) institucional: build: - context: ./front-end-aggios.app-institucional + context: ./frontend-aggios.app dockerfile: Dockerfile container_name: aggios-institucional restart: unless-stopped @@ -186,8 +174,6 @@ services: volumes: postgres_data: driver: local - pgadmin_data: - driver: local redis_data: driver: local minio_data: diff --git a/front-end-dash.aggios.app/app/(agency)/clientes/page.tsx b/front-end-dash.aggios.app/app/(agency)/clientes/page.tsx new file mode 100644 index 0000000..4b0ecca --- /dev/null +++ b/front-end-dash.aggios.app/app/(agency)/clientes/page.tsx @@ -0,0 +1,26 @@ +"use client"; + +export default function ClientesPage() { + return ( +
+
+

Clientes

+

Gerencie sua carteira de clientes

+
+ +
+
+
+ +
+

+ Módulo CRM em Desenvolvimento +

+

+ Em breve você poderá gerenciar seus clientes com recursos avançados de CRM. +

+
+
+
+ ); +} diff --git a/front-end-dash.aggios.app/app/(agency)/configuracoes/page.tsx b/front-end-dash.aggios.app/app/(agency)/configuracoes/page.tsx new file mode 100644 index 0000000..9a5c8e4 --- /dev/null +++ b/front-end-dash.aggios.app/app/(agency)/configuracoes/page.tsx @@ -0,0 +1,713 @@ +"use client"; + +import { useState, useEffect } from 'react'; +import { Tab } from '@headlessui/react'; +import { Dialog } from '@/components/ui'; +import { + BuildingOfficeIcon, + SwatchIcon, + PhotoIcon, + UserGroupIcon, + ShieldCheckIcon, + BellIcon, +} from '@heroicons/react/24/outline'; + +const tabs = [ + { name: 'Dados da Agência', icon: BuildingOfficeIcon }, + { name: 'Personalização', icon: SwatchIcon }, + { name: 'Logo e Marca', icon: PhotoIcon }, + { name: 'Equipe', icon: UserGroupIcon }, + { name: 'Segurança', icon: ShieldCheckIcon }, + { name: 'Notificações', icon: BellIcon }, +]; + +const themePresets = [ + { name: 'Laranja/Rosa', gradient: 'linear-gradient(90deg, #FF3A05, #FF0080)', colors: ['#FF3A05', '#FF0080'] }, + { name: 'Azul/Roxo', gradient: 'linear-gradient(90deg, #0066FF, #9333EA)', colors: ['#0066FF', '#9333EA'] }, + { name: 'Verde/Esmeralda', gradient: 'linear-gradient(90deg, #10B981, #059669)', colors: ['#10B981', '#059669'] }, + { name: 'Ciano/Azul', gradient: 'linear-gradient(90deg, #06B6D4, #3B82F6)', colors: ['#06B6D4', '#3B82F6'] }, + { name: 'Rosa/Roxo', gradient: 'linear-gradient(90deg, #EC4899, #A855F7)', colors: ['#EC4899', '#A855F7'] }, + { name: 'Vermelho/Laranja', gradient: 'linear-gradient(90deg, #EF4444, #F97316)', colors: ['#EF4444', '#F97316'] }, +]; + +export default function ConfiguracoesPage() { + const [selectedTab, setSelectedTab] = useState(0); + const [selectedTheme, setSelectedTheme] = useState(0); + const [customColor1, setCustomColor1] = useState('#FF3A05'); + const [customColor2, setCustomColor2] = useState('#FF0080'); + const [showSuccessDialog, setShowSuccessDialog] = useState(false); + const [successMessage, setSuccessMessage] = useState(''); + const [loading, setLoading] = useState(true); + + // Dados da agência (buscados da API) + const [agencyData, setAgencyData] = useState({ + name: '', + cnpj: '', + email: '', + phone: '', + website: '', + address: '', + city: '', + state: '', + zip: '', + razaoSocial: '', + description: '', + industry: '', + }); + + // Dados para alteração de senha + const [passwordData, setPasswordData] = useState({ + currentPassword: '', + newPassword: '', + confirmPassword: '', + }); + + // Buscar dados da agência da API + useEffect(() => { + const fetchAgencyData = async () => { + try { + setLoading(true); + const token = localStorage.getItem('token'); + const userData = localStorage.getItem('user'); + + if (!token || !userData) { + console.error('Usuário não autenticado'); + setLoading(false); + return; + } + + // Buscar dados da API + const response = await fetch('http://localhost:8080/api/agency/profile', { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + if (response.ok) { + const data = await response.json(); + setAgencyData({ + name: data.name || '', + cnpj: data.cnpj || '', + email: data.email || '', + phone: data.phone || '', + website: data.website || '', + address: data.address || '', + city: data.city || '', + state: data.state || '', + zip: data.zip || '', + razaoSocial: data.razao_social || '', + description: data.description || '', + industry: data.industry || '', + }); + } else { + console.error('Erro ao buscar dados:', response.status); + // Fallback para localStorage se API falhar + const savedData = localStorage.getItem('cadastroData'); + if (savedData) { + const data = JSON.parse(savedData); + const user = JSON.parse(userData); + setAgencyData({ + name: data.formData?.companyName || '', + cnpj: data.formData?.cnpj || '', + email: data.formData?.email || user.email || '', + phone: data.contacts?.[0]?.phone || '', + website: data.formData?.website || '', + address: `${data.cepData?.logradouro || ''}, ${data.formData?.number || ''}`, + city: data.cepData?.localidade || '', + state: data.cepData?.uf || '', + zip: data.formData?.cep || '', + razaoSocial: data.cnpjData?.razaoSocial || '', + description: data.formData?.description || '', + industry: data.formData?.industry || '', + }); + } + } + } catch (error) { + console.error('Erro ao buscar dados da agência:', error); + setSuccessMessage('Erro ao carregar dados da agência.'); + setShowSuccessDialog(true); + } finally { + setLoading(false); + } + }; + + fetchAgencyData(); + }, []); + + const applyTheme = (gradient: string) => { + document.documentElement.style.setProperty('--gradient-primary', gradient); + document.documentElement.style.setProperty('--gradient', gradient); + document.documentElement.style.setProperty('--gradient-text', gradient.replace('90deg', 'to right')); + document.documentElement.style.setProperty('--color-gradient-brand', gradient.replace('90deg', 'to right')); + }; + + const applyCustomTheme = () => { + const gradient = `linear-gradient(90deg, ${customColor1}, ${customColor2})`; + applyTheme(gradient); + }; + + const handleSaveAgency = async () => { + try { + const token = localStorage.getItem('token'); + if (!token) { + setSuccessMessage('Você precisa estar autenticado.'); + setShowSuccessDialog(true); + return; + } + + const response = await fetch('http://localhost:8080/api/agency/profile', { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: agencyData.name, + cnpj: agencyData.cnpj, + email: agencyData.email, + phone: agencyData.phone, + website: agencyData.website, + address: agencyData.address, + city: agencyData.city, + state: agencyData.state, + zip: agencyData.zip, + razao_social: agencyData.razaoSocial, + description: agencyData.description, + industry: agencyData.industry, + }), + }); + + if (response.ok) { + setSuccessMessage('Dados da agência salvos com sucesso!'); + } else { + setSuccessMessage('Erro ao salvar dados. Tente novamente.'); + } + } catch (error) { + console.error('Erro ao salvar:', error); + setSuccessMessage('Erro ao salvar dados. Verifique sua conexão.'); + } + setShowSuccessDialog(true); + }; + + const handleSaveTheme = () => { + // TODO: Integrar com API para salvar no banco + const selectedGradient = themePresets[selectedTheme].gradient; + console.log('Salvando tema:', selectedGradient); + setSuccessMessage('Tema salvo com sucesso!'); + setShowSuccessDialog(true); + }; + + const handleChangePassword = async () => { + // Validações + if (!passwordData.currentPassword) { + setSuccessMessage('Por favor, informe sua senha atual.'); + setShowSuccessDialog(true); + return; + } + if (!passwordData.newPassword || passwordData.newPassword.length < 8) { + setSuccessMessage('A nova senha deve ter pelo menos 8 caracteres.'); + setShowSuccessDialog(true); + return; + } + if (passwordData.newPassword !== passwordData.confirmPassword) { + setSuccessMessage('As senhas não coincidem.'); + setShowSuccessDialog(true); + return; + } + + try { + const token = localStorage.getItem('token'); + if (!token) { + setSuccessMessage('Você precisa estar autenticado.'); + setShowSuccessDialog(true); + return; + } + + const response = await fetch('http://localhost:8080/api/auth/change-password', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + currentPassword: passwordData.currentPassword, + newPassword: passwordData.newPassword, + }), + }); + + if (response.ok) { + setPasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' }); + setSuccessMessage('Senha alterada com sucesso!'); + } else { + const error = await response.text(); + setSuccessMessage(error || 'Erro ao alterar senha. Verifique sua senha atual.'); + } + } catch (error) { + console.error('Erro ao alterar senha:', error); + setSuccessMessage('Erro ao alterar senha. Verifique sua conexão.'); + } + setShowSuccessDialog(true); + }; + + return ( +
+ {/* Header */} +
+

+ Configurações +

+

+ Gerencie as configurações da sua agência +

+
+ + {/* Loading State */} + {loading ? ( +
+
+
+ ) : ( + <> + {/* Tabs */} + + + {tabs.map((tab) => { + const Icon = tab.icon; + return ( + + `w-full flex items-center justify-center space-x-2 rounded-lg py-2.5 text-sm font-medium leading-5 transition-all + ${selected + ? 'bg-white dark:bg-gray-900 text-gray-900 dark:text-white shadow' + : 'text-gray-600 dark:text-gray-400 hover:bg-white/[0.5] dark:hover:bg-gray-700/[0.5] hover:text-gray-900 dark:hover:text-white' + }` + } + > + + {tab.name} + + ); + })} + + + + {/* Tab 1: Dados da Agência */} + +

+ Informações da Agência +

+ +
+
+ + setAgencyData({ ...agencyData, name: e.target.value })} + className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none" + /> +
+ +
+ + setAgencyData({ ...agencyData, cnpj: e.target.value })} + className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none" + /> +
+ +
+ + setAgencyData({ ...agencyData, email: e.target.value })} + className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none" + /> +
+ +
+ + setAgencyData({ ...agencyData, phone: e.target.value })} + className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none" + /> +
+ +
+ + setAgencyData({ ...agencyData, website: e.target.value })} + className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none" + /> +
+ +
+ + setAgencyData({ ...agencyData, address: e.target.value })} + className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none" + /> +
+ +
+ + setAgencyData({ ...agencyData, city: e.target.value })} + className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none" + /> +
+ +
+ + setAgencyData({ ...agencyData, state: e.target.value })} + className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none" + /> +
+ +
+ + setAgencyData({ ...agencyData, zip: e.target.value })} + className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none" + /> +
+
+ +
+ +
+
+ + {/* Tab 2: Personalização */} + +

+ Personalização do Dashboard +

+ + {/* Temas Pré-definidos */} +
+

+ Temas Pré-definidos +

+
+ {themePresets.map((theme, idx) => ( + + ))} +
+
+ + {/* Cores Customizadas */} +
+

+ Cores Personalizadas +

+
+
+ + setCustomColor1(e.target.value)} + className="w-20 h-20 rounded-lg cursor-pointer border-2 border-gray-300 dark:border-gray-600" + /> +
+
+ + setCustomColor2(e.target.value)} + className="w-20 h-20 rounded-lg cursor-pointer border-2 border-gray-300 dark:border-gray-600" + /> +
+
+ +
+
+ +
+
+ +
+ +
+ + + {/* Tab 3: Logo e Marca */} + +

+ Logo e Identidade Visual +

+ +
+
+ +
+ +

+ Arraste e solte sua logo aqui ou clique para fazer upload +

+

+ PNG, JPG ou SVG (máx. 2MB) +

+ +
+
+ +
+ +
+ +

+ Upload do favicon (ícone da aba do navegador) +

+

+ ICO ou PNG 32x32 pixels +

+ +
+
+
+ +
+ +
+
+ + {/* Tab 4: Equipe */} + +

+ Gerenciamento de Equipe +

+ +
+ +

+ Em breve: gerenciamento completo de usuários e permissões +

+ +
+
+ + {/* Tab 5: Segurança */} + +

+ Segurança e Privacidade +

+ + {/* Alteração de Senha */} +
+

+ Alterar Senha +

+ +
+
+ + setPasswordData({ ...passwordData, currentPassword: e.target.value })} + className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none" + placeholder="Digite sua senha atual" + /> +
+ +
+ + setPasswordData({ ...passwordData, newPassword: e.target.value })} + className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none" + placeholder="Digite a nova senha (mínimo 8 caracteres)" + /> +
+ +
+ + setPasswordData({ ...passwordData, confirmPassword: e.target.value })} + className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none" + placeholder="Digite a nova senha novamente" + /> +
+ +
+ +
+
+ + {/* Recursos Futuros */} +
+

+ Recursos em Desenvolvimento +

+
+
+ + Autenticação em duas etapas (2FA) +
+
+ + Histórico de acessos +
+
+ + Dispositivos conectados +
+
+
+
+
+ + {/* Tab 6: Notificações */} + +

+ Preferências de Notificações +

+ +
+ +

+ Em breve: configuração de notificações por e-mail, push e mais +

+
+
+ + + + )} + + {/* Dialog de Sucesso */} + setShowSuccessDialog(false)} + title="Sucesso" + size="sm" + > + +

{successMessage}

+
+ + + +
+
+ ); +} diff --git a/front-end-dash.aggios.app/app/(agency)/dashboard/page.tsx b/front-end-dash.aggios.app/app/(agency)/dashboard/page.tsx new file mode 100644 index 0000000..c5c9c4d --- /dev/null +++ b/front-end-dash.aggios.app/app/(agency)/dashboard/page.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { getUser } from "@/lib/auth"; +import { + ChartBarIcon, + UserGroupIcon, + FolderIcon, + CurrencyDollarIcon, + ArrowTrendingUpIcon, + ArrowTrendingDownIcon +} from '@heroicons/react/24/outline'; + +interface StatCardProps { + title: string; + value: string | number; + icon: React.ComponentType>; + trend?: number; + color: 'blue' | 'purple' | 'gray' | 'green'; +} + +const colorClasses = { + blue: { + iconBg: 'bg-blue-50 dark:bg-blue-900/20', + iconColor: 'text-blue-600 dark:text-blue-400', + trend: 'text-blue-600 dark:text-blue-400' + }, + purple: { + iconBg: 'bg-purple-50 dark:bg-purple-900/20', + iconColor: 'text-purple-600 dark:text-purple-400', + trend: 'text-purple-600 dark:text-purple-400' + }, + gray: { + iconBg: 'bg-gray-50 dark:bg-gray-900/20', + iconColor: 'text-gray-600 dark:text-gray-400', + trend: 'text-gray-600 dark:text-gray-400' + }, + green: { + iconBg: 'bg-emerald-50 dark:bg-emerald-900/20', + iconColor: 'text-emerald-600 dark:text-emerald-400', + trend: 'text-emerald-600 dark:text-emerald-400' + } +}; + +function StatCard({ title, value, icon: Icon, trend, color }: StatCardProps) { + const colors = colorClasses[color]; + const isPositive = trend && trend > 0; + + return ( +
+
+
+

{title}

+

{value}

+ {trend !== undefined && ( +
+ {isPositive ? ( + + ) : ( + + )} + + {Math.abs(trend)}% + + vs mês anterior +
+ )} +
+
+ +
+
+
+ ); +} + +export default function DashboardPage() { + const router = useRouter(); + const [stats, setStats] = useState({ + clientes: 0, + projetos: 0, + tarefas: 0, + faturamento: 0 + }); + + useEffect(() => { + // Verificar se é SUPERADMIN e redirecionar + const user = getUser(); + if (user && user.role === 'SUPERADMIN') { + router.push('/superadmin'); + return; + } + + // Simulando carregamento de dados + setTimeout(() => { + setStats({ + clientes: 127, + projetos: 18, + tarefas: 64, + faturamento: 87500 + }); + }, 300); + }, [router]); + + return ( +
+ {/* Header */} +
+

+ Dashboard +

+

+ Bem-vindo ao seu painel de controle +

+
+ + {/* Stats Grid */} +
+ + + + +
+ + {/* Coming Soon Card */} +
+
+
+ +
+

+ Em Desenvolvimento +

+

+ Estamos construindo recursos incríveis de CRM e ERP para sua agência. + Em breve você terá acesso a análises detalhadas, gestão completa de clientes e muito mais. +

+
+ {['CRM', 'ERP', 'Projetos', 'Pagamentos', 'Documentos', 'Suporte', 'Contratos'].map((item) => ( + + {item} + + ))} +
+
+
+
+ ); +} diff --git a/front-end-dash.aggios.app/app/(agency)/layout.tsx b/front-end-dash.aggios.app/app/(agency)/layout.tsx new file mode 100644 index 0000000..cf1df21 --- /dev/null +++ b/front-end-dash.aggios.app/app/(agency)/layout.tsx @@ -0,0 +1,569 @@ +"use client"; + +import { useEffect, useState, Fragment } from 'react'; +import { useRouter } from 'next/navigation'; +import dynamic from 'next/dynamic'; +import { Menu, Transition } from '@headlessui/react'; +import { + Bars3Icon, + XMarkIcon, + MagnifyingGlassIcon, + BellIcon, + Cog6ToothIcon, + UserCircleIcon, + ArrowRightOnRectangleIcon, + ChevronDownIcon, + ChevronRightIcon, + UserGroupIcon, + BuildingOfficeIcon, + FolderIcon, + CreditCardIcon, + DocumentTextIcon, + LifebuoyIcon, + DocumentCheckIcon, + UsersIcon, + UserPlusIcon, + PhoneIcon, + FunnelIcon, + ChartBarIcon, + HomeIcon, + CubeIcon, + ShoppingCartIcon, + BanknotesIcon, + DocumentDuplicateIcon, + ShareIcon, + DocumentMagnifyingGlassIcon, + TrashIcon, + RectangleStackIcon, + CalendarIcon, + UserGroupIcon as TeamIcon, + ReceiptPercentIcon, + CreditCardIcon as PaymentIcon, + ChatBubbleLeftRightIcon, + BookOpenIcon, + ArchiveBoxIcon, + PencilSquareIcon, +} from '@heroicons/react/24/outline'; + +const ThemeToggle = dynamic(() => import('@/components/ThemeToggle'), { ssr: false }); +const ThemeTester = dynamic(() => import('@/components/ThemeTester'), { ssr: false }); + +export default function AgencyLayout({ + children, +}: { + children: React.ReactNode; +}) { + const router = useRouter(); + const [user, setUser] = useState(null); + const [agencyName, setAgencyName] = useState(''); + const [sidebarOpen, setSidebarOpen] = useState(true); + const [searchOpen, setSearchOpen] = useState(false); + const [activeSubmenu, setActiveSubmenu] = useState(null); + const [selectedClient, setSelectedClient] = useState(null); + + // Mock de clientes - no futuro virá da API + const clients = [ + { id: 1, name: 'Todos os Clientes', avatar: null }, + { id: 2, name: 'Empresa ABC Ltda', avatar: 'A' }, + { id: 3, name: 'Tech Solutions Inc', avatar: 'T' }, + { id: 4, name: 'Marketing Pro', avatar: 'M' }, + { id: 5, name: 'Design Studio', avatar: 'D' }, + ]; + + useEffect(() => { + const token = localStorage.getItem('token'); + const userData = localStorage.getItem('user'); + + if (!token || !userData) { + router.push('/login'); + return; + } + + const parsedUser = JSON.parse(userData); + setUser(parsedUser); + + if (parsedUser.role === 'SUPERADMIN') { + router.push('/superadmin'); + return; + } + + const hostname = window.location.hostname; + const subdomain = hostname.split('.')[0]; + setAgencyName(subdomain); + + // Inicializar com "Todos os Clientes" + setSelectedClient(clients[0]); + + // Atalho de teclado para abrir pesquisa (Ctrl/Cmd + K) + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'k') { + e.preventDefault(); + setSearchOpen(true); + } + if (e.key === 'Escape') { + setSearchOpen(false); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [router]); + + if (!user) { + return null; + } + + const menuItems = [ + { + icon: UserGroupIcon, + label: 'CRM', + href: '/crm', + submenu: [ + { icon: UsersIcon, label: 'Clientes', href: '/crm/clientes' }, + { icon: UserPlusIcon, label: 'Leads', href: '/crm/leads' }, + { icon: PhoneIcon, label: 'Contatos', href: '/crm/contatos' }, + { icon: FunnelIcon, label: 'Funil de Vendas', href: '/crm/funil' }, + { icon: ChartBarIcon, label: 'Relatórios', href: '/crm/relatorios' }, + ] + }, + { + icon: BuildingOfficeIcon, + label: 'ERP', + href: '/erp', + submenu: [ + { icon: HomeIcon, label: 'Dashboard', href: '/erp/dashboard' }, + { icon: CubeIcon, label: 'Estoque', href: '/erp/estoque' }, + { icon: ShoppingCartIcon, label: 'Compras', href: '/erp/compras' }, + { icon: BanknotesIcon, label: 'Vendas', href: '/erp/vendas' }, + { icon: ChartBarIcon, label: 'Financeiro', href: '/erp/financeiro' }, + ] + }, + { + icon: FolderIcon, + label: 'Projetos', + href: '/projetos', + submenu: [ + { icon: RectangleStackIcon, label: 'Todos Projetos', href: '/projetos/todos' }, + { icon: RectangleStackIcon, label: 'Kanban', href: '/projetos/kanban' }, + { icon: CalendarIcon, label: 'Calendário', href: '/projetos/calendario' }, + { icon: TeamIcon, label: 'Equipes', href: '/projetos/equipes' }, + ] + }, + { + icon: CreditCardIcon, + label: 'Pagamentos', + href: '/pagamentos', + submenu: [ + { icon: DocumentTextIcon, label: 'Faturas', href: '/pagamentos/faturas' }, + { icon: ReceiptPercentIcon, label: 'Recebimentos', href: '/pagamentos/recebimentos' }, + { icon: PaymentIcon, label: 'Assinaturas', href: '/pagamentos/assinaturas' }, + { icon: BanknotesIcon, label: 'Gateway', href: '/pagamentos/gateway' }, + ] + }, + { + icon: DocumentTextIcon, + label: 'Documentos', + href: '/documentos', + submenu: [ + { icon: FolderIcon, label: 'Meus Arquivos', href: '/documentos/arquivos' }, + { icon: ShareIcon, label: 'Compartilhados', href: '/documentos/compartilhados' }, + { icon: DocumentDuplicateIcon, label: 'Modelos', href: '/documentos/modelos' }, + { icon: TrashIcon, label: 'Lixeira', href: '/documentos/lixeira' }, + ] + }, + { + icon: LifebuoyIcon, + label: 'Suporte', + href: '/suporte', + submenu: [ + { icon: DocumentMagnifyingGlassIcon, label: 'Tickets', href: '/suporte/tickets' }, + { icon: BookOpenIcon, label: 'Base de Conhecimento', href: '/suporte/kb' }, + { icon: ChatBubbleLeftRightIcon, label: 'Chat', href: '/suporte/chat' }, + ] + }, + { + icon: DocumentCheckIcon, + label: 'Contratos', + href: '/contratos', + submenu: [ + { icon: DocumentCheckIcon, label: 'Ativos', href: '/contratos/ativos' }, + { icon: PencilSquareIcon, label: 'Rascunhos', href: '/contratos/rascunhos' }, + { icon: ArchiveBoxIcon, label: 'Arquivados', href: '/contratos/arquivados' }, + { icon: DocumentDuplicateIcon, label: 'Modelos', href: '/contratos/modelos' }, + ] + }, + ]; + + const handleLogout = () => { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + router.push('/login'); + }; + + return ( +
+ {/* Sidebar */} + + + {/* Submenu Lateral */} + + + + + {/* Main Content */} +
+ {/* Header */} +
+
+

+ Dashboard +

+ + {/* Seletor de Cliente */} + + + {selectedClient?.avatar ? ( +
+ {selectedClient.avatar} +
+ ) : ( + + )} + + {selectedClient?.name || 'Selecionar Cliente'} + + +
+ + +
+
+ + +
+
+
+ {clients.map((client) => ( + + {({ active }) => ( + + )} + + ))} +
+
+
+
+
+ +
+ {/* Pesquisa */} + + + + + {/* Notificações */} + + + + + + + +
+

Notificações

+
+
+ Nenhuma notificação no momento +
+
+
+
+ + {/* Configurações */} + + + +
+
+ + {/* Page Content */} +
+ {children} +
+
+ + {/* Modal de Pesquisa */} + +
+ +
setSearchOpen(false)} /> + + +
+ +
+
+ + + +
+ +
+
+ +

+ Digite para buscar... +

+
+
+ +
+
+
+ + ↑↓ + navegar + + + + selecionar + +
+
+
+
+
+
+
+ + + {/* Theme Tester - Temporário para desenvolvimento */} + +
+ ); +} diff --git a/front-end-dash.aggios.app/app/(auth)/LayoutWrapper.tsx b/front-end-dash.aggios.app/app/(auth)/LayoutWrapper.tsx index fef44c4..023fe35 100644 --- a/front-end-dash.aggios.app/app/(auth)/LayoutWrapper.tsx +++ b/front-end-dash.aggios.app/app/(auth)/LayoutWrapper.tsx @@ -1,12 +1,7 @@ 'use client'; -import { ThemeProvider } from '@/contexts/ThemeContext'; import { ReactNode } from 'react'; export default function AuthLayoutWrapper({ children }: { children: ReactNode }) { - return ( - - {children} - - ); + return <>{children}; } diff --git a/front-end-dash.aggios.app/app/(auth)/cadastro/page.tsx b/front-end-dash.aggios.app/app/(auth)/cadastro/page.tsx index cce919d..5bfaad1 100644 --- a/front-end-dash.aggios.app/app/(auth)/cadastro/page.tsx +++ b/front-end-dash.aggios.app/app/(auth)/cadastro/page.tsx @@ -38,19 +38,6 @@ export default function CadastroPage() { // Carregar dados do localStorage ao montar useEffect(() => { - // Mostrar dica de atalho - setTimeout(() => { - toast('💡 Dica: Pressione a tecla T para preencher dados de teste automaticamente!', { - duration: 5000, - icon: '⚡', - style: { - background: '#FFA500', - color: '#fff', - fontWeight: 'bold', - }, - }); - }, 1000); - const saved = localStorage.getItem('cadastroFormData'); if (saved) { try { @@ -94,20 +81,6 @@ export default function CadastroPage() { localStorage.setItem('cadastroFormData', JSON.stringify(dataToSave)); }, [currentStep, completedSteps, formData, contacts, password, passwordStrength, cnpjData, cepData, subdomain, domainAvailable, primaryColor, secondaryColor, logoUrl]); - // ATALHO DE TECLADO - Pressione T para preencher dados de teste - useEffect(() => { - const handleKeyPress = (e: KeyboardEvent) => { - if (e.key === 't' || e.key === 'T') { - if (confirm('🚀 PREENCHER DADOS DE TESTE?\n\nIsso vai preencher todos os campos automaticamente e ir pro Step 5.\n\nClique OK para continuar.')) { - fillTestData(); - } - } - }; - - window.addEventListener('keydown', handleKeyPress); - return () => window.removeEventListener('keydown', handleKeyPress); - }, []); - // Função para atualizar formData const updateFormData = (name: string, value: any) => { setFormData(prev => ({ ...prev, [name]: value })); @@ -323,48 +296,48 @@ export default function CadastroPage() { const handleSubmitRegistration = async () => { try { const payload = { - // Step 1 - Dados Pessoais - email: formData.email, - password: password, - fullName: formData.fullName, - newsletter: formData.newsletter || false, - - // Step 2 - Empresa - companyName: formData.companyName, + // Dados da agência + agencyName: formData.companyName, + subdomain: subdomain, cnpj: formData.cnpj, - razaoSocial: cnpjData.razaoSocial, + razaoSocial: formData.razaoSocial, description: formData.description, website: formData.website, industry: formData.industry, - teamSize: formData.teamSize, - // Step 3 - Localização e Contato + // Endereço cep: formData.cep, - state: cepData.state, - city: cepData.city, - neighborhood: cepData.neighborhood, - street: cepData.street, + state: formData.state, + city: formData.city, + neighborhood: formData.neighborhood, + street: formData.street, number: formData.number, complement: formData.complement, - contacts: contacts, - // Step 4 - Domínio - subdomain: subdomain, - - // Step 5 - Personalização - primaryColor: primaryColor, - secondaryColor: secondaryColor, - logoUrl: logoUrl, + // Admin + adminEmail: formData.email, + adminPassword: password, + adminName: formData.fullName, }; console.log('📤 Enviando cadastro completo:', payload); toast.loading('Criando sua conta...', { id: 'register' }); - const data = await apiRequest(API_ENDPOINTS.register, { + const response = await fetch('/api/admin/agencies', { method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, body: JSON.stringify(payload), }); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Erro ao criar conta'); + } + + const data = await response.json(); + console.log('📥 Resposta data:', data); // Salvar autenticação @@ -373,6 +346,7 @@ export default function CadastroPage() { id: data.id, email: data.email, name: data.name, + role: data.role || 'ADMIN_AGENCIA', tenantId: data.tenantId, company: data.company, subdomain: data.subdomain @@ -382,7 +356,7 @@ export default function CadastroPage() { // Sucesso - limpar localStorage do form localStorage.removeItem('cadastroFormData'); - toast.success('Conta criada com sucesso! Redirecionando para o painel...', { + toast.success('Conta criada com sucesso! Redirecionando para seu painel...', { id: 'register', duration: 2000, style: { @@ -391,9 +365,10 @@ export default function CadastroPage() { }, }); - // Aguardar 2 segundos e redirecionar para o painel + // Redirecionar para o painel da agência no subdomínio setTimeout(() => { - window.location.href = '/painel'; + const agencyUrl = `http://${data.subdomain}.localhost/login`; + window.location.href = agencyUrl; }, 2000); } catch (error: any) { @@ -702,35 +677,12 @@ export default function CadastroPage() { {currentStepData?.description}

- - {/* BOTÃO TESTE RÁPIDO - GRANDE E VISÍVEL */} -
{/* Formulário */}
- {/* Botão Teste Rápido GRANDE */} -
- -

- Preenche todos os campos e vai direto pro Step 5 para você só clicar em Finalizar -

-
{ e.preventDefault(); handleNext(e); }} className="space-y-6"> {currentStep === 1 && (
diff --git a/front-end-dash.aggios.app/app/(auth)/layout.tsx b/front-end-dash.aggios.app/app/(auth)/layout.tsx index 51f5229..e85e7ff 100644 --- a/front-end-dash.aggios.app/app/(auth)/layout.tsx +++ b/front-end-dash.aggios.app/app/(auth)/layout.tsx @@ -1,17 +1,13 @@ "use client"; -import { ThemeProvider } from '@/contexts/ThemeContext'; - export default function LoginLayout({ children, }: { children: React.ReactNode; }) { return ( - -
- {children} -
-
+
+ {children} +
); } diff --git a/front-end-dash.aggios.app/app/(auth)/recuperar-senha/page.tsx b/front-end-dash.aggios.app/app/(auth)/recuperar-senha/page.tsx index 54851fd..fba2455 100644 --- a/front-end-dash.aggios.app/app/(auth)/recuperar-senha/page.tsx +++ b/front-end-dash.aggios.app/app/(auth)/recuperar-senha/page.tsx @@ -77,7 +77,7 @@ export default function RecuperarSenhaPage() {
{/* Logo mobile */}
-
+

aggios

@@ -86,10 +86,10 @@ export default function RecuperarSenhaPage() { <> {/* Header */}
-

- Recuperar senha +

+ Recuperar Senha

-

+

Digite seu email e enviaremos um link para redefinir sua senha

@@ -136,15 +136,15 @@ export default function RecuperarSenhaPage() {
-

+

Email enviado!

-

+

Enviamos um link de recuperação para:

-

+

{email}

@@ -152,10 +152,10 @@ export default function RecuperarSenhaPage() {
-

+

Verifique sua caixa de entrada

-

+

Clique no link que enviamos para redefinir sua senha. Se não receber em alguns minutos, verifique sua pasta de spam.

@@ -185,12 +185,12 @@ export default function RecuperarSenhaPage() {
{/* Lado Direito - Branding */} -
+
{/* Logo */}
-

+

aggios

diff --git a/front-end-dash.aggios.app/app/LayoutWrapper.tsx b/front-end-dash.aggios.app/app/LayoutWrapper.tsx index 9534d1e..a80cbb2 100644 --- a/front-end-dash.aggios.app/app/LayoutWrapper.tsx +++ b/front-end-dash.aggios.app/app/LayoutWrapper.tsx @@ -1,12 +1,7 @@ 'use client'; -import { ThemeProvider } from '@/contexts/ThemeContext'; import { ReactNode } from 'react'; export default function LayoutWrapper({ children }: { children: ReactNode }) { - return ( - - {children} - - ); + return <>{children}; } diff --git a/front-end-dash.aggios.app/app/api/admin/agencies/[id]/route.ts b/front-end-dash.aggios.app/app/api/admin/agencies/[id]/route.ts new file mode 100644 index 0000000..78ee039 --- /dev/null +++ b/front-end-dash.aggios.app/app/api/admin/agencies/[id]/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const BACKEND_BASE_URL = 'http://aggios-backend:8080'; + +export async function GET(_request: NextRequest, context: { params: Promise<{ id: string }> }) { + try { + const { id } = await context.params; + const response = await fetch(`${BACKEND_BASE_URL}/api/admin/agencies/${id}`, { + method: 'GET', + }); + + const contentType = response.headers.get('content-type'); + const isJSON = contentType && contentType.includes('application/json'); + const payload = isJSON ? await response.json() : await response.text(); + + if (!response.ok) { + const errorBody = typeof payload === 'string' ? { error: payload } : payload; + return NextResponse.json(errorBody, { status: response.status }); + } + + return NextResponse.json(payload, { status: response.status }); + } catch (error) { + console.error('Agency detail proxy error:', error); + return NextResponse.json({ error: 'Erro ao buscar detalhes da agência' }, { status: 500 }); + } +} + +export async function DELETE(_request: NextRequest, context: { params: Promise<{ id: string }> }) { + try { + const { id } = await context.params; + const response = await fetch(`${BACKEND_BASE_URL}/api/admin/agencies/${id}`, { + method: 'DELETE', + }); + + if (!response.ok && response.status !== 204) { + const payload = await response.json().catch(() => ({ error: 'Erro ao excluir agência' })); + return NextResponse.json(payload, { status: response.status }); + } + + return new NextResponse(null, { status: response.status }); + } catch (error) { + console.error('Agency delete proxy error:', error); + return NextResponse.json({ error: 'Erro ao excluir agência' }, { status: 500 }); + } +} diff --git a/front-end-dash.aggios.app/app/api/admin/agencies/route.ts b/front-end-dash.aggios.app/app/api/admin/agencies/route.ts new file mode 100644 index 0000000..5b65b11 --- /dev/null +++ b/front-end-dash.aggios.app/app/api/admin/agencies/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(request: NextRequest) { + try { + const response = await fetch('http://aggios-backend:8080/api/admin/agencies', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + const data = await response.json(); + + if (!response.ok) { + return NextResponse.json(data, { status: response.status }); + } + + return NextResponse.json(data); + } catch (error) { + console.error('Agencies list error:', error); + return NextResponse.json( + { error: 'Erro ao buscar agências' }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + const response = await fetch('http://aggios-backend:8080/api/admin/agencies/register', { + 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('Agency registration error:', error); + return NextResponse.json( + { error: 'Erro ao registrar agência' }, + { status: 500 } + ); + } +} diff --git a/front-end-dash.aggios.app/app/api/auth/login/route.ts b/front-end-dash.aggios.app/app/api/auth/login/route.ts new file mode 100644 index 0000000..6ea82a6 --- /dev/null +++ b/front-end-dash.aggios.app/app/api/auth/login/route.ts @@ -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 } + ); + } +} diff --git a/front-end-dash.aggios.app/app/globals.css b/front-end-dash.aggios.app/app/globals.css index 6a094f0..74eb1f1 100644 --- a/front-end-dash.aggios.app/app/globals.css +++ b/front-end-dash.aggios.app/app/globals.css @@ -1,94 +1,165 @@ +@config "../tailwind.config.js"; + @import "tailwindcss"; -@import "remixicon/fonts/remixicon.css"; +@import "./tokens.css"; + +@custom-variant dark (&:is(.dark *)); :root { - /* Cores do Design System Aggios */ - --primary: #FF3A05; - --secondary: #FF0080; - --background: #FDFDFC; - --foreground: #000000; - --text-secondary: #7D7D7D; - --border: #E5E5E5; - --white: #FFFFFF; + 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); +} - /* Gradiente */ - --gradient: linear-gradient(90deg, #FF3A05, #FF0080); - --gradient-text: linear-gradient(to right, #FF3A05, #FF0080); +html.dark { + color-scheme: dark; +} - /* Espaçamentos */ - --space-xs: 4px; - --space-sm: 8px; - --space-md: 16px; - --space-lg: 24px; - --space-xl: 32px; - --space-2xl: 48px; +@layer base { + * { + font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif; + } + + 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-500); + color: var(--color-text-inverse); + } + + .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-text-secondary: var(--text-secondary); + --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); - --font-sans: var(--font-inter); - --font-heading: var(--font-open-sans); - --font-mono: var(--font-fira-code); + --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); } -body { - background: var(--background); - color: var(--foreground); - font-family: var(--font-sans), Arial, Helvetica, sans-serif; - line-height: 1.5; +.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); } -/* Estilos base dos inputs */ -input, -select, -textarea { - font-size: 14px; - box-shadow: none !important; -} +@layer base { + * { + @apply border-border outline-ring/50; + } -input:focus, -select:focus, -textarea:focus { - box-shadow: none !important; - outline: none !important; -} - -/* Focus visible para acessibilidade */ -*:focus-visible { - outline: 2px solid var(--primary); - outline-offset: 2px; -} - -/* Hero section gradient text */ -.gradient-text { - background: var(--gradient-text); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; -} - -/* Hover gradient text */ -.hover\:gradient-text:hover { - background: var(--gradient-text); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; -} - -/* Group hover para remover gradiente e usar cor sólida */ -.group:hover .group-hover\:text-white { - background: none !important; - -webkit-background-clip: unset !important; - -webkit-text-fill-color: unset !important; - background-clip: unset !important; - color: white !important; -} - -/* Smooth scroll */ -html { - scroll-behavior: smooth; + body { + @apply bg-background text-foreground; + } } \ No newline at end of file diff --git a/front-end-dash.aggios.app/app/layout.tsx b/front-end-dash.aggios.app/app/layout.tsx index 58fb256..08d0c54 100644 --- a/front-end-dash.aggios.app/app/layout.tsx +++ b/front-end-dash.aggios.app/app/layout.tsx @@ -2,17 +2,18 @@ import type { Metadata } from "next"; import { Inter, Open_Sans, Fira_Code } from "next/font/google"; import "./globals.css"; import LayoutWrapper from "./LayoutWrapper"; +import { ThemeProvider } from "next-themes"; const inter = Inter({ variable: "--font-inter", subsets: ["latin"], - weight: ["400", "500", "600"], + weight: ["400", "500", "600", "700"], }); const openSans = Open_Sans({ variable: "--font-open-sans", subsets: ["latin"], - weight: ["700"], + weight: ["600", "700"], }); const firaCode = Fira_Code({ @@ -32,31 +33,16 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + -