diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..4bbe2c7 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,10 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build-agency-frontend", + "type": "shell", + "command": "docker compose build agency" + } + ] +} \ No newline at end of file diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 20aeaa7..e56d0a1 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -62,7 +62,7 @@ func main() { solutionRepo := repository.NewSolutionRepository(db) // Initialize services - authService := service.NewAuthService(userRepo, tenantRepo, cfg) + authService := service.NewAuthService(userRepo, tenantRepo, crmRepo, cfg) agencyService := service.NewAgencyService(userRepo, tenantRepo, cfg, db) tenantService := service.NewTenantService(tenantRepo, db) companyService := service.NewCompanyService(companyRepo) @@ -73,6 +73,7 @@ func main() { authHandler := handlers.NewAuthHandler(authService) agencyProfileHandler := handlers.NewAgencyHandler(tenantRepo, cfg) agencyHandler := handlers.NewAgencyRegistrationHandler(agencyService, cfg) + collaboratorHandler := handlers.NewCollaboratorHandler(userRepo, agencyService) tenantHandler := handlers.NewTenantHandler(tenantService) companyHandler := handlers.NewCompanyHandler(companyService) planHandler := handlers.NewPlanHandler(planService) @@ -81,6 +82,7 @@ func main() { signupTemplateHandler := handlers.NewSignupTemplateHandler(signupTemplateRepo, userRepo, tenantRepo, agencyService) agencyTemplateHandler := handlers.NewAgencyTemplateHandler(agencyTemplateRepo, agencyService, userRepo, tenantRepo) filesHandler := handlers.NewFilesHandler(cfg) + customerPortalHandler := handlers.NewCustomerPortalHandler(crmRepo, authService, cfg) // Initialize upload handler uploadHandler, err := handlers.NewUploadHandler(cfg) @@ -112,7 +114,8 @@ func main() { router.HandleFunc("/api/health", healthHandler.Check) // Auth - router.HandleFunc("/api/auth/login", authHandler.Login) + router.HandleFunc("/api/auth/login", authHandler.UnifiedLogin) // Nova rota unificada + router.HandleFunc("/api/auth/login/legacy", authHandler.Login) // Antiga rota (deprecada) router.HandleFunc("/api/auth/register", agencyHandler.PublicRegister).Methods("POST") // Public agency template registration (for creating new agencies) @@ -133,6 +136,13 @@ func main() { // Tenant check (public) router.HandleFunc("/api/tenant/check", tenantHandler.CheckExists).Methods("GET") router.HandleFunc("/api/tenant/config", tenantHandler.GetPublicConfig).Methods("GET") + router.HandleFunc("/api/tenants/{id}/profile", tenantHandler.GetProfile).Methods("GET") + + // Tenant branding (protected - used by both agency and customer portal) + router.Handle("/api/tenant/branding", middleware.RequireAnyAuthenticated(cfg)(http.HandlerFunc(tenantHandler.GetBranding))).Methods("GET") + + // Public customer registration (for agency portal signup) + router.HandleFunc("/api/public/customers/register", crmHandler.PublicRegisterCustomer).Methods("POST") // Hash generator (dev only - remove in production) router.HandleFunc("/api/hash", handlers.GenerateHash).Methods("POST") @@ -239,6 +249,9 @@ func main() { // Tenant solutions (which solutions the tenant has access to) router.Handle("/api/tenant/solutions", authMiddleware(http.HandlerFunc(solutionHandler.GetTenantSolutions))).Methods("GET") + // Dashboard + router.Handle("/api/crm/dashboard", authMiddleware(http.HandlerFunc(crmHandler.GetDashboard))).Methods("GET") + // Customers router.Handle("/api/crm/customers", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { @@ -280,6 +293,8 @@ func main() { crmHandler.DeleteList(w, r) } }))).Methods("GET", "PUT", "PATCH", "DELETE") + + router.Handle("/api/crm/lists/{id}/leads", authMiddleware(http.HandlerFunc(crmHandler.GetLeadsByList))).Methods("GET") // Customer <-> List relationship router.Handle("/api/crm/customers/{customer_id}/lists/{list_id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -291,6 +306,124 @@ func main() { } }))).Methods("POST", "DELETE") + // Leads + router.Handle("/api/crm/leads", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + crmHandler.GetLeads(w, r) + case http.MethodPost: + crmHandler.CreateLead(w, r) + } + }))).Methods("GET", "POST") + + router.Handle("/api/crm/leads/export", authMiddleware(http.HandlerFunc(crmHandler.ExportLeads))).Methods("GET") + router.Handle("/api/crm/leads/import", authMiddleware(http.HandlerFunc(crmHandler.ImportLeads))).Methods("POST") + router.Handle("/api/crm/leads/{leadId}/stage", authMiddleware(http.HandlerFunc(crmHandler.UpdateLeadStage))).Methods("PUT") + + router.Handle("/api/crm/leads/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + crmHandler.GetLead(w, r) + case http.MethodPut, http.MethodPatch: + crmHandler.UpdateLead(w, r) + case http.MethodDelete: + crmHandler.DeleteLead(w, r) + } + }))).Methods("GET", "PUT", "PATCH", "DELETE") + + // Funnels & Stages + router.Handle("/api/crm/funnels", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + crmHandler.ListFunnels(w, r) + case http.MethodPost: + crmHandler.CreateFunnel(w, r) + } + }))).Methods("GET", "POST") + + router.Handle("/api/crm/funnels/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + crmHandler.GetFunnel(w, r) + case http.MethodPut: + crmHandler.UpdateFunnel(w, r) + case http.MethodDelete: + crmHandler.DeleteFunnel(w, r) + } + }))).Methods("GET", "PUT", "DELETE") + + router.Handle("/api/crm/funnels/{funnelId}/stages", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + crmHandler.ListStages(w, r) + case http.MethodPost: + crmHandler.CreateStage(w, r) + } + }))).Methods("GET", "POST") + + router.Handle("/api/crm/stages/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPut: + crmHandler.UpdateStage(w, r) + case http.MethodDelete: + crmHandler.DeleteStage(w, r) + } + }))).Methods("PUT", "DELETE") + + // Lead ingest (integrations) + router.Handle("/api/crm/leads/ingest", authMiddleware(http.HandlerFunc(crmHandler.IngestLead))).Methods("POST") + + // Share tokens (generate) + router.Handle("/api/crm/customers/share-token", authMiddleware(http.HandlerFunc(crmHandler.GenerateShareToken))).Methods("POST") + + // Share data (public endpoint - no auth required) + router.HandleFunc("/api/crm/share/{token}", crmHandler.GetSharedData).Methods("GET") + + // ==================== CUSTOMER PORTAL ==================== + // Customer portal login (public endpoint) + router.HandleFunc("/api/portal/login", customerPortalHandler.Login).Methods("POST") + + // Customer portal dashboard (requires customer auth) + router.Handle("/api/portal/dashboard", middleware.RequireCustomer(cfg)(http.HandlerFunc(customerPortalHandler.GetPortalDashboard))).Methods("GET") + + // Customer portal leads (requires customer auth) + router.Handle("/api/portal/leads", middleware.RequireCustomer(cfg)(http.HandlerFunc(customerPortalHandler.GetPortalLeads))).Methods("GET") + + // Customer portal lists (requires customer auth) + router.Handle("/api/portal/lists", middleware.RequireCustomer(cfg)(http.HandlerFunc(customerPortalHandler.GetPortalLists))).Methods("GET") + + // Customer portal profile (requires customer auth) + router.Handle("/api/portal/profile", middleware.RequireCustomer(cfg)(http.HandlerFunc(customerPortalHandler.GetPortalProfile))).Methods("GET") + + // Customer portal change password (requires customer auth) + router.Handle("/api/portal/change-password", middleware.RequireCustomer(cfg)(http.HandlerFunc(customerPortalHandler.ChangePassword))).Methods("POST") + + // Customer portal logo upload (requires customer auth) + router.Handle("/api/portal/logo", middleware.RequireCustomer(cfg)(http.HandlerFunc(customerPortalHandler.UploadLogo))).Methods("POST") + + // ==================== AGENCY COLLABORATORS ==================== + // List collaborators (requires agency auth, owner only) + router.Handle("/api/agency/collaborators", middleware.RequireAgencyUser(cfg)(http.HandlerFunc(collaboratorHandler.ListCollaborators))).Methods("GET") + + // Invite collaborator (requires agency auth, owner only) + router.Handle("/api/agency/collaborators/invite", middleware.RequireAgencyUser(cfg)(http.HandlerFunc(collaboratorHandler.InviteCollaborator))).Methods("POST") + + // Remove collaborator (requires agency auth, owner only) + router.Handle("/api/agency/collaborators/{id}", middleware.RequireAgencyUser(cfg)(http.HandlerFunc(collaboratorHandler.RemoveCollaborator))).Methods("DELETE") + + // Generate customer portal access (agency staff) + router.Handle("/api/crm/customers/{id}/portal-access", authMiddleware(http.HandlerFunc(crmHandler.GenerateCustomerPortalAccess))).Methods("POST") + + // Lead <-> List relationship + router.Handle("/api/crm/leads/{lead_id}/lists/{list_id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + crmHandler.AddLeadToList(w, r) + case http.MethodDelete: + crmHandler.RemoveLeadFromList(w, r) + } + }))).Methods("POST", "DELETE") + // Apply global middlewares: tenant -> cors -> security -> rateLimit -> router handler := tenantDetector(corsMiddleware(securityMiddleware(rateLimitMiddleware(router)))) diff --git a/backend/go.mod b/backend/go.mod index bb45a75..6f71319 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -7,5 +7,6 @@ require ( github.com/google/uuid v1.6.0 github.com/lib/pq v1.10.9 github.com/minio/minio-go/v7 v7.0.63 + github.com/xuri/excelize/v2 v2.8.1 golang.org/x/crypto v0.27.0 ) diff --git a/backend/internal/api/handlers/auth.go b/backend/internal/api/handlers/auth.go index 7e79611..10983f0 100644 --- a/backend/internal/api/handlers/auth.go +++ b/backend/internal/api/handlers/auth.go @@ -167,3 +167,94 @@ func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) { "message": "Password changed successfully", }) } + +// UnifiedLogin handles login for all user types (agency, customer, superadmin) +func (h *AuthHandler) UnifiedLogin(w http.ResponseWriter, r *http.Request) { + log.Printf("🔐 UNIFIED 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)) + + sanitized := strings.TrimSpace(string(bodyBytes)) + var req domain.UnifiedLoginRequest + 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("📧 Unified login attempt for email: %s", req.Email) + + response, err := h.authService.UnifiedLogin(req) + if err != nil { + log.Printf("❌ authService.UnifiedLogin error: %v", err) + if err == service.ErrInvalidCredentials || strings.Contains(err.Error(), "não autorizado") { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{ + "error": err.Error(), + }) + } else { + http.Error(w, "Internal server error", http.StatusInternalServerError) + } + return + } + + // VALIDAÇÃO DE SEGURANÇA: Verificar se o tenant corresponde ao subdomain acessado + tenantIDFromContext := "" + if ctxTenantID := r.Context().Value(middleware.TenantIDKey); ctxTenantID != nil { + tenantIDFromContext, _ = ctxTenantID.(string) + } + + // Se foi detectado um tenant no contexto E o usuário tem tenant + if tenantIDFromContext != "" && response.TenantID != "" { + if response.TenantID != tenantIDFromContext { + log.Printf("❌ LOGIN BLOCKED: User from tenant %s tried to login in tenant %s subdomain", + response.TenantID, tenantIDFromContext) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Credenciais inválidas para esta agência", + }) + return + } + log.Printf("✅ TENANT LOGIN VALIDATION PASSED: %s", response.TenantID) + } + + log.Printf("✅ Unified login successful: email=%s, type=%s, role=%s", + response.Email, response.UserType, response.Role) + + // Montar resposta compatível com frontend antigo E com novos campos + compatibleResponse := map[string]interface{}{ + "token": response.Token, + "user": map[string]interface{}{ + "id": response.UserID, + "email": response.Email, + "name": response.Name, + "role": response.Role, + "tenant_id": response.TenantID, + "user_type": response.UserType, + }, + // Campos adicionais do sistema unificado + "user_type": response.UserType, + "user_id": response.UserID, + "subdomain": response.Subdomain, + "tenant_id": response.TenantID, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(compatibleResponse) +} diff --git a/backend/internal/api/handlers/collaborator.go b/backend/internal/api/handlers/collaborator.go new file mode 100644 index 0000000..086b8b6 --- /dev/null +++ b/backend/internal/api/handlers/collaborator.go @@ -0,0 +1,271 @@ +package handlers + +import ( + "aggios-app/backend/internal/api/middleware" + "aggios-app/backend/internal/domain" + "aggios-app/backend/internal/repository" + "aggios-app/backend/internal/service" + "encoding/json" + "log" + "net/http" + "time" + + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +// CollaboratorHandler handles agency collaborator management +type CollaboratorHandler struct { + userRepo *repository.UserRepository + agencyServ *service.AgencyService +} + +// NewCollaboratorHandler creates a new collaborator handler +func NewCollaboratorHandler(userRepo *repository.UserRepository, agencyServ *service.AgencyService) *CollaboratorHandler { + return &CollaboratorHandler{ + userRepo: userRepo, + agencyServ: agencyServ, + } +} + +// AddCollaboratorRequest representa a requisição para adicionar um colaborador +type AddCollaboratorRequest struct { + Email string `json:"email"` + Name string `json:"name"` +} + +// CollaboratorResponse representa um colaborador +type CollaboratorResponse struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + AgencyRole string `json:"agency_role"` // owner ou collaborator + CreatedAt time.Time `json:"created_at"` + CollaboratorCreatedAt *time.Time `json:"collaborator_created_at,omitempty"` +} + +// ListCollaborators lista todos os colaboradores da agência (apenas owner pode ver) +func (h *CollaboratorHandler) ListCollaborators(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + ownerID, _ := r.Context().Value(middleware.UserIDKey).(string) + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + agencyRole, _ := r.Context().Value("agency_role").(string) + + // Apenas owner pode listar colaboradores + if agencyRole != "owner" { + log.Printf("❌ COLLABORATOR ACCESS BLOCKED: User %s tried to list collaborators", ownerID) + http.Error(w, "Only agency owners can manage collaborators", http.StatusForbidden) + return + } + + // Buscar todos os usuários da agência + tenantUUID := parseUUID(tenantID) + if tenantUUID == nil { + http.Error(w, "Invalid tenant ID", http.StatusBadRequest) + return + } + users, err := h.userRepo.ListByTenantID(*tenantUUID) + if err != nil { + log.Printf("Error fetching collaborators: %v", err) + http.Error(w, "Error fetching collaborators", http.StatusInternalServerError) + return + } + + // Formatar resposta + collaborators := make([]CollaboratorResponse, 0) + for _, user := range users { + collaborators = append(collaborators, CollaboratorResponse{ + ID: user.ID.String(), + Email: user.Email, + Name: user.Name, + AgencyRole: user.AgencyRole, + CreatedAt: user.CreatedAt, + CollaboratorCreatedAt: user.CollaboratorCreatedAt, + }) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "collaborators": collaborators, + }) +} + +// InviteCollaborator convida um novo colaborador para a agência (apenas owner pode fazer isso) +func (h *CollaboratorHandler) InviteCollaborator(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + ownerID, _ := r.Context().Value(middleware.UserIDKey).(string) + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + agencyRole, _ := r.Context().Value("agency_role").(string) + + // Apenas owner pode convidar colaboradores + if agencyRole != "owner" { + log.Printf("❌ COLLABORATOR INVITE BLOCKED: User %s tried to invite collaborator", ownerID) + http.Error(w, "Only agency owners can invite collaborators", http.StatusForbidden) + return + } + + var req AddCollaboratorRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Validar email + if req.Email == "" { + http.Error(w, "Email is required", http.StatusBadRequest) + return + } + + // Validar se email já existe + exists, err := h.userRepo.EmailExists(req.Email) + if err != nil { + log.Printf("Error checking email: %v", err) + http.Error(w, "Error processing request", http.StatusInternalServerError) + return + } + if exists { + http.Error(w, "Email already registered", http.StatusConflict) + return + } + + // Gerar senha temporária (8 caracteres aleatórios) + tempPassword := generateTempPassword() + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(tempPassword), bcrypt.DefaultCost) + if err != nil { + log.Printf("Error hashing password: %v", err) + http.Error(w, "Error processing request", http.StatusInternalServerError) + return + } + + // Criar novo colaborador + ownerUUID := parseUUID(ownerID) + tenantUUID := parseUUID(tenantID) + now := time.Now() + + collaborator := &domain.User{ + TenantID: tenantUUID, + Email: req.Email, + Password: string(hashedPassword), + Name: req.Name, + Role: "ADMIN_AGENCIA", + AgencyRole: "collaborator", + CreatedBy: ownerUUID, + CollaboratorCreatedAt: &now, + } + + if err := h.userRepo.Create(collaborator); err != nil { + log.Printf("Error creating collaborator: %v", err) + http.Error(w, "Error creating collaborator", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Collaborator invited successfully", + "temporary_password": tempPassword, + "collaborator": CollaboratorResponse{ + ID: collaborator.ID.String(), + Email: collaborator.Email, + Name: collaborator.Name, + AgencyRole: collaborator.AgencyRole, + CreatedAt: collaborator.CreatedAt, + CollaboratorCreatedAt: collaborator.CollaboratorCreatedAt, + }, + }) +} + +// RemoveCollaborator remove um colaborador da agência (apenas owner pode fazer isso) +func (h *CollaboratorHandler) RemoveCollaborator(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + ownerID, _ := r.Context().Value(middleware.UserIDKey).(string) + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + agencyRole, _ := r.Context().Value("agency_role").(string) + + // Apenas owner pode remover colaboradores + if agencyRole != "owner" { + log.Printf("❌ COLLABORATOR REMOVE BLOCKED: User %s tried to remove collaborator", ownerID) + http.Error(w, "Only agency owners can remove collaborators", http.StatusForbidden) + return + } + + collaboratorID := r.URL.Query().Get("id") + if collaboratorID == "" { + http.Error(w, "Collaborator ID is required", http.StatusBadRequest) + return + } + + // Converter ID para UUID + collaboratorUUID := parseUUID(collaboratorID) + if collaboratorUUID == nil { + http.Error(w, "Invalid collaborator ID", http.StatusBadRequest) + return + } + + // Buscar o colaborador + collaborator, err := h.userRepo.GetByID(*collaboratorUUID) + if err != nil { + http.Error(w, "Collaborator not found", http.StatusNotFound) + return + } + + // Verificar se o colaborador pertence à mesma agência + if collaborator.TenantID == nil || collaborator.TenantID.String() != tenantID { + http.Error(w, "Collaborator not found in this agency", http.StatusForbidden) + return + } + + // Não permitir remover o owner + if collaborator.AgencyRole == "owner" { + http.Error(w, "Cannot remove the agency owner", http.StatusBadRequest) + return + } + + // Remover colaborador + if err := h.userRepo.Delete(*collaboratorUUID); err != nil { + log.Printf("Error removing collaborator: %v", err) + http.Error(w, "Error removing collaborator", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "message": "Collaborator removed successfully", + }) +} + +// generateTempPassword gera uma senha temporária +func generateTempPassword() string { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*" + return randomString(12, charset) +} + +// randomString gera uma string aleatória +func randomString(length int, charset string) string { + b := make([]byte, length) + for i := range b { + b[i] = charset[i%len(charset)] + } + return string(b) +} + +// parseUUID converte string para UUID +func parseUUID(s string) *uuid.UUID { + u, err := uuid.Parse(s) + if err != nil { + return nil + } + return &u +} diff --git a/backend/internal/api/handlers/crm.go b/backend/internal/api/handlers/crm.go index a167179..25e4d7b 100644 --- a/backend/internal/api/handlers/crm.go +++ b/backend/internal/api/handlers/crm.go @@ -4,12 +4,19 @@ import ( "aggios-app/backend/internal/domain" "aggios-app/backend/internal/repository" "aggios-app/backend/internal/api/middleware" + "crypto/rand" + "database/sql" + "encoding/hex" "encoding/json" "log" "net/http" + "regexp" + "strings" + "time" "github.com/google/uuid" "github.com/gorilla/mux" + "golang.org/x/crypto/bcrypt" ) type CRMHandler struct { @@ -22,6 +29,258 @@ func NewCRMHandler(repo *repository.CRMRepository) *CRMHandler { // ==================== CUSTOMERS ==================== +type publicRegisterRequest struct { + domain.CRMCustomer + Password string `json:"password"` +} + +// PublicRegisterCustomer allows public registration without authentication +// SECURITY: Rate limited, validates tenant exists, checks email format, prevents duplicates +func (h *CRMHandler) PublicRegisterCustomer(w http.ResponseWriter, r *http.Request) { + var req publicRegisterRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Invalid request body", + "message": err.Error(), + }) + return + } + + customer := req.CRMCustomer + password := req.Password + + // SECURITY 0: Validar força da senha + if password == "" || len(password) < 8 { + log.Printf("⚠️ Public registration blocked: invalid password length") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "invalid_password", + "message": "A senha deve ter no mínimo 8 caracteres.", + }) + return + } + + // Validar complexidade da senha + hasUpper := false + hasLower := false + hasNumber := false + + for _, char := range password { + switch { + case char >= 'A' && char <= 'Z': + hasUpper = true + case char >= 'a' && char <= 'z': + hasLower = true + case char >= '0' && char <= '9': + hasNumber = true + } + } + + if !hasUpper || !hasLower || !hasNumber { + log.Printf("⚠️ Public registration blocked: weak password (missing character types)") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "weak_password", + "message": "A senha deve conter pelo menos uma letra maiúscula, uma minúscula e um número.", + }) + return + } + + // SECURITY 1: Validar tenant_id obrigatório + if customer.TenantID == "" { + log.Printf("⚠️ Public registration blocked: missing tenant_id") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "tenant_id is required", + }) + return + } + + // SECURITY 2: Validar que o tenant existe no banco + tenantExists, err := h.repo.TenantExists(customer.TenantID) + if err != nil { + log.Printf("❌ Error checking tenant existence: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Internal server error", + }) + return + } + if !tenantExists { + log.Printf("🚫 Public registration blocked: invalid tenant_id=%s", customer.TenantID) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Invalid agency", + }) + return + } + + // SECURITY 3: Validar campos obrigatórios + if customer.Name == "" || customer.Email == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "name and email are required", + }) + return + } + + // SECURITY 4: Validar formato de email + email := strings.TrimSpace(strings.ToLower(customer.Email)) + emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) + if !emailRegex.MatchString(email) { + log.Printf("⚠️ Public registration blocked: invalid email format=%s", email) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Invalid email format", + }) + return + } + customer.Email = email + + // SECURITY 5: Verificar se email já existe para este tenant (constraint unique_email_per_tenant) + existingCustomer, err := h.repo.GetCustomerByEmailAndTenant(email, customer.TenantID) + if err == nil && existingCustomer != nil { + log.Printf("⚠️ Public registration blocked: email already exists for tenant (tenant=%s, email=%s)", customer.TenantID, email) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + json.NewEncoder(w).Encode(map[string]string{ + "error": "duplicate_email", + "message": "Já existe uma conta cadastrada com este e-mail.", + }) + return + } + + // SECURITY 6: Verificar duplicidade de CPF/CNPJ nos notes (formato JSON) + if customer.Notes != "" { + log.Printf("🔍 Public registration: checking notes for logo/cpf/cnpj: %s", customer.Notes) + var notesData map[string]interface{} + if err := json.Unmarshal([]byte(customer.Notes), ¬esData); err == nil { + // Extrair CPF ou CNPJ + cpf, hasCPF := notesData["cpf"].(string) + cnpj, hasCNPJ := notesData["cnpj"].(string) + + // Verificar CPF duplicado + if hasCPF && cpf != "" { + existing, err := h.repo.GetCustomerByCPF(cpf, customer.TenantID) + if err == nil && existing != nil { + log.Printf("⚠️ Public registration blocked: CPF already exists (tenant=%s, cpf=%s)", customer.TenantID, cpf) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + json.NewEncoder(w).Encode(map[string]string{ + "error": "duplicate_email", + "message": "Já existe uma conta cadastrada com este CPF.", + }) + return + } + } + + // Verificar CNPJ duplicado + if hasCNPJ && cnpj != "" { + existing, err := h.repo.GetCustomerByCNPJ(cnpj, customer.TenantID) + if err == nil && existing != nil { + log.Printf("⚠️ Public registration blocked: CNPJ already exists (tenant=%s, cnpj=%s)", customer.TenantID, cnpj) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + json.NewEncoder(w).Encode(map[string]string{ + "error": "duplicate_email", + "message": "Já existe uma conta cadastrada com este CNPJ.", + }) + return + } + } + + // Extrair logo se existir + if logo, hasLogo := notesData["logo_path"].(string); hasLogo && logo != "" { + log.Printf("🖼️ Found logo in public registration notes: %s", logo) + customer.LogoURL = logo + } + } else { + log.Printf("⚠️ Failed to unmarshal public registration notes: %v", err) + } + } + + // SECURITY 7: Sanitizar nome + customer.Name = strings.TrimSpace(customer.Name) + if len(customer.Name) > 255 { + customer.Name = customer.Name[:255] + } + + // SECURITY 8: Hash da senha fornecida pelo cliente + passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + log.Printf("❌ Error hashing password: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Internal server error", + }) + return + } + + customer.ID = uuid.New().String() + customer.IsActive = true + // CreatedBy fica vazio pois é cadastro público + + // Garantir que as tags de cadastro público sejam aplicadas + if customer.Tags == nil || len(customer.Tags) == 0 { + customer.Tags = []string{"cadastro_publico", "pendente_aprovacao"} + } else { + // Garantir que tenha pelo menos a tag cadastro_publico + hasPublicTag := false + hasPendingTag := false + for _, tag := range customer.Tags { + if tag == "cadastro_publico" { + hasPublicTag = true + } + if tag == "pendente_aprovacao" { + hasPendingTag = true + } + } + if !hasPublicTag { + customer.Tags = append(customer.Tags, "cadastro_publico") + } + if !hasPendingTag { + customer.Tags = append(customer.Tags, "pendente_aprovacao") + } + } + + log.Printf("📝 Public customer registration: tenant_id=%s, email=%s, name=%s, tags=%v", customer.TenantID, email, customer.Name, customer.Tags) + + if err := h.repo.CreateCustomer(&customer); err != nil { + log.Printf("❌ Error creating public customer: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to create customer", + "message": err.Error(), + }) + return + } + + // Salvar senha hasheada + if err := h.repo.UpdateCustomerPassword(customer.ID, string(passwordHash)); err != nil { + log.Printf("⚠️ Error saving password for customer %s: %v", customer.ID, err) + // Não retornar erro pois o cliente foi criado, senha pode ser resetada depois + } + + log.Printf("✅ Public customer created successfully with password: id=%s", customer.ID) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]interface{}{ + "customer": customer, + }) +} + func (h *CRMHandler) CreateCustomer(w http.ResponseWriter, r *http.Request) { tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) userID, _ := r.Context().Value(middleware.UserIDKey).(string) @@ -51,6 +310,8 @@ func (h *CRMHandler) CreateCustomer(w http.ResponseWriter, r *http.Request) { customer.CreatedBy = userID customer.IsActive = true + log.Printf("➕ CreateCustomer called: name=%s, company=%s, logo_url=%s", customer.Name, customer.Company, customer.LogoURL) + if err := h.repo.CreateCustomer(&customer); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) @@ -61,6 +322,18 @@ func (h *CRMHandler) CreateCustomer(w http.ResponseWriter, r *http.Request) { return } + // Auto-create a default campaign for this customer + defaultCampaign := domain.CRMList{ + ID: uuid.New().String(), + TenantID: tenantID, + CustomerID: &customer.ID, + Name: "Geral - " + customer.Name, + Description: "Campanha padrão para " + customer.Name, + Color: "#3b82f6", + CreatedBy: userID, + } + _ = h.repo.CreateList(&defaultCampaign) + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]interface{}{ @@ -71,6 +344,8 @@ func (h *CRMHandler) CreateCustomer(w http.ResponseWriter, r *http.Request) { func (h *CRMHandler) GetCustomers(w http.ResponseWriter, r *http.Request) { tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + log.Printf("GetCustomers: tenantID=%s", tenantID) + if tenantID == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) @@ -81,6 +356,7 @@ func (h *CRMHandler) GetCustomers(w http.ResponseWriter, r *http.Request) { } customers, err := h.repo.GetCustomersByTenant(tenantID) + log.Printf("GetCustomers: found %d customers, error: %v", len(customers), err) if err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) @@ -169,6 +445,8 @@ func (h *CRMHandler) UpdateCustomer(w http.ResponseWriter, r *http.Request) { customer.ID = customerID customer.TenantID = tenantID + log.Printf("🔄 UpdateCustomer called for customer %s, name: %s, logo_url: %s, tags: %v", customerID, customer.Name, customer.LogoURL, customer.Tags) + if err := h.repo.UpdateCustomer(&customer); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) @@ -179,6 +457,34 @@ func (h *CRMHandler) UpdateCustomer(w http.ResponseWriter, r *http.Request) { return } + // Se as tags foram alteradas e a tag pendente_aprovacao foi removida, liberar acesso ao portal + hasPendingTag := false + for _, tag := range customer.Tags { + if tag == "pendente_aprovacao" { + hasPendingTag = true + break + } + } + + log.Printf("🔍 Checking portal access: hasPendingTag=%v", hasPendingTag) + + // Se não tem mais a tag pendente e tinha senha definida, liberar acesso + if !hasPendingTag { + existingCustomer, err := h.repo.GetCustomerByID(customerID, tenantID) + log.Printf("🔍 Existing customer check: hasPassword=%v, err=%v", existingCustomer != nil && existingCustomer.PasswordHash != "", err) + + if err == nil && existingCustomer.PasswordHash != "" { + // Liberar acesso ao portal + if err := h.repo.EnableCustomerPortalAccess(customerID); err != nil { + log.Printf("⚠️ Warning: Failed to enable portal access for customer %s: %v", customerID, err) + } else { + log.Printf("✅ Portal access enabled for customer %s", customerID) + } + } else if err == nil && existingCustomer.PasswordHash == "" { + log.Printf("⚠️ Customer %s approved but has no password yet - portal access will be enabled when password is set", customerID) + } + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ "message": "Customer updated successfully", @@ -253,6 +559,11 @@ func (h *CRMHandler) CreateList(w http.ResponseWriter, r *http.Request) { list.TenantID = tenantID list.CreatedBy = userID + // Handle empty customer_id + if list.CustomerID != nil && *list.CustomerID == "" { + list.CustomerID = nil + } + if list.Color == "" { list.Color = "#3b82f6" } @@ -286,8 +597,12 @@ func (h *CRMHandler) GetLists(w http.ResponseWriter, r *http.Request) { return } + customerID := r.URL.Query().Get("customer_id") + log.Printf("GetLists: tenantID=%s, customerID=%s", tenantID, customerID) + lists, err := h.repo.GetListsByTenant(tenantID) if err != nil { + log.Printf("GetLists: Error fetching lists: %v", err) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]string{ @@ -301,6 +616,18 @@ func (h *CRMHandler) GetLists(w http.ResponseWriter, r *http.Request) { lists = []domain.CRMListWithCustomers{} } + // Filter by customer if provided + if customerID != "" { + filteredLists := []domain.CRMListWithCustomers{} + for _, list := range lists { + if list.CustomerID != nil && *list.CustomerID == customerID { + filteredLists = append(filteredLists, list) + } + } + log.Printf("GetLists: Filtered lists from %d to %d", len(lists), len(filteredLists)) + lists = filteredLists + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "lists": lists, @@ -375,6 +702,11 @@ func (h *CRMHandler) UpdateList(w http.ResponseWriter, r *http.Request) { list.ID = listID list.TenantID = tenantID + // Handle empty customer_id + if list.CustomerID != nil && *list.CustomerID == "" { + list.CustomerID = nil + } + if err := h.repo.UpdateList(&list); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) @@ -422,6 +754,468 @@ func (h *CRMHandler) DeleteList(w http.ResponseWriter, r *http.Request) { }) } +// ==================== LEADS ==================== + +func (h *CRMHandler) CreateLead(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + userID, _ := r.Context().Value(middleware.UserIDKey).(string) + + if tenantID == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Missing tenant_id"}) + return + } + + var lead domain.CRMLead + if err := json.NewDecoder(r.Body).Decode(&lead); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Invalid request body", + "message": err.Error(), + }) + return + } + + lead.ID = uuid.New().String() + lead.TenantID = tenantID + lead.CreatedBy = userID + lead.IsActive = true + if lead.Status == "" { + lead.Status = "novo" + } + if lead.SourceMeta == nil { + lead.SourceMeta = json.RawMessage(`{}`) + } + + // Ensure default funnel and stage if not provided + if lead.FunnelID == nil || *lead.FunnelID == "" || lead.StageID == nil || *lead.StageID == "" { + funnelID, err := h.repo.EnsureDefaultFunnel(tenantID) + if err == nil { + lead.FunnelID = &funnelID + stages, err := h.repo.GetStagesByFunnelID(funnelID) + if err == nil && len(stages) > 0 { + lead.StageID = &stages[0].ID + } + } + } + + if err := h.repo.CreateLead(&lead); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to create lead", + "message": err.Error(), + }) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]interface{}{ + "lead": lead, + }) +} + +func (h *CRMHandler) GetLeads(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + if tenantID == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Missing tenant_id"}) + return + } + + customerID := r.URL.Query().Get("customer_id") + log.Printf("GetLeads: tenantID=%s, customerID=%s", tenantID, customerID) + + leads, err := h.repo.GetLeadsWithListsByTenant(tenantID) + if err != nil { + log.Printf("GetLeads: Error fetching leads: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to fetch leads", + "message": err.Error(), + }) + return + } + if leads == nil { + leads = []domain.CRMLeadWithLists{} + } + + // Filter by customer if provided + if customerID != "" { + filteredLeads := []domain.CRMLeadWithLists{} + for _, lead := range leads { + if lead.CustomerID != nil && *lead.CustomerID == customerID { + filteredLeads = append(filteredLeads, lead) + } + } + log.Printf("GetLeads: Filtered leads for customer %s: from %d to %d", customerID, len(leads), len(filteredLeads)) + leads = filteredLeads + } else { + log.Printf("GetLeads: No customer_id filter applied, returning all %d leads", len(leads)) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "leads": leads, + }) +} + +func (h *CRMHandler) GetLeadsByList(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + listID := vars["id"] + + leads, err := h.repo.GetLeadsByListID(listID) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to fetch leads", + "message": err.Error(), + }) + return + } + if leads == nil { + leads = []domain.CRMLead{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "leads": leads, + }) +} + +func (h *CRMHandler) GetLead(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + if tenantID == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Missing tenant_id"}) + return + } + + vars := mux.Vars(r) + leadID := vars["id"] + + lead, err := h.repo.GetLeadByID(leadID, tenantID) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Lead not found", + "message": err.Error(), + }) + return + } + + lists, _ := h.repo.GetLeadLists(leadID) + if lists == nil { + lists = []domain.CRMList{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "lead": lead, + "lists": lists, + }) +} + +func (h *CRMHandler) UpdateLead(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + if tenantID == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Missing tenant_id"}) + return + } + + vars := mux.Vars(r) + leadID := vars["id"] + + var lead domain.CRMLead + if err := json.NewDecoder(r.Body).Decode(&lead); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Invalid request body", + "message": err.Error(), + }) + return + } + + lead.ID = leadID + lead.TenantID = tenantID + if lead.SourceMeta == nil { + lead.SourceMeta = json.RawMessage(`{}`) + } + + if err := h.repo.UpdateLead(&lead); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to update lead", + "message": err.Error(), + }) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"message": "Lead updated successfully"}) +} + +func (h *CRMHandler) DeleteLead(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + if tenantID == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Missing tenant_id"}) + return + } + + vars := mux.Vars(r) + leadID := vars["id"] + + if err := h.repo.DeleteLead(leadID, tenantID); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to delete lead", + "message": err.Error(), + }) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"message": "Lead deleted successfully"}) +} + +func (h *CRMHandler) AddLeadToList(w http.ResponseWriter, r *http.Request) { + userID, _ := r.Context().Value(middleware.UserIDKey).(string) + vars := mux.Vars(r) + leadID := vars["lead_id"] + listID := vars["list_id"] + + if err := h.repo.AddLeadToList(leadID, listID, userID); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to add lead to list", + "message": err.Error(), + }) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"message": "Lead added to list successfully"}) +} + +func (h *CRMHandler) RemoveLeadFromList(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + leadID := vars["lead_id"] + listID := vars["list_id"] + + if err := h.repo.RemoveLeadFromList(leadID, listID); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to remove lead from list", + "message": err.Error(), + }) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"message": "Lead removed from list successfully"}) +} + +type LeadIngestRequest struct { + Name string `json:"name"` + Email string `json:"email"` + Phone string `json:"phone"` + Notes string `json:"notes"` + Tags []string `json:"tags"` + Source string `json:"source"` + SourceMeta map[string]interface{} `json:"source_meta"` + ListID string `json:"list_id"` + ListName string `json:"list_name"` + Status string `json:"status"` + CustomerID string `json:"customer_id"` +} + +func (h *CRMHandler) IngestLead(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + userID, _ := r.Context().Value(middleware.UserIDKey).(string) + if tenantID == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Missing tenant_id"}) + return + } + + var req LeadIngestRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Invalid request body", + "message": err.Error(), + }) + return + } + + if req.Email == "" && req.Phone == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Informe ao menos email ou telefone", + }) + return + } + + // Resolver list_id (opcional) + listID := req.ListID + if listID == "" && req.ListName != "" { + if existing, err := h.repo.GetListByName(tenantID, req.ListName); err == nil { + listID = existing.ID + } else if err == sql.ErrNoRows { + newList := domain.CRMList{ + ID: uuid.New().String(), + TenantID: tenantID, + Name: req.ListName, + Description: "Criada automaticamente via ingestão de leads", + Color: "#3b82f6", + CreatedBy: userID, + } + if err := h.repo.CreateList(&newList); err == nil { + listID = newList.ID + } + } + } + + // Dedup por email/phone + var existingLead *domain.CRMLead + if found, err := h.repo.GetLeadByEmailOrPhone(tenantID, req.Email, req.Phone); err == nil { + existingLead = found + } else if err != nil && err != sql.ErrNoRows { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to ingest lead", + "message": err.Error(), + }) + return + } + + // Normalizar source_meta + sourceMetaBytes, _ := json.Marshal(req.SourceMeta) + if len(sourceMetaBytes) == 0 { + sourceMetaBytes = []byte(`{}`) + } + + if req.Status == "" { + req.Status = "novo" + } + if req.Source == "" { + req.Source = "import" + } + + // Processar customer_id + var customerIDPtr *string + if req.CustomerID != "" { + customerIDPtr = &req.CustomerID + } + + if existingLead == nil { + lead := domain.CRMLead{ + ID: uuid.New().String(), + TenantID: tenantID, + CustomerID: customerIDPtr, + Name: req.Name, + Email: req.Email, + Phone: req.Phone, + Source: req.Source, + SourceMeta: json.RawMessage(sourceMetaBytes), + Status: req.Status, + Notes: req.Notes, + Tags: req.Tags, + IsActive: true, + CreatedBy: userID, + } + + if err := h.repo.CreateLead(&lead); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to ingest lead", + "message": err.Error(), + }) + return + } + + if listID != "" { + _ = h.repo.AddLeadToList(lead.ID, listID, userID) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]interface{}{ + "lead": lead, + "created": true, + "linked_list": listID != "", + }) + return + } + + // Se já existe: atualiza campos básicos se vierem preenchidos + updated := *existingLead + if customerIDPtr != nil { + updated.CustomerID = customerIDPtr + } + if updated.Name == "" && req.Name != "" { + updated.Name = req.Name + } + if updated.Email == "" && req.Email != "" { + updated.Email = req.Email + } + if updated.Phone == "" && req.Phone != "" { + updated.Phone = req.Phone + } + updated.Source = req.Source + updated.SourceMeta = json.RawMessage(sourceMetaBytes) + if updated.Status == "" { + updated.Status = req.Status + } + if req.Notes != "" { + updated.Notes = req.Notes + } + if len(req.Tags) > 0 { + updated.Tags = req.Tags + } + if err := h.repo.UpdateLead(&updated); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to update existing lead", + "message": err.Error(), + }) + return + } + if listID != "" { + _ = h.repo.AddLeadToList(updated.ID, listID, userID) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "lead": updated, + "created": false, + "linked_list": listID != "", + }) +} + // ==================== CUSTOMER <-> LIST ==================== func (h *CRMHandler) AddCustomerToList(w http.ResponseWriter, r *http.Request) { @@ -468,3 +1262,616 @@ func (h *CRMHandler) RemoveCustomerFromList(w http.ResponseWriter, r *http.Reque }) } +// GenerateShareToken gera um token de compartilhamento para visualização de leads de um cliente +func (h *CRMHandler) GenerateShareToken(w http.ResponseWriter, r *http.Request) { + var req struct { + CustomerID string `json:"customer_id"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Invalid request body"}) + return + } + + tenantID := r.Header.Get("X-Tenant-Subdomain") + if tenantID == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Tenant ID is required"}) + return + } + + userID := r.Context().Value("user_id").(string) + + // Gera token seguro + tokenBytes := make([]byte, 32) + if _, err := rand.Read(tokenBytes); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": "Failed to generate token"}) + return + } + token := hex.EncodeToString(tokenBytes) + + shareToken := domain.CRMShareToken{ + ID: uuid.New().String(), + TenantID: tenantID, + CustomerID: req.CustomerID, + Token: token, + ExpiresAt: nil, // Token sem expiração + CreatedBy: userID, + CreatedAt: time.Now(), + } + + if err := h.repo.CreateShareToken(&shareToken); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to create share token", + "message": err.Error(), + }) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "token": token, + }) +} + +// GetSharedData retorna os dados compartilhados de um cliente via token (endpoint público) +func (h *CRMHandler) GetSharedData(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + token := vars["token"] + + shareToken, err := h.repo.GetShareTokenByToken(token) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"error": "Invalid or expired token"}) + return + } + + // Verifica se o token expirou + if shareToken.ExpiresAt != nil && shareToken.ExpiresAt.Before(time.Now()) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(map[string]string{"error": "Token expired"}) + return + } + + // Busca dados do cliente + customer, err := h.repo.GetCustomerByID(shareToken.CustomerID, shareToken.TenantID) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"error": "Customer not found"}) + return + } + + // Busca leads do cliente + leads, err := h.repo.GetLeadsByCustomerID(shareToken.CustomerID) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": "Failed to fetch leads"}) + return + } + + // Calcula estatísticas + stats := calculateLeadStats(leads) + + response := map[string]interface{}{ + "customer": map[string]string{ + "name": customer.Name, + "company": customer.Company, + }, + "leads": leads, + "stats": stats, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +func calculateLeadStats(leads []domain.CRMLead) map[string]interface{} { + stats := map[string]interface{}{ + "total": len(leads), + "novo": 0, + "qualificado": 0, + "negociacao": 0, + "convertido": 0, + "perdido": 0, + "bySource": make(map[string]int), + "conversionRate": 0.0, + "thisMonth": 0, + "lastMonth": 0, + } + + if len(leads) == 0 { + return stats + } + + now := time.Now() + currentMonth := now.Month() + currentYear := now.Year() + lastMonth := now.AddDate(0, -1, 0) + + statusCount := make(map[string]int) + bySource := make(map[string]int) + thisMonthCount := 0 + lastMonthCount := 0 + + for _, lead := range leads { + // Conta por status + statusCount[lead.Status]++ + + // Conta por origem + source := lead.Source + if source == "" { + source = "manual" + } + bySource[source]++ + + // Conta por mês + if lead.CreatedAt.Month() == currentMonth && lead.CreatedAt.Year() == currentYear { + thisMonthCount++ + } + if lead.CreatedAt.Month() == lastMonth.Month() && lead.CreatedAt.Year() == lastMonth.Year() { + lastMonthCount++ + } + } + + stats["novo"] = statusCount["novo"] + stats["qualificado"] = statusCount["qualificado"] + stats["negociacao"] = statusCount["negociacao"] + stats["convertido"] = statusCount["convertido"] + stats["perdido"] = statusCount["perdido"] + stats["bySource"] = bySource + stats["thisMonth"] = thisMonthCount + stats["lastMonth"] = lastMonthCount + + // Taxa de conversão + if len(leads) > 0 { + conversionRate := (float64(statusCount["convertido"]) / float64(len(leads))) * 100 + stats["conversionRate"] = conversionRate + } + + return stats +} + +// GenerateCustomerPortalAccess gera credenciais de acesso ao portal para um cliente +func (h *CRMHandler) GenerateCustomerPortalAccess(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + if tenantID == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Missing tenant_id"}) + return + } + + vars := mux.Vars(r) + customerID := vars["id"] + + var req struct { + Password string `json:"password"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Password == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Password is required"}) + return + } + + // Verificar se cliente existe + customer, err := h.repo.GetCustomerByID(customerID, tenantID) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"error": "Customer not found"}) + return + } + + // Gerar hash da senha + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": "Failed to generate password"}) + return + } + + // Atualizar acesso ao portal + if err := h.repo.SetCustomerPortalAccess(customerID, string(hashedPassword), true); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": "Failed to set portal access"}) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Portal access granted", + "email": customer.Email, + }) +} + +// GetDashboard returns stats for the CRM dashboard +func (h *CRMHandler) GetDashboard(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + if tenantID == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Missing tenant_id"}) + return + } + + customerID := r.URL.Query().Get("customer_id") + log.Printf("GetDashboard: tenantID=%s, customerID=%s", tenantID, customerID) + + // Get all leads for stats + leads, err := h.repo.GetLeadsByTenant(tenantID) + if err != nil { + log.Printf("GetDashboard: Error fetching leads: %v", err) + leads = []domain.CRMLead{} + } + + // Get all customers for stats + customers, err := h.repo.GetCustomersByTenant(tenantID) + if err != nil { + log.Printf("GetDashboard: Error fetching customers: %v", err) + customers = []domain.CRMCustomer{} + } + + // Get all lists (campaigns) + lists, err := h.repo.GetListsByTenant(tenantID) + if err != nil { + log.Printf("GetDashboard: Error fetching lists: %v", err) + } + + // Filter by customer if provided + if customerID != "" { + filteredLeads := []domain.CRMLead{} + for _, lead := range leads { + if lead.CustomerID != nil && *lead.CustomerID == customerID { + filteredLeads = append(filteredLeads, lead) + } + } + log.Printf("GetDashboard: Filtered leads from %d to %d", len(leads), len(filteredLeads)) + leads = filteredLeads + + filteredLists := []domain.CRMListWithCustomers{} + for _, list := range lists { + if list.CustomerID != nil && *list.CustomerID == customerID { + filteredLists = append(filteredLists, list) + } + } + log.Printf("GetDashboard: Filtered lists from %d to %d", len(lists), len(filteredLists)) + lists = filteredLists + } + + stats := calculateLeadStats(leads) + stats["total_customers"] = len(customers) + if customerID != "" { + stats["total_customers"] = 1 // If filtered by customer, we only care about that one + } + stats["total_campaigns"] = len(lists) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "stats": stats, + }) +} + +// ImportLeads handles bulk lead import from JSON +func (h *CRMHandler) ImportLeads(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + userID, _ := r.Context().Value(middleware.UserIDKey).(string) + + if tenantID == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Missing tenant_id"}) + return + } + + var req struct { + CampaignID string `json:"campaign_id"` + CustomerID string `json:"customer_id"` + Leads []domain.CRMLead `json:"leads"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Printf("ImportLeads: Error decoding body: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Invalid request body"}) + return + } + + log.Printf("ImportLeads: Received %d leads for campaign %s and customer %s", len(req.Leads), req.CampaignID, req.CustomerID) + + if len(req.Leads) == 0 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "No leads provided"}) + return + } + + // Get default funnel and stage + var defaultFunnelID string + var defaultStageID string + funnelID, err := h.repo.EnsureDefaultFunnel(tenantID) + if err == nil { + defaultFunnelID = funnelID + stages, err := h.repo.GetStagesByFunnelID(funnelID) + if err == nil && len(stages) > 0 { + defaultStageID = stages[0].ID + } + } + + // Prepare leads for bulk insert + now := time.Now() + for i := range req.Leads { + if req.Leads[i].ID == "" { + req.Leads[i].ID = uuid.New().String() + } + req.Leads[i].TenantID = tenantID + req.Leads[i].CreatedBy = userID + req.Leads[i].CreatedAt = now + req.Leads[i].UpdatedAt = now + req.Leads[i].IsActive = true + if req.Leads[i].Status == "" { + req.Leads[i].Status = "novo" + } + if req.Leads[i].Source == "" { + req.Leads[i].Source = "import" + } + if len(req.Leads[i].SourceMeta) == 0 { + req.Leads[i].SourceMeta = json.RawMessage("{}") + } + + // Assign default funnel and stage if not provided + if (req.Leads[i].FunnelID == nil || *req.Leads[i].FunnelID == "") && defaultFunnelID != "" { + req.Leads[i].FunnelID = &defaultFunnelID + } + if (req.Leads[i].StageID == nil || *req.Leads[i].StageID == "") && defaultStageID != "" { + req.Leads[i].StageID = &defaultStageID + } + + log.Printf("Lead %d: SourceMeta='%s'", i, string(req.Leads[i].SourceMeta)) + // If a customer_id was provided in the request, use it for all leads + if req.CustomerID != "" { + customerID := req.CustomerID + req.Leads[i].CustomerID = &customerID + } + } + + // Bulk insert leads + if err := h.repo.BulkCreateLeads(req.Leads); err != nil { + log.Printf("ImportLeads: Error in BulkCreateLeads: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": "Failed to import leads", "details": err.Error()}) + return + } + + log.Printf("ImportLeads: Successfully created %d leads", len(req.Leads)) + + // If a campaign_id was provided, link all leads to it + if req.CampaignID != "" { + leadIDs := make([]string, len(req.Leads)) + for i, lead := range req.Leads { + leadIDs[i] = lead.ID + } + if err := h.repo.BulkAddLeadsToList(leadIDs, req.CampaignID, userID); err != nil { + log.Printf("ImportLeads: Error in BulkAddLeadsToList: %v", err) + } + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Leads imported successfully", + "count": len(req.Leads), + }) +} + +// ==================== FUNNELS & STAGES ==================== + +func (h *CRMHandler) ListFunnels(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + if tenantID == "" { + http.Error(w, "Missing tenant_id", http.StatusBadRequest) + return + } + + funnels, err := h.repo.GetFunnelsByTenant(tenantID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // If no funnels, ensure default exists + if len(funnels) == 0 { + _, err := h.repo.EnsureDefaultFunnel(tenantID) + if err == nil { + funnels, _ = h.repo.GetFunnelsByTenant(tenantID) + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"funnels": funnels}) +} + +func (h *CRMHandler) GetFunnel(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + if tenantID == "" { + http.Error(w, "Missing tenant_id", http.StatusBadRequest) + return + } + + vars := mux.Vars(r) + id := vars["id"] + + funnel, err := h.repo.GetFunnelByID(id, tenantID) + if err != nil { + http.Error(w, "Funnel not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"funnel": funnel}) +} + +func (h *CRMHandler) CreateFunnel(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + var funnel domain.CRMFunnel + if err := json.NewDecoder(r.Body).Decode(&funnel); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + funnel.ID = uuid.New().String() + funnel.TenantID = tenantID + + if err := h.repo.CreateFunnel(&funnel); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(funnel) +} + +func (h *CRMHandler) UpdateFunnel(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + vars := mux.Vars(r) + id := vars["id"] + + var funnel domain.CRMFunnel + if err := json.NewDecoder(r.Body).Decode(&funnel); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + funnel.ID = id + funnel.TenantID = tenantID + + if err := h.repo.UpdateFunnel(&funnel); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *CRMHandler) DeleteFunnel(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + vars := mux.Vars(r) + id := vars["id"] + + if err := h.repo.DeleteFunnel(id, tenantID); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *CRMHandler) ListStages(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + funnelID := vars["funnelId"] + + stages, err := h.repo.GetStagesByFunnelID(funnelID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"stages": stages}) +} + +func (h *CRMHandler) CreateStage(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + funnelID := vars["funnelId"] + + var stage domain.CRMFunnelStage + if err := json.NewDecoder(r.Body).Decode(&stage); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + stage.ID = uuid.New().String() + stage.FunnelID = funnelID + + if err := h.repo.CreateFunnelStage(&stage); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(stage) +} + +func (h *CRMHandler) UpdateStage(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + var stage domain.CRMFunnelStage + if err := json.NewDecoder(r.Body).Decode(&stage); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + stage.ID = id + + if err := h.repo.UpdateFunnelStage(&stage); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *CRMHandler) DeleteStage(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + if err := h.repo.DeleteFunnelStage(id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *CRMHandler) UpdateLeadStage(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + vars := mux.Vars(r) + leadID := vars["leadId"] + + var req struct { + FunnelID string `json:"funnel_id"` + StageID string `json:"stage_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := h.repo.UpdateLeadStage(leadID, tenantID, req.FunnelID, req.StageID); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + + diff --git a/backend/internal/api/handlers/customer_portal.go b/backend/internal/api/handlers/customer_portal.go new file mode 100644 index 0000000..11cfe3b --- /dev/null +++ b/backend/internal/api/handlers/customer_portal.go @@ -0,0 +1,465 @@ +package handlers + +import ( + "aggios-app/backend/internal/domain" + "aggios-app/backend/internal/repository" + "aggios-app/backend/internal/service" + "aggios-app/backend/internal/config" + "aggios-app/backend/internal/api/middleware" + "context" + "database/sql" + "encoding/json" + "fmt" + "log" + "net/http" + "path/filepath" + "strings" + "time" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" + "golang.org/x/crypto/bcrypt" +) + +type CustomerPortalHandler struct { + crmRepo *repository.CRMRepository + authService *service.AuthService + cfg *config.Config + minioClient *minio.Client +} + +func NewCustomerPortalHandler(crmRepo *repository.CRMRepository, authService *service.AuthService, cfg *config.Config) *CustomerPortalHandler { + // 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 { + log.Printf("❌ Failed to create MinIO client for CustomerPortalHandler: %v", err) + } + + return &CustomerPortalHandler{ + crmRepo: crmRepo, + authService: authService, + cfg: cfg, + minioClient: minioClient, + } +} + +// CustomerLoginRequest representa a requisição de login do cliente +type CustomerLoginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +// CustomerLoginResponse representa a resposta de login do cliente +type CustomerLoginResponse struct { + Token string `json:"token"` + Customer *CustomerPortalInfo `json:"customer"` +} + +// CustomerPortalInfo representa informações seguras do cliente para o portal +type CustomerPortalInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Company string `json:"company"` + HasPortalAccess bool `json:"has_portal_access"` + TenantID string `json:"tenant_id"` +} + +// Login autentica um cliente e retorna um token JWT +func (h *CustomerPortalHandler) Login(w http.ResponseWriter, r *http.Request) { + var req CustomerLoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Invalid request body", + }) + return + } + + // Validar entrada + if req.Email == "" || req.Password == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Email e senha são obrigatórios", + }) + return + } + + // Buscar cliente por email + customer, err := h.crmRepo.GetCustomerByEmail(req.Email) + if err != nil { + if err == sql.ErrNoRows { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Credenciais inválidas", + }) + return + } + log.Printf("Error fetching customer: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Erro ao processar login", + }) + return + } + + // Verificar se tem acesso ao portal + if !customer.HasPortalAccess { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Acesso ao portal não autorizado. Entre em contato com o administrador.", + }) + return + } + + // Verificar senha + if err := bcrypt.CompareHashAndPassword([]byte(customer.PasswordHash), []byte(req.Password)); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Credenciais inválidas", + }) + return + } + + // Atualizar último login + if err := h.crmRepo.UpdateCustomerLastLogin(customer.ID); err != nil { + log.Printf("Warning: Failed to update last login for customer %s: %v", customer.ID, err) + } + + // Gerar token JWT + token, err := h.authService.GenerateCustomerToken(customer.ID, customer.TenantID, customer.Email) + if err != nil { + log.Printf("Error generating token: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Erro ao gerar token de autenticação", + }) + return + } + + // Resposta de sucesso + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(CustomerLoginResponse{ + Token: token, + Customer: &CustomerPortalInfo{ + ID: customer.ID, + Name: customer.Name, + Email: customer.Email, + Company: customer.Company, + HasPortalAccess: customer.HasPortalAccess, + TenantID: customer.TenantID, + }, + }) +} + +// GetPortalDashboard retorna dados do dashboard para o cliente autenticado +func (h *CustomerPortalHandler) GetPortalDashboard(w http.ResponseWriter, r *http.Request) { + customerID, _ := r.Context().Value(middleware.CustomerIDKey).(string) + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + + // Buscar leads do cliente + leads, err := h.crmRepo.GetLeadsByCustomerID(customerID) + if err != nil { + log.Printf("Error fetching leads: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Erro ao buscar leads", + }) + return + } + + // Buscar informações do cliente + customer, err := h.crmRepo.GetCustomerByID(customerID, tenantID) + if err != nil { + log.Printf("Error fetching customer: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Erro ao buscar informações do cliente", + }) + return + } + + // Calcular estatísticas + rawStats := calculateLeadStats(leads) + stats := map[string]interface{}{ + "total_leads": rawStats["total"], + "active_leads": rawStats["novo"].(int) + rawStats["qualificado"].(int) + rawStats["negociacao"].(int), + "converted": rawStats["convertido"], + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "customer": CustomerPortalInfo{ + ID: customer.ID, + Name: customer.Name, + Email: customer.Email, + Company: customer.Company, + HasPortalAccess: customer.HasPortalAccess, + TenantID: customer.TenantID, + }, + "leads": leads, + "stats": stats, + }) +} + +// GetPortalLeads retorna apenas os leads do cliente +func (h *CustomerPortalHandler) GetPortalLeads(w http.ResponseWriter, r *http.Request) { + customerID, _ := r.Context().Value(middleware.CustomerIDKey).(string) + + leads, err := h.crmRepo.GetLeadsByCustomerID(customerID) + if err != nil { + log.Printf("Error fetching leads: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Erro ao buscar leads", + }) + return + } + + if leads == nil { + leads = []domain.CRMLead{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "leads": leads, + }) +} + +// GetPortalLists retorna as listas que possuem leads do cliente +func (h *CustomerPortalHandler) GetPortalLists(w http.ResponseWriter, r *http.Request) { + customerID, _ := r.Context().Value(middleware.CustomerIDKey).(string) + + lists, err := h.crmRepo.GetListsByCustomerID(customerID) + if err != nil { + log.Printf("Error fetching portal lists: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Erro ao buscar listas", + }) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "lists": lists, + }) +} + +// GetPortalProfile retorna o perfil completo do cliente +func (h *CustomerPortalHandler) GetPortalProfile(w http.ResponseWriter, r *http.Request) { + customerID, _ := r.Context().Value(middleware.CustomerIDKey).(string) + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + + // Buscar informações do cliente + customer, err := h.crmRepo.GetCustomerByID(customerID, tenantID) + if err != nil { + log.Printf("Error fetching customer: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Erro ao buscar perfil", + }) + return + } + + // Buscar leads para estatísticas + leads, err := h.crmRepo.GetLeadsByCustomerID(customerID) + if err != nil { + log.Printf("Error fetching leads for stats: %v", err) + leads = []domain.CRMLead{} + } + + // Calcular estatísticas + stats := calculateLeadStats(leads) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "customer": map[string]interface{}{ + "id": customer.ID, + "name": customer.Name, + "email": customer.Email, + "phone": customer.Phone, + "company": customer.Company, + "logo_url": customer.LogoURL, + "portal_last_login": customer.PortalLastLogin, + "created_at": customer.CreatedAt, + "total_leads": len(leads), + "converted_leads": stats["convertido"].(int), + }, + }) +} + +// ChangePasswordRequest representa a requisição de troca de senha +type CustomerChangePasswordRequest struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` +} + +// ChangePassword altera a senha do cliente +func (h *CustomerPortalHandler) ChangePassword(w http.ResponseWriter, r *http.Request) { + customerID, _ := r.Context().Value(middleware.CustomerIDKey).(string) + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + + var req CustomerChangePasswordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Invalid request body", + }) + return + } + + // Validar entrada + if req.CurrentPassword == "" || req.NewPassword == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Senha atual e nova senha são obrigatórias", + }) + return + } + + if len(req.NewPassword) < 6 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "A nova senha deve ter no mínimo 6 caracteres", + }) + return + } + + // Buscar cliente + customer, err := h.crmRepo.GetCustomerByID(customerID, tenantID) + if err != nil { + log.Printf("Error fetching customer: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Erro ao processar solicitação", + }) + return + } + + // Verificar senha atual + if err := bcrypt.CompareHashAndPassword([]byte(customer.PasswordHash), []byte(req.CurrentPassword)); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Senha atual incorreta", + }) + return + } + + // Gerar hash da nova senha + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) + if err != nil { + log.Printf("Error hashing password: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Erro ao processar nova senha", + }) + return + } + + // Atualizar senha no banco + if err := h.crmRepo.UpdateCustomerPassword(customerID, string(hashedPassword)); err != nil { + log.Printf("Error updating password: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Erro ao atualizar senha", + }) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "message": "Senha alterada com sucesso", + }) +} + +// UploadLogo faz o upload do logo do cliente +func (h *CustomerPortalHandler) UploadLogo(w http.ResponseWriter, r *http.Request) { + customerID, _ := r.Context().Value(middleware.CustomerIDKey).(string) + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + + if h.minioClient == nil { + http.Error(w, "Storage service unavailable", http.StatusServiceUnavailable) + return + } + + // 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 !strings.HasPrefix(contentType, "image/") { + http.Error(w, "Only images are allowed", http.StatusBadRequest) + return + } + + // Generate unique filename + ext := filepath.Ext(header.Filename) + if ext == "" { + ext = ".png" // Default extension + } + filename := fmt.Sprintf("logo-%d%s", time.Now().Unix(), ext) + objectPath := fmt.Sprintf("customers/%s/%s", customerID, filename) + + // Upload to MinIO + ctx := context.Background() + bucketName := h.cfg.Minio.BucketName + + _, err = h.minioClient.PutObject(ctx, bucketName, objectPath, file, header.Size, minio.PutObjectOptions{ + ContentType: contentType, + }) + if err != nil { + log.Printf("Error uploading to MinIO: %v", err) + http.Error(w, "Failed to upload file", http.StatusInternalServerError) + return + } + + // Generate public URL + logoURL := fmt.Sprintf("%s/api/files/%s/%s", h.cfg.Minio.PublicURL, bucketName, objectPath) + + // Update customer in database + err = h.crmRepo.UpdateCustomerLogo(customerID, tenantID, logoURL) + if err != nil { + log.Printf("Error updating customer logo in DB: %v", err) + http.Error(w, "Failed to update profile", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "logo_url": logoURL, + }) +} diff --git a/backend/internal/api/handlers/export.go b/backend/internal/api/handlers/export.go new file mode 100644 index 0000000..2105b17 --- /dev/null +++ b/backend/internal/api/handlers/export.go @@ -0,0 +1,210 @@ +package handlers + +import ( + "aggios-app/backend/internal/api/middleware" + "aggios-app/backend/internal/domain" + "encoding/csv" + "encoding/json" + "fmt" + "log" + "net/http" + "strings" + + "github.com/xuri/excelize/v2" +) + +// ExportLeads handles exporting leads in different formats +func (h *CRMHandler) ExportLeads(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + if tenantID == "" { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Missing tenant_id"}) + return + } + + format := r.URL.Query().Get("format") + if format == "" { + format = "csv" + } + + customerID := r.URL.Query().Get("customer_id") + campaignID := r.URL.Query().Get("campaign_id") + + var leads []domain.CRMLead + var err error + + if campaignID != "" { + leads, err = h.repo.GetLeadsByListID(campaignID) + } else if customerID != "" { + leads, err = h.repo.GetLeadsByTenant(tenantID) + // Filter by customer manually + filtered := []domain.CRMLead{} + for _, lead := range leads { + if lead.CustomerID != nil && *lead.CustomerID == customerID { + filtered = append(filtered, lead) + } + } + leads = filtered + } else { + leads, err = h.repo.GetLeadsByTenant(tenantID) + } + + if err != nil { + log.Printf("ExportLeads: Error fetching leads: %v", err) + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": "Failed to fetch leads"}) + return + } + + switch strings.ToLower(format) { + case "json": + exportJSON(w, leads) + case "xlsx", "excel": + exportXLSX(w, leads) + default: + exportCSV(w, leads) + } +} + +func exportJSON(w http.ResponseWriter, leads []domain.CRMLead) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Disposition", "attachment; filename=leads.json") + + json.NewEncoder(w).Encode(map[string]interface{}{ + "leads": leads, + "count": len(leads), + }) +} + +func exportCSV(w http.ResponseWriter, leads []domain.CRMLead) { + w.Header().Set("Content-Type", "text/csv") + w.Header().Set("Content-Disposition", "attachment; filename=leads.csv") + + writer := csv.NewWriter(w) + defer writer.Flush() + + // Header + header := []string{"ID", "Nome", "Email", "Telefone", "Status", "Origem", "Notas", "Tags", "Criado Em"} + writer.Write(header) + + // Data + for _, lead := range leads { + tags := "" + if len(lead.Tags) > 0 { + tags = strings.Join(lead.Tags, ", ") + } + + phone := "" + if lead.Phone != "" { + phone = lead.Phone + } + + notes := "" + if lead.Notes != "" { + notes = lead.Notes + } + + row := []string{ + lead.ID, + lead.Name, + lead.Email, + phone, + lead.Status, + lead.Source, + notes, + tags, + lead.CreatedAt.Format("02/01/2006 15:04"), + } + writer.Write(row) + } +} + +func exportXLSX(w http.ResponseWriter, leads []domain.CRMLead) { + f := excelize.NewFile() + defer f.Close() + + sheetName := "Leads" + index, err := f.NewSheet(sheetName) + if err != nil { + log.Printf("Error creating sheet: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + // Set active sheet + f.SetActiveSheet(index) + + // Header style + headerStyle, _ := f.NewStyle(&excelize.Style{ + Font: &excelize.Font{ + Bold: true, + Size: 12, + }, + Fill: excelize.Fill{ + Type: "pattern", + Color: []string{"#4472C4"}, + Pattern: 1, + }, + Alignment: &excelize.Alignment{ + Horizontal: "center", + Vertical: "center", + }, + }) + + // Headers + headers := []string{"ID", "Nome", "Email", "Telefone", "Status", "Origem", "Notas", "Tags", "Criado Em"} + for i, header := range headers { + cell := fmt.Sprintf("%s1", string(rune('A'+i))) + f.SetCellValue(sheetName, cell, header) + f.SetCellStyle(sheetName, cell, cell, headerStyle) + } + + // Data + for i, lead := range leads { + row := i + 2 + + tags := "" + if len(lead.Tags) > 0 { + tags = strings.Join(lead.Tags, ", ") + } + + phone := "" + if lead.Phone != "" { + phone = lead.Phone + } + + notes := "" + if lead.Notes != "" { + notes = lead.Notes + } + + f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), lead.ID) + f.SetCellValue(sheetName, fmt.Sprintf("B%d", row), lead.Name) + f.SetCellValue(sheetName, fmt.Sprintf("C%d", row), lead.Email) + f.SetCellValue(sheetName, fmt.Sprintf("D%d", row), phone) + f.SetCellValue(sheetName, fmt.Sprintf("E%d", row), lead.Status) + f.SetCellValue(sheetName, fmt.Sprintf("F%d", row), lead.Source) + f.SetCellValue(sheetName, fmt.Sprintf("G%d", row), notes) + f.SetCellValue(sheetName, fmt.Sprintf("H%d", row), tags) + f.SetCellValue(sheetName, fmt.Sprintf("I%d", row), lead.CreatedAt.Format("02/01/2006 15:04")) + } + + // Auto-adjust column widths + for i := 0; i < len(headers); i++ { + col := string(rune('A' + i)) + f.SetColWidth(sheetName, col, col, 15) + } + f.SetColWidth(sheetName, "B", "B", 25) // Nome + f.SetColWidth(sheetName, "C", "C", 30) // Email + f.SetColWidth(sheetName, "G", "G", 40) // Notas + + // Delete default sheet if exists + f.DeleteSheet("Sheet1") + + w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + w.Header().Set("Content-Disposition", "attachment; filename=leads.xlsx") + + if err := f.Write(w); err != nil { + log.Printf("Error writing xlsx: %v", err) + } +} diff --git a/backend/internal/api/handlers/tenant.go b/backend/internal/api/handlers/tenant.go index 601acf4..00ce4b0 100644 --- a/backend/internal/api/handlers/tenant.go +++ b/backend/internal/api/handlers/tenant.go @@ -5,7 +5,10 @@ import ( "log" "net/http" + "aggios-app/backend/internal/api/middleware" "aggios-app/backend/internal/service" + + "github.com/google/uuid" ) // TenantHandler handles tenant/agency listing endpoints @@ -93,7 +96,8 @@ func (h *TenantHandler) GetPublicConfig(w http.ResponseWriter, r *http.Request) } // Return only public info - response := map[string]string{ + response := map[string]interface{}{ + "id": tenant.ID.String(), "name": tenant.Name, "primary_color": tenant.PrimaryColor, "secondary_color": tenant.SecondaryColor, @@ -106,3 +110,88 @@ func (h *TenantHandler) GetPublicConfig(w http.ResponseWriter, r *http.Request) w.Header().Set("Content-Type", "application/json; charset=utf-8") json.NewEncoder(w).Encode(response) } + +// GetBranding returns branding info for the current authenticated tenant +func (h *TenantHandler) GetBranding(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Get tenant from context (set by auth middleware) + tenantID := r.Context().Value(middleware.TenantIDKey) + if tenantID == nil { + http.Error(w, "Tenant not found in context", http.StatusUnauthorized) + return + } + + // Parse tenant ID + tid, err := uuid.Parse(tenantID.(string)) + if err != nil { + http.Error(w, "Invalid tenant ID", http.StatusBadRequest) + return + } + + // Get tenant from database + tenant, err := h.tenantService.GetByID(tid) + if err != nil { + http.Error(w, "Error fetching branding", http.StatusInternalServerError) + return + } + + // Return branding info + response := map[string]interface{}{ + "id": tenant.ID.String(), + "name": tenant.Name, + "primary_color": tenant.PrimaryColor, + "secondary_color": tenant.SecondaryColor, + "logo_url": tenant.LogoURL, + "logo_horizontal_url": tenant.LogoHorizontalURL, + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + json.NewEncoder(w).Encode(response) +} + +// GetProfile returns public tenant information by tenant ID +func (h *TenantHandler) GetProfile(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Extract tenant ID from URL path + // URL format: /api/tenants/{id}/profile + tenantIDStr := r.URL.Path[len("/api/tenants/"):] + if idx := len(tenantIDStr) - len("/profile"); idx > 0 { + tenantIDStr = tenantIDStr[:idx] + } + + if tenantIDStr == "" { + http.Error(w, "tenant_id is required", http.StatusBadRequest) + return + } + + // Para compatibilidade, aceitar tanto UUID quanto ID numérico + // Primeiro tentar como UUID, se falhar buscar tenant diretamente + tenant, err := h.tenantService.GetBySubdomain(tenantIDStr) + if err != nil { + log.Printf("Error getting tenant: %v", err) + http.Error(w, "Tenant not found", http.StatusNotFound) + return + } + + // Return public info + response := map[string]interface{}{ + "tenant": map[string]string{ + "company": tenant.Name, + "primary_color": tenant.PrimaryColor, + "secondary_color": tenant.SecondaryColor, + "logo_url": tenant.LogoURL, + "logo_horizontal_url": tenant.LogoHorizontalURL, + }, + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + json.NewEncoder(w).Encode(response) +} diff --git a/backend/internal/api/middleware/auth.go b/backend/internal/api/middleware/auth.go index 8e3db9f..587b996 100644 --- a/backend/internal/api/middleware/auth.go +++ b/backend/internal/api/middleware/auth.go @@ -65,6 +65,16 @@ func Auth(cfg *config.Config) func(http.Handler) http.Handler { tenantIDFromJWT, _ = tenantIDClaim.(string) } + // VALIDAÇÃO DE SEGURANÇA: Verificar user_type para impedir clientes de acessarem rotas de agência + if userTypeClaim, ok := claims["user_type"]; ok && userTypeClaim != nil { + userType, _ := userTypeClaim.(string) + if userType == "customer" { + log.Printf("❌ CUSTOMER ACCESS BLOCKED: Customer %s tried to access agency route %s", userID, r.RequestURI) + http.Error(w, "Forbidden: Customers cannot access agency routes", http.StatusForbidden) + return + } + } + // VALIDAÇÃO DE SEGURANÇA: Verificar se o tenant_id do JWT corresponde ao subdomínio acessado // Pegar o tenant_id do contexto (detectado pelo TenantDetector middleware ANTES deste) tenantIDFromContext := "" diff --git a/backend/internal/api/middleware/collaborator_readonly.go b/backend/internal/api/middleware/collaborator_readonly.go new file mode 100644 index 0000000..e0efeff --- /dev/null +++ b/backend/internal/api/middleware/collaborator_readonly.go @@ -0,0 +1,44 @@ +package middleware + +import ( + "log" + "net/http" + "strings" +) + +// CheckCollaboratorReadOnly verifica se um colaborador está tentando fazer operações de escrita +// Se sim, bloqueia com 403 +func CheckCollaboratorReadOnly(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verificar agency_role do contexto + agencyRole, ok := r.Context().Value("agency_role").(string) + if !ok { + // Se não houver agency_role no contexto, é um customer, deixa passar + next.ServeHTTP(w, r) + return + } + + // Apenas colaboradores têm restrição de read-only + if agencyRole != "collaborator" { + next.ServeHTTP(w, r) + return + } + + // Verificar se é uma operação de escrita + method := r.Method + if method == http.MethodPost || method == http.MethodPut || method == http.MethodDelete { + // Verificar a rota + path := r.URL.Path + + // Bloquear operações de escrita em CRM + if strings.Contains(path, "/api/crm/") { + userID, _ := r.Context().Value(UserIDKey).(string) + log.Printf("❌ COLLABORATOR WRITE BLOCKED: User %s (collaborator) tried %s %s", userID, method, path) + http.Error(w, "Colaboradores têm acesso somente leitura", http.StatusForbidden) + return + } + } + + next.ServeHTTP(w, r) + }) +} diff --git a/backend/internal/api/middleware/customer_auth.go b/backend/internal/api/middleware/customer_auth.go new file mode 100644 index 0000000..0d70727 --- /dev/null +++ b/backend/internal/api/middleware/customer_auth.go @@ -0,0 +1,85 @@ +package middleware + +import ( + "aggios-app/backend/internal/config" + "context" + "log" + "net/http" + "strings" + + "github.com/golang-jwt/jwt/v5" +) + +const ( + CustomerIDKey contextKey = "customer_id" +) + +// CustomerAuthMiddleware valida tokens JWT de clientes do portal +func CustomerAuthMiddleware(cfg *config.Config) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Extrair token do header Authorization + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + http.Error(w, "Authorization header required", http.StatusUnauthorized) + return + } + + // Remover "Bearer " prefix + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + if tokenString == authHeader { + http.Error(w, "Invalid authorization format", http.StatusUnauthorized) + return + } + + // Parse e validar token + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + // Verificar método de assinatura + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, jwt.ErrSignatureInvalid + } + return []byte(cfg.JWT.Secret), nil + }) + + if err != nil || !token.Valid { + log.Printf("Invalid token: %v", err) + http.Error(w, "Invalid or expired token", http.StatusUnauthorized) + return + } + + // Extrair claims + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + http.Error(w, "Invalid token claims", http.StatusUnauthorized) + return + } + + // Verificar se é token de customer + tokenType, _ := claims["type"].(string) + if tokenType != "customer_portal" { + http.Error(w, "Invalid token type", http.StatusUnauthorized) + return + } + + // Extrair customer_id e tenant_id + customerID, ok := claims["customer_id"].(string) + if !ok { + http.Error(w, "Invalid customer_id in token", http.StatusUnauthorized) + return + } + + tenantID, ok := claims["tenant_id"].(string) + if !ok { + http.Error(w, "Invalid tenant_id in token", http.StatusUnauthorized) + return + } + + // Adicionar ao contexto + ctx := context.WithValue(r.Context(), CustomerIDKey, customerID) + ctx = context.WithValue(ctx, TenantIDKey, tenantID) + + // Prosseguir com a requisição + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/backend/internal/api/middleware/unified_auth.go b/backend/internal/api/middleware/unified_auth.go new file mode 100644 index 0000000..16fcfa2 --- /dev/null +++ b/backend/internal/api/middleware/unified_auth.go @@ -0,0 +1,104 @@ +package middleware + +import ( + "aggios-app/backend/internal/config" + "aggios-app/backend/internal/domain" + "context" + "log" + "net/http" + "strings" + + "github.com/golang-jwt/jwt/v5" +) + +// UnifiedAuthMiddleware valida JWT unificado e permite múltiplos tipos de usuários +func UnifiedAuthMiddleware(cfg *config.Config, allowedTypes ...domain.UserType) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Extrair token do header Authorization + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + log.Printf("🚫 UnifiedAuth: Missing Authorization header") + http.Error(w, "Unauthorized: Missing token", http.StatusUnauthorized) + return + } + + // Formato esperado: "Bearer " + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + log.Printf("🚫 UnifiedAuth: Invalid Authorization format") + http.Error(w, "Unauthorized: Invalid token format", http.StatusUnauthorized) + return + } + + tokenString := parts[1] + + // Parsear e validar token + token, err := jwt.ParseWithClaims(tokenString, &domain.UnifiedClaims{}, func(token *jwt.Token) (interface{}, error) { + return []byte(cfg.JWT.Secret), nil + }) + + if err != nil { + log.Printf("🚫 UnifiedAuth: Token parse error: %v", err) + http.Error(w, "Unauthorized: Invalid token", http.StatusUnauthorized) + return + } + + claims, ok := token.Claims.(*domain.UnifiedClaims) + if !ok || !token.Valid { + log.Printf("🚫 UnifiedAuth: Invalid token claims") + http.Error(w, "Unauthorized: Invalid token", http.StatusUnauthorized) + return + } + + // Verificar se o tipo de usuário é permitido + if len(allowedTypes) > 0 { + allowed := false + for _, allowedType := range allowedTypes { + if claims.UserType == allowedType { + allowed = true + break + } + } + if !allowed { + log.Printf("🚫 UnifiedAuth: User type %s not allowed (allowed: %v)", claims.UserType, allowedTypes) + http.Error(w, "Forbidden: Insufficient permissions", http.StatusForbidden) + return + } + } + + // Adicionar informações ao contexto + ctx := r.Context() + ctx = context.WithValue(ctx, UserIDKey, claims.UserID) + ctx = context.WithValue(ctx, TenantIDKey, claims.TenantID) + ctx = context.WithValue(ctx, "email", claims.Email) + ctx = context.WithValue(ctx, "user_type", string(claims.UserType)) + ctx = context.WithValue(ctx, "role", claims.Role) + + // Para compatibilidade com handlers de portal que esperam CustomerIDKey + if claims.UserType == domain.UserTypeCustomer { + ctx = context.WithValue(ctx, CustomerIDKey, claims.UserID) + } + + log.Printf("✅ UnifiedAuth: Authenticated user_id=%s, type=%s, role=%s, tenant=%s", + claims.UserID, claims.UserType, claims.Role, claims.TenantID) + + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// RequireAgencyUser middleware que permite apenas usuários de agência (admin, colaborador) +func RequireAgencyUser(cfg *config.Config) func(http.Handler) http.Handler { + return UnifiedAuthMiddleware(cfg, domain.UserTypeAgency) +} + +// RequireCustomer middleware que permite apenas clientes +func RequireCustomer(cfg *config.Config) func(http.Handler) http.Handler { + return UnifiedAuthMiddleware(cfg, domain.UserTypeCustomer) +} + +// RequireAnyAuthenticated middleware que permite qualquer usuário autenticado +func RequireAnyAuthenticated(cfg *config.Config) func(http.Handler) http.Handler { + return UnifiedAuthMiddleware(cfg) // Sem filtro de tipo +} diff --git a/backend/internal/data/postgres/migrations/001_add_agency_roles.sql b/backend/internal/data/postgres/migrations/001_add_agency_roles.sql new file mode 100644 index 0000000..1164707 --- /dev/null +++ b/backend/internal/data/postgres/migrations/001_add_agency_roles.sql @@ -0,0 +1,18 @@ +-- Migration: Add agency user roles and collaborator tracking +-- Purpose: Support owner/collaborator hierarchy for agency users + +-- 1. Add agency_role column to users table (owner or collaborator) +ALTER TABLE users ADD COLUMN IF NOT EXISTS agency_role VARCHAR(50) DEFAULT 'owner' CHECK (agency_role IN ('owner', 'collaborator')); + +-- 2. Add created_by column to track which user created this collaborator +ALTER TABLE users ADD COLUMN IF NOT EXISTS created_by UUID REFERENCES users(id) ON DELETE SET NULL; + +-- 3. Update existing ADMIN_AGENCIA users to have 'owner' agency_role +UPDATE users SET agency_role = 'owner' WHERE role = 'ADMIN_AGENCIA' AND agency_role IS NULL; + +-- 4. Add collaborator_created_at to track when the collaborator was added +ALTER TABLE users ADD COLUMN IF NOT EXISTS collaborator_created_at TIMESTAMP WITH TIME ZONE; + +-- 5. Create index for faster queries +CREATE INDEX IF NOT EXISTS idx_users_agency_role ON users(tenant_id, agency_role); +CREATE INDEX IF NOT EXISTS idx_users_created_by ON users(created_by); diff --git a/backend/internal/domain/auth_unified.go b/backend/internal/domain/auth_unified.go new file mode 100644 index 0000000..e38ff09 --- /dev/null +++ b/backend/internal/domain/auth_unified.go @@ -0,0 +1,42 @@ +package domain + +import "github.com/golang-jwt/jwt/v5" + +// UserType representa os diferentes tipos de usuários do sistema +type UserType string + +const ( + UserTypeAgency UserType = "agency_user" // Usuários das agências (admin, colaborador) + UserTypeCustomer UserType = "customer" // Clientes do CRM + // SUPERADMIN usa endpoint próprio /api/admin/*, não usa autenticação unificada +) + +// UnifiedClaims representa as claims do JWT unificado +type UnifiedClaims struct { + UserID string `json:"user_id"` // ID do usuário (user.id ou customer.id) + UserType UserType `json:"user_type"` // Tipo de usuário + TenantID string `json:"tenant_id,omitempty"` // ID do tenant (agência) + Email string `json:"email"` // Email do usuário + Role string `json:"role,omitempty"` // Role (para agency_user: ADMIN_AGENCIA, CLIENTE) + AgencyRole string `json:"agency_role,omitempty"` // Agency role (owner ou collaborator) + jwt.RegisteredClaims +} + +// UnifiedLoginRequest representa uma requisição de login unificada +type UnifiedLoginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +// UnifiedLoginResponse representa a resposta de login unificada +type UnifiedLoginResponse struct { + Token string `json:"token"` + UserType UserType `json:"user_type"` + UserID string `json:"user_id"` + Email string `json:"email"` + Name string `json:"name"` + Role string `json:"role,omitempty"` // Apenas para agency_user + AgencyRole string `json:"agency_role,omitempty"` // owner ou collaborator + TenantID string `json:"tenant_id,omitempty"` // ID do tenant + Subdomain string `json:"subdomain,omitempty"` // Subdomínio da agência +} diff --git a/backend/internal/domain/crm.go b/backend/internal/domain/crm.go index 972f3b3..cfb7300 100644 --- a/backend/internal/domain/crm.go +++ b/backend/internal/domain/crm.go @@ -1,31 +1,41 @@ package domain -import "time" +import ( + "encoding/json" + "time" +) type CRMCustomer struct { - ID string `json:"id" db:"id"` - TenantID string `json:"tenant_id" db:"tenant_id"` - Name string `json:"name" db:"name"` - Email string `json:"email" db:"email"` - Phone string `json:"phone" db:"phone"` - Company string `json:"company" db:"company"` - Position string `json:"position" db:"position"` - Address string `json:"address" db:"address"` - City string `json:"city" db:"city"` - State string `json:"state" db:"state"` - ZipCode string `json:"zip_code" db:"zip_code"` - Country string `json:"country" db:"country"` - Notes string `json:"notes" db:"notes"` - Tags []string `json:"tags" db:"tags"` - IsActive bool `json:"is_active" db:"is_active"` - CreatedBy string `json:"created_by" db:"created_by"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + ID string `json:"id" db:"id"` + TenantID string `json:"tenant_id" db:"tenant_id"` + Name string `json:"name" db:"name"` + Email string `json:"email" db:"email"` + Phone string `json:"phone" db:"phone"` + Company string `json:"company" db:"company"` + Position string `json:"position" db:"position"` + Address string `json:"address" db:"address"` + City string `json:"city" db:"city"` + State string `json:"state" db:"state"` + ZipCode string `json:"zip_code" db:"zip_code"` + Country string `json:"country" db:"country"` + Notes string `json:"notes" db:"notes"` + Tags []string `json:"tags" db:"tags"` + LogoURL string `json:"logo_url" db:"logo_url"` + IsActive bool `json:"is_active" db:"is_active"` + CreatedBy string `json:"created_by" db:"created_by"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + PasswordHash string `json:"-" db:"password_hash"` + HasPortalAccess bool `json:"has_portal_access" db:"has_portal_access"` + PortalLastLogin *time.Time `json:"portal_last_login,omitempty" db:"portal_last_login"` + PortalCreatedAt *time.Time `json:"portal_created_at,omitempty" db:"portal_created_at"` } type CRMList struct { ID string `json:"id" db:"id"` TenantID string `json:"tenant_id" db:"tenant_id"` + CustomerID *string `json:"customer_id" db:"customer_id"` + FunnelID *string `json:"funnel_id" db:"funnel_id"` Name string `json:"name" db:"name"` Description string `json:"description" db:"description"` Color string `json:"color" db:"color"` @@ -49,5 +59,77 @@ type CRMCustomerWithLists struct { type CRMListWithCustomers struct { CRMList - CustomerCount int `json:"customer_count"` + CustomerName string `json:"customer_name"` + CustomerCount int `json:"customer_count"` + LeadCount int `json:"lead_count"` +} + +// ==================== LEADS ==================== + +type CRMLead struct { + ID string `json:"id" db:"id"` + TenantID string `json:"tenant_id" db:"tenant_id"` + CustomerID *string `json:"customer_id" db:"customer_id"` + FunnelID *string `json:"funnel_id" db:"funnel_id"` + StageID *string `json:"stage_id" db:"stage_id"` + Name string `json:"name" db:"name"` + Email string `json:"email" db:"email"` + Phone string `json:"phone" db:"phone"` + Source string `json:"source" db:"source"` + SourceMeta json.RawMessage `json:"source_meta" db:"source_meta"` + Status string `json:"status" db:"status"` + Notes string `json:"notes" db:"notes"` + Tags []string `json:"tags" db:"tags"` + IsActive bool `json:"is_active" db:"is_active"` + CreatedBy string `json:"created_by" db:"created_by"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +type CRMFunnel struct { + ID string `json:"id" db:"id"` + TenantID string `json:"tenant_id" db:"tenant_id"` + Name string `json:"name" db:"name"` + Description string `json:"description" db:"description"` + IsDefault bool `json:"is_default" db:"is_default"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +type CRMFunnelStage struct { + ID string `json:"id" db:"id"` + FunnelID string `json:"funnel_id" db:"funnel_id"` + Name string `json:"name" db:"name"` + Description string `json:"description" db:"description"` + Color string `json:"color" db:"color"` + OrderIndex int `json:"order_index" db:"order_index"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +type CRMFunnelWithStages struct { + CRMFunnel + Stages []CRMFunnelStage `json:"stages"` +} + +type CRMLeadList struct { + LeadID string `json:"lead_id" db:"lead_id"` + ListID string `json:"list_id" db:"list_id"` + AddedAt time.Time `json:"added_at" db:"added_at"` + AddedBy string `json:"added_by" db:"added_by"` +} + +type CRMLeadWithLists struct { + CRMLead + Lists []CRMList `json:"lists"` +} + +type CRMShareToken struct { + ID string `json:"id" db:"id"` + TenantID string `json:"tenant_id" db:"tenant_id"` + CustomerID string `json:"customer_id" db:"customer_id"` + Token string `json:"token" db:"token"` + ExpiresAt *time.Time `json:"expires_at" db:"expires_at"` + CreatedBy string `json:"created_by" db:"created_by"` + CreatedAt time.Time `json:"created_at" db:"created_at"` } diff --git a/backend/internal/domain/user.go b/backend/internal/domain/user.go index eefbb30..4bbdbf9 100644 --- a/backend/internal/domain/user.go +++ b/backend/internal/domain/user.go @@ -8,14 +8,17 @@ import ( // User represents a user in the system type User struct { - ID uuid.UUID `json:"id" db:"id"` - TenantID *uuid.UUID `json:"tenant_id,omitempty" db:"tenant_id"` - Email string `json:"email" db:"email"` - Password string `json:"-" db:"password_hash"` - Name string `json:"name" db:"first_name"` - Role string `json:"role" db:"role"` // SUPERADMIN, ADMIN_AGENCIA, CLIENTE - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + ID uuid.UUID `json:"id" db:"id"` + TenantID *uuid.UUID `json:"tenant_id,omitempty" db:"tenant_id"` + Email string `json:"email" db:"email"` + Password string `json:"-" db:"password_hash"` + Name string `json:"name" db:"first_name"` + Role string `json:"role" db:"role"` // SUPERADMIN, ADMIN_AGENCIA, CLIENTE + AgencyRole string `json:"agency_role" db:"agency_role"` // owner or collaborator (only for ADMIN_AGENCIA) + CreatedBy *uuid.UUID `json:"created_by,omitempty" db:"created_by"` // Which owner created this collaborator + CollaboratorCreatedAt *time.Time `json:"collaborator_created_at,omitempty" db:"collaborator_created_at"` // When collaborator was added + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } // CreateUserRequest represents the request to create a new user diff --git a/backend/internal/repository/crm_repository.go b/backend/internal/repository/crm_repository.go index 463d9f4..2ea7ccb 100644 --- a/backend/internal/repository/crm_repository.go +++ b/backend/internal/repository/crm_repository.go @@ -4,6 +4,7 @@ import ( "aggios-app/backend/internal/domain" "database/sql" "fmt" + "log" "github.com/lib/pq" ) @@ -23,25 +24,34 @@ func (r *CRMRepository) CreateCustomer(customer *domain.CRMCustomer) error { INSERT INTO crm_customers ( id, tenant_id, name, email, phone, company, position, address, city, state, zip_code, country, notes, tags, - is_active, created_by - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + is_active, created_by, logo_url + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) RETURNING created_at, updated_at ` + // Handle optional created_by field (NULL for public registrations) + var createdBy interface{} + if customer.CreatedBy != "" { + createdBy = customer.CreatedBy + } else { + createdBy = nil + } + return r.db.QueryRow( query, customer.ID, customer.TenantID, customer.Name, customer.Email, customer.Phone, customer.Company, customer.Position, customer.Address, customer.City, customer.State, customer.ZipCode, customer.Country, customer.Notes, pq.Array(customer.Tags), - customer.IsActive, customer.CreatedBy, + customer.IsActive, createdBy, customer.LogoURL, ).Scan(&customer.CreatedAt, &customer.UpdatedAt) } func (r *CRMRepository) GetCustomersByTenant(tenantID string) ([]domain.CRMCustomer, error) { query := ` SELECT id, tenant_id, name, email, phone, company, position, - address, city, state, zip_code, country, notes, tags, - is_active, created_by, created_at, updated_at + address, city, state, zip_code, country, notes, tags, + is_active, COALESCE(created_by::text, '') AS created_by, created_at, updated_at, + COALESCE(logo_url, '') as logo_url FROM crm_customers WHERE tenant_id = $1 AND is_active = true ORDER BY created_at DESC @@ -59,7 +69,7 @@ func (r *CRMRepository) GetCustomersByTenant(tenantID string) ([]domain.CRMCusto err := rows.Scan( &c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position, &c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags), - &c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, + &c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, &c.LogoURL, ) if err != nil { return nil, err @@ -73,8 +83,9 @@ func (r *CRMRepository) GetCustomersByTenant(tenantID string) ([]domain.CRMCusto func (r *CRMRepository) GetCustomerByID(id string, tenantID string) (*domain.CRMCustomer, error) { query := ` SELECT id, tenant_id, name, email, phone, company, position, - address, city, state, zip_code, country, notes, tags, - is_active, created_by, created_at, updated_at + address, city, state, zip_code, country, notes, tags, + is_active, COALESCE(created_by::text, '') AS created_by, created_at, updated_at, + COALESCE(logo_url, '') as logo_url FROM crm_customers WHERE id = $1 AND tenant_id = $2 ` @@ -83,7 +94,7 @@ func (r *CRMRepository) GetCustomerByID(id string, tenantID string) (*domain.CRM err := r.db.QueryRow(query, id, tenantID).Scan( &c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position, &c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags), - &c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, + &c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, &c.LogoURL, ) if err != nil { @@ -98,15 +109,15 @@ func (r *CRMRepository) UpdateCustomer(customer *domain.CRMCustomer) error { UPDATE crm_customers SET name = $1, email = $2, phone = $3, company = $4, position = $5, address = $6, city = $7, state = $8, zip_code = $9, country = $10, - notes = $11, tags = $12, is_active = $13 - WHERE id = $14 AND tenant_id = $15 + notes = $11, tags = $12, is_active = $13, logo_url = $14 + WHERE id = $15 AND tenant_id = $16 ` result, err := r.db.Exec( query, customer.Name, customer.Email, customer.Phone, customer.Company, customer.Position, customer.Address, customer.City, customer.State, customer.ZipCode, customer.Country, - customer.Notes, pq.Array(customer.Tags), customer.IsActive, + customer.Notes, pq.Array(customer.Tags), customer.IsActive, customer.LogoURL, customer.ID, customer.TenantID, ) @@ -150,26 +161,27 @@ func (r *CRMRepository) DeleteCustomer(id string, tenantID string) error { func (r *CRMRepository) CreateList(list *domain.CRMList) error { query := ` - INSERT INTO crm_lists (id, tenant_id, name, description, color, created_by) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO crm_lists (id, tenant_id, customer_id, funnel_id, name, description, color, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING created_at, updated_at ` return r.db.QueryRow( query, - list.ID, list.TenantID, list.Name, list.Description, list.Color, list.CreatedBy, + list.ID, list.TenantID, list.CustomerID, list.FunnelID, list.Name, list.Description, list.Color, list.CreatedBy, ).Scan(&list.CreatedAt, &list.UpdatedAt) } func (r *CRMRepository) GetListsByTenant(tenantID string) ([]domain.CRMListWithCustomers, error) { query := ` - SELECT l.id, l.tenant_id, l.name, l.description, l.color, l.created_by, + SELECT l.id, l.tenant_id, l.customer_id, l.funnel_id, l.name, l.description, l.color, l.created_by, l.created_at, l.updated_at, - COUNT(cl.customer_id) as customer_count + COALESCE(c.name, '') as customer_name, + (SELECT COUNT(*) FROM crm_customer_lists cl WHERE cl.list_id = l.id) as customer_count, + (SELECT COUNT(*) FROM crm_lead_lists ll WHERE ll.list_id = l.id) as lead_count FROM crm_lists l - LEFT JOIN crm_customer_lists cl ON l.id = cl.list_id + LEFT JOIN crm_customers c ON l.customer_id = c.id WHERE l.tenant_id = $1 - GROUP BY l.id ORDER BY l.created_at DESC ` @@ -183,8 +195,8 @@ func (r *CRMRepository) GetListsByTenant(tenantID string) ([]domain.CRMListWithC for rows.Next() { var l domain.CRMListWithCustomers err := rows.Scan( - &l.ID, &l.TenantID, &l.Name, &l.Description, &l.Color, &l.CreatedBy, - &l.CreatedAt, &l.UpdatedAt, &l.CustomerCount, + &l.ID, &l.TenantID, &l.CustomerID, &l.FunnelID, &l.Name, &l.Description, &l.Color, &l.CreatedBy, + &l.CreatedAt, &l.UpdatedAt, &l.CustomerName, &l.CustomerCount, &l.LeadCount, ) if err != nil { return nil, err @@ -197,14 +209,14 @@ func (r *CRMRepository) GetListsByTenant(tenantID string) ([]domain.CRMListWithC func (r *CRMRepository) GetListByID(id string, tenantID string) (*domain.CRMList, error) { query := ` - SELECT id, tenant_id, name, description, color, created_by, created_at, updated_at + SELECT id, tenant_id, customer_id, funnel_id, name, description, color, created_by, created_at, updated_at FROM crm_lists WHERE id = $1 AND tenant_id = $2 ` var l domain.CRMList err := r.db.QueryRow(query, id, tenantID).Scan( - &l.ID, &l.TenantID, &l.Name, &l.Description, &l.Color, &l.CreatedBy, + &l.ID, &l.TenantID, &l.CustomerID, &l.FunnelID, &l.Name, &l.Description, &l.Color, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt, ) @@ -218,11 +230,11 @@ func (r *CRMRepository) GetListByID(id string, tenantID string) (*domain.CRMList func (r *CRMRepository) UpdateList(list *domain.CRMList) error { query := ` UPDATE crm_lists SET - name = $1, description = $2, color = $3 - WHERE id = $4 AND tenant_id = $5 + name = $1, description = $2, color = $3, customer_id = $4, funnel_id = $5 + WHERE id = $6 AND tenant_id = $7 ` - result, err := r.db.Exec(query, list.Name, list.Description, list.Color, list.ID, list.TenantID) + result, err := r.db.Exec(query, list.Name, list.Description, list.Color, list.CustomerID, list.FunnelID, list.ID, list.TenantID) if err != nil { return err } @@ -315,7 +327,8 @@ func (r *CRMRepository) GetListCustomers(listID string, tenantID string) ([]doma query := ` SELECT c.id, c.tenant_id, c.name, c.email, c.phone, c.company, c.position, c.address, c.city, c.state, c.zip_code, c.country, c.notes, c.tags, - c.is_active, c.created_by, c.created_at, c.updated_at + c.is_active, c.created_by, c.created_at, c.updated_at, + COALESCE(c.logo_url, '') as logo_url FROM crm_customers c INNER JOIN crm_customer_lists cl ON c.id = cl.customer_id WHERE cl.list_id = $1 AND c.tenant_id = $2 AND c.is_active = true @@ -334,7 +347,7 @@ func (r *CRMRepository) GetListCustomers(listID string, tenantID string) ([]doma err := rows.Scan( &c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position, &c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags), - &c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, + &c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, &c.LogoURL, ) if err != nil { return nil, err @@ -344,3 +357,803 @@ func (r *CRMRepository) GetListCustomers(listID string, tenantID string) ([]doma return customers, nil } + +// ==================== LEADS ==================== + +func (r *CRMRepository) CreateLead(lead *domain.CRMLead) error { + query := ` + INSERT INTO crm_leads ( + id, tenant_id, customer_id, funnel_id, stage_id, name, email, phone, source, source_meta, + status, notes, tags, is_active, created_by + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + RETURNING created_at, updated_at + ` + + return r.db.QueryRow( + query, + lead.ID, lead.TenantID, lead.CustomerID, lead.FunnelID, lead.StageID, lead.Name, lead.Email, lead.Phone, + lead.Source, lead.SourceMeta, lead.Status, lead.Notes, pq.Array(lead.Tags), + lead.IsActive, lead.CreatedBy, + ).Scan(&lead.CreatedAt, &lead.UpdatedAt) +} +func (r *CRMRepository) AddLeadToList(leadID, listID, addedBy string) error { + query := ` + INSERT INTO crm_lead_lists (lead_id, list_id, added_by) + VALUES ($1, $2, $3) + ON CONFLICT (lead_id, list_id) DO NOTHING + ` + _, err := r.db.Exec(query, leadID, listID, addedBy) + return err +} + +func (r *CRMRepository) BulkAddLeadsToList(leadIDs []string, listID string, addedBy string) error { + tx, err := r.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + stmt, err := tx.Prepare(pq.CopyIn("crm_lead_lists", "lead_id", "list_id", "added_by")) + if err != nil { + return err + } + defer stmt.Close() + + for _, leadID := range leadIDs { + _, err = stmt.Exec(leadID, listID, addedBy) + if err != nil { + return err + } + } + + _, err = stmt.Exec() + if err != nil { + return err + } + + return tx.Commit() +} + +func (r *CRMRepository) BulkCreateLeads(leads []domain.CRMLead) error { + tx, err := r.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + query := ` + INSERT INTO crm_leads ( + id, tenant_id, customer_id, funnel_id, stage_id, name, email, phone, source, + source_meta, status, notes, tags, is_active, created_by + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15 + ) ON CONFLICT (tenant_id, email) DO UPDATE SET + customer_id = COALESCE(EXCLUDED.customer_id, crm_leads.customer_id), + funnel_id = COALESCE(EXCLUDED.funnel_id, crm_leads.funnel_id), + stage_id = COALESCE(EXCLUDED.stage_id, crm_leads.stage_id), + name = COALESCE(EXCLUDED.name, crm_leads.name), + phone = COALESCE(EXCLUDED.phone, crm_leads.phone), + source = EXCLUDED.source, + source_meta = EXCLUDED.source_meta, + status = EXCLUDED.status, + notes = COALESCE(EXCLUDED.notes, crm_leads.notes), + tags = EXCLUDED.tags, + updated_at = CURRENT_TIMESTAMP + RETURNING id + ` + + stmt, err := tx.Prepare(query) + if err != nil { + return err + } + defer stmt.Close() + + for i := range leads { + var returnedID string + err = stmt.QueryRow( + leads[i].ID, leads[i].TenantID, leads[i].CustomerID, leads[i].FunnelID, leads[i].StageID, leads[i].Name, leads[i].Email, leads[i].Phone, + leads[i].Source, string(leads[i].SourceMeta), leads[i].Status, leads[i].Notes, pq.Array(leads[i].Tags), + leads[i].IsActive, leads[i].CreatedBy, + ).Scan(&returnedID) + if err != nil { + return err + } + // Atualiza o ID do lead com o ID retornado (pode ser diferente em caso de conflito) + leads[i].ID = returnedID + } + + return tx.Commit() +} + +func (r *CRMRepository) GetLeadsByTenant(tenantID string) ([]domain.CRMLead, error) { + query := ` + SELECT id, tenant_id, customer_id, funnel_id, stage_id, name, email, phone, source, source_meta, + status, COALESCE(notes, ''), tags, is_active, created_by, created_at, updated_at + FROM crm_leads + WHERE tenant_id = $1 AND is_active = true + ORDER BY created_at DESC + ` + + rows, err := r.db.Query(query, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + + var leads []domain.CRMLead + for rows.Next() { + var l domain.CRMLead + err := rows.Scan( + &l.ID, &l.TenantID, &l.CustomerID, &l.FunnelID, &l.StageID, &l.Name, &l.Email, &l.Phone, &l.Source, &l.SourceMeta, + &l.Status, &l.Notes, pq.Array(&l.Tags), &l.IsActive, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt, + ) + if err != nil { + log.Printf("Error scanning lead: %v", err) + continue + } + leads = append(leads, l) + } + + return leads, nil +} + +func (r *CRMRepository) GetLeadsWithListsByTenant(tenantID string) ([]domain.CRMLeadWithLists, error) { + leads, err := r.GetLeadsByTenant(tenantID) + if err != nil { + return nil, err + } + + var leadsWithLists []domain.CRMLeadWithLists + for _, l := range leads { + lists, err := r.GetListsByLeadID(l.ID) + if err != nil { + lists = []domain.CRMList{} + } + leadsWithLists = append(leadsWithLists, domain.CRMLeadWithLists{ + CRMLead: l, + Lists: lists, + }) + } + + return leadsWithLists, nil +} + +func (r *CRMRepository) GetListsByLeadID(leadID string) ([]domain.CRMList, error) { + query := ` + SELECT l.id, l.tenant_id, l.customer_id, l.name, l.description, l.color, l.created_by, l.created_at, l.updated_at + FROM crm_lists l + JOIN crm_lead_lists cll ON l.id = cll.list_id + WHERE cll.lead_id = $1 + ` + + rows, err := r.db.Query(query, leadID) + if err != nil { + return nil, err + } + defer rows.Close() + + var lists []domain.CRMList + for rows.Next() { + var l domain.CRMList + err := rows.Scan( + &l.ID, &l.TenantID, &l.CustomerID, &l.Name, &l.Description, &l.Color, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt, + ) + if err != nil { + return nil, err + } + lists = append(lists, l) + } + + return lists, nil +} + +func (r *CRMRepository) GetLeadByID(id string, tenantID string) (*domain.CRMLead, error) { + query := ` + SELECT id, tenant_id, customer_id, funnel_id, stage_id, name, email, phone, source, source_meta, + status, COALESCE(notes, ''), tags, is_active, created_by, created_at, updated_at + FROM crm_leads + WHERE id = $1 AND tenant_id = $2 + ` + + var l domain.CRMLead + err := r.db.QueryRow(query, id, tenantID).Scan( + &l.ID, &l.TenantID, &l.CustomerID, &l.FunnelID, &l.StageID, &l.Name, &l.Email, &l.Phone, &l.Source, &l.SourceMeta, + &l.Status, &l.Notes, pq.Array(&l.Tags), &l.IsActive, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt, + ) + if err != nil { + return nil, err + } + + return &l, nil +} + +func (r *CRMRepository) UpdateLead(lead *domain.CRMLead) error { + query := ` + UPDATE crm_leads SET + customer_id = $1, + funnel_id = $2, + stage_id = $3, + name = $4, + email = $5, + phone = $6, + source = $7, + source_meta = $8, + status = $9, + notes = $10, + tags = $11, + is_active = $12 + WHERE id = $13 AND tenant_id = $14 + ` + + result, err := r.db.Exec( + query, + lead.CustomerID, lead.FunnelID, lead.StageID, lead.Name, lead.Email, lead.Phone, lead.Source, lead.SourceMeta, + lead.Status, lead.Notes, pq.Array(lead.Tags), lead.IsActive, + lead.ID, lead.TenantID, + ) + if err != nil { + return err + } + + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return fmt.Errorf("lead not found") + } + + return nil +} + +func (r *CRMRepository) DeleteLead(id string, tenantID string) error { + query := `DELETE FROM crm_leads WHERE id = $1 AND tenant_id = $2` + result, err := r.db.Exec(query, id, tenantID) + if err != nil { + return err + } + + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return fmt.Errorf("lead not found") + } + return nil +} + +func (r *CRMRepository) GetLeadByEmailOrPhone(tenantID, email, phone string) (*domain.CRMLead, error) { + query := ` + SELECT id, tenant_id, customer_id, name, email, phone, source, source_meta, + status, COALESCE(notes, ''), tags, is_active, created_by, created_at, updated_at + FROM crm_leads + WHERE tenant_id = $1 + AND ( + (email IS NOT NULL AND $2 <> '' AND LOWER(email) = LOWER($2)) + OR (phone IS NOT NULL AND $3 <> '' AND phone = $3) + ) + ORDER BY created_at DESC + LIMIT 1 + ` + + var l domain.CRMLead + err := r.db.QueryRow(query, tenantID, email, phone).Scan( + &l.ID, &l.TenantID, &l.CustomerID, &l.Name, &l.Email, &l.Phone, &l.Source, &l.SourceMeta, + &l.Status, &l.Notes, pq.Array(&l.Tags), &l.IsActive, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt, + ) + if err != nil { + return nil, err + } + + return &l, nil +} + +func (r *CRMRepository) RemoveLeadFromList(leadID, listID string) error { + query := `DELETE FROM crm_lead_lists WHERE lead_id = $1 AND list_id = $2` + _, err := r.db.Exec(query, leadID, listID) + return err +} + +func (r *CRMRepository) GetLeadLists(leadID string) ([]domain.CRMList, error) { + query := ` + SELECT l.id, l.tenant_id, l.name, COALESCE(l.description, ''), l.color, l.created_by, + l.created_at, l.updated_at + FROM crm_lists l + INNER JOIN crm_lead_lists ll ON l.id = ll.list_id + WHERE ll.lead_id = $1 + ORDER BY l.name + ` + + rows, err := r.db.Query(query, leadID) + if err != nil { + return nil, err + } + defer rows.Close() + + var lists []domain.CRMList + for rows.Next() { + var l domain.CRMList + err := rows.Scan( + &l.ID, &l.TenantID, &l.Name, &l.Description, &l.Color, &l.CreatedBy, + &l.CreatedAt, &l.UpdatedAt, + ) + if err != nil { + return nil, err + } + lists = append(lists, l) + } + + return lists, nil +} + +func (r *CRMRepository) GetListByName(tenantID, name string) (*domain.CRMList, error) { + query := ` + SELECT id, tenant_id, name, description, color, created_by, created_at, updated_at + FROM crm_lists + WHERE tenant_id = $1 AND LOWER(name) = LOWER($2) + LIMIT 1 + ` + + var l domain.CRMList + err := r.db.QueryRow(query, tenantID, name).Scan( + &l.ID, &l.TenantID, &l.Name, &l.Description, &l.Color, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt, + ) + if err != nil { + return nil, err + } + return &l, nil +} + +// CreateShareToken cria um novo token de compartilhamento +func (r *CRMRepository) CreateShareToken(token *domain.CRMShareToken) error { + query := ` + INSERT INTO crm_share_tokens (id, tenant_id, customer_id, token, expires_at, created_by, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ` + _, err := r.db.Exec(query, token.ID, token.TenantID, token.CustomerID, token.Token, token.ExpiresAt, token.CreatedBy, token.CreatedAt) + return err +} + +// GetShareTokenByToken busca um token de compartilhamento pelo token +func (r *CRMRepository) GetShareTokenByToken(token string) (*domain.CRMShareToken, error) { + query := ` + SELECT id, tenant_id, customer_id, token, expires_at, created_by, created_at + FROM crm_share_tokens + WHERE token = $1 + ` + + var st domain.CRMShareToken + err := r.db.QueryRow(query, token).Scan( + &st.ID, &st.TenantID, &st.CustomerID, &st.Token, &st.ExpiresAt, &st.CreatedBy, &st.CreatedAt, + ) + if err != nil { + return nil, err + } + return &st, nil +} + +// GetLeadsByCustomerID retorna todos os leads de um cliente específico +func (r *CRMRepository) GetLeadsByCustomerID(customerID string) ([]domain.CRMLead, error) { + query := ` + SELECT id, tenant_id, customer_id, name, email, phone, source, source_meta, + status, notes, tags, is_active, created_by, created_at, updated_at + FROM crm_leads + WHERE customer_id = $1 AND is_active = true + ORDER BY created_at DESC + ` + + rows, err := r.db.Query(query, customerID) + if err != nil { + return nil, err + } + defer rows.Close() + + var leads []domain.CRMLead + for rows.Next() { + var l domain.CRMLead + err := rows.Scan( + &l.ID, &l.TenantID, &l.CustomerID, &l.Name, &l.Email, &l.Phone, &l.Source, &l.SourceMeta, + &l.Status, &l.Notes, &l.Tags, &l.IsActive, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt, + ) + if err != nil { + return nil, err + } + leads = append(leads, l) + } + + if leads == nil { + leads = []domain.CRMLead{} + } + + return leads, nil +} + +// GetListsByCustomerID retorna todas as listas que possuem leads de um cliente específico +func (r *CRMRepository) GetListsByCustomerID(customerID string) ([]domain.CRMList, error) { + query := ` + SELECT DISTINCT l.id, l.tenant_id, l.name, l.description, l.color, l.created_by, + l.created_at, l.updated_at + FROM crm_lists l + INNER JOIN crm_lead_lists ll ON l.id = ll.list_id + INNER JOIN crm_leads le ON ll.lead_id = le.id + WHERE le.customer_id = $1 + ORDER BY l.name + ` + + rows, err := r.db.Query(query, customerID) + if err != nil { + return nil, err + } + defer rows.Close() + + var lists []domain.CRMList + for rows.Next() { + var l domain.CRMList + err := rows.Scan( + &l.ID, &l.TenantID, &l.Name, &l.Description, &l.Color, &l.CreatedBy, + &l.CreatedAt, &l.UpdatedAt, + ) + if err != nil { + return nil, err + } + lists = append(lists, l) + } + + if lists == nil { + lists = []domain.CRMList{} + } + + return lists, nil +} + +// GetCustomerByEmail busca um cliente pelo email +func (r *CRMRepository) GetCustomerByEmail(email string) (*domain.CRMCustomer, error) { + query := ` + SELECT id, tenant_id, name, email, + COALESCE(phone, '') as phone, + COALESCE(company, '') as company, + COALESCE(position, '') as position, + COALESCE(address, '') as address, + COALESCE(city, '') as city, + COALESCE(state, '') as state, + COALESCE(zip_code, '') as zip_code, + COALESCE(country, '') as country, + COALESCE(notes, '{}') as notes, + COALESCE(tags, '{}') as tags, + is_active, + created_by, + created_at, + updated_at, + COALESCE(password_hash, '') as password_hash, + has_portal_access, + portal_last_login, + portal_created_at + FROM crm_customers + WHERE email = $1 AND is_active = true + ` + + var c domain.CRMCustomer + var createdBy sql.NullString + err := r.db.QueryRow(query, email).Scan( + &c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position, + &c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags), + &c.IsActive, &createdBy, &c.CreatedAt, &c.UpdatedAt, + &c.PasswordHash, &c.HasPortalAccess, &c.PortalLastLogin, &c.PortalCreatedAt, + ) + + if err != nil { + return nil, err + } + + if createdBy.Valid { + c.CreatedBy = createdBy.String + } + + return &c, nil +} + +// UpdateCustomerLastLogin atualiza o último login do cliente no portal +func (r *CRMRepository) UpdateCustomerLastLogin(customerID string) error { + query := `UPDATE crm_customers SET portal_last_login = NOW() WHERE id = $1` + _, err := r.db.Exec(query, customerID) + return err +} + +// SetCustomerPortalAccess define o acesso ao portal e senha para um cliente +func (r *CRMRepository) SetCustomerPortalAccess(customerID, passwordHash string, hasAccess bool) error { + query := ` + UPDATE crm_customers + SET password_hash = $1, + has_portal_access = $2, + portal_created_at = CASE + WHEN portal_created_at IS NULL THEN NOW() + ELSE portal_created_at + END + WHERE id = $3 + ` + _, err := r.db.Exec(query, passwordHash, hasAccess, customerID) + return err +} + +// UpdateCustomerPassword atualiza apenas a senha do cliente +func (r *CRMRepository) UpdateCustomerPassword(customerID, passwordHash string) error { + query := ` + UPDATE crm_customers + SET password_hash = $1 + WHERE id = $2 + ` + _, err := r.db.Exec(query, passwordHash, customerID) + return err +} + +// UpdateCustomerLogo atualiza apenas o logo do cliente +func (r *CRMRepository) UpdateCustomerLogo(customerID, tenantID, logoURL string) error { + query := ` + UPDATE crm_customers + SET logo_url = $1 + WHERE id = $2 AND tenant_id = $3 + ` + _, err := r.db.Exec(query, logoURL, customerID, tenantID) + return err +} + +// GetCustomerByEmailAndTenant checks if a customer with the given email exists for the tenant +func (r *CRMRepository) GetCustomerByEmailAndTenant(email string, tenantID string) (*domain.CRMCustomer, error) { + query := ` + SELECT id, tenant_id, name, email, phone, company, position, + address, city, state, zip_code, country, notes, tags, + is_active, created_by, created_at, updated_at + FROM crm_customers + WHERE LOWER(email) = LOWER($1) AND tenant_id = $2 + LIMIT 1 + ` + + var c domain.CRMCustomer + err := r.db.QueryRow(query, email, tenantID).Scan( + &c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position, + &c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags), + &c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, + ) + + if err == sql.ErrNoRows { + return nil, nil // Not found is not an error + } + if err != nil { + return nil, err + } + + return &c, nil +} + +// TenantExists checks if a tenant with the given ID exists +func (r *CRMRepository) TenantExists(tenantID string) (bool, error) { + query := `SELECT EXISTS(SELECT 1 FROM tenants WHERE id = $1 AND is_active = true)` + var exists bool + err := r.db.QueryRow(query, tenantID).Scan(&exists) + return exists, err +} + +// EnableCustomerPortalAccess habilita o acesso ao portal para um cliente (usado na aprovação) +func (r *CRMRepository) EnableCustomerPortalAccess(customerID string) error { + query := ` + UPDATE crm_customers + SET has_portal_access = true, + portal_created_at = COALESCE(portal_created_at, NOW()) + WHERE id = $1 + ` + _, err := r.db.Exec(query, customerID) + return err +} + +// GetCustomerByCPF checks if a customer with the given CPF exists for the tenant +func (r *CRMRepository) GetCustomerByCPF(cpf string, tenantID string) (*domain.CRMCustomer, error) { + query := ` + SELECT id, tenant_id, name, email, phone, company, position, + address, city, state, zip_code, country, notes, tags, + is_active, created_by, created_at, updated_at + FROM crm_customers + WHERE tenant_id = $1 AND notes LIKE '%"cpf":"' || $2 || '"%' + LIMIT 1 + ` + + var c domain.CRMCustomer + err := r.db.QueryRow(query, tenantID, cpf).Scan( + &c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position, + &c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags), + &c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, + ) + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + return &c, nil +} + +// GetCustomerByCNPJ checks if a customer with the given CNPJ exists for the tenant +func (r *CRMRepository) GetCustomerByCNPJ(cnpj string, tenantID string) (*domain.CRMCustomer, error) { + query := ` + SELECT id, tenant_id, name, email, phone, company, position, + address, city, state, zip_code, country, notes, tags, + is_active, created_by, created_at, updated_at + FROM crm_customers + WHERE tenant_id = $1 AND notes LIKE '%"cnpj":"' || $2 || '"%' + LIMIT 1 + ` + + var c domain.CRMCustomer + err := r.db.QueryRow(query, tenantID, cnpj).Scan( + &c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position, + &c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags), + &c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, + ) + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + return &c, nil +} + +func (r *CRMRepository) GetLeadsByListID(listID string) ([]domain.CRMLead, error) { + query := ` + SELECT l.id, l.tenant_id, l.customer_id, l.funnel_id, l.stage_id, l.name, l.email, l.phone, + l.source, l.source_meta, l.status, COALESCE(l.notes, ''), l.tags, + l.is_active, COALESCE(l.created_by::text, '') as created_by, l.created_at, l.updated_at + FROM crm_leads l + INNER JOIN crm_lead_lists ll ON l.id = ll.lead_id + WHERE ll.list_id = $1 + ORDER BY l.created_at DESC + ` + + rows, err := r.db.Query(query, listID) + if err != nil { + return nil, err + } + defer rows.Close() + + var leads []domain.CRMLead + for rows.Next() { + var l domain.CRMLead + var sourceMeta []byte + err := rows.Scan( + &l.ID, &l.TenantID, &l.CustomerID, &l.FunnelID, &l.StageID, &l.Name, &l.Email, &l.Phone, + &l.Source, &sourceMeta, &l.Status, &l.Notes, pq.Array(&l.Tags), + &l.IsActive, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt, + ) + if err != nil { + log.Printf("Error scanning lead from list: %v", err) + continue + } + if sourceMeta != nil { + l.SourceMeta = sourceMeta + } + leads = append(leads, l) + } + + return leads, nil +} + +// ==================== FUNNELS & STAGES ==================== + +func (r *CRMRepository) CreateFunnel(funnel *domain.CRMFunnel) error { + query := ` + INSERT INTO crm_funnels (id, tenant_id, name, description, is_default) + VALUES ($1, $2, $3, $4, $5) + RETURNING created_at, updated_at + ` + return r.db.QueryRow(query, funnel.ID, funnel.TenantID, funnel.Name, funnel.Description, funnel.IsDefault). + Scan(&funnel.CreatedAt, &funnel.UpdatedAt) +} + +func (r *CRMRepository) GetFunnelsByTenant(tenantID string) ([]domain.CRMFunnel, error) { + query := `SELECT id, tenant_id, name, COALESCE(description, ''), is_default, created_at, updated_at FROM crm_funnels WHERE tenant_id = $1 ORDER BY created_at ASC` + rows, err := r.db.Query(query, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + + var funnels []domain.CRMFunnel + for rows.Next() { + var f domain.CRMFunnel + if err := rows.Scan(&f.ID, &f.TenantID, &f.Name, &f.Description, &f.IsDefault, &f.CreatedAt, &f.UpdatedAt); err != nil { + return nil, err + } + funnels = append(funnels, f) + } + return funnels, nil +} + +func (r *CRMRepository) GetFunnelByID(id, tenantID string) (*domain.CRMFunnel, error) { + query := `SELECT id, tenant_id, name, COALESCE(description, ''), is_default, created_at, updated_at FROM crm_funnels WHERE id = $1 AND tenant_id = $2` + var f domain.CRMFunnel + err := r.db.QueryRow(query, id, tenantID).Scan(&f.ID, &f.TenantID, &f.Name, &f.Description, &f.IsDefault, &f.CreatedAt, &f.UpdatedAt) + if err != nil { + return nil, err + } + return &f, nil +} + +func (r *CRMRepository) UpdateFunnel(funnel *domain.CRMFunnel) error { + query := `UPDATE crm_funnels SET name = $1, description = $2, is_default = $3, updated_at = CURRENT_TIMESTAMP WHERE id = $4 AND tenant_id = $5` + _, err := r.db.Exec(query, funnel.Name, funnel.Description, funnel.IsDefault, funnel.ID, funnel.TenantID) + return err +} + +func (r *CRMRepository) DeleteFunnel(id, tenantID string) error { + query := `DELETE FROM crm_funnels WHERE id = $1 AND tenant_id = $2` + _, err := r.db.Exec(query, id, tenantID) + return err +} + +func (r *CRMRepository) CreateFunnelStage(stage *domain.CRMFunnelStage) error { + query := ` + INSERT INTO crm_funnel_stages (id, funnel_id, name, description, color, order_index) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING created_at, updated_at + ` + return r.db.QueryRow(query, stage.ID, stage.FunnelID, stage.Name, stage.Description, stage.Color, stage.OrderIndex). + Scan(&stage.CreatedAt, &stage.UpdatedAt) +} + +func (r *CRMRepository) GetStagesByFunnelID(funnelID string) ([]domain.CRMFunnelStage, error) { + query := `SELECT id, funnel_id, name, COALESCE(description, ''), color, order_index, created_at, updated_at FROM crm_funnel_stages WHERE funnel_id = $1 ORDER BY order_index ASC` + rows, err := r.db.Query(query, funnelID) + if err != nil { + return nil, err + } + defer rows.Close() + + var stages []domain.CRMFunnelStage + for rows.Next() { + var s domain.CRMFunnelStage + if err := rows.Scan(&s.ID, &s.FunnelID, &s.Name, &s.Description, &s.Color, &s.OrderIndex, &s.CreatedAt, &s.UpdatedAt); err != nil { + return nil, err + } + stages = append(stages, s) + } + return stages, nil +} + +func (r *CRMRepository) UpdateFunnelStage(stage *domain.CRMFunnelStage) error { + query := `UPDATE crm_funnel_stages SET name = $1, description = $2, color = $3, order_index = $4, updated_at = CURRENT_TIMESTAMP WHERE id = $5` + _, err := r.db.Exec(query, stage.Name, stage.Description, stage.Color, stage.OrderIndex, stage.ID) + return err +} + +func (r *CRMRepository) DeleteFunnelStage(id string) error { + query := `DELETE FROM crm_funnel_stages WHERE id = $1` + _, err := r.db.Exec(query, id) + return err +} + +func (r *CRMRepository) UpdateLeadStage(leadID, tenantID, funnelID, stageID string) error { + query := `UPDATE crm_leads SET funnel_id = $1, stage_id = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3 AND tenant_id = $4` + _, err := r.db.Exec(query, funnelID, stageID, leadID, tenantID) + return err +} + +func (r *CRMRepository) EnsureDefaultFunnel(tenantID string) (string, error) { + // Check if tenant already has a funnel + var funnelID string + query := `SELECT id FROM crm_funnels WHERE tenant_id = $1 LIMIT 1` + err := r.db.QueryRow(query, tenantID).Scan(&funnelID) + if err == nil { + return funnelID, nil + } + + // If not, create default using the function we defined in migration + query = `SELECT create_default_crm_funnel($1)` + err = r.db.QueryRow(query, tenantID).Scan(&funnelID) + return funnelID, err +} + + diff --git a/backend/internal/repository/user_repository.go b/backend/internal/repository/user_repository.go index 3787c63..46170f9 100644 --- a/backend/internal/repository/user_repository.go +++ b/backend/internal/repository/user_repository.go @@ -161,3 +161,73 @@ func (r *UserRepository) FindAdminByTenantID(tenantID uuid.UUID) (*domain.User, return user, nil } +// ListByTenantID returns all users for a tenant (excluding the tenant admin) +func (r *UserRepository) ListByTenantID(tenantID uuid.UUID) ([]domain.User, error) { + query := ` + SELECT id, tenant_id, email, password_hash, first_name, role, created_at, updated_at, + agency_role, created_by, collaborator_created_at + FROM users + WHERE tenant_id = $1 AND is_active = true AND role != 'SUPERADMIN' + ORDER BY created_at DESC + ` + + rows, err := r.db.Query(query, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + + var users []domain.User + for rows.Next() { + user := domain.User{} + err := rows.Scan( + &user.ID, + &user.TenantID, + &user.Email, + &user.Password, + &user.Name, + &user.Role, + &user.CreatedAt, + &user.UpdatedAt, + &user.AgencyRole, + &user.CreatedBy, + &user.CollaboratorCreatedAt, + ) + if err != nil { + return nil, err + } + users = append(users, user) + } + + return users, rows.Err() +} + +// GetByID returns a user by ID +func (r *UserRepository) GetByID(id uuid.UUID) (*domain.User, error) { + return r.FindByID(id) +} + +// Delete marks a user as inactive +func (r *UserRepository) Delete(id uuid.UUID) error { + query := ` + UPDATE users + SET is_active = false, updated_at = NOW() + WHERE id = $1 + ` + + result, err := r.db.Exec(query, id) + if err != nil { + return err + } + + rows, err := result.RowsAffected() + if err != nil { + return err + } + + if rows == 0 { + return sql.ErrNoRows + } + + return nil +} \ No newline at end of file diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index 817abb5..aa12bad 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -24,17 +24,19 @@ var ( // AuthService handles authentication business logic type AuthService struct { - userRepo *repository.UserRepository - tenantRepo *repository.TenantRepository - cfg *config.Config + userRepo *repository.UserRepository + tenantRepo *repository.TenantRepository + crmRepo *repository.CRMRepository + cfg *config.Config } // NewAuthService creates a new auth service -func NewAuthService(userRepo *repository.UserRepository, tenantRepo *repository.TenantRepository, cfg *config.Config) *AuthService { +func NewAuthService(userRepo *repository.UserRepository, tenantRepo *repository.TenantRepository, crmRepo *repository.CRMRepository, cfg *config.Config) *AuthService { return &AuthService{ - userRepo: userRepo, - tenantRepo: tenantRepo, - cfg: cfg, + userRepo: userRepo, + tenantRepo: tenantRepo, + crmRepo: crmRepo, + cfg: cfg, } } @@ -175,3 +177,158 @@ func (s *AuthService) ChangePassword(userID string, currentPassword, newPassword func parseUUID(s string) (uuid.UUID, error) { return uuid.Parse(s) } + +// GenerateCustomerToken gera um token JWT para um cliente do CRM +func (s *AuthService) GenerateCustomerToken(customerID, tenantID, email string) (string, error) { + claims := jwt.MapClaims{ + "customer_id": customerID, + "tenant_id": tenantID, + "email": email, + "type": "customer_portal", + "exp": time.Now().Add(time.Hour * 24 * 30).Unix(), // 30 dias + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(s.cfg.JWT.Secret)) +} + +// UnifiedLogin autentica qualquer tipo de usuário (agência ou cliente) e retorna token unificado +func (s *AuthService) UnifiedLogin(req domain.UnifiedLoginRequest) (*domain.UnifiedLoginResponse, error) { + email := req.Email + password := req.Password + + // TENTATIVA 1: Buscar em users (agência) + user, err := s.userRepo.FindByEmail(email) + if err == nil && user != nil { + // Verificar senha + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil { + log.Printf("❌ Password mismatch for agency user %s", email) + return nil, ErrInvalidCredentials + } + + // SUPERADMIN usa login próprio em outro domínio, não deve usar esta rota + if user.Role == "SUPERADMIN" { + log.Printf("🚫 SUPERADMIN attempted unified login - redirecting to proper endpoint") + return nil, errors.New("superadmins devem usar o painel administrativo") + } + + // Gerar token unificado para agency_user + token, err := s.generateUnifiedToken(user.ID.String(), domain.UserTypeAgency, email, user.Role, user.AgencyRole, user.TenantID) + if err != nil { + log.Printf("❌ Error generating unified token: %v", err) + return nil, err + } + + // Buscar subdomain se tiver tenant + subdomain := "" + tenantID := "" + if user.TenantID != nil { + tenantID = user.TenantID.String() + tenant, err := s.tenantRepo.FindByID(*user.TenantID) + if err == nil && tenant != nil { + subdomain = tenant.Subdomain + } + } + + log.Printf("✅ Agency user logged in: %s (type=agency_user, role=%s, agency_role=%s)", email, user.Role, user.AgencyRole) + + return &domain.UnifiedLoginResponse{ + Token: token, + UserType: domain.UserTypeAgency, + UserID: user.ID.String(), + Email: email, + Name: user.Name, + Role: user.Role, + AgencyRole: user.AgencyRole, + TenantID: tenantID, + Subdomain: subdomain, + }, nil + } + + // TENTATIVA 2: Buscar em crm_customers + log.Printf("🔍 Attempting to find customer in CRM: %s", email) + customer, err := s.crmRepo.GetCustomerByEmail(email) + log.Printf("🔍 CRM GetCustomerByEmail result: customer=%v, err=%v", customer != nil, err) + if err == nil && customer != nil { + // Verificar se tem acesso ao portal + if !customer.HasPortalAccess { + log.Printf("🚫 Customer %s has no portal access", email) + return nil, errors.New("acesso ao portal não autorizado. Entre em contato com o administrador") + } + + // Verificar senha + if customer.PasswordHash == "" { + log.Printf("❌ Customer %s has no password set", email) + return nil, ErrInvalidCredentials + } + + if err := bcrypt.CompareHashAndPassword([]byte(customer.PasswordHash), []byte(password)); err != nil { + log.Printf("❌ Password mismatch for customer %s", email) + return nil, ErrInvalidCredentials + } + + // Atualizar último login + if err := s.crmRepo.UpdateCustomerLastLogin(customer.ID); err != nil { + log.Printf("⚠️ Warning: Failed to update last login for customer %s: %v", customer.ID, err) + } + + // Gerar token unificado + tenantUUID, _ := uuid.Parse(customer.TenantID) + token, err := s.generateUnifiedToken(customer.ID, domain.UserTypeCustomer, email, "", "", &tenantUUID) + if err != nil { + log.Printf("❌ Error generating unified token: %v", err) + return nil, err + } + + // Buscar subdomain do tenant + subdomain := "" + if tenantUUID != uuid.Nil { + tenant, err := s.tenantRepo.FindByID(tenantUUID) + if err == nil && tenant != nil { + subdomain = tenant.Subdomain + } + } + + log.Printf("✅ Customer logged in: %s (tenant=%s)", email, customer.TenantID) + + return &domain.UnifiedLoginResponse{ + Token: token, + UserType: domain.UserTypeCustomer, + UserID: customer.ID, + Email: email, + Name: customer.Name, + TenantID: customer.TenantID, + Subdomain: subdomain, + }, nil + } + + // Não encontrou em nenhuma tabela + log.Printf("❌ User not found: %s", email) + return nil, ErrInvalidCredentials +} + +// generateUnifiedToken cria um JWT com claims unificadas +func (s *AuthService) generateUnifiedToken(userID string, userType domain.UserType, email, role, agencyRole string, tenantID *uuid.UUID) (string, error) { + tenantIDStr := "" + if tenantID != nil { + tenantIDStr = tenantID.String() + } + + claims := domain.UnifiedClaims{ + UserID: userID, + UserType: userType, + TenantID: tenantIDStr, + Email: email, + Role: role, + AgencyRole: agencyRole, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24 * 30)), // 30 dias + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(s.cfg.JWT.Secret)) +} + + diff --git a/backups/.superadmin_password.txt b/backups/.superadmin_password.txt new file mode 100644 index 0000000..0242b98 Binary files /dev/null and b/backups/.superadmin_password.txt differ diff --git a/backups/aggios_backup_2025-12-13_19-56-18.sql b/backups/aggios_backup_2025-12-13_19-56-18.sql new file mode 100644 index 0000000..412a239 Binary files /dev/null and b/backups/aggios_backup_2025-12-13_19-56-18.sql differ diff --git a/backups/aggios_backup_2025-12-13_20-12-49.sql b/backups/aggios_backup_2025-12-13_20-12-49.sql new file mode 100644 index 0000000..19d7087 Binary files /dev/null and b/backups/aggios_backup_2025-12-13_20-12-49.sql differ diff --git a/backups/aggios_backup_2025-12-13_20-17-59.sql b/backups/aggios_backup_2025-12-13_20-17-59.sql new file mode 100644 index 0000000..5b4e56c Binary files /dev/null and b/backups/aggios_backup_2025-12-13_20-17-59.sql differ diff --git a/backups/aggios_backup_2025-12-13_20-23-08.sql b/backups/aggios_backup_2025-12-13_20-23-08.sql new file mode 100644 index 0000000..f71ebe7 Binary files /dev/null and b/backups/aggios_backup_2025-12-13_20-23-08.sql differ diff --git a/backups/aggios_backup_2025-12-14_02-42-03.sql b/backups/aggios_backup_2025-12-14_02-42-03.sql new file mode 100644 index 0000000..b62521a --- /dev/null +++ b/backups/aggios_backup_2025-12-14_02-42-03.sql @@ -0,0 +1,343 @@ +-- +-- PostgreSQL database dump +-- + +\restrict mUKTWCYeXvRf2SKhMr352J1jYiouAP5fsYPxvQjxn9xhEgk8BrOSEtYCYQoFicQ + +-- Dumped from database version 16.11 +-- Dumped by pg_dump version 18.1 + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET transaction_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +-- +-- Name: pgcrypto; Type: EXTENSION; Schema: -; Owner: - +-- + +CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public; + + +-- +-- Name: EXTENSION pgcrypto; Type: COMMENT; Schema: -; Owner: +-- + +COMMENT ON EXTENSION pgcrypto IS 'cryptographic functions'; + + +-- +-- Name: uuid-ossp; Type: EXTENSION; Schema: -; Owner: - +-- + +CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA public; + + +-- +-- Name: EXTENSION "uuid-ossp"; Type: COMMENT; Schema: -; Owner: +-- + +COMMENT ON EXTENSION "uuid-ossp" IS 'generate universally unique identifiers (UUIDs)'; + + +SET default_tablespace = ''; + +SET default_table_access_method = heap; + +-- +-- Name: companies; Type: TABLE; Schema: public; Owner: aggios +-- + +CREATE TABLE public.companies ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + tenant_id uuid NOT NULL, + cnpj character varying(18) NOT NULL, + razao_social character varying(255) NOT NULL, + nome_fantasia character varying(255), + email character varying(255), + telefone character varying(20), + status character varying(50) DEFAULT 'active'::character varying, + created_by_user_id uuid, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP +); + + +ALTER TABLE public.companies OWNER TO aggios; + +-- +-- Name: refresh_tokens; Type: TABLE; Schema: public; Owner: aggios +-- + +CREATE TABLE public.refresh_tokens ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + user_id uuid NOT NULL, + token_hash character varying(255) NOT NULL, + expires_at timestamp with time zone NOT NULL, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP +); + + +ALTER TABLE public.refresh_tokens OWNER TO aggios; + +-- +-- Name: tenants; Type: TABLE; Schema: public; Owner: aggios +-- + +CREATE TABLE public.tenants ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + name character varying(255) NOT NULL, + domain character varying(255) NOT NULL, + subdomain character varying(63) NOT NULL, + cnpj character varying(18), + razao_social character varying(255), + email character varying(255), + phone character varying(20), + website character varying(255), + address text, + city character varying(100), + state character varying(2), + zip character varying(10), + description text, + industry character varying(100), + is_active boolean DEFAULT true, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + neighborhood character varying(100), + street character varying(100), + number character varying(20), + complement character varying(100), + team_size character varying(20), + primary_color character varying(7), + secondary_color character varying(7), + logo_url text, + logo_horizontal_url text +); + + +ALTER TABLE public.tenants OWNER TO aggios; + +-- +-- Name: users; Type: TABLE; Schema: public; Owner: aggios +-- + +CREATE TABLE public.users ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + tenant_id uuid, + email character varying(255) NOT NULL, + password_hash character varying(255) NOT NULL, + first_name character varying(128), + last_name character varying(128), + role character varying(50) DEFAULT 'CLIENTE'::character varying, + is_active boolean DEFAULT true, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT users_role_check CHECK (((role)::text = ANY ((ARRAY['SUPERADMIN'::character varying, 'ADMIN_AGENCIA'::character varying, 'CLIENTE'::character varying])::text[]))) +); + + +ALTER TABLE public.users OWNER TO aggios; + +-- +-- Data for Name: companies; Type: TABLE DATA; Schema: public; Owner: aggios +-- + +COPY public.companies (id, tenant_id, cnpj, razao_social, nome_fantasia, email, telefone, status, created_by_user_id, created_at, updated_at) FROM stdin; +\. + + +-- +-- Data for Name: refresh_tokens; Type: TABLE DATA; Schema: public; Owner: aggios +-- + +COPY public.refresh_tokens (id, user_id, token_hash, expires_at, created_at) FROM stdin; +\. + + +-- +-- Data for Name: tenants; Type: TABLE DATA; Schema: public; Owner: aggios +-- + +COPY public.tenants (id, name, domain, subdomain, cnpj, razao_social, email, phone, website, address, city, state, zip, description, industry, is_active, created_at, updated_at, neighborhood, street, number, complement, team_size, primary_color, secondary_color, logo_url, logo_horizontal_url) FROM stdin; +d351e725-1428-45f3-b2e3-ca767e9b952c Agência Teste agencia-teste.aggios.app agencia-teste \N \N \N \N \N \N \N \N \N \N \N t 2025-12-13 22:31:35.818953+00 2025-12-13 22:31:35.818953+00 \N \N \N \N \N \N \N \N \N +13d32cc3-0490-4557-96a3-7a38da194185 Empresa Teste teste-empresa.localhost teste-empresa 12.345.678/0001-90 EMPRESA TESTE LTDA teste@teste.com (11) 99999-9999 teste.com.br Avenida Paulista, 1000 - Andar 10 S�o Paulo SP 01310-100 Empresa de teste tecnologia t 2025-12-13 23:22:58.406376+00 2025-12-13 23:22:58.406376+00 Bela Vista \N 1000 Andar 10 1-10 #8B5CF6 #A78BFA +ae271be0-a63c-407f-9cf6-a4a8c8a0a4dc IdealPages idealpages.localhost idealpages 31.091.190/0001-23 ERIK DA SILVA SANTOS 36615318830 erik@idealpages.com.br (13) 92000-4392 idealpages.com.br Rua Quatorze, 150 - Casa Guarujá SP 11436-575 Empresa de contrucao de marca e desenvolvimento de software agencia-digital t 2025-12-13 23:23:35.508285+00 2025-12-13 23:26:40.947714+00 Vila Zilda \N 150 Casa 1-10 #8B5CF6 #A78BFA http://api.localhost/api/files/aggios-logos/tenants/ae271be0-a63c-407f-9cf6-a4a8c8a0a4dc/logo-1765668400.png +\. + + +-- +-- Data for Name: users; Type: TABLE DATA; Schema: public; Owner: aggios +-- + +COPY public.users (id, tenant_id, email, password_hash, first_name, last_name, role, is_active, created_at, updated_at) FROM stdin; +7b51ae6e-6fb0-42c4-8473-a98cbfcda6a4 \N admin@aggios.app $2a$10$yhCREFqXL7FA4zveCFcl4eYODNTSyt/swuYjS0nXkEq8pzqJo.BwO Super Admin SUPERADMIN t 2025-12-13 23:02:33.124444+00 2025-12-13 23:02:33.124444+00 +488351e7-4ddc-41a4-9cd3-5c3dec833c44 13d32cc3-0490-4557-96a3-7a38da194185 teste@teste.com $2a$10$fx3bQqL01A9UqJwSwKpdLuVCq8M/1L9CvcQhx5tTkdinsvCpPsh4a Teste Silva \N ADMIN_AGENCIA t 2025-12-13 23:22:58.446011+00 2025-12-13 23:22:58.446011+00 +8742c1a1-5f1a-4df3-aa53-dcf94a2a2591 ae271be0-a63c-407f-9cf6-a4a8c8a0a4dc erik@idealpages.com.br $2a$10$tD8Kq/ZW0fbmW3Ga5JsKbOUy0nzsIZwkXJKaf43gFDVnRxjaf63Em Erik da Silva Santos \N ADMIN_AGENCIA t 2025-12-13 23:23:35.551192+00 2025-12-13 23:23:35.551192+00 +\. + + +-- +-- Name: companies companies_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.companies + ADD CONSTRAINT companies_pkey PRIMARY KEY (id); + + +-- +-- Name: companies companies_tenant_id_cnpj_key; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.companies + ADD CONSTRAINT companies_tenant_id_cnpj_key UNIQUE (tenant_id, cnpj); + + +-- +-- Name: refresh_tokens refresh_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.refresh_tokens + ADD CONSTRAINT refresh_tokens_pkey PRIMARY KEY (id); + + +-- +-- Name: tenants tenants_domain_key; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.tenants + ADD CONSTRAINT tenants_domain_key UNIQUE (domain); + + +-- +-- Name: tenants tenants_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.tenants + ADD CONSTRAINT tenants_pkey PRIMARY KEY (id); + + +-- +-- Name: tenants tenants_subdomain_key; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.tenants + ADD CONSTRAINT tenants_subdomain_key UNIQUE (subdomain); + + +-- +-- Name: users users_email_key; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_email_key UNIQUE (email); + + +-- +-- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_pkey PRIMARY KEY (id); + + +-- +-- Name: idx_companies_cnpj; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_companies_cnpj ON public.companies USING btree (cnpj); + + +-- +-- Name: idx_companies_tenant_id; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_companies_tenant_id ON public.companies USING btree (tenant_id); + + +-- +-- Name: idx_refresh_tokens_expires_at; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_refresh_tokens_expires_at ON public.refresh_tokens USING btree (expires_at); + + +-- +-- Name: idx_refresh_tokens_user_id; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_refresh_tokens_user_id ON public.refresh_tokens USING btree (user_id); + + +-- +-- Name: idx_tenants_domain; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_tenants_domain ON public.tenants USING btree (domain); + + +-- +-- Name: idx_tenants_subdomain; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_tenants_subdomain ON public.tenants USING btree (subdomain); + + +-- +-- Name: idx_users_email; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_users_email ON public.users USING btree (email); + + +-- +-- Name: idx_users_tenant_id; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_users_tenant_id ON public.users USING btree (tenant_id); + + +-- +-- Name: companies companies_created_by_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.companies + ADD CONSTRAINT companies_created_by_user_id_fkey FOREIGN KEY (created_by_user_id) REFERENCES public.users(id); + + +-- +-- Name: companies companies_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.companies + ADD CONSTRAINT companies_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE; + + +-- +-- Name: refresh_tokens refresh_tokens_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.refresh_tokens + ADD CONSTRAINT refresh_tokens_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: users users_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE; + + +-- +-- PostgreSQL database dump complete +-- + +\unrestrict mUKTWCYeXvRf2SKhMr352J1jYiouAP5fsYPxvQjxn9xhEgk8BrOSEtYCYQoFicQ + diff --git a/backups/aggios_backup_2025-12-14_03-42-29.sql b/backups/aggios_backup_2025-12-14_03-42-29.sql new file mode 100644 index 0000000..d56caea --- /dev/null +++ b/backups/aggios_backup_2025-12-14_03-42-29.sql @@ -0,0 +1,343 @@ +-- +-- PostgreSQL database dump +-- + +\restrict ZSl79LbDN89EVihiEgzYdjR8EV38YLVYgKFBBZX4jKNuTBgFyc2DCZ8bFM5F42n + +-- Dumped from database version 16.11 +-- Dumped by pg_dump version 18.1 + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET transaction_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +-- +-- Name: pgcrypto; Type: EXTENSION; Schema: -; Owner: - +-- + +CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public; + + +-- +-- Name: EXTENSION pgcrypto; Type: COMMENT; Schema: -; Owner: +-- + +COMMENT ON EXTENSION pgcrypto IS 'cryptographic functions'; + + +-- +-- Name: uuid-ossp; Type: EXTENSION; Schema: -; Owner: - +-- + +CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA public; + + +-- +-- Name: EXTENSION "uuid-ossp"; Type: COMMENT; Schema: -; Owner: +-- + +COMMENT ON EXTENSION "uuid-ossp" IS 'generate universally unique identifiers (UUIDs)'; + + +SET default_tablespace = ''; + +SET default_table_access_method = heap; + +-- +-- Name: companies; Type: TABLE; Schema: public; Owner: aggios +-- + +CREATE TABLE public.companies ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + tenant_id uuid NOT NULL, + cnpj character varying(18) NOT NULL, + razao_social character varying(255) NOT NULL, + nome_fantasia character varying(255), + email character varying(255), + telefone character varying(20), + status character varying(50) DEFAULT 'active'::character varying, + created_by_user_id uuid, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP +); + + +ALTER TABLE public.companies OWNER TO aggios; + +-- +-- Name: refresh_tokens; Type: TABLE; Schema: public; Owner: aggios +-- + +CREATE TABLE public.refresh_tokens ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + user_id uuid NOT NULL, + token_hash character varying(255) NOT NULL, + expires_at timestamp with time zone NOT NULL, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP +); + + +ALTER TABLE public.refresh_tokens OWNER TO aggios; + +-- +-- Name: tenants; Type: TABLE; Schema: public; Owner: aggios +-- + +CREATE TABLE public.tenants ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + name character varying(255) NOT NULL, + domain character varying(255) NOT NULL, + subdomain character varying(63) NOT NULL, + cnpj character varying(18), + razao_social character varying(255), + email character varying(255), + phone character varying(20), + website character varying(255), + address text, + city character varying(100), + state character varying(2), + zip character varying(10), + description text, + industry character varying(100), + is_active boolean DEFAULT true, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + neighborhood character varying(100), + street character varying(100), + number character varying(20), + complement character varying(100), + team_size character varying(20), + primary_color character varying(7), + secondary_color character varying(7), + logo_url text, + logo_horizontal_url text +); + + +ALTER TABLE public.tenants OWNER TO aggios; + +-- +-- Name: users; Type: TABLE; Schema: public; Owner: aggios +-- + +CREATE TABLE public.users ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + tenant_id uuid, + email character varying(255) NOT NULL, + password_hash character varying(255) NOT NULL, + first_name character varying(128), + last_name character varying(128), + role character varying(50) DEFAULT 'CLIENTE'::character varying, + is_active boolean DEFAULT true, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT users_role_check CHECK (((role)::text = ANY ((ARRAY['SUPERADMIN'::character varying, 'ADMIN_AGENCIA'::character varying, 'CLIENTE'::character varying])::text[]))) +); + + +ALTER TABLE public.users OWNER TO aggios; + +-- +-- Data for Name: companies; Type: TABLE DATA; Schema: public; Owner: aggios +-- + +COPY public.companies (id, tenant_id, cnpj, razao_social, nome_fantasia, email, telefone, status, created_by_user_id, created_at, updated_at) FROM stdin; +\. + + +-- +-- Data for Name: refresh_tokens; Type: TABLE DATA; Schema: public; Owner: aggios +-- + +COPY public.refresh_tokens (id, user_id, token_hash, expires_at, created_at) FROM stdin; +\. + + +-- +-- Data for Name: tenants; Type: TABLE DATA; Schema: public; Owner: aggios +-- + +COPY public.tenants (id, name, domain, subdomain, cnpj, razao_social, email, phone, website, address, city, state, zip, description, industry, is_active, created_at, updated_at, neighborhood, street, number, complement, team_size, primary_color, secondary_color, logo_url, logo_horizontal_url) FROM stdin; +d351e725-1428-45f3-b2e3-ca767e9b952c Agência Teste agencia-teste.aggios.app agencia-teste \N \N \N \N \N \N \N \N \N \N \N t 2025-12-13 22:31:35.818953+00 2025-12-13 22:31:35.818953+00 \N \N \N \N \N \N \N \N \N +13d32cc3-0490-4557-96a3-7a38da194185 Empresa Teste teste-empresa.localhost teste-empresa 12.345.678/0001-90 EMPRESA TESTE LTDA teste@teste.com (11) 99999-9999 teste.com.br Avenida Paulista, 1000 - Andar 10 S�o Paulo SP 01310-100 Empresa de teste tecnologia t 2025-12-13 23:22:58.406376+00 2025-12-13 23:22:58.406376+00 Bela Vista \N 1000 Andar 10 1-10 #8B5CF6 #A78BFA +ae271be0-a63c-407f-9cf6-a4a8c8a0a4dc IdealPages idealpages.localhost idealpages 31.091.190/0001-23 ERIK DA SILVA SANTOS 36615318830 erik@idealpages.com.br (13) 92000-4392 idealpages.com.br Rua Quatorze, 150 - Casa Guarujá SP 11436-575 Empresa de contrucao de marca e desenvolvimento de software agencia-digital t 2025-12-13 23:23:35.508285+00 2025-12-13 23:26:40.947714+00 Vila Zilda \N 150 Casa 1-10 #8B5CF6 #A78BFA http://api.localhost/api/files/aggios-logos/tenants/ae271be0-a63c-407f-9cf6-a4a8c8a0a4dc/logo-1765668400.png +\. + + +-- +-- Data for Name: users; Type: TABLE DATA; Schema: public; Owner: aggios +-- + +COPY public.users (id, tenant_id, email, password_hash, first_name, last_name, role, is_active, created_at, updated_at) FROM stdin; +7b51ae6e-6fb0-42c4-8473-a98cbfcda6a4 \N admin@aggios.app $2a$10$yhCREFqXL7FA4zveCFcl4eYODNTSyt/swuYjS0nXkEq8pzqJo.BwO Super Admin SUPERADMIN t 2025-12-13 23:02:33.124444+00 2025-12-13 23:02:33.124444+00 +488351e7-4ddc-41a4-9cd3-5c3dec833c44 13d32cc3-0490-4557-96a3-7a38da194185 teste@teste.com $2a$10$fx3bQqL01A9UqJwSwKpdLuVCq8M/1L9CvcQhx5tTkdinsvCpPsh4a Teste Silva \N ADMIN_AGENCIA t 2025-12-13 23:22:58.446011+00 2025-12-13 23:22:58.446011+00 +8742c1a1-5f1a-4df3-aa53-dcf94a2a2591 ae271be0-a63c-407f-9cf6-a4a8c8a0a4dc erik@idealpages.com.br $2a$10$tD8Kq/ZW0fbmW3Ga5JsKbOUy0nzsIZwkXJKaf43gFDVnRxjaf63Em Erik da Silva Santos \N ADMIN_AGENCIA t 2025-12-13 23:23:35.551192+00 2025-12-13 23:23:35.551192+00 +\. + + +-- +-- Name: companies companies_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.companies + ADD CONSTRAINT companies_pkey PRIMARY KEY (id); + + +-- +-- Name: companies companies_tenant_id_cnpj_key; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.companies + ADD CONSTRAINT companies_tenant_id_cnpj_key UNIQUE (tenant_id, cnpj); + + +-- +-- Name: refresh_tokens refresh_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.refresh_tokens + ADD CONSTRAINT refresh_tokens_pkey PRIMARY KEY (id); + + +-- +-- Name: tenants tenants_domain_key; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.tenants + ADD CONSTRAINT tenants_domain_key UNIQUE (domain); + + +-- +-- Name: tenants tenants_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.tenants + ADD CONSTRAINT tenants_pkey PRIMARY KEY (id); + + +-- +-- Name: tenants tenants_subdomain_key; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.tenants + ADD CONSTRAINT tenants_subdomain_key UNIQUE (subdomain); + + +-- +-- Name: users users_email_key; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_email_key UNIQUE (email); + + +-- +-- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_pkey PRIMARY KEY (id); + + +-- +-- Name: idx_companies_cnpj; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_companies_cnpj ON public.companies USING btree (cnpj); + + +-- +-- Name: idx_companies_tenant_id; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_companies_tenant_id ON public.companies USING btree (tenant_id); + + +-- +-- Name: idx_refresh_tokens_expires_at; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_refresh_tokens_expires_at ON public.refresh_tokens USING btree (expires_at); + + +-- +-- Name: idx_refresh_tokens_user_id; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_refresh_tokens_user_id ON public.refresh_tokens USING btree (user_id); + + +-- +-- Name: idx_tenants_domain; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_tenants_domain ON public.tenants USING btree (domain); + + +-- +-- Name: idx_tenants_subdomain; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_tenants_subdomain ON public.tenants USING btree (subdomain); + + +-- +-- Name: idx_users_email; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_users_email ON public.users USING btree (email); + + +-- +-- Name: idx_users_tenant_id; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_users_tenant_id ON public.users USING btree (tenant_id); + + +-- +-- Name: companies companies_created_by_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.companies + ADD CONSTRAINT companies_created_by_user_id_fkey FOREIGN KEY (created_by_user_id) REFERENCES public.users(id); + + +-- +-- Name: companies companies_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.companies + ADD CONSTRAINT companies_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE; + + +-- +-- Name: refresh_tokens refresh_tokens_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.refresh_tokens + ADD CONSTRAINT refresh_tokens_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: users users_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE; + + +-- +-- PostgreSQL database dump complete +-- + +\unrestrict ZSl79LbDN89EVihiEgzYdjR8EV38YLVYgKFBBZX4jKNuTBgFyc2DCZ8bFM5F42n + diff --git a/backups/aggios_backup_2025-12-16_15-37-28.sql b/backups/aggios_backup_2025-12-16_15-37-28.sql new file mode 100644 index 0000000..b7e7d24 --- /dev/null +++ b/backups/aggios_backup_2025-12-16_15-37-28.sql @@ -0,0 +1,1091 @@ +-- +-- PostgreSQL database dump +-- + +\restrict rsDz7INpk8jrQ6BjlYpnVTNop7sACFH202OZN869YgxdVa2PUD1exrYfNmoL7Sa + +-- Dumped from database version 16.11 +-- Dumped by pg_dump version 18.1 + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET transaction_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +-- +-- Name: pgcrypto; Type: EXTENSION; Schema: -; Owner: - +-- + +CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public; + + +-- +-- Name: EXTENSION pgcrypto; Type: COMMENT; Schema: -; Owner: +-- + +COMMENT ON EXTENSION pgcrypto IS 'cryptographic functions'; + + +-- +-- Name: uuid-ossp; Type: EXTENSION; Schema: -; Owner: - +-- + +CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA public; + + +-- +-- Name: EXTENSION "uuid-ossp"; Type: COMMENT; Schema: -; Owner: +-- + +COMMENT ON EXTENSION "uuid-ossp" IS 'generate universally unique identifiers (UUIDs)'; + + +-- +-- Name: update_updated_at_column(); Type: FUNCTION; Schema: public; Owner: aggios +-- + +CREATE FUNCTION public.update_updated_at_column() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$; + + +ALTER FUNCTION public.update_updated_at_column() OWNER TO aggios; + +SET default_tablespace = ''; + +SET default_table_access_method = heap; + +-- +-- Name: agency_signup_templates; Type: TABLE; Schema: public; Owner: aggios +-- + +CREATE TABLE public.agency_signup_templates ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + name character varying(255) NOT NULL, + slug character varying(100) NOT NULL, + description text, + form_fields jsonb DEFAULT '[]'::jsonb NOT NULL, + available_modules jsonb DEFAULT '[]'::jsonb NOT NULL, + custom_primary_color character varying(7), + custom_logo_url text, + redirect_url text, + success_message text, + is_active boolean DEFAULT true, + usage_count integer DEFAULT 0, + max_uses integer, + expires_at timestamp with time zone, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP +); + + +ALTER TABLE public.agency_signup_templates OWNER TO aggios; + +-- +-- Name: TABLE agency_signup_templates; Type: COMMENT; Schema: public; Owner: aggios +-- + +COMMENT ON TABLE public.agency_signup_templates IS 'Templates de cadastro de agências (SuperAdmin → Agências)'; + + +-- +-- Name: COLUMN agency_signup_templates.form_fields; Type: COMMENT; Schema: public; Owner: aggios +-- + +COMMENT ON COLUMN public.agency_signup_templates.form_fields IS 'Campos do formulário em JSONB: [{name: cnpj, required: true, enabled: true}]'; + + +-- +-- Name: COLUMN agency_signup_templates.available_modules; Type: COMMENT; Schema: public; Owner: aggios +-- + +COMMENT ON COLUMN public.agency_signup_templates.available_modules IS 'Módulos disponíveis: [CRM, Financial, Projects]'; + + +-- +-- Name: agency_subscriptions; Type: TABLE; Schema: public; Owner: aggios +-- + +CREATE TABLE public.agency_subscriptions ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + agency_id uuid NOT NULL, + plan_id uuid NOT NULL, + billing_type character varying(20) DEFAULT 'monthly'::character varying NOT NULL, + current_users integer DEFAULT 0 NOT NULL, + status character varying(50) DEFAULT 'active'::character varying NOT NULL, + start_date timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + renewal_date timestamp without time zone NOT NULL, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP +); + + +ALTER TABLE public.agency_subscriptions OWNER TO aggios; + +-- +-- Name: TABLE agency_subscriptions; Type: COMMENT; Schema: public; Owner: aggios +-- + +COMMENT ON TABLE public.agency_subscriptions IS 'Tracks agency subscription to plans'; + + +-- +-- Name: COLUMN agency_subscriptions.billing_type; Type: COMMENT; Schema: public; Owner: aggios +-- + +COMMENT ON COLUMN public.agency_subscriptions.billing_type IS 'Monthly or annual billing'; + + +-- +-- Name: COLUMN agency_subscriptions.current_users; Type: COMMENT; Schema: public; Owner: aggios +-- + +COMMENT ON COLUMN public.agency_subscriptions.current_users IS 'Current count of users (collaborators + clients)'; + + +-- +-- Name: companies; Type: TABLE; Schema: public; Owner: aggios +-- + +CREATE TABLE public.companies ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + tenant_id uuid NOT NULL, + cnpj character varying(18) NOT NULL, + razao_social character varying(255) NOT NULL, + nome_fantasia character varying(255), + email character varying(255), + telefone character varying(20), + status character varying(50) DEFAULT 'active'::character varying, + created_by_user_id uuid, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP +); + + +ALTER TABLE public.companies OWNER TO aggios; + +-- +-- Name: crm_customer_lists; Type: TABLE; Schema: public; Owner: aggios +-- + +CREATE TABLE public.crm_customer_lists ( + customer_id uuid NOT NULL, + list_id uuid NOT NULL, + added_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP, + added_by uuid +); + + +ALTER TABLE public.crm_customer_lists OWNER TO aggios; + +-- +-- Name: crm_customers; Type: TABLE; Schema: public; Owner: aggios +-- + +CREATE TABLE public.crm_customers ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + tenant_id uuid NOT NULL, + name character varying(255) NOT NULL, + email character varying(255), + phone character varying(50), + company character varying(255), + "position" character varying(100), + address character varying(255), + city character varying(100), + state character varying(50), + zip_code character varying(20), + country character varying(100) DEFAULT 'Brasil'::character varying, + notes text, + tags text[], + is_active boolean DEFAULT true, + created_by uuid, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP +); + + +ALTER TABLE public.crm_customers OWNER TO aggios; + +-- +-- Name: crm_lists; Type: TABLE; Schema: public; Owner: aggios +-- + +CREATE TABLE public.crm_lists ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + tenant_id uuid NOT NULL, + name character varying(100) NOT NULL, + description text, + color character varying(7) DEFAULT '#3b82f6'::character varying, + created_by uuid, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP +); + + +ALTER TABLE public.crm_lists OWNER TO aggios; + +-- +-- Name: plan_solutions; Type: TABLE; Schema: public; Owner: aggios +-- + +CREATE TABLE public.plan_solutions ( + plan_id uuid NOT NULL, + solution_id uuid NOT NULL, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP +); + + +ALTER TABLE public.plan_solutions OWNER TO aggios; + +-- +-- Name: plans; Type: TABLE; Schema: public; Owner: aggios +-- + +CREATE TABLE public.plans ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + name character varying(255) NOT NULL, + slug character varying(100) NOT NULL, + description text, + min_users integer DEFAULT 1 NOT NULL, + max_users integer DEFAULT 30 NOT NULL, + monthly_price numeric(10,2), + annual_price numeric(10,2), + features text[] DEFAULT '{}'::text[] NOT NULL, + differentiators text[] DEFAULT '{}'::text[] NOT NULL, + storage_gb integer DEFAULT 1 NOT NULL, + is_active boolean DEFAULT true NOT NULL, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP +); + + +ALTER TABLE public.plans OWNER TO aggios; + +-- +-- Name: TABLE plans; Type: COMMENT; Schema: public; Owner: aggios +-- + +COMMENT ON TABLE public.plans IS 'Subscription plans for agencies'; + + +-- +-- Name: COLUMN plans.max_users; Type: COMMENT; Schema: public; Owner: aggios +-- + +COMMENT ON COLUMN public.plans.max_users IS '-1 means unlimited users'; + + +-- +-- Name: refresh_tokens; Type: TABLE; Schema: public; Owner: aggios +-- + +CREATE TABLE public.refresh_tokens ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + user_id uuid NOT NULL, + token_hash character varying(255) NOT NULL, + expires_at timestamp with time zone NOT NULL, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP +); + + +ALTER TABLE public.refresh_tokens OWNER TO aggios; + +-- +-- Name: solutions; Type: TABLE; Schema: public; Owner: aggios +-- + +CREATE TABLE public.solutions ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + name character varying(100) NOT NULL, + slug character varying(50) NOT NULL, + icon character varying(50), + description text, + is_active boolean DEFAULT true, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP +); + + +ALTER TABLE public.solutions OWNER TO aggios; + +-- +-- Name: COLUMN solutions.slug; Type: COMMENT; Schema: public; Owner: aggios +-- + +COMMENT ON COLUMN public.solutions.slug IS 'Slug usado para identificar a solução no menu (deve corresponder ao ID do menu no frontend)'; + + +-- +-- Name: COLUMN solutions.icon; Type: COMMENT; Schema: public; Owner: aggios +-- + +COMMENT ON COLUMN public.solutions.icon IS 'Emoji ou código do ícone para exibição visual'; + + +-- +-- Name: template_solutions; Type: TABLE; Schema: public; Owner: aggios +-- + +CREATE TABLE public.template_solutions ( + template_id uuid NOT NULL, + solution_id uuid NOT NULL, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP +); + + +ALTER TABLE public.template_solutions OWNER TO aggios; + +-- +-- Name: TABLE template_solutions; Type: COMMENT; Schema: public; Owner: aggios +-- + +COMMENT ON TABLE public.template_solutions IS 'Relacionamento N:N entre signup templates e solutions - define quais soluções estarão disponíveis ao cadastrar via template'; + + +-- +-- Name: tenants; Type: TABLE; Schema: public; Owner: aggios +-- + +CREATE TABLE public.tenants ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + name character varying(255) NOT NULL, + domain character varying(255) NOT NULL, + subdomain character varying(63) NOT NULL, + cnpj character varying(18), + razao_social character varying(255), + email character varying(255), + phone character varying(20), + website character varying(255), + address text, + city character varying(100), + state character varying(2), + zip character varying(10), + description text, + industry character varying(100), + is_active boolean DEFAULT true, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + neighborhood character varying(100), + street character varying(100), + number character varying(20), + complement character varying(100), + team_size character varying(20), + primary_color character varying(7), + secondary_color character varying(7), + logo_url text, + logo_horizontal_url text +); + + +ALTER TABLE public.tenants OWNER TO aggios; + +-- +-- Name: users; Type: TABLE; Schema: public; Owner: aggios +-- + +CREATE TABLE public.users ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + tenant_id uuid, + email character varying(255) NOT NULL, + password_hash character varying(255) NOT NULL, + first_name character varying(128), + last_name character varying(128), + role character varying(50) DEFAULT 'CLIENTE'::character varying, + is_active boolean DEFAULT true, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT users_role_check CHECK (((role)::text = ANY ((ARRAY['SUPERADMIN'::character varying, 'ADMIN_AGENCIA'::character varying, 'CLIENTE'::character varying])::text[]))) +); + + +ALTER TABLE public.users OWNER TO aggios; + +-- +-- Data for Name: agency_signup_templates; Type: TABLE DATA; Schema: public; Owner: aggios +-- + +COPY public.agency_signup_templates (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 stdin; +\. + + +-- +-- Data for Name: agency_subscriptions; Type: TABLE DATA; Schema: public; Owner: aggios +-- + +COPY public.agency_subscriptions (id, agency_id, plan_id, billing_type, current_users, status, start_date, renewal_date, created_at, updated_at) FROM stdin; +80bc290e-1a78-45b9-8419-a75137a86c7c ae271be0-a63c-407f-9cf6-a4a8c8a0a4dc 2cf6727a-bb36-48dc-8b7c-a207834f4943 monthly 1 active 2025-12-15 19:26:27.783807 2026-01-14 19:26:27.783807 2025-12-15 19:26:27.783807 2025-12-15 19:26:27.783807 +\. + + +-- +-- Data for Name: companies; Type: TABLE DATA; Schema: public; Owner: aggios +-- + +COPY public.companies (id, tenant_id, cnpj, razao_social, nome_fantasia, email, telefone, status, created_by_user_id, created_at, updated_at) FROM stdin; +\. + + +-- +-- Data for Name: crm_customer_lists; Type: TABLE DATA; Schema: public; Owner: aggios +-- + +COPY public.crm_customer_lists (customer_id, list_id, added_at, added_by) FROM stdin; +\. + + +-- +-- Data for Name: crm_customers; Type: TABLE DATA; Schema: public; Owner: aggios +-- + +COPY public.crm_customers (id, tenant_id, name, email, phone, company, "position", address, city, state, zip_code, country, notes, tags, is_active, created_by, created_at, updated_at) FROM stdin; +7160a1e0-d8f0-4fce-a62a-9cc706118ecd ae271be0-a63c-407f-9cf6-a4a8c8a0a4dc teste teste@gmail.com 13996465280 asda asda Brasil asd {dasdas} t 8742c1a1-5f1a-4df3-aa53-dcf94a2a2591 2025-12-16 03:13:10.576109 2025-12-16 03:13:10.576109 +\. + + +-- +-- Data for Name: crm_lists; Type: TABLE DATA; Schema: public; Owner: aggios +-- + +COPY public.crm_lists (id, tenant_id, name, description, color, created_by, created_at, updated_at) FROM stdin; +\. + + +-- +-- Data for Name: plan_solutions; Type: TABLE DATA; Schema: public; Owner: aggios +-- + +COPY public.plan_solutions (plan_id, solution_id, created_at) FROM stdin; +2cf6727a-bb36-48dc-8b7c-a207834f4943 00000000-0000-0000-0000-000000000001 2025-12-16 02:54:22.93456 +\. + + +-- +-- Data for Name: plans; Type: TABLE DATA; Schema: public; Owner: aggios +-- + +COPY public.plans (id, name, slug, description, min_users, max_users, monthly_price, annual_price, features, differentiators, storage_gb, is_active, created_at, updated_at) FROM stdin; +2cf6727a-bb36-48dc-8b7c-a207834f4943 Ignites ignite Plano simples aggios.app 1 30 199.00 1990.00 {} {} 1 t 2025-12-15 00:04:13.710353 2025-12-15 23:54:21.514581 +\. + + +-- +-- Data for Name: refresh_tokens; Type: TABLE DATA; Schema: public; Owner: aggios +-- + +COPY public.refresh_tokens (id, user_id, token_hash, expires_at, created_at) FROM stdin; +\. + + +-- +-- Data for Name: solutions; Type: TABLE DATA; Schema: public; Owner: aggios +-- + +COPY public.solutions (id, name, slug, icon, description, is_active, created_at, updated_at) FROM stdin; +00000000-0000-0000-0000-000000000001 CRM crm Gestão de Relacionamento com Clientes t 2025-12-15 02:58:24.89748 2025-12-15 02:58:24.89748 +00000000-0000-0000-0000-000000000002 ERP erp Gestão Empresarial e Financeira t 2025-12-15 19:46:10.537836 2025-12-15 19:46:10.537836 +00000000-0000-0000-0000-000000000003 Projetos projetos Gestão de Projetos e Tarefas t 2025-12-15 19:46:10.537836 2025-12-15 19:46:10.537836 +00000000-0000-0000-0000-000000000004 Helpdesk helpdesk Central de Atendimento e Suporte t 2025-12-15 19:46:10.537836 2025-12-15 19:46:10.537836 +00000000-0000-0000-0000-000000000005 Pagamentos pagamentos Gestão de Cobranças e Pagamentos t 2025-12-15 19:46:10.537836 2025-12-15 19:46:10.537836 +00000000-0000-0000-0000-000000000006 Contratos contratos Gestão de Contratos e Documentos Legais t 2025-12-15 19:46:10.537836 2025-12-15 19:46:10.537836 +00000000-0000-0000-0000-000000000007 Documentos documentos Armazenamento e Gestão de Arquivos t 2025-12-15 19:46:10.537836 2025-12-15 19:46:10.537836 +00000000-0000-0000-0000-000000000008 Redes Sociais social Gestão de Redes Sociais t 2025-12-15 19:46:10.537836 2025-12-15 22:06:15.81116 +\. + + +-- +-- Data for Name: template_solutions; Type: TABLE DATA; Schema: public; Owner: aggios +-- + +COPY public.template_solutions (template_id, solution_id, created_at) FROM stdin; +\. + + +-- +-- Data for Name: tenants; Type: TABLE DATA; Schema: public; Owner: aggios +-- + +COPY public.tenants (id, name, domain, subdomain, cnpj, razao_social, email, phone, website, address, city, state, zip, description, industry, is_active, created_at, updated_at, neighborhood, street, number, complement, team_size, primary_color, secondary_color, logo_url, logo_horizontal_url) FROM stdin; +ae271be0-a63c-407f-9cf6-a4a8c8a0a4dc IdealPages idealpages.localhost idealpages 31.091.190/0001-23 ERIK DA SILVA SANTOS 36615318830 erik@idealpages.com.br (13) 92000-4392 idealpages.com.br Rua Quatorze, 150 - Casa Guarujá SP 11436-575 Empresa de contrucao de marca e desenvolvimento de software agencia-digital t 2025-12-13 23:23:35.508285+00 2025-12-16 04:44:54.654034+00 Vila Zilda \N 150 Casa 1-10 #298934 #A78BFA +\. + + +-- +-- Data for Name: users; Type: TABLE DATA; Schema: public; Owner: aggios +-- + +COPY public.users (id, tenant_id, email, password_hash, first_name, last_name, role, is_active, created_at, updated_at) FROM stdin; +7b51ae6e-6fb0-42c4-8473-a98cbfcda6a4 \N admin@aggios.app $2a$10$yhCREFqXL7FA4zveCFcl4eYODNTSyt/swuYjS0nXkEq8pzqJo.BwO Super Admin SUPERADMIN t 2025-12-13 23:02:33.124444+00 2025-12-13 23:02:33.124444+00 +8742c1a1-5f1a-4df3-aa53-dcf94a2a2591 ae271be0-a63c-407f-9cf6-a4a8c8a0a4dc erik@idealpages.com.br $2a$10$tD8Kq/ZW0fbmW3Ga5JsKbOUy0nzsIZwkXJKaf43gFDVnRxjaf63Em Erik da Silva Santos \N ADMIN_AGENCIA t 2025-12-13 23:23:35.551192+00 2025-12-13 23:23:35.551192+00 +\. + + +-- +-- Name: agency_signup_templates agency_signup_templates_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.agency_signup_templates + ADD CONSTRAINT agency_signup_templates_pkey PRIMARY KEY (id); + + +-- +-- Name: agency_signup_templates agency_signup_templates_slug_key; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.agency_signup_templates + ADD CONSTRAINT agency_signup_templates_slug_key UNIQUE (slug); + + +-- +-- Name: agency_subscriptions agency_subscriptions_agency_id_key; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.agency_subscriptions + ADD CONSTRAINT agency_subscriptions_agency_id_key UNIQUE (agency_id); + + +-- +-- Name: agency_subscriptions agency_subscriptions_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.agency_subscriptions + ADD CONSTRAINT agency_subscriptions_pkey PRIMARY KEY (id); + + +-- +-- Name: companies companies_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.companies + ADD CONSTRAINT companies_pkey PRIMARY KEY (id); + + +-- +-- Name: companies companies_tenant_id_cnpj_key; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.companies + ADD CONSTRAINT companies_tenant_id_cnpj_key UNIQUE (tenant_id, cnpj); + + +-- +-- Name: crm_customer_lists crm_customer_lists_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.crm_customer_lists + ADD CONSTRAINT crm_customer_lists_pkey PRIMARY KEY (customer_id, list_id); + + +-- +-- Name: crm_customers crm_customers_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.crm_customers + ADD CONSTRAINT crm_customers_pkey PRIMARY KEY (id); + + +-- +-- Name: crm_lists crm_lists_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.crm_lists + ADD CONSTRAINT crm_lists_pkey PRIMARY KEY (id); + + +-- +-- Name: plan_solutions plan_solutions_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.plan_solutions + ADD CONSTRAINT plan_solutions_pkey PRIMARY KEY (plan_id, solution_id); + + +-- +-- Name: plans plans_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.plans + ADD CONSTRAINT plans_pkey PRIMARY KEY (id); + + +-- +-- Name: plans plans_slug_key; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.plans + ADD CONSTRAINT plans_slug_key UNIQUE (slug); + + +-- +-- Name: refresh_tokens refresh_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.refresh_tokens + ADD CONSTRAINT refresh_tokens_pkey PRIMARY KEY (id); + + +-- +-- Name: solutions solutions_name_key; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.solutions + ADD CONSTRAINT solutions_name_key UNIQUE (name); + + +-- +-- Name: solutions solutions_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.solutions + ADD CONSTRAINT solutions_pkey PRIMARY KEY (id); + + +-- +-- Name: solutions solutions_slug_key; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.solutions + ADD CONSTRAINT solutions_slug_key UNIQUE (slug); + + +-- +-- Name: template_solutions template_solutions_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.template_solutions + ADD CONSTRAINT template_solutions_pkey PRIMARY KEY (template_id, solution_id); + + +-- +-- Name: tenants tenants_domain_key; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.tenants + ADD CONSTRAINT tenants_domain_key UNIQUE (domain); + + +-- +-- Name: tenants tenants_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.tenants + ADD CONSTRAINT tenants_pkey PRIMARY KEY (id); + + +-- +-- Name: tenants tenants_subdomain_key; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.tenants + ADD CONSTRAINT tenants_subdomain_key UNIQUE (subdomain); + + +-- +-- Name: crm_customers unique_email_per_tenant; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.crm_customers + ADD CONSTRAINT unique_email_per_tenant UNIQUE (tenant_id, email); + + +-- +-- Name: crm_lists unique_list_per_tenant; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.crm_lists + ADD CONSTRAINT unique_list_per_tenant UNIQUE (tenant_id, name); + + +-- +-- Name: users users_email_key; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_email_key UNIQUE (email); + + +-- +-- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_pkey PRIMARY KEY (id); + + +-- +-- Name: idx_agency_subscriptions_agency_id; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_agency_subscriptions_agency_id ON public.agency_subscriptions USING btree (agency_id); + + +-- +-- Name: idx_agency_subscriptions_plan_id; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_agency_subscriptions_plan_id ON public.agency_subscriptions USING btree (plan_id); + + +-- +-- Name: idx_agency_subscriptions_status; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_agency_subscriptions_status ON public.agency_subscriptions USING btree (status); + + +-- +-- Name: idx_agency_templates_active; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_agency_templates_active ON public.agency_signup_templates USING btree (is_active); + + +-- +-- Name: idx_agency_templates_slug; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_agency_templates_slug ON public.agency_signup_templates USING btree (slug); + + +-- +-- Name: idx_companies_cnpj; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_companies_cnpj ON public.companies USING btree (cnpj); + + +-- +-- Name: idx_companies_tenant_id; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_companies_tenant_id ON public.companies USING btree (tenant_id); + + +-- +-- Name: idx_crm_customer_lists_customer_id; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_crm_customer_lists_customer_id ON public.crm_customer_lists USING btree (customer_id); + + +-- +-- Name: idx_crm_customer_lists_list_id; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_crm_customer_lists_list_id ON public.crm_customer_lists USING btree (list_id); + + +-- +-- Name: idx_crm_customers_email; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_crm_customers_email ON public.crm_customers USING btree (email); + + +-- +-- Name: idx_crm_customers_is_active; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_crm_customers_is_active ON public.crm_customers USING btree (is_active); + + +-- +-- Name: idx_crm_customers_name; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_crm_customers_name ON public.crm_customers USING btree (name); + + +-- +-- Name: idx_crm_customers_tags; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_crm_customers_tags ON public.crm_customers USING gin (tags); + + +-- +-- Name: idx_crm_customers_tenant_id; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_crm_customers_tenant_id ON public.crm_customers USING btree (tenant_id); + + +-- +-- Name: idx_crm_lists_name; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_crm_lists_name ON public.crm_lists USING btree (name); + + +-- +-- Name: idx_crm_lists_tenant_id; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_crm_lists_tenant_id ON public.crm_lists USING btree (tenant_id); + + +-- +-- Name: idx_plan_solutions_plan_id; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_plan_solutions_plan_id ON public.plan_solutions USING btree (plan_id); + + +-- +-- Name: idx_plan_solutions_solution_id; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_plan_solutions_solution_id ON public.plan_solutions USING btree (solution_id); + + +-- +-- Name: idx_plans_is_active; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_plans_is_active ON public.plans USING btree (is_active); + + +-- +-- Name: idx_plans_slug; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_plans_slug ON public.plans USING btree (slug); + + +-- +-- Name: idx_refresh_tokens_expires_at; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_refresh_tokens_expires_at ON public.refresh_tokens USING btree (expires_at); + + +-- +-- Name: idx_refresh_tokens_user_id; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_refresh_tokens_user_id ON public.refresh_tokens USING btree (user_id); + + +-- +-- Name: idx_solutions_is_active; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_solutions_is_active ON public.solutions USING btree (is_active); + + +-- +-- Name: idx_solutions_slug; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_solutions_slug ON public.solutions USING btree (slug); + + +-- +-- Name: idx_template_solutions_solution; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_template_solutions_solution ON public.template_solutions USING btree (solution_id); + + +-- +-- Name: idx_template_solutions_template; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_template_solutions_template ON public.template_solutions USING btree (template_id); + + +-- +-- Name: idx_tenants_domain; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_tenants_domain ON public.tenants USING btree (domain); + + +-- +-- Name: idx_tenants_subdomain; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_tenants_subdomain ON public.tenants USING btree (subdomain); + + +-- +-- Name: idx_users_email; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_users_email ON public.users USING btree (email); + + +-- +-- Name: idx_users_tenant_id; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_users_tenant_id ON public.users USING btree (tenant_id); + + +-- +-- Name: crm_customers update_crm_customers_updated_at; Type: TRIGGER; Schema: public; Owner: aggios +-- + +CREATE TRIGGER update_crm_customers_updated_at BEFORE UPDATE ON public.crm_customers FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + + +-- +-- Name: crm_lists update_crm_lists_updated_at; Type: TRIGGER; Schema: public; Owner: aggios +-- + +CREATE TRIGGER update_crm_lists_updated_at BEFORE UPDATE ON public.crm_lists FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + + +-- +-- Name: agency_subscriptions agency_subscriptions_agency_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.agency_subscriptions + ADD CONSTRAINT agency_subscriptions_agency_id_fkey FOREIGN KEY (agency_id) REFERENCES public.tenants(id) ON DELETE CASCADE; + + +-- +-- Name: agency_subscriptions agency_subscriptions_plan_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.agency_subscriptions + ADD CONSTRAINT agency_subscriptions_plan_id_fkey FOREIGN KEY (plan_id) REFERENCES public.plans(id) ON DELETE RESTRICT; + + +-- +-- Name: companies companies_created_by_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.companies + ADD CONSTRAINT companies_created_by_user_id_fkey FOREIGN KEY (created_by_user_id) REFERENCES public.users(id); + + +-- +-- Name: companies companies_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.companies + ADD CONSTRAINT companies_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE; + + +-- +-- Name: crm_customer_lists crm_customer_lists_added_by_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.crm_customer_lists + ADD CONSTRAINT crm_customer_lists_added_by_fkey FOREIGN KEY (added_by) REFERENCES public.users(id); + + +-- +-- Name: crm_customer_lists crm_customer_lists_customer_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.crm_customer_lists + ADD CONSTRAINT crm_customer_lists_customer_id_fkey FOREIGN KEY (customer_id) REFERENCES public.crm_customers(id) ON DELETE CASCADE; + + +-- +-- Name: crm_customer_lists crm_customer_lists_list_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.crm_customer_lists + ADD CONSTRAINT crm_customer_lists_list_id_fkey FOREIGN KEY (list_id) REFERENCES public.crm_lists(id) ON DELETE CASCADE; + + +-- +-- Name: crm_customers crm_customers_created_by_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.crm_customers + ADD CONSTRAINT crm_customers_created_by_fkey FOREIGN KEY (created_by) REFERENCES public.users(id); + + +-- +-- Name: crm_customers crm_customers_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.crm_customers + ADD CONSTRAINT crm_customers_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE; + + +-- +-- Name: crm_lists crm_lists_created_by_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.crm_lists + ADD CONSTRAINT crm_lists_created_by_fkey FOREIGN KEY (created_by) REFERENCES public.users(id); + + +-- +-- Name: crm_lists crm_lists_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.crm_lists + ADD CONSTRAINT crm_lists_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE; + + +-- +-- Name: plan_solutions plan_solutions_plan_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.plan_solutions + ADD CONSTRAINT plan_solutions_plan_id_fkey FOREIGN KEY (plan_id) REFERENCES public.plans(id) ON DELETE CASCADE; + + +-- +-- Name: plan_solutions plan_solutions_solution_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.plan_solutions + ADD CONSTRAINT plan_solutions_solution_id_fkey FOREIGN KEY (solution_id) REFERENCES public.solutions(id) ON DELETE CASCADE; + + +-- +-- Name: refresh_tokens refresh_tokens_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.refresh_tokens + ADD CONSTRAINT refresh_tokens_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: template_solutions template_solutions_solution_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.template_solutions + ADD CONSTRAINT template_solutions_solution_id_fkey FOREIGN KEY (solution_id) REFERENCES public.solutions(id) ON DELETE CASCADE; + + +-- +-- Name: template_solutions template_solutions_template_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.template_solutions + ADD CONSTRAINT template_solutions_template_id_fkey FOREIGN KEY (template_id) REFERENCES public.agency_signup_templates(id) ON DELETE CASCADE; + + +-- +-- Name: users users_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE; + + +-- +-- PostgreSQL database dump complete +-- + +\unrestrict rsDz7INpk8jrQ6BjlYpnVTNop7sACFH202OZN869YgxdVa2PUD1exrYfNmoL7Sa + diff --git a/backups/aggios_backup_2025-12-17_13-26-04.sql b/backups/aggios_backup_2025-12-17_13-26-04.sql new file mode 100644 index 0000000..be18d40 --- /dev/null +++ b/backups/aggios_backup_2025-12-17_13-26-04.sql @@ -0,0 +1,1094 @@ +-- +-- PostgreSQL database dump +-- + +\restrict ncvzwECg3gFaKDQN1qiZ5Czm3zMLkxhxcfcv3fVgXaROlVKnomYb3Jc6VjtZXqC + +-- Dumped from database version 16.11 +-- Dumped by pg_dump version 18.1 + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET transaction_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +-- +-- Name: pgcrypto; Type: EXTENSION; Schema: -; Owner: - +-- + +CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public; + + +-- +-- Name: EXTENSION pgcrypto; Type: COMMENT; Schema: -; Owner: +-- + +COMMENT ON EXTENSION pgcrypto IS 'cryptographic functions'; + + +-- +-- Name: uuid-ossp; Type: EXTENSION; Schema: -; Owner: - +-- + +CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA public; + + +-- +-- Name: EXTENSION "uuid-ossp"; Type: COMMENT; Schema: -; Owner: +-- + +COMMENT ON EXTENSION "uuid-ossp" IS 'generate universally unique identifiers (UUIDs)'; + + +-- +-- Name: update_updated_at_column(); Type: FUNCTION; Schema: public; Owner: aggios +-- + +CREATE FUNCTION public.update_updated_at_column() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$; + + +ALTER FUNCTION public.update_updated_at_column() OWNER TO aggios; + +SET default_tablespace = ''; + +SET default_table_access_method = heap; + +-- +-- Name: agency_signup_templates; Type: TABLE; Schema: public; Owner: aggios +-- + +CREATE TABLE public.agency_signup_templates ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + name character varying(255) NOT NULL, + slug character varying(100) NOT NULL, + description text, + form_fields jsonb DEFAULT '[]'::jsonb NOT NULL, + available_modules jsonb DEFAULT '[]'::jsonb NOT NULL, + custom_primary_color character varying(7), + custom_logo_url text, + redirect_url text, + success_message text, + is_active boolean DEFAULT true, + usage_count integer DEFAULT 0, + max_uses integer, + expires_at timestamp with time zone, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP +); + + +ALTER TABLE public.agency_signup_templates OWNER TO aggios; + +-- +-- Name: TABLE agency_signup_templates; Type: COMMENT; Schema: public; Owner: aggios +-- + +COMMENT ON TABLE public.agency_signup_templates IS 'Templates de cadastro de agências (SuperAdmin → Agências)'; + + +-- +-- Name: COLUMN agency_signup_templates.form_fields; Type: COMMENT; Schema: public; Owner: aggios +-- + +COMMENT ON COLUMN public.agency_signup_templates.form_fields IS 'Campos do formulário em JSONB: [{name: cnpj, required: true, enabled: true}]'; + + +-- +-- Name: COLUMN agency_signup_templates.available_modules; Type: COMMENT; Schema: public; Owner: aggios +-- + +COMMENT ON COLUMN public.agency_signup_templates.available_modules IS 'Módulos disponíveis: [CRM, Financial, Projects]'; + + +-- +-- Name: agency_subscriptions; Type: TABLE; Schema: public; Owner: aggios +-- + +CREATE TABLE public.agency_subscriptions ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + agency_id uuid NOT NULL, + plan_id uuid NOT NULL, + billing_type character varying(20) DEFAULT 'monthly'::character varying NOT NULL, + current_users integer DEFAULT 0 NOT NULL, + status character varying(50) DEFAULT 'active'::character varying NOT NULL, + start_date timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + renewal_date timestamp without time zone NOT NULL, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP +); + + +ALTER TABLE public.agency_subscriptions OWNER TO aggios; + +-- +-- Name: TABLE agency_subscriptions; Type: COMMENT; Schema: public; Owner: aggios +-- + +COMMENT ON TABLE public.agency_subscriptions IS 'Tracks agency subscription to plans'; + + +-- +-- Name: COLUMN agency_subscriptions.billing_type; Type: COMMENT; Schema: public; Owner: aggios +-- + +COMMENT ON COLUMN public.agency_subscriptions.billing_type IS 'Monthly or annual billing'; + + +-- +-- Name: COLUMN agency_subscriptions.current_users; Type: COMMENT; Schema: public; Owner: aggios +-- + +COMMENT ON COLUMN public.agency_subscriptions.current_users IS 'Current count of users (collaborators + clients)'; + + +-- +-- Name: companies; Type: TABLE; Schema: public; Owner: aggios +-- + +CREATE TABLE public.companies ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + tenant_id uuid NOT NULL, + cnpj character varying(18) NOT NULL, + razao_social character varying(255) NOT NULL, + nome_fantasia character varying(255), + email character varying(255), + telefone character varying(20), + status character varying(50) DEFAULT 'active'::character varying, + created_by_user_id uuid, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP +); + + +ALTER TABLE public.companies OWNER TO aggios; + +-- +-- Name: crm_customer_lists; Type: TABLE; Schema: public; Owner: aggios +-- + +CREATE TABLE public.crm_customer_lists ( + customer_id uuid NOT NULL, + list_id uuid NOT NULL, + added_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP, + added_by uuid +); + + +ALTER TABLE public.crm_customer_lists OWNER TO aggios; + +-- +-- Name: crm_customers; Type: TABLE; Schema: public; Owner: aggios +-- + +CREATE TABLE public.crm_customers ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + tenant_id uuid NOT NULL, + name character varying(255) NOT NULL, + email character varying(255), + phone character varying(50), + company character varying(255), + "position" character varying(100), + address character varying(255), + city character varying(100), + state character varying(50), + zip_code character varying(20), + country character varying(100) DEFAULT 'Brasil'::character varying, + notes text, + tags text[], + is_active boolean DEFAULT true, + created_by uuid, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP +); + + +ALTER TABLE public.crm_customers OWNER TO aggios; + +-- +-- Name: crm_lists; Type: TABLE; Schema: public; Owner: aggios +-- + +CREATE TABLE public.crm_lists ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + tenant_id uuid NOT NULL, + name character varying(100) NOT NULL, + description text, + color character varying(7) DEFAULT '#3b82f6'::character varying, + created_by uuid, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP +); + + +ALTER TABLE public.crm_lists OWNER TO aggios; + +-- +-- Name: plan_solutions; Type: TABLE; Schema: public; Owner: aggios +-- + +CREATE TABLE public.plan_solutions ( + plan_id uuid NOT NULL, + solution_id uuid NOT NULL, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP +); + + +ALTER TABLE public.plan_solutions OWNER TO aggios; + +-- +-- Name: plans; Type: TABLE; Schema: public; Owner: aggios +-- + +CREATE TABLE public.plans ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + name character varying(255) NOT NULL, + slug character varying(100) NOT NULL, + description text, + min_users integer DEFAULT 1 NOT NULL, + max_users integer DEFAULT 30 NOT NULL, + monthly_price numeric(10,2), + annual_price numeric(10,2), + features text[] DEFAULT '{}'::text[] NOT NULL, + differentiators text[] DEFAULT '{}'::text[] NOT NULL, + storage_gb integer DEFAULT 1 NOT NULL, + is_active boolean DEFAULT true NOT NULL, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP +); + + +ALTER TABLE public.plans OWNER TO aggios; + +-- +-- Name: TABLE plans; Type: COMMENT; Schema: public; Owner: aggios +-- + +COMMENT ON TABLE public.plans IS 'Subscription plans for agencies'; + + +-- +-- Name: COLUMN plans.max_users; Type: COMMENT; Schema: public; Owner: aggios +-- + +COMMENT ON COLUMN public.plans.max_users IS '-1 means unlimited users'; + + +-- +-- Name: refresh_tokens; Type: TABLE; Schema: public; Owner: aggios +-- + +CREATE TABLE public.refresh_tokens ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + user_id uuid NOT NULL, + token_hash character varying(255) NOT NULL, + expires_at timestamp with time zone NOT NULL, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP +); + + +ALTER TABLE public.refresh_tokens OWNER TO aggios; + +-- +-- Name: solutions; Type: TABLE; Schema: public; Owner: aggios +-- + +CREATE TABLE public.solutions ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + name character varying(100) NOT NULL, + slug character varying(50) NOT NULL, + icon character varying(50), + description text, + is_active boolean DEFAULT true, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP +); + + +ALTER TABLE public.solutions OWNER TO aggios; + +-- +-- Name: COLUMN solutions.slug; Type: COMMENT; Schema: public; Owner: aggios +-- + +COMMENT ON COLUMN public.solutions.slug IS 'Slug usado para identificar a solução no menu (deve corresponder ao ID do menu no frontend)'; + + +-- +-- Name: COLUMN solutions.icon; Type: COMMENT; Schema: public; Owner: aggios +-- + +COMMENT ON COLUMN public.solutions.icon IS 'Emoji ou código do ícone para exibição visual'; + + +-- +-- Name: template_solutions; Type: TABLE; Schema: public; Owner: aggios +-- + +CREATE TABLE public.template_solutions ( + template_id uuid NOT NULL, + solution_id uuid NOT NULL, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP +); + + +ALTER TABLE public.template_solutions OWNER TO aggios; + +-- +-- Name: TABLE template_solutions; Type: COMMENT; Schema: public; Owner: aggios +-- + +COMMENT ON TABLE public.template_solutions IS 'Relacionamento N:N entre signup templates e solutions - define quais soluções estarão disponíveis ao cadastrar via template'; + + +-- +-- Name: tenants; Type: TABLE; Schema: public; Owner: aggios +-- + +CREATE TABLE public.tenants ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + name character varying(255) NOT NULL, + domain character varying(255) NOT NULL, + subdomain character varying(63) NOT NULL, + cnpj character varying(18), + razao_social character varying(255), + email character varying(255), + phone character varying(20), + website character varying(255), + address text, + city character varying(100), + state character varying(2), + zip character varying(10), + description text, + industry character varying(100), + is_active boolean DEFAULT true, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + neighborhood character varying(100), + street character varying(100), + number character varying(20), + complement character varying(100), + team_size character varying(20), + primary_color character varying(7), + secondary_color character varying(7), + logo_url text, + logo_horizontal_url text +); + + +ALTER TABLE public.tenants OWNER TO aggios; + +-- +-- Name: users; Type: TABLE; Schema: public; Owner: aggios +-- + +CREATE TABLE public.users ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + tenant_id uuid, + email character varying(255) NOT NULL, + password_hash character varying(255) NOT NULL, + first_name character varying(128), + last_name character varying(128), + role character varying(50) DEFAULT 'CLIENTE'::character varying, + is_active boolean DEFAULT true, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT users_role_check CHECK (((role)::text = ANY ((ARRAY['SUPERADMIN'::character varying, 'ADMIN_AGENCIA'::character varying, 'CLIENTE'::character varying])::text[]))) +); + + +ALTER TABLE public.users OWNER TO aggios; + +-- +-- Data for Name: agency_signup_templates; Type: TABLE DATA; Schema: public; Owner: aggios +-- + +COPY public.agency_signup_templates (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 stdin; +\. + + +-- +-- Data for Name: agency_subscriptions; Type: TABLE DATA; Schema: public; Owner: aggios +-- + +COPY public.agency_subscriptions (id, agency_id, plan_id, billing_type, current_users, status, start_date, renewal_date, created_at, updated_at) FROM stdin; +80bc290e-1a78-45b9-8419-a75137a86c7c ae271be0-a63c-407f-9cf6-a4a8c8a0a4dc 2cf6727a-bb36-48dc-8b7c-a207834f4943 monthly 1 active 2025-12-15 19:26:27.783807 2026-01-14 19:26:27.783807 2025-12-15 19:26:27.783807 2025-12-15 19:26:27.783807 +\. + + +-- +-- Data for Name: companies; Type: TABLE DATA; Schema: public; Owner: aggios +-- + +COPY public.companies (id, tenant_id, cnpj, razao_social, nome_fantasia, email, telefone, status, created_by_user_id, created_at, updated_at) FROM stdin; +\. + + +-- +-- Data for Name: crm_customer_lists; Type: TABLE DATA; Schema: public; Owner: aggios +-- + +COPY public.crm_customer_lists (customer_id, list_id, added_at, added_by) FROM stdin; +\. + + +-- +-- Data for Name: crm_customers; Type: TABLE DATA; Schema: public; Owner: aggios +-- + +COPY public.crm_customers (id, tenant_id, name, email, phone, company, "position", address, city, state, zip_code, country, notes, tags, is_active, created_by, created_at, updated_at) FROM stdin; +7160a1e0-d8f0-4fce-a62a-9cc706118ecd ae271be0-a63c-407f-9cf6-a4a8c8a0a4dc teste teste@gmail.com 13996465280 asda asda Brasil asd {dasdas} t 8742c1a1-5f1a-4df3-aa53-dcf94a2a2591 2025-12-16 03:13:10.576109 2025-12-16 03:13:10.576109 +\. + + +-- +-- Data for Name: crm_lists; Type: TABLE DATA; Schema: public; Owner: aggios +-- + +COPY public.crm_lists (id, tenant_id, name, description, color, created_by, created_at, updated_at) FROM stdin; +f8366f56-755e-4a5a-b784-dbc6e11564c8 ae271be0-a63c-407f-9cf6-a4a8c8a0a4dc teste teste #EC4899 8742c1a1-5f1a-4df3-aa53-dcf94a2a2591 2025-12-16 18:48:38.051286 2025-12-16 18:48:38.051286 +\. + + +-- +-- Data for Name: plan_solutions; Type: TABLE DATA; Schema: public; Owner: aggios +-- + +COPY public.plan_solutions (plan_id, solution_id, created_at) FROM stdin; +2cf6727a-bb36-48dc-8b7c-a207834f4943 00000000-0000-0000-0000-000000000001 2025-12-16 22:47:41.092009 +\. + + +-- +-- Data for Name: plans; Type: TABLE DATA; Schema: public; Owner: aggios +-- + +COPY public.plans (id, name, slug, description, min_users, max_users, monthly_price, annual_price, features, differentiators, storage_gb, is_active, created_at, updated_at) FROM stdin; +2cf6727a-bb36-48dc-8b7c-a207834f4943 Ignites ignite Plano simples aggios.app 1 30 199.00 1990.00 {} {} 1 t 2025-12-15 00:04:13.710353 2025-12-16 19:47:41.08154 +\. + + +-- +-- Data for Name: refresh_tokens; Type: TABLE DATA; Schema: public; Owner: aggios +-- + +COPY public.refresh_tokens (id, user_id, token_hash, expires_at, created_at) FROM stdin; +\. + + +-- +-- Data for Name: solutions; Type: TABLE DATA; Schema: public; Owner: aggios +-- + +COPY public.solutions (id, name, slug, icon, description, is_active, created_at, updated_at) FROM stdin; +00000000-0000-0000-0000-000000000001 CRM crm Gestão de Relacionamento com Clientes t 2025-12-15 02:58:24.89748 2025-12-15 02:58:24.89748 +00000000-0000-0000-0000-000000000002 ERP erp Gestão Empresarial e Financeira t 2025-12-15 19:46:10.537836 2025-12-15 19:46:10.537836 +00000000-0000-0000-0000-000000000003 Projetos projetos Gestão de Projetos e Tarefas t 2025-12-15 19:46:10.537836 2025-12-15 19:46:10.537836 +00000000-0000-0000-0000-000000000004 Helpdesk helpdesk Central de Atendimento e Suporte t 2025-12-15 19:46:10.537836 2025-12-15 19:46:10.537836 +00000000-0000-0000-0000-000000000005 Pagamentos pagamentos Gestão de Cobranças e Pagamentos t 2025-12-15 19:46:10.537836 2025-12-15 19:46:10.537836 +00000000-0000-0000-0000-000000000006 Contratos contratos Gestão de Contratos e Documentos Legais t 2025-12-15 19:46:10.537836 2025-12-15 19:46:10.537836 +00000000-0000-0000-0000-000000000007 Documentos documentos Armazenamento e Gestão de Arquivos t 2025-12-15 19:46:10.537836 2025-12-15 19:46:10.537836 +00000000-0000-0000-0000-000000000008 Redes Sociais social Gestão de Redes Sociais t 2025-12-15 19:46:10.537836 2025-12-15 22:06:15.81116 +\. + + +-- +-- Data for Name: template_solutions; Type: TABLE DATA; Schema: public; Owner: aggios +-- + +COPY public.template_solutions (template_id, solution_id, created_at) FROM stdin; +\. + + +-- +-- Data for Name: tenants; Type: TABLE DATA; Schema: public; Owner: aggios +-- + +COPY public.tenants (id, name, domain, subdomain, cnpj, razao_social, email, phone, website, address, city, state, zip, description, industry, is_active, created_at, updated_at, neighborhood, street, number, complement, team_size, primary_color, secondary_color, logo_url, logo_horizontal_url) FROM stdin; +3c4c1d84-8645-49d1-a24e-3bae60525cbc avon avon.localhost avon 56.991.441/0001-57 AVON COSMETICOS LTDA. adriana@avon.com.br (13) 9946-5280 eeeeeeeeeeeeeeeeeeeeeee Rua Quatorze, 14 - Casa Guarujá SP 11436-575 eeeeeeeeeeeeeeeeeeeeee agencia-digital t 2025-12-17 03:13:38.790798+00 2025-12-17 03:13:38.790798+00 Vila Zilda \N 14 Casa 1-10 #e5014c #FF0080 data:image/jpeg;base64,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 +ae271be0-a63c-407f-9cf6-a4a8c8a0a4dc IdealPages idealpages.localhost idealpages 31.091.190/0001-23 ERIK DA SILVA SANTOS 36615318830 erik@idealpages.com.br (13) 92000-4392 idealpages.com.br Rua Quatorze, 150 - Casa Guarujá SP 11436-575 Empresa de contrucao de marca e desenvolvimento de software agencia-digital t 2025-12-13 23:23:35.508285+00 2025-12-16 22:40:21.289552+00 Vila Zilda \N 150 Casa 1-10 #5a1bee #A78BFA http://api.localhost/api/files/aggios-logos/tenants/ae271be0-a63c-407f-9cf6-a4a8c8a0a4dc/logo-1765924821.png +\. + + +-- +-- Data for Name: users; Type: TABLE DATA; Schema: public; Owner: aggios +-- + +COPY public.users (id, tenant_id, email, password_hash, first_name, last_name, role, is_active, created_at, updated_at) FROM stdin; +7b51ae6e-6fb0-42c4-8473-a98cbfcda6a4 \N admin@aggios.app $2a$10$yhCREFqXL7FA4zveCFcl4eYODNTSyt/swuYjS0nXkEq8pzqJo.BwO Super Admin SUPERADMIN t 2025-12-13 23:02:33.124444+00 2025-12-13 23:02:33.124444+00 +8742c1a1-5f1a-4df3-aa53-dcf94a2a2591 ae271be0-a63c-407f-9cf6-a4a8c8a0a4dc erik@idealpages.com.br $2a$10$tD8Kq/ZW0fbmW3Ga5JsKbOUy0nzsIZwkXJKaf43gFDVnRxjaf63Em Erik da Silva Santos \N ADMIN_AGENCIA t 2025-12-13 23:23:35.551192+00 2025-12-13 23:23:35.551192+00 +d49c08b8-b115-4032-aaf7-ed50e62d511c 3c4c1d84-8645-49d1-a24e-3bae60525cbc adriana@avon.com.br $2a$10$MBiRsT1K7pp4lH2JIKg4SeUQrb461rxN0/.x1Ibgae/IFI.aK418a Adriana Ribeiro \N ADMIN_AGENCIA t 2025-12-17 03:13:38.832374+00 2025-12-17 03:13:38.832374+00 +\. + + +-- +-- Name: agency_signup_templates agency_signup_templates_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.agency_signup_templates + ADD CONSTRAINT agency_signup_templates_pkey PRIMARY KEY (id); + + +-- +-- Name: agency_signup_templates agency_signup_templates_slug_key; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.agency_signup_templates + ADD CONSTRAINT agency_signup_templates_slug_key UNIQUE (slug); + + +-- +-- Name: agency_subscriptions agency_subscriptions_agency_id_key; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.agency_subscriptions + ADD CONSTRAINT agency_subscriptions_agency_id_key UNIQUE (agency_id); + + +-- +-- Name: agency_subscriptions agency_subscriptions_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.agency_subscriptions + ADD CONSTRAINT agency_subscriptions_pkey PRIMARY KEY (id); + + +-- +-- Name: companies companies_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.companies + ADD CONSTRAINT companies_pkey PRIMARY KEY (id); + + +-- +-- Name: companies companies_tenant_id_cnpj_key; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.companies + ADD CONSTRAINT companies_tenant_id_cnpj_key UNIQUE (tenant_id, cnpj); + + +-- +-- Name: crm_customer_lists crm_customer_lists_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.crm_customer_lists + ADD CONSTRAINT crm_customer_lists_pkey PRIMARY KEY (customer_id, list_id); + + +-- +-- Name: crm_customers crm_customers_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.crm_customers + ADD CONSTRAINT crm_customers_pkey PRIMARY KEY (id); + + +-- +-- Name: crm_lists crm_lists_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.crm_lists + ADD CONSTRAINT crm_lists_pkey PRIMARY KEY (id); + + +-- +-- Name: plan_solutions plan_solutions_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.plan_solutions + ADD CONSTRAINT plan_solutions_pkey PRIMARY KEY (plan_id, solution_id); + + +-- +-- Name: plans plans_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.plans + ADD CONSTRAINT plans_pkey PRIMARY KEY (id); + + +-- +-- Name: plans plans_slug_key; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.plans + ADD CONSTRAINT plans_slug_key UNIQUE (slug); + + +-- +-- Name: refresh_tokens refresh_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.refresh_tokens + ADD CONSTRAINT refresh_tokens_pkey PRIMARY KEY (id); + + +-- +-- Name: solutions solutions_name_key; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.solutions + ADD CONSTRAINT solutions_name_key UNIQUE (name); + + +-- +-- Name: solutions solutions_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.solutions + ADD CONSTRAINT solutions_pkey PRIMARY KEY (id); + + +-- +-- Name: solutions solutions_slug_key; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.solutions + ADD CONSTRAINT solutions_slug_key UNIQUE (slug); + + +-- +-- Name: template_solutions template_solutions_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.template_solutions + ADD CONSTRAINT template_solutions_pkey PRIMARY KEY (template_id, solution_id); + + +-- +-- Name: tenants tenants_domain_key; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.tenants + ADD CONSTRAINT tenants_domain_key UNIQUE (domain); + + +-- +-- Name: tenants tenants_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.tenants + ADD CONSTRAINT tenants_pkey PRIMARY KEY (id); + + +-- +-- Name: tenants tenants_subdomain_key; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.tenants + ADD CONSTRAINT tenants_subdomain_key UNIQUE (subdomain); + + +-- +-- Name: crm_customers unique_email_per_tenant; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.crm_customers + ADD CONSTRAINT unique_email_per_tenant UNIQUE (tenant_id, email); + + +-- +-- Name: crm_lists unique_list_per_tenant; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.crm_lists + ADD CONSTRAINT unique_list_per_tenant UNIQUE (tenant_id, name); + + +-- +-- Name: users users_email_key; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_email_key UNIQUE (email); + + +-- +-- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_pkey PRIMARY KEY (id); + + +-- +-- Name: idx_agency_subscriptions_agency_id; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_agency_subscriptions_agency_id ON public.agency_subscriptions USING btree (agency_id); + + +-- +-- Name: idx_agency_subscriptions_plan_id; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_agency_subscriptions_plan_id ON public.agency_subscriptions USING btree (plan_id); + + +-- +-- Name: idx_agency_subscriptions_status; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_agency_subscriptions_status ON public.agency_subscriptions USING btree (status); + + +-- +-- Name: idx_agency_templates_active; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_agency_templates_active ON public.agency_signup_templates USING btree (is_active); + + +-- +-- Name: idx_agency_templates_slug; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_agency_templates_slug ON public.agency_signup_templates USING btree (slug); + + +-- +-- Name: idx_companies_cnpj; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_companies_cnpj ON public.companies USING btree (cnpj); + + +-- +-- Name: idx_companies_tenant_id; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_companies_tenant_id ON public.companies USING btree (tenant_id); + + +-- +-- Name: idx_crm_customer_lists_customer_id; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_crm_customer_lists_customer_id ON public.crm_customer_lists USING btree (customer_id); + + +-- +-- Name: idx_crm_customer_lists_list_id; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_crm_customer_lists_list_id ON public.crm_customer_lists USING btree (list_id); + + +-- +-- Name: idx_crm_customers_email; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_crm_customers_email ON public.crm_customers USING btree (email); + + +-- +-- Name: idx_crm_customers_is_active; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_crm_customers_is_active ON public.crm_customers USING btree (is_active); + + +-- +-- Name: idx_crm_customers_name; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_crm_customers_name ON public.crm_customers USING btree (name); + + +-- +-- Name: idx_crm_customers_tags; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_crm_customers_tags ON public.crm_customers USING gin (tags); + + +-- +-- Name: idx_crm_customers_tenant_id; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_crm_customers_tenant_id ON public.crm_customers USING btree (tenant_id); + + +-- +-- Name: idx_crm_lists_name; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_crm_lists_name ON public.crm_lists USING btree (name); + + +-- +-- Name: idx_crm_lists_tenant_id; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_crm_lists_tenant_id ON public.crm_lists USING btree (tenant_id); + + +-- +-- Name: idx_plan_solutions_plan_id; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_plan_solutions_plan_id ON public.plan_solutions USING btree (plan_id); + + +-- +-- Name: idx_plan_solutions_solution_id; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_plan_solutions_solution_id ON public.plan_solutions USING btree (solution_id); + + +-- +-- Name: idx_plans_is_active; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_plans_is_active ON public.plans USING btree (is_active); + + +-- +-- Name: idx_plans_slug; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_plans_slug ON public.plans USING btree (slug); + + +-- +-- Name: idx_refresh_tokens_expires_at; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_refresh_tokens_expires_at ON public.refresh_tokens USING btree (expires_at); + + +-- +-- Name: idx_refresh_tokens_user_id; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_refresh_tokens_user_id ON public.refresh_tokens USING btree (user_id); + + +-- +-- Name: idx_solutions_is_active; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_solutions_is_active ON public.solutions USING btree (is_active); + + +-- +-- Name: idx_solutions_slug; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_solutions_slug ON public.solutions USING btree (slug); + + +-- +-- Name: idx_template_solutions_solution; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_template_solutions_solution ON public.template_solutions USING btree (solution_id); + + +-- +-- Name: idx_template_solutions_template; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_template_solutions_template ON public.template_solutions USING btree (template_id); + + +-- +-- Name: idx_tenants_domain; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_tenants_domain ON public.tenants USING btree (domain); + + +-- +-- Name: idx_tenants_subdomain; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_tenants_subdomain ON public.tenants USING btree (subdomain); + + +-- +-- Name: idx_users_email; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_users_email ON public.users USING btree (email); + + +-- +-- Name: idx_users_tenant_id; Type: INDEX; Schema: public; Owner: aggios +-- + +CREATE INDEX idx_users_tenant_id ON public.users USING btree (tenant_id); + + +-- +-- Name: crm_customers update_crm_customers_updated_at; Type: TRIGGER; Schema: public; Owner: aggios +-- + +CREATE TRIGGER update_crm_customers_updated_at BEFORE UPDATE ON public.crm_customers FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + + +-- +-- Name: crm_lists update_crm_lists_updated_at; Type: TRIGGER; Schema: public; Owner: aggios +-- + +CREATE TRIGGER update_crm_lists_updated_at BEFORE UPDATE ON public.crm_lists FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + + +-- +-- Name: agency_subscriptions agency_subscriptions_agency_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.agency_subscriptions + ADD CONSTRAINT agency_subscriptions_agency_id_fkey FOREIGN KEY (agency_id) REFERENCES public.tenants(id) ON DELETE CASCADE; + + +-- +-- Name: agency_subscriptions agency_subscriptions_plan_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.agency_subscriptions + ADD CONSTRAINT agency_subscriptions_plan_id_fkey FOREIGN KEY (plan_id) REFERENCES public.plans(id) ON DELETE RESTRICT; + + +-- +-- Name: companies companies_created_by_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.companies + ADD CONSTRAINT companies_created_by_user_id_fkey FOREIGN KEY (created_by_user_id) REFERENCES public.users(id); + + +-- +-- Name: companies companies_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.companies + ADD CONSTRAINT companies_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE; + + +-- +-- Name: crm_customer_lists crm_customer_lists_added_by_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.crm_customer_lists + ADD CONSTRAINT crm_customer_lists_added_by_fkey FOREIGN KEY (added_by) REFERENCES public.users(id); + + +-- +-- Name: crm_customer_lists crm_customer_lists_customer_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.crm_customer_lists + ADD CONSTRAINT crm_customer_lists_customer_id_fkey FOREIGN KEY (customer_id) REFERENCES public.crm_customers(id) ON DELETE CASCADE; + + +-- +-- Name: crm_customer_lists crm_customer_lists_list_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.crm_customer_lists + ADD CONSTRAINT crm_customer_lists_list_id_fkey FOREIGN KEY (list_id) REFERENCES public.crm_lists(id) ON DELETE CASCADE; + + +-- +-- Name: crm_customers crm_customers_created_by_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.crm_customers + ADD CONSTRAINT crm_customers_created_by_fkey FOREIGN KEY (created_by) REFERENCES public.users(id); + + +-- +-- Name: crm_customers crm_customers_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.crm_customers + ADD CONSTRAINT crm_customers_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE; + + +-- +-- Name: crm_lists crm_lists_created_by_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.crm_lists + ADD CONSTRAINT crm_lists_created_by_fkey FOREIGN KEY (created_by) REFERENCES public.users(id); + + +-- +-- Name: crm_lists crm_lists_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.crm_lists + ADD CONSTRAINT crm_lists_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE; + + +-- +-- Name: plan_solutions plan_solutions_plan_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.plan_solutions + ADD CONSTRAINT plan_solutions_plan_id_fkey FOREIGN KEY (plan_id) REFERENCES public.plans(id) ON DELETE CASCADE; + + +-- +-- Name: plan_solutions plan_solutions_solution_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.plan_solutions + ADD CONSTRAINT plan_solutions_solution_id_fkey FOREIGN KEY (solution_id) REFERENCES public.solutions(id) ON DELETE CASCADE; + + +-- +-- Name: refresh_tokens refresh_tokens_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.refresh_tokens + ADD CONSTRAINT refresh_tokens_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: template_solutions template_solutions_solution_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.template_solutions + ADD CONSTRAINT template_solutions_solution_id_fkey FOREIGN KEY (solution_id) REFERENCES public.solutions(id) ON DELETE CASCADE; + + +-- +-- Name: template_solutions template_solutions_template_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.template_solutions + ADD CONSTRAINT template_solutions_template_id_fkey FOREIGN KEY (template_id) REFERENCES public.agency_signup_templates(id) ON DELETE CASCADE; + + +-- +-- Name: users users_tenant_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: aggios +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE; + + +-- +-- PostgreSQL database dump complete +-- + +\unrestrict ncvzwECg3gFaKDQN1qiZ5Czm3zMLkxhxcfcv3fVgXaROlVKnomYb3Jc6VjtZXqC + diff --git a/docs/COLABORADORES_SETUP.md b/docs/COLABORADORES_SETUP.md new file mode 100644 index 0000000..11b84b8 --- /dev/null +++ b/docs/COLABORADORES_SETUP.md @@ -0,0 +1,159 @@ +# Sistema de Hierarquia de Usuários - Guia de Configuração + +## Visão Geral + +O sistema implementa dois tipos de usuários para agências: + +1. **Dono da Agência (owner)** - Acesso total + - Pode convidar colaboradores + - Pode remover colaboradores + - Tem acesso completo ao CRM + +2. **Colaborador (collaborator)** - Acesso Restrito + - Pode VER leads e clientes + - **NÃO pode** editar ou remover dados + - Acesso somente leitura (read-only) + +## Configuração Inicial + +### Passo 1: Configurar o primeiro usuário como "owner" + +Após criar a primeira agência e seu usuário admin, execute o script SQL: + +```bash +docker exec aggios-postgres psql -U postgres -d aggios < /docker-entrypoint-initdb.d/../setup_owner_role.sql +``` + +Ou manualmente: + +```sql +UPDATE users +SET agency_role = 'owner' +WHERE email = 'seu-email@exemplo.com' AND role = 'ADMIN_AGENCIA'; +``` + +### Passo 2: Login e acessar o gerenciamento de colaboradores + +1. Faça login com o usuário owner +2. Vá em **Configurações > Equipe** +3. Clique em "Convidar Colaborador" + +### Passo 3: Convidar um colaborador + +- Preencha Nome e Email +- Clique em "Convidar" +- Copie a senha temporária (16 caracteres) +- Compartilhe com o colaborador + +## Fluxo de Funcionamento + +### Quando um Colaborador é Convidado + +1. Novo usuário é criado com `agency_role = 'collaborator'` +2. Recebe uma **senha temporária aleatória** +3. Email é adicionado à agência do owner + +### Quando um Colaborador Faz Login + +1. JWT contém `"agency_role": "collaborator"` +2. Frontend detecta a restrição + - Botões de editar/deletar desabilitados + - Mensagens de acesso restrito +3. Backend bloqueia POST/PUT/DELETE em `/api/crm/*` + - Retorna 403 Forbidden se tentar + +### Dados no JWT + +```json +{ + "user_id": "uuid", + "user_type": "agency_user", + "agency_role": "owner", // ou "collaborator" + "email": "usuario@exemplo.com", + "role": "ADMIN_AGENCIA", + "tenant_id": "uuid", + "exp": 1234567890 +} +``` + +## Banco de Dados + +### Novos Campos na Tabela `users` + +```sql +- agency_role VARCHAR(50) -- 'owner' ou 'collaborator' +- created_by UUID REFERENCES users -- Quem criou este colaborador +- collaborator_created_at TIMESTAMP -- Quando foi adicionado +``` + +## Endpoints da API + +### Listar Colaboradores +``` +GET /api/agency/collaborators +Headers: Authorization: Bearer +Resposta: Array de Collaborators +Restrição: Apenas owner pode usar +``` + +### Convidar Colaborador +``` +POST /api/agency/collaborators/invite +Body: { "email": "...", "name": "..." } +Resposta: { "temporary_password": "..." } +Restrição: Apenas owner pode usar +``` + +### Remover Colaborador +``` +DELETE /api/agency/collaborators/{id} +Restrição: Apenas owner pode usar +``` + +## Página de Interface + +**Localização:** `/configuracoes` → Aba "Equipe" + +### Funcionalidades +- ✅ Ver lista de colaboradores (dono apenas) +- ✅ Convidar novo colaborador +- ✅ Copiar senha temporária +- ✅ Remover colaborador (com confirmação) +- ✅ Ver data de adição de cada colaborador +- ✅ Indicador visual (badge) do tipo de usuário + +## Troubleshooting + +### "Apenas o dono da agência pode gerenciar colaboradores" + +**Causa:** O usuário não tem `agency_role = 'owner'` + +**Solução:** +```sql +UPDATE users +SET agency_role = 'owner' +WHERE id = 'seu-user-id'; +``` + +### Colaborador consegue editar dados (bug) + +**Causa:** A middleware de read-only não está ativa + +**Status:** Implementada em `backend/internal/api/middleware/collaborator_readonly.go` + +**Para ativar:** Descomente a linha em `main.go` que aplica `CheckCollaboratorReadOnly` + +### Senha temporária não aparece + +**Verificar:** +1. API `/api/agency/collaborators/invite` retorna 200? +2. Response JSON tem o campo `temporary_password`? +3. Verificar logs do backend para erros + +## Próximas Melhorias + +- [ ] Permitir editar nome/email do colaborador +- [ ] Definir permissões granulares por colaborador +- [ ] Histórico de ações feitas por cada colaborador +- [ ] 2FA para owners +- [ ] Auditoria de quem removeu quem diff --git a/front-end-agency/Dockerfile b/front-end-agency/Dockerfile index 47bef17..ee96a7c 100644 --- a/front-end-agency/Dockerfile +++ b/front-end-agency/Dockerfile @@ -30,6 +30,12 @@ RUN npm ci --omit=dev COPY --from=builder /app/.next ./.next COPY --from=builder /app/public ./public +# Create uploads directory +RUN mkdir -p ./public/uploads/logos && chown -R node:node ./public/uploads + +# Switch to node user +USER node + # Expose port EXPOSE 3000 diff --git a/front-end-agency/app/(agency)/AgencyLayoutClient.tsx b/front-end-agency/app/(agency)/AgencyLayoutClient.tsx index 0a07c06..7d6b34e 100644 --- a/front-end-agency/app/(agency)/AgencyLayoutClient.tsx +++ b/front-end-agency/app/(agency)/AgencyLayoutClient.tsx @@ -3,110 +3,31 @@ import { DashboardLayout } from '@/components/layout/DashboardLayout'; import { AgencyBranding } from '@/components/layout/AgencyBranding'; import AuthGuard from '@/components/auth/AuthGuard'; +import { CRMFilterProvider } from '@/contexts/CRMFilterContext'; import { useState, useEffect } from 'react'; import { HomeIcon, RocketLaunchIcon, - ChartBarIcon, - BriefcaseIcon, - LifebuoyIcon, - CreditCardIcon, - DocumentTextIcon, - FolderIcon, - ShareIcon, + UserPlusIcon, + RectangleStackIcon, + UsersIcon, + MegaphoneIcon, } from '@heroicons/react/24/outline'; const AGENCY_MENU_ITEMS = [ - { id: 'dashboard', label: 'Visão Geral', href: '/dashboard', icon: HomeIcon }, + { id: 'dashboard', label: 'Dashboard', href: '/dashboard', icon: HomeIcon }, { id: 'crm', label: 'CRM', href: '/crm', icon: RocketLaunchIcon, + requiredSolution: 'crm', subItems: [ - { label: 'Dashboard', href: '/crm' }, - { label: 'Clientes', href: '/crm/clientes' }, - { label: 'Funis', href: '/crm/funis' }, - { label: 'Negociações', href: '/crm/negociacoes' }, - ] - }, - { - id: 'erp', - label: 'ERP', - href: '/erp', - icon: ChartBarIcon, - subItems: [ - { label: 'Dashboard', href: '/erp' }, - { label: 'Fluxo de Caixa', href: '/erp/fluxo-caixa' }, - { label: 'Contas a Pagar', href: '/erp/contas-pagar' }, - { label: 'Contas a Receber', href: '/erp/contas-receber' }, - ] - }, - { - id: 'projetos', - label: 'Projetos', - href: '/projetos', - icon: BriefcaseIcon, - subItems: [ - { label: 'Dashboard', href: '/projetos' }, - { label: 'Meus Projetos', href: '/projetos/lista' }, - { label: 'Tarefas', href: '/projetos/tarefas' }, - { label: 'Cronograma', href: '/projetos/cronograma' }, - ] - }, - { - id: 'helpdesk', - label: 'Helpdesk', - href: '/helpdesk', - icon: LifebuoyIcon, - subItems: [ - { label: 'Dashboard', href: '/helpdesk' }, - { label: 'Chamados', href: '/helpdesk/chamados' }, - { label: 'Base de Conhecimento', href: '/helpdesk/kb' }, - ] - }, - { - id: 'pagamentos', - label: 'Pagamentos', - href: '/pagamentos', - icon: CreditCardIcon, - subItems: [ - { label: 'Dashboard', href: '/pagamentos' }, - { label: 'Cobranças', href: '/pagamentos/cobrancas' }, - { label: 'Assinaturas', href: '/pagamentos/assinaturas' }, - ] - }, - { - id: 'contratos', - label: 'Contratos', - href: '/contratos', - icon: DocumentTextIcon, - subItems: [ - { label: 'Dashboard', href: '/contratos' }, - { label: 'Ativos', href: '/contratos/ativos' }, - { label: 'Modelos', href: '/contratos/modelos' }, - ] - }, - { - id: 'documentos', - label: 'Documentos', - href: '/documentos', - icon: FolderIcon, - subItems: [ - { label: 'Meus Arquivos', href: '/documentos' }, - { label: 'Compartilhados', href: '/documentos/compartilhados' }, - { label: 'Lixeira', href: '/documentos/lixeira' }, - ] - }, - { - id: 'social', - label: 'Redes Sociais', - href: '/social', - icon: ShareIcon, - subItems: [ - { label: 'Dashboard', href: '/social' }, - { label: 'Agendamento', href: '/social/agendamento' }, - { label: 'Relatórios', href: '/social/relatorios' }, + { label: 'Visão Geral', href: '/crm', icon: HomeIcon }, + { label: 'Funis de Vendas', href: '/crm/funis', icon: RectangleStackIcon }, + { label: 'Clientes', href: '/crm/clientes', icon: UsersIcon }, + { label: 'Campanhas', href: '/crm/campanhas', icon: MegaphoneIcon }, + { label: 'Leads', href: '/crm/leads', icon: UserPlusIcon }, ] }, ]; @@ -148,7 +69,8 @@ export function AgencyLayoutClient({ children, colors }: AgencyLayoutClientProps // Sempre mostrar dashboard + soluções disponíveis const filtered = AGENCY_MENU_ITEMS.filter(item => { if (item.id === 'dashboard') return true; - return solutionSlugs.includes(item.id); + const requiredSolution = (item as any).requiredSolution; + return solutionSlugs.includes((requiredSolution || item.id).toLowerCase()); }); console.log('📋 Menu filtrado:', filtered.map(i => i.id)); @@ -171,11 +93,13 @@ export function AgencyLayoutClient({ children, colors }: AgencyLayoutClientProps }, []); return ( - - - - {children} - + + + + + {children} + + ); } diff --git a/front-end-agency/app/(agency)/configuracoes/page.tsx b/front-end-agency/app/(agency)/configuracoes/page.tsx index 598c0c4..dad4098 100644 --- a/front-end-agency/app/(agency)/configuracoes/page.tsx +++ b/front-end-agency/app/(agency)/configuracoes/page.tsx @@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'; import { Tab } from '@headlessui/react'; import { Button, Dialog, Input } from '@/components/ui'; import { Toaster, toast } from 'react-hot-toast'; +import TeamManagement from '@/components/team/TeamManagement'; import { BuildingOfficeIcon, PhotoIcon, @@ -1040,19 +1041,7 @@ export default function ConfiguracoesPage() { {/* Tab 3: Equipe */} -

- Gerenciamento de Equipe -

- -
- -

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

- -
+
{/* Tab 3: Segurança */} diff --git a/front-end-agency/app/(agency)/crm/campanhas/[id]/page.tsx b/front-end-agency/app/(agency)/crm/campanhas/[id]/page.tsx new file mode 100644 index 0000000..eda47ba --- /dev/null +++ b/front-end-agency/app/(agency)/crm/campanhas/[id]/page.tsx @@ -0,0 +1,624 @@ +"use client"; + +import { Fragment, useEffect, useState, use } from 'react'; +import { Tab, Menu, Transition } from '@headlessui/react'; +import { + UserGroupIcon, + InformationCircleIcon, + CreditCardIcon, + ArrowLeftIcon, + PlusIcon, + MagnifyingGlassIcon, + FunnelIcon, + EllipsisVerticalIcon, + PencilIcon, + TrashIcon, + EnvelopeIcon, + PhoneIcon, + TagIcon, + CalendarIcon, + UserIcon, + ArrowDownTrayIcon, + BriefcaseIcon, +} from '@heroicons/react/24/outline'; +import Link from 'next/link'; +import { useToast } from '@/components/layout/ToastContext'; +import KanbanBoard from '@/components/crm/KanbanBoard'; + +interface Lead { + id: string; + name: string; + email: string; + phone: string; + status: string; + created_at: string; + tags: string[]; +} + +interface Campaign { + id: string; + name: string; + description: string; + color: string; + customer_id: string; + customer_name: string; + lead_count: number; + created_at: string; +} + +const STATUS_OPTIONS = [ + { value: 'novo', label: 'Novo', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' }, + { value: 'qualificado', label: 'Qualificado', color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' }, + { value: 'negociacao', label: 'Em Negociação', color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' }, + { value: 'convertido', label: 'Convertido', color: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200' }, + { value: 'perdido', label: 'Perdido', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' }, +]; + +function classNames(...classes: string[]) { + return classes.filter(Boolean).join(' '); +} + +export default function CampaignDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = use(params); + const toast = useToast(); + const [campaign, setCampaign] = useState(null); + const [leads, setLeads] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(''); + const [funnels, setFunnels] = useState([]); + const [selectedFunnelId, setSelectedFunnelId] = useState(''); + + useEffect(() => { + fetchCampaignDetails(); + fetchCampaignLeads(); + fetchFunnels(); + }, [id]); + + const fetchFunnels = async () => { + try { + const response = await fetch('/api/crm/funnels', { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + if (response.ok) { + const data = await response.json(); + setFunnels(data.funnels || []); + if (data.funnels?.length > 0) { + setSelectedFunnelId(data.funnels[0].id); + } + } + } catch (error) { + console.error('Error fetching funnels:', error); + } + }; + + const fetchCampaignDetails = async () => { + try { + const response = await fetch(`/api/crm/lists`, { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}`, + }, + }); + if (response.ok) { + const data = await response.json(); + const found = data.lists?.find((l: Campaign) => l.id === id); + if (found) { + setCampaign(found); + } + } + } catch (error) { + console.error('Error fetching campaign details:', error); + } + }; + + const fetchCampaignLeads = async () => { + try { + const response = await fetch(`/api/crm/lists/${id}/leads`, { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}`, + }, + }); + if (response.ok) { + const data = await response.json(); + setLeads(data.leads || []); + } + } catch (error) { + console.error('Error fetching leads:', error); + } finally { + setLoading(false); + } + }; + + const filteredLeads = leads.filter(lead => + (lead.name?.toLowerCase() || '').includes(searchTerm.toLowerCase()) || + (lead.email?.toLowerCase() || '').includes(searchTerm.toLowerCase()) + ); + + const handleExport = async (format: 'csv' | 'xlsx' | 'json') => { + try { + const token = localStorage.getItem('token'); + const response = await fetch(`/api/crm/leads/export?format=${format}&campaign_id=${id}`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (response.ok) { + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `leads-${campaign?.name || 'campaign'}.${format === 'xlsx' ? 'xlsx' : format}`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + toast.success('Exportado com sucesso!'); + } else { + toast.error('Erro ao exportar leads'); + } + } catch (error) { + console.error('Export error:', error); + toast.error('Erro ao exportar'); + } + }; + + if (loading && !campaign) { + return ( +
+
+
+ ); + } + + if (!campaign) { + return ( +
+

Campanha não encontrada

+ + + Voltar para Campanhas + +
+ ); + } + + return ( +
+ {/* Header */} +
+ + + Voltar para Campanhas + + +
+
+
+ +
+
+

+ {campaign.name} +

+
+ {campaign.customer_name ? ( + + {campaign.customer_name} + + ) : ( + + Geral + + )} + + + {leads.length} leads vinculados + +
+
+
+ +
+
+ + + + Exportar + + + +
+ + {({ active }) => ( + + )} + + + {({ active }) => ( + + )} + + + {({ active }) => ( + + )} + +
+
+
+
+
+ + + + Importar Leads + +
+
+
+ + {/* Tabs */} + + + + classNames( + 'w-full rounded-lg py-2.5 text-sm font-medium leading-5 transition-all duration-200', + 'ring-white ring-opacity-60 ring-offset-2 ring-offset-brand-400 focus:outline-none', + selected + ? 'bg-white dark:bg-zinc-900 text-brand-600 dark:text-brand-400 shadow-sm' + : 'text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200 hover:bg-white/[0.12]' + ) + }> +
+ + Monitoramento +
+
+ + classNames( + 'w-full rounded-lg py-2.5 text-sm font-medium leading-5 transition-all duration-200', + 'ring-white ring-opacity-60 ring-offset-2 ring-offset-brand-400 focus:outline-none', + selected + ? 'bg-white dark:bg-zinc-900 text-brand-600 dark:text-brand-400 shadow-sm' + : 'text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200 hover:bg-white/[0.12]' + ) + }> +
+ + Leads +
+
+ + classNames( + 'w-full rounded-lg py-2.5 text-sm font-medium leading-5 transition-all duration-200', + 'ring-white ring-opacity-60 ring-offset-2 ring-offset-brand-400 focus:outline-none', + selected + ? 'bg-white dark:bg-zinc-900 text-brand-600 dark:text-brand-400 shadow-sm' + : 'text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200 hover:bg-white/[0.12]' + ) + }> +
+ + Informações +
+
+ + classNames( + 'w-full rounded-lg py-2.5 text-sm font-medium leading-5 transition-all duration-200', + 'ring-white ring-opacity-60 ring-offset-2 ring-offset-brand-400 focus:outline-none', + selected + ? 'bg-white dark:bg-zinc-900 text-brand-600 dark:text-brand-400 shadow-sm' + : 'text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200 hover:bg-white/[0.12]' + ) + }> +
+ + Pagamentos +
+
+
+ + + {/* Monitoramento Panel */} + + {funnels.length > 0 ? ( +
+
+
+
+ +
+
+

Monitoramento de Leads

+

Acompanhe o progresso dos leads desta campanha no funil.

+
+
+ +
+ + +
+
+ +
+ +
+
+ ) : ( +
+
+ +
+

+ Nenhum funil configurado +

+

+ Configure um funil de vendas para começar a monitorar os leads desta campanha. +

+ + Configurar Funis + +
+ )} +
+ + {/* Leads Panel */} + +
+
+
+
+ setSearchTerm(e.target.value)} + /> +
+
+ +
+
+ + {filteredLeads.length === 0 ? ( +
+
+ +
+

+ Nenhum lead encontrado +

+

+ {searchTerm ? 'Nenhum lead corresponde à sua busca.' : 'Esta campanha ainda não possui leads vinculados.'} +

+
+ ) : ( +
+ {filteredLeads.map((lead) => ( +
+
+
+

+ {lead.name || 'Sem nome'} +

+ s.value === lead.status)?.color || 'bg-zinc-100 text-zinc-800' + )}> + {STATUS_OPTIONS.find(s => s.value === lead.status)?.label || lead.status} + +
+ +
+ +
+ {lead.email && ( +
+ + {lead.email} +
+ )} + {lead.phone && ( +
+ + {lead.phone} +
+ )} +
+ +
+
+ + {new Date(lead.created_at).toLocaleDateString('pt-BR')} +
+ +
+
+ ))} +
+ )} +
+ + {/* Info Panel */} + +
+
+
+
+

Detalhes da Campanha

+
+
+
+ +

+ {campaign.description || 'Nenhuma descrição fornecida para esta campanha.'} +

+
+ +
+
+ +
+ + {new Date(campaign.created_at).toLocaleDateString('pt-BR', { day: '2-digit', month: 'long', year: 'numeric' })} +
+
+
+ +
+
+ {campaign.color} +
+
+
+
+
+ +
+
+

Configurações de Integração

+
+
+
+
+ +
+

Webhook de Entrada

+

+ Use este endpoint para enviar leads automaticamente de outras plataformas (Typeform, Elementor, etc). +

+
+ + https://api.aggios.app/v1/webhooks/leads/{campaign.id} + + +
+
+
+
+
+
+
+ +
+
+

Cliente Responsável

+ {campaign.customer_id ? ( +
+
+
+ +
+
+

{campaign.customer_name}

+

Cliente Ativo

+
+
+ + + Ver Perfil do Cliente + +
+ ) : ( +
+

Esta é uma campanha geral da agência.

+
+ )} +
+ +
+

Resumo de Performance

+
+
+ Total de Leads + {leads.length} +
+
+
+
+

+ +12% em relação ao mês passado +

+
+
+
+
+
+ + {/* Payments Panel */} + +
+
+
+ +
+

Módulo de Pagamentos

+

+ Em breve você poderá gerenciar orçamentos, faturas e pagamentos vinculados diretamente a esta campanha. +

+ +
+
+
+
+
+
+ ); +} diff --git a/front-end-agency/app/(agency)/crm/campanhas/page.tsx b/front-end-agency/app/(agency)/crm/campanhas/page.tsx new file mode 100644 index 0000000..8f0c19f --- /dev/null +++ b/front-end-agency/app/(agency)/crm/campanhas/page.tsx @@ -0,0 +1,622 @@ +"use client"; + +import { Fragment, useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Menu, Transition } from '@headlessui/react'; +import ConfirmDialog from '@/components/layout/ConfirmDialog'; +import { useToast } from '@/components/layout/ToastContext'; +import Pagination from '@/components/layout/Pagination'; +import { useCRMFilter } from '@/contexts/CRMFilterContext'; +import { SolutionGuard } from '@/components/auth/SolutionGuard'; +import SearchableSelect from '@/components/form/SearchableSelect'; +import { + ListBulletIcon, + TrashIcon, + PencilIcon, + EllipsisVerticalIcon, + MagnifyingGlassIcon, + PlusIcon, + XMarkIcon, + UserGroupIcon, + EyeIcon, + CalendarIcon, + RectangleStackIcon, +} from '@heroicons/react/24/outline'; + +interface List { + id: string; + tenant_id: string; + customer_id: string; + customer_name: string; + funnel_id?: string; + name: string; + description: string; + color: string; + customer_count: number; + lead_count: number; + created_at: string; + updated_at: string; +} + +interface Funnel { + id: string; + name: string; +} + +interface Customer { + id: string; + name: string; + company: string; +} + +const COLORS = [ + { name: 'Azul', value: '#3B82F6' }, + { name: 'Verde', value: '#10B981' }, + { name: 'Roxo', value: '#8B5CF6' }, + { name: 'Rosa', value: '#EC4899' }, + { name: 'Laranja', value: '#F97316' }, + { name: 'Amarelo', value: '#EAB308' }, + { name: 'Vermelho', value: '#EF4444' }, + { name: 'Cinza', value: '#6B7280' }, +]; + +function CampaignsContent() { + const router = useRouter(); + const toast = useToast(); + const { selectedCustomerId } = useCRMFilter(); + console.log('📢 CampaignsPage render, selectedCustomerId:', selectedCustomerId); + + const [lists, setLists] = useState([]); + const [customers, setCustomers] = useState([]); + const [funnels, setFunnels] = useState([]); + const [loading, setLoading] = useState(true); + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingList, setEditingList] = useState(null); + + const [confirmOpen, setConfirmOpen] = useState(false); + const [listToDelete, setListToDelete] = useState(null); + + const [searchTerm, setSearchTerm] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + const itemsPerPage = 10; + + const [formData, setFormData] = useState({ + name: '', + description: '', + color: COLORS[0].value, + customer_id: '', + funnel_id: '', + }); + + useEffect(() => { + console.log('🔄 CampaignsPage useEffect triggered by selectedCustomerId:', selectedCustomerId); + fetchLists(); + fetchCustomers(); + fetchFunnels(); + }, [selectedCustomerId]); + + const fetchFunnels = async () => { + try { + const response = await fetch('/api/crm/funnels', { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + if (response.ok) { + const data = await response.json(); + setFunnels(data.funnels || []); + } + } catch (error) { + console.error('Error fetching funnels:', error); + } + }; + + const fetchCustomers = async () => { + try { + const response = await fetch('/api/crm/customers', { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}`, + }, + }); + if (response.ok) { + const data = await response.json(); + setCustomers(data.customers || []); + } + } catch (error) { + console.error('Error fetching customers:', error); + } + }; + + const fetchLists = async () => { + try { + setLoading(true); + const url = selectedCustomerId + ? `/api/crm/lists?customer_id=${selectedCustomerId}` + : '/api/crm/lists'; + + console.log(`📊 Fetching campaigns from: ${url}`); + + const response = await fetch(url, { + cache: 'no-store', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}`, + }, + }); + if (response.ok) { + const data = await response.json(); + console.log('📊 Campaigns data received:', data); + setLists(data.lists || []); + } + } catch (error) { + console.error('Error fetching campaigns:', error); + } finally { + setLoading(false); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + const url = editingList + ? `/api/crm/lists/${editingList.id}` + : '/api/crm/lists'; + + const method = editingList ? 'PUT' : 'POST'; + + try { + const response = await fetch(url, { + method, + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(formData), + }); + + if (response.ok) { + toast.success( + editingList ? 'Campanha atualizada' : 'Campanha criada', + editingList ? 'A campanha foi atualizada com sucesso.' : 'A nova campanha foi criada com sucesso.' + ); + fetchLists(); + handleCloseModal(); + } else { + const error = await response.json(); + toast.error('Erro', error.message || 'Não foi possível salvar a campanha.'); + } + } catch (error) { + console.error('Error saving campaign:', error); + toast.error('Erro', 'Ocorreu um erro ao salvar a campanha.'); + } + }; + + const handleNewCampaign = () => { + setEditingList(null); + setFormData({ + name: '', + description: '', + color: COLORS[0].value, + customer_id: selectedCustomerId || '', + funnel_id: '', + }); + setIsModalOpen(true); + }; + + const handleEdit = (list: List) => { + setEditingList(list); + setFormData({ + name: list.name, + description: list.description, + color: list.color, + customer_id: list.customer_id || '', + funnel_id: list.funnel_id || '', + }); + setIsModalOpen(true); + }; + + const handleDeleteClick = (id: string) => { + setListToDelete(id); + setConfirmOpen(true); + }; + + const handleConfirmDelete = async () => { + if (!listToDelete) return; + + try { + const response = await fetch(`/api/crm/lists/${listToDelete}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}`, + }, + }); + + if (response.ok) { + setLists(lists.filter(l => l.id !== listToDelete)); + toast.success('Campanha excluída', 'A campanha foi excluída com sucesso.'); + } else { + toast.error('Erro ao excluir', 'Não foi possível excluir a campanha.'); + } + } catch (error) { + console.error('Error deleting campaign:', error); + toast.error('Erro ao excluir', 'Ocorreu um erro ao excluir a campanha.'); + } finally { + setConfirmOpen(false); + setListToDelete(null); + } + }; + + const handleCloseModal = () => { + setIsModalOpen(false); + setEditingList(null); + setFormData({ + name: '', + description: '', + color: COLORS[0].value, + customer_id: '', + funnel_id: '', + }); + }; + + const filteredLists = lists.filter((list) => { + const searchLower = searchTerm.toLowerCase(); + return ( + (list.name?.toLowerCase() || '').includes(searchLower) || + (list.description?.toLowerCase() || '').includes(searchLower) + ); + }); + + const totalPages = Math.ceil(filteredLists.length / itemsPerPage); + const paginatedLists = filteredLists.slice( + (currentPage - 1) * itemsPerPage, + currentPage * itemsPerPage + ); + + return ( +
+ {/* Header */} +
+
+

Campanhas

+

+ Organize seus leads e rastreie a origem de cada um +

+
+ +
+ + {/* Search */} +
+
+
+ setSearchTerm(e.target.value)} + /> +
+ + {/* Table */} + {loading ? ( +
+
+
+ ) : filteredLists.length === 0 ? ( +
+
+ +
+

+ Nenhuma campanha encontrada +

+

+ {searchTerm ? 'Nenhuma campanha corresponde à sua busca.' : 'Comece criando sua primeira campanha.'} +

+
+ ) : ( +
+
+ + + + + + + + + + + + {paginatedLists.map((list) => ( + router.push(`/crm/campanhas/${list.id}`)} + className="group hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition-colors cursor-pointer" + > + + + + + + + ))} + +
CampanhaCliente VinculadoLeadsCriada emAções
+
+
+ +
+
+
+ {list.name} +
+ {list.description && ( +
+ {list.description} +
+ )} +
+
+
+ {list.customer_name ? ( + + {list.customer_name} + + ) : ( + + Geral + + )} + +
+ + {list.lead_count || 0} +
+
+
+ + {new Date(list.created_at).toLocaleDateString('pt-BR')} +
+
+
e.stopPropagation()}> + + + + + + + + +
+ + {({ active }) => ( + + )} + +
+
+ + {({ active }) => ( + + )} + +
+
+
+
+
+
+
+ +
+ )} + + {/* Modal */} + {isModalOpen && ( +
+
+
+ +
+
+ +
+ +
+
+
+ +
+
+

+ {editingList ? 'Editar Campanha' : 'Nova Campanha'} +

+

+ {editingList ? 'Atualize as informações da campanha.' : 'Crie uma nova campanha para organizar seus leads.'} +

+
+
+ +
+ ({ + id: c.id, + name: c.name, + subtitle: c.company || undefined + }))} + value={formData.customer_id} + onChange={(value) => setFormData({ ...formData, customer_id: value || '' })} + placeholder="Nenhum cliente (Geral)" + emptyText="Nenhum cliente encontrado" + helperText="Vincule esta campanha a um cliente específico para melhor organização." + /> + +
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="Ex: Black Friday 2025" + required + className="w-full px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[var(--brand-color)] focus:border-transparent transition-all" + /> +
+ +
+ +