diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..a260222 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,14 @@ +{ + "permissions": { + "allow": [ + "Bash(docker-compose up:*)", + "Bash(docker-compose ps:*)", + "Bash(grep:*)", + "Bash(cat:*)", + "Bash(docker logs:*)", + "Bash(docker exec:*)", + "Bash(npx tsc:*)", + "Bash(docker-compose restart:*)" + ] + } +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 9e26dfe..ee30068 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1 +1,36 @@ -{} \ No newline at end of file +{ + // ============================================ + // CONFIGURAÇÕES TAILWIND CSS + // ============================================ + + "tailwindCSS.validate": false, // DESATIVA validação para remover avisos chatos + "tailwindCSS.showPixelEquivalents": false, + + // ⚠️ ATENÇÃO: AVISOS "suggestCanonicalClasses" SÃO BUGS DO PLUGIN + // O Tailwind CSS IntelliSense está bugado e sugere sintaxe ERRADA. + // + // ✅ Sintaxe CORRETA (Tailwind v4): + // - [var(--brand-color)] ← Use isso! + // - bg-gradient-to-r ← Use isso! + // + // ❌ Sintaxe ERRADA (sugestão bugada): + // - (--brand-color) ← NÃO funciona! + // - bg-linear-to-r ← NÃO funciona! + // + // Por isso desativamos a validação acima (tailwindCSS.validate: false) + + // ============================================ + // CONFIGURAÇÕES CSS + // ============================================ + + "css.validate": true, + "css.lint.unknownAtRules": "ignore", + + // ============================================ + // MELHORIAS NO EDITOR + // ============================================ + + "editor.quickSuggestions": { + "strings": true + } +} diff --git a/1. docs/mind-projeto-simples.md b/1. docs/mind-projeto-simples.md new file mode 100644 index 0000000..573aebb --- /dev/null +++ b/1. docs/mind-projeto-simples.md @@ -0,0 +1,174 @@ +# Arquitetura Multi-tenant - Modelo de Negócio Aggios + +## Visão Geral da Plataforma + +A plataforma Aggios utiliza uma arquitetura multi-tenant em três camadas principais: + +``` +┌─────────────────────────────────────────────────┐ +│ aggios.app (Site Institucional) │ +│ - Marketing │ +│ - Cadastro de novas agências │ +└─────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────┐ +│ dash.aggios.app (SuperAdmin) │ +│ - Você (dono da plataforma) │ +│ - Gerencia TODAS as agências │ +│ - Vê analytics globais │ +└─────────────────────────────────────────────────┘ + │ + ┌───────────┴───────────┐ + ▼ ▼ +┌──────────────────┐ ┌──────────────────┐ +│ idealpages. │ │ outraagencia. │ +│ aggios.app │ │ aggios.app │ +├──────────────────┤ ├──────────────────┤ +│ Painel da │ │ Painel da │ +│ IdeaPages │ │ Outra Agência │ +│ │ │ │ +│ • CRM │ │ • CRM │ +│ • ERP │ │ • ERP │ +│ • Projetos │ │ • Projetos │ +│ • White Label │ │ • White Label │ +│ (seu logo) │ │ (logo deles) │ +└──────────────────┘ └──────────────────┘ + │ │ + ▼ ▼ + Clientes da Clientes da + IdeaPages Outra Agência +``` + +## Como Funciona na Prática + +### 1. Sua Agência (Exemplo: IdeaPages) +- **URL**: `idealpages.aggios.app` +- **White Label**: Logo e cores da IdeaPages +- **Clientes**: Cadastrados DENTRO da agência IdeaPages +- **Isolamento**: Cada cliente é isolado por tenant_id (multi-tenant) + +### 2. Quando um Cliente Precisa do CRM + +**Você SEMPRE manda a URL da sua agência**, não aggios.app! + +- Cliente cria conta em `idealpages.aggios.app` +- Cliente acessa `idealpages.aggios.app` com login próprio +- Cliente vê **SEU logo** (IdeaPages) +- Cliente vê **SEU white label** +- Cliente só vê os dados DELE (isolamento por tenant) + +### 3. Estrutura de Clientes + +``` +IdeaPages (você - agência) +├── Cliente 1 (Empresa ABC) +│ ├── Vê: Logo IdeaPages +│ ├── Acessa: idealpages.aggios.app +│ └── Usa: CRM, ERP, Projetos (dados isolados) +│ +├── Cliente 2 (Tech Solutions) +│ ├── Vê: Logo IdeaPages +│ ├── Acessa: idealpages.aggios.app +│ └── Usa: CRM, ERP, Projetos (dados isolados) +│ +└── Cliente 3 (Marketing Pro) + ├── Vê: Logo IdeaPages + ├── Acessa: idealpages.aggios.app + └── Usa: CRM, ERP, Projetos (dados isolados) +``` + +## Benefícios para a Agência + +✅ **White Label Completo**: Cliente vê sua marca, não "Aggios" +✅ **Controle Total**: Você gerencia todos os seus clientes +✅ **Isolamento de Dados**: Cada cliente só vê os próprios dados +✅ **Escalável**: Adicione quantos clientes quiser na mesma agência +✅ **Identidade Visual**: Logo e cores personalizadas por agência + +## Fluxo de Trabalho + +1. **Agência se cadastra** → Cria subdomínio (ex: idealpages.aggios.app) +2. **Agência personaliza** → Upload de logo, cores, identidade visual +3. **Agência adiciona clientes** → Cada cliente recebe credenciais +4. **Cliente acessa** → idealpages.aggios.app (vê marca da agência) +5. **Cliente usa módulos** → CRM, ERP, Projetos (dados isolados) + +## Resposta Direta + +**Pergunta**: "Cliente precisa do CRM, mando aggios.app ou idealpages.aggios.app?" + +**Resposta**: **`idealpages.aggios.app`** ✅ + +O cliente SEMPRE acessa o painel da sua agência, onde verá sua marca e terá acesso aos módulos que você liberar. + +--- + +## Sistema de Links de Cadastro Personalizados + +### Visão Geral + +Sistema que permite ao SuperAdmin criar links de cadastro customizados, escolhendo: +- **Campos do formulário**: Quais informações coletar +- **Módulos habilitados**: Quais funcionalidades o cliente terá acesso +- **Branding**: Logo e cores personalizadas + +### Fluxo de Uso + +1. **SuperAdmin** acessa `dash.aggios.app/superadmin/signup-templates` +2. **Cria template** selecionando: + - Campos: email, senha, subdomínio, CNPJ, telefone, etc. + - Módulos: CRM, ERP, PROJECTS, FINANCIAL, etc. + - Slug: URL amigável (ex: `crm-rapido`) +3. **Compartilha link**: `aggios.app/cadastro/crm-rapido` +4. **Cliente acessa** e vê formulário personalizado +5. **Após cadastro**, tenant criado com módulos específicos + +### Exemplo Real: DH Projects + +``` +Template: "CRM Rápido" +Slug: crm-rapido +Campos: email, senha, subdomínio, nome da empresa +Módulos: CRM + +Link gerado: aggios.app/cadastro/crm-rapido + +Cliente preenche: +- Email: contato@dhprojects.com +- Senha: ******** +- Subdomínio: dhprojects +- Empresa: DH Projects + +Resultado: +✅ Tenant criado: dhprojects.aggios.app +✅ Módulo CRM habilitado +✅ Outros módulos desabilitados +``` + +### Estrutura Técnica + +**Backend:** +- Tabela: `signup_templates` +- Repository: `SignupTemplateRepository` +- Handlers: `/api/admin/signup-templates` (CRUD) +- Handler público: `/api/signup-templates/slug/{slug}` (renderiza form) + +**Frontend:** +- Gerenciamento: `dash.aggios.app/superadmin/signup-templates` +- Cadastro público: `aggios.app/cadastro/{slug}` + +**Campos Disponíveis:** +- email, password, subdomain (obrigatórios) +- company_name, cnpj, phone, address, city, state, zipcode (opcionais) + +**Módulos Disponíveis:** +- CRM, ERP, PROJECTS, FINANCIAL, INVENTORY, HR + +### Benefícios + +✅ Cadastro rápido para clientes específicos +✅ Coleta apenas informações necessárias +✅ Habilita somente módulos contratados +✅ Reduz fricção no onboarding +✅ Personalização por caso de uso diff --git a/1. docs/nova-interface.md b/1. docs/nova-interface.md new file mode 100644 index 0000000..517720f --- /dev/null +++ b/1. docs/nova-interface.md @@ -0,0 +1,149 @@ +# System Instruction: Arquitetura de Layout com Sidebar Expansível + +**Role:** Senior React Developer & UI Specialist +**Tech Stack:** React, Tailwind CSS (Sem bibliotecas de ícones ou fontes externas). + +**Objetivo:** +Implementar um sistema de layout "Dashboard" composto por um **Menu Lateral (Sidebar)** que expande e colapsa suavemente e uma área de conteúdo principal. + +**Requisitos Críticos de Animação:** +1. A transição de largura da sidebar deve ser suave (transition-all duration-300). +2. O texto dos botões **não deve quebrar** ou desaparecer bruscamente. Use a técnica de transição de `max-width` e `opacity` para que o texto deslize suavemente para fora. +3. Não utilize bibliotecas de animação (Framer Motion, etc), apenas Tailwind CSS puro. + +--- + +## 1. Componente: `DashboardLayout.tsx` (Container Principal) + +Este componente deve gerenciar o estado global do menu (aberto/fechado) para evitar "prop drilling" desnecessário. + +```tsx +import React, { useState } from 'react'; +import { SidebarRail } from './SidebarRail'; + +interface DashboardLayoutProps { + children: React.ReactNode; +} + +export const DashboardLayout: React.FC = ({ children }) => { + // Estado centralizado do layout + const [isExpanded, setIsExpanded] = useState(true); + const [activeTab, setActiveTab] = useState('home'); + + return ( +
+ {/* Sidebar controla seu próprio estado visual via props */} + setIsExpanded(!isExpanded)} + /> + + {/* Área de Conteúdo (Children) */} +
+ {children} +
+
+ ); +}; +``` + +## 2. Componente: `SidebarRail.tsx` (Lógica de Animação) + +Aqui reside a lógica visual. Substitua os ícones por `Icon` ou SVGs genéricos para manter o código agnóstico. + +**Pontos de atenção no código abaixo:** +* `w-[220px]` vs `w-[72px]`: Define a largura física. +* `max-w-[150px]` vs `max-w-0`: Define a animação do texto. +* `whitespace-nowrap`: Impede que o texto pule de linha enquanto fecha. + +```tsx +import React from 'react'; + +interface SidebarRailProps { + activeTab: string; + onTabChange: (tab: string) => void; + isExpanded: boolean; + onToggle: () => void; +} + +export const SidebarRail: React.FC = ({ activeTab, onTabChange, isExpanded, onToggle }) => { + return ( +
+ {/* Header / Toggle */} +
+
+ Logo +
+ + {/* Título com animação de opacidade e largura */} +
+ App Name +
+
+ + {/* Navegação */} +
+ onTabChange('home')} + isExpanded={isExpanded} + /> + onTabChange('settings')} + isExpanded={isExpanded} + /> +
+ + {/* Footer / Toggle Button */} +
+ +
+
+ ); +}; + +// Subcomponente do Botão (Essencial para a animação do texto) +const RailButton = ({ label, active, onClick, isExpanded }: any) => ( + +); +``` \ No newline at end of file diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 6c981d6..17b3f00 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -7,6 +7,7 @@ import ( "net/http" _ "github.com/lib/pq" + "github.com/gorilla/mux" "aggios-app/backend/internal/api/handlers" "aggios-app/backend/internal/api/middleware" @@ -53,6 +54,8 @@ func main() { userRepo := repository.NewUserRepository(db) tenantRepo := repository.NewTenantRepository(db) companyRepo := repository.NewCompanyRepository(db) + signupTemplateRepo := repository.NewSignupTemplateRepository(db) + agencyTemplateRepo := repository.NewAgencyTemplateRepository(db) // Initialize services authService := service.NewAuthService(userRepo, tenantRepo, cfg) @@ -67,6 +70,14 @@ func main() { agencyHandler := handlers.NewAgencyRegistrationHandler(agencyService, cfg) tenantHandler := handlers.NewTenantHandler(tenantService) companyHandler := handlers.NewCompanyHandler(companyService) + signupTemplateHandler := handlers.NewSignupTemplateHandler(signupTemplateRepo, userRepo, tenantRepo, agencyService) + agencyTemplateHandler := handlers.NewAgencyTemplateHandler(agencyTemplateRepo, agencyService, userRepo, tenantRepo) + + // Initialize upload handler + uploadHandler, err := handlers.NewUploadHandler(cfg) + if err != nil { + log.Fatalf("❌ Erro ao inicializar upload handler: %v", err) + } // Create middleware chain tenantDetector := middleware.TenantDetector(tenantRepo) @@ -76,44 +87,95 @@ func main() { authMiddleware := middleware.Auth(cfg) // Setup routes - mux := http.NewServeMux() + router := mux.NewRouter() - // Health check (no auth) - mux.HandleFunc("/health", healthHandler.Check) - mux.HandleFunc("/api/health", healthHandler.Check) + // Serve static files (uploads) + fs := http.FileServer(http.Dir("./uploads")) + router.PathPrefix("/uploads/").Handler(http.StripPrefix("/uploads", fs)) - // Auth routes (public with rate limiting) - mux.HandleFunc("/api/auth/login", authHandler.Login) + // ==================== PUBLIC ROUTES ==================== + + // Health check + router.HandleFunc("/health", healthHandler.Check) + router.HandleFunc("/api/health", healthHandler.Check) - // Protected auth routes - mux.Handle("/api/auth/change-password", authMiddleware(http.HandlerFunc(authHandler.ChangePassword))) + // Auth + router.HandleFunc("/api/auth/login", authHandler.Login) + router.HandleFunc("/api/auth/register", agencyHandler.PublicRegister).Methods("POST") + + // Public agency template registration (for creating new agencies) + router.HandleFunc("/api/agency-templates", agencyTemplateHandler.GetTemplateBySlug).Methods("GET") + router.HandleFunc("/api/agency-signup/register", agencyTemplateHandler.PublicRegisterAgency).Methods("POST") + + // Public client signup via templates + router.HandleFunc("/api/signup-templates/slug/{slug}", signupTemplateHandler.GetTemplateBySlug).Methods("GET") + router.HandleFunc("/api/signup/register", signupTemplateHandler.PublicRegister).Methods("POST") + + // File upload (public for signup, will also work with auth) + router.HandleFunc("/api/upload", uploadHandler.Upload).Methods("POST") - // 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) - mux.HandleFunc("/api/tenant/check", tenantHandler.CheckExists) + // Tenant check (public) + router.HandleFunc("/api/tenant/check", tenantHandler.CheckExists).Methods("GET") - // Client registration (ADMIN_AGENCIA only - requires auth) - mux.Handle("/api/agencies/clients/register", authMiddleware(http.HandlerFunc(agencyHandler.RegisterClient))) + // Hash generator (dev only - remove in production) + router.HandleFunc("/api/hash", handlers.GenerateHash).Methods("POST") + + // ==================== PROTECTED ROUTES ==================== + + // Auth (protected) + router.Handle("/api/auth/change-password", authMiddleware(http.HandlerFunc(authHandler.ChangePassword))).Methods("POST") + + // SUPERADMIN: Agency management + router.HandleFunc("/api/admin/agencies/register", agencyHandler.RegisterAgency).Methods("POST") + router.HandleFunc("/api/admin/agencies", tenantHandler.ListAll).Methods("GET") + router.HandleFunc("/api/admin/agencies/{id}", agencyHandler.HandleAgency).Methods("GET", "PATCH", "DELETE") + + // SUPERADMIN: Agency template management + router.Handle("/api/admin/agency-templates", authMiddleware(http.HandlerFunc(agencyTemplateHandler.ListTemplates))).Methods("GET") + router.Handle("/api/admin/agency-templates", authMiddleware(http.HandlerFunc(agencyTemplateHandler.CreateTemplate))).Methods("POST") + + // SUPERADMIN: Client signup template management + router.Handle("/api/admin/signup-templates", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + signupTemplateHandler.ListTemplates(w, r) + } else if r.Method == http.MethodPost { + signupTemplateHandler.CreateTemplate(w, r) + } + }))).Methods("GET", "POST") + + router.Handle("/api/admin/signup-templates/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + signupTemplateHandler.GetTemplateByID(w, r) + case http.MethodPut, http.MethodPatch: + signupTemplateHandler.UpdateTemplate(w, r) + case http.MethodDelete: + signupTemplateHandler.DeleteTemplate(w, r) + } + }))).Methods("GET", "PUT", "PATCH", "DELETE") + + // ADMIN_AGENCIA: Client registration + router.Handle("/api/agencies/clients/register", authMiddleware(http.HandlerFunc(agencyHandler.RegisterClient))).Methods("POST") // Agency profile routes (protected) - mux.Handle("/api/agency/profile", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { + router.Handle("/api/agency/profile", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: agencyProfileHandler.GetProfile(w, r) - } else if r.Method == http.MethodPut || r.Method == http.MethodPatch { + case http.MethodPut, http.MethodPatch: agencyProfileHandler.UpdateProfile(w, r) - } else { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } - }))) + }))).Methods("GET", "PUT", "PATCH") - // Protected routes (require authentication) - mux.Handle("/api/companies", authMiddleware(http.HandlerFunc(companyHandler.List))) - mux.Handle("/api/companies/create", authMiddleware(http.HandlerFunc(companyHandler.Create))) + // Agency logo upload (protected) + router.Handle("/api/agency/logo", authMiddleware(http.HandlerFunc(agencyProfileHandler.UploadLogo))).Methods("POST") - // Apply global middlewares: tenant -> cors -> security -> rateLimit -> mux - handler := tenantDetector(corsMiddleware(securityMiddleware(rateLimitMiddleware(mux)))) + // Company routes (protected) + router.Handle("/api/companies", authMiddleware(http.HandlerFunc(companyHandler.List))).Methods("GET") + router.Handle("/api/companies/create", authMiddleware(http.HandlerFunc(companyHandler.Create))).Methods("POST") + + // Apply global middlewares: tenant -> cors -> security -> rateLimit -> router + handler := tenantDetector(corsMiddleware(securityMiddleware(rateLimitMiddleware(router)))) // Start server addr := fmt.Sprintf(":%s", cfg.Server.Port) diff --git a/backend/go.mod b/backend/go.mod index 69b2ba9..bb45a75 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -6,5 +6,6 @@ require ( github.com/golang-jwt/jwt/v5 v5.2.0 github.com/google/uuid v1.6.0 github.com/lib/pq v1.10.9 + github.com/minio/minio-go/v7 v7.0.63 golang.org/x/crypto v0.27.0 ) diff --git a/backend/internal/api/handlers/agency.go b/backend/internal/api/handlers/agency.go index 793c777..6e2e9db 100644 --- a/backend/internal/api/handlers/agency.go +++ b/backend/internal/api/handlers/agency.go @@ -5,7 +5,6 @@ import ( "errors" "log" "net/http" - "strings" "time" "aggios-app/backend/internal/config" @@ -13,6 +12,7 @@ import ( "aggios-app/backend/internal/service" "github.com/golang-jwt/jwt/v5" + "github.com/gorilla/mux" "github.com/google/uuid" ) @@ -45,6 +45,8 @@ func (h *AgencyRegistrationHandler) RegisterAgency(w http.ResponseWriter, r *htt } log.Printf("📥 Registering agency: %s (subdomain: %s)", req.AgencyName, req.Subdomain) + log.Printf("📊 Payload received: RazaoSocial=%s, Phone=%s, City=%s, State=%s, Neighborhood=%s, TeamSize=%s, PrimaryColor=%s, SecondaryColor=%s", + req.RazaoSocial, req.Phone, req.City, req.State, req.Neighborhood, req.TeamSize, req.PrimaryColor, req.SecondaryColor) tenant, admin, err := h.agencyService.RegisterAgency(req) if err != nil { @@ -104,6 +106,112 @@ func (h *AgencyRegistrationHandler) RegisterAgency(w http.ResponseWriter, r *htt json.NewEncoder(w).Encode(response) } +// PublicRegister handles public agency registration +func (h *AgencyRegistrationHandler) PublicRegister(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req domain.PublicRegisterAgencyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Printf("❌ Error decoding request: %v", err) + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + log.Printf("📥 Public Registering agency: %s (subdomain: %s)", req.CompanyName, req.Subdomain) + log.Printf("📦 Full Payload: %+v", req) + + // Map to internal request + phone := "" + if len(req.Contacts) > 0 { + phone = req.Contacts[0].Whatsapp + } + + internalReq := domain.RegisterAgencyRequest{ + AgencyName: req.CompanyName, + Subdomain: req.Subdomain, + CNPJ: req.CNPJ, + RazaoSocial: req.RazaoSocial, + Description: req.Description, + Website: req.Website, + Industry: req.Industry, + Phone: phone, + TeamSize: req.TeamSize, + CEP: req.CEP, + State: req.State, + City: req.City, + Neighborhood: req.Neighborhood, + Street: req.Street, + Number: req.Number, + Complement: req.Complement, + PrimaryColor: req.PrimaryColor, + SecondaryColor: req.SecondaryColor, + LogoURL: req.LogoURL, + AdminEmail: req.Email, + AdminPassword: req.Password, + AdminName: req.FullName, + } + + tenant, admin, err := h.agencyService.RegisterAgency(internalReq) + if err != nil { + log.Printf("❌ Error registering agency: %v", err) + switch err { + case service.ErrSubdomainTaken: + http.Error(w, err.Error(), http.StatusConflict) + case service.ErrEmailAlreadyExists: + http.Error(w, err.Error(), http.StatusConflict) + case service.ErrWeakPassword: + http.Error(w, err.Error(), http.StatusBadRequest) + default: + http.Error(w, "Internal server error", http.StatusInternalServerError) + } + return + } + + log.Printf("✅ Agency created: %s (ID: %s)", tenant.Name, tenant.ID) + + // Generate JWT token for the new admin + claims := jwt.MapClaims{ + "user_id": admin.ID.String(), + "email": admin.Email, + "role": admin.Role, + "tenant_id": tenant.ID.String(), + "exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days + "iat": time.Now().Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString([]byte(h.cfg.JWT.Secret)) + if err != nil { + http.Error(w, "Failed to generate token", http.StatusInternalServerError) + return + } + + protocol := "http://" + if h.cfg.App.Environment == "production" { + protocol = "https://" + } + + response := map[string]interface{}{ + "token": tokenString, + "id": admin.ID, + "email": admin.Email, + "name": admin.Name, + "role": admin.Role, + "tenantId": tenant.ID, + "company": tenant.Name, + "subdomain": tenant.Subdomain, + "message": "Agency registered successfully", + "access_url": protocol + tenant.Domain, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(response) +} + // RegisterClient handles client registration (ADMIN_AGENCIA only) func (h *AgencyRegistrationHandler) RegisterClient(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -147,9 +255,10 @@ func (h *AgencyRegistrationHandler) HandleAgency(w http.ResponseWriter, r *http. return } - agencyID := strings.TrimPrefix(r.URL.Path, "/api/admin/agencies/") - if agencyID == "" || agencyID == r.URL.Path { - http.NotFound(w, r) + vars := mux.Vars(r) + agencyID := vars["id"] + if agencyID == "" { + http.Error(w, "Missing agency ID", http.StatusBadRequest) return } @@ -174,6 +283,27 @@ func (h *AgencyRegistrationHandler) HandleAgency(w http.ResponseWriter, r *http. w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(details) + case http.MethodPatch: + var updateData map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&updateData); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if isActive, ok := updateData["is_active"].(bool); ok { + if err := h.agencyService.UpdateAgencyStatus(id, isActive); err != nil { + if errors.Is(err, service.ErrTenantNotFound) { + http.Error(w, "Agency not found", http.StatusNotFound) + return + } + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"message": "Status updated"}) + case http.MethodDelete: if err := h.agencyService.DeleteAgency(id); err != nil { if errors.Is(err, service.ErrTenantNotFound) { diff --git a/backend/internal/api/handlers/agency_logo.go b/backend/internal/api/handlers/agency_logo.go new file mode 100644 index 0000000..dcaba57 --- /dev/null +++ b/backend/internal/api/handlers/agency_logo.go @@ -0,0 +1,225 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "path/filepath" + "time" + + "aggios-app/backend/internal/api/middleware" + + "github.com/google/uuid" + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +// UploadLogo handles logo file uploads +func (h *AgencyHandler) UploadLogo(w http.ResponseWriter, r *http.Request) { + // Only accept POST + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + log.Printf("Logo upload request received from tenant") + + // Get tenant ID from context + tenantIDVal := r.Context().Value(middleware.TenantIDKey) + if tenantIDVal == nil { + log.Printf("No tenant ID in context") + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Try to get as uuid.UUID first, if that fails try string and parse + var tenantID uuid.UUID + var ok bool + + tenantID, ok = tenantIDVal.(uuid.UUID) + if !ok { + // Try as string + tenantIDStr, isString := tenantIDVal.(string) + if !isString { + log.Printf("Invalid tenant ID type: %T", tenantIDVal) + http.Error(w, "Invalid tenant ID", http.StatusBadRequest) + return + } + + var err error + tenantID, err = uuid.Parse(tenantIDStr) + if err != nil { + log.Printf("Failed to parse tenant ID: %v", err) + http.Error(w, "Invalid tenant ID format", http.StatusBadRequest) + return + } + } + + log.Printf("Processing logo upload for tenant: %s", tenantID) + + // Parse multipart form (2MB max) + const maxLogoSize = 2 * 1024 * 1024 + if err := r.ParseMultipartForm(maxLogoSize); err != nil { + http.Error(w, "File too large", http.StatusBadRequest) + return + } + + file, header, err := r.FormFile("logo") + if err != nil { + http.Error(w, "Failed to read file", http.StatusBadRequest) + return + } + defer file.Close() + + // Validate file type + contentType := header.Header.Get("Content-Type") + if contentType != "image/png" && contentType != "image/jpeg" && contentType != "image/svg+xml" && contentType != "image/jpg" { + http.Error(w, "Only PNG, JPG or SVG files are allowed", http.StatusBadRequest) + return + } + + // Get logo type (logo or horizontal) + logoType := r.FormValue("type") + if logoType != "logo" && logoType != "horizontal" { + logoType = "logo" + } + + // Get current logo URL from database to delete old file + var currentLogoURL string + var queryErr error + if logoType == "horizontal" { + queryErr = h.tenantRepo.DB().QueryRow("SELECT logo_horizontal_url FROM tenants WHERE id = $1", tenantID).Scan(¤tLogoURL) + } else { + queryErr = h.tenantRepo.DB().QueryRow("SELECT logo_url FROM tenants WHERE id = $1", tenantID).Scan(¤tLogoURL) + } + if queryErr != nil && queryErr.Error() != "sql: no rows in result set" { + log.Printf("Warning: Failed to get current logo URL: %v", queryErr) + } + + // Initialize MinIO client + minioClient, err := minio.New("aggios-minio:9000", &minio.Options{ + Creds: credentials.NewStaticV4("minioadmin", "M1n10_S3cur3_P@ss_2025!", ""), + Secure: false, + }) + if err != nil { + log.Printf("Failed to create MinIO client: %v", err) + http.Error(w, "Storage service unavailable", http.StatusInternalServerError) + return + } + + // Ensure bucket exists + bucketName := "aggios-logos" + ctx := context.Background() + exists, err := minioClient.BucketExists(ctx, bucketName) + if err != nil { + log.Printf("Failed to check bucket: %v", err) + http.Error(w, "Storage error", http.StatusInternalServerError) + return + } + + if !exists { + err = minioClient.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{}) + if err != nil { + log.Printf("Failed to create bucket: %v", err) + http.Error(w, "Storage error", http.StatusInternalServerError) + return + } + // Set bucket policy to public-read + policy := fmt.Sprintf(`{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"AWS": ["*"]}, + "Action": ["s3:GetObject"], + "Resource": ["arn:aws:s3:::%s/*"] + }] + }`, bucketName) + err = minioClient.SetBucketPolicy(ctx, bucketName, policy) + if err != nil { + log.Printf("Warning: Failed to set bucket policy: %v", err) + } + } + + // Read file content + fileBytes, err := io.ReadAll(file) + if err != nil { + http.Error(w, "Failed to read file", http.StatusInternalServerError) + return + } + + // Generate unique filename + ext := filepath.Ext(header.Filename) + filename := fmt.Sprintf("tenants/%s/%s-%d%s", tenantID, logoType, time.Now().Unix(), ext) + + // Upload to MinIO + _, err = minioClient.PutObject( + ctx, + bucketName, + filename, + bytes.NewReader(fileBytes), + int64(len(fileBytes)), + minio.PutObjectOptions{ + ContentType: contentType, + }, + ) + if err != nil { + log.Printf("Failed to upload to MinIO: %v", err) + http.Error(w, "Failed to save file", http.StatusInternalServerError) + return + } + + // Generate public URL + logoURL := fmt.Sprintf("http://localhost:9000/%s/%s", bucketName, filename) + + log.Printf("Logo uploaded successfully: %s", logoURL) + + // Delete old logo file from MinIO if exists + if currentLogoURL != "" && currentLogoURL != "https://via.placeholder.com/150" { + // Extract object key from URL + // Example: http://localhost:9000/aggios-logos/tenants/uuid/logo-123.png -> tenants/uuid/logo-123.png + oldFilename := "" + if len(currentLogoURL) > 0 { + // Split by bucket name + if idx := len("http://localhost:9000/aggios-logos/"); idx < len(currentLogoURL) { + oldFilename = currentLogoURL[idx:] + } + } + + if oldFilename != "" { + err = minioClient.RemoveObject(ctx, bucketName, oldFilename, minio.RemoveObjectOptions{}) + if err != nil { + log.Printf("Warning: Failed to delete old logo %s: %v", oldFilename, err) + // Don't fail the request if deletion fails + } else { + log.Printf("Old logo deleted successfully: %s", oldFilename) + } + } + } + + // Update tenant record in database + var err2 error + if logoType == "horizontal" { + _, err2 = h.tenantRepo.DB().Exec("UPDATE tenants SET logo_horizontal_url = $1, updated_at = NOW() WHERE id = $2", logoURL, tenantID) + } else { + _, err2 = h.tenantRepo.DB().Exec("UPDATE tenants SET logo_url = $1, updated_at = NOW() WHERE id = $2", logoURL, tenantID) + } + + if err2 != nil { + log.Printf("Failed to update logo: %v", err2) + http.Error(w, "Failed to update database", http.StatusInternalServerError) + return + } + + // Return success response + response := map[string]string{ + "logo_url": logoURL, + "message": "Logo uploaded successfully", + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} diff --git a/backend/internal/api/handlers/agency_profile.go b/backend/internal/api/handlers/agency_profile.go index c2bc14f..0a79642 100644 --- a/backend/internal/api/handlers/agency_profile.go +++ b/backend/internal/api/handlers/agency_profile.go @@ -22,34 +22,50 @@ func NewAgencyHandler(tenantRepo *repository.TenantRepository) *AgencyHandler { } type AgencyProfileResponse struct { - ID string `json:"id"` - Name string `json:"name"` - CNPJ string `json:"cnpj"` - Email string `json:"email"` - Phone string `json:"phone"` - Website string `json:"website"` - Address string `json:"address"` - City string `json:"city"` - State string `json:"state"` - Zip string `json:"zip"` - RazaoSocial string `json:"razao_social"` - Description string `json:"description"` - Industry string `json:"industry"` + ID string `json:"id"` + Name string `json:"name"` + CNPJ string `json:"cnpj"` + Email string `json:"email"` + Phone string `json:"phone"` + Website string `json:"website"` + Address string `json:"address"` + Neighborhood string `json:"neighborhood"` + Number string `json:"number"` + Complement string `json:"complement"` + City string `json:"city"` + State string `json:"state"` + Zip string `json:"zip"` + RazaoSocial string `json:"razao_social"` + Description string `json:"description"` + Industry string `json:"industry"` + TeamSize string `json:"team_size"` + PrimaryColor string `json:"primary_color"` + SecondaryColor string `json:"secondary_color"` + LogoURL string `json:"logo_url"` + LogoHorizontalURL string `json:"logo_horizontal_url"` } type UpdateAgencyProfileRequest struct { - Name string `json:"name"` - CNPJ string `json:"cnpj"` - Email string `json:"email"` - Phone string `json:"phone"` - Website string `json:"website"` - Address string `json:"address"` - City string `json:"city"` - State string `json:"state"` - Zip string `json:"zip"` - RazaoSocial string `json:"razao_social"` - Description string `json:"description"` - Industry string `json:"industry"` + Name string `json:"name"` + CNPJ string `json:"cnpj"` + Email string `json:"email"` + Phone string `json:"phone"` + Website string `json:"website"` + Address string `json:"address"` + Neighborhood string `json:"neighborhood"` + Number string `json:"number"` + Complement string `json:"complement"` + City string `json:"city"` + State string `json:"state"` + Zip string `json:"zip"` + RazaoSocial string `json:"razao_social"` + Description string `json:"description"` + Industry string `json:"industry"` + TeamSize string `json:"team_size"` + PrimaryColor string `json:"primary_color"` + SecondaryColor string `json:"secondary_color"` + LogoURL string `json:"logo_url"` + LogoHorizontalURL string `json:"logo_horizontal_url"` } // GetProfile returns the current agency profile @@ -61,10 +77,8 @@ func (h *AgencyHandler) GetProfile(w http.ResponseWriter, r *http.Request) { // Get tenant from context (set by auth middleware) tenantID := r.Context().Value(middleware.TenantIDKey) - log.Printf("DEBUG GetProfile: tenantID from context = %v (type: %T)", tenantID, tenantID) if tenantID == nil { - log.Printf("DEBUG GetProfile: tenantID is nil from auth middleware") http.Error(w, "Tenant not found in context", http.StatusUnauthorized) return } @@ -87,20 +101,32 @@ func (h *AgencyHandler) GetProfile(w http.ResponseWriter, r *http.Request) { return } + log.Printf("🔍 GetProfile for tenant %s: Found %s", tid, tenant.Name) + log.Printf("📄 Tenant Data: Address=%s, Number=%s, TeamSize=%s, RazaoSocial=%s", + tenant.Address, tenant.Number, tenant.TeamSize, tenant.RazaoSocial) + response := AgencyProfileResponse{ - ID: tenant.ID.String(), - Name: tenant.Name, - CNPJ: tenant.CNPJ, - Email: tenant.Email, - Phone: tenant.Phone, - Website: tenant.Website, - Address: tenant.Address, - City: tenant.City, - State: tenant.State, - Zip: tenant.Zip, - RazaoSocial: tenant.RazaoSocial, - Description: tenant.Description, - Industry: tenant.Industry, + ID: tenant.ID.String(), + Name: tenant.Name, + CNPJ: tenant.CNPJ, + Email: tenant.Email, + Phone: tenant.Phone, + Website: tenant.Website, + Address: tenant.Address, + Neighborhood: tenant.Neighborhood, + Number: tenant.Number, + Complement: tenant.Complement, + City: tenant.City, + State: tenant.State, + Zip: tenant.Zip, + RazaoSocial: tenant.RazaoSocial, + Description: tenant.Description, + Industry: tenant.Industry, + TeamSize: tenant.TeamSize, + PrimaryColor: tenant.PrimaryColor, + SecondaryColor: tenant.SecondaryColor, + LogoURL: tenant.LogoURL, + LogoHorizontalURL: tenant.LogoHorizontalURL, } w.Header().Set("Content-Type", "application/json") @@ -136,18 +162,26 @@ func (h *AgencyHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) { // Prepare updates updates := map[string]interface{}{ - "name": req.Name, - "cnpj": req.CNPJ, - "razao_social": req.RazaoSocial, - "email": req.Email, - "phone": req.Phone, - "website": req.Website, - "address": req.Address, - "city": req.City, - "state": req.State, - "zip": req.Zip, - "description": req.Description, - "industry": req.Industry, + "name": req.Name, + "cnpj": req.CNPJ, + "razao_social": req.RazaoSocial, + "email": req.Email, + "phone": req.Phone, + "website": req.Website, + "address": req.Address, + "neighborhood": req.Neighborhood, + "number": req.Number, + "complement": req.Complement, + "city": req.City, + "state": req.State, + "zip": req.Zip, + "description": req.Description, + "industry": req.Industry, + "team_size": req.TeamSize, + "primary_color": req.PrimaryColor, + "secondary_color": req.SecondaryColor, + "logo_url": req.LogoURL, + "logo_horizontal_url": req.LogoHorizontalURL, } // Update in database @@ -164,21 +198,30 @@ func (h *AgencyHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) { } response := AgencyProfileResponse{ - ID: tenant.ID.String(), - Name: tenant.Name, - CNPJ: tenant.CNPJ, - Email: tenant.Email, - Phone: tenant.Phone, - Website: tenant.Website, - Address: tenant.Address, - City: tenant.City, - State: tenant.State, - Zip: tenant.Zip, - RazaoSocial: tenant.RazaoSocial, - Description: tenant.Description, - Industry: tenant.Industry, + ID: tenant.ID.String(), + Name: tenant.Name, + CNPJ: tenant.CNPJ, + Email: tenant.Email, + Phone: tenant.Phone, + Website: tenant.Website, + Address: tenant.Address, + Neighborhood: tenant.Neighborhood, + Number: tenant.Number, + Complement: tenant.Complement, + City: tenant.City, + State: tenant.State, + Zip: tenant.Zip, + RazaoSocial: tenant.RazaoSocial, + Description: tenant.Description, + Industry: tenant.Industry, + TeamSize: tenant.TeamSize, + PrimaryColor: tenant.PrimaryColor, + SecondaryColor: tenant.SecondaryColor, + LogoURL: tenant.LogoURL, + LogoHorizontalURL: tenant.LogoHorizontalURL, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } + diff --git a/backend/internal/api/handlers/agency_template_handler.go b/backend/internal/api/handlers/agency_template_handler.go new file mode 100644 index 0000000..9e23e12 --- /dev/null +++ b/backend/internal/api/handlers/agency_template_handler.go @@ -0,0 +1,239 @@ +package handlers + +import ( + "aggios-app/backend/internal/domain" + "aggios-app/backend/internal/repository" + "aggios-app/backend/internal/service" + "encoding/json" + "log" + "net/http" + + "golang.org/x/crypto/bcrypt" +) + +type AgencyTemplateHandler struct { + templateRepo *repository.AgencyTemplateRepository + agencyService *service.AgencyService + userRepo *repository.UserRepository + tenantRepo *repository.TenantRepository +} + +func NewAgencyTemplateHandler( + templateRepo *repository.AgencyTemplateRepository, + agencyService *service.AgencyService, + userRepo *repository.UserRepository, + tenantRepo *repository.TenantRepository, +) *AgencyTemplateHandler { + return &AgencyTemplateHandler{ + templateRepo: templateRepo, + agencyService: agencyService, + userRepo: userRepo, + tenantRepo: tenantRepo, + } +} + +// GetTemplateBySlug - Public endpoint to get template details +func (h *AgencyTemplateHandler) GetTemplateBySlug(w http.ResponseWriter, r *http.Request) { + slug := r.URL.Query().Get("slug") + if slug == "" { + http.Error(w, "Missing slug parameter", http.StatusBadRequest) + return + } + + template, err := h.templateRepo.FindBySlug(slug) + if err != nil { + log.Printf("Template not found: %v", err) + http.Error(w, "Template not found or expired", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(template) +} + +// PublicRegisterAgency - Public endpoint for agency registration via template +func (h *AgencyTemplateHandler) PublicRegisterAgency(w http.ResponseWriter, r *http.Request) { + var req domain.AgencyRegistrationViaTemplate + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // 1. Validar template + template, err := h.templateRepo.FindBySlug(req.TemplateSlug) + if err != nil { + log.Printf("Template error: %v", err) + http.Error(w, "Invalid or expired template", http.StatusBadRequest) + return + } + + // 2. Validar campos obrigatórios + if req.AgencyName == "" || req.Subdomain == "" || req.AdminEmail == "" || req.AdminPassword == "" { + http.Error(w, "Missing required fields", http.StatusBadRequest) + return + } + + // 3. Validar senha + if len(req.AdminPassword) < 8 { + http.Error(w, "Password must be at least 8 characters", http.StatusBadRequest) + return + } + + // 4. Verificar se email já existe + existingUser, _ := h.userRepo.FindByEmail(req.AdminEmail) + if existingUser != nil { + http.Error(w, "Email already registered", http.StatusConflict) + return + } + + // 5. Verificar se subdomain já existe + existingTenant, _ := h.tenantRepo.FindBySubdomain(req.Subdomain) + if existingTenant != nil { + http.Error(w, "Subdomain already taken", http.StatusConflict) + return + } + + // 6. Hash da senha + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.AdminPassword), bcrypt.DefaultCost) + if err != nil { + log.Printf("Error hashing password: %v", err) + http.Error(w, "Error processing password", http.StatusInternalServerError) + return + } + + // 7. Criar tenant (agência) + tenant := &domain.Tenant{ + Name: req.AgencyName, + Domain: req.Subdomain + ".aggios.app", + Subdomain: req.Subdomain, + CNPJ: req.CNPJ, + RazaoSocial: req.RazaoSocial, + Website: req.Website, + Phone: req.Phone, + Description: req.Description, + Industry: req.Industry, + TeamSize: req.TeamSize, + } + + // Endereço (se fornecido) + if req.Address != nil { + tenant.Address = req.Address["street"] + tenant.Number = req.Address["number"] + tenant.Complement = req.Address["complement"] + tenant.Neighborhood = req.Address["neighborhood"] + tenant.City = req.Address["city"] + tenant.State = req.Address["state"] + tenant.Zip = req.Address["cep"] + } + + // Personalização do template + if template.CustomPrimaryColor.Valid { + tenant.PrimaryColor = template.CustomPrimaryColor.String + } + if template.CustomLogoURL.Valid { + tenant.LogoURL = template.CustomLogoURL.String + } + + if err := h.tenantRepo.Create(tenant); err != nil { + log.Printf("Error creating tenant: %v", err) + http.Error(w, "Error creating agency", http.StatusInternalServerError) + return + } + + // 8. Criar usuário admin da agência + user := &domain.User{ + Email: req.AdminEmail, + Password: string(hashedPassword), + Name: req.AdminName, + Role: "ADMIN_AGENCIA", + TenantID: &tenant.ID, + } + + if err := h.userRepo.Create(user); err != nil { + log.Printf("Error creating user: %v", err) + http.Error(w, "Error creating admin user", http.StatusInternalServerError) + return + } + + // 9. Incrementar contador de uso do template + if err := h.templateRepo.IncrementUsageCount(template.ID.String()); err != nil { + log.Printf("Warning: failed to increment usage count: %v", err) + } + + // 10. Preparar resposta com redirect + redirectURL := template.RedirectURL.String + if redirectURL == "" { + redirectURL = "http://" + req.Subdomain + ".localhost/login" + } + + response := map[string]interface{}{ + "success": true, + "message": template.SuccessMessage.String, + "tenant_id": tenant.ID, + "user_id": user.ID, + "redirect_url": redirectURL, + "subdomain": req.Subdomain, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// CreateTemplate - SUPERADMIN only +func (h *AgencyTemplateHandler) CreateTemplate(w http.ResponseWriter, r *http.Request) { + var req domain.CreateAgencyTemplateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + formFieldsJSON, _ := repository.FormFieldsToJSON(req.FormFields) + modulesJSON, _ := json.Marshal(req.AvailableModules) + + template := &domain.AgencySignupTemplate{ + Name: req.Name, + Slug: req.Slug, + Description: req.Description, + FormFields: formFieldsJSON, + AvailableModules: modulesJSON, + IsActive: true, + } + + if req.CustomPrimaryColor != "" { + template.CustomPrimaryColor.Valid = true + template.CustomPrimaryColor.String = req.CustomPrimaryColor + } + if req.CustomLogoURL != "" { + template.CustomLogoURL.Valid = true + template.CustomLogoURL.String = req.CustomLogoURL + } + if req.RedirectURL != "" { + template.RedirectURL.Valid = true + template.RedirectURL.String = req.RedirectURL + } + if req.SuccessMessage != "" { + template.SuccessMessage.Valid = true + template.SuccessMessage.String = req.SuccessMessage + } + + if err := h.templateRepo.Create(template); err != nil { + log.Printf("Error creating template: %v", err) + http.Error(w, "Error creating template", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(template) +} + +// ListTemplates - SUPERADMIN only +func (h *AgencyTemplateHandler) ListTemplates(w http.ResponseWriter, r *http.Request) { + templates, err := h.templateRepo.List() + if err != nil { + http.Error(w, "Error fetching templates", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(templates) +} diff --git a/backend/internal/api/handlers/auth.go b/backend/internal/api/handlers/auth.go index 92496ef..8f64964 100644 --- a/backend/internal/api/handlers/auth.go +++ b/backend/internal/api/handlers/auth.go @@ -3,6 +3,7 @@ package handlers import ( "encoding/json" "io" + "log" "net/http" "strings" @@ -55,28 +56,38 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { // Login handles user login func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { + log.Printf("🔐 LOGIN HANDLER CALLED - Method: %s", r.Method) + if r.Method != http.MethodPost { + log.Printf("❌ Method not allowed: %s", r.Method) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } bodyBytes, err := io.ReadAll(r.Body) if err != nil { + log.Printf("❌ Failed to read body: %v", err) http.Error(w, "Failed to read request body", http.StatusBadRequest) return } defer r.Body.Close() + log.Printf("📥 Raw body: %s", string(bodyBytes)) + // Trim whitespace to avoid decode errors caused by BOM or stray chars sanitized := strings.TrimSpace(string(bodyBytes)) var req domain.LoginRequest if err := json.Unmarshal([]byte(sanitized), &req); err != nil { + log.Printf("❌ JSON parse error: %v", err) http.Error(w, "Invalid request body", http.StatusBadRequest) return } + log.Printf("📧 Login attempt for email: %s", req.Email) + response, err := h.authService.Login(req) if err != nil { + log.Printf("❌ authService.Login error: %v", err) if err == service.ErrInvalidCredentials { http.Error(w, err.Error(), http.StatusUnauthorized) } else { @@ -85,6 +96,7 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { return } + log.Printf("✅ Login successful for %s, role=%s", response.User.Email, response.User.Role) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } diff --git a/backend/internal/api/handlers/hash.go b/backend/internal/api/handlers/hash.go new file mode 100644 index 0000000..9c06825 --- /dev/null +++ b/backend/internal/api/handlers/hash.go @@ -0,0 +1,38 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "golang.org/x/crypto/bcrypt" +) + +type HashRequest struct { + Password string `json:"password"` +} + +type HashResponse struct { + Hash string `json:"hash"` +} + +func GenerateHash(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req HashRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + http.Error(w, "Failed to generate hash", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(HashResponse{Hash: string(hash)}) +} diff --git a/backend/internal/api/handlers/signup_template.go b/backend/internal/api/handlers/signup_template.go new file mode 100644 index 0000000..59f88fc --- /dev/null +++ b/backend/internal/api/handlers/signup_template.go @@ -0,0 +1,180 @@ +package handlers + +import ( + "aggios-app/backend/internal/api/middleware" + "aggios-app/backend/internal/domain" + "aggios-app/backend/internal/repository" + "aggios-app/backend/internal/service" + "context" + "encoding/json" + "log" + "net/http" + + "github.com/google/uuid" + "github.com/gorilla/mux" +) + +type SignupTemplateHandler struct { + repo *repository.SignupTemplateRepository + userRepo *repository.UserRepository + tenantRepo *repository.TenantRepository + agencyService *service.AgencyService +} + +func NewSignupTemplateHandler( + repo *repository.SignupTemplateRepository, + userRepo *repository.UserRepository, + tenantRepo *repository.TenantRepository, + agencyService *service.AgencyService, +) *SignupTemplateHandler { + return &SignupTemplateHandler{ + repo: repo, + userRepo: userRepo, + tenantRepo: tenantRepo, + agencyService: agencyService, + } +} + +// CreateTemplate cria um novo template (SuperAdmin) +func (h *SignupTemplateHandler) CreateTemplate(w http.ResponseWriter, r *http.Request) { + var template domain.SignupTemplate + if err := json.NewDecoder(r.Body).Decode(&template); err != nil { + log.Printf("Error decoding request body: %v", err) + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Pegar user_id do contexto (do middleware de autenticação) + userIDStr, ok := r.Context().Value(middleware.UserIDKey).(string) + if !ok || userIDStr == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + userID, err := uuid.Parse(userIDStr) + if err != nil { + log.Printf("Error parsing user_id: %v", err) + http.Error(w, "Invalid user ID", http.StatusUnauthorized) + return + } + + template.CreatedBy = userID + template.IsActive = true + + ctx := context.Background() + if err := h.repo.Create(ctx, &template); err != nil { + log.Printf("Error creating signup template: %v", err) + http.Error(w, "Error creating template", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(template) +} + +// ListTemplates lista todos os templates (SuperAdmin) +func (h *SignupTemplateHandler) ListTemplates(w http.ResponseWriter, r *http.Request) { + ctx := context.Background() + templates, err := h.repo.List(ctx) + if err != nil { + log.Printf("Error listing signup templates: %v", err) + http.Error(w, "Error listing templates", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(templates) +} + +// GetTemplateBySlug retorna um template pelo slug (público) +func (h *SignupTemplateHandler) GetTemplateBySlug(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + slug := vars["slug"] + + ctx := context.Background() + template, err := h.repo.FindBySlug(ctx, slug) + if err != nil { + log.Printf("Error finding signup template by slug %s: %v", slug, err) + http.Error(w, "Template not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(template) +} + +// GetTemplateByID retorna um template pelo ID (SuperAdmin) +func (h *SignupTemplateHandler) GetTemplateByID(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + idStr := vars["id"] + + id, err := uuid.Parse(idStr) + if err != nil { + http.Error(w, "Invalid template ID", http.StatusBadRequest) + return + } + + ctx := context.Background() + template, err := h.repo.FindByID(ctx, id) + if err != nil { + log.Printf("Error finding signup template by ID %s: %v", idStr, err) + http.Error(w, "Template not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(template) +} + +// UpdateTemplate atualiza um template (SuperAdmin) +func (h *SignupTemplateHandler) UpdateTemplate(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + idStr := vars["id"] + + id, err := uuid.Parse(idStr) + if err != nil { + http.Error(w, "Invalid template ID", http.StatusBadRequest) + return + } + + var template domain.SignupTemplate + if err := json.NewDecoder(r.Body).Decode(&template); err != nil { + log.Printf("Error decoding request body: %v", err) + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + template.ID = id + + ctx := context.Background() + if err := h.repo.Update(ctx, &template); err != nil { + log.Printf("Error updating signup template: %v", err) + http.Error(w, "Error updating template", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(template) +} + +// DeleteTemplate deleta um template (SuperAdmin) +func (h *SignupTemplateHandler) DeleteTemplate(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + idStr := vars["id"] + + id, err := uuid.Parse(idStr) + if err != nil { + http.Error(w, "Invalid template ID", http.StatusBadRequest) + return + } + + ctx := context.Background() + if err := h.repo.Delete(ctx, id); err != nil { + log.Printf("Error deleting signup template: %v", err) + http.Error(w, "Error deleting template", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/backend/internal/api/handlers/signup_template_register.go b/backend/internal/api/handlers/signup_template_register.go new file mode 100644 index 0000000..b6fdc37 --- /dev/null +++ b/backend/internal/api/handlers/signup_template_register.go @@ -0,0 +1,121 @@ +package handlers + +import ( + "aggios-app/backend/internal/domain" + "context" + "encoding/json" + "log" + "net/http" + + "golang.org/x/crypto/bcrypt" +) + +// PublicSignupRequest representa o cadastro público via template +type PublicSignupRequest struct { + TemplateSlug string `json:"template_slug"` + Email string `json:"email"` + Password string `json:"password"` + Name string `json:"name"` + Subdomain string `json:"subdomain"` + CompanyName string `json:"company_name"` +} + +// PublicRegister handles public registration via template +func (h *SignupTemplateHandler) PublicRegister(w http.ResponseWriter, r *http.Request) { + var req PublicSignupRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Printf("Error decoding request body: %v", err) + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + ctx := context.Background() + + // 1. Buscar o template + template, err := h.repo.FindBySlug(ctx, req.TemplateSlug) + if err != nil { + log.Printf("Error finding template: %v", err) + http.Error(w, "Template not found", http.StatusNotFound) + return + } + + // 2. Incrementar usage_count + if err := h.repo.IncrementUsageCount(ctx, template.ID); err != nil { + log.Printf("Error incrementing usage count: %v", err) + } + + // 3. Verificar se email já existe + emailExists, err := h.userRepo.EmailExists(req.Email) + if err != nil { + log.Printf("Error checking email: %v", err) + http.Error(w, "Error processing registration", http.StatusInternalServerError) + return + } + if emailExists { + http.Error(w, "Email already registered", http.StatusBadRequest) + return + } + + // 4. Verificar se subdomain já existe (se fornecido) + if req.Subdomain != "" { + exists, err := h.tenantRepo.SubdomainExists(req.Subdomain) + if err != nil { + log.Printf("Error checking subdomain: %v", err) + http.Error(w, "Error processing registration", http.StatusInternalServerError) + return + } + if exists { + http.Error(w, "Subdomain already taken", http.StatusBadRequest) + return + } + } + + // 5. Hash da senha + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + log.Printf("Error hashing password: %v", err) + http.Error(w, "Error processing registration", http.StatusInternalServerError) + return + } + + // 6. Criar tenant (empresa/cliente) + tenant := &domain.Tenant{ + Name: req.CompanyName, + Domain: req.Subdomain + ".aggios.app", + Subdomain: req.Subdomain, + Description: "Registered via " + template.Name, + } + + if err := h.tenantRepo.Create(tenant); err != nil { + log.Printf("Error creating tenant: %v", err) + http.Error(w, "Error creating account", http.StatusInternalServerError) + return + } + + // 7. Criar usuário admin do tenant + user := &domain.User{ + Email: req.Email, + Password: string(hashedPassword), + Name: req.Name, + Role: "CLIENTE", + TenantID: &tenant.ID, + } + + if err := h.userRepo.Create(user); err != nil { + log.Printf("Error creating user: %v", err) + http.Error(w, "Error creating user", http.StatusInternalServerError) + return + } + + // 8. Resposta de sucesso + response := map[string]interface{}{ + "success": true, + "message": template.SuccessMessage, + "tenant_id": tenant.ID, + "user_id": user.ID, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(response) +} diff --git a/backend/internal/api/handlers/upload.go b/backend/internal/api/handlers/upload.go new file mode 100644 index 0000000..22827b5 --- /dev/null +++ b/backend/internal/api/handlers/upload.go @@ -0,0 +1,130 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "path/filepath" + "strings" + + "aggios-app/backend/internal/api/middleware" + "aggios-app/backend/internal/config" + + "github.com/google/uuid" + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +// UploadHandler handles file upload endpoints +type UploadHandler struct { + minioClient *minio.Client + cfg *config.Config +} + +// NewUploadHandler creates a new upload handler +func NewUploadHandler(cfg *config.Config) (*UploadHandler, error) { + // Initialize MinIO client + minioClient, err := minio.New(cfg.Minio.Endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(cfg.Minio.RootUser, cfg.Minio.RootPassword, ""), + Secure: cfg.Minio.UseSSL, + }) + if err != nil { + return nil, fmt.Errorf("failed to create MinIO client: %w", err) + } + + // Ensure bucket exists + ctx := context.Background() + bucketName := cfg.Minio.BucketName + exists, err := minioClient.BucketExists(ctx, bucketName) + if err != nil { + return nil, fmt.Errorf("failed to check bucket existence: %w", err) + } + + if !exists { + err = minioClient.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to create bucket: %w", err) + } + } + + return &UploadHandler{ + minioClient: minioClient, + cfg: cfg, + }, nil +} + +// UploadResponse represents the upload response +type UploadResponse struct { + FileID string `json:"file_id"` + FileName string `json:"file_name"` + FileURL string `json:"file_url"` + FileSize int64 `json:"file_size"` +} + +// Upload handles file upload +func (h *UploadHandler) Upload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Try to get user ID from context (optional for signup flow) + userIDStr, _ := r.Context().Value(middleware.UserIDKey).(string) + + // Use temp tenant for unauthenticated uploads (signup flow) + tenantID := uuid.MustParse("00000000-0000-0000-0000-000000000000") + if userIDStr != "" { + // TODO: Query database to get tenant_id from user_id when authenticated + } + + // Parse multipart form (max 10MB) + if err := r.ParseMultipartForm(10 << 20); err != nil { + http.Error(w, "File too large (max 10MB)", http.StatusBadRequest) + return + } + + // Get file from form + file, header, err := r.FormFile("file") + if err != nil { + http.Error(w, "Failed to read file", http.StatusBadRequest) + return + } + defer file.Close() + + // Validate file type (images only) + contentType := header.Header.Get("Content-Type") + if !strings.HasPrefix(contentType, "image/") { + http.Error(w, "Only images are allowed", http.StatusBadRequest) + return + } + + // Generate unique file ID + fileID := uuid.New() + ext := filepath.Ext(header.Filename) + objectName := fmt.Sprintf("tenants/%s/logos/%s%s", tenantID.String(), fileID.String(), ext) + + // Upload to MinIO + ctx := context.Background() + _, err = h.minioClient.PutObject(ctx, h.cfg.Minio.BucketName, objectName, file, header.Size, minio.PutObjectOptions{ + ContentType: contentType, + }) + if err != nil { + http.Error(w, "Failed to upload file", http.StatusInternalServerError) + return + } + + // Generate public URL (replace internal hostname with localhost for browser access) + fileURL := fmt.Sprintf("http://localhost:9000/%s/%s", h.cfg.Minio.BucketName, objectName) + + // Return response + response := UploadResponse{ + FileID: fileID.String(), + FileName: header.Filename, + FileURL: fileURL, + FileSize: header.Size, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} diff --git a/backend/internal/api/middleware/auth.go b/backend/internal/api/middleware/auth.go index 6cbdc85..9d62a60 100644 --- a/backend/internal/api/middleware/auth.go +++ b/backend/internal/api/middleware/auth.go @@ -40,17 +40,33 @@ func Auth(cfg *config.Config) func(http.Handler) http.Handler { return } - claims, ok := token.Claims.(jwt.MapClaims) - if !ok { - http.Error(w, "Invalid token claims", http.StatusUnauthorized) - return - } + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + http.Error(w, "Invalid token claims", http.StatusUnauthorized) + return + } - userID := claims["user_id"].(string) - tenantID := claims["tenant_id"].(string) - ctx := context.WithValue(r.Context(), UserIDKey, userID) - ctx = context.WithValue(ctx, TenantIDKey, tenantID) - next.ServeHTTP(w, r.WithContext(ctx)) - }) - } + // Verificar se user_id existe e é do tipo correto + userIDClaim, ok := claims["user_id"] + if !ok || userIDClaim == nil { + http.Error(w, "Missing user_id in token", http.StatusUnauthorized) + return + } + userID, ok := userIDClaim.(string) + if !ok { + http.Error(w, "Invalid user_id format in token", http.StatusUnauthorized) + return + } + + // tenant_id pode ser nil para SuperAdmin + var tenantID string + if tenantIDClaim, ok := claims["tenant_id"]; ok && tenantIDClaim != nil { + tenantID, _ = tenantIDClaim.(string) + } + + ctx := context.WithValue(r.Context(), UserIDKey, userID) + ctx = context.WithValue(ctx, TenantIDKey, tenantID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) } +} \ No newline at end of file diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index a142904..4d3ad9e 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -11,6 +11,7 @@ type Config struct { JWT JWTConfig Security SecurityConfig App AppConfig + Minio MinioConfig } // AppConfig holds application-level settings @@ -45,6 +46,15 @@ type SecurityConfig struct { PasswordMinLength int } +// MinioConfig holds MinIO configuration +type MinioConfig struct { + Endpoint string + RootUser string + RootPassword string + UseSSL bool + BucketName string +} + // Load loads configuration from environment variables func Load() *Config { env := getEnvOrDefault("APP_ENV", "development") @@ -90,6 +100,13 @@ func Load() *Config { MaxAttemptsPerMin: maxAttempts, PasswordMinLength: 8, }, + Minio: MinioConfig{ + Endpoint: getEnvOrDefault("MINIO_ENDPOINT", "minio:9000"), + RootUser: getEnvOrDefault("MINIO_ROOT_USER", "minioadmin"), + RootPassword: getEnvOrDefault("MINIO_ROOT_PASSWORD", "changeme"), + UseSSL: getEnvOrDefault("MINIO_USE_SSL", "false") == "true", + BucketName: getEnvOrDefault("MINIO_BUCKET_NAME", "aggios"), + }, } } diff --git a/backend/internal/domain/agency_template.go b/backend/internal/domain/agency_template.go new file mode 100644 index 0000000..e79c54e --- /dev/null +++ b/backend/internal/domain/agency_template.go @@ -0,0 +1,66 @@ +package domain + +import ( + "database/sql" + "time" + + "github.com/google/uuid" +) + +// AgencySignupTemplate represents a signup template for agencies (SuperAdmin → Agency) +type AgencySignupTemplate struct { + ID uuid.UUID `json:"id" db:"id"` + Name string `json:"name" db:"name"` + Slug string `json:"slug" db:"slug"` + Description string `json:"description" db:"description"` + FormFields []byte `json:"form_fields" db:"form_fields"` // JSONB + AvailableModules []byte `json:"available_modules" db:"available_modules"` // JSONB + CustomPrimaryColor sql.NullString `json:"custom_primary_color" db:"custom_primary_color"` + CustomLogoURL sql.NullString `json:"custom_logo_url" db:"custom_logo_url"` + RedirectURL sql.NullString `json:"redirect_url" db:"redirect_url"` + SuccessMessage sql.NullString `json:"success_message" db:"success_message"` + IsActive bool `json:"is_active" db:"is_active"` + UsageCount int `json:"usage_count" db:"usage_count"` + MaxUses sql.NullInt64 `json:"max_uses" db:"max_uses"` + ExpiresAt sql.NullTime `json:"expires_at" db:"expires_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// CreateAgencyTemplateRequest for creating a new agency template +type CreateAgencyTemplateRequest struct { + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + FormFields []string `json:"form_fields"` + AvailableModules []string `json:"available_modules"` + CustomPrimaryColor string `json:"custom_primary_color"` + CustomLogoURL string `json:"custom_logo_url"` + RedirectURL string `json:"redirect_url"` + SuccessMessage string `json:"success_message"` + MaxUses int `json:"max_uses"` +} + +// AgencyRegistrationViaTemplate for public registration via template +type AgencyRegistrationViaTemplate struct { + TemplateSlug string `json:"template_slug"` + + // Agency info + AgencyName string `json:"agencyName"` + Subdomain string `json:"subdomain"` + CNPJ string `json:"cnpj"` + RazaoSocial string `json:"razaoSocial"` + Website string `json:"website"` + Phone string `json:"phone"` + + // Admin + AdminEmail string `json:"adminEmail"` + AdminPassword string `json:"adminPassword"` + AdminName string `json:"adminName"` + + // Optional fields + Description string `json:"description"` + Industry string `json:"industry"` + TeamSize string `json:"teamSize"` + Address map[string]string `json:"address"` +} diff --git a/backend/internal/domain/signup_template.go b/backend/internal/domain/signup_template.go new file mode 100644 index 0000000..1304326 --- /dev/null +++ b/backend/internal/domain/signup_template.go @@ -0,0 +1,35 @@ +package domain + +import ( + "time" + + "github.com/google/uuid" +) + +// FormField representa um campo do formulário de cadastro +type FormField struct { + Name string `json:"name"` + Label string `json:"label"` + Type string `json:"type"` // email, password, text, tel, etc + Required bool `json:"required"` + Order int `json:"order"` +} + +// SignupTemplate representa um template de cadastro personalizado +type SignupTemplate struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Slug string `json:"slug"` + FormFields []FormField `json:"form_fields"` + EnabledModules []string `json:"enabled_modules"` // ["CRM", "ERP", "PROJECTS"] + RedirectURL string `json:"redirect_url,omitempty"` + SuccessMessage string `json:"success_message,omitempty"` + CustomLogoURL string `json:"custom_logo_url,omitempty"` + CustomPrimaryColor string `json:"custom_primary_color,omitempty"` + IsActive bool `json:"is_active"` + UsageCount int `json:"usage_count"` + CreatedBy uuid.UUID `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/backend/internal/domain/tenant.go b/backend/internal/domain/tenant.go index 4013253..e9387aa 100644 --- a/backend/internal/domain/tenant.go +++ b/backend/internal/domain/tenant.go @@ -18,14 +18,22 @@ type Tenant struct { Phone string `json:"phone,omitempty" db:"phone"` Website string `json:"website,omitempty" db:"website"` Address string `json:"address,omitempty" db:"address"` + Neighborhood string `json:"neighborhood,omitempty" db:"neighborhood"` + Number string `json:"number,omitempty" db:"number"` + Complement string `json:"complement,omitempty" db:"complement"` City string `json:"city,omitempty" db:"city"` State string `json:"state,omitempty" db:"state"` Zip string `json:"zip,omitempty" db:"zip"` - Description string `json:"description,omitempty" db:"description"` - Industry string `json:"industry,omitempty" db:"industry"` - IsActive bool `json:"is_active" db:"is_active"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + Description string `json:"description,omitempty" db:"description"` + Industry string `json:"industry,omitempty" db:"industry"` + TeamSize string `json:"team_size,omitempty" db:"team_size"` + PrimaryColor string `json:"primary_color,omitempty" db:"primary_color"` + SecondaryColor string `json:"secondary_color,omitempty" db:"secondary_color"` + LogoURL string `json:"logo_url,omitempty" db:"logo_url"` + LogoHorizontalURL string `json:"logo_horizontal_url,omitempty" db:"logo_horizontal_url"` + IsActive bool `json:"is_active" db:"is_active"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } // CreateTenantRequest represents the request to create a new tenant diff --git a/backend/internal/domain/user.go b/backend/internal/domain/user.go index 1aa9a23..eefbb30 100644 --- a/backend/internal/domain/user.go +++ b/backend/internal/domain/user.go @@ -36,6 +36,8 @@ type RegisterAgencyRequest struct { Description string `json:"description"` Website string `json:"website"` Industry string `json:"industry"` + Phone string `json:"phone"` + TeamSize string `json:"teamSize"` // Endereço CEP string `json:"cep"` @@ -46,12 +48,59 @@ type RegisterAgencyRequest struct { Number string `json:"number"` Complement string `json:"complement"` + // Personalização + PrimaryColor string `json:"primaryColor"` + SecondaryColor string `json:"secondaryColor"` + LogoURL string `json:"logoUrl"` + LogoHorizontalURL string `json:"logoHorizontalUrl"` + // Admin da Agência AdminEmail string `json:"adminEmail"` AdminPassword string `json:"adminPassword"` AdminName string `json:"adminName"` } +// PublicRegisterAgencyRequest represents the public signup payload +type PublicRegisterAgencyRequest struct { + // User + Email string `json:"email"` + Password string `json:"password"` + FullName string `json:"fullName"` + Newsletter bool `json:"newsletter"` + + // Company + CompanyName string `json:"companyName"` + CNPJ string `json:"cnpj"` + RazaoSocial string `json:"razaoSocial"` + Description string `json:"description"` + Website string `json:"website"` + Industry string `json:"industry"` + TeamSize string `json:"teamSize"` + + // Address + CEP string `json:"cep"` + State string `json:"state"` + City string `json:"city"` + Neighborhood string `json:"neighborhood"` + Street string `json:"street"` + Number string `json:"number"` + Complement string `json:"complement"` + + // Contacts (simplified for now, taking the first one as phone if available) + Contacts []struct { + ID int `json:"id"` + Whatsapp string `json:"whatsapp"` + } `json:"contacts"` + + // Domain + Subdomain string `json:"subdomain"` + + // Branding + PrimaryColor string `json:"primaryColor"` + SecondaryColor string `json:"secondaryColor"` + LogoURL string `json:"logoUrl"` +} + // RegisterClientRequest represents client registration (ADMIN_AGENCIA only) type RegisterClientRequest struct { Email string `json:"email"` diff --git a/backend/internal/repository/agency_template_repository.go b/backend/internal/repository/agency_template_repository.go new file mode 100644 index 0000000..863a550 --- /dev/null +++ b/backend/internal/repository/agency_template_repository.go @@ -0,0 +1,168 @@ +package repository + +import ( + "aggios-app/backend/internal/domain" + "database/sql" + "encoding/json" + "fmt" +) + +type AgencyTemplateRepository struct { + db *sql.DB +} + +func NewAgencyTemplateRepository(db *sql.DB) *AgencyTemplateRepository { + return &AgencyTemplateRepository{db: db} +} + +func (r *AgencyTemplateRepository) Create(template *domain.AgencySignupTemplate) error { + query := ` + INSERT INTO agency_signup_templates ( + name, slug, description, form_fields, available_modules, + custom_primary_color, custom_logo_url, redirect_url, success_message, max_uses + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id, created_at, updated_at + ` + + return r.db.QueryRow( + query, + template.Name, + template.Slug, + template.Description, + template.FormFields, + template.AvailableModules, + template.CustomPrimaryColor, + template.CustomLogoURL, + template.RedirectURL, + template.SuccessMessage, + template.MaxUses, + ).Scan(&template.ID, &template.CreatedAt, &template.UpdatedAt) +} + +func (r *AgencyTemplateRepository) FindBySlug(slug string) (*domain.AgencySignupTemplate, error) { + var template domain.AgencySignupTemplate + query := ` + SELECT id, name, slug, description, form_fields, available_modules, + custom_primary_color, custom_logo_url, redirect_url, success_message, + is_active, usage_count, max_uses, expires_at, created_at, updated_at + FROM agency_signup_templates + WHERE slug = $1 AND is_active = true + ` + + err := r.db.QueryRow(query, slug).Scan( + &template.ID, &template.Name, &template.Slug, &template.Description, + &template.FormFields, &template.AvailableModules, + &template.CustomPrimaryColor, &template.CustomLogoURL, + &template.RedirectURL, &template.SuccessMessage, + &template.IsActive, &template.UsageCount, &template.MaxUses, + &template.ExpiresAt, &template.CreatedAt, &template.UpdatedAt, + ) + + if err != nil { + return nil, err + } + + // Validar se expirou + if template.ExpiresAt.Valid && template.ExpiresAt.Time.Before(sql.NullTime{}.Time) { + return nil, fmt.Errorf("template expired") + } + + // Validar limite de usos + if template.MaxUses.Valid && template.UsageCount >= int(template.MaxUses.Int64) { + return nil, fmt.Errorf("template usage limit reached") + } + + return &template, nil +} + +func (r *AgencyTemplateRepository) List() ([]domain.AgencySignupTemplate, error) { + var templates []domain.AgencySignupTemplate + query := ` + SELECT id, name, slug, description, form_fields, available_modules, + custom_primary_color, custom_logo_url, redirect_url, success_message, + is_active, usage_count, max_uses, expires_at, created_at, updated_at + FROM agency_signup_templates + ORDER BY created_at DESC + ` + + rows, err := r.db.Query(query) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var t domain.AgencySignupTemplate + if err := rows.Scan( + &t.ID, &t.Name, &t.Slug, &t.Description, + &t.FormFields, &t.AvailableModules, + &t.CustomPrimaryColor, &t.CustomLogoURL, + &t.RedirectURL, &t.SuccessMessage, + &t.IsActive, &t.UsageCount, &t.MaxUses, + &t.ExpiresAt, &t.CreatedAt, &t.UpdatedAt, + ); err != nil { + return nil, err + } + templates = append(templates, t) + } + + return templates, rows.Err() +} + +func (r *AgencyTemplateRepository) IncrementUsageCount(id string) error { + query := `UPDATE agency_signup_templates SET usage_count = usage_count + 1 WHERE id = $1` + _, err := r.db.Exec(query, id) + return err +} + +func (r *AgencyTemplateRepository) Update(template *domain.AgencySignupTemplate) error { + query := ` + UPDATE agency_signup_templates + SET name = $1, description = $2, form_fields = $3, available_modules = $4, + custom_primary_color = $5, custom_logo_url = $6, redirect_url = $7, + success_message = $8, is_active = $9, max_uses = $10, updated_at = CURRENT_TIMESTAMP + WHERE id = $11 + ` + + _, err := r.db.Exec( + query, + template.Name, + template.Description, + template.FormFields, + template.AvailableModules, + template.CustomPrimaryColor, + template.CustomLogoURL, + template.RedirectURL, + template.SuccessMessage, + template.IsActive, + template.MaxUses, + template.ID, + ) + return err +} + +func (r *AgencyTemplateRepository) Delete(id string) error { + query := `DELETE FROM agency_signup_templates WHERE id = $1` + _, err := r.db.Exec(query, id) + return err +} + +// Helper: Convert form fields to JSON +func FormFieldsToJSON(fields []string) ([]byte, error) { + type FormField struct { + Name string `json:"name"` + Required bool `json:"required"` + Enabled bool `json:"enabled"` + } + + var formFields []FormField + for _, field := range fields { + formFields = append(formFields, FormField{ + Name: field, + Required: field == "agencyName" || field == "subdomain" || field == "adminEmail" || field == "adminPassword", + Enabled: true, + }) + } + + return json.Marshal(formFields) +} diff --git a/backend/internal/repository/signup_template_repository.go b/backend/internal/repository/signup_template_repository.go new file mode 100644 index 0000000..9f80cb1 --- /dev/null +++ b/backend/internal/repository/signup_template_repository.go @@ -0,0 +1,280 @@ +package repository + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + + "aggios-app/backend/internal/domain" + + "github.com/google/uuid" +) + +type SignupTemplateRepository struct { + db *sql.DB +} + +func NewSignupTemplateRepository(db *sql.DB) *SignupTemplateRepository { + return &SignupTemplateRepository{db: db} +} + +// Create cria um novo template de cadastro +func (r *SignupTemplateRepository) Create(ctx context.Context, template *domain.SignupTemplate) error { + formFieldsJSON, err := json.Marshal(template.FormFields) + if err != nil { + return fmt.Errorf("error marshaling form_fields: %w", err) + } + + modulesJSON, err := json.Marshal(template.EnabledModules) + if err != nil { + return fmt.Errorf("error marshaling enabled_modules: %w", err) + } + + query := ` + INSERT INTO signup_templates ( + name, description, slug, form_fields, enabled_modules, + redirect_url, success_message, custom_logo_url, custom_primary_color, + is_active, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING id, created_at, updated_at + ` + + err = r.db.QueryRowContext( + ctx, query, + template.Name, template.Description, template.Slug, + formFieldsJSON, modulesJSON, + template.RedirectURL, template.SuccessMessage, + template.CustomLogoURL, template.CustomPrimaryColor, + template.IsActive, template.CreatedBy, + ).Scan(&template.ID, &template.CreatedAt, &template.UpdatedAt) + + if err != nil { + return fmt.Errorf("error creating signup template: %w", err) + } + + return nil +} + +// FindBySlug busca um template pelo slug +func (r *SignupTemplateRepository) FindBySlug(ctx context.Context, slug string) (*domain.SignupTemplate, error) { + query := ` + SELECT id, name, description, slug, form_fields, enabled_modules, + redirect_url, success_message, custom_logo_url, custom_primary_color, + is_active, usage_count, created_by, created_at, updated_at + FROM signup_templates + WHERE slug = $1 AND is_active = true + ` + + var template domain.SignupTemplate + var formFieldsJSON, modulesJSON []byte + var redirectURL, successMessage, customLogoURL, customPrimaryColor sql.NullString + + err := r.db.QueryRowContext(ctx, query, slug).Scan( + &template.ID, &template.Name, &template.Description, &template.Slug, + &formFieldsJSON, &modulesJSON, + &redirectURL, &successMessage, + &customLogoURL, &customPrimaryColor, + &template.IsActive, &template.UsageCount, &template.CreatedBy, + &template.CreatedAt, &template.UpdatedAt, + ) + + if redirectURL.Valid { + template.RedirectURL = redirectURL.String + } + if successMessage.Valid { + template.SuccessMessage = successMessage.String + } + if customLogoURL.Valid { + template.CustomLogoURL = customLogoURL.String + } + if customPrimaryColor.Valid { + template.CustomPrimaryColor = customPrimaryColor.String + } + + if err == sql.ErrNoRows { + return nil, fmt.Errorf("signup template not found") + } + if err != nil { + return nil, fmt.Errorf("error finding signup template: %w", err) + } + + if err := json.Unmarshal(formFieldsJSON, &template.FormFields); err != nil { + return nil, fmt.Errorf("error unmarshaling form_fields: %w", err) + } + + if err := json.Unmarshal(modulesJSON, &template.EnabledModules); err != nil { + return nil, fmt.Errorf("error unmarshaling enabled_modules: %w", err) + } + + return &template, nil +} + +// FindByID busca um template pelo ID +func (r *SignupTemplateRepository) FindByID(ctx context.Context, id uuid.UUID) (*domain.SignupTemplate, error) { + query := ` + SELECT id, name, description, slug, form_fields, enabled_modules, + redirect_url, success_message, custom_logo_url, custom_primary_color, + is_active, usage_count, created_by, created_at, updated_at + FROM signup_templates + WHERE id = $1 + ` + + var template domain.SignupTemplate + var formFieldsJSON, modulesJSON []byte + var redirectURL, successMessage, customLogoURL, customPrimaryColor sql.NullString + + err := r.db.QueryRowContext(ctx, query, id).Scan( + &template.ID, &template.Name, &template.Description, &template.Slug, + &formFieldsJSON, &modulesJSON, + &redirectURL, &successMessage, + &customLogoURL, &customPrimaryColor, + &template.IsActive, &template.UsageCount, &template.CreatedBy, + &template.CreatedAt, &template.UpdatedAt, + ) + + if redirectURL.Valid { + template.RedirectURL = redirectURL.String + } + if successMessage.Valid { + template.SuccessMessage = successMessage.String + } + if customLogoURL.Valid { + template.CustomLogoURL = customLogoURL.String + } + if customPrimaryColor.Valid { + template.CustomPrimaryColor = customPrimaryColor.String + } + + if err == sql.ErrNoRows { + return nil, fmt.Errorf("signup template not found") + } + if err != nil { + return nil, fmt.Errorf("error finding signup template: %w", err) + } + + if err := json.Unmarshal(formFieldsJSON, &template.FormFields); err != nil { + return nil, fmt.Errorf("error unmarshaling form_fields: %w", err) + } + + if err := json.Unmarshal(modulesJSON, &template.EnabledModules); err != nil { + return nil, fmt.Errorf("error unmarshaling enabled_modules: %w", err) + } + + return &template, nil +} + +// List lista todos os templates +func (r *SignupTemplateRepository) List(ctx context.Context) ([]*domain.SignupTemplate, error) { + query := ` + SELECT id, name, description, slug, form_fields, enabled_modules, + redirect_url, success_message, custom_logo_url, custom_primary_color, + is_active, usage_count, created_by, created_at, updated_at + FROM signup_templates + ORDER BY created_at DESC + ` + + rows, err := r.db.QueryContext(ctx, query) + if err != nil { + return nil, fmt.Errorf("error listing signup templates: %w", err) + } + defer rows.Close() + + var templates []*domain.SignupTemplate + + for rows.Next() { + var template domain.SignupTemplate + var formFieldsJSON, modulesJSON []byte + var redirectURL, successMessage, customLogoURL, customPrimaryColor sql.NullString + + err := rows.Scan( + &template.ID, &template.Name, &template.Description, &template.Slug, + &formFieldsJSON, &modulesJSON, + &redirectURL, &successMessage, + &customLogoURL, &customPrimaryColor, + &template.IsActive, &template.UsageCount, &template.CreatedBy, + &template.CreatedAt, &template.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("error scanning signup template: %w", err) + } + + if redirectURL.Valid { + template.RedirectURL = redirectURL.String + } + if successMessage.Valid { + template.SuccessMessage = successMessage.String + } + if customLogoURL.Valid { + template.CustomLogoURL = customLogoURL.String + } + if customPrimaryColor.Valid { + template.CustomPrimaryColor = customPrimaryColor.String + } + + if err := json.Unmarshal(formFieldsJSON, &template.FormFields); err != nil { + return nil, fmt.Errorf("error unmarshaling form_fields: %w", err) + } + + if err := json.Unmarshal(modulesJSON, &template.EnabledModules); err != nil { + return nil, fmt.Errorf("error unmarshaling enabled_modules: %w", err) + } + + templates = append(templates, &template) + } + + return templates, nil +} + +// IncrementUsageCount incrementa o contador de uso +func (r *SignupTemplateRepository) IncrementUsageCount(ctx context.Context, id uuid.UUID) error { + query := `UPDATE signup_templates SET usage_count = usage_count + 1 WHERE id = $1` + _, err := r.db.ExecContext(ctx, query, id) + return err +} + +// Update atualiza um template +func (r *SignupTemplateRepository) Update(ctx context.Context, template *domain.SignupTemplate) error { + formFieldsJSON, err := json.Marshal(template.FormFields) + if err != nil { + return fmt.Errorf("error marshaling form_fields: %w", err) + } + + modulesJSON, err := json.Marshal(template.EnabledModules) + if err != nil { + return fmt.Errorf("error marshaling enabled_modules: %w", err) + } + + query := ` + UPDATE signup_templates SET + name = $1, description = $2, slug = $3, form_fields = $4, enabled_modules = $5, + redirect_url = $6, success_message = $7, custom_logo_url = $8, custom_primary_color = $9, + is_active = $10 + WHERE id = $11 + ` + + _, err = r.db.ExecContext( + ctx, query, + template.Name, template.Description, template.Slug, + formFieldsJSON, modulesJSON, + template.RedirectURL, template.SuccessMessage, + template.CustomLogoURL, template.CustomPrimaryColor, + template.IsActive, template.ID, + ) + + if err != nil { + return fmt.Errorf("error updating signup template: %w", err) + } + + return nil +} + +// Delete deleta um template +func (r *SignupTemplateRepository) Delete(ctx context.Context, id uuid.UUID) error { + query := `DELETE FROM signup_templates WHERE id = $1` + _, err := r.db.ExecContext(ctx, query, id) + if err != nil { + return fmt.Errorf("error deleting signup template: %w", err) + } + return nil +} diff --git a/backend/internal/repository/tenant_repository.go b/backend/internal/repository/tenant_repository.go index 49ce561..5d800ec 100644 --- a/backend/internal/repository/tenant_repository.go +++ b/backend/internal/repository/tenant_repository.go @@ -19,14 +19,21 @@ func NewTenantRepository(db *sql.DB) *TenantRepository { return &TenantRepository{db: db} } +// DB returns the underlying database connection +func (r *TenantRepository) DB() *sql.DB { + return r.db +} + // Create creates a new tenant func (r *TenantRepository) Create(tenant *domain.Tenant) error { query := ` INSERT INTO tenants ( - id, name, domain, subdomain, cnpj, razao_social, email, website, - address, city, state, zip, description, industry, created_at, updated_at + id, name, domain, subdomain, cnpj, razao_social, email, phone, website, + address, neighborhood, number, complement, city, state, zip, + description, industry, team_size, primary_color, secondary_color, + logo_url, logo_horizontal_url, created_at, updated_at ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25) RETURNING id, created_at, updated_at ` @@ -44,13 +51,22 @@ func (r *TenantRepository) Create(tenant *domain.Tenant) error { tenant.CNPJ, tenant.RazaoSocial, tenant.Email, + tenant.Phone, tenant.Website, tenant.Address, + tenant.Neighborhood, + tenant.Number, + tenant.Complement, tenant.City, tenant.State, tenant.Zip, tenant.Description, tenant.Industry, + tenant.TeamSize, + tenant.PrimaryColor, + tenant.SecondaryColor, + tenant.LogoURL, + tenant.LogoHorizontalURL, tenant.CreatedAt, tenant.UpdatedAt, ).Scan(&tenant.ID, &tenant.CreatedAt, &tenant.UpdatedAt) @@ -59,14 +75,16 @@ func (r *TenantRepository) Create(tenant *domain.Tenant) error { // 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 + SELECT id, name, domain, subdomain, cnpj, razao_social, email, phone, website, + address, neighborhood, number, complement, city, state, zip, description, industry, team_size, + primary_color, secondary_color, logo_url, logo_horizontal_url, + is_active, created_at, updated_at FROM tenants WHERE id = $1 ` tenant := &domain.Tenant{} - var cnpj, razaoSocial, email, phone, website, address, city, state, zip, description, industry sql.NullString + var cnpj, razaoSocial, email, phone, website, address, neighborhood, number, complement, city, state, zip, description, industry, teamSize, primaryColor, secondaryColor, logoURL, logoHorizontalURL sql.NullString err := r.db.QueryRow(query, id).Scan( &tenant.ID, @@ -79,11 +97,19 @@ func (r *TenantRepository) FindByID(id uuid.UUID) (*domain.Tenant, error) { &phone, &website, &address, + &neighborhood, + &number, + &complement, &city, &state, &zip, &description, &industry, + &teamSize, + &primaryColor, + &secondaryColor, + &logoURL, + &logoHorizontalURL, &tenant.IsActive, &tenant.CreatedAt, &tenant.UpdatedAt, @@ -116,6 +142,15 @@ func (r *TenantRepository) FindByID(id uuid.UUID) (*domain.Tenant, error) { if address.Valid { tenant.Address = address.String } + if neighborhood.Valid { + tenant.Neighborhood = neighborhood.String + } + if number.Valid { + tenant.Number = number.String + } + if complement.Valid { + tenant.Complement = complement.String + } if city.Valid { tenant.City = city.String } @@ -131,6 +166,21 @@ func (r *TenantRepository) FindByID(id uuid.UUID) (*domain.Tenant, error) { if industry.Valid { tenant.Industry = industry.String } + if teamSize.Valid { + tenant.TeamSize = teamSize.String + } + if primaryColor.Valid { + tenant.PrimaryColor = primaryColor.String + } + if secondaryColor.Valid { + tenant.SecondaryColor = secondaryColor.String + } + if logoURL.Valid { + tenant.LogoURL = logoURL.String + } + if logoHorizontalURL.Valid { + tenant.LogoHorizontalURL = logoHorizontalURL.String + } return tenant, nil } @@ -171,7 +221,7 @@ func (r *TenantRepository) SubdomainExists(subdomain string) (bool, error) { // FindAll returns all tenants func (r *TenantRepository) FindAll() ([]*domain.Tenant, error) { query := ` - SELECT id, name, domain, subdomain, is_active, created_at, updated_at + SELECT id, name, domain, subdomain, email, phone, cnpj, logo_url, is_active, created_at, updated_at FROM tenants ORDER BY created_at DESC ` @@ -185,11 +235,17 @@ func (r *TenantRepository) FindAll() ([]*domain.Tenant, error) { var tenants []*domain.Tenant for rows.Next() { tenant := &domain.Tenant{} + var email, phone, cnpj, logoURL sql.NullString + err := rows.Scan( &tenant.ID, &tenant.Name, &tenant.Domain, &tenant.Subdomain, + &email, + &phone, + &cnpj, + &logoURL, &tenant.IsActive, &tenant.CreatedAt, &tenant.UpdatedAt, @@ -197,6 +253,20 @@ func (r *TenantRepository) FindAll() ([]*domain.Tenant, error) { if err != nil { return nil, err } + + if email.Valid { + tenant.Email = email.String + } + if phone.Valid { + tenant.Phone = phone.String + } + if cnpj.Valid { + tenant.CNPJ = cnpj.String + } + if logoURL.Valid { + tenant.LogoURL = logoURL.String + } + tenants = append(tenants, tenant) } @@ -209,7 +279,21 @@ func (r *TenantRepository) FindAll() ([]*domain.Tenant, error) { // Delete removes a tenant (and cascades to related data) func (r *TenantRepository) Delete(id uuid.UUID) error { - result, err := r.db.Exec(`DELETE FROM tenants WHERE id = $1`, id) + // Start transaction + tx, err := r.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + // Delete all users associated with this tenant first + _, err = tx.Exec(`DELETE FROM users WHERE tenant_id = $1`, id) + if err != nil { + return err + } + + // Delete the tenant + result, err := tx.Exec(`DELETE FROM tenants WHERE id = $1`, id) if err != nil { return err } @@ -223,7 +307,8 @@ func (r *TenantRepository) Delete(id uuid.UUID) error { return sql.ErrNoRows } - return nil + // Commit transaction + return tx.Commit() } // UpdateProfile updates tenant profile information @@ -237,13 +322,21 @@ func (r *TenantRepository) UpdateProfile(id uuid.UUID, updates map[string]interf phone = COALESCE($5, phone), website = COALESCE($6, website), address = COALESCE($7, address), - city = COALESCE($8, city), - state = COALESCE($9, state), - zip = COALESCE($10, zip), - description = COALESCE($11, description), - industry = COALESCE($12, industry), - updated_at = $13 - WHERE id = $14 + neighborhood = COALESCE($8, neighborhood), + number = COALESCE($9, number), + complement = COALESCE($10, complement), + city = COALESCE($11, city), + state = COALESCE($12, state), + zip = COALESCE($13, zip), + description = COALESCE($14, description), + industry = COALESCE($15, industry), + team_size = COALESCE($16, team_size), + primary_color = COALESCE($17, primary_color), + secondary_color = COALESCE($18, secondary_color), + logo_url = COALESCE($19, logo_url), + logo_horizontal_url = COALESCE($20, logo_horizontal_url), + updated_at = $21 + WHERE id = $22 ` _, err := r.db.Exec( @@ -255,14 +348,29 @@ func (r *TenantRepository) UpdateProfile(id uuid.UUID, updates map[string]interf updates["phone"], updates["website"], updates["address"], + updates["neighborhood"], + updates["number"], + updates["complement"], updates["city"], updates["state"], updates["zip"], updates["description"], updates["industry"], + updates["team_size"], + updates["primary_color"], + updates["secondary_color"], + updates["logo_url"], + updates["logo_horizontal_url"], time.Now(), id, ) return err } + +// UpdateStatus updates the is_active status of a tenant +func (r *TenantRepository) UpdateStatus(id uuid.UUID, isActive bool) error { + query := `UPDATE tenants SET is_active = $1, updated_at = $2 WHERE id = $3` + _, err := r.db.Exec(query, isActive, time.Now(), id) + return err +} diff --git a/backend/internal/repository/user_repository.go b/backend/internal/repository/user_repository.go index 4b663f7..3787c63 100644 --- a/backend/internal/repository/user_repository.go +++ b/backend/internal/repository/user_repository.go @@ -2,6 +2,7 @@ package repository import ( "database/sql" + "log" "time" "aggios-app/backend/internal/domain" @@ -53,6 +54,8 @@ func (r *UserRepository) Create(user *domain.User) error { // FindByEmail finds a user by email func (r *UserRepository) FindByEmail(email string) (*domain.User, error) { + log.Printf("🔍 FindByEmail called with: %s", email) + query := ` SELECT id, tenant_id, email, password_hash, first_name, role, created_at, updated_at FROM users @@ -72,10 +75,16 @@ func (r *UserRepository) FindByEmail(email string) (*domain.User, error) { ) if err == sql.ErrNoRows { + log.Printf("❌ User not found: %s", email) return nil, nil } + if err != nil { + log.Printf("❌ DB error finding user %s: %v", email, err) + return nil, err + } - return user, err + log.Printf("✅ Found user: %s, role: %s", user.Email, user.Role) + return user, nil } // FindByID finds a user by ID diff --git a/backend/internal/service/agency_service.go b/backend/internal/service/agency_service.go index 8071c15..07cfc67 100644 --- a/backend/internal/service/agency_service.go +++ b/backend/internal/service/agency_service.go @@ -60,24 +60,30 @@ func (s *AgencyService) RegisterAgency(req domain.RegisterAgencyRequest) (*domai if req.Complement != "" { address += " - " + req.Complement } - if req.Neighborhood != "" { - address += " - " + req.Neighborhood - } tenant := &domain.Tenant{ - Name: req.AgencyName, - Domain: fmt.Sprintf("%s.%s", req.Subdomain, s.cfg.App.BaseDomain), - Subdomain: req.Subdomain, - CNPJ: req.CNPJ, - RazaoSocial: req.RazaoSocial, - Email: req.AdminEmail, - Website: req.Website, - Address: address, - City: req.City, - State: req.State, - Zip: req.CEP, - Description: req.Description, - Industry: req.Industry, + Name: req.AgencyName, + Domain: fmt.Sprintf("%s.%s", req.Subdomain, s.cfg.App.BaseDomain), + Subdomain: req.Subdomain, + CNPJ: req.CNPJ, + RazaoSocial: req.RazaoSocial, + Email: req.AdminEmail, + Phone: req.Phone, + Website: req.Website, + Address: address, + Neighborhood: req.Neighborhood, + Number: req.Number, + Complement: req.Complement, + City: req.City, + State: req.State, + Zip: req.CEP, + Description: req.Description, + Industry: req.Industry, + TeamSize: req.TeamSize, + PrimaryColor: req.PrimaryColor, + SecondaryColor: req.SecondaryColor, + LogoURL: req.LogoURL, + LogoHorizontalURL: req.LogoHorizontalURL, } if err := s.tenantRepo.Create(tenant); err != nil { @@ -189,3 +195,16 @@ func (s *AgencyService) DeleteAgency(id uuid.UUID) error { return s.tenantRepo.Delete(id) } + +// UpdateAgencyStatus updates the is_active status of a tenant +func (s *AgencyService) UpdateAgencyStatus(id uuid.UUID, isActive bool) error { + tenant, err := s.tenantRepo.FindByID(id) + if err != nil { + return err + } + if tenant == nil { + return ErrTenantNotFound + } + + return s.tenantRepo.UpdateStatus(id, isActive) +} diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index b1ede41..817abb5 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -2,6 +2,7 @@ package service import ( "errors" + "log" "time" "aggios-app/backend/internal/config" @@ -78,14 +79,20 @@ func (s *AuthService) Login(req domain.LoginRequest) (*domain.LoginResponse, err // Find user by email user, err := s.userRepo.FindByEmail(req.Email) if err != nil { + log.Printf("❌ DB error finding user %s: %v", req.Email, err) return nil, err } if user == nil { + log.Printf("❌ User not found: %s", req.Email) return nil, ErrInvalidCredentials } + log.Printf("🔍 Attempting login for %s with password_hash: %.10s...", req.Email, user.Password) + log.Printf("🔍 Provided password length: %d", len(req.Password)) + // Verify password if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil { + log.Printf("❌ Password mismatch for %s: %v", req.Email, err) return nil, ErrInvalidCredentials } diff --git a/docker-compose.yml b/docker-compose.yml index 976036d..a842180 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -167,6 +167,30 @@ services: networks: - aggios-network + # Frontend - Agency (tenant-only) + agency: + build: + context: ./front-end-agency + dockerfile: Dockerfile + container_name: aggios-agency + restart: unless-stopped + labels: + - "traefik.enable=true" + - "traefik.http.routers.agency.rule=Host(`agency.aggios.local`) || Host(`agency.localhost`) || HostRegexp(`^.+\\.localhost$`)" + - "traefik.http.routers.agency.entrypoints=web" + environment: + - NODE_ENV=production + - NEXT_PUBLIC_API_URL=http://api.localhost + - API_INTERNAL_URL=http://backend:8080 + healthcheck: + test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000" ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + networks: + - aggios-network + volumes: postgres_data: driver: local diff --git a/front-end-agency/.gitignore b/front-end-agency/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/front-end-agency/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/front-end-agency/Dockerfile b/front-end-agency/Dockerfile new file mode 100644 index 0000000..47bef17 --- /dev/null +++ b/front-end-agency/Dockerfile @@ -0,0 +1,41 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package.json package-lock.json ./ + +# Install dependencies +RUN npm ci + +# Copy source code +COPY . . + +# Build Next.js +RUN npm run build + +# Runtime stage +FROM node:20-alpine + +WORKDIR /app + +# Copy package files +COPY package.json package-lock.json ./ + +# Install only production dependencies +RUN npm ci --omit=dev + +# Copy built app from builder +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/public ./public + +# Expose port +EXPOSE 3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3000', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})" + +# Start app +CMD ["npm", "start"] diff --git a/front-end-agency/README.md b/front-end-agency/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/front-end-agency/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/front-end-dash.aggios.app/app/(agency)/clientes/page.tsx b/front-end-agency/app/(agency)/clientes/page.tsx similarity index 100% rename from front-end-dash.aggios.app/app/(agency)/clientes/page.tsx rename to front-end-agency/app/(agency)/clientes/page.tsx diff --git a/front-end-agency/app/(agency)/configuracoes/page.tsx b/front-end-agency/app/(agency)/configuracoes/page.tsx new file mode 100644 index 0000000..af8fb8c --- /dev/null +++ b/front-end-agency/app/(agency)/configuracoes/page.tsx @@ -0,0 +1,1105 @@ +"use client"; + +import { useState, useEffect } from 'react'; +import { Tab } from '@headlessui/react'; +import { Button, Dialog } from '@/components/ui'; +import { Toaster, toast } from 'react-hot-toast'; +import { + BuildingOfficeIcon, + PhotoIcon, + UserGroupIcon, + ShieldCheckIcon, + BellIcon, + ArrowUpTrayIcon, + TrashIcon, + CheckCircleIcon, +} from '@heroicons/react/24/outline'; + +const tabs = [ + { name: 'Dados da Agência', icon: BuildingOfficeIcon }, + { name: 'Logo e Marca', icon: PhotoIcon }, + { name: 'Equipe', icon: UserGroupIcon }, + { name: 'Segurança', icon: ShieldCheckIcon }, + { name: 'Notificações', icon: BellIcon }, +]; + +const parseAddressParts = (address: string) => { + if (!address) return { street: '', number: '', complement: '' }; + + const [streetPart, rest] = address.split(',', 2).map(part => part.trim()); + if (!rest) return { street: streetPart, number: '', complement: '' }; + + const [numberPart, complementPart] = rest.split('-', 2).map(part => part.trim()); + return { + street: streetPart, + number: numberPart || '', + complement: complementPart || '', + }; +}; + +export default function ConfiguracoesPage() { + const [selectedTab, setSelectedTab] = useState(0); + const [showSuccessDialog, setShowSuccessDialog] = useState(false); + const [successMessage, setSuccessMessage] = useState(''); + const [showSupportDialog, setShowSupportDialog] = useState(false); + const [supportMessage, setSupportMessage] = useState('Para alterar estes dados, contate o suporte.'); + const [loading, setLoading] = useState(true); + const [loadingCep, setLoadingCep] = useState(false); + const [uploadingLogo, setUploadingLogo] = useState(false); + const [logoPreview, setLogoPreview] = useState(null); + const [logoHorizontalPreview, setLogoHorizontalPreview] = useState(null); + + // Dados da agência (buscados da API) + const [agencyData, setAgencyData] = useState({ + name: '', + cnpj: '', + email: '', + phone: '', + website: '', + address: '', + street: '', + number: '', + complement: '', + neighborhood: '', + city: '', + state: '', + zip: '', + razaoSocial: '', + description: '', + industry: '', + teamSize: '', + logoUrl: '', + logoHorizontalUrl: '', + }); + + // Dados para alteração de senha + const [passwordData, setPasswordData] = useState({ + currentPassword: '', + newPassword: '', + confirmPassword: '', + }); + + useEffect(() => { + const fetchAgencyData = async () => { + try { + setLoading(true); + const token = localStorage.getItem('token'); + const userData = localStorage.getItem('user'); + + if (!token || !userData) { + console.error('Usuário não autenticado'); + setLoading(false); + return; + } + + // Buscar dados da API + const response = await fetch('/api/agency/profile', { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + if (response.ok) { + const data = await response.json(); + console.log('DEBUG: API response data:', data); + console.log('DEBUG: logo_url:', data.logo_url); + console.log('DEBUG: logo_horizontal_url:', data.logo_horizontal_url); + + const parsedAddress = parseAddressParts(data.address || ''); + setAgencyData({ + name: data.name || '', + cnpj: data.cnpj || '', + email: data.email || '', + phone: data.phone || '', + website: data.website || '', + address: data.address || '', + street: parsedAddress.street, + number: data.number || parsedAddress.number, + complement: data.complement || parsedAddress.complement, + neighborhood: data.neighborhood || '', + city: data.city || '', + state: data.state || '', + zip: data.zip || '', + razaoSocial: data.razao_social || '', + description: data.description || '', + industry: data.industry || '', + teamSize: data.team_size || '', + logoUrl: data.logo_url || '', + logoHorizontalUrl: data.logo_horizontal_url || '', + }); + + // Set logo previews + console.log('DEBUG: Setting previews...'); + if (data.logo_url) { + console.log('DEBUG: Setting logoPreview to:', data.logo_url); + setLogoPreview(data.logo_url); + } + if (data.logo_horizontal_url) { + console.log('DEBUG: Setting logoHorizontalPreview to:', data.logo_horizontal_url); + setLogoHorizontalPreview(data.logo_horizontal_url); + } + } else { + console.error('Erro ao buscar dados:', response.status); + // Fallback para localStorage se API falhar + const savedData = localStorage.getItem('cadastroData'); + if (savedData) { + const data = JSON.parse(savedData); + const user = JSON.parse(userData); + setAgencyData({ + name: data.formData?.companyName || '', + cnpj: data.formData?.cnpj || '', + email: data.formData?.email || user.email || '', + phone: data.contacts?.[0]?.phone || '', + website: data.formData?.website || '', + address: `${data.cepData?.logradouro || ''}, ${data.formData?.number || ''}`, + street: data.cepData?.logradouro || '', + number: data.formData?.number || '', + complement: data.formData?.complement || '', + neighborhood: data.cepData?.bairro || '', + city: data.cepData?.localidade || '', + state: data.cepData?.uf || '', + zip: data.formData?.cep || '', + razaoSocial: data.cnpjData?.razaoSocial || '', + description: data.formData?.description || '', + industry: data.formData?.industry || '', + teamSize: data.formData?.teamSize || '', + logoUrl: '', + logoHorizontalUrl: '', + }); + } + } + } catch (error) { + console.error('Erro ao buscar dados da agência:', error); + setSuccessMessage('Erro ao carregar dados da agência.'); + setShowSuccessDialog(true); + } finally { + setLoading(false); + } + }; + + fetchAgencyData(); + }, []); + + const handleLogoUpload = async (file: File, type: 'logo' | 'horizontal') => { + if (!file) return; + + // Validar tipo de arquivo + if (!file.type.startsWith('image/')) { + toast.error('Por favor, selecione uma imagem válida'); + return; + } + + // Validar tamanho (2MB) + if (file.size > 2 * 1024 * 1024) { + toast.error('A imagem deve ter no máximo 2MB'); + return; + } + + setUploadingLogo(true); + try { + const token = localStorage.getItem('token'); + if (!token) { + toast.error('Você precisa estar autenticado'); + return; + } + + const formData = new FormData(); + formData.append('logo', file); + formData.append('type', type); + + const response = await fetch('/api/agency/logo', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + }, + body: formData, + }); + + if (response.ok) { + const data = await response.json(); + const logoUrl = data.logo_url || data.url; + + if (type === 'logo') { + setAgencyData(prev => ({ ...prev, logoUrl })); + setLogoPreview(logoUrl); + } else { + setAgencyData(prev => ({ ...prev, logoHorizontalUrl: logoUrl })); + setLogoHorizontalPreview(logoUrl); + } + + toast.success('Logo enviado com sucesso!'); + } else { + const errorData = await response.json().catch(() => ({})); + console.error('Upload error:', errorData); + toast.error(errorData.error || 'Erro ao enviar logo. Tente novamente.'); + } + } catch (error) { + console.error('Erro ao fazer upload:', error); + toast.error('Erro ao enviar logo. Verifique sua conexão.'); + } finally { + setUploadingLogo(false); + } + }; + + const handleFileSelect = (e: React.ChangeEvent, type: 'logo' | 'horizontal') => { + const file = e.target.files?.[0]; + if (file) { + // Create preview immediately + const reader = new FileReader(); + reader.onloadend = () => { + const previewUrl = reader.result as string; + if (type === 'logo') { + setLogoPreview(previewUrl); + } else { + setLogoHorizontalPreview(previewUrl); + } + }; + reader.readAsDataURL(file); + + // Upload file + handleLogoUpload(file, type); + } + }; + + const formatCep = (value: string) => { + const numbers = value.replace(/\D/g, ''); + return numbers.replace(/(\d{5})(\d{0,3})/, '$1-$2').substring(0, 9); + }; + + const fetchCepData = async (cep: string) => { + const numbers = cep.replace(/\D/g, ''); + if (numbers.length !== 8) return; + + setLoadingCep(true); + try { + const response = await fetch(`https://viacep.com.br/ws/${numbers}/json/`); + if (!response.ok) { + toast.error('Não foi possível consultar o CEP agora.'); + return; + } + + const data = await response.json(); + if (data?.erro) { + toast.error('CEP não encontrado. Verifique o número.'); + setAgencyData(prev => ({ + ...prev, + address: '', + street: '', + neighborhood: '', + city: '', + state: '', + })); + return; + } + + const formattedCep = formatCep(cep); + const nextAddress = data.logradouro || ''; + const nextNeighborhood = data.bairro || ''; + const nextCity = data.localidade || ''; + const nextState = data.uf || ''; + + setAgencyData(prev => ({ + ...prev, + zip: formattedCep, + address: nextAddress, + street: nextAddress, + neighborhood: nextNeighborhood, + city: nextCity, + state: nextState, + })); + + toast.success('Endereço preenchido pelo CEP'); + } catch (error) { + console.error('Erro ao buscar CEP:', error); + toast.error('Erro ao buscar CEP. Tente novamente.'); + } finally { + setLoadingCep(false); + } + }; + + const handleSaveAgency = async () => { + try { + const token = localStorage.getItem('token'); + if (!token) { + setSuccessMessage('Você precisa estar autenticado.'); + setShowSuccessDialog(true); + return; + } + + const composedAddress = agencyData.street + ? `${agencyData.street}${agencyData.number ? `, ${agencyData.number}` : ''}${agencyData.complement ? ` - ${agencyData.complement}` : ''}` + : agencyData.address; + + const response = await fetch('/api/agency/profile', { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: agencyData.name, + cnpj: agencyData.cnpj, + email: agencyData.email, + phone: agencyData.phone, + website: agencyData.website, + address: composedAddress, + neighborhood: agencyData.neighborhood, + number: agencyData.number, + complement: agencyData.complement, + city: agencyData.city, + state: agencyData.state, + zip: agencyData.zip, + razao_social: agencyData.razaoSocial, + description: agencyData.description, + industry: agencyData.industry, + team_size: agencyData.teamSize, + }), + }); + + if (response.ok) { + setSuccessMessage('Dados da agência salvos com sucesso!'); + } else { + setSuccessMessage('Erro ao salvar dados. Tente novamente.'); + } + } catch (error) { + console.error('Erro ao salvar:', error); + setSuccessMessage('Erro ao salvar dados. Verifique sua conexão.'); + } + setShowSuccessDialog(true); + }; + + const handleChangePassword = async () => { + // Validações + if (!passwordData.currentPassword) { + setSuccessMessage('Por favor, informe sua senha atual.'); + setShowSuccessDialog(true); + return; + } + if (!passwordData.newPassword || passwordData.newPassword.length < 8) { + setSuccessMessage('A nova senha deve ter pelo menos 8 caracteres.'); + setShowSuccessDialog(true); + return; + } + if (passwordData.newPassword !== passwordData.confirmPassword) { + setSuccessMessage('As senhas não coincidem.'); + setShowSuccessDialog(true); + return; + } + + try { + const token = localStorage.getItem('token'); + if (!token) { + setSuccessMessage('Você precisa estar autenticado.'); + setShowSuccessDialog(true); + return; + } + + const response = await fetch('/api/auth/change-password', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + currentPassword: passwordData.currentPassword, + newPassword: passwordData.newPassword, + }), + }); + + if (response.ok) { + setPasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' }); + setSuccessMessage('Senha alterada com sucesso!'); + } else { + const error = await response.text(); + setSuccessMessage(error || 'Erro ao alterar senha. Verifique sua senha atual.'); + } + } catch (error) { + console.error('Erro ao alterar senha:', error); + setSuccessMessage('Erro ao alterar senha. Verifique sua conexão.'); + } + setShowSuccessDialog(true); + }; + + return ( +
+ + {/* Header */} +
+

+ Configurações +

+

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

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

+ Informações da Agência +

+ +
+
+ + setAgencyData({ ...agencyData, name: e.target.value })} + className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none" + /> +
+ +
+ + setAgencyData({ ...agencyData, razaoSocial: e.target.value })} + className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none" + /> +
+ +
+ + { + setSupportMessage('Para alterar CNPJ, contate o suporte.'); + setShowSupportDialog(true); + }} + className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none cursor-pointer" + /> +
+ +
+ + { + setSupportMessage('Para alterar o e-mail de acesso, contate o suporte.'); + setShowSupportDialog(true); + }} + className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none cursor-pointer" + /> +
+ +
+ + setAgencyData({ ...agencyData, phone: e.target.value })} + className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none" + /> +
+ +
+ + setAgencyData({ ...agencyData, website: e.target.value })} + className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none" + /> +
+ +
+ + setAgencyData({ ...agencyData, industry: e.target.value })} + className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none" + /> +
+ +
+ + setAgencyData({ ...agencyData, teamSize: e.target.value })} + className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none" + /> +
+
+
+ + {/* Card: Contato e Localização */} +
+

+ Contato e Localização +

+ +
+ +
+ + { + const formatted = formatCep(e.target.value); + setAgencyData(prev => ({ ...prev, zip: formatted })); + + const numbers = formatted.replace(/\D/g, ''); + if (numbers.length === 8) { + fetchCepData(formatted); + } + + if (numbers.length === 0) { + setAgencyData(prev => ({ + ...prev, + address: '', + street: '', + neighborhood: '', + city: '', + state: '', + })); + } + }} + className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none" + /> +
+ +
+ + +
+ + +
+ +
+ + +
+ + +
+ + setAgencyData({ ...agencyData, number: e.target.value })} + className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none" + /> +
+ +
+ + setAgencyData({ ...agencyData, complement: e.target.value })} + className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-600 focus:outline-none" + /> +
+
+ +
+ +
+
+
+ + {/* Tab 2: Logo e Marca */} + +
+
+

+ Logo e Identidade Visual +

+

+ Configure os logos da sua agência que serão exibidos no sistema +

+
+ +
+ {/* Logo Principal */} +
+
+

+ Logo Principal (Quadrado) +

+

+ Usado no menu lateral e ícones do sistema +

+
+ +
+ {logoPreview ? ( +
+
+
+ Logo Principal +
+ {agencyData.logoUrl && ( +
+
+ + Salvo +
+
+ )} +
+
+ + +
+
+ ) : ( + + )} +
+
+ + {/* Logo Horizontal */} +
+
+

+ Logo Horizontal (Opcional) +

+

+ Usado no cabeçalho e emails +

+
+ +
+ {logoHorizontalPreview ? ( +
+
+
+ Logo Horizontal +
+ {agencyData.logoHorizontalUrl && ( +
+
+ + Salvo +
+
+ )} +
+
+ + +
+
+ ) : ( + + )} +
+
+
+ + {/* Info adicional */} +
+

+ Dica: Para melhores resultados, use imagens de alta qualidade em formato PNG com fundo transparente. +

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

+ Gerenciamento de Equipe +

+ +
+ +

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

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

+ Segurança e Privacidade +

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

+ Alterar Senha +

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

+ Recursos em Desenvolvimento +

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

+ Preferências de Notificações +

+ +
+ +

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

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

{successMessage}

+
+ + + +
+ + {/* Dialog de Suporte */} + setShowSupportDialog(false)} + title="Contatar suporte" + > + +

{supportMessage}

+

Envie um e-mail para suporte@aggios.app ou abra um chamado para ajuste desses dados.

+
+ + + +
+
+ ); +} diff --git a/front-end-dash.aggios.app/app/(agency)/dashboard/page.tsx b/front-end-agency/app/(agency)/dashboard/page.tsx similarity index 100% rename from front-end-dash.aggios.app/app/(agency)/dashboard/page.tsx rename to front-end-agency/app/(agency)/dashboard/page.tsx diff --git a/front-end-dash.aggios.app/app/(agency)/layout.tsx b/front-end-agency/app/(agency)/layout.tsx similarity index 92% rename from front-end-dash.aggios.app/app/(agency)/layout.tsx rename to front-end-agency/app/(agency)/layout.tsx index 3f54734..daeddad 100644 --- a/front-end-dash.aggios.app/app/(agency)/layout.tsx +++ b/front-end-agency/app/(agency)/layout.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState, Fragment } from 'react'; -import { useRouter } from 'next/navigation'; +import { usePathname, useRouter } from 'next/navigation'; import dynamic from 'next/dynamic'; import { Menu, Transition } from '@headlessui/react'; import { @@ -47,6 +47,7 @@ import { const ThemeToggle = dynamic(() => import('@/components/ThemeToggle'), { ssr: false }); const ThemeTester = dynamic(() => import('@/components/ThemeTester'), { ssr: false }); +const DynamicFavicon = dynamic(() => import('@/components/DynamicFavicon'), { ssr: false }); const DEFAULT_GRADIENT = 'linear-gradient(135deg, #ff3a05, #ff0080)'; @@ -63,8 +64,10 @@ export default function AgencyLayout({ children: React.ReactNode; }) { const router = useRouter(); + const pathname = usePathname(); const [user, setUser] = useState(null); const [agencyName, setAgencyName] = useState(''); + const [agencyLogo, setAgencyLogo] = useState(''); const [sidebarOpen, setSidebarOpen] = useState(true); const [searchOpen, setSearchOpen] = useState(false); const [activeSubmenu, setActiveSubmenu] = useState(null); @@ -87,7 +90,6 @@ export default function AgencyLayout({ router.push('/login'); return; } - const parsedUser = JSON.parse(userData); setUser(parsedUser); @@ -98,10 +100,33 @@ export default function AgencyLayout({ const hostname = window.location.hostname; const hostSubdomain = hostname.split('.')[0] || 'default'; - const themeKey = parsedUser?.subdomain || parsedUser?.tenantId || hostSubdomain; + const themeKey = parsedUser?.subdomain || parsedUser?.tenantId || parsedUser?.tenant_id || hostSubdomain; setAgencyName(parsedUser?.subdomain || hostSubdomain); + // Buscar logo da agência + const fetchAgencyLogo = async () => { + try { + const response = await fetch('/api/agency/profile', { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + if (response.ok) { + const data = await response.json(); + if (data.logo_url) { + setAgencyLogo(data.logo_url); + } + } + } catch (error) { + console.error('Erro ao buscar logo da agência:', error); + } + }; + + fetchAgencyLogo(); + const storedGradient = localStorage.getItem(`agency-theme:${themeKey}`); setGradientVariables(storedGradient || DEFAULT_GRADIENT); @@ -126,6 +151,17 @@ export default function AgencyLayout({ }; }, [router]); + useEffect(() => { + const hostname = window.location.hostname; + const hostSubdomain = hostname.split('.')[0] || 'default'; + const userData = localStorage.getItem('user'); + const parsedUser = userData ? JSON.parse(userData) : null; + const themeKey = parsedUser?.subdomain || parsedUser?.tenantId || parsedUser?.tenant_id || hostSubdomain; + const storedGradient = localStorage.getItem(`agency-theme:${themeKey}`) || DEFAULT_GRADIENT; + + setGradientVariables(storedGradient); + }, [pathname]); + if (!user) { return null; } @@ -219,6 +255,9 @@ export default function AgencyLayout({ return (
+ {/* Favicon Dinâmico */} + + {/* Sidebar */}
@@ -251,8 +300,8 @@ export default function AgencyLayout({ key={idx} onClick={() => setActiveSubmenu(isActive ? null : idx)} className={`w-full flex items-center ${(sidebarOpen && activeSubmenu === null) ? 'space-x-3 px-3' : 'justify-center px-0'} py-2.5 mb-1 rounded-lg transition-all group cursor-pointer ${isActive - ? 'bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900' - : 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white' + ? 'bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900' + : 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white' }`} > diff --git a/front-end-dash.aggios.app/app/(auth)/LayoutWrapper.tsx b/front-end-agency/app/(auth)/LayoutWrapper.tsx similarity index 100% rename from front-end-dash.aggios.app/app/(auth)/LayoutWrapper.tsx rename to front-end-agency/app/(auth)/LayoutWrapper.tsx diff --git a/front-end-dash.aggios.app/app/(auth)/cadastro/page.tsx b/front-end-agency/app/(auth)/cadastro/page.tsx similarity index 89% rename from front-end-dash.aggios.app/app/(auth)/cadastro/page.tsx rename to front-end-agency/app/(auth)/cadastro/page.tsx index 80176b1..c504220 100644 --- a/front-end-dash.aggios.app/app/(auth)/cadastro/page.tsx +++ b/front-end-agency/app/(auth)/cadastro/page.tsx @@ -34,8 +34,60 @@ export default function CadastroPage() { const [primaryColor, setPrimaryColor] = useState("#ff3a05"); const [secondaryColor, setSecondaryColor] = useState("#ff0080"); const [logoUrl, setLogoUrl] = useState(""); + const [logoHorizontalUrl, setLogoHorizontalUrl] = useState(""); + const [uploadingLogo, setUploadingLogo] = useState(false); + const [uploadingLogoHorizontal, setUploadingLogoHorizontal] = useState(false); const [showPreviewMobile, setShowPreviewMobile] = useState(false); + // Função para upload de logo + const handleLogoUpload = async (file: File, isHorizontal: boolean = false) => { + if (file.size > 10 * 1024 * 1024) { + toast.error('Arquivo muito grande. Máximo: 10MB'); + return; + } + + if (isHorizontal) { + setUploadingLogoHorizontal(true); + } else { + setUploadingLogo(true); + } + const formData = new FormData(); + formData.append('file', file); + + try { + const token = localStorage.getItem('token'); + const headers: HeadersInit = {}; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch('/api/upload', { + method: 'POST', + headers, + body: formData, + }); + + if (!response.ok) throw new Error('Upload failed'); + + const data = await response.json(); + if (isHorizontal) { + setLogoHorizontalUrl(data.file_url); + } else { + setLogoUrl(data.file_url); + } + toast.success('Logo enviado com sucesso!'); + } catch (error) { + console.error('Erro no upload:', error); + toast.error('Falha ao enviar logo. Tente novamente.'); + } finally { + if (isHorizontal) { + setUploadingLogoHorizontal(false); + } else { + setUploadingLogo(false); + } + } + }; + // Carregar dados do localStorage ao montar useEffect(() => { const saved = localStorage.getItem('cadastroFormData'); @@ -314,6 +366,12 @@ export default function CadastroPage() { number: formData.number, complement: formData.complement, + // Personalização + primaryColor: formData.primaryColor, + secondaryColor: formData.secondaryColor, + logoUrl: logoUrl, + logoHorizontalUrl: logoHorizontalUrl, + // Admin adminEmail: formData.email, adminPassword: password, @@ -334,12 +392,20 @@ export default function CadastroPage() { if (!response.ok) { let errorMessage = 'Erro ao criar conta'; try { - const error = await response.json(); - errorMessage = error.message || error.error || errorMessage; - } catch (e) { const text = await response.text(); - if (text) errorMessage = text; + // Tentar parsear como JSON primeiro + try { + const error = JSON.parse(text); + errorMessage = error.message || error.error || text; + } catch { + // Se não for JSON, usar o texto direto + errorMessage = text || errorMessage; + } + } catch (e) { + // Erro ao ler resposta } + + toast.error(errorMessage, { id: 'register' }); throw new Error(errorMessage); } @@ -573,16 +639,30 @@ export default function CadastroPage() { if (response.ok) { const data = await response.json(); if (!data.erro) { - setCepData({ + const nextCep = { state: data.uf || "", city: data.localidade || "", neighborhood: data.bairro || "", street: data.logradouro || "" - }); + }; + setCepData(nextCep); + setFormData(prev => ({ + ...prev, + state: nextCep.state, + city: nextCep.city, + neighborhood: nextCep.neighborhood, + street: nextCep.street, + })); + } else { + toast.error('CEP não encontrado. Verifique o número.'); + setCepData({ state: "", city: "", neighborhood: "", street: "" }); } + } else { + toast.error('Não foi possível consultar o CEP agora.'); } } catch (error) { console.error("Erro ao buscar CEP:", error); + toast.error('Erro ao buscar CEP. Tente novamente.'); } finally { setLoadingCep(false); } @@ -851,7 +931,7 @@ export default function CadastroPage() {