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 ( +
Gerencie sua carteira de clientes
++ Em breve você poderá gerenciar seus clientes com recursos avançados de CRM. +
++ Gerencie as configurações da sua agência +
++ 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 +
+ ++ Em breve: gerenciamento completo de usuários e permissões +
+ ++ Em breve: configuração de notificações por e-mail, push e mais +
+{title}
+{value}
+ {trend !== undefined && ( ++ Bem-vindo ao seu painel de controle +
++ 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. +
++ Digite para buscar... +
+- Preenche todos os campos e vai direto pro Step 5 para você só clicar em Finalizar -
-