Files
aggios.app/backend/internal/api/handlers/crm.go

1878 lines
54 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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), &notesData); 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)
}