feat: versão 1.5 - CRM Beta com leads, funis, campanhas e portal do cliente
This commit is contained in:
@@ -62,7 +62,7 @@ func main() {
|
||||
solutionRepo := repository.NewSolutionRepository(db)
|
||||
|
||||
// Initialize services
|
||||
authService := service.NewAuthService(userRepo, tenantRepo, cfg)
|
||||
authService := service.NewAuthService(userRepo, tenantRepo, crmRepo, cfg)
|
||||
agencyService := service.NewAgencyService(userRepo, tenantRepo, cfg, db)
|
||||
tenantService := service.NewTenantService(tenantRepo, db)
|
||||
companyService := service.NewCompanyService(companyRepo)
|
||||
@@ -73,6 +73,7 @@ func main() {
|
||||
authHandler := handlers.NewAuthHandler(authService)
|
||||
agencyProfileHandler := handlers.NewAgencyHandler(tenantRepo, cfg)
|
||||
agencyHandler := handlers.NewAgencyRegistrationHandler(agencyService, cfg)
|
||||
collaboratorHandler := handlers.NewCollaboratorHandler(userRepo, agencyService)
|
||||
tenantHandler := handlers.NewTenantHandler(tenantService)
|
||||
companyHandler := handlers.NewCompanyHandler(companyService)
|
||||
planHandler := handlers.NewPlanHandler(planService)
|
||||
@@ -81,6 +82,7 @@ func main() {
|
||||
signupTemplateHandler := handlers.NewSignupTemplateHandler(signupTemplateRepo, userRepo, tenantRepo, agencyService)
|
||||
agencyTemplateHandler := handlers.NewAgencyTemplateHandler(agencyTemplateRepo, agencyService, userRepo, tenantRepo)
|
||||
filesHandler := handlers.NewFilesHandler(cfg)
|
||||
customerPortalHandler := handlers.NewCustomerPortalHandler(crmRepo, authService, cfg)
|
||||
|
||||
// Initialize upload handler
|
||||
uploadHandler, err := handlers.NewUploadHandler(cfg)
|
||||
@@ -112,7 +114,8 @@ func main() {
|
||||
router.HandleFunc("/api/health", healthHandler.Check)
|
||||
|
||||
// Auth
|
||||
router.HandleFunc("/api/auth/login", authHandler.Login)
|
||||
router.HandleFunc("/api/auth/login", authHandler.UnifiedLogin) // Nova rota unificada
|
||||
router.HandleFunc("/api/auth/login/legacy", authHandler.Login) // Antiga rota (deprecada)
|
||||
router.HandleFunc("/api/auth/register", agencyHandler.PublicRegister).Methods("POST")
|
||||
|
||||
// Public agency template registration (for creating new agencies)
|
||||
@@ -133,6 +136,13 @@ func main() {
|
||||
// Tenant check (public)
|
||||
router.HandleFunc("/api/tenant/check", tenantHandler.CheckExists).Methods("GET")
|
||||
router.HandleFunc("/api/tenant/config", tenantHandler.GetPublicConfig).Methods("GET")
|
||||
router.HandleFunc("/api/tenants/{id}/profile", tenantHandler.GetProfile).Methods("GET")
|
||||
|
||||
// Tenant branding (protected - used by both agency and customer portal)
|
||||
router.Handle("/api/tenant/branding", middleware.RequireAnyAuthenticated(cfg)(http.HandlerFunc(tenantHandler.GetBranding))).Methods("GET")
|
||||
|
||||
// Public customer registration (for agency portal signup)
|
||||
router.HandleFunc("/api/public/customers/register", crmHandler.PublicRegisterCustomer).Methods("POST")
|
||||
|
||||
// Hash generator (dev only - remove in production)
|
||||
router.HandleFunc("/api/hash", handlers.GenerateHash).Methods("POST")
|
||||
@@ -239,6 +249,9 @@ func main() {
|
||||
// Tenant solutions (which solutions the tenant has access to)
|
||||
router.Handle("/api/tenant/solutions", authMiddleware(http.HandlerFunc(solutionHandler.GetTenantSolutions))).Methods("GET")
|
||||
|
||||
// Dashboard
|
||||
router.Handle("/api/crm/dashboard", authMiddleware(http.HandlerFunc(crmHandler.GetDashboard))).Methods("GET")
|
||||
|
||||
// Customers
|
||||
router.Handle("/api/crm/customers", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
@@ -280,6 +293,8 @@ func main() {
|
||||
crmHandler.DeleteList(w, r)
|
||||
}
|
||||
}))).Methods("GET", "PUT", "PATCH", "DELETE")
|
||||
|
||||
router.Handle("/api/crm/lists/{id}/leads", authMiddleware(http.HandlerFunc(crmHandler.GetLeadsByList))).Methods("GET")
|
||||
|
||||
// Customer <-> List relationship
|
||||
router.Handle("/api/crm/customers/{customer_id}/lists/{list_id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -291,6 +306,124 @@ func main() {
|
||||
}
|
||||
}))).Methods("POST", "DELETE")
|
||||
|
||||
// Leads
|
||||
router.Handle("/api/crm/leads", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
crmHandler.GetLeads(w, r)
|
||||
case http.MethodPost:
|
||||
crmHandler.CreateLead(w, r)
|
||||
}
|
||||
}))).Methods("GET", "POST")
|
||||
|
||||
router.Handle("/api/crm/leads/export", authMiddleware(http.HandlerFunc(crmHandler.ExportLeads))).Methods("GET")
|
||||
router.Handle("/api/crm/leads/import", authMiddleware(http.HandlerFunc(crmHandler.ImportLeads))).Methods("POST")
|
||||
router.Handle("/api/crm/leads/{leadId}/stage", authMiddleware(http.HandlerFunc(crmHandler.UpdateLeadStage))).Methods("PUT")
|
||||
|
||||
router.Handle("/api/crm/leads/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
crmHandler.GetLead(w, r)
|
||||
case http.MethodPut, http.MethodPatch:
|
||||
crmHandler.UpdateLead(w, r)
|
||||
case http.MethodDelete:
|
||||
crmHandler.DeleteLead(w, r)
|
||||
}
|
||||
}))).Methods("GET", "PUT", "PATCH", "DELETE")
|
||||
|
||||
// Funnels & Stages
|
||||
router.Handle("/api/crm/funnels", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
crmHandler.ListFunnels(w, r)
|
||||
case http.MethodPost:
|
||||
crmHandler.CreateFunnel(w, r)
|
||||
}
|
||||
}))).Methods("GET", "POST")
|
||||
|
||||
router.Handle("/api/crm/funnels/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
crmHandler.GetFunnel(w, r)
|
||||
case http.MethodPut:
|
||||
crmHandler.UpdateFunnel(w, r)
|
||||
case http.MethodDelete:
|
||||
crmHandler.DeleteFunnel(w, r)
|
||||
}
|
||||
}))).Methods("GET", "PUT", "DELETE")
|
||||
|
||||
router.Handle("/api/crm/funnels/{funnelId}/stages", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
crmHandler.ListStages(w, r)
|
||||
case http.MethodPost:
|
||||
crmHandler.CreateStage(w, r)
|
||||
}
|
||||
}))).Methods("GET", "POST")
|
||||
|
||||
router.Handle("/api/crm/stages/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPut:
|
||||
crmHandler.UpdateStage(w, r)
|
||||
case http.MethodDelete:
|
||||
crmHandler.DeleteStage(w, r)
|
||||
}
|
||||
}))).Methods("PUT", "DELETE")
|
||||
|
||||
// Lead ingest (integrations)
|
||||
router.Handle("/api/crm/leads/ingest", authMiddleware(http.HandlerFunc(crmHandler.IngestLead))).Methods("POST")
|
||||
|
||||
// Share tokens (generate)
|
||||
router.Handle("/api/crm/customers/share-token", authMiddleware(http.HandlerFunc(crmHandler.GenerateShareToken))).Methods("POST")
|
||||
|
||||
// Share data (public endpoint - no auth required)
|
||||
router.HandleFunc("/api/crm/share/{token}", crmHandler.GetSharedData).Methods("GET")
|
||||
|
||||
// ==================== CUSTOMER PORTAL ====================
|
||||
// Customer portal login (public endpoint)
|
||||
router.HandleFunc("/api/portal/login", customerPortalHandler.Login).Methods("POST")
|
||||
|
||||
// Customer portal dashboard (requires customer auth)
|
||||
router.Handle("/api/portal/dashboard", middleware.RequireCustomer(cfg)(http.HandlerFunc(customerPortalHandler.GetPortalDashboard))).Methods("GET")
|
||||
|
||||
// Customer portal leads (requires customer auth)
|
||||
router.Handle("/api/portal/leads", middleware.RequireCustomer(cfg)(http.HandlerFunc(customerPortalHandler.GetPortalLeads))).Methods("GET")
|
||||
|
||||
// Customer portal lists (requires customer auth)
|
||||
router.Handle("/api/portal/lists", middleware.RequireCustomer(cfg)(http.HandlerFunc(customerPortalHandler.GetPortalLists))).Methods("GET")
|
||||
|
||||
// Customer portal profile (requires customer auth)
|
||||
router.Handle("/api/portal/profile", middleware.RequireCustomer(cfg)(http.HandlerFunc(customerPortalHandler.GetPortalProfile))).Methods("GET")
|
||||
|
||||
// Customer portal change password (requires customer auth)
|
||||
router.Handle("/api/portal/change-password", middleware.RequireCustomer(cfg)(http.HandlerFunc(customerPortalHandler.ChangePassword))).Methods("POST")
|
||||
|
||||
// Customer portal logo upload (requires customer auth)
|
||||
router.Handle("/api/portal/logo", middleware.RequireCustomer(cfg)(http.HandlerFunc(customerPortalHandler.UploadLogo))).Methods("POST")
|
||||
|
||||
// ==================== AGENCY COLLABORATORS ====================
|
||||
// List collaborators (requires agency auth, owner only)
|
||||
router.Handle("/api/agency/collaborators", middleware.RequireAgencyUser(cfg)(http.HandlerFunc(collaboratorHandler.ListCollaborators))).Methods("GET")
|
||||
|
||||
// Invite collaborator (requires agency auth, owner only)
|
||||
router.Handle("/api/agency/collaborators/invite", middleware.RequireAgencyUser(cfg)(http.HandlerFunc(collaboratorHandler.InviteCollaborator))).Methods("POST")
|
||||
|
||||
// Remove collaborator (requires agency auth, owner only)
|
||||
router.Handle("/api/agency/collaborators/{id}", middleware.RequireAgencyUser(cfg)(http.HandlerFunc(collaboratorHandler.RemoveCollaborator))).Methods("DELETE")
|
||||
|
||||
// Generate customer portal access (agency staff)
|
||||
router.Handle("/api/crm/customers/{id}/portal-access", authMiddleware(http.HandlerFunc(crmHandler.GenerateCustomerPortalAccess))).Methods("POST")
|
||||
|
||||
// Lead <-> List relationship
|
||||
router.Handle("/api/crm/leads/{lead_id}/lists/{list_id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
crmHandler.AddLeadToList(w, r)
|
||||
case http.MethodDelete:
|
||||
crmHandler.RemoveLeadFromList(w, r)
|
||||
}
|
||||
}))).Methods("POST", "DELETE")
|
||||
|
||||
// Apply global middlewares: tenant -> cors -> security -> rateLimit -> router
|
||||
handler := tenantDetector(corsMiddleware(securityMiddleware(rateLimitMiddleware(router))))
|
||||
|
||||
|
||||
@@ -7,5 +7,6 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/minio/minio-go/v7 v7.0.63
|
||||
github.com/xuri/excelize/v2 v2.8.1
|
||||
golang.org/x/crypto v0.27.0
|
||||
)
|
||||
|
||||
@@ -167,3 +167,94 @@ func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
"message": "Password changed successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// UnifiedLogin handles login for all user types (agency, customer, superadmin)
|
||||
func (h *AuthHandler) UnifiedLogin(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("🔐 UNIFIED LOGIN HANDLER CALLED - Method: %s", r.Method)
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
log.Printf("❌ Method not allowed: %s", r.Method)
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
log.Printf("❌ Failed to read body: %v", err)
|
||||
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
log.Printf("📥 Raw body: %s", string(bodyBytes))
|
||||
|
||||
sanitized := strings.TrimSpace(string(bodyBytes))
|
||||
var req domain.UnifiedLoginRequest
|
||||
if err := json.Unmarshal([]byte(sanitized), &req); err != nil {
|
||||
log.Printf("❌ JSON parse error: %v", err)
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("📧 Unified login attempt for email: %s", req.Email)
|
||||
|
||||
response, err := h.authService.UnifiedLogin(req)
|
||||
if err != nil {
|
||||
log.Printf("❌ authService.UnifiedLogin error: %v", err)
|
||||
if err == service.ErrInvalidCredentials || strings.Contains(err.Error(), "não autorizado") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// VALIDAÇÃO DE SEGURANÇA: Verificar se o tenant corresponde ao subdomain acessado
|
||||
tenantIDFromContext := ""
|
||||
if ctxTenantID := r.Context().Value(middleware.TenantIDKey); ctxTenantID != nil {
|
||||
tenantIDFromContext, _ = ctxTenantID.(string)
|
||||
}
|
||||
|
||||
// Se foi detectado um tenant no contexto E o usuário tem tenant
|
||||
if tenantIDFromContext != "" && response.TenantID != "" {
|
||||
if response.TenantID != tenantIDFromContext {
|
||||
log.Printf("❌ LOGIN BLOCKED: User from tenant %s tried to login in tenant %s subdomain",
|
||||
response.TenantID, tenantIDFromContext)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Credenciais inválidas para esta agência",
|
||||
})
|
||||
return
|
||||
}
|
||||
log.Printf("✅ TENANT LOGIN VALIDATION PASSED: %s", response.TenantID)
|
||||
}
|
||||
|
||||
log.Printf("✅ Unified login successful: email=%s, type=%s, role=%s",
|
||||
response.Email, response.UserType, response.Role)
|
||||
|
||||
// Montar resposta compatível com frontend antigo E com novos campos
|
||||
compatibleResponse := map[string]interface{}{
|
||||
"token": response.Token,
|
||||
"user": map[string]interface{}{
|
||||
"id": response.UserID,
|
||||
"email": response.Email,
|
||||
"name": response.Name,
|
||||
"role": response.Role,
|
||||
"tenant_id": response.TenantID,
|
||||
"user_type": response.UserType,
|
||||
},
|
||||
// Campos adicionais do sistema unificado
|
||||
"user_type": response.UserType,
|
||||
"user_id": response.UserID,
|
||||
"subdomain": response.Subdomain,
|
||||
"tenant_id": response.TenantID,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(compatibleResponse)
|
||||
}
|
||||
|
||||
271
backend/internal/api/handlers/collaborator.go
Normal file
271
backend/internal/api/handlers/collaborator.go
Normal file
@@ -0,0 +1,271 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"aggios-app/backend/internal/api/middleware"
|
||||
"aggios-app/backend/internal/domain"
|
||||
"aggios-app/backend/internal/repository"
|
||||
"aggios-app/backend/internal/service"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// CollaboratorHandler handles agency collaborator management
|
||||
type CollaboratorHandler struct {
|
||||
userRepo *repository.UserRepository
|
||||
agencyServ *service.AgencyService
|
||||
}
|
||||
|
||||
// NewCollaboratorHandler creates a new collaborator handler
|
||||
func NewCollaboratorHandler(userRepo *repository.UserRepository, agencyServ *service.AgencyService) *CollaboratorHandler {
|
||||
return &CollaboratorHandler{
|
||||
userRepo: userRepo,
|
||||
agencyServ: agencyServ,
|
||||
}
|
||||
}
|
||||
|
||||
// AddCollaboratorRequest representa a requisição para adicionar um colaborador
|
||||
type AddCollaboratorRequest struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// CollaboratorResponse representa um colaborador
|
||||
type CollaboratorResponse struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
AgencyRole string `json:"agency_role"` // owner ou collaborator
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CollaboratorCreatedAt *time.Time `json:"collaborator_created_at,omitempty"`
|
||||
}
|
||||
|
||||
// ListCollaborators lista todos os colaboradores da agência (apenas owner pode ver)
|
||||
func (h *CollaboratorHandler) ListCollaborators(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
ownerID, _ := r.Context().Value(middleware.UserIDKey).(string)
|
||||
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
|
||||
agencyRole, _ := r.Context().Value("agency_role").(string)
|
||||
|
||||
// Apenas owner pode listar colaboradores
|
||||
if agencyRole != "owner" {
|
||||
log.Printf("❌ COLLABORATOR ACCESS BLOCKED: User %s tried to list collaborators", ownerID)
|
||||
http.Error(w, "Only agency owners can manage collaborators", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Buscar todos os usuários da agência
|
||||
tenantUUID := parseUUID(tenantID)
|
||||
if tenantUUID == nil {
|
||||
http.Error(w, "Invalid tenant ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
users, err := h.userRepo.ListByTenantID(*tenantUUID)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching collaborators: %v", err)
|
||||
http.Error(w, "Error fetching collaborators", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Formatar resposta
|
||||
collaborators := make([]CollaboratorResponse, 0)
|
||||
for _, user := range users {
|
||||
collaborators = append(collaborators, CollaboratorResponse{
|
||||
ID: user.ID.String(),
|
||||
Email: user.Email,
|
||||
Name: user.Name,
|
||||
AgencyRole: user.AgencyRole,
|
||||
CreatedAt: user.CreatedAt,
|
||||
CollaboratorCreatedAt: user.CollaboratorCreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"collaborators": collaborators,
|
||||
})
|
||||
}
|
||||
|
||||
// InviteCollaborator convida um novo colaborador para a agência (apenas owner pode fazer isso)
|
||||
func (h *CollaboratorHandler) InviteCollaborator(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
ownerID, _ := r.Context().Value(middleware.UserIDKey).(string)
|
||||
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
|
||||
agencyRole, _ := r.Context().Value("agency_role").(string)
|
||||
|
||||
// Apenas owner pode convidar colaboradores
|
||||
if agencyRole != "owner" {
|
||||
log.Printf("❌ COLLABORATOR INVITE BLOCKED: User %s tried to invite collaborator", ownerID)
|
||||
http.Error(w, "Only agency owners can invite collaborators", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
var req AddCollaboratorRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validar email
|
||||
if req.Email == "" {
|
||||
http.Error(w, "Email is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validar se email já existe
|
||||
exists, err := h.userRepo.EmailExists(req.Email)
|
||||
if err != nil {
|
||||
log.Printf("Error checking email: %v", err)
|
||||
http.Error(w, "Error processing request", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if exists {
|
||||
http.Error(w, "Email already registered", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
// Gerar senha temporária (8 caracteres aleatórios)
|
||||
tempPassword := generateTempPassword()
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(tempPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Printf("Error hashing password: %v", err)
|
||||
http.Error(w, "Error processing request", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Criar novo colaborador
|
||||
ownerUUID := parseUUID(ownerID)
|
||||
tenantUUID := parseUUID(tenantID)
|
||||
now := time.Now()
|
||||
|
||||
collaborator := &domain.User{
|
||||
TenantID: tenantUUID,
|
||||
Email: req.Email,
|
||||
Password: string(hashedPassword),
|
||||
Name: req.Name,
|
||||
Role: "ADMIN_AGENCIA",
|
||||
AgencyRole: "collaborator",
|
||||
CreatedBy: ownerUUID,
|
||||
CollaboratorCreatedAt: &now,
|
||||
}
|
||||
|
||||
if err := h.userRepo.Create(collaborator); err != nil {
|
||||
log.Printf("Error creating collaborator: %v", err)
|
||||
http.Error(w, "Error creating collaborator", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"message": "Collaborator invited successfully",
|
||||
"temporary_password": tempPassword,
|
||||
"collaborator": CollaboratorResponse{
|
||||
ID: collaborator.ID.String(),
|
||||
Email: collaborator.Email,
|
||||
Name: collaborator.Name,
|
||||
AgencyRole: collaborator.AgencyRole,
|
||||
CreatedAt: collaborator.CreatedAt,
|
||||
CollaboratorCreatedAt: collaborator.CollaboratorCreatedAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// RemoveCollaborator remove um colaborador da agência (apenas owner pode fazer isso)
|
||||
func (h *CollaboratorHandler) RemoveCollaborator(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
ownerID, _ := r.Context().Value(middleware.UserIDKey).(string)
|
||||
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
|
||||
agencyRole, _ := r.Context().Value("agency_role").(string)
|
||||
|
||||
// Apenas owner pode remover colaboradores
|
||||
if agencyRole != "owner" {
|
||||
log.Printf("❌ COLLABORATOR REMOVE BLOCKED: User %s tried to remove collaborator", ownerID)
|
||||
http.Error(w, "Only agency owners can remove collaborators", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
collaboratorID := r.URL.Query().Get("id")
|
||||
if collaboratorID == "" {
|
||||
http.Error(w, "Collaborator ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Converter ID para UUID
|
||||
collaboratorUUID := parseUUID(collaboratorID)
|
||||
if collaboratorUUID == nil {
|
||||
http.Error(w, "Invalid collaborator ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Buscar o colaborador
|
||||
collaborator, err := h.userRepo.GetByID(*collaboratorUUID)
|
||||
if err != nil {
|
||||
http.Error(w, "Collaborator not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Verificar se o colaborador pertence à mesma agência
|
||||
if collaborator.TenantID == nil || collaborator.TenantID.String() != tenantID {
|
||||
http.Error(w, "Collaborator not found in this agency", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Não permitir remover o owner
|
||||
if collaborator.AgencyRole == "owner" {
|
||||
http.Error(w, "Cannot remove the agency owner", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Remover colaborador
|
||||
if err := h.userRepo.Delete(*collaboratorUUID); err != nil {
|
||||
log.Printf("Error removing collaborator: %v", err)
|
||||
http.Error(w, "Error removing collaborator", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"message": "Collaborator removed successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// generateTempPassword gera uma senha temporária
|
||||
func generateTempPassword() string {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*"
|
||||
return randomString(12, charset)
|
||||
}
|
||||
|
||||
// randomString gera uma string aleatória
|
||||
func randomString(length int, charset string) string {
|
||||
b := make([]byte, length)
|
||||
for i := range b {
|
||||
b[i] = charset[i%len(charset)]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// parseUUID converte string para UUID
|
||||
func parseUUID(s string) *uuid.UUID {
|
||||
u, err := uuid.Parse(s)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &u
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
465
backend/internal/api/handlers/customer_portal.go
Normal file
465
backend/internal/api/handlers/customer_portal.go
Normal file
@@ -0,0 +1,465 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"aggios-app/backend/internal/domain"
|
||||
"aggios-app/backend/internal/repository"
|
||||
"aggios-app/backend/internal/service"
|
||||
"aggios-app/backend/internal/config"
|
||||
"aggios-app/backend/internal/api/middleware"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type CustomerPortalHandler struct {
|
||||
crmRepo *repository.CRMRepository
|
||||
authService *service.AuthService
|
||||
cfg *config.Config
|
||||
minioClient *minio.Client
|
||||
}
|
||||
|
||||
func NewCustomerPortalHandler(crmRepo *repository.CRMRepository, authService *service.AuthService, cfg *config.Config) *CustomerPortalHandler {
|
||||
// Initialize MinIO client
|
||||
minioClient, err := minio.New(cfg.Minio.Endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(cfg.Minio.RootUser, cfg.Minio.RootPassword, ""),
|
||||
Secure: cfg.Minio.UseSSL,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("❌ Failed to create MinIO client for CustomerPortalHandler: %v", err)
|
||||
}
|
||||
|
||||
return &CustomerPortalHandler{
|
||||
crmRepo: crmRepo,
|
||||
authService: authService,
|
||||
cfg: cfg,
|
||||
minioClient: minioClient,
|
||||
}
|
||||
}
|
||||
|
||||
// CustomerLoginRequest representa a requisição de login do cliente
|
||||
type CustomerLoginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// CustomerLoginResponse representa a resposta de login do cliente
|
||||
type CustomerLoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
Customer *CustomerPortalInfo `json:"customer"`
|
||||
}
|
||||
|
||||
// CustomerPortalInfo representa informações seguras do cliente para o portal
|
||||
type CustomerPortalInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Company string `json:"company"`
|
||||
HasPortalAccess bool `json:"has_portal_access"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
}
|
||||
|
||||
// Login autentica um cliente e retorna um token JWT
|
||||
func (h *CustomerPortalHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var req CustomerLoginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Validar entrada
|
||||
if req.Email == "" || req.Password == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Email e senha são obrigatórios",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Buscar cliente por email
|
||||
customer, err := h.crmRepo.GetCustomerByEmail(req.Email)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Credenciais inválidas",
|
||||
})
|
||||
return
|
||||
}
|
||||
log.Printf("Error fetching customer: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Erro ao processar login",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Verificar se tem acesso ao portal
|
||||
if !customer.HasPortalAccess {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Acesso ao portal não autorizado. Entre em contato com o administrador.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Verificar senha
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(customer.PasswordHash), []byte(req.Password)); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Credenciais inválidas",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Atualizar último login
|
||||
if err := h.crmRepo.UpdateCustomerLastLogin(customer.ID); err != nil {
|
||||
log.Printf("Warning: Failed to update last login for customer %s: %v", customer.ID, err)
|
||||
}
|
||||
|
||||
// Gerar token JWT
|
||||
token, err := h.authService.GenerateCustomerToken(customer.ID, customer.TenantID, customer.Email)
|
||||
if err != nil {
|
||||
log.Printf("Error generating token: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Erro ao gerar token de autenticação",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Resposta de sucesso
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(CustomerLoginResponse{
|
||||
Token: token,
|
||||
Customer: &CustomerPortalInfo{
|
||||
ID: customer.ID,
|
||||
Name: customer.Name,
|
||||
Email: customer.Email,
|
||||
Company: customer.Company,
|
||||
HasPortalAccess: customer.HasPortalAccess,
|
||||
TenantID: customer.TenantID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetPortalDashboard retorna dados do dashboard para o cliente autenticado
|
||||
func (h *CustomerPortalHandler) GetPortalDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
customerID, _ := r.Context().Value(middleware.CustomerIDKey).(string)
|
||||
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
|
||||
|
||||
// Buscar leads do cliente
|
||||
leads, err := h.crmRepo.GetLeadsByCustomerID(customerID)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching leads: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Erro ao buscar leads",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Buscar informações do cliente
|
||||
customer, err := h.crmRepo.GetCustomerByID(customerID, tenantID)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching customer: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Erro ao buscar informações do cliente",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Calcular estatísticas
|
||||
rawStats := calculateLeadStats(leads)
|
||||
stats := map[string]interface{}{
|
||||
"total_leads": rawStats["total"],
|
||||
"active_leads": rawStats["novo"].(int) + rawStats["qualificado"].(int) + rawStats["negociacao"].(int),
|
||||
"converted": rawStats["convertido"],
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"customer": CustomerPortalInfo{
|
||||
ID: customer.ID,
|
||||
Name: customer.Name,
|
||||
Email: customer.Email,
|
||||
Company: customer.Company,
|
||||
HasPortalAccess: customer.HasPortalAccess,
|
||||
TenantID: customer.TenantID,
|
||||
},
|
||||
"leads": leads,
|
||||
"stats": stats,
|
||||
})
|
||||
}
|
||||
|
||||
// GetPortalLeads retorna apenas os leads do cliente
|
||||
func (h *CustomerPortalHandler) GetPortalLeads(w http.ResponseWriter, r *http.Request) {
|
||||
customerID, _ := r.Context().Value(middleware.CustomerIDKey).(string)
|
||||
|
||||
leads, err := h.crmRepo.GetLeadsByCustomerID(customerID)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching leads: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Erro ao buscar leads",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if leads == nil {
|
||||
leads = []domain.CRMLead{}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"leads": leads,
|
||||
})
|
||||
}
|
||||
|
||||
// GetPortalLists retorna as listas que possuem leads do cliente
|
||||
func (h *CustomerPortalHandler) GetPortalLists(w http.ResponseWriter, r *http.Request) {
|
||||
customerID, _ := r.Context().Value(middleware.CustomerIDKey).(string)
|
||||
|
||||
lists, err := h.crmRepo.GetListsByCustomerID(customerID)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching portal lists: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Erro ao buscar listas",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"lists": lists,
|
||||
})
|
||||
}
|
||||
|
||||
// GetPortalProfile retorna o perfil completo do cliente
|
||||
func (h *CustomerPortalHandler) GetPortalProfile(w http.ResponseWriter, r *http.Request) {
|
||||
customerID, _ := r.Context().Value(middleware.CustomerIDKey).(string)
|
||||
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
|
||||
|
||||
// Buscar informações do cliente
|
||||
customer, err := h.crmRepo.GetCustomerByID(customerID, tenantID)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching customer: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Erro ao buscar perfil",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Buscar leads para estatísticas
|
||||
leads, err := h.crmRepo.GetLeadsByCustomerID(customerID)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching leads for stats: %v", err)
|
||||
leads = []domain.CRMLead{}
|
||||
}
|
||||
|
||||
// Calcular estatísticas
|
||||
stats := calculateLeadStats(leads)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"customer": map[string]interface{}{
|
||||
"id": customer.ID,
|
||||
"name": customer.Name,
|
||||
"email": customer.Email,
|
||||
"phone": customer.Phone,
|
||||
"company": customer.Company,
|
||||
"logo_url": customer.LogoURL,
|
||||
"portal_last_login": customer.PortalLastLogin,
|
||||
"created_at": customer.CreatedAt,
|
||||
"total_leads": len(leads),
|
||||
"converted_leads": stats["convertido"].(int),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ChangePasswordRequest representa a requisição de troca de senha
|
||||
type CustomerChangePasswordRequest struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
// ChangePassword altera a senha do cliente
|
||||
func (h *CustomerPortalHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
customerID, _ := r.Context().Value(middleware.CustomerIDKey).(string)
|
||||
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
|
||||
|
||||
var req CustomerChangePasswordRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Validar entrada
|
||||
if req.CurrentPassword == "" || req.NewPassword == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Senha atual e nova senha são obrigatórias",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.NewPassword) < 6 {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "A nova senha deve ter no mínimo 6 caracteres",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Buscar cliente
|
||||
customer, err := h.crmRepo.GetCustomerByID(customerID, tenantID)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching customer: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Erro ao processar solicitação",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Verificar senha atual
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(customer.PasswordHash), []byte(req.CurrentPassword)); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Senha atual incorreta",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Gerar hash da nova senha
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
log.Printf("Error hashing password: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Erro ao processar nova senha",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Atualizar senha no banco
|
||||
if err := h.crmRepo.UpdateCustomerPassword(customerID, string(hashedPassword)); err != nil {
|
||||
log.Printf("Error updating password: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Erro ao atualizar senha",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"message": "Senha alterada com sucesso",
|
||||
})
|
||||
}
|
||||
|
||||
// UploadLogo faz o upload do logo do cliente
|
||||
func (h *CustomerPortalHandler) UploadLogo(w http.ResponseWriter, r *http.Request) {
|
||||
customerID, _ := r.Context().Value(middleware.CustomerIDKey).(string)
|
||||
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
|
||||
|
||||
if h.minioClient == nil {
|
||||
http.Error(w, "Storage service unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse multipart form (2MB max)
|
||||
const maxLogoSize = 2 * 1024 * 1024
|
||||
if err := r.ParseMultipartForm(maxLogoSize); err != nil {
|
||||
http.Error(w, "File too large", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("logo")
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read file", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Validate file type
|
||||
contentType := header.Header.Get("Content-Type")
|
||||
if !strings.HasPrefix(contentType, "image/") {
|
||||
http.Error(w, "Only images are allowed", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
ext := filepath.Ext(header.Filename)
|
||||
if ext == "" {
|
||||
ext = ".png" // Default extension
|
||||
}
|
||||
filename := fmt.Sprintf("logo-%d%s", time.Now().Unix(), ext)
|
||||
objectPath := fmt.Sprintf("customers/%s/%s", customerID, filename)
|
||||
|
||||
// Upload to MinIO
|
||||
ctx := context.Background()
|
||||
bucketName := h.cfg.Minio.BucketName
|
||||
|
||||
_, err = h.minioClient.PutObject(ctx, bucketName, objectPath, file, header.Size, minio.PutObjectOptions{
|
||||
ContentType: contentType,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Error uploading to MinIO: %v", err)
|
||||
http.Error(w, "Failed to upload file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate public URL
|
||||
logoURL := fmt.Sprintf("%s/api/files/%s/%s", h.cfg.Minio.PublicURL, bucketName, objectPath)
|
||||
|
||||
// Update customer in database
|
||||
err = h.crmRepo.UpdateCustomerLogo(customerID, tenantID, logoURL)
|
||||
if err != nil {
|
||||
log.Printf("Error updating customer logo in DB: %v", err)
|
||||
http.Error(w, "Failed to update profile", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"logo_url": logoURL,
|
||||
})
|
||||
}
|
||||
210
backend/internal/api/handlers/export.go
Normal file
210
backend/internal/api/handlers/export.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"aggios-app/backend/internal/api/middleware"
|
||||
"aggios-app/backend/internal/domain"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
// ExportLeads handles exporting leads in different formats
|
||||
func (h *CRMHandler) ExportLeads(w http.ResponseWriter, r *http.Request) {
|
||||
tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string)
|
||||
if tenantID == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "Missing tenant_id"})
|
||||
return
|
||||
}
|
||||
|
||||
format := r.URL.Query().Get("format")
|
||||
if format == "" {
|
||||
format = "csv"
|
||||
}
|
||||
|
||||
customerID := r.URL.Query().Get("customer_id")
|
||||
campaignID := r.URL.Query().Get("campaign_id")
|
||||
|
||||
var leads []domain.CRMLead
|
||||
var err error
|
||||
|
||||
if campaignID != "" {
|
||||
leads, err = h.repo.GetLeadsByListID(campaignID)
|
||||
} else if customerID != "" {
|
||||
leads, err = h.repo.GetLeadsByTenant(tenantID)
|
||||
// Filter by customer manually
|
||||
filtered := []domain.CRMLead{}
|
||||
for _, lead := range leads {
|
||||
if lead.CustomerID != nil && *lead.CustomerID == customerID {
|
||||
filtered = append(filtered, lead)
|
||||
}
|
||||
}
|
||||
leads = filtered
|
||||
} else {
|
||||
leads, err = h.repo.GetLeadsByTenant(tenantID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("ExportLeads: Error fetching leads: %v", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "Failed to fetch leads"})
|
||||
return
|
||||
}
|
||||
|
||||
switch strings.ToLower(format) {
|
||||
case "json":
|
||||
exportJSON(w, leads)
|
||||
case "xlsx", "excel":
|
||||
exportXLSX(w, leads)
|
||||
default:
|
||||
exportCSV(w, leads)
|
||||
}
|
||||
}
|
||||
|
||||
func exportJSON(w http.ResponseWriter, leads []domain.CRMLead) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=leads.json")
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"leads": leads,
|
||||
"count": len(leads),
|
||||
})
|
||||
}
|
||||
|
||||
func exportCSV(w http.ResponseWriter, leads []domain.CRMLead) {
|
||||
w.Header().Set("Content-Type", "text/csv")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=leads.csv")
|
||||
|
||||
writer := csv.NewWriter(w)
|
||||
defer writer.Flush()
|
||||
|
||||
// Header
|
||||
header := []string{"ID", "Nome", "Email", "Telefone", "Status", "Origem", "Notas", "Tags", "Criado Em"}
|
||||
writer.Write(header)
|
||||
|
||||
// Data
|
||||
for _, lead := range leads {
|
||||
tags := ""
|
||||
if len(lead.Tags) > 0 {
|
||||
tags = strings.Join(lead.Tags, ", ")
|
||||
}
|
||||
|
||||
phone := ""
|
||||
if lead.Phone != "" {
|
||||
phone = lead.Phone
|
||||
}
|
||||
|
||||
notes := ""
|
||||
if lead.Notes != "" {
|
||||
notes = lead.Notes
|
||||
}
|
||||
|
||||
row := []string{
|
||||
lead.ID,
|
||||
lead.Name,
|
||||
lead.Email,
|
||||
phone,
|
||||
lead.Status,
|
||||
lead.Source,
|
||||
notes,
|
||||
tags,
|
||||
lead.CreatedAt.Format("02/01/2006 15:04"),
|
||||
}
|
||||
writer.Write(row)
|
||||
}
|
||||
}
|
||||
|
||||
func exportXLSX(w http.ResponseWriter, leads []domain.CRMLead) {
|
||||
f := excelize.NewFile()
|
||||
defer f.Close()
|
||||
|
||||
sheetName := "Leads"
|
||||
index, err := f.NewSheet(sheetName)
|
||||
if err != nil {
|
||||
log.Printf("Error creating sheet: %v", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Set active sheet
|
||||
f.SetActiveSheet(index)
|
||||
|
||||
// Header style
|
||||
headerStyle, _ := f.NewStyle(&excelize.Style{
|
||||
Font: &excelize.Font{
|
||||
Bold: true,
|
||||
Size: 12,
|
||||
},
|
||||
Fill: excelize.Fill{
|
||||
Type: "pattern",
|
||||
Color: []string{"#4472C4"},
|
||||
Pattern: 1,
|
||||
},
|
||||
Alignment: &excelize.Alignment{
|
||||
Horizontal: "center",
|
||||
Vertical: "center",
|
||||
},
|
||||
})
|
||||
|
||||
// Headers
|
||||
headers := []string{"ID", "Nome", "Email", "Telefone", "Status", "Origem", "Notas", "Tags", "Criado Em"}
|
||||
for i, header := range headers {
|
||||
cell := fmt.Sprintf("%s1", string(rune('A'+i)))
|
||||
f.SetCellValue(sheetName, cell, header)
|
||||
f.SetCellStyle(sheetName, cell, cell, headerStyle)
|
||||
}
|
||||
|
||||
// Data
|
||||
for i, lead := range leads {
|
||||
row := i + 2
|
||||
|
||||
tags := ""
|
||||
if len(lead.Tags) > 0 {
|
||||
tags = strings.Join(lead.Tags, ", ")
|
||||
}
|
||||
|
||||
phone := ""
|
||||
if lead.Phone != "" {
|
||||
phone = lead.Phone
|
||||
}
|
||||
|
||||
notes := ""
|
||||
if lead.Notes != "" {
|
||||
notes = lead.Notes
|
||||
}
|
||||
|
||||
f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), lead.ID)
|
||||
f.SetCellValue(sheetName, fmt.Sprintf("B%d", row), lead.Name)
|
||||
f.SetCellValue(sheetName, fmt.Sprintf("C%d", row), lead.Email)
|
||||
f.SetCellValue(sheetName, fmt.Sprintf("D%d", row), phone)
|
||||
f.SetCellValue(sheetName, fmt.Sprintf("E%d", row), lead.Status)
|
||||
f.SetCellValue(sheetName, fmt.Sprintf("F%d", row), lead.Source)
|
||||
f.SetCellValue(sheetName, fmt.Sprintf("G%d", row), notes)
|
||||
f.SetCellValue(sheetName, fmt.Sprintf("H%d", row), tags)
|
||||
f.SetCellValue(sheetName, fmt.Sprintf("I%d", row), lead.CreatedAt.Format("02/01/2006 15:04"))
|
||||
}
|
||||
|
||||
// Auto-adjust column widths
|
||||
for i := 0; i < len(headers); i++ {
|
||||
col := string(rune('A' + i))
|
||||
f.SetColWidth(sheetName, col, col, 15)
|
||||
}
|
||||
f.SetColWidth(sheetName, "B", "B", 25) // Nome
|
||||
f.SetColWidth(sheetName, "C", "C", 30) // Email
|
||||
f.SetColWidth(sheetName, "G", "G", 40) // Notas
|
||||
|
||||
// Delete default sheet if exists
|
||||
f.DeleteSheet("Sheet1")
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=leads.xlsx")
|
||||
|
||||
if err := f.Write(w); err != nil {
|
||||
log.Printf("Error writing xlsx: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,10 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"aggios-app/backend/internal/api/middleware"
|
||||
"aggios-app/backend/internal/service"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// TenantHandler handles tenant/agency listing endpoints
|
||||
@@ -93,7 +96,8 @@ func (h *TenantHandler) GetPublicConfig(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// Return only public info
|
||||
response := map[string]string{
|
||||
response := map[string]interface{}{
|
||||
"id": tenant.ID.String(),
|
||||
"name": tenant.Name,
|
||||
"primary_color": tenant.PrimaryColor,
|
||||
"secondary_color": tenant.SecondaryColor,
|
||||
@@ -106,3 +110,88 @@ func (h *TenantHandler) GetPublicConfig(w http.ResponseWriter, r *http.Request)
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// GetBranding returns branding info for the current authenticated tenant
|
||||
func (h *TenantHandler) GetBranding(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Get tenant from context (set by auth middleware)
|
||||
tenantID := r.Context().Value(middleware.TenantIDKey)
|
||||
if tenantID == nil {
|
||||
http.Error(w, "Tenant not found in context", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse tenant ID
|
||||
tid, err := uuid.Parse(tenantID.(string))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid tenant ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get tenant from database
|
||||
tenant, err := h.tenantService.GetByID(tid)
|
||||
if err != nil {
|
||||
http.Error(w, "Error fetching branding", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return branding info
|
||||
response := map[string]interface{}{
|
||||
"id": tenant.ID.String(),
|
||||
"name": tenant.Name,
|
||||
"primary_color": tenant.PrimaryColor,
|
||||
"secondary_color": tenant.SecondaryColor,
|
||||
"logo_url": tenant.LogoURL,
|
||||
"logo_horizontal_url": tenant.LogoHorizontalURL,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// GetProfile returns public tenant information by tenant ID
|
||||
func (h *TenantHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract tenant ID from URL path
|
||||
// URL format: /api/tenants/{id}/profile
|
||||
tenantIDStr := r.URL.Path[len("/api/tenants/"):]
|
||||
if idx := len(tenantIDStr) - len("/profile"); idx > 0 {
|
||||
tenantIDStr = tenantIDStr[:idx]
|
||||
}
|
||||
|
||||
if tenantIDStr == "" {
|
||||
http.Error(w, "tenant_id is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Para compatibilidade, aceitar tanto UUID quanto ID numérico
|
||||
// Primeiro tentar como UUID, se falhar buscar tenant diretamente
|
||||
tenant, err := h.tenantService.GetBySubdomain(tenantIDStr)
|
||||
if err != nil {
|
||||
log.Printf("Error getting tenant: %v", err)
|
||||
http.Error(w, "Tenant not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Return public info
|
||||
response := map[string]interface{}{
|
||||
"tenant": map[string]string{
|
||||
"company": tenant.Name,
|
||||
"primary_color": tenant.PrimaryColor,
|
||||
"secondary_color": tenant.SecondaryColor,
|
||||
"logo_url": tenant.LogoURL,
|
||||
"logo_horizontal_url": tenant.LogoHorizontalURL,
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
@@ -65,6 +65,16 @@ func Auth(cfg *config.Config) func(http.Handler) http.Handler {
|
||||
tenantIDFromJWT, _ = tenantIDClaim.(string)
|
||||
}
|
||||
|
||||
// VALIDAÇÃO DE SEGURANÇA: Verificar user_type para impedir clientes de acessarem rotas de agência
|
||||
if userTypeClaim, ok := claims["user_type"]; ok && userTypeClaim != nil {
|
||||
userType, _ := userTypeClaim.(string)
|
||||
if userType == "customer" {
|
||||
log.Printf("❌ CUSTOMER ACCESS BLOCKED: Customer %s tried to access agency route %s", userID, r.RequestURI)
|
||||
http.Error(w, "Forbidden: Customers cannot access agency routes", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// VALIDAÇÃO DE SEGURANÇA: Verificar se o tenant_id do JWT corresponde ao subdomínio acessado
|
||||
// Pegar o tenant_id do contexto (detectado pelo TenantDetector middleware ANTES deste)
|
||||
tenantIDFromContext := ""
|
||||
|
||||
44
backend/internal/api/middleware/collaborator_readonly.go
Normal file
44
backend/internal/api/middleware/collaborator_readonly.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CheckCollaboratorReadOnly verifica se um colaborador está tentando fazer operações de escrita
|
||||
// Se sim, bloqueia com 403
|
||||
func CheckCollaboratorReadOnly(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verificar agency_role do contexto
|
||||
agencyRole, ok := r.Context().Value("agency_role").(string)
|
||||
if !ok {
|
||||
// Se não houver agency_role no contexto, é um customer, deixa passar
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Apenas colaboradores têm restrição de read-only
|
||||
if agencyRole != "collaborator" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Verificar se é uma operação de escrita
|
||||
method := r.Method
|
||||
if method == http.MethodPost || method == http.MethodPut || method == http.MethodDelete {
|
||||
// Verificar a rota
|
||||
path := r.URL.Path
|
||||
|
||||
// Bloquear operações de escrita em CRM
|
||||
if strings.Contains(path, "/api/crm/") {
|
||||
userID, _ := r.Context().Value(UserIDKey).(string)
|
||||
log.Printf("❌ COLLABORATOR WRITE BLOCKED: User %s (collaborator) tried %s %s", userID, method, path)
|
||||
http.Error(w, "Colaboradores têm acesso somente leitura", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
85
backend/internal/api/middleware/customer_auth.go
Normal file
85
backend/internal/api/middleware/customer_auth.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"aggios-app/backend/internal/config"
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
CustomerIDKey contextKey = "customer_id"
|
||||
)
|
||||
|
||||
// CustomerAuthMiddleware valida tokens JWT de clientes do portal
|
||||
func CustomerAuthMiddleware(cfg *config.Config) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Extrair token do header Authorization
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
http.Error(w, "Authorization header required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Remover "Bearer " prefix
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if tokenString == authHeader {
|
||||
http.Error(w, "Invalid authorization format", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse e validar token
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
// Verificar método de assinatura
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, jwt.ErrSignatureInvalid
|
||||
}
|
||||
return []byte(cfg.JWT.Secret), nil
|
||||
})
|
||||
|
||||
if err != nil || !token.Valid {
|
||||
log.Printf("Invalid token: %v", err)
|
||||
http.Error(w, "Invalid or expired token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Extrair claims
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
http.Error(w, "Invalid token claims", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Verificar se é token de customer
|
||||
tokenType, _ := claims["type"].(string)
|
||||
if tokenType != "customer_portal" {
|
||||
http.Error(w, "Invalid token type", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Extrair customer_id e tenant_id
|
||||
customerID, ok := claims["customer_id"].(string)
|
||||
if !ok {
|
||||
http.Error(w, "Invalid customer_id in token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
tenantID, ok := claims["tenant_id"].(string)
|
||||
if !ok {
|
||||
http.Error(w, "Invalid tenant_id in token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Adicionar ao contexto
|
||||
ctx := context.WithValue(r.Context(), CustomerIDKey, customerID)
|
||||
ctx = context.WithValue(ctx, TenantIDKey, tenantID)
|
||||
|
||||
// Prosseguir com a requisição
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
104
backend/internal/api/middleware/unified_auth.go
Normal file
104
backend/internal/api/middleware/unified_auth.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"aggios-app/backend/internal/config"
|
||||
"aggios-app/backend/internal/domain"
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// UnifiedAuthMiddleware valida JWT unificado e permite múltiplos tipos de usuários
|
||||
func UnifiedAuthMiddleware(cfg *config.Config, allowedTypes ...domain.UserType) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Extrair token do header Authorization
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
log.Printf("🚫 UnifiedAuth: Missing Authorization header")
|
||||
http.Error(w, "Unauthorized: Missing token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Formato esperado: "Bearer <token>"
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
log.Printf("🚫 UnifiedAuth: Invalid Authorization format")
|
||||
http.Error(w, "Unauthorized: Invalid token format", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := parts[1]
|
||||
|
||||
// Parsear e validar token
|
||||
token, err := jwt.ParseWithClaims(tokenString, &domain.UnifiedClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(cfg.JWT.Secret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("🚫 UnifiedAuth: Token parse error: %v", err)
|
||||
http.Error(w, "Unauthorized: Invalid token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*domain.UnifiedClaims)
|
||||
if !ok || !token.Valid {
|
||||
log.Printf("🚫 UnifiedAuth: Invalid token claims")
|
||||
http.Error(w, "Unauthorized: Invalid token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Verificar se o tipo de usuário é permitido
|
||||
if len(allowedTypes) > 0 {
|
||||
allowed := false
|
||||
for _, allowedType := range allowedTypes {
|
||||
if claims.UserType == allowedType {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
log.Printf("🚫 UnifiedAuth: User type %s not allowed (allowed: %v)", claims.UserType, allowedTypes)
|
||||
http.Error(w, "Forbidden: Insufficient permissions", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Adicionar informações ao contexto
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, UserIDKey, claims.UserID)
|
||||
ctx = context.WithValue(ctx, TenantIDKey, claims.TenantID)
|
||||
ctx = context.WithValue(ctx, "email", claims.Email)
|
||||
ctx = context.WithValue(ctx, "user_type", string(claims.UserType))
|
||||
ctx = context.WithValue(ctx, "role", claims.Role)
|
||||
|
||||
// Para compatibilidade com handlers de portal que esperam CustomerIDKey
|
||||
if claims.UserType == domain.UserTypeCustomer {
|
||||
ctx = context.WithValue(ctx, CustomerIDKey, claims.UserID)
|
||||
}
|
||||
|
||||
log.Printf("✅ UnifiedAuth: Authenticated user_id=%s, type=%s, role=%s, tenant=%s",
|
||||
claims.UserID, claims.UserType, claims.Role, claims.TenantID)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAgencyUser middleware que permite apenas usuários de agência (admin, colaborador)
|
||||
func RequireAgencyUser(cfg *config.Config) func(http.Handler) http.Handler {
|
||||
return UnifiedAuthMiddleware(cfg, domain.UserTypeAgency)
|
||||
}
|
||||
|
||||
// RequireCustomer middleware que permite apenas clientes
|
||||
func RequireCustomer(cfg *config.Config) func(http.Handler) http.Handler {
|
||||
return UnifiedAuthMiddleware(cfg, domain.UserTypeCustomer)
|
||||
}
|
||||
|
||||
// RequireAnyAuthenticated middleware que permite qualquer usuário autenticado
|
||||
func RequireAnyAuthenticated(cfg *config.Config) func(http.Handler) http.Handler {
|
||||
return UnifiedAuthMiddleware(cfg) // Sem filtro de tipo
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Migration: Add agency user roles and collaborator tracking
|
||||
-- Purpose: Support owner/collaborator hierarchy for agency users
|
||||
|
||||
-- 1. Add agency_role column to users table (owner or collaborator)
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS agency_role VARCHAR(50) DEFAULT 'owner' CHECK (agency_role IN ('owner', 'collaborator'));
|
||||
|
||||
-- 2. Add created_by column to track which user created this collaborator
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS created_by UUID REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
-- 3. Update existing ADMIN_AGENCIA users to have 'owner' agency_role
|
||||
UPDATE users SET agency_role = 'owner' WHERE role = 'ADMIN_AGENCIA' AND agency_role IS NULL;
|
||||
|
||||
-- 4. Add collaborator_created_at to track when the collaborator was added
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS collaborator_created_at TIMESTAMP WITH TIME ZONE;
|
||||
|
||||
-- 5. Create index for faster queries
|
||||
CREATE INDEX IF NOT EXISTS idx_users_agency_role ON users(tenant_id, agency_role);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_created_by ON users(created_by);
|
||||
42
backend/internal/domain/auth_unified.go
Normal file
42
backend/internal/domain/auth_unified.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package domain
|
||||
|
||||
import "github.com/golang-jwt/jwt/v5"
|
||||
|
||||
// UserType representa os diferentes tipos de usuários do sistema
|
||||
type UserType string
|
||||
|
||||
const (
|
||||
UserTypeAgency UserType = "agency_user" // Usuários das agências (admin, colaborador)
|
||||
UserTypeCustomer UserType = "customer" // Clientes do CRM
|
||||
// SUPERADMIN usa endpoint próprio /api/admin/*, não usa autenticação unificada
|
||||
)
|
||||
|
||||
// UnifiedClaims representa as claims do JWT unificado
|
||||
type UnifiedClaims struct {
|
||||
UserID string `json:"user_id"` // ID do usuário (user.id ou customer.id)
|
||||
UserType UserType `json:"user_type"` // Tipo de usuário
|
||||
TenantID string `json:"tenant_id,omitempty"` // ID do tenant (agência)
|
||||
Email string `json:"email"` // Email do usuário
|
||||
Role string `json:"role,omitempty"` // Role (para agency_user: ADMIN_AGENCIA, CLIENTE)
|
||||
AgencyRole string `json:"agency_role,omitempty"` // Agency role (owner ou collaborator)
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// UnifiedLoginRequest representa uma requisição de login unificada
|
||||
type UnifiedLoginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// UnifiedLoginResponse representa a resposta de login unificada
|
||||
type UnifiedLoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
UserType UserType `json:"user_type"`
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role,omitempty"` // Apenas para agency_user
|
||||
AgencyRole string `json:"agency_role,omitempty"` // owner ou collaborator
|
||||
TenantID string `json:"tenant_id,omitempty"` // ID do tenant
|
||||
Subdomain string `json:"subdomain,omitempty"` // Subdomínio da agência
|
||||
}
|
||||
@@ -1,31 +1,41 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CRMCustomer struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
TenantID string `json:"tenant_id" db:"tenant_id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Email string `json:"email" db:"email"`
|
||||
Phone string `json:"phone" db:"phone"`
|
||||
Company string `json:"company" db:"company"`
|
||||
Position string `json:"position" db:"position"`
|
||||
Address string `json:"address" db:"address"`
|
||||
City string `json:"city" db:"city"`
|
||||
State string `json:"state" db:"state"`
|
||||
ZipCode string `json:"zip_code" db:"zip_code"`
|
||||
Country string `json:"country" db:"country"`
|
||||
Notes string `json:"notes" db:"notes"`
|
||||
Tags []string `json:"tags" db:"tags"`
|
||||
IsActive bool `json:"is_active" db:"is_active"`
|
||||
CreatedBy string `json:"created_by" db:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
ID string `json:"id" db:"id"`
|
||||
TenantID string `json:"tenant_id" db:"tenant_id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Email string `json:"email" db:"email"`
|
||||
Phone string `json:"phone" db:"phone"`
|
||||
Company string `json:"company" db:"company"`
|
||||
Position string `json:"position" db:"position"`
|
||||
Address string `json:"address" db:"address"`
|
||||
City string `json:"city" db:"city"`
|
||||
State string `json:"state" db:"state"`
|
||||
ZipCode string `json:"zip_code" db:"zip_code"`
|
||||
Country string `json:"country" db:"country"`
|
||||
Notes string `json:"notes" db:"notes"`
|
||||
Tags []string `json:"tags" db:"tags"`
|
||||
LogoURL string `json:"logo_url" db:"logo_url"`
|
||||
IsActive bool `json:"is_active" db:"is_active"`
|
||||
CreatedBy string `json:"created_by" db:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
PasswordHash string `json:"-" db:"password_hash"`
|
||||
HasPortalAccess bool `json:"has_portal_access" db:"has_portal_access"`
|
||||
PortalLastLogin *time.Time `json:"portal_last_login,omitempty" db:"portal_last_login"`
|
||||
PortalCreatedAt *time.Time `json:"portal_created_at,omitempty" db:"portal_created_at"`
|
||||
}
|
||||
|
||||
type CRMList struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
TenantID string `json:"tenant_id" db:"tenant_id"`
|
||||
CustomerID *string `json:"customer_id" db:"customer_id"`
|
||||
FunnelID *string `json:"funnel_id" db:"funnel_id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Description string `json:"description" db:"description"`
|
||||
Color string `json:"color" db:"color"`
|
||||
@@ -49,5 +59,77 @@ type CRMCustomerWithLists struct {
|
||||
|
||||
type CRMListWithCustomers struct {
|
||||
CRMList
|
||||
CustomerCount int `json:"customer_count"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
CustomerCount int `json:"customer_count"`
|
||||
LeadCount int `json:"lead_count"`
|
||||
}
|
||||
|
||||
// ==================== LEADS ====================
|
||||
|
||||
type CRMLead struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
TenantID string `json:"tenant_id" db:"tenant_id"`
|
||||
CustomerID *string `json:"customer_id" db:"customer_id"`
|
||||
FunnelID *string `json:"funnel_id" db:"funnel_id"`
|
||||
StageID *string `json:"stage_id" db:"stage_id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Email string `json:"email" db:"email"`
|
||||
Phone string `json:"phone" db:"phone"`
|
||||
Source string `json:"source" db:"source"`
|
||||
SourceMeta json.RawMessage `json:"source_meta" db:"source_meta"`
|
||||
Status string `json:"status" db:"status"`
|
||||
Notes string `json:"notes" db:"notes"`
|
||||
Tags []string `json:"tags" db:"tags"`
|
||||
IsActive bool `json:"is_active" db:"is_active"`
|
||||
CreatedBy string `json:"created_by" db:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
type CRMFunnel struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
TenantID string `json:"tenant_id" db:"tenant_id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Description string `json:"description" db:"description"`
|
||||
IsDefault bool `json:"is_default" db:"is_default"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
type CRMFunnelStage struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
FunnelID string `json:"funnel_id" db:"funnel_id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Description string `json:"description" db:"description"`
|
||||
Color string `json:"color" db:"color"`
|
||||
OrderIndex int `json:"order_index" db:"order_index"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
type CRMFunnelWithStages struct {
|
||||
CRMFunnel
|
||||
Stages []CRMFunnelStage `json:"stages"`
|
||||
}
|
||||
|
||||
type CRMLeadList struct {
|
||||
LeadID string `json:"lead_id" db:"lead_id"`
|
||||
ListID string `json:"list_id" db:"list_id"`
|
||||
AddedAt time.Time `json:"added_at" db:"added_at"`
|
||||
AddedBy string `json:"added_by" db:"added_by"`
|
||||
}
|
||||
|
||||
type CRMLeadWithLists struct {
|
||||
CRMLead
|
||||
Lists []CRMList `json:"lists"`
|
||||
}
|
||||
|
||||
type CRMShareToken struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
TenantID string `json:"tenant_id" db:"tenant_id"`
|
||||
CustomerID string `json:"customer_id" db:"customer_id"`
|
||||
Token string `json:"token" db:"token"`
|
||||
ExpiresAt *time.Time `json:"expires_at" db:"expires_at"`
|
||||
CreatedBy string `json:"created_by" db:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
}
|
||||
|
||||
@@ -8,14 +8,17 @@ import (
|
||||
|
||||
// User represents a user in the system
|
||||
type User struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
TenantID *uuid.UUID `json:"tenant_id,omitempty" db:"tenant_id"`
|
||||
Email string `json:"email" db:"email"`
|
||||
Password string `json:"-" db:"password_hash"`
|
||||
Name string `json:"name" db:"first_name"`
|
||||
Role string `json:"role" db:"role"` // SUPERADMIN, ADMIN_AGENCIA, CLIENTE
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
TenantID *uuid.UUID `json:"tenant_id,omitempty" db:"tenant_id"`
|
||||
Email string `json:"email" db:"email"`
|
||||
Password string `json:"-" db:"password_hash"`
|
||||
Name string `json:"name" db:"first_name"`
|
||||
Role string `json:"role" db:"role"` // SUPERADMIN, ADMIN_AGENCIA, CLIENTE
|
||||
AgencyRole string `json:"agency_role" db:"agency_role"` // owner or collaborator (only for ADMIN_AGENCIA)
|
||||
CreatedBy *uuid.UUID `json:"created_by,omitempty" db:"created_by"` // Which owner created this collaborator
|
||||
CollaboratorCreatedAt *time.Time `json:"collaborator_created_at,omitempty" db:"collaborator_created_at"` // When collaborator was added
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
// CreateUserRequest represents the request to create a new user
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"aggios-app/backend/internal/domain"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
@@ -23,25 +24,34 @@ func (r *CRMRepository) CreateCustomer(customer *domain.CRMCustomer) error {
|
||||
INSERT INTO crm_customers (
|
||||
id, tenant_id, name, email, phone, company, position,
|
||||
address, city, state, zip_code, country, notes, tags,
|
||||
is_active, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
is_active, created_by, logo_url
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||
RETURNING created_at, updated_at
|
||||
`
|
||||
|
||||
// Handle optional created_by field (NULL for public registrations)
|
||||
var createdBy interface{}
|
||||
if customer.CreatedBy != "" {
|
||||
createdBy = customer.CreatedBy
|
||||
} else {
|
||||
createdBy = nil
|
||||
}
|
||||
|
||||
return r.db.QueryRow(
|
||||
query,
|
||||
customer.ID, customer.TenantID, customer.Name, customer.Email, customer.Phone,
|
||||
customer.Company, customer.Position, customer.Address, customer.City, customer.State,
|
||||
customer.ZipCode, customer.Country, customer.Notes, pq.Array(customer.Tags),
|
||||
customer.IsActive, customer.CreatedBy,
|
||||
customer.IsActive, createdBy, customer.LogoURL,
|
||||
).Scan(&customer.CreatedAt, &customer.UpdatedAt)
|
||||
}
|
||||
|
||||
func (r *CRMRepository) GetCustomersByTenant(tenantID string) ([]domain.CRMCustomer, error) {
|
||||
query := `
|
||||
SELECT id, tenant_id, name, email, phone, company, position,
|
||||
address, city, state, zip_code, country, notes, tags,
|
||||
is_active, created_by, created_at, updated_at
|
||||
address, city, state, zip_code, country, notes, tags,
|
||||
is_active, COALESCE(created_by::text, '') AS created_by, created_at, updated_at,
|
||||
COALESCE(logo_url, '') as logo_url
|
||||
FROM crm_customers
|
||||
WHERE tenant_id = $1 AND is_active = true
|
||||
ORDER BY created_at DESC
|
||||
@@ -59,7 +69,7 @@ func (r *CRMRepository) GetCustomersByTenant(tenantID string) ([]domain.CRMCusto
|
||||
err := rows.Scan(
|
||||
&c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position,
|
||||
&c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags),
|
||||
&c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt,
|
||||
&c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, &c.LogoURL,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -73,8 +83,9 @@ func (r *CRMRepository) GetCustomersByTenant(tenantID string) ([]domain.CRMCusto
|
||||
func (r *CRMRepository) GetCustomerByID(id string, tenantID string) (*domain.CRMCustomer, error) {
|
||||
query := `
|
||||
SELECT id, tenant_id, name, email, phone, company, position,
|
||||
address, city, state, zip_code, country, notes, tags,
|
||||
is_active, created_by, created_at, updated_at
|
||||
address, city, state, zip_code, country, notes, tags,
|
||||
is_active, COALESCE(created_by::text, '') AS created_by, created_at, updated_at,
|
||||
COALESCE(logo_url, '') as logo_url
|
||||
FROM crm_customers
|
||||
WHERE id = $1 AND tenant_id = $2
|
||||
`
|
||||
@@ -83,7 +94,7 @@ func (r *CRMRepository) GetCustomerByID(id string, tenantID string) (*domain.CRM
|
||||
err := r.db.QueryRow(query, id, tenantID).Scan(
|
||||
&c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position,
|
||||
&c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags),
|
||||
&c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt,
|
||||
&c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, &c.LogoURL,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -98,15 +109,15 @@ func (r *CRMRepository) UpdateCustomer(customer *domain.CRMCustomer) error {
|
||||
UPDATE crm_customers SET
|
||||
name = $1, email = $2, phone = $3, company = $4, position = $5,
|
||||
address = $6, city = $7, state = $8, zip_code = $9, country = $10,
|
||||
notes = $11, tags = $12, is_active = $13
|
||||
WHERE id = $14 AND tenant_id = $15
|
||||
notes = $11, tags = $12, is_active = $13, logo_url = $14
|
||||
WHERE id = $15 AND tenant_id = $16
|
||||
`
|
||||
|
||||
result, err := r.db.Exec(
|
||||
query,
|
||||
customer.Name, customer.Email, customer.Phone, customer.Company, customer.Position,
|
||||
customer.Address, customer.City, customer.State, customer.ZipCode, customer.Country,
|
||||
customer.Notes, pq.Array(customer.Tags), customer.IsActive,
|
||||
customer.Notes, pq.Array(customer.Tags), customer.IsActive, customer.LogoURL,
|
||||
customer.ID, customer.TenantID,
|
||||
)
|
||||
|
||||
@@ -150,26 +161,27 @@ func (r *CRMRepository) DeleteCustomer(id string, tenantID string) error {
|
||||
|
||||
func (r *CRMRepository) CreateList(list *domain.CRMList) error {
|
||||
query := `
|
||||
INSERT INTO crm_lists (id, tenant_id, name, description, color, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
INSERT INTO crm_lists (id, tenant_id, customer_id, funnel_id, name, description, color, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING created_at, updated_at
|
||||
`
|
||||
|
||||
return r.db.QueryRow(
|
||||
query,
|
||||
list.ID, list.TenantID, list.Name, list.Description, list.Color, list.CreatedBy,
|
||||
list.ID, list.TenantID, list.CustomerID, list.FunnelID, list.Name, list.Description, list.Color, list.CreatedBy,
|
||||
).Scan(&list.CreatedAt, &list.UpdatedAt)
|
||||
}
|
||||
|
||||
func (r *CRMRepository) GetListsByTenant(tenantID string) ([]domain.CRMListWithCustomers, error) {
|
||||
query := `
|
||||
SELECT l.id, l.tenant_id, l.name, l.description, l.color, l.created_by,
|
||||
SELECT l.id, l.tenant_id, l.customer_id, l.funnel_id, l.name, l.description, l.color, l.created_by,
|
||||
l.created_at, l.updated_at,
|
||||
COUNT(cl.customer_id) as customer_count
|
||||
COALESCE(c.name, '') as customer_name,
|
||||
(SELECT COUNT(*) FROM crm_customer_lists cl WHERE cl.list_id = l.id) as customer_count,
|
||||
(SELECT COUNT(*) FROM crm_lead_lists ll WHERE ll.list_id = l.id) as lead_count
|
||||
FROM crm_lists l
|
||||
LEFT JOIN crm_customer_lists cl ON l.id = cl.list_id
|
||||
LEFT JOIN crm_customers c ON l.customer_id = c.id
|
||||
WHERE l.tenant_id = $1
|
||||
GROUP BY l.id
|
||||
ORDER BY l.created_at DESC
|
||||
`
|
||||
|
||||
@@ -183,8 +195,8 @@ func (r *CRMRepository) GetListsByTenant(tenantID string) ([]domain.CRMListWithC
|
||||
for rows.Next() {
|
||||
var l domain.CRMListWithCustomers
|
||||
err := rows.Scan(
|
||||
&l.ID, &l.TenantID, &l.Name, &l.Description, &l.Color, &l.CreatedBy,
|
||||
&l.CreatedAt, &l.UpdatedAt, &l.CustomerCount,
|
||||
&l.ID, &l.TenantID, &l.CustomerID, &l.FunnelID, &l.Name, &l.Description, &l.Color, &l.CreatedBy,
|
||||
&l.CreatedAt, &l.UpdatedAt, &l.CustomerName, &l.CustomerCount, &l.LeadCount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -197,14 +209,14 @@ func (r *CRMRepository) GetListsByTenant(tenantID string) ([]domain.CRMListWithC
|
||||
|
||||
func (r *CRMRepository) GetListByID(id string, tenantID string) (*domain.CRMList, error) {
|
||||
query := `
|
||||
SELECT id, tenant_id, name, description, color, created_by, created_at, updated_at
|
||||
SELECT id, tenant_id, customer_id, funnel_id, name, description, color, created_by, created_at, updated_at
|
||||
FROM crm_lists
|
||||
WHERE id = $1 AND tenant_id = $2
|
||||
`
|
||||
|
||||
var l domain.CRMList
|
||||
err := r.db.QueryRow(query, id, tenantID).Scan(
|
||||
&l.ID, &l.TenantID, &l.Name, &l.Description, &l.Color, &l.CreatedBy,
|
||||
&l.ID, &l.TenantID, &l.CustomerID, &l.FunnelID, &l.Name, &l.Description, &l.Color, &l.CreatedBy,
|
||||
&l.CreatedAt, &l.UpdatedAt,
|
||||
)
|
||||
|
||||
@@ -218,11 +230,11 @@ func (r *CRMRepository) GetListByID(id string, tenantID string) (*domain.CRMList
|
||||
func (r *CRMRepository) UpdateList(list *domain.CRMList) error {
|
||||
query := `
|
||||
UPDATE crm_lists SET
|
||||
name = $1, description = $2, color = $3
|
||||
WHERE id = $4 AND tenant_id = $5
|
||||
name = $1, description = $2, color = $3, customer_id = $4, funnel_id = $5
|
||||
WHERE id = $6 AND tenant_id = $7
|
||||
`
|
||||
|
||||
result, err := r.db.Exec(query, list.Name, list.Description, list.Color, list.ID, list.TenantID)
|
||||
result, err := r.db.Exec(query, list.Name, list.Description, list.Color, list.CustomerID, list.FunnelID, list.ID, list.TenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -315,7 +327,8 @@ func (r *CRMRepository) GetListCustomers(listID string, tenantID string) ([]doma
|
||||
query := `
|
||||
SELECT c.id, c.tenant_id, c.name, c.email, c.phone, c.company, c.position,
|
||||
c.address, c.city, c.state, c.zip_code, c.country, c.notes, c.tags,
|
||||
c.is_active, c.created_by, c.created_at, c.updated_at
|
||||
c.is_active, c.created_by, c.created_at, c.updated_at,
|
||||
COALESCE(c.logo_url, '') as logo_url
|
||||
FROM crm_customers c
|
||||
INNER JOIN crm_customer_lists cl ON c.id = cl.customer_id
|
||||
WHERE cl.list_id = $1 AND c.tenant_id = $2 AND c.is_active = true
|
||||
@@ -334,7 +347,7 @@ func (r *CRMRepository) GetListCustomers(listID string, tenantID string) ([]doma
|
||||
err := rows.Scan(
|
||||
&c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position,
|
||||
&c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags),
|
||||
&c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt,
|
||||
&c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, &c.LogoURL,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -344,3 +357,803 @@ func (r *CRMRepository) GetListCustomers(listID string, tenantID string) ([]doma
|
||||
|
||||
return customers, nil
|
||||
}
|
||||
|
||||
// ==================== LEADS ====================
|
||||
|
||||
func (r *CRMRepository) CreateLead(lead *domain.CRMLead) error {
|
||||
query := `
|
||||
INSERT INTO crm_leads (
|
||||
id, tenant_id, customer_id, funnel_id, stage_id, name, email, phone, source, source_meta,
|
||||
status, notes, tags, is_active, created_by
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
|
||||
RETURNING created_at, updated_at
|
||||
`
|
||||
|
||||
return r.db.QueryRow(
|
||||
query,
|
||||
lead.ID, lead.TenantID, lead.CustomerID, lead.FunnelID, lead.StageID, lead.Name, lead.Email, lead.Phone,
|
||||
lead.Source, lead.SourceMeta, lead.Status, lead.Notes, pq.Array(lead.Tags),
|
||||
lead.IsActive, lead.CreatedBy,
|
||||
).Scan(&lead.CreatedAt, &lead.UpdatedAt)
|
||||
}
|
||||
func (r *CRMRepository) AddLeadToList(leadID, listID, addedBy string) error {
|
||||
query := `
|
||||
INSERT INTO crm_lead_lists (lead_id, list_id, added_by)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (lead_id, list_id) DO NOTHING
|
||||
`
|
||||
_, err := r.db.Exec(query, leadID, listID, addedBy)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *CRMRepository) BulkAddLeadsToList(leadIDs []string, listID string, addedBy string) error {
|
||||
tx, err := r.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
stmt, err := tx.Prepare(pq.CopyIn("crm_lead_lists", "lead_id", "list_id", "added_by"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, leadID := range leadIDs {
|
||||
_, err = stmt.Exec(leadID, listID, addedBy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, err = stmt.Exec()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (r *CRMRepository) BulkCreateLeads(leads []domain.CRMLead) error {
|
||||
tx, err := r.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
query := `
|
||||
INSERT INTO crm_leads (
|
||||
id, tenant_id, customer_id, funnel_id, stage_id, name, email, phone, source,
|
||||
source_meta, status, notes, tags, is_active, created_by
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15
|
||||
) ON CONFLICT (tenant_id, email) DO UPDATE SET
|
||||
customer_id = COALESCE(EXCLUDED.customer_id, crm_leads.customer_id),
|
||||
funnel_id = COALESCE(EXCLUDED.funnel_id, crm_leads.funnel_id),
|
||||
stage_id = COALESCE(EXCLUDED.stage_id, crm_leads.stage_id),
|
||||
name = COALESCE(EXCLUDED.name, crm_leads.name),
|
||||
phone = COALESCE(EXCLUDED.phone, crm_leads.phone),
|
||||
source = EXCLUDED.source,
|
||||
source_meta = EXCLUDED.source_meta,
|
||||
status = EXCLUDED.status,
|
||||
notes = COALESCE(EXCLUDED.notes, crm_leads.notes),
|
||||
tags = EXCLUDED.tags,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
stmt, err := tx.Prepare(query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for i := range leads {
|
||||
var returnedID string
|
||||
err = stmt.QueryRow(
|
||||
leads[i].ID, leads[i].TenantID, leads[i].CustomerID, leads[i].FunnelID, leads[i].StageID, leads[i].Name, leads[i].Email, leads[i].Phone,
|
||||
leads[i].Source, string(leads[i].SourceMeta), leads[i].Status, leads[i].Notes, pq.Array(leads[i].Tags),
|
||||
leads[i].IsActive, leads[i].CreatedBy,
|
||||
).Scan(&returnedID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Atualiza o ID do lead com o ID retornado (pode ser diferente em caso de conflito)
|
||||
leads[i].ID = returnedID
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (r *CRMRepository) GetLeadsByTenant(tenantID string) ([]domain.CRMLead, error) {
|
||||
query := `
|
||||
SELECT id, tenant_id, customer_id, funnel_id, stage_id, name, email, phone, source, source_meta,
|
||||
status, COALESCE(notes, ''), tags, is_active, created_by, created_at, updated_at
|
||||
FROM crm_leads
|
||||
WHERE tenant_id = $1 AND is_active = true
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
rows, err := r.db.Query(query, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var leads []domain.CRMLead
|
||||
for rows.Next() {
|
||||
var l domain.CRMLead
|
||||
err := rows.Scan(
|
||||
&l.ID, &l.TenantID, &l.CustomerID, &l.FunnelID, &l.StageID, &l.Name, &l.Email, &l.Phone, &l.Source, &l.SourceMeta,
|
||||
&l.Status, &l.Notes, pq.Array(&l.Tags), &l.IsActive, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Error scanning lead: %v", err)
|
||||
continue
|
||||
}
|
||||
leads = append(leads, l)
|
||||
}
|
||||
|
||||
return leads, nil
|
||||
}
|
||||
|
||||
func (r *CRMRepository) GetLeadsWithListsByTenant(tenantID string) ([]domain.CRMLeadWithLists, error) {
|
||||
leads, err := r.GetLeadsByTenant(tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var leadsWithLists []domain.CRMLeadWithLists
|
||||
for _, l := range leads {
|
||||
lists, err := r.GetListsByLeadID(l.ID)
|
||||
if err != nil {
|
||||
lists = []domain.CRMList{}
|
||||
}
|
||||
leadsWithLists = append(leadsWithLists, domain.CRMLeadWithLists{
|
||||
CRMLead: l,
|
||||
Lists: lists,
|
||||
})
|
||||
}
|
||||
|
||||
return leadsWithLists, nil
|
||||
}
|
||||
|
||||
func (r *CRMRepository) GetListsByLeadID(leadID string) ([]domain.CRMList, error) {
|
||||
query := `
|
||||
SELECT l.id, l.tenant_id, l.customer_id, l.name, l.description, l.color, l.created_by, l.created_at, l.updated_at
|
||||
FROM crm_lists l
|
||||
JOIN crm_lead_lists cll ON l.id = cll.list_id
|
||||
WHERE cll.lead_id = $1
|
||||
`
|
||||
|
||||
rows, err := r.db.Query(query, leadID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var lists []domain.CRMList
|
||||
for rows.Next() {
|
||||
var l domain.CRMList
|
||||
err := rows.Scan(
|
||||
&l.ID, &l.TenantID, &l.CustomerID, &l.Name, &l.Description, &l.Color, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lists = append(lists, l)
|
||||
}
|
||||
|
||||
return lists, nil
|
||||
}
|
||||
|
||||
func (r *CRMRepository) GetLeadByID(id string, tenantID string) (*domain.CRMLead, error) {
|
||||
query := `
|
||||
SELECT id, tenant_id, customer_id, funnel_id, stage_id, name, email, phone, source, source_meta,
|
||||
status, COALESCE(notes, ''), tags, is_active, created_by, created_at, updated_at
|
||||
FROM crm_leads
|
||||
WHERE id = $1 AND tenant_id = $2
|
||||
`
|
||||
|
||||
var l domain.CRMLead
|
||||
err := r.db.QueryRow(query, id, tenantID).Scan(
|
||||
&l.ID, &l.TenantID, &l.CustomerID, &l.FunnelID, &l.StageID, &l.Name, &l.Email, &l.Phone, &l.Source, &l.SourceMeta,
|
||||
&l.Status, &l.Notes, pq.Array(&l.Tags), &l.IsActive, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
func (r *CRMRepository) UpdateLead(lead *domain.CRMLead) error {
|
||||
query := `
|
||||
UPDATE crm_leads SET
|
||||
customer_id = $1,
|
||||
funnel_id = $2,
|
||||
stage_id = $3,
|
||||
name = $4,
|
||||
email = $5,
|
||||
phone = $6,
|
||||
source = $7,
|
||||
source_meta = $8,
|
||||
status = $9,
|
||||
notes = $10,
|
||||
tags = $11,
|
||||
is_active = $12
|
||||
WHERE id = $13 AND tenant_id = $14
|
||||
`
|
||||
|
||||
result, err := r.db.Exec(
|
||||
query,
|
||||
lead.CustomerID, lead.FunnelID, lead.StageID, lead.Name, lead.Email, lead.Phone, lead.Source, lead.SourceMeta,
|
||||
lead.Status, lead.Notes, pq.Array(lead.Tags), lead.IsActive,
|
||||
lead.ID, lead.TenantID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("lead not found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *CRMRepository) DeleteLead(id string, tenantID string) error {
|
||||
query := `DELETE FROM crm_leads WHERE id = $1 AND tenant_id = $2`
|
||||
result, err := r.db.Exec(query, id, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("lead not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *CRMRepository) GetLeadByEmailOrPhone(tenantID, email, phone string) (*domain.CRMLead, error) {
|
||||
query := `
|
||||
SELECT id, tenant_id, customer_id, name, email, phone, source, source_meta,
|
||||
status, COALESCE(notes, ''), tags, is_active, created_by, created_at, updated_at
|
||||
FROM crm_leads
|
||||
WHERE tenant_id = $1
|
||||
AND (
|
||||
(email IS NOT NULL AND $2 <> '' AND LOWER(email) = LOWER($2))
|
||||
OR (phone IS NOT NULL AND $3 <> '' AND phone = $3)
|
||||
)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
var l domain.CRMLead
|
||||
err := r.db.QueryRow(query, tenantID, email, phone).Scan(
|
||||
&l.ID, &l.TenantID, &l.CustomerID, &l.Name, &l.Email, &l.Phone, &l.Source, &l.SourceMeta,
|
||||
&l.Status, &l.Notes, pq.Array(&l.Tags), &l.IsActive, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
func (r *CRMRepository) RemoveLeadFromList(leadID, listID string) error {
|
||||
query := `DELETE FROM crm_lead_lists WHERE lead_id = $1 AND list_id = $2`
|
||||
_, err := r.db.Exec(query, leadID, listID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *CRMRepository) GetLeadLists(leadID string) ([]domain.CRMList, error) {
|
||||
query := `
|
||||
SELECT l.id, l.tenant_id, l.name, COALESCE(l.description, ''), l.color, l.created_by,
|
||||
l.created_at, l.updated_at
|
||||
FROM crm_lists l
|
||||
INNER JOIN crm_lead_lists ll ON l.id = ll.list_id
|
||||
WHERE ll.lead_id = $1
|
||||
ORDER BY l.name
|
||||
`
|
||||
|
||||
rows, err := r.db.Query(query, leadID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var lists []domain.CRMList
|
||||
for rows.Next() {
|
||||
var l domain.CRMList
|
||||
err := rows.Scan(
|
||||
&l.ID, &l.TenantID, &l.Name, &l.Description, &l.Color, &l.CreatedBy,
|
||||
&l.CreatedAt, &l.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lists = append(lists, l)
|
||||
}
|
||||
|
||||
return lists, nil
|
||||
}
|
||||
|
||||
func (r *CRMRepository) GetListByName(tenantID, name string) (*domain.CRMList, error) {
|
||||
query := `
|
||||
SELECT id, tenant_id, name, description, color, created_by, created_at, updated_at
|
||||
FROM crm_lists
|
||||
WHERE tenant_id = $1 AND LOWER(name) = LOWER($2)
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
var l domain.CRMList
|
||||
err := r.db.QueryRow(query, tenantID, name).Scan(
|
||||
&l.ID, &l.TenantID, &l.Name, &l.Description, &l.Color, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
// CreateShareToken cria um novo token de compartilhamento
|
||||
func (r *CRMRepository) CreateShareToken(token *domain.CRMShareToken) error {
|
||||
query := `
|
||||
INSERT INTO crm_share_tokens (id, tenant_id, customer_id, token, expires_at, created_by, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`
|
||||
_, err := r.db.Exec(query, token.ID, token.TenantID, token.CustomerID, token.Token, token.ExpiresAt, token.CreatedBy, token.CreatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetShareTokenByToken busca um token de compartilhamento pelo token
|
||||
func (r *CRMRepository) GetShareTokenByToken(token string) (*domain.CRMShareToken, error) {
|
||||
query := `
|
||||
SELECT id, tenant_id, customer_id, token, expires_at, created_by, created_at
|
||||
FROM crm_share_tokens
|
||||
WHERE token = $1
|
||||
`
|
||||
|
||||
var st domain.CRMShareToken
|
||||
err := r.db.QueryRow(query, token).Scan(
|
||||
&st.ID, &st.TenantID, &st.CustomerID, &st.Token, &st.ExpiresAt, &st.CreatedBy, &st.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &st, nil
|
||||
}
|
||||
|
||||
// GetLeadsByCustomerID retorna todos os leads de um cliente específico
|
||||
func (r *CRMRepository) GetLeadsByCustomerID(customerID string) ([]domain.CRMLead, error) {
|
||||
query := `
|
||||
SELECT id, tenant_id, customer_id, name, email, phone, source, source_meta,
|
||||
status, notes, tags, is_active, created_by, created_at, updated_at
|
||||
FROM crm_leads
|
||||
WHERE customer_id = $1 AND is_active = true
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
rows, err := r.db.Query(query, customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var leads []domain.CRMLead
|
||||
for rows.Next() {
|
||||
var l domain.CRMLead
|
||||
err := rows.Scan(
|
||||
&l.ID, &l.TenantID, &l.CustomerID, &l.Name, &l.Email, &l.Phone, &l.Source, &l.SourceMeta,
|
||||
&l.Status, &l.Notes, &l.Tags, &l.IsActive, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
leads = append(leads, l)
|
||||
}
|
||||
|
||||
if leads == nil {
|
||||
leads = []domain.CRMLead{}
|
||||
}
|
||||
|
||||
return leads, nil
|
||||
}
|
||||
|
||||
// GetListsByCustomerID retorna todas as listas que possuem leads de um cliente específico
|
||||
func (r *CRMRepository) GetListsByCustomerID(customerID string) ([]domain.CRMList, error) {
|
||||
query := `
|
||||
SELECT DISTINCT l.id, l.tenant_id, l.name, l.description, l.color, l.created_by,
|
||||
l.created_at, l.updated_at
|
||||
FROM crm_lists l
|
||||
INNER JOIN crm_lead_lists ll ON l.id = ll.list_id
|
||||
INNER JOIN crm_leads le ON ll.lead_id = le.id
|
||||
WHERE le.customer_id = $1
|
||||
ORDER BY l.name
|
||||
`
|
||||
|
||||
rows, err := r.db.Query(query, customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var lists []domain.CRMList
|
||||
for rows.Next() {
|
||||
var l domain.CRMList
|
||||
err := rows.Scan(
|
||||
&l.ID, &l.TenantID, &l.Name, &l.Description, &l.Color, &l.CreatedBy,
|
||||
&l.CreatedAt, &l.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lists = append(lists, l)
|
||||
}
|
||||
|
||||
if lists == nil {
|
||||
lists = []domain.CRMList{}
|
||||
}
|
||||
|
||||
return lists, nil
|
||||
}
|
||||
|
||||
// GetCustomerByEmail busca um cliente pelo email
|
||||
func (r *CRMRepository) GetCustomerByEmail(email string) (*domain.CRMCustomer, error) {
|
||||
query := `
|
||||
SELECT id, tenant_id, name, email,
|
||||
COALESCE(phone, '') as phone,
|
||||
COALESCE(company, '') as company,
|
||||
COALESCE(position, '') as position,
|
||||
COALESCE(address, '') as address,
|
||||
COALESCE(city, '') as city,
|
||||
COALESCE(state, '') as state,
|
||||
COALESCE(zip_code, '') as zip_code,
|
||||
COALESCE(country, '') as country,
|
||||
COALESCE(notes, '{}') as notes,
|
||||
COALESCE(tags, '{}') as tags,
|
||||
is_active,
|
||||
created_by,
|
||||
created_at,
|
||||
updated_at,
|
||||
COALESCE(password_hash, '') as password_hash,
|
||||
has_portal_access,
|
||||
portal_last_login,
|
||||
portal_created_at
|
||||
FROM crm_customers
|
||||
WHERE email = $1 AND is_active = true
|
||||
`
|
||||
|
||||
var c domain.CRMCustomer
|
||||
var createdBy sql.NullString
|
||||
err := r.db.QueryRow(query, email).Scan(
|
||||
&c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position,
|
||||
&c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags),
|
||||
&c.IsActive, &createdBy, &c.CreatedAt, &c.UpdatedAt,
|
||||
&c.PasswordHash, &c.HasPortalAccess, &c.PortalLastLogin, &c.PortalCreatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if createdBy.Valid {
|
||||
c.CreatedBy = createdBy.String
|
||||
}
|
||||
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// UpdateCustomerLastLogin atualiza o último login do cliente no portal
|
||||
func (r *CRMRepository) UpdateCustomerLastLogin(customerID string) error {
|
||||
query := `UPDATE crm_customers SET portal_last_login = NOW() WHERE id = $1`
|
||||
_, err := r.db.Exec(query, customerID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetCustomerPortalAccess define o acesso ao portal e senha para um cliente
|
||||
func (r *CRMRepository) SetCustomerPortalAccess(customerID, passwordHash string, hasAccess bool) error {
|
||||
query := `
|
||||
UPDATE crm_customers
|
||||
SET password_hash = $1,
|
||||
has_portal_access = $2,
|
||||
portal_created_at = CASE
|
||||
WHEN portal_created_at IS NULL THEN NOW()
|
||||
ELSE portal_created_at
|
||||
END
|
||||
WHERE id = $3
|
||||
`
|
||||
_, err := r.db.Exec(query, passwordHash, hasAccess, customerID)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateCustomerPassword atualiza apenas a senha do cliente
|
||||
func (r *CRMRepository) UpdateCustomerPassword(customerID, passwordHash string) error {
|
||||
query := `
|
||||
UPDATE crm_customers
|
||||
SET password_hash = $1
|
||||
WHERE id = $2
|
||||
`
|
||||
_, err := r.db.Exec(query, passwordHash, customerID)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateCustomerLogo atualiza apenas o logo do cliente
|
||||
func (r *CRMRepository) UpdateCustomerLogo(customerID, tenantID, logoURL string) error {
|
||||
query := `
|
||||
UPDATE crm_customers
|
||||
SET logo_url = $1
|
||||
WHERE id = $2 AND tenant_id = $3
|
||||
`
|
||||
_, err := r.db.Exec(query, logoURL, customerID, tenantID)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetCustomerByEmailAndTenant checks if a customer with the given email exists for the tenant
|
||||
func (r *CRMRepository) GetCustomerByEmailAndTenant(email string, tenantID string) (*domain.CRMCustomer, error) {
|
||||
query := `
|
||||
SELECT id, tenant_id, name, email, phone, company, position,
|
||||
address, city, state, zip_code, country, notes, tags,
|
||||
is_active, created_by, created_at, updated_at
|
||||
FROM crm_customers
|
||||
WHERE LOWER(email) = LOWER($1) AND tenant_id = $2
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
var c domain.CRMCustomer
|
||||
err := r.db.QueryRow(query, email, tenantID).Scan(
|
||||
&c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position,
|
||||
&c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags),
|
||||
&c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil // Not found is not an error
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// TenantExists checks if a tenant with the given ID exists
|
||||
func (r *CRMRepository) TenantExists(tenantID string) (bool, error) {
|
||||
query := `SELECT EXISTS(SELECT 1 FROM tenants WHERE id = $1 AND is_active = true)`
|
||||
var exists bool
|
||||
err := r.db.QueryRow(query, tenantID).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
// EnableCustomerPortalAccess habilita o acesso ao portal para um cliente (usado na aprovação)
|
||||
func (r *CRMRepository) EnableCustomerPortalAccess(customerID string) error {
|
||||
query := `
|
||||
UPDATE crm_customers
|
||||
SET has_portal_access = true,
|
||||
portal_created_at = COALESCE(portal_created_at, NOW())
|
||||
WHERE id = $1
|
||||
`
|
||||
_, err := r.db.Exec(query, customerID)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetCustomerByCPF checks if a customer with the given CPF exists for the tenant
|
||||
func (r *CRMRepository) GetCustomerByCPF(cpf string, tenantID string) (*domain.CRMCustomer, error) {
|
||||
query := `
|
||||
SELECT id, tenant_id, name, email, phone, company, position,
|
||||
address, city, state, zip_code, country, notes, tags,
|
||||
is_active, created_by, created_at, updated_at
|
||||
FROM crm_customers
|
||||
WHERE tenant_id = $1 AND notes LIKE '%"cpf":"' || $2 || '"%'
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
var c domain.CRMCustomer
|
||||
err := r.db.QueryRow(query, tenantID, cpf).Scan(
|
||||
&c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position,
|
||||
&c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags),
|
||||
&c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// GetCustomerByCNPJ checks if a customer with the given CNPJ exists for the tenant
|
||||
func (r *CRMRepository) GetCustomerByCNPJ(cnpj string, tenantID string) (*domain.CRMCustomer, error) {
|
||||
query := `
|
||||
SELECT id, tenant_id, name, email, phone, company, position,
|
||||
address, city, state, zip_code, country, notes, tags,
|
||||
is_active, created_by, created_at, updated_at
|
||||
FROM crm_customers
|
||||
WHERE tenant_id = $1 AND notes LIKE '%"cnpj":"' || $2 || '"%'
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
var c domain.CRMCustomer
|
||||
err := r.db.QueryRow(query, tenantID, cnpj).Scan(
|
||||
&c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position,
|
||||
&c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags),
|
||||
&c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (r *CRMRepository) GetLeadsByListID(listID string) ([]domain.CRMLead, error) {
|
||||
query := `
|
||||
SELECT l.id, l.tenant_id, l.customer_id, l.funnel_id, l.stage_id, l.name, l.email, l.phone,
|
||||
l.source, l.source_meta, l.status, COALESCE(l.notes, ''), l.tags,
|
||||
l.is_active, COALESCE(l.created_by::text, '') as created_by, l.created_at, l.updated_at
|
||||
FROM crm_leads l
|
||||
INNER JOIN crm_lead_lists ll ON l.id = ll.lead_id
|
||||
WHERE ll.list_id = $1
|
||||
ORDER BY l.created_at DESC
|
||||
`
|
||||
|
||||
rows, err := r.db.Query(query, listID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var leads []domain.CRMLead
|
||||
for rows.Next() {
|
||||
var l domain.CRMLead
|
||||
var sourceMeta []byte
|
||||
err := rows.Scan(
|
||||
&l.ID, &l.TenantID, &l.CustomerID, &l.FunnelID, &l.StageID, &l.Name, &l.Email, &l.Phone,
|
||||
&l.Source, &sourceMeta, &l.Status, &l.Notes, pq.Array(&l.Tags),
|
||||
&l.IsActive, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Error scanning lead from list: %v", err)
|
||||
continue
|
||||
}
|
||||
if sourceMeta != nil {
|
||||
l.SourceMeta = sourceMeta
|
||||
}
|
||||
leads = append(leads, l)
|
||||
}
|
||||
|
||||
return leads, nil
|
||||
}
|
||||
|
||||
// ==================== FUNNELS & STAGES ====================
|
||||
|
||||
func (r *CRMRepository) CreateFunnel(funnel *domain.CRMFunnel) error {
|
||||
query := `
|
||||
INSERT INTO crm_funnels (id, tenant_id, name, description, is_default)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING created_at, updated_at
|
||||
`
|
||||
return r.db.QueryRow(query, funnel.ID, funnel.TenantID, funnel.Name, funnel.Description, funnel.IsDefault).
|
||||
Scan(&funnel.CreatedAt, &funnel.UpdatedAt)
|
||||
}
|
||||
|
||||
func (r *CRMRepository) GetFunnelsByTenant(tenantID string) ([]domain.CRMFunnel, error) {
|
||||
query := `SELECT id, tenant_id, name, COALESCE(description, ''), is_default, created_at, updated_at FROM crm_funnels WHERE tenant_id = $1 ORDER BY created_at ASC`
|
||||
rows, err := r.db.Query(query, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var funnels []domain.CRMFunnel
|
||||
for rows.Next() {
|
||||
var f domain.CRMFunnel
|
||||
if err := rows.Scan(&f.ID, &f.TenantID, &f.Name, &f.Description, &f.IsDefault, &f.CreatedAt, &f.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
funnels = append(funnels, f)
|
||||
}
|
||||
return funnels, nil
|
||||
}
|
||||
|
||||
func (r *CRMRepository) GetFunnelByID(id, tenantID string) (*domain.CRMFunnel, error) {
|
||||
query := `SELECT id, tenant_id, name, COALESCE(description, ''), is_default, created_at, updated_at FROM crm_funnels WHERE id = $1 AND tenant_id = $2`
|
||||
var f domain.CRMFunnel
|
||||
err := r.db.QueryRow(query, id, tenantID).Scan(&f.ID, &f.TenantID, &f.Name, &f.Description, &f.IsDefault, &f.CreatedAt, &f.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
func (r *CRMRepository) UpdateFunnel(funnel *domain.CRMFunnel) error {
|
||||
query := `UPDATE crm_funnels SET name = $1, description = $2, is_default = $3, updated_at = CURRENT_TIMESTAMP WHERE id = $4 AND tenant_id = $5`
|
||||
_, err := r.db.Exec(query, funnel.Name, funnel.Description, funnel.IsDefault, funnel.ID, funnel.TenantID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *CRMRepository) DeleteFunnel(id, tenantID string) error {
|
||||
query := `DELETE FROM crm_funnels WHERE id = $1 AND tenant_id = $2`
|
||||
_, err := r.db.Exec(query, id, tenantID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *CRMRepository) CreateFunnelStage(stage *domain.CRMFunnelStage) error {
|
||||
query := `
|
||||
INSERT INTO crm_funnel_stages (id, funnel_id, name, description, color, order_index)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING created_at, updated_at
|
||||
`
|
||||
return r.db.QueryRow(query, stage.ID, stage.FunnelID, stage.Name, stage.Description, stage.Color, stage.OrderIndex).
|
||||
Scan(&stage.CreatedAt, &stage.UpdatedAt)
|
||||
}
|
||||
|
||||
func (r *CRMRepository) GetStagesByFunnelID(funnelID string) ([]domain.CRMFunnelStage, error) {
|
||||
query := `SELECT id, funnel_id, name, COALESCE(description, ''), color, order_index, created_at, updated_at FROM crm_funnel_stages WHERE funnel_id = $1 ORDER BY order_index ASC`
|
||||
rows, err := r.db.Query(query, funnelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var stages []domain.CRMFunnelStage
|
||||
for rows.Next() {
|
||||
var s domain.CRMFunnelStage
|
||||
if err := rows.Scan(&s.ID, &s.FunnelID, &s.Name, &s.Description, &s.Color, &s.OrderIndex, &s.CreatedAt, &s.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stages = append(stages, s)
|
||||
}
|
||||
return stages, nil
|
||||
}
|
||||
|
||||
func (r *CRMRepository) UpdateFunnelStage(stage *domain.CRMFunnelStage) error {
|
||||
query := `UPDATE crm_funnel_stages SET name = $1, description = $2, color = $3, order_index = $4, updated_at = CURRENT_TIMESTAMP WHERE id = $5`
|
||||
_, err := r.db.Exec(query, stage.Name, stage.Description, stage.Color, stage.OrderIndex, stage.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *CRMRepository) DeleteFunnelStage(id string) error {
|
||||
query := `DELETE FROM crm_funnel_stages WHERE id = $1`
|
||||
_, err := r.db.Exec(query, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *CRMRepository) UpdateLeadStage(leadID, tenantID, funnelID, stageID string) error {
|
||||
query := `UPDATE crm_leads SET funnel_id = $1, stage_id = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3 AND tenant_id = $4`
|
||||
_, err := r.db.Exec(query, funnelID, stageID, leadID, tenantID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *CRMRepository) EnsureDefaultFunnel(tenantID string) (string, error) {
|
||||
// Check if tenant already has a funnel
|
||||
var funnelID string
|
||||
query := `SELECT id FROM crm_funnels WHERE tenant_id = $1 LIMIT 1`
|
||||
err := r.db.QueryRow(query, tenantID).Scan(&funnelID)
|
||||
if err == nil {
|
||||
return funnelID, nil
|
||||
}
|
||||
|
||||
// If not, create default using the function we defined in migration
|
||||
query = `SELECT create_default_crm_funnel($1)`
|
||||
err = r.db.QueryRow(query, tenantID).Scan(&funnelID)
|
||||
return funnelID, err
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -161,3 +161,73 @@ func (r *UserRepository) FindAdminByTenantID(tenantID uuid.UUID) (*domain.User,
|
||||
|
||||
return user, nil
|
||||
}
|
||||
// ListByTenantID returns all users for a tenant (excluding the tenant admin)
|
||||
func (r *UserRepository) ListByTenantID(tenantID uuid.UUID) ([]domain.User, error) {
|
||||
query := `
|
||||
SELECT id, tenant_id, email, password_hash, first_name, role, created_at, updated_at,
|
||||
agency_role, created_by, collaborator_created_at
|
||||
FROM users
|
||||
WHERE tenant_id = $1 AND is_active = true AND role != 'SUPERADMIN'
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
rows, err := r.db.Query(query, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var users []domain.User
|
||||
for rows.Next() {
|
||||
user := domain.User{}
|
||||
err := rows.Scan(
|
||||
&user.ID,
|
||||
&user.TenantID,
|
||||
&user.Email,
|
||||
&user.Password,
|
||||
&user.Name,
|
||||
&user.Role,
|
||||
&user.CreatedAt,
|
||||
&user.UpdatedAt,
|
||||
&user.AgencyRole,
|
||||
&user.CreatedBy,
|
||||
&user.CollaboratorCreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
// GetByID returns a user by ID
|
||||
func (r *UserRepository) GetByID(id uuid.UUID) (*domain.User, error) {
|
||||
return r.FindByID(id)
|
||||
}
|
||||
|
||||
// Delete marks a user as inactive
|
||||
func (r *UserRepository) Delete(id uuid.UUID) error {
|
||||
query := `
|
||||
UPDATE users
|
||||
SET is_active = false, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
result, err := r.db.Exec(query, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -24,17 +24,19 @@ var (
|
||||
|
||||
// AuthService handles authentication business logic
|
||||
type AuthService struct {
|
||||
userRepo *repository.UserRepository
|
||||
tenantRepo *repository.TenantRepository
|
||||
cfg *config.Config
|
||||
userRepo *repository.UserRepository
|
||||
tenantRepo *repository.TenantRepository
|
||||
crmRepo *repository.CRMRepository
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// NewAuthService creates a new auth service
|
||||
func NewAuthService(userRepo *repository.UserRepository, tenantRepo *repository.TenantRepository, cfg *config.Config) *AuthService {
|
||||
func NewAuthService(userRepo *repository.UserRepository, tenantRepo *repository.TenantRepository, crmRepo *repository.CRMRepository, cfg *config.Config) *AuthService {
|
||||
return &AuthService{
|
||||
userRepo: userRepo,
|
||||
tenantRepo: tenantRepo,
|
||||
cfg: cfg,
|
||||
userRepo: userRepo,
|
||||
tenantRepo: tenantRepo,
|
||||
crmRepo: crmRepo,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,3 +177,158 @@ func (s *AuthService) ChangePassword(userID string, currentPassword, newPassword
|
||||
func parseUUID(s string) (uuid.UUID, error) {
|
||||
return uuid.Parse(s)
|
||||
}
|
||||
|
||||
// GenerateCustomerToken gera um token JWT para um cliente do CRM
|
||||
func (s *AuthService) GenerateCustomerToken(customerID, tenantID, email string) (string, error) {
|
||||
claims := jwt.MapClaims{
|
||||
"customer_id": customerID,
|
||||
"tenant_id": tenantID,
|
||||
"email": email,
|
||||
"type": "customer_portal",
|
||||
"exp": time.Now().Add(time.Hour * 24 * 30).Unix(), // 30 dias
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(s.cfg.JWT.Secret))
|
||||
}
|
||||
|
||||
// UnifiedLogin autentica qualquer tipo de usuário (agência ou cliente) e retorna token unificado
|
||||
func (s *AuthService) UnifiedLogin(req domain.UnifiedLoginRequest) (*domain.UnifiedLoginResponse, error) {
|
||||
email := req.Email
|
||||
password := req.Password
|
||||
|
||||
// TENTATIVA 1: Buscar em users (agência)
|
||||
user, err := s.userRepo.FindByEmail(email)
|
||||
if err == nil && user != nil {
|
||||
// Verificar senha
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
|
||||
log.Printf("❌ Password mismatch for agency user %s", email)
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
// SUPERADMIN usa login próprio em outro domínio, não deve usar esta rota
|
||||
if user.Role == "SUPERADMIN" {
|
||||
log.Printf("🚫 SUPERADMIN attempted unified login - redirecting to proper endpoint")
|
||||
return nil, errors.New("superadmins devem usar o painel administrativo")
|
||||
}
|
||||
|
||||
// Gerar token unificado para agency_user
|
||||
token, err := s.generateUnifiedToken(user.ID.String(), domain.UserTypeAgency, email, user.Role, user.AgencyRole, user.TenantID)
|
||||
if err != nil {
|
||||
log.Printf("❌ Error generating unified token: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Buscar subdomain se tiver tenant
|
||||
subdomain := ""
|
||||
tenantID := ""
|
||||
if user.TenantID != nil {
|
||||
tenantID = user.TenantID.String()
|
||||
tenant, err := s.tenantRepo.FindByID(*user.TenantID)
|
||||
if err == nil && tenant != nil {
|
||||
subdomain = tenant.Subdomain
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("✅ Agency user logged in: %s (type=agency_user, role=%s, agency_role=%s)", email, user.Role, user.AgencyRole)
|
||||
|
||||
return &domain.UnifiedLoginResponse{
|
||||
Token: token,
|
||||
UserType: domain.UserTypeAgency,
|
||||
UserID: user.ID.String(),
|
||||
Email: email,
|
||||
Name: user.Name,
|
||||
Role: user.Role,
|
||||
AgencyRole: user.AgencyRole,
|
||||
TenantID: tenantID,
|
||||
Subdomain: subdomain,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TENTATIVA 2: Buscar em crm_customers
|
||||
log.Printf("🔍 Attempting to find customer in CRM: %s", email)
|
||||
customer, err := s.crmRepo.GetCustomerByEmail(email)
|
||||
log.Printf("🔍 CRM GetCustomerByEmail result: customer=%v, err=%v", customer != nil, err)
|
||||
if err == nil && customer != nil {
|
||||
// Verificar se tem acesso ao portal
|
||||
if !customer.HasPortalAccess {
|
||||
log.Printf("🚫 Customer %s has no portal access", email)
|
||||
return nil, errors.New("acesso ao portal não autorizado. Entre em contato com o administrador")
|
||||
}
|
||||
|
||||
// Verificar senha
|
||||
if customer.PasswordHash == "" {
|
||||
log.Printf("❌ Customer %s has no password set", email)
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(customer.PasswordHash), []byte(password)); err != nil {
|
||||
log.Printf("❌ Password mismatch for customer %s", email)
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
// Atualizar último login
|
||||
if err := s.crmRepo.UpdateCustomerLastLogin(customer.ID); err != nil {
|
||||
log.Printf("⚠️ Warning: Failed to update last login for customer %s: %v", customer.ID, err)
|
||||
}
|
||||
|
||||
// Gerar token unificado
|
||||
tenantUUID, _ := uuid.Parse(customer.TenantID)
|
||||
token, err := s.generateUnifiedToken(customer.ID, domain.UserTypeCustomer, email, "", "", &tenantUUID)
|
||||
if err != nil {
|
||||
log.Printf("❌ Error generating unified token: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Buscar subdomain do tenant
|
||||
subdomain := ""
|
||||
if tenantUUID != uuid.Nil {
|
||||
tenant, err := s.tenantRepo.FindByID(tenantUUID)
|
||||
if err == nil && tenant != nil {
|
||||
subdomain = tenant.Subdomain
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("✅ Customer logged in: %s (tenant=%s)", email, customer.TenantID)
|
||||
|
||||
return &domain.UnifiedLoginResponse{
|
||||
Token: token,
|
||||
UserType: domain.UserTypeCustomer,
|
||||
UserID: customer.ID,
|
||||
Email: email,
|
||||
Name: customer.Name,
|
||||
TenantID: customer.TenantID,
|
||||
Subdomain: subdomain,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Não encontrou em nenhuma tabela
|
||||
log.Printf("❌ User not found: %s", email)
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
// generateUnifiedToken cria um JWT com claims unificadas
|
||||
func (s *AuthService) generateUnifiedToken(userID string, userType domain.UserType, email, role, agencyRole string, tenantID *uuid.UUID) (string, error) {
|
||||
tenantIDStr := ""
|
||||
if tenantID != nil {
|
||||
tenantIDStr = tenantID.String()
|
||||
}
|
||||
|
||||
claims := domain.UnifiedClaims{
|
||||
UserID: userID,
|
||||
UserType: userType,
|
||||
TenantID: tenantIDStr,
|
||||
Email: email,
|
||||
Role: role,
|
||||
AgencyRole: agencyRole,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24 * 30)), // 30 dias
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(s.cfg.JWT.Secret))
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user