package handlers 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 { repo *repository.CRMRepository } func NewCRMHandler(repo *repository.CRMRepository) *CRMHandler { return &CRMHandler{repo: repo} } // ==================== 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) if tenantID == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{ "error": "Missing tenant_id", }) return } var customer domain.CRMCustomer if err := json.NewDecoder(r.Body).Decode(&customer); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{ "error": "Invalid request body", "message": err.Error(), }) return } customer.ID = uuid.New().String() customer.TenantID = tenantID customer.CreatedBy = userID customer.IsActive = true 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) json.NewEncoder(w).Encode(map[string]string{ "error": "Failed to create customer", "message": err.Error(), }) 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{}{ "customer": customer, }) } 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) json.NewEncoder(w).Encode(map[string]string{ "error": "Missing tenant_id", }) return } 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) json.NewEncoder(w).Encode(map[string]string{ "error": "Failed to fetch customers", "message": err.Error(), }) return } if customers == nil { customers = []domain.CRMCustomer{} } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "customers": customers, }) } func (h *CRMHandler) GetCustomer(w http.ResponseWriter, r *http.Request) { tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) if tenantID == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{ "error": "Missing tenant_id", }) return } vars := mux.Vars(r) customerID := vars["id"] customer, err := h.repo.GetCustomerByID(customerID, tenantID) if err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]string{ "error": "Customer not found", "message": err.Error(), }) return } // Buscar listas do cliente lists, _ := h.repo.GetCustomerLists(customerID) if lists == nil { lists = []domain.CRMList{} } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "customer": customer, "lists": lists, }) } func (h *CRMHandler) UpdateCustomer(w http.ResponseWriter, r *http.Request) { tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) if tenantID == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{ "error": "Missing tenant_id", }) return } vars := mux.Vars(r) customerID := vars["id"] var customer domain.CRMCustomer if err := json.NewDecoder(r.Body).Decode(&customer); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{ "error": "Invalid request body", "message": err.Error(), }) return } customer.ID = customerID customer.TenantID = tenantID 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) json.NewEncoder(w).Encode(map[string]string{ "error": "Failed to update customer", "message": err.Error(), }) 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", }) } func (h *CRMHandler) DeleteCustomer(w http.ResponseWriter, r *http.Request) { tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) if tenantID == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{ "error": "Missing tenant_id", }) return } vars := mux.Vars(r) customerID := vars["id"] if err := h.repo.DeleteCustomer(customerID, tenantID); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]string{ "error": "Failed to delete customer", "message": err.Error(), }) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ "message": "Customer deleted successfully", }) } // ==================== LISTS ==================== func (h *CRMHandler) CreateList(w http.ResponseWriter, r *http.Request) { tenantIDVal := r.Context().Value(middleware.TenantIDKey) userIDVal := r.Context().Value(middleware.UserIDKey) log.Printf("🔍 CreateList DEBUG: tenantID type=%T value=%v | userID type=%T value=%v", tenantIDVal, tenantIDVal, userIDVal, userIDVal) tenantID, ok := tenantIDVal.(string) if !ok || tenantID == "" { log.Printf("❌ CreateList: Missing or invalid tenant_id") w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{ "error": "Missing tenant_id", }) return } userID, _ := userIDVal.(string) var list domain.CRMList if err := json.NewDecoder(r.Body).Decode(&list); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{ "error": "Invalid request body", "message": err.Error(), }) return } list.ID = uuid.New().String() list.TenantID = tenantID list.CreatedBy = userID // Handle empty customer_id if list.CustomerID != nil && *list.CustomerID == "" { list.CustomerID = nil } if list.Color == "" { list.Color = "#3b82f6" } if err := h.repo.CreateList(&list); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]string{ "error": "Failed to create list", "message": err.Error(), }) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]interface{}{ "list": list, }) } func (h *CRMHandler) GetLists(w http.ResponseWriter, r *http.Request) { tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) if tenantID == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{ "error": "Missing tenant_id", }) return } 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{ "error": "Failed to fetch lists", "message": err.Error(), }) return } if lists == nil { 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, }) } func (h *CRMHandler) GetList(w http.ResponseWriter, r *http.Request) { tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) if tenantID == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{ "error": "Missing tenant_id", }) return } vars := mux.Vars(r) listID := vars["id"] list, err := h.repo.GetListByID(listID, tenantID) if err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]string{ "error": "List not found", "message": err.Error(), }) return } // Buscar clientes da lista customers, _ := h.repo.GetListCustomers(listID, tenantID) if customers == nil { customers = []domain.CRMCustomer{} } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "list": list, "customers": customers, }) } func (h *CRMHandler) UpdateList(w http.ResponseWriter, r *http.Request) { tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) if tenantID == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{ "error": "Missing tenant_id", }) return } vars := mux.Vars(r) listID := vars["id"] var list domain.CRMList if err := json.NewDecoder(r.Body).Decode(&list); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{ "error": "Invalid request body", "message": err.Error(), }) return } list.ID = listID list.TenantID = tenantID // 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) json.NewEncoder(w).Encode(map[string]string{ "error": "Failed to update list", "message": err.Error(), }) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ "message": "List updated successfully", }) } func (h *CRMHandler) DeleteList(w http.ResponseWriter, r *http.Request) { tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) if tenantID == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{ "error": "Missing tenant_id", }) return } vars := mux.Vars(r) listID := vars["id"] if err := h.repo.DeleteList(listID, tenantID); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]string{ "error": "Failed to delete list", "message": err.Error(), }) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ "message": "List deleted successfully", }) } // ==================== 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) { userID, _ := r.Context().Value(middleware.UserIDKey).(string) vars := mux.Vars(r) customerID := vars["customer_id"] listID := vars["list_id"] if err := h.repo.AddCustomerToList(customerID, listID, userID); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]string{ "error": "Failed to add customer to list", "message": err.Error(), }) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ "message": "Customer added to list successfully", }) } func (h *CRMHandler) RemoveCustomerFromList(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) customerID := vars["customer_id"] listID := vars["list_id"] if err := h.repo.RemoveCustomerFromList(customerID, listID); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]string{ "error": "Failed to remove customer from list", "message": err.Error(), }) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ "message": "Customer removed from list successfully", }) } // 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) }