From dfb91c8ba59a4dfe86ce1eac4d4a329f37935573 Mon Sep 17 00:00:00 2001 From: Erik Silva Date: Wed, 24 Dec 2025 17:36:52 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20vers=C3=A3o=201.5=20-=20CRM=20Beta=20co?= =?UTF-8?q?m=20leads,=20funis,=20campanhas=20e=20portal=20do=20cliente?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/tasks.json | 10 + backend/cmd/server/main.go | 137 +- backend/go.mod | 1 + backend/internal/api/handlers/auth.go | 91 ++ backend/internal/api/handlers/collaborator.go | 271 ++++ backend/internal/api/handlers/crm.go | 1407 +++++++++++++++++ .../internal/api/handlers/customer_portal.go | 465 ++++++ backend/internal/api/handlers/export.go | 210 +++ backend/internal/api/handlers/tenant.go | 91 +- backend/internal/api/middleware/auth.go | 10 + .../api/middleware/collaborator_readonly.go | 44 + .../internal/api/middleware/customer_auth.go | 85 + .../internal/api/middleware/unified_auth.go | 104 ++ .../migrations/001_add_agency_roles.sql | 18 + backend/internal/domain/auth_unified.go | 42 + backend/internal/domain/crm.go | 122 +- backend/internal/domain/user.go | 19 +- backend/internal/repository/crm_repository.go | 869 +++++++++- .../internal/repository/user_repository.go | 70 + backend/internal/service/auth_service.go | 171 +- backups/.superadmin_password.txt | Bin 0 -> 62 bytes backups/aggios_backup_2025-12-13_19-56-18.sql | Bin 0 -> 18824 bytes backups/aggios_backup_2025-12-13_20-12-49.sql | Bin 0 -> 19230 bytes backups/aggios_backup_2025-12-13_20-17-59.sql | Bin 0 -> 19644 bytes backups/aggios_backup_2025-12-13_20-23-08.sql | Bin 0 -> 20986 bytes backups/aggios_backup_2025-12-14_02-42-03.sql | 343 ++++ backups/aggios_backup_2025-12-14_03-42-29.sql | 343 ++++ backups/aggios_backup_2025-12-16_15-37-28.sql | 1091 +++++++++++++ backups/aggios_backup_2025-12-17_13-26-04.sql | 1094 +++++++++++++ docs/COLABORADORES_SETUP.md | 159 ++ front-end-agency/Dockerfile | 6 + .../app/(agency)/AgencyLayoutClient.tsx | 118 +- .../app/(agency)/configuracoes/page.tsx | 15 +- .../app/(agency)/crm/campanhas/[id]/page.tsx | 624 ++++++++ .../app/(agency)/crm/campanhas/page.tsx | 622 ++++++++ .../app/(agency)/crm/clientes/page.tsx | 1203 +++++++++++--- .../app/(agency)/crm/funis/[id]/page.tsx | 426 +++++ .../app/(agency)/crm/funis/page.tsx | 467 +++++- .../app/(agency)/crm/leads/importar/page.tsx | 648 ++++++++ .../app/(agency)/crm/leads/page.tsx | 1287 +++++++++++++++ .../app/(agency)/crm/listas/page.tsx | 432 ----- front-end-agency/app/(agency)/crm/page.tsx | 297 ++-- .../app/api/agency/branding/route.ts | 55 + .../crm/customers/[id]/portal-access/route.ts | 42 + .../app/api/crm/customers/[id]/route.ts | 126 ++ .../app/api/crm/customers/route.ts | 66 + .../app/api/portal/change-password/route.ts | 48 + .../app/api/portal/dashboard/route.ts | 34 + .../app/api/portal/leads/route.ts | 34 + .../app/api/portal/login/route.ts | 30 + .../app/api/portal/profile/route.ts | 36 + .../app/api/portal/register/route.ts | 125 ++ .../app/cliente/(portal)/dashboard/page.tsx | 272 ++++ .../app/cliente/(portal)/layout.tsx | 73 + .../app/cliente/(portal)/leads/page.tsx | 193 +++ .../app/cliente/(portal)/listas/page.tsx | 138 ++ .../app/cliente/(portal)/perfil/page.tsx | 404 +++++ .../app/cliente/cadastro/cadastro-client.tsx | 1087 +++++++++++++ .../app/cliente/cadastro/page.tsx | 8 + .../app/cliente/cadastro/sucesso/page.tsx | 49 + .../cadastro/sucesso/sucesso-client.tsx | 218 +++ front-end-agency/app/login/page.tsx | 46 +- .../app/share/leads/[token]/page.tsx | 310 ++++ .../components/auth/AuthGuard.tsx | 33 +- .../components/crm/CRMCustomerFilter.tsx | 226 +++ .../components/crm/KanbanBoard.tsx | 545 +++++++ .../components/form/SearchableSelect.tsx | 149 ++ .../components/layout/DashboardLayout.tsx | 4 +- .../components/layout/MobileBottomBar.tsx | 155 +- front-end-agency/components/layout/Modal.tsx | 79 + .../components/layout/Pagination.tsx | 108 ++ .../components/layout/SidebarRail.tsx | 2 +- front-end-agency/components/layout/TopBar.tsx | 27 +- .../components/team/TeamManagement.tsx | 570 +++++++ .../components/ui/CommandPalette.tsx | 48 +- .../contexts/CRMFilterContext.tsx | 51 + front-end-agency/lib/auth.ts | 1 + front-end-agency/lib/branding.ts | 49 + front-end-agency/lib/register-customer.ts | 180 +++ front-end-agency/next.config.ts | 4 + front-end-agency/package-lock.json | 22 +- front-end-agency/package.json | 4 +- .../public/uploads/logos/.gitkeep | 1 + postgres/migrations/015_create_crm_leads.sql | 70 + .../migrations/016_add_customer_to_leads.sql | 11 + .../017_create_crm_share_tokens.sql | 20 + postgres/migrations/018_add_customer_auth.sql | 21 + .../019_add_customer_to_campaigns.sql | 9 + .../migrations/020_create_crm_funnels.sql | 65 + .../021_link_funnels_to_campaigns.sql | 8 + postgres/setup_owner_role.sql | 26 + scripts/README.md | 137 -- scripts/backup-db.ps1 | 32 - scripts/rebuild-safe.ps1 | 37 - scripts/reset-superadmin-password.ps1 | 94 -- scripts/restore-db.ps1 | 33 - scripts/setup-backup-agendado.ps1 | 38 - setup-hosts.ps1 | 55 - 98 files changed, 18255 insertions(+), 1465 deletions(-) create mode 100644 .vscode/tasks.json create mode 100644 backend/internal/api/handlers/collaborator.go create mode 100644 backend/internal/api/handlers/customer_portal.go create mode 100644 backend/internal/api/handlers/export.go create mode 100644 backend/internal/api/middleware/collaborator_readonly.go create mode 100644 backend/internal/api/middleware/customer_auth.go create mode 100644 backend/internal/api/middleware/unified_auth.go create mode 100644 backend/internal/data/postgres/migrations/001_add_agency_roles.sql create mode 100644 backend/internal/domain/auth_unified.go create mode 100644 backups/.superadmin_password.txt create mode 100644 backups/aggios_backup_2025-12-13_19-56-18.sql create mode 100644 backups/aggios_backup_2025-12-13_20-12-49.sql create mode 100644 backups/aggios_backup_2025-12-13_20-17-59.sql create mode 100644 backups/aggios_backup_2025-12-13_20-23-08.sql create mode 100644 backups/aggios_backup_2025-12-14_02-42-03.sql create mode 100644 backups/aggios_backup_2025-12-14_03-42-29.sql create mode 100644 backups/aggios_backup_2025-12-16_15-37-28.sql create mode 100644 backups/aggios_backup_2025-12-17_13-26-04.sql create mode 100644 docs/COLABORADORES_SETUP.md create mode 100644 front-end-agency/app/(agency)/crm/campanhas/[id]/page.tsx create mode 100644 front-end-agency/app/(agency)/crm/campanhas/page.tsx create mode 100644 front-end-agency/app/(agency)/crm/funis/[id]/page.tsx create mode 100644 front-end-agency/app/(agency)/crm/leads/importar/page.tsx create mode 100644 front-end-agency/app/(agency)/crm/leads/page.tsx delete mode 100644 front-end-agency/app/(agency)/crm/listas/page.tsx create mode 100644 front-end-agency/app/api/agency/branding/route.ts create mode 100644 front-end-agency/app/api/crm/customers/[id]/portal-access/route.ts create mode 100644 front-end-agency/app/api/crm/customers/[id]/route.ts create mode 100644 front-end-agency/app/api/crm/customers/route.ts create mode 100644 front-end-agency/app/api/portal/change-password/route.ts create mode 100644 front-end-agency/app/api/portal/dashboard/route.ts create mode 100644 front-end-agency/app/api/portal/leads/route.ts create mode 100644 front-end-agency/app/api/portal/login/route.ts create mode 100644 front-end-agency/app/api/portal/profile/route.ts create mode 100644 front-end-agency/app/api/portal/register/route.ts create mode 100644 front-end-agency/app/cliente/(portal)/dashboard/page.tsx create mode 100644 front-end-agency/app/cliente/(portal)/layout.tsx create mode 100644 front-end-agency/app/cliente/(portal)/leads/page.tsx create mode 100644 front-end-agency/app/cliente/(portal)/listas/page.tsx create mode 100644 front-end-agency/app/cliente/(portal)/perfil/page.tsx create mode 100644 front-end-agency/app/cliente/cadastro/cadastro-client.tsx create mode 100644 front-end-agency/app/cliente/cadastro/page.tsx create mode 100644 front-end-agency/app/cliente/cadastro/sucesso/page.tsx create mode 100644 front-end-agency/app/cliente/cadastro/sucesso/sucesso-client.tsx create mode 100644 front-end-agency/app/share/leads/[token]/page.tsx create mode 100644 front-end-agency/components/crm/CRMCustomerFilter.tsx create mode 100644 front-end-agency/components/crm/KanbanBoard.tsx create mode 100644 front-end-agency/components/form/SearchableSelect.tsx create mode 100644 front-end-agency/components/layout/Modal.tsx create mode 100644 front-end-agency/components/layout/Pagination.tsx create mode 100644 front-end-agency/components/team/TeamManagement.tsx create mode 100644 front-end-agency/contexts/CRMFilterContext.tsx create mode 100644 front-end-agency/lib/branding.ts create mode 100644 front-end-agency/lib/register-customer.ts create mode 100644 front-end-agency/public/uploads/logos/.gitkeep create mode 100644 postgres/migrations/015_create_crm_leads.sql create mode 100644 postgres/migrations/016_add_customer_to_leads.sql create mode 100644 postgres/migrations/017_create_crm_share_tokens.sql create mode 100644 postgres/migrations/018_add_customer_auth.sql create mode 100644 postgres/migrations/019_add_customer_to_campaigns.sql create mode 100644 postgres/migrations/020_create_crm_funnels.sql create mode 100644 postgres/migrations/021_link_funnels_to_campaigns.sql create mode 100644 postgres/setup_owner_role.sql delete mode 100644 scripts/README.md delete mode 100644 scripts/backup-db.ps1 delete mode 100644 scripts/rebuild-safe.ps1 delete mode 100644 scripts/reset-superadmin-password.ps1 delete mode 100644 scripts/restore-db.ps1 delete mode 100644 scripts/setup-backup-agendado.ps1 delete mode 100644 setup-hosts.ps1 diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..4bbe2c7 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,10 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build-agency-frontend", + "type": "shell", + "command": "docker compose build agency" + } + ] +} \ No newline at end of file diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 20aeaa7..e56d0a1 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -62,7 +62,7 @@ func main() { solutionRepo := repository.NewSolutionRepository(db) // Initialize services - authService := service.NewAuthService(userRepo, tenantRepo, cfg) + authService := service.NewAuthService(userRepo, tenantRepo, crmRepo, cfg) agencyService := service.NewAgencyService(userRepo, tenantRepo, cfg, db) tenantService := service.NewTenantService(tenantRepo, db) companyService := service.NewCompanyService(companyRepo) @@ -73,6 +73,7 @@ func main() { authHandler := handlers.NewAuthHandler(authService) agencyProfileHandler := handlers.NewAgencyHandler(tenantRepo, cfg) agencyHandler := handlers.NewAgencyRegistrationHandler(agencyService, cfg) + collaboratorHandler := handlers.NewCollaboratorHandler(userRepo, agencyService) tenantHandler := handlers.NewTenantHandler(tenantService) companyHandler := handlers.NewCompanyHandler(companyService) planHandler := handlers.NewPlanHandler(planService) @@ -81,6 +82,7 @@ func main() { signupTemplateHandler := handlers.NewSignupTemplateHandler(signupTemplateRepo, userRepo, tenantRepo, agencyService) agencyTemplateHandler := handlers.NewAgencyTemplateHandler(agencyTemplateRepo, agencyService, userRepo, tenantRepo) filesHandler := handlers.NewFilesHandler(cfg) + customerPortalHandler := handlers.NewCustomerPortalHandler(crmRepo, authService, cfg) // Initialize upload handler uploadHandler, err := handlers.NewUploadHandler(cfg) @@ -112,7 +114,8 @@ func main() { router.HandleFunc("/api/health", healthHandler.Check) // Auth - router.HandleFunc("/api/auth/login", authHandler.Login) + router.HandleFunc("/api/auth/login", authHandler.UnifiedLogin) // Nova rota unificada + router.HandleFunc("/api/auth/login/legacy", authHandler.Login) // Antiga rota (deprecada) router.HandleFunc("/api/auth/register", agencyHandler.PublicRegister).Methods("POST") // Public agency template registration (for creating new agencies) @@ -133,6 +136,13 @@ func main() { // Tenant check (public) router.HandleFunc("/api/tenant/check", tenantHandler.CheckExists).Methods("GET") router.HandleFunc("/api/tenant/config", tenantHandler.GetPublicConfig).Methods("GET") + router.HandleFunc("/api/tenants/{id}/profile", tenantHandler.GetProfile).Methods("GET") + + // Tenant branding (protected - used by both agency and customer portal) + router.Handle("/api/tenant/branding", middleware.RequireAnyAuthenticated(cfg)(http.HandlerFunc(tenantHandler.GetBranding))).Methods("GET") + + // Public customer registration (for agency portal signup) + router.HandleFunc("/api/public/customers/register", crmHandler.PublicRegisterCustomer).Methods("POST") // Hash generator (dev only - remove in production) router.HandleFunc("/api/hash", handlers.GenerateHash).Methods("POST") @@ -239,6 +249,9 @@ func main() { // Tenant solutions (which solutions the tenant has access to) router.Handle("/api/tenant/solutions", authMiddleware(http.HandlerFunc(solutionHandler.GetTenantSolutions))).Methods("GET") + // Dashboard + router.Handle("/api/crm/dashboard", authMiddleware(http.HandlerFunc(crmHandler.GetDashboard))).Methods("GET") + // Customers router.Handle("/api/crm/customers", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { @@ -280,6 +293,8 @@ func main() { crmHandler.DeleteList(w, r) } }))).Methods("GET", "PUT", "PATCH", "DELETE") + + router.Handle("/api/crm/lists/{id}/leads", authMiddleware(http.HandlerFunc(crmHandler.GetLeadsByList))).Methods("GET") // Customer <-> List relationship router.Handle("/api/crm/customers/{customer_id}/lists/{list_id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -291,6 +306,124 @@ func main() { } }))).Methods("POST", "DELETE") + // Leads + router.Handle("/api/crm/leads", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + crmHandler.GetLeads(w, r) + case http.MethodPost: + crmHandler.CreateLead(w, r) + } + }))).Methods("GET", "POST") + + router.Handle("/api/crm/leads/export", authMiddleware(http.HandlerFunc(crmHandler.ExportLeads))).Methods("GET") + router.Handle("/api/crm/leads/import", authMiddleware(http.HandlerFunc(crmHandler.ImportLeads))).Methods("POST") + router.Handle("/api/crm/leads/{leadId}/stage", authMiddleware(http.HandlerFunc(crmHandler.UpdateLeadStage))).Methods("PUT") + + router.Handle("/api/crm/leads/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + crmHandler.GetLead(w, r) + case http.MethodPut, http.MethodPatch: + crmHandler.UpdateLead(w, r) + case http.MethodDelete: + crmHandler.DeleteLead(w, r) + } + }))).Methods("GET", "PUT", "PATCH", "DELETE") + + // Funnels & Stages + router.Handle("/api/crm/funnels", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + crmHandler.ListFunnels(w, r) + case http.MethodPost: + crmHandler.CreateFunnel(w, r) + } + }))).Methods("GET", "POST") + + router.Handle("/api/crm/funnels/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + crmHandler.GetFunnel(w, r) + case http.MethodPut: + crmHandler.UpdateFunnel(w, r) + case http.MethodDelete: + crmHandler.DeleteFunnel(w, r) + } + }))).Methods("GET", "PUT", "DELETE") + + router.Handle("/api/crm/funnels/{funnelId}/stages", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + crmHandler.ListStages(w, r) + case http.MethodPost: + crmHandler.CreateStage(w, r) + } + }))).Methods("GET", "POST") + + router.Handle("/api/crm/stages/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPut: + crmHandler.UpdateStage(w, r) + case http.MethodDelete: + crmHandler.DeleteStage(w, r) + } + }))).Methods("PUT", "DELETE") + + // Lead ingest (integrations) + router.Handle("/api/crm/leads/ingest", authMiddleware(http.HandlerFunc(crmHandler.IngestLead))).Methods("POST") + + // Share tokens (generate) + router.Handle("/api/crm/customers/share-token", authMiddleware(http.HandlerFunc(crmHandler.GenerateShareToken))).Methods("POST") + + // Share data (public endpoint - no auth required) + router.HandleFunc("/api/crm/share/{token}", crmHandler.GetSharedData).Methods("GET") + + // ==================== CUSTOMER PORTAL ==================== + // Customer portal login (public endpoint) + router.HandleFunc("/api/portal/login", customerPortalHandler.Login).Methods("POST") + + // Customer portal dashboard (requires customer auth) + router.Handle("/api/portal/dashboard", middleware.RequireCustomer(cfg)(http.HandlerFunc(customerPortalHandler.GetPortalDashboard))).Methods("GET") + + // Customer portal leads (requires customer auth) + router.Handle("/api/portal/leads", middleware.RequireCustomer(cfg)(http.HandlerFunc(customerPortalHandler.GetPortalLeads))).Methods("GET") + + // Customer portal lists (requires customer auth) + router.Handle("/api/portal/lists", middleware.RequireCustomer(cfg)(http.HandlerFunc(customerPortalHandler.GetPortalLists))).Methods("GET") + + // Customer portal profile (requires customer auth) + router.Handle("/api/portal/profile", middleware.RequireCustomer(cfg)(http.HandlerFunc(customerPortalHandler.GetPortalProfile))).Methods("GET") + + // Customer portal change password (requires customer auth) + router.Handle("/api/portal/change-password", middleware.RequireCustomer(cfg)(http.HandlerFunc(customerPortalHandler.ChangePassword))).Methods("POST") + + // Customer portal logo upload (requires customer auth) + router.Handle("/api/portal/logo", middleware.RequireCustomer(cfg)(http.HandlerFunc(customerPortalHandler.UploadLogo))).Methods("POST") + + // ==================== AGENCY COLLABORATORS ==================== + // List collaborators (requires agency auth, owner only) + router.Handle("/api/agency/collaborators", middleware.RequireAgencyUser(cfg)(http.HandlerFunc(collaboratorHandler.ListCollaborators))).Methods("GET") + + // Invite collaborator (requires agency auth, owner only) + router.Handle("/api/agency/collaborators/invite", middleware.RequireAgencyUser(cfg)(http.HandlerFunc(collaboratorHandler.InviteCollaborator))).Methods("POST") + + // Remove collaborator (requires agency auth, owner only) + router.Handle("/api/agency/collaborators/{id}", middleware.RequireAgencyUser(cfg)(http.HandlerFunc(collaboratorHandler.RemoveCollaborator))).Methods("DELETE") + + // Generate customer portal access (agency staff) + router.Handle("/api/crm/customers/{id}/portal-access", authMiddleware(http.HandlerFunc(crmHandler.GenerateCustomerPortalAccess))).Methods("POST") + + // Lead <-> List relationship + router.Handle("/api/crm/leads/{lead_id}/lists/{list_id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + crmHandler.AddLeadToList(w, r) + case http.MethodDelete: + crmHandler.RemoveLeadFromList(w, r) + } + }))).Methods("POST", "DELETE") + // Apply global middlewares: tenant -> cors -> security -> rateLimit -> router handler := tenantDetector(corsMiddleware(securityMiddleware(rateLimitMiddleware(router)))) diff --git a/backend/go.mod b/backend/go.mod index bb45a75..6f71319 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -7,5 +7,6 @@ require ( github.com/google/uuid v1.6.0 github.com/lib/pq v1.10.9 github.com/minio/minio-go/v7 v7.0.63 + github.com/xuri/excelize/v2 v2.8.1 golang.org/x/crypto v0.27.0 ) diff --git a/backend/internal/api/handlers/auth.go b/backend/internal/api/handlers/auth.go index 7e79611..10983f0 100644 --- a/backend/internal/api/handlers/auth.go +++ b/backend/internal/api/handlers/auth.go @@ -167,3 +167,94 @@ func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) { "message": "Password changed successfully", }) } + +// UnifiedLogin handles login for all user types (agency, customer, superadmin) +func (h *AuthHandler) UnifiedLogin(w http.ResponseWriter, r *http.Request) { + log.Printf("🔐 UNIFIED LOGIN HANDLER CALLED - Method: %s", r.Method) + + if r.Method != http.MethodPost { + log.Printf("❌ Method not allowed: %s", r.Method) + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + log.Printf("❌ Failed to read body: %v", err) + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + log.Printf("📥 Raw body: %s", string(bodyBytes)) + + sanitized := strings.TrimSpace(string(bodyBytes)) + var req domain.UnifiedLoginRequest + if err := json.Unmarshal([]byte(sanitized), &req); err != nil { + log.Printf("❌ JSON parse error: %v", err) + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + log.Printf("📧 Unified login attempt for email: %s", req.Email) + + response, err := h.authService.UnifiedLogin(req) + if err != nil { + log.Printf("❌ authService.UnifiedLogin error: %v", err) + if err == service.ErrInvalidCredentials || strings.Contains(err.Error(), "não autorizado") { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{ + "error": err.Error(), + }) + } else { + http.Error(w, "Internal server error", http.StatusInternalServerError) + } + return + } + + // VALIDAÇÃO DE SEGURANÇA: Verificar se o tenant corresponde ao subdomain acessado + tenantIDFromContext := "" + if ctxTenantID := r.Context().Value(middleware.TenantIDKey); ctxTenantID != nil { + tenantIDFromContext, _ = ctxTenantID.(string) + } + + // Se foi detectado um tenant no contexto E o usuário tem tenant + if tenantIDFromContext != "" && response.TenantID != "" { + if response.TenantID != tenantIDFromContext { + log.Printf("❌ LOGIN BLOCKED: User from tenant %s tried to login in tenant %s subdomain", + response.TenantID, tenantIDFromContext) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Credenciais inválidas para esta agência", + }) + return + } + log.Printf("✅ TENANT LOGIN VALIDATION PASSED: %s", response.TenantID) + } + + log.Printf("✅ Unified login successful: email=%s, type=%s, role=%s", + response.Email, response.UserType, response.Role) + + // Montar resposta compatível com frontend antigo E com novos campos + compatibleResponse := map[string]interface{}{ + "token": response.Token, + "user": map[string]interface{}{ + "id": response.UserID, + "email": response.Email, + "name": response.Name, + "role": response.Role, + "tenant_id": response.TenantID, + "user_type": response.UserType, + }, + // Campos adicionais do sistema unificado + "user_type": response.UserType, + "user_id": response.UserID, + "subdomain": response.Subdomain, + "tenant_id": response.TenantID, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(compatibleResponse) +} diff --git a/backend/internal/api/handlers/collaborator.go b/backend/internal/api/handlers/collaborator.go new file mode 100644 index 0000000..086b8b6 --- /dev/null +++ b/backend/internal/api/handlers/collaborator.go @@ -0,0 +1,271 @@ +package handlers + +import ( + "aggios-app/backend/internal/api/middleware" + "aggios-app/backend/internal/domain" + "aggios-app/backend/internal/repository" + "aggios-app/backend/internal/service" + "encoding/json" + "log" + "net/http" + "time" + + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +// CollaboratorHandler handles agency collaborator management +type CollaboratorHandler struct { + userRepo *repository.UserRepository + agencyServ *service.AgencyService +} + +// NewCollaboratorHandler creates a new collaborator handler +func NewCollaboratorHandler(userRepo *repository.UserRepository, agencyServ *service.AgencyService) *CollaboratorHandler { + return &CollaboratorHandler{ + userRepo: userRepo, + agencyServ: agencyServ, + } +} + +// AddCollaboratorRequest representa a requisição para adicionar um colaborador +type AddCollaboratorRequest struct { + Email string `json:"email"` + Name string `json:"name"` +} + +// CollaboratorResponse representa um colaborador +type CollaboratorResponse struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + AgencyRole string `json:"agency_role"` // owner ou collaborator + CreatedAt time.Time `json:"created_at"` + CollaboratorCreatedAt *time.Time `json:"collaborator_created_at,omitempty"` +} + +// ListCollaborators lista todos os colaboradores da agência (apenas owner pode ver) +func (h *CollaboratorHandler) ListCollaborators(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + ownerID, _ := r.Context().Value(middleware.UserIDKey).(string) + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + agencyRole, _ := r.Context().Value("agency_role").(string) + + // Apenas owner pode listar colaboradores + if agencyRole != "owner" { + log.Printf("❌ COLLABORATOR ACCESS BLOCKED: User %s tried to list collaborators", ownerID) + http.Error(w, "Only agency owners can manage collaborators", http.StatusForbidden) + return + } + + // Buscar todos os usuários da agência + tenantUUID := parseUUID(tenantID) + if tenantUUID == nil { + http.Error(w, "Invalid tenant ID", http.StatusBadRequest) + return + } + users, err := h.userRepo.ListByTenantID(*tenantUUID) + if err != nil { + log.Printf("Error fetching collaborators: %v", err) + http.Error(w, "Error fetching collaborators", http.StatusInternalServerError) + return + } + + // Formatar resposta + collaborators := make([]CollaboratorResponse, 0) + for _, user := range users { + collaborators = append(collaborators, CollaboratorResponse{ + ID: user.ID.String(), + Email: user.Email, + Name: user.Name, + AgencyRole: user.AgencyRole, + CreatedAt: user.CreatedAt, + CollaboratorCreatedAt: user.CollaboratorCreatedAt, + }) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "collaborators": collaborators, + }) +} + +// InviteCollaborator convida um novo colaborador para a agência (apenas owner pode fazer isso) +func (h *CollaboratorHandler) InviteCollaborator(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + ownerID, _ := r.Context().Value(middleware.UserIDKey).(string) + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + agencyRole, _ := r.Context().Value("agency_role").(string) + + // Apenas owner pode convidar colaboradores + if agencyRole != "owner" { + log.Printf("❌ COLLABORATOR INVITE BLOCKED: User %s tried to invite collaborator", ownerID) + http.Error(w, "Only agency owners can invite collaborators", http.StatusForbidden) + return + } + + var req AddCollaboratorRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Validar email + if req.Email == "" { + http.Error(w, "Email is required", http.StatusBadRequest) + return + } + + // Validar se email já existe + exists, err := h.userRepo.EmailExists(req.Email) + if err != nil { + log.Printf("Error checking email: %v", err) + http.Error(w, "Error processing request", http.StatusInternalServerError) + return + } + if exists { + http.Error(w, "Email already registered", http.StatusConflict) + return + } + + // Gerar senha temporária (8 caracteres aleatórios) + tempPassword := generateTempPassword() + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(tempPassword), bcrypt.DefaultCost) + if err != nil { + log.Printf("Error hashing password: %v", err) + http.Error(w, "Error processing request", http.StatusInternalServerError) + return + } + + // Criar novo colaborador + ownerUUID := parseUUID(ownerID) + tenantUUID := parseUUID(tenantID) + now := time.Now() + + collaborator := &domain.User{ + TenantID: tenantUUID, + Email: req.Email, + Password: string(hashedPassword), + Name: req.Name, + Role: "ADMIN_AGENCIA", + AgencyRole: "collaborator", + CreatedBy: ownerUUID, + CollaboratorCreatedAt: &now, + } + + if err := h.userRepo.Create(collaborator); err != nil { + log.Printf("Error creating collaborator: %v", err) + http.Error(w, "Error creating collaborator", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Collaborator invited successfully", + "temporary_password": tempPassword, + "collaborator": CollaboratorResponse{ + ID: collaborator.ID.String(), + Email: collaborator.Email, + Name: collaborator.Name, + AgencyRole: collaborator.AgencyRole, + CreatedAt: collaborator.CreatedAt, + CollaboratorCreatedAt: collaborator.CollaboratorCreatedAt, + }, + }) +} + +// RemoveCollaborator remove um colaborador da agência (apenas owner pode fazer isso) +func (h *CollaboratorHandler) RemoveCollaborator(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + ownerID, _ := r.Context().Value(middleware.UserIDKey).(string) + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + agencyRole, _ := r.Context().Value("agency_role").(string) + + // Apenas owner pode remover colaboradores + if agencyRole != "owner" { + log.Printf("❌ COLLABORATOR REMOVE BLOCKED: User %s tried to remove collaborator", ownerID) + http.Error(w, "Only agency owners can remove collaborators", http.StatusForbidden) + return + } + + collaboratorID := r.URL.Query().Get("id") + if collaboratorID == "" { + http.Error(w, "Collaborator ID is required", http.StatusBadRequest) + return + } + + // Converter ID para UUID + collaboratorUUID := parseUUID(collaboratorID) + if collaboratorUUID == nil { + http.Error(w, "Invalid collaborator ID", http.StatusBadRequest) + return + } + + // Buscar o colaborador + collaborator, err := h.userRepo.GetByID(*collaboratorUUID) + if err != nil { + http.Error(w, "Collaborator not found", http.StatusNotFound) + return + } + + // Verificar se o colaborador pertence à mesma agência + if collaborator.TenantID == nil || collaborator.TenantID.String() != tenantID { + http.Error(w, "Collaborator not found in this agency", http.StatusForbidden) + return + } + + // Não permitir remover o owner + if collaborator.AgencyRole == "owner" { + http.Error(w, "Cannot remove the agency owner", http.StatusBadRequest) + return + } + + // Remover colaborador + if err := h.userRepo.Delete(*collaboratorUUID); err != nil { + log.Printf("Error removing collaborator: %v", err) + http.Error(w, "Error removing collaborator", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "message": "Collaborator removed successfully", + }) +} + +// generateTempPassword gera uma senha temporária +func generateTempPassword() string { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*" + return randomString(12, charset) +} + +// randomString gera uma string aleatória +func randomString(length int, charset string) string { + b := make([]byte, length) + for i := range b { + b[i] = charset[i%len(charset)] + } + return string(b) +} + +// parseUUID converte string para UUID +func parseUUID(s string) *uuid.UUID { + u, err := uuid.Parse(s) + if err != nil { + return nil + } + return &u +} diff --git a/backend/internal/api/handlers/crm.go b/backend/internal/api/handlers/crm.go index a167179..25e4d7b 100644 --- a/backend/internal/api/handlers/crm.go +++ b/backend/internal/api/handlers/crm.go @@ -4,12 +4,19 @@ import ( "aggios-app/backend/internal/domain" "aggios-app/backend/internal/repository" "aggios-app/backend/internal/api/middleware" + "crypto/rand" + "database/sql" + "encoding/hex" "encoding/json" "log" "net/http" + "regexp" + "strings" + "time" "github.com/google/uuid" "github.com/gorilla/mux" + "golang.org/x/crypto/bcrypt" ) type CRMHandler struct { @@ -22,6 +29,258 @@ func NewCRMHandler(repo *repository.CRMRepository) *CRMHandler { // ==================== CUSTOMERS ==================== +type publicRegisterRequest struct { + domain.CRMCustomer + Password string `json:"password"` +} + +// PublicRegisterCustomer allows public registration without authentication +// SECURITY: Rate limited, validates tenant exists, checks email format, prevents duplicates +func (h *CRMHandler) PublicRegisterCustomer(w http.ResponseWriter, r *http.Request) { + var req publicRegisterRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Invalid request body", + "message": err.Error(), + }) + return + } + + customer := req.CRMCustomer + password := req.Password + + // SECURITY 0: Validar força da senha + if password == "" || len(password) < 8 { + log.Printf("⚠️ Public registration blocked: invalid password length") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "invalid_password", + "message": "A senha deve ter no mínimo 8 caracteres.", + }) + return + } + + // Validar complexidade da senha + hasUpper := false + hasLower := false + hasNumber := false + + for _, char := range password { + switch { + case char >= 'A' && char <= 'Z': + hasUpper = true + case char >= 'a' && char <= 'z': + hasLower = true + case char >= '0' && char <= '9': + hasNumber = true + } + } + + if !hasUpper || !hasLower || !hasNumber { + log.Printf("⚠️ Public registration blocked: weak password (missing character types)") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "weak_password", + "message": "A senha deve conter pelo menos uma letra maiúscula, uma minúscula e um número.", + }) + return + } + + // SECURITY 1: Validar tenant_id obrigatório + if customer.TenantID == "" { + log.Printf("⚠️ Public registration blocked: missing tenant_id") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "tenant_id is required", + }) + return + } + + // SECURITY 2: Validar que o tenant existe no banco + tenantExists, err := h.repo.TenantExists(customer.TenantID) + if err != nil { + log.Printf("❌ Error checking tenant existence: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Internal server error", + }) + return + } + if !tenantExists { + log.Printf("🚫 Public registration blocked: invalid tenant_id=%s", customer.TenantID) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Invalid agency", + }) + return + } + + // SECURITY 3: Validar campos obrigatórios + if customer.Name == "" || customer.Email == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "name and email are required", + }) + return + } + + // SECURITY 4: Validar formato de email + email := strings.TrimSpace(strings.ToLower(customer.Email)) + emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) + if !emailRegex.MatchString(email) { + log.Printf("⚠️ Public registration blocked: invalid email format=%s", email) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Invalid email format", + }) + return + } + customer.Email = email + + // SECURITY 5: Verificar se email já existe para este tenant (constraint unique_email_per_tenant) + existingCustomer, err := h.repo.GetCustomerByEmailAndTenant(email, customer.TenantID) + if err == nil && existingCustomer != nil { + log.Printf("⚠️ Public registration blocked: email already exists for tenant (tenant=%s, email=%s)", customer.TenantID, email) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + json.NewEncoder(w).Encode(map[string]string{ + "error": "duplicate_email", + "message": "Já existe uma conta cadastrada com este e-mail.", + }) + return + } + + // SECURITY 6: Verificar duplicidade de CPF/CNPJ nos notes (formato JSON) + if customer.Notes != "" { + log.Printf("🔍 Public registration: checking notes for logo/cpf/cnpj: %s", customer.Notes) + var notesData map[string]interface{} + if err := json.Unmarshal([]byte(customer.Notes), ¬esData); err == nil { + // Extrair CPF ou CNPJ + cpf, hasCPF := notesData["cpf"].(string) + cnpj, hasCNPJ := notesData["cnpj"].(string) + + // Verificar CPF duplicado + if hasCPF && cpf != "" { + existing, err := h.repo.GetCustomerByCPF(cpf, customer.TenantID) + if err == nil && existing != nil { + log.Printf("⚠️ Public registration blocked: CPF already exists (tenant=%s, cpf=%s)", customer.TenantID, cpf) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + json.NewEncoder(w).Encode(map[string]string{ + "error": "duplicate_email", + "message": "Já existe uma conta cadastrada com este CPF.", + }) + return + } + } + + // Verificar CNPJ duplicado + if hasCNPJ && cnpj != "" { + existing, err := h.repo.GetCustomerByCNPJ(cnpj, customer.TenantID) + if err == nil && existing != nil { + log.Printf("⚠️ Public registration blocked: CNPJ already exists (tenant=%s, cnpj=%s)", customer.TenantID, cnpj) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + json.NewEncoder(w).Encode(map[string]string{ + "error": "duplicate_email", + "message": "Já existe uma conta cadastrada com este CNPJ.", + }) + return + } + } + + // Extrair logo se existir + if logo, hasLogo := notesData["logo_path"].(string); hasLogo && logo != "" { + log.Printf("🖼️ Found logo in public registration notes: %s", logo) + customer.LogoURL = logo + } + } else { + log.Printf("⚠️ Failed to unmarshal public registration notes: %v", err) + } + } + + // SECURITY 7: Sanitizar nome + customer.Name = strings.TrimSpace(customer.Name) + if len(customer.Name) > 255 { + customer.Name = customer.Name[:255] + } + + // SECURITY 8: Hash da senha fornecida pelo cliente + passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + log.Printf("❌ Error hashing password: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Internal server error", + }) + return + } + + customer.ID = uuid.New().String() + customer.IsActive = true + // CreatedBy fica vazio pois é cadastro público + + // Garantir que as tags de cadastro público sejam aplicadas + if customer.Tags == nil || len(customer.Tags) == 0 { + customer.Tags = []string{"cadastro_publico", "pendente_aprovacao"} + } else { + // Garantir que tenha pelo menos a tag cadastro_publico + hasPublicTag := false + hasPendingTag := false + for _, tag := range customer.Tags { + if tag == "cadastro_publico" { + hasPublicTag = true + } + if tag == "pendente_aprovacao" { + hasPendingTag = true + } + } + if !hasPublicTag { + customer.Tags = append(customer.Tags, "cadastro_publico") + } + if !hasPendingTag { + customer.Tags = append(customer.Tags, "pendente_aprovacao") + } + } + + log.Printf("📝 Public customer registration: tenant_id=%s, email=%s, name=%s, tags=%v", customer.TenantID, email, customer.Name, customer.Tags) + + if err := h.repo.CreateCustomer(&customer); err != nil { + log.Printf("❌ Error creating public customer: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to create customer", + "message": err.Error(), + }) + return + } + + // Salvar senha hasheada + if err := h.repo.UpdateCustomerPassword(customer.ID, string(passwordHash)); err != nil { + log.Printf("⚠️ Error saving password for customer %s: %v", customer.ID, err) + // Não retornar erro pois o cliente foi criado, senha pode ser resetada depois + } + + log.Printf("✅ Public customer created successfully with password: id=%s", customer.ID) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]interface{}{ + "customer": customer, + }) +} + func (h *CRMHandler) CreateCustomer(w http.ResponseWriter, r *http.Request) { tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) userID, _ := r.Context().Value(middleware.UserIDKey).(string) @@ -51,6 +310,8 @@ func (h *CRMHandler) CreateCustomer(w http.ResponseWriter, r *http.Request) { customer.CreatedBy = userID customer.IsActive = true + log.Printf("➕ CreateCustomer called: name=%s, company=%s, logo_url=%s", customer.Name, customer.Company, customer.LogoURL) + if err := h.repo.CreateCustomer(&customer); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) @@ -61,6 +322,18 @@ func (h *CRMHandler) CreateCustomer(w http.ResponseWriter, r *http.Request) { return } + // Auto-create a default campaign for this customer + defaultCampaign := domain.CRMList{ + ID: uuid.New().String(), + TenantID: tenantID, + CustomerID: &customer.ID, + Name: "Geral - " + customer.Name, + Description: "Campanha padrão para " + customer.Name, + Color: "#3b82f6", + CreatedBy: userID, + } + _ = h.repo.CreateList(&defaultCampaign) + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]interface{}{ @@ -71,6 +344,8 @@ func (h *CRMHandler) CreateCustomer(w http.ResponseWriter, r *http.Request) { func (h *CRMHandler) GetCustomers(w http.ResponseWriter, r *http.Request) { tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + log.Printf("GetCustomers: tenantID=%s", tenantID) + if tenantID == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) @@ -81,6 +356,7 @@ func (h *CRMHandler) GetCustomers(w http.ResponseWriter, r *http.Request) { } customers, err := h.repo.GetCustomersByTenant(tenantID) + log.Printf("GetCustomers: found %d customers, error: %v", len(customers), err) if err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) @@ -169,6 +445,8 @@ func (h *CRMHandler) UpdateCustomer(w http.ResponseWriter, r *http.Request) { customer.ID = customerID customer.TenantID = tenantID + log.Printf("🔄 UpdateCustomer called for customer %s, name: %s, logo_url: %s, tags: %v", customerID, customer.Name, customer.LogoURL, customer.Tags) + if err := h.repo.UpdateCustomer(&customer); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) @@ -179,6 +457,34 @@ func (h *CRMHandler) UpdateCustomer(w http.ResponseWriter, r *http.Request) { return } + // Se as tags foram alteradas e a tag pendente_aprovacao foi removida, liberar acesso ao portal + hasPendingTag := false + for _, tag := range customer.Tags { + if tag == "pendente_aprovacao" { + hasPendingTag = true + break + } + } + + log.Printf("🔍 Checking portal access: hasPendingTag=%v", hasPendingTag) + + // Se não tem mais a tag pendente e tinha senha definida, liberar acesso + if !hasPendingTag { + existingCustomer, err := h.repo.GetCustomerByID(customerID, tenantID) + log.Printf("🔍 Existing customer check: hasPassword=%v, err=%v", existingCustomer != nil && existingCustomer.PasswordHash != "", err) + + if err == nil && existingCustomer.PasswordHash != "" { + // Liberar acesso ao portal + if err := h.repo.EnableCustomerPortalAccess(customerID); err != nil { + log.Printf("⚠️ Warning: Failed to enable portal access for customer %s: %v", customerID, err) + } else { + log.Printf("✅ Portal access enabled for customer %s", customerID) + } + } else if err == nil && existingCustomer.PasswordHash == "" { + log.Printf("⚠️ Customer %s approved but has no password yet - portal access will be enabled when password is set", customerID) + } + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ "message": "Customer updated successfully", @@ -253,6 +559,11 @@ func (h *CRMHandler) CreateList(w http.ResponseWriter, r *http.Request) { list.TenantID = tenantID list.CreatedBy = userID + // Handle empty customer_id + if list.CustomerID != nil && *list.CustomerID == "" { + list.CustomerID = nil + } + if list.Color == "" { list.Color = "#3b82f6" } @@ -286,8 +597,12 @@ func (h *CRMHandler) GetLists(w http.ResponseWriter, r *http.Request) { return } + customerID := r.URL.Query().Get("customer_id") + log.Printf("GetLists: tenantID=%s, customerID=%s", tenantID, customerID) + lists, err := h.repo.GetListsByTenant(tenantID) if err != nil { + log.Printf("GetLists: Error fetching lists: %v", err) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]string{ @@ -301,6 +616,18 @@ func (h *CRMHandler) GetLists(w http.ResponseWriter, r *http.Request) { lists = []domain.CRMListWithCustomers{} } + // Filter by customer if provided + if customerID != "" { + filteredLists := []domain.CRMListWithCustomers{} + for _, list := range lists { + if list.CustomerID != nil && *list.CustomerID == customerID { + filteredLists = append(filteredLists, list) + } + } + log.Printf("GetLists: Filtered lists from %d to %d", len(lists), len(filteredLists)) + lists = filteredLists + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "lists": lists, @@ -375,6 +702,11 @@ func (h *CRMHandler) UpdateList(w http.ResponseWriter, r *http.Request) { list.ID = listID list.TenantID = tenantID + // Handle empty customer_id + if list.CustomerID != nil && *list.CustomerID == "" { + list.CustomerID = nil + } + if err := h.repo.UpdateList(&list); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) @@ -422,6 +754,468 @@ func (h *CRMHandler) DeleteList(w http.ResponseWriter, r *http.Request) { }) } +// ==================== LEADS ==================== + +func (h *CRMHandler) CreateLead(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + userID, _ := r.Context().Value(middleware.UserIDKey).(string) + + if tenantID == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Missing tenant_id"}) + return + } + + var lead domain.CRMLead + if err := json.NewDecoder(r.Body).Decode(&lead); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Invalid request body", + "message": err.Error(), + }) + return + } + + lead.ID = uuid.New().String() + lead.TenantID = tenantID + lead.CreatedBy = userID + lead.IsActive = true + if lead.Status == "" { + lead.Status = "novo" + } + if lead.SourceMeta == nil { + lead.SourceMeta = json.RawMessage(`{}`) + } + + // Ensure default funnel and stage if not provided + if lead.FunnelID == nil || *lead.FunnelID == "" || lead.StageID == nil || *lead.StageID == "" { + funnelID, err := h.repo.EnsureDefaultFunnel(tenantID) + if err == nil { + lead.FunnelID = &funnelID + stages, err := h.repo.GetStagesByFunnelID(funnelID) + if err == nil && len(stages) > 0 { + lead.StageID = &stages[0].ID + } + } + } + + if err := h.repo.CreateLead(&lead); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to create lead", + "message": err.Error(), + }) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]interface{}{ + "lead": lead, + }) +} + +func (h *CRMHandler) GetLeads(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + if tenantID == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Missing tenant_id"}) + return + } + + customerID := r.URL.Query().Get("customer_id") + log.Printf("GetLeads: tenantID=%s, customerID=%s", tenantID, customerID) + + leads, err := h.repo.GetLeadsWithListsByTenant(tenantID) + if err != nil { + log.Printf("GetLeads: Error fetching leads: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to fetch leads", + "message": err.Error(), + }) + return + } + if leads == nil { + leads = []domain.CRMLeadWithLists{} + } + + // Filter by customer if provided + if customerID != "" { + filteredLeads := []domain.CRMLeadWithLists{} + for _, lead := range leads { + if lead.CustomerID != nil && *lead.CustomerID == customerID { + filteredLeads = append(filteredLeads, lead) + } + } + log.Printf("GetLeads: Filtered leads for customer %s: from %d to %d", customerID, len(leads), len(filteredLeads)) + leads = filteredLeads + } else { + log.Printf("GetLeads: No customer_id filter applied, returning all %d leads", len(leads)) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "leads": leads, + }) +} + +func (h *CRMHandler) GetLeadsByList(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + listID := vars["id"] + + leads, err := h.repo.GetLeadsByListID(listID) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to fetch leads", + "message": err.Error(), + }) + return + } + if leads == nil { + leads = []domain.CRMLead{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "leads": leads, + }) +} + +func (h *CRMHandler) GetLead(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + if tenantID == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Missing tenant_id"}) + return + } + + vars := mux.Vars(r) + leadID := vars["id"] + + lead, err := h.repo.GetLeadByID(leadID, tenantID) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Lead not found", + "message": err.Error(), + }) + return + } + + lists, _ := h.repo.GetLeadLists(leadID) + if lists == nil { + lists = []domain.CRMList{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "lead": lead, + "lists": lists, + }) +} + +func (h *CRMHandler) UpdateLead(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + if tenantID == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Missing tenant_id"}) + return + } + + vars := mux.Vars(r) + leadID := vars["id"] + + var lead domain.CRMLead + if err := json.NewDecoder(r.Body).Decode(&lead); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Invalid request body", + "message": err.Error(), + }) + return + } + + lead.ID = leadID + lead.TenantID = tenantID + if lead.SourceMeta == nil { + lead.SourceMeta = json.RawMessage(`{}`) + } + + if err := h.repo.UpdateLead(&lead); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to update lead", + "message": err.Error(), + }) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"message": "Lead updated successfully"}) +} + +func (h *CRMHandler) DeleteLead(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + if tenantID == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Missing tenant_id"}) + return + } + + vars := mux.Vars(r) + leadID := vars["id"] + + if err := h.repo.DeleteLead(leadID, tenantID); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to delete lead", + "message": err.Error(), + }) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"message": "Lead deleted successfully"}) +} + +func (h *CRMHandler) AddLeadToList(w http.ResponseWriter, r *http.Request) { + userID, _ := r.Context().Value(middleware.UserIDKey).(string) + vars := mux.Vars(r) + leadID := vars["lead_id"] + listID := vars["list_id"] + + if err := h.repo.AddLeadToList(leadID, listID, userID); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to add lead to list", + "message": err.Error(), + }) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"message": "Lead added to list successfully"}) +} + +func (h *CRMHandler) RemoveLeadFromList(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + leadID := vars["lead_id"] + listID := vars["list_id"] + + if err := h.repo.RemoveLeadFromList(leadID, listID); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to remove lead from list", + "message": err.Error(), + }) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"message": "Lead removed from list successfully"}) +} + +type LeadIngestRequest struct { + Name string `json:"name"` + Email string `json:"email"` + Phone string `json:"phone"` + Notes string `json:"notes"` + Tags []string `json:"tags"` + Source string `json:"source"` + SourceMeta map[string]interface{} `json:"source_meta"` + ListID string `json:"list_id"` + ListName string `json:"list_name"` + Status string `json:"status"` + CustomerID string `json:"customer_id"` +} + +func (h *CRMHandler) IngestLead(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + userID, _ := r.Context().Value(middleware.UserIDKey).(string) + if tenantID == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Missing tenant_id"}) + return + } + + var req LeadIngestRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Invalid request body", + "message": err.Error(), + }) + return + } + + if req.Email == "" && req.Phone == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Informe ao menos email ou telefone", + }) + return + } + + // Resolver list_id (opcional) + listID := req.ListID + if listID == "" && req.ListName != "" { + if existing, err := h.repo.GetListByName(tenantID, req.ListName); err == nil { + listID = existing.ID + } else if err == sql.ErrNoRows { + newList := domain.CRMList{ + ID: uuid.New().String(), + TenantID: tenantID, + Name: req.ListName, + Description: "Criada automaticamente via ingestão de leads", + Color: "#3b82f6", + CreatedBy: userID, + } + if err := h.repo.CreateList(&newList); err == nil { + listID = newList.ID + } + } + } + + // Dedup por email/phone + var existingLead *domain.CRMLead + if found, err := h.repo.GetLeadByEmailOrPhone(tenantID, req.Email, req.Phone); err == nil { + existingLead = found + } else if err != nil && err != sql.ErrNoRows { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to ingest lead", + "message": err.Error(), + }) + return + } + + // Normalizar source_meta + sourceMetaBytes, _ := json.Marshal(req.SourceMeta) + if len(sourceMetaBytes) == 0 { + sourceMetaBytes = []byte(`{}`) + } + + if req.Status == "" { + req.Status = "novo" + } + if req.Source == "" { + req.Source = "import" + } + + // Processar customer_id + var customerIDPtr *string + if req.CustomerID != "" { + customerIDPtr = &req.CustomerID + } + + if existingLead == nil { + lead := domain.CRMLead{ + ID: uuid.New().String(), + TenantID: tenantID, + CustomerID: customerIDPtr, + Name: req.Name, + Email: req.Email, + Phone: req.Phone, + Source: req.Source, + SourceMeta: json.RawMessage(sourceMetaBytes), + Status: req.Status, + Notes: req.Notes, + Tags: req.Tags, + IsActive: true, + CreatedBy: userID, + } + + if err := h.repo.CreateLead(&lead); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to ingest lead", + "message": err.Error(), + }) + return + } + + if listID != "" { + _ = h.repo.AddLeadToList(lead.ID, listID, userID) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]interface{}{ + "lead": lead, + "created": true, + "linked_list": listID != "", + }) + return + } + + // Se já existe: atualiza campos básicos se vierem preenchidos + updated := *existingLead + if customerIDPtr != nil { + updated.CustomerID = customerIDPtr + } + if updated.Name == "" && req.Name != "" { + updated.Name = req.Name + } + if updated.Email == "" && req.Email != "" { + updated.Email = req.Email + } + if updated.Phone == "" && req.Phone != "" { + updated.Phone = req.Phone + } + updated.Source = req.Source + updated.SourceMeta = json.RawMessage(sourceMetaBytes) + if updated.Status == "" { + updated.Status = req.Status + } + if req.Notes != "" { + updated.Notes = req.Notes + } + if len(req.Tags) > 0 { + updated.Tags = req.Tags + } + if err := h.repo.UpdateLead(&updated); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to update existing lead", + "message": err.Error(), + }) + return + } + if listID != "" { + _ = h.repo.AddLeadToList(updated.ID, listID, userID) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "lead": updated, + "created": false, + "linked_list": listID != "", + }) +} + // ==================== CUSTOMER <-> LIST ==================== func (h *CRMHandler) AddCustomerToList(w http.ResponseWriter, r *http.Request) { @@ -468,3 +1262,616 @@ func (h *CRMHandler) RemoveCustomerFromList(w http.ResponseWriter, r *http.Reque }) } +// GenerateShareToken gera um token de compartilhamento para visualização de leads de um cliente +func (h *CRMHandler) GenerateShareToken(w http.ResponseWriter, r *http.Request) { + var req struct { + CustomerID string `json:"customer_id"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Invalid request body"}) + return + } + + tenantID := r.Header.Get("X-Tenant-Subdomain") + if tenantID == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Tenant ID is required"}) + return + } + + userID := r.Context().Value("user_id").(string) + + // Gera token seguro + tokenBytes := make([]byte, 32) + if _, err := rand.Read(tokenBytes); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": "Failed to generate token"}) + return + } + token := hex.EncodeToString(tokenBytes) + + shareToken := domain.CRMShareToken{ + ID: uuid.New().String(), + TenantID: tenantID, + CustomerID: req.CustomerID, + Token: token, + ExpiresAt: nil, // Token sem expiração + CreatedBy: userID, + CreatedAt: time.Now(), + } + + if err := h.repo.CreateShareToken(&shareToken); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to create share token", + "message": err.Error(), + }) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "token": token, + }) +} + +// GetSharedData retorna os dados compartilhados de um cliente via token (endpoint público) +func (h *CRMHandler) GetSharedData(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + token := vars["token"] + + shareToken, err := h.repo.GetShareTokenByToken(token) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"error": "Invalid or expired token"}) + return + } + + // Verifica se o token expirou + if shareToken.ExpiresAt != nil && shareToken.ExpiresAt.Before(time.Now()) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(map[string]string{"error": "Token expired"}) + return + } + + // Busca dados do cliente + customer, err := h.repo.GetCustomerByID(shareToken.CustomerID, shareToken.TenantID) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"error": "Customer not found"}) + return + } + + // Busca leads do cliente + leads, err := h.repo.GetLeadsByCustomerID(shareToken.CustomerID) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": "Failed to fetch leads"}) + return + } + + // Calcula estatísticas + stats := calculateLeadStats(leads) + + response := map[string]interface{}{ + "customer": map[string]string{ + "name": customer.Name, + "company": customer.Company, + }, + "leads": leads, + "stats": stats, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +func calculateLeadStats(leads []domain.CRMLead) map[string]interface{} { + stats := map[string]interface{}{ + "total": len(leads), + "novo": 0, + "qualificado": 0, + "negociacao": 0, + "convertido": 0, + "perdido": 0, + "bySource": make(map[string]int), + "conversionRate": 0.0, + "thisMonth": 0, + "lastMonth": 0, + } + + if len(leads) == 0 { + return stats + } + + now := time.Now() + currentMonth := now.Month() + currentYear := now.Year() + lastMonth := now.AddDate(0, -1, 0) + + statusCount := make(map[string]int) + bySource := make(map[string]int) + thisMonthCount := 0 + lastMonthCount := 0 + + for _, lead := range leads { + // Conta por status + statusCount[lead.Status]++ + + // Conta por origem + source := lead.Source + if source == "" { + source = "manual" + } + bySource[source]++ + + // Conta por mês + if lead.CreatedAt.Month() == currentMonth && lead.CreatedAt.Year() == currentYear { + thisMonthCount++ + } + if lead.CreatedAt.Month() == lastMonth.Month() && lead.CreatedAt.Year() == lastMonth.Year() { + lastMonthCount++ + } + } + + stats["novo"] = statusCount["novo"] + stats["qualificado"] = statusCount["qualificado"] + stats["negociacao"] = statusCount["negociacao"] + stats["convertido"] = statusCount["convertido"] + stats["perdido"] = statusCount["perdido"] + stats["bySource"] = bySource + stats["thisMonth"] = thisMonthCount + stats["lastMonth"] = lastMonthCount + + // Taxa de conversão + if len(leads) > 0 { + conversionRate := (float64(statusCount["convertido"]) / float64(len(leads))) * 100 + stats["conversionRate"] = conversionRate + } + + return stats +} + +// GenerateCustomerPortalAccess gera credenciais de acesso ao portal para um cliente +func (h *CRMHandler) GenerateCustomerPortalAccess(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + if tenantID == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Missing tenant_id"}) + return + } + + vars := mux.Vars(r) + customerID := vars["id"] + + var req struct { + Password string `json:"password"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Password == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Password is required"}) + return + } + + // Verificar se cliente existe + customer, err := h.repo.GetCustomerByID(customerID, tenantID) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"error": "Customer not found"}) + return + } + + // Gerar hash da senha + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": "Failed to generate password"}) + return + } + + // Atualizar acesso ao portal + if err := h.repo.SetCustomerPortalAccess(customerID, string(hashedPassword), true); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": "Failed to set portal access"}) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Portal access granted", + "email": customer.Email, + }) +} + +// GetDashboard returns stats for the CRM dashboard +func (h *CRMHandler) GetDashboard(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + if tenantID == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Missing tenant_id"}) + return + } + + customerID := r.URL.Query().Get("customer_id") + log.Printf("GetDashboard: tenantID=%s, customerID=%s", tenantID, customerID) + + // Get all leads for stats + leads, err := h.repo.GetLeadsByTenant(tenantID) + if err != nil { + log.Printf("GetDashboard: Error fetching leads: %v", err) + leads = []domain.CRMLead{} + } + + // Get all customers for stats + customers, err := h.repo.GetCustomersByTenant(tenantID) + if err != nil { + log.Printf("GetDashboard: Error fetching customers: %v", err) + customers = []domain.CRMCustomer{} + } + + // Get all lists (campaigns) + lists, err := h.repo.GetListsByTenant(tenantID) + if err != nil { + log.Printf("GetDashboard: Error fetching lists: %v", err) + } + + // Filter by customer if provided + if customerID != "" { + filteredLeads := []domain.CRMLead{} + for _, lead := range leads { + if lead.CustomerID != nil && *lead.CustomerID == customerID { + filteredLeads = append(filteredLeads, lead) + } + } + log.Printf("GetDashboard: Filtered leads from %d to %d", len(leads), len(filteredLeads)) + leads = filteredLeads + + filteredLists := []domain.CRMListWithCustomers{} + for _, list := range lists { + if list.CustomerID != nil && *list.CustomerID == customerID { + filteredLists = append(filteredLists, list) + } + } + log.Printf("GetDashboard: Filtered lists from %d to %d", len(lists), len(filteredLists)) + lists = filteredLists + } + + stats := calculateLeadStats(leads) + stats["total_customers"] = len(customers) + if customerID != "" { + stats["total_customers"] = 1 // If filtered by customer, we only care about that one + } + stats["total_campaigns"] = len(lists) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "stats": stats, + }) +} + +// ImportLeads handles bulk lead import from JSON +func (h *CRMHandler) ImportLeads(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + userID, _ := r.Context().Value(middleware.UserIDKey).(string) + + if tenantID == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Missing tenant_id"}) + return + } + + var req struct { + CampaignID string `json:"campaign_id"` + CustomerID string `json:"customer_id"` + Leads []domain.CRMLead `json:"leads"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Printf("ImportLeads: Error decoding body: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Invalid request body"}) + return + } + + log.Printf("ImportLeads: Received %d leads for campaign %s and customer %s", len(req.Leads), req.CampaignID, req.CustomerID) + + if len(req.Leads) == 0 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "No leads provided"}) + return + } + + // Get default funnel and stage + var defaultFunnelID string + var defaultStageID string + funnelID, err := h.repo.EnsureDefaultFunnel(tenantID) + if err == nil { + defaultFunnelID = funnelID + stages, err := h.repo.GetStagesByFunnelID(funnelID) + if err == nil && len(stages) > 0 { + defaultStageID = stages[0].ID + } + } + + // Prepare leads for bulk insert + now := time.Now() + for i := range req.Leads { + if req.Leads[i].ID == "" { + req.Leads[i].ID = uuid.New().String() + } + req.Leads[i].TenantID = tenantID + req.Leads[i].CreatedBy = userID + req.Leads[i].CreatedAt = now + req.Leads[i].UpdatedAt = now + req.Leads[i].IsActive = true + if req.Leads[i].Status == "" { + req.Leads[i].Status = "novo" + } + if req.Leads[i].Source == "" { + req.Leads[i].Source = "import" + } + if len(req.Leads[i].SourceMeta) == 0 { + req.Leads[i].SourceMeta = json.RawMessage("{}") + } + + // Assign default funnel and stage if not provided + if (req.Leads[i].FunnelID == nil || *req.Leads[i].FunnelID == "") && defaultFunnelID != "" { + req.Leads[i].FunnelID = &defaultFunnelID + } + if (req.Leads[i].StageID == nil || *req.Leads[i].StageID == "") && defaultStageID != "" { + req.Leads[i].StageID = &defaultStageID + } + + log.Printf("Lead %d: SourceMeta='%s'", i, string(req.Leads[i].SourceMeta)) + // If a customer_id was provided in the request, use it for all leads + if req.CustomerID != "" { + customerID := req.CustomerID + req.Leads[i].CustomerID = &customerID + } + } + + // Bulk insert leads + if err := h.repo.BulkCreateLeads(req.Leads); err != nil { + log.Printf("ImportLeads: Error in BulkCreateLeads: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": "Failed to import leads", "details": err.Error()}) + return + } + + log.Printf("ImportLeads: Successfully created %d leads", len(req.Leads)) + + // If a campaign_id was provided, link all leads to it + if req.CampaignID != "" { + leadIDs := make([]string, len(req.Leads)) + for i, lead := range req.Leads { + leadIDs[i] = lead.ID + } + if err := h.repo.BulkAddLeadsToList(leadIDs, req.CampaignID, userID); err != nil { + log.Printf("ImportLeads: Error in BulkAddLeadsToList: %v", err) + } + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Leads imported successfully", + "count": len(req.Leads), + }) +} + +// ==================== FUNNELS & STAGES ==================== + +func (h *CRMHandler) ListFunnels(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + if tenantID == "" { + http.Error(w, "Missing tenant_id", http.StatusBadRequest) + return + } + + funnels, err := h.repo.GetFunnelsByTenant(tenantID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // If no funnels, ensure default exists + if len(funnels) == 0 { + _, err := h.repo.EnsureDefaultFunnel(tenantID) + if err == nil { + funnels, _ = h.repo.GetFunnelsByTenant(tenantID) + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"funnels": funnels}) +} + +func (h *CRMHandler) GetFunnel(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + if tenantID == "" { + http.Error(w, "Missing tenant_id", http.StatusBadRequest) + return + } + + vars := mux.Vars(r) + id := vars["id"] + + funnel, err := h.repo.GetFunnelByID(id, tenantID) + if err != nil { + http.Error(w, "Funnel not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"funnel": funnel}) +} + +func (h *CRMHandler) CreateFunnel(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + var funnel domain.CRMFunnel + if err := json.NewDecoder(r.Body).Decode(&funnel); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + funnel.ID = uuid.New().String() + funnel.TenantID = tenantID + + if err := h.repo.CreateFunnel(&funnel); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(funnel) +} + +func (h *CRMHandler) UpdateFunnel(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + vars := mux.Vars(r) + id := vars["id"] + + var funnel domain.CRMFunnel + if err := json.NewDecoder(r.Body).Decode(&funnel); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + funnel.ID = id + funnel.TenantID = tenantID + + if err := h.repo.UpdateFunnel(&funnel); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *CRMHandler) DeleteFunnel(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + vars := mux.Vars(r) + id := vars["id"] + + if err := h.repo.DeleteFunnel(id, tenantID); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *CRMHandler) ListStages(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + funnelID := vars["funnelId"] + + stages, err := h.repo.GetStagesByFunnelID(funnelID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"stages": stages}) +} + +func (h *CRMHandler) CreateStage(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + funnelID := vars["funnelId"] + + var stage domain.CRMFunnelStage + if err := json.NewDecoder(r.Body).Decode(&stage); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + stage.ID = uuid.New().String() + stage.FunnelID = funnelID + + if err := h.repo.CreateFunnelStage(&stage); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(stage) +} + +func (h *CRMHandler) UpdateStage(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + var stage domain.CRMFunnelStage + if err := json.NewDecoder(r.Body).Decode(&stage); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + stage.ID = id + + if err := h.repo.UpdateFunnelStage(&stage); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *CRMHandler) DeleteStage(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + if err := h.repo.DeleteFunnelStage(id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *CRMHandler) UpdateLeadStage(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + vars := mux.Vars(r) + leadID := vars["leadId"] + + var req struct { + FunnelID string `json:"funnel_id"` + StageID string `json:"stage_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := h.repo.UpdateLeadStage(leadID, tenantID, req.FunnelID, req.StageID); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + + diff --git a/backend/internal/api/handlers/customer_portal.go b/backend/internal/api/handlers/customer_portal.go new file mode 100644 index 0000000..11cfe3b --- /dev/null +++ b/backend/internal/api/handlers/customer_portal.go @@ -0,0 +1,465 @@ +package handlers + +import ( + "aggios-app/backend/internal/domain" + "aggios-app/backend/internal/repository" + "aggios-app/backend/internal/service" + "aggios-app/backend/internal/config" + "aggios-app/backend/internal/api/middleware" + "context" + "database/sql" + "encoding/json" + "fmt" + "log" + "net/http" + "path/filepath" + "strings" + "time" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" + "golang.org/x/crypto/bcrypt" +) + +type CustomerPortalHandler struct { + crmRepo *repository.CRMRepository + authService *service.AuthService + cfg *config.Config + minioClient *minio.Client +} + +func NewCustomerPortalHandler(crmRepo *repository.CRMRepository, authService *service.AuthService, cfg *config.Config) *CustomerPortalHandler { + // Initialize MinIO client + minioClient, err := minio.New(cfg.Minio.Endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(cfg.Minio.RootUser, cfg.Minio.RootPassword, ""), + Secure: cfg.Minio.UseSSL, + }) + if err != nil { + log.Printf("❌ Failed to create MinIO client for CustomerPortalHandler: %v", err) + } + + return &CustomerPortalHandler{ + crmRepo: crmRepo, + authService: authService, + cfg: cfg, + minioClient: minioClient, + } +} + +// CustomerLoginRequest representa a requisição de login do cliente +type CustomerLoginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +// CustomerLoginResponse representa a resposta de login do cliente +type CustomerLoginResponse struct { + Token string `json:"token"` + Customer *CustomerPortalInfo `json:"customer"` +} + +// CustomerPortalInfo representa informações seguras do cliente para o portal +type CustomerPortalInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Company string `json:"company"` + HasPortalAccess bool `json:"has_portal_access"` + TenantID string `json:"tenant_id"` +} + +// Login autentica um cliente e retorna um token JWT +func (h *CustomerPortalHandler) Login(w http.ResponseWriter, r *http.Request) { + var req CustomerLoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Invalid request body", + }) + return + } + + // Validar entrada + if req.Email == "" || req.Password == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Email e senha são obrigatórios", + }) + return + } + + // Buscar cliente por email + customer, err := h.crmRepo.GetCustomerByEmail(req.Email) + if err != nil { + if err == sql.ErrNoRows { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Credenciais inválidas", + }) + return + } + log.Printf("Error fetching customer: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Erro ao processar login", + }) + return + } + + // Verificar se tem acesso ao portal + if !customer.HasPortalAccess { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Acesso ao portal não autorizado. Entre em contato com o administrador.", + }) + return + } + + // Verificar senha + if err := bcrypt.CompareHashAndPassword([]byte(customer.PasswordHash), []byte(req.Password)); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Credenciais inválidas", + }) + return + } + + // Atualizar último login + if err := h.crmRepo.UpdateCustomerLastLogin(customer.ID); err != nil { + log.Printf("Warning: Failed to update last login for customer %s: %v", customer.ID, err) + } + + // Gerar token JWT + token, err := h.authService.GenerateCustomerToken(customer.ID, customer.TenantID, customer.Email) + if err != nil { + log.Printf("Error generating token: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Erro ao gerar token de autenticação", + }) + return + } + + // Resposta de sucesso + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(CustomerLoginResponse{ + Token: token, + Customer: &CustomerPortalInfo{ + ID: customer.ID, + Name: customer.Name, + Email: customer.Email, + Company: customer.Company, + HasPortalAccess: customer.HasPortalAccess, + TenantID: customer.TenantID, + }, + }) +} + +// GetPortalDashboard retorna dados do dashboard para o cliente autenticado +func (h *CustomerPortalHandler) GetPortalDashboard(w http.ResponseWriter, r *http.Request) { + customerID, _ := r.Context().Value(middleware.CustomerIDKey).(string) + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + + // Buscar leads do cliente + leads, err := h.crmRepo.GetLeadsByCustomerID(customerID) + if err != nil { + log.Printf("Error fetching leads: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Erro ao buscar leads", + }) + return + } + + // Buscar informações do cliente + customer, err := h.crmRepo.GetCustomerByID(customerID, tenantID) + if err != nil { + log.Printf("Error fetching customer: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Erro ao buscar informações do cliente", + }) + return + } + + // Calcular estatísticas + rawStats := calculateLeadStats(leads) + stats := map[string]interface{}{ + "total_leads": rawStats["total"], + "active_leads": rawStats["novo"].(int) + rawStats["qualificado"].(int) + rawStats["negociacao"].(int), + "converted": rawStats["convertido"], + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "customer": CustomerPortalInfo{ + ID: customer.ID, + Name: customer.Name, + Email: customer.Email, + Company: customer.Company, + HasPortalAccess: customer.HasPortalAccess, + TenantID: customer.TenantID, + }, + "leads": leads, + "stats": stats, + }) +} + +// GetPortalLeads retorna apenas os leads do cliente +func (h *CustomerPortalHandler) GetPortalLeads(w http.ResponseWriter, r *http.Request) { + customerID, _ := r.Context().Value(middleware.CustomerIDKey).(string) + + leads, err := h.crmRepo.GetLeadsByCustomerID(customerID) + if err != nil { + log.Printf("Error fetching leads: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Erro ao buscar leads", + }) + return + } + + if leads == nil { + leads = []domain.CRMLead{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "leads": leads, + }) +} + +// GetPortalLists retorna as listas que possuem leads do cliente +func (h *CustomerPortalHandler) GetPortalLists(w http.ResponseWriter, r *http.Request) { + customerID, _ := r.Context().Value(middleware.CustomerIDKey).(string) + + lists, err := h.crmRepo.GetListsByCustomerID(customerID) + if err != nil { + log.Printf("Error fetching portal lists: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Erro ao buscar listas", + }) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "lists": lists, + }) +} + +// GetPortalProfile retorna o perfil completo do cliente +func (h *CustomerPortalHandler) GetPortalProfile(w http.ResponseWriter, r *http.Request) { + customerID, _ := r.Context().Value(middleware.CustomerIDKey).(string) + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + + // Buscar informações do cliente + customer, err := h.crmRepo.GetCustomerByID(customerID, tenantID) + if err != nil { + log.Printf("Error fetching customer: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Erro ao buscar perfil", + }) + return + } + + // Buscar leads para estatísticas + leads, err := h.crmRepo.GetLeadsByCustomerID(customerID) + if err != nil { + log.Printf("Error fetching leads for stats: %v", err) + leads = []domain.CRMLead{} + } + + // Calcular estatísticas + stats := calculateLeadStats(leads) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "customer": map[string]interface{}{ + "id": customer.ID, + "name": customer.Name, + "email": customer.Email, + "phone": customer.Phone, + "company": customer.Company, + "logo_url": customer.LogoURL, + "portal_last_login": customer.PortalLastLogin, + "created_at": customer.CreatedAt, + "total_leads": len(leads), + "converted_leads": stats["convertido"].(int), + }, + }) +} + +// ChangePasswordRequest representa a requisição de troca de senha +type CustomerChangePasswordRequest struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` +} + +// ChangePassword altera a senha do cliente +func (h *CustomerPortalHandler) ChangePassword(w http.ResponseWriter, r *http.Request) { + customerID, _ := r.Context().Value(middleware.CustomerIDKey).(string) + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + + var req CustomerChangePasswordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Invalid request body", + }) + return + } + + // Validar entrada + if req.CurrentPassword == "" || req.NewPassword == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Senha atual e nova senha são obrigatórias", + }) + return + } + + if len(req.NewPassword) < 6 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "A nova senha deve ter no mínimo 6 caracteres", + }) + return + } + + // Buscar cliente + customer, err := h.crmRepo.GetCustomerByID(customerID, tenantID) + if err != nil { + log.Printf("Error fetching customer: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Erro ao processar solicitação", + }) + return + } + + // Verificar senha atual + if err := bcrypt.CompareHashAndPassword([]byte(customer.PasswordHash), []byte(req.CurrentPassword)); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Senha atual incorreta", + }) + return + } + + // Gerar hash da nova senha + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) + if err != nil { + log.Printf("Error hashing password: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Erro ao processar nova senha", + }) + return + } + + // Atualizar senha no banco + if err := h.crmRepo.UpdateCustomerPassword(customerID, string(hashedPassword)); err != nil { + log.Printf("Error updating password: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Erro ao atualizar senha", + }) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "message": "Senha alterada com sucesso", + }) +} + +// UploadLogo faz o upload do logo do cliente +func (h *CustomerPortalHandler) UploadLogo(w http.ResponseWriter, r *http.Request) { + customerID, _ := r.Context().Value(middleware.CustomerIDKey).(string) + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + + if h.minioClient == nil { + http.Error(w, "Storage service unavailable", http.StatusServiceUnavailable) + return + } + + // Parse multipart form (2MB max) + const maxLogoSize = 2 * 1024 * 1024 + if err := r.ParseMultipartForm(maxLogoSize); err != nil { + http.Error(w, "File too large", http.StatusBadRequest) + return + } + + file, header, err := r.FormFile("logo") + if err != nil { + http.Error(w, "Failed to read file", http.StatusBadRequest) + return + } + defer file.Close() + + // Validate file type + contentType := header.Header.Get("Content-Type") + if !strings.HasPrefix(contentType, "image/") { + http.Error(w, "Only images are allowed", http.StatusBadRequest) + return + } + + // Generate unique filename + ext := filepath.Ext(header.Filename) + if ext == "" { + ext = ".png" // Default extension + } + filename := fmt.Sprintf("logo-%d%s", time.Now().Unix(), ext) + objectPath := fmt.Sprintf("customers/%s/%s", customerID, filename) + + // Upload to MinIO + ctx := context.Background() + bucketName := h.cfg.Minio.BucketName + + _, err = h.minioClient.PutObject(ctx, bucketName, objectPath, file, header.Size, minio.PutObjectOptions{ + ContentType: contentType, + }) + if err != nil { + log.Printf("Error uploading to MinIO: %v", err) + http.Error(w, "Failed to upload file", http.StatusInternalServerError) + return + } + + // Generate public URL + logoURL := fmt.Sprintf("%s/api/files/%s/%s", h.cfg.Minio.PublicURL, bucketName, objectPath) + + // Update customer in database + err = h.crmRepo.UpdateCustomerLogo(customerID, tenantID, logoURL) + if err != nil { + log.Printf("Error updating customer logo in DB: %v", err) + http.Error(w, "Failed to update profile", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "logo_url": logoURL, + }) +} diff --git a/backend/internal/api/handlers/export.go b/backend/internal/api/handlers/export.go new file mode 100644 index 0000000..2105b17 --- /dev/null +++ b/backend/internal/api/handlers/export.go @@ -0,0 +1,210 @@ +package handlers + +import ( + "aggios-app/backend/internal/api/middleware" + "aggios-app/backend/internal/domain" + "encoding/csv" + "encoding/json" + "fmt" + "log" + "net/http" + "strings" + + "github.com/xuri/excelize/v2" +) + +// ExportLeads handles exporting leads in different formats +func (h *CRMHandler) ExportLeads(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + if tenantID == "" { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Missing tenant_id"}) + return + } + + format := r.URL.Query().Get("format") + if format == "" { + format = "csv" + } + + customerID := r.URL.Query().Get("customer_id") + campaignID := r.URL.Query().Get("campaign_id") + + var leads []domain.CRMLead + var err error + + if campaignID != "" { + leads, err = h.repo.GetLeadsByListID(campaignID) + } else if customerID != "" { + leads, err = h.repo.GetLeadsByTenant(tenantID) + // Filter by customer manually + filtered := []domain.CRMLead{} + for _, lead := range leads { + if lead.CustomerID != nil && *lead.CustomerID == customerID { + filtered = append(filtered, lead) + } + } + leads = filtered + } else { + leads, err = h.repo.GetLeadsByTenant(tenantID) + } + + if err != nil { + log.Printf("ExportLeads: Error fetching leads: %v", err) + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": "Failed to fetch leads"}) + return + } + + switch strings.ToLower(format) { + case "json": + exportJSON(w, leads) + case "xlsx", "excel": + exportXLSX(w, leads) + default: + exportCSV(w, leads) + } +} + +func exportJSON(w http.ResponseWriter, leads []domain.CRMLead) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Disposition", "attachment; filename=leads.json") + + json.NewEncoder(w).Encode(map[string]interface{}{ + "leads": leads, + "count": len(leads), + }) +} + +func exportCSV(w http.ResponseWriter, leads []domain.CRMLead) { + w.Header().Set("Content-Type", "text/csv") + w.Header().Set("Content-Disposition", "attachment; filename=leads.csv") + + writer := csv.NewWriter(w) + defer writer.Flush() + + // Header + header := []string{"ID", "Nome", "Email", "Telefone", "Status", "Origem", "Notas", "Tags", "Criado Em"} + writer.Write(header) + + // Data + for _, lead := range leads { + tags := "" + if len(lead.Tags) > 0 { + tags = strings.Join(lead.Tags, ", ") + } + + phone := "" + if lead.Phone != "" { + phone = lead.Phone + } + + notes := "" + if lead.Notes != "" { + notes = lead.Notes + } + + row := []string{ + lead.ID, + lead.Name, + lead.Email, + phone, + lead.Status, + lead.Source, + notes, + tags, + lead.CreatedAt.Format("02/01/2006 15:04"), + } + writer.Write(row) + } +} + +func exportXLSX(w http.ResponseWriter, leads []domain.CRMLead) { + f := excelize.NewFile() + defer f.Close() + + sheetName := "Leads" + index, err := f.NewSheet(sheetName) + if err != nil { + log.Printf("Error creating sheet: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + // Set active sheet + f.SetActiveSheet(index) + + // Header style + headerStyle, _ := f.NewStyle(&excelize.Style{ + Font: &excelize.Font{ + Bold: true, + Size: 12, + }, + Fill: excelize.Fill{ + Type: "pattern", + Color: []string{"#4472C4"}, + Pattern: 1, + }, + Alignment: &excelize.Alignment{ + Horizontal: "center", + Vertical: "center", + }, + }) + + // Headers + headers := []string{"ID", "Nome", "Email", "Telefone", "Status", "Origem", "Notas", "Tags", "Criado Em"} + for i, header := range headers { + cell := fmt.Sprintf("%s1", string(rune('A'+i))) + f.SetCellValue(sheetName, cell, header) + f.SetCellStyle(sheetName, cell, cell, headerStyle) + } + + // Data + for i, lead := range leads { + row := i + 2 + + tags := "" + if len(lead.Tags) > 0 { + tags = strings.Join(lead.Tags, ", ") + } + + phone := "" + if lead.Phone != "" { + phone = lead.Phone + } + + notes := "" + if lead.Notes != "" { + notes = lead.Notes + } + + f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), lead.ID) + f.SetCellValue(sheetName, fmt.Sprintf("B%d", row), lead.Name) + f.SetCellValue(sheetName, fmt.Sprintf("C%d", row), lead.Email) + f.SetCellValue(sheetName, fmt.Sprintf("D%d", row), phone) + f.SetCellValue(sheetName, fmt.Sprintf("E%d", row), lead.Status) + f.SetCellValue(sheetName, fmt.Sprintf("F%d", row), lead.Source) + f.SetCellValue(sheetName, fmt.Sprintf("G%d", row), notes) + f.SetCellValue(sheetName, fmt.Sprintf("H%d", row), tags) + f.SetCellValue(sheetName, fmt.Sprintf("I%d", row), lead.CreatedAt.Format("02/01/2006 15:04")) + } + + // Auto-adjust column widths + for i := 0; i < len(headers); i++ { + col := string(rune('A' + i)) + f.SetColWidth(sheetName, col, col, 15) + } + f.SetColWidth(sheetName, "B", "B", 25) // Nome + f.SetColWidth(sheetName, "C", "C", 30) // Email + f.SetColWidth(sheetName, "G", "G", 40) // Notas + + // Delete default sheet if exists + f.DeleteSheet("Sheet1") + + w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + w.Header().Set("Content-Disposition", "attachment; filename=leads.xlsx") + + if err := f.Write(w); err != nil { + log.Printf("Error writing xlsx: %v", err) + } +} diff --git a/backend/internal/api/handlers/tenant.go b/backend/internal/api/handlers/tenant.go index 601acf4..00ce4b0 100644 --- a/backend/internal/api/handlers/tenant.go +++ b/backend/internal/api/handlers/tenant.go @@ -5,7 +5,10 @@ import ( "log" "net/http" + "aggios-app/backend/internal/api/middleware" "aggios-app/backend/internal/service" + + "github.com/google/uuid" ) // TenantHandler handles tenant/agency listing endpoints @@ -93,7 +96,8 @@ func (h *TenantHandler) GetPublicConfig(w http.ResponseWriter, r *http.Request) } // Return only public info - response := map[string]string{ + response := map[string]interface{}{ + "id": tenant.ID.String(), "name": tenant.Name, "primary_color": tenant.PrimaryColor, "secondary_color": tenant.SecondaryColor, @@ -106,3 +110,88 @@ func (h *TenantHandler) GetPublicConfig(w http.ResponseWriter, r *http.Request) w.Header().Set("Content-Type", "application/json; charset=utf-8") json.NewEncoder(w).Encode(response) } + +// GetBranding returns branding info for the current authenticated tenant +func (h *TenantHandler) GetBranding(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Get tenant from context (set by auth middleware) + tenantID := r.Context().Value(middleware.TenantIDKey) + if tenantID == nil { + http.Error(w, "Tenant not found in context", http.StatusUnauthorized) + return + } + + // Parse tenant ID + tid, err := uuid.Parse(tenantID.(string)) + if err != nil { + http.Error(w, "Invalid tenant ID", http.StatusBadRequest) + return + } + + // Get tenant from database + tenant, err := h.tenantService.GetByID(tid) + if err != nil { + http.Error(w, "Error fetching branding", http.StatusInternalServerError) + return + } + + // Return branding info + response := map[string]interface{}{ + "id": tenant.ID.String(), + "name": tenant.Name, + "primary_color": tenant.PrimaryColor, + "secondary_color": tenant.SecondaryColor, + "logo_url": tenant.LogoURL, + "logo_horizontal_url": tenant.LogoHorizontalURL, + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + json.NewEncoder(w).Encode(response) +} + +// GetProfile returns public tenant information by tenant ID +func (h *TenantHandler) GetProfile(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Extract tenant ID from URL path + // URL format: /api/tenants/{id}/profile + tenantIDStr := r.URL.Path[len("/api/tenants/"):] + if idx := len(tenantIDStr) - len("/profile"); idx > 0 { + tenantIDStr = tenantIDStr[:idx] + } + + if tenantIDStr == "" { + http.Error(w, "tenant_id is required", http.StatusBadRequest) + return + } + + // Para compatibilidade, aceitar tanto UUID quanto ID numérico + // Primeiro tentar como UUID, se falhar buscar tenant diretamente + tenant, err := h.tenantService.GetBySubdomain(tenantIDStr) + if err != nil { + log.Printf("Error getting tenant: %v", err) + http.Error(w, "Tenant not found", http.StatusNotFound) + return + } + + // Return public info + response := map[string]interface{}{ + "tenant": map[string]string{ + "company": tenant.Name, + "primary_color": tenant.PrimaryColor, + "secondary_color": tenant.SecondaryColor, + "logo_url": tenant.LogoURL, + "logo_horizontal_url": tenant.LogoHorizontalURL, + }, + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + json.NewEncoder(w).Encode(response) +} diff --git a/backend/internal/api/middleware/auth.go b/backend/internal/api/middleware/auth.go index 8e3db9f..587b996 100644 --- a/backend/internal/api/middleware/auth.go +++ b/backend/internal/api/middleware/auth.go @@ -65,6 +65,16 @@ func Auth(cfg *config.Config) func(http.Handler) http.Handler { tenantIDFromJWT, _ = tenantIDClaim.(string) } + // VALIDAÇÃO DE SEGURANÇA: Verificar user_type para impedir clientes de acessarem rotas de agência + if userTypeClaim, ok := claims["user_type"]; ok && userTypeClaim != nil { + userType, _ := userTypeClaim.(string) + if userType == "customer" { + log.Printf("❌ CUSTOMER ACCESS BLOCKED: Customer %s tried to access agency route %s", userID, r.RequestURI) + http.Error(w, "Forbidden: Customers cannot access agency routes", http.StatusForbidden) + return + } + } + // VALIDAÇÃO DE SEGURANÇA: Verificar se o tenant_id do JWT corresponde ao subdomínio acessado // Pegar o tenant_id do contexto (detectado pelo TenantDetector middleware ANTES deste) tenantIDFromContext := "" diff --git a/backend/internal/api/middleware/collaborator_readonly.go b/backend/internal/api/middleware/collaborator_readonly.go new file mode 100644 index 0000000..e0efeff --- /dev/null +++ b/backend/internal/api/middleware/collaborator_readonly.go @@ -0,0 +1,44 @@ +package middleware + +import ( + "log" + "net/http" + "strings" +) + +// CheckCollaboratorReadOnly verifica se um colaborador está tentando fazer operações de escrita +// Se sim, bloqueia com 403 +func CheckCollaboratorReadOnly(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verificar agency_role do contexto + agencyRole, ok := r.Context().Value("agency_role").(string) + if !ok { + // Se não houver agency_role no contexto, é um customer, deixa passar + next.ServeHTTP(w, r) + return + } + + // Apenas colaboradores têm restrição de read-only + if agencyRole != "collaborator" { + next.ServeHTTP(w, r) + return + } + + // Verificar se é uma operação de escrita + method := r.Method + if method == http.MethodPost || method == http.MethodPut || method == http.MethodDelete { + // Verificar a rota + path := r.URL.Path + + // Bloquear operações de escrita em CRM + if strings.Contains(path, "/api/crm/") { + userID, _ := r.Context().Value(UserIDKey).(string) + log.Printf("❌ COLLABORATOR WRITE BLOCKED: User %s (collaborator) tried %s %s", userID, method, path) + http.Error(w, "Colaboradores têm acesso somente leitura", http.StatusForbidden) + return + } + } + + next.ServeHTTP(w, r) + }) +} diff --git a/backend/internal/api/middleware/customer_auth.go b/backend/internal/api/middleware/customer_auth.go new file mode 100644 index 0000000..0d70727 --- /dev/null +++ b/backend/internal/api/middleware/customer_auth.go @@ -0,0 +1,85 @@ +package middleware + +import ( + "aggios-app/backend/internal/config" + "context" + "log" + "net/http" + "strings" + + "github.com/golang-jwt/jwt/v5" +) + +const ( + CustomerIDKey contextKey = "customer_id" +) + +// CustomerAuthMiddleware valida tokens JWT de clientes do portal +func CustomerAuthMiddleware(cfg *config.Config) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Extrair token do header Authorization + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + http.Error(w, "Authorization header required", http.StatusUnauthorized) + return + } + + // Remover "Bearer " prefix + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + if tokenString == authHeader { + http.Error(w, "Invalid authorization format", http.StatusUnauthorized) + return + } + + // Parse e validar token + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + // Verificar método de assinatura + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, jwt.ErrSignatureInvalid + } + return []byte(cfg.JWT.Secret), nil + }) + + if err != nil || !token.Valid { + log.Printf("Invalid token: %v", err) + http.Error(w, "Invalid or expired token", http.StatusUnauthorized) + return + } + + // Extrair claims + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + http.Error(w, "Invalid token claims", http.StatusUnauthorized) + return + } + + // Verificar se é token de customer + tokenType, _ := claims["type"].(string) + if tokenType != "customer_portal" { + http.Error(w, "Invalid token type", http.StatusUnauthorized) + return + } + + // Extrair customer_id e tenant_id + customerID, ok := claims["customer_id"].(string) + if !ok { + http.Error(w, "Invalid customer_id in token", http.StatusUnauthorized) + return + } + + tenantID, ok := claims["tenant_id"].(string) + if !ok { + http.Error(w, "Invalid tenant_id in token", http.StatusUnauthorized) + return + } + + // Adicionar ao contexto + ctx := context.WithValue(r.Context(), CustomerIDKey, customerID) + ctx = context.WithValue(ctx, TenantIDKey, tenantID) + + // Prosseguir com a requisição + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/backend/internal/api/middleware/unified_auth.go b/backend/internal/api/middleware/unified_auth.go new file mode 100644 index 0000000..16fcfa2 --- /dev/null +++ b/backend/internal/api/middleware/unified_auth.go @@ -0,0 +1,104 @@ +package middleware + +import ( + "aggios-app/backend/internal/config" + "aggios-app/backend/internal/domain" + "context" + "log" + "net/http" + "strings" + + "github.com/golang-jwt/jwt/v5" +) + +// UnifiedAuthMiddleware valida JWT unificado e permite múltiplos tipos de usuários +func UnifiedAuthMiddleware(cfg *config.Config, allowedTypes ...domain.UserType) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Extrair token do header Authorization + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + log.Printf("🚫 UnifiedAuth: Missing Authorization header") + http.Error(w, "Unauthorized: Missing token", http.StatusUnauthorized) + return + } + + // Formato esperado: "Bearer " + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + log.Printf("🚫 UnifiedAuth: Invalid Authorization format") + http.Error(w, "Unauthorized: Invalid token format", http.StatusUnauthorized) + return + } + + tokenString := parts[1] + + // Parsear e validar token + token, err := jwt.ParseWithClaims(tokenString, &domain.UnifiedClaims{}, func(token *jwt.Token) (interface{}, error) { + return []byte(cfg.JWT.Secret), nil + }) + + if err != nil { + log.Printf("🚫 UnifiedAuth: Token parse error: %v", err) + http.Error(w, "Unauthorized: Invalid token", http.StatusUnauthorized) + return + } + + claims, ok := token.Claims.(*domain.UnifiedClaims) + if !ok || !token.Valid { + log.Printf("🚫 UnifiedAuth: Invalid token claims") + http.Error(w, "Unauthorized: Invalid token", http.StatusUnauthorized) + return + } + + // Verificar se o tipo de usuário é permitido + if len(allowedTypes) > 0 { + allowed := false + for _, allowedType := range allowedTypes { + if claims.UserType == allowedType { + allowed = true + break + } + } + if !allowed { + log.Printf("🚫 UnifiedAuth: User type %s not allowed (allowed: %v)", claims.UserType, allowedTypes) + http.Error(w, "Forbidden: Insufficient permissions", http.StatusForbidden) + return + } + } + + // Adicionar informações ao contexto + ctx := r.Context() + ctx = context.WithValue(ctx, UserIDKey, claims.UserID) + ctx = context.WithValue(ctx, TenantIDKey, claims.TenantID) + ctx = context.WithValue(ctx, "email", claims.Email) + ctx = context.WithValue(ctx, "user_type", string(claims.UserType)) + ctx = context.WithValue(ctx, "role", claims.Role) + + // Para compatibilidade com handlers de portal que esperam CustomerIDKey + if claims.UserType == domain.UserTypeCustomer { + ctx = context.WithValue(ctx, CustomerIDKey, claims.UserID) + } + + log.Printf("✅ UnifiedAuth: Authenticated user_id=%s, type=%s, role=%s, tenant=%s", + claims.UserID, claims.UserType, claims.Role, claims.TenantID) + + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// RequireAgencyUser middleware que permite apenas usuários de agência (admin, colaborador) +func RequireAgencyUser(cfg *config.Config) func(http.Handler) http.Handler { + return UnifiedAuthMiddleware(cfg, domain.UserTypeAgency) +} + +// RequireCustomer middleware que permite apenas clientes +func RequireCustomer(cfg *config.Config) func(http.Handler) http.Handler { + return UnifiedAuthMiddleware(cfg, domain.UserTypeCustomer) +} + +// RequireAnyAuthenticated middleware que permite qualquer usuário autenticado +func RequireAnyAuthenticated(cfg *config.Config) func(http.Handler) http.Handler { + return UnifiedAuthMiddleware(cfg) // Sem filtro de tipo +} diff --git a/backend/internal/data/postgres/migrations/001_add_agency_roles.sql b/backend/internal/data/postgres/migrations/001_add_agency_roles.sql new file mode 100644 index 0000000..1164707 --- /dev/null +++ b/backend/internal/data/postgres/migrations/001_add_agency_roles.sql @@ -0,0 +1,18 @@ +-- Migration: Add agency user roles and collaborator tracking +-- Purpose: Support owner/collaborator hierarchy for agency users + +-- 1. Add agency_role column to users table (owner or collaborator) +ALTER TABLE users ADD COLUMN IF NOT EXISTS agency_role VARCHAR(50) DEFAULT 'owner' CHECK (agency_role IN ('owner', 'collaborator')); + +-- 2. Add created_by column to track which user created this collaborator +ALTER TABLE users ADD COLUMN IF NOT EXISTS created_by UUID REFERENCES users(id) ON DELETE SET NULL; + +-- 3. Update existing ADMIN_AGENCIA users to have 'owner' agency_role +UPDATE users SET agency_role = 'owner' WHERE role = 'ADMIN_AGENCIA' AND agency_role IS NULL; + +-- 4. Add collaborator_created_at to track when the collaborator was added +ALTER TABLE users ADD COLUMN IF NOT EXISTS collaborator_created_at TIMESTAMP WITH TIME ZONE; + +-- 5. Create index for faster queries +CREATE INDEX IF NOT EXISTS idx_users_agency_role ON users(tenant_id, agency_role); +CREATE INDEX IF NOT EXISTS idx_users_created_by ON users(created_by); diff --git a/backend/internal/domain/auth_unified.go b/backend/internal/domain/auth_unified.go new file mode 100644 index 0000000..e38ff09 --- /dev/null +++ b/backend/internal/domain/auth_unified.go @@ -0,0 +1,42 @@ +package domain + +import "github.com/golang-jwt/jwt/v5" + +// UserType representa os diferentes tipos de usuários do sistema +type UserType string + +const ( + UserTypeAgency UserType = "agency_user" // Usuários das agências (admin, colaborador) + UserTypeCustomer UserType = "customer" // Clientes do CRM + // SUPERADMIN usa endpoint próprio /api/admin/*, não usa autenticação unificada +) + +// UnifiedClaims representa as claims do JWT unificado +type UnifiedClaims struct { + UserID string `json:"user_id"` // ID do usuário (user.id ou customer.id) + UserType UserType `json:"user_type"` // Tipo de usuário + TenantID string `json:"tenant_id,omitempty"` // ID do tenant (agência) + Email string `json:"email"` // Email do usuário + Role string `json:"role,omitempty"` // Role (para agency_user: ADMIN_AGENCIA, CLIENTE) + AgencyRole string `json:"agency_role,omitempty"` // Agency role (owner ou collaborator) + jwt.RegisteredClaims +} + +// UnifiedLoginRequest representa uma requisição de login unificada +type UnifiedLoginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +// UnifiedLoginResponse representa a resposta de login unificada +type UnifiedLoginResponse struct { + Token string `json:"token"` + UserType UserType `json:"user_type"` + UserID string `json:"user_id"` + Email string `json:"email"` + Name string `json:"name"` + Role string `json:"role,omitempty"` // Apenas para agency_user + AgencyRole string `json:"agency_role,omitempty"` // owner ou collaborator + TenantID string `json:"tenant_id,omitempty"` // ID do tenant + Subdomain string `json:"subdomain,omitempty"` // Subdomínio da agência +} diff --git a/backend/internal/domain/crm.go b/backend/internal/domain/crm.go index 972f3b3..cfb7300 100644 --- a/backend/internal/domain/crm.go +++ b/backend/internal/domain/crm.go @@ -1,31 +1,41 @@ package domain -import "time" +import ( + "encoding/json" + "time" +) type CRMCustomer struct { - ID string `json:"id" db:"id"` - TenantID string `json:"tenant_id" db:"tenant_id"` - Name string `json:"name" db:"name"` - Email string `json:"email" db:"email"` - Phone string `json:"phone" db:"phone"` - Company string `json:"company" db:"company"` - Position string `json:"position" db:"position"` - Address string `json:"address" db:"address"` - City string `json:"city" db:"city"` - State string `json:"state" db:"state"` - ZipCode string `json:"zip_code" db:"zip_code"` - Country string `json:"country" db:"country"` - Notes string `json:"notes" db:"notes"` - Tags []string `json:"tags" db:"tags"` - IsActive bool `json:"is_active" db:"is_active"` - CreatedBy string `json:"created_by" db:"created_by"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + ID string `json:"id" db:"id"` + TenantID string `json:"tenant_id" db:"tenant_id"` + Name string `json:"name" db:"name"` + Email string `json:"email" db:"email"` + Phone string `json:"phone" db:"phone"` + Company string `json:"company" db:"company"` + Position string `json:"position" db:"position"` + Address string `json:"address" db:"address"` + City string `json:"city" db:"city"` + State string `json:"state" db:"state"` + ZipCode string `json:"zip_code" db:"zip_code"` + Country string `json:"country" db:"country"` + Notes string `json:"notes" db:"notes"` + Tags []string `json:"tags" db:"tags"` + LogoURL string `json:"logo_url" db:"logo_url"` + IsActive bool `json:"is_active" db:"is_active"` + CreatedBy string `json:"created_by" db:"created_by"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + PasswordHash string `json:"-" db:"password_hash"` + HasPortalAccess bool `json:"has_portal_access" db:"has_portal_access"` + PortalLastLogin *time.Time `json:"portal_last_login,omitempty" db:"portal_last_login"` + PortalCreatedAt *time.Time `json:"portal_created_at,omitempty" db:"portal_created_at"` } type CRMList struct { ID string `json:"id" db:"id"` TenantID string `json:"tenant_id" db:"tenant_id"` + CustomerID *string `json:"customer_id" db:"customer_id"` + FunnelID *string `json:"funnel_id" db:"funnel_id"` Name string `json:"name" db:"name"` Description string `json:"description" db:"description"` Color string `json:"color" db:"color"` @@ -49,5 +59,77 @@ type CRMCustomerWithLists struct { type CRMListWithCustomers struct { CRMList - CustomerCount int `json:"customer_count"` + CustomerName string `json:"customer_name"` + CustomerCount int `json:"customer_count"` + LeadCount int `json:"lead_count"` +} + +// ==================== LEADS ==================== + +type CRMLead struct { + ID string `json:"id" db:"id"` + TenantID string `json:"tenant_id" db:"tenant_id"` + CustomerID *string `json:"customer_id" db:"customer_id"` + FunnelID *string `json:"funnel_id" db:"funnel_id"` + StageID *string `json:"stage_id" db:"stage_id"` + Name string `json:"name" db:"name"` + Email string `json:"email" db:"email"` + Phone string `json:"phone" db:"phone"` + Source string `json:"source" db:"source"` + SourceMeta json.RawMessage `json:"source_meta" db:"source_meta"` + Status string `json:"status" db:"status"` + Notes string `json:"notes" db:"notes"` + Tags []string `json:"tags" db:"tags"` + IsActive bool `json:"is_active" db:"is_active"` + CreatedBy string `json:"created_by" db:"created_by"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +type CRMFunnel struct { + ID string `json:"id" db:"id"` + TenantID string `json:"tenant_id" db:"tenant_id"` + Name string `json:"name" db:"name"` + Description string `json:"description" db:"description"` + IsDefault bool `json:"is_default" db:"is_default"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +type CRMFunnelStage struct { + ID string `json:"id" db:"id"` + FunnelID string `json:"funnel_id" db:"funnel_id"` + Name string `json:"name" db:"name"` + Description string `json:"description" db:"description"` + Color string `json:"color" db:"color"` + OrderIndex int `json:"order_index" db:"order_index"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +type CRMFunnelWithStages struct { + CRMFunnel + Stages []CRMFunnelStage `json:"stages"` +} + +type CRMLeadList struct { + LeadID string `json:"lead_id" db:"lead_id"` + ListID string `json:"list_id" db:"list_id"` + AddedAt time.Time `json:"added_at" db:"added_at"` + AddedBy string `json:"added_by" db:"added_by"` +} + +type CRMLeadWithLists struct { + CRMLead + Lists []CRMList `json:"lists"` +} + +type CRMShareToken struct { + ID string `json:"id" db:"id"` + TenantID string `json:"tenant_id" db:"tenant_id"` + CustomerID string `json:"customer_id" db:"customer_id"` + Token string `json:"token" db:"token"` + ExpiresAt *time.Time `json:"expires_at" db:"expires_at"` + CreatedBy string `json:"created_by" db:"created_by"` + CreatedAt time.Time `json:"created_at" db:"created_at"` } diff --git a/backend/internal/domain/user.go b/backend/internal/domain/user.go index eefbb30..4bbdbf9 100644 --- a/backend/internal/domain/user.go +++ b/backend/internal/domain/user.go @@ -8,14 +8,17 @@ import ( // User represents a user in the system type User struct { - ID uuid.UUID `json:"id" db:"id"` - TenantID *uuid.UUID `json:"tenant_id,omitempty" db:"tenant_id"` - Email string `json:"email" db:"email"` - Password string `json:"-" db:"password_hash"` - Name string `json:"name" db:"first_name"` - Role string `json:"role" db:"role"` // SUPERADMIN, ADMIN_AGENCIA, CLIENTE - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + ID uuid.UUID `json:"id" db:"id"` + TenantID *uuid.UUID `json:"tenant_id,omitempty" db:"tenant_id"` + Email string `json:"email" db:"email"` + Password string `json:"-" db:"password_hash"` + Name string `json:"name" db:"first_name"` + Role string `json:"role" db:"role"` // SUPERADMIN, ADMIN_AGENCIA, CLIENTE + AgencyRole string `json:"agency_role" db:"agency_role"` // owner or collaborator (only for ADMIN_AGENCIA) + CreatedBy *uuid.UUID `json:"created_by,omitempty" db:"created_by"` // Which owner created this collaborator + CollaboratorCreatedAt *time.Time `json:"collaborator_created_at,omitempty" db:"collaborator_created_at"` // When collaborator was added + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } // CreateUserRequest represents the request to create a new user diff --git a/backend/internal/repository/crm_repository.go b/backend/internal/repository/crm_repository.go index 463d9f4..2ea7ccb 100644 --- a/backend/internal/repository/crm_repository.go +++ b/backend/internal/repository/crm_repository.go @@ -4,6 +4,7 @@ import ( "aggios-app/backend/internal/domain" "database/sql" "fmt" + "log" "github.com/lib/pq" ) @@ -23,25 +24,34 @@ func (r *CRMRepository) CreateCustomer(customer *domain.CRMCustomer) error { INSERT INTO crm_customers ( id, tenant_id, name, email, phone, company, position, address, city, state, zip_code, country, notes, tags, - is_active, created_by - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + is_active, created_by, logo_url + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) RETURNING created_at, updated_at ` + // Handle optional created_by field (NULL for public registrations) + var createdBy interface{} + if customer.CreatedBy != "" { + createdBy = customer.CreatedBy + } else { + createdBy = nil + } + return r.db.QueryRow( query, customer.ID, customer.TenantID, customer.Name, customer.Email, customer.Phone, customer.Company, customer.Position, customer.Address, customer.City, customer.State, customer.ZipCode, customer.Country, customer.Notes, pq.Array(customer.Tags), - customer.IsActive, customer.CreatedBy, + customer.IsActive, createdBy, customer.LogoURL, ).Scan(&customer.CreatedAt, &customer.UpdatedAt) } func (r *CRMRepository) GetCustomersByTenant(tenantID string) ([]domain.CRMCustomer, error) { query := ` SELECT id, tenant_id, name, email, phone, company, position, - address, city, state, zip_code, country, notes, tags, - is_active, created_by, created_at, updated_at + address, city, state, zip_code, country, notes, tags, + is_active, COALESCE(created_by::text, '') AS created_by, created_at, updated_at, + COALESCE(logo_url, '') as logo_url FROM crm_customers WHERE tenant_id = $1 AND is_active = true ORDER BY created_at DESC @@ -59,7 +69,7 @@ func (r *CRMRepository) GetCustomersByTenant(tenantID string) ([]domain.CRMCusto err := rows.Scan( &c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position, &c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags), - &c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, + &c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, &c.LogoURL, ) if err != nil { return nil, err @@ -73,8 +83,9 @@ func (r *CRMRepository) GetCustomersByTenant(tenantID string) ([]domain.CRMCusto func (r *CRMRepository) GetCustomerByID(id string, tenantID string) (*domain.CRMCustomer, error) { query := ` SELECT id, tenant_id, name, email, phone, company, position, - address, city, state, zip_code, country, notes, tags, - is_active, created_by, created_at, updated_at + address, city, state, zip_code, country, notes, tags, + is_active, COALESCE(created_by::text, '') AS created_by, created_at, updated_at, + COALESCE(logo_url, '') as logo_url FROM crm_customers WHERE id = $1 AND tenant_id = $2 ` @@ -83,7 +94,7 @@ func (r *CRMRepository) GetCustomerByID(id string, tenantID string) (*domain.CRM err := r.db.QueryRow(query, id, tenantID).Scan( &c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position, &c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags), - &c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, + &c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, &c.LogoURL, ) if err != nil { @@ -98,15 +109,15 @@ func (r *CRMRepository) UpdateCustomer(customer *domain.CRMCustomer) error { UPDATE crm_customers SET name = $1, email = $2, phone = $3, company = $4, position = $5, address = $6, city = $7, state = $8, zip_code = $9, country = $10, - notes = $11, tags = $12, is_active = $13 - WHERE id = $14 AND tenant_id = $15 + notes = $11, tags = $12, is_active = $13, logo_url = $14 + WHERE id = $15 AND tenant_id = $16 ` result, err := r.db.Exec( query, customer.Name, customer.Email, customer.Phone, customer.Company, customer.Position, customer.Address, customer.City, customer.State, customer.ZipCode, customer.Country, - customer.Notes, pq.Array(customer.Tags), customer.IsActive, + customer.Notes, pq.Array(customer.Tags), customer.IsActive, customer.LogoURL, customer.ID, customer.TenantID, ) @@ -150,26 +161,27 @@ func (r *CRMRepository) DeleteCustomer(id string, tenantID string) error { func (r *CRMRepository) CreateList(list *domain.CRMList) error { query := ` - INSERT INTO crm_lists (id, tenant_id, name, description, color, created_by) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO crm_lists (id, tenant_id, customer_id, funnel_id, name, description, color, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING created_at, updated_at ` return r.db.QueryRow( query, - list.ID, list.TenantID, list.Name, list.Description, list.Color, list.CreatedBy, + list.ID, list.TenantID, list.CustomerID, list.FunnelID, list.Name, list.Description, list.Color, list.CreatedBy, ).Scan(&list.CreatedAt, &list.UpdatedAt) } func (r *CRMRepository) GetListsByTenant(tenantID string) ([]domain.CRMListWithCustomers, error) { query := ` - SELECT l.id, l.tenant_id, l.name, l.description, l.color, l.created_by, + SELECT l.id, l.tenant_id, l.customer_id, l.funnel_id, l.name, l.description, l.color, l.created_by, l.created_at, l.updated_at, - COUNT(cl.customer_id) as customer_count + COALESCE(c.name, '') as customer_name, + (SELECT COUNT(*) FROM crm_customer_lists cl WHERE cl.list_id = l.id) as customer_count, + (SELECT COUNT(*) FROM crm_lead_lists ll WHERE ll.list_id = l.id) as lead_count FROM crm_lists l - LEFT JOIN crm_customer_lists cl ON l.id = cl.list_id + LEFT JOIN crm_customers c ON l.customer_id = c.id WHERE l.tenant_id = $1 - GROUP BY l.id ORDER BY l.created_at DESC ` @@ -183,8 +195,8 @@ func (r *CRMRepository) GetListsByTenant(tenantID string) ([]domain.CRMListWithC for rows.Next() { var l domain.CRMListWithCustomers err := rows.Scan( - &l.ID, &l.TenantID, &l.Name, &l.Description, &l.Color, &l.CreatedBy, - &l.CreatedAt, &l.UpdatedAt, &l.CustomerCount, + &l.ID, &l.TenantID, &l.CustomerID, &l.FunnelID, &l.Name, &l.Description, &l.Color, &l.CreatedBy, + &l.CreatedAt, &l.UpdatedAt, &l.CustomerName, &l.CustomerCount, &l.LeadCount, ) if err != nil { return nil, err @@ -197,14 +209,14 @@ func (r *CRMRepository) GetListsByTenant(tenantID string) ([]domain.CRMListWithC func (r *CRMRepository) GetListByID(id string, tenantID string) (*domain.CRMList, error) { query := ` - SELECT id, tenant_id, name, description, color, created_by, created_at, updated_at + SELECT id, tenant_id, customer_id, funnel_id, name, description, color, created_by, created_at, updated_at FROM crm_lists WHERE id = $1 AND tenant_id = $2 ` var l domain.CRMList err := r.db.QueryRow(query, id, tenantID).Scan( - &l.ID, &l.TenantID, &l.Name, &l.Description, &l.Color, &l.CreatedBy, + &l.ID, &l.TenantID, &l.CustomerID, &l.FunnelID, &l.Name, &l.Description, &l.Color, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt, ) @@ -218,11 +230,11 @@ func (r *CRMRepository) GetListByID(id string, tenantID string) (*domain.CRMList func (r *CRMRepository) UpdateList(list *domain.CRMList) error { query := ` UPDATE crm_lists SET - name = $1, description = $2, color = $3 - WHERE id = $4 AND tenant_id = $5 + name = $1, description = $2, color = $3, customer_id = $4, funnel_id = $5 + WHERE id = $6 AND tenant_id = $7 ` - result, err := r.db.Exec(query, list.Name, list.Description, list.Color, list.ID, list.TenantID) + result, err := r.db.Exec(query, list.Name, list.Description, list.Color, list.CustomerID, list.FunnelID, list.ID, list.TenantID) if err != nil { return err } @@ -315,7 +327,8 @@ func (r *CRMRepository) GetListCustomers(listID string, tenantID string) ([]doma query := ` SELECT c.id, c.tenant_id, c.name, c.email, c.phone, c.company, c.position, c.address, c.city, c.state, c.zip_code, c.country, c.notes, c.tags, - c.is_active, c.created_by, c.created_at, c.updated_at + c.is_active, c.created_by, c.created_at, c.updated_at, + COALESCE(c.logo_url, '') as logo_url FROM crm_customers c INNER JOIN crm_customer_lists cl ON c.id = cl.customer_id WHERE cl.list_id = $1 AND c.tenant_id = $2 AND c.is_active = true @@ -334,7 +347,7 @@ func (r *CRMRepository) GetListCustomers(listID string, tenantID string) ([]doma err := rows.Scan( &c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position, &c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags), - &c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, + &c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, &c.LogoURL, ) if err != nil { return nil, err @@ -344,3 +357,803 @@ func (r *CRMRepository) GetListCustomers(listID string, tenantID string) ([]doma return customers, nil } + +// ==================== LEADS ==================== + +func (r *CRMRepository) CreateLead(lead *domain.CRMLead) error { + query := ` + INSERT INTO crm_leads ( + id, tenant_id, customer_id, funnel_id, stage_id, name, email, phone, source, source_meta, + status, notes, tags, is_active, created_by + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + RETURNING created_at, updated_at + ` + + return r.db.QueryRow( + query, + lead.ID, lead.TenantID, lead.CustomerID, lead.FunnelID, lead.StageID, lead.Name, lead.Email, lead.Phone, + lead.Source, lead.SourceMeta, lead.Status, lead.Notes, pq.Array(lead.Tags), + lead.IsActive, lead.CreatedBy, + ).Scan(&lead.CreatedAt, &lead.UpdatedAt) +} +func (r *CRMRepository) AddLeadToList(leadID, listID, addedBy string) error { + query := ` + INSERT INTO crm_lead_lists (lead_id, list_id, added_by) + VALUES ($1, $2, $3) + ON CONFLICT (lead_id, list_id) DO NOTHING + ` + _, err := r.db.Exec(query, leadID, listID, addedBy) + return err +} + +func (r *CRMRepository) BulkAddLeadsToList(leadIDs []string, listID string, addedBy string) error { + tx, err := r.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + stmt, err := tx.Prepare(pq.CopyIn("crm_lead_lists", "lead_id", "list_id", "added_by")) + if err != nil { + return err + } + defer stmt.Close() + + for _, leadID := range leadIDs { + _, err = stmt.Exec(leadID, listID, addedBy) + if err != nil { + return err + } + } + + _, err = stmt.Exec() + if err != nil { + return err + } + + return tx.Commit() +} + +func (r *CRMRepository) BulkCreateLeads(leads []domain.CRMLead) error { + tx, err := r.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + query := ` + INSERT INTO crm_leads ( + id, tenant_id, customer_id, funnel_id, stage_id, name, email, phone, source, + source_meta, status, notes, tags, is_active, created_by + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15 + ) ON CONFLICT (tenant_id, email) DO UPDATE SET + customer_id = COALESCE(EXCLUDED.customer_id, crm_leads.customer_id), + funnel_id = COALESCE(EXCLUDED.funnel_id, crm_leads.funnel_id), + stage_id = COALESCE(EXCLUDED.stage_id, crm_leads.stage_id), + name = COALESCE(EXCLUDED.name, crm_leads.name), + phone = COALESCE(EXCLUDED.phone, crm_leads.phone), + source = EXCLUDED.source, + source_meta = EXCLUDED.source_meta, + status = EXCLUDED.status, + notes = COALESCE(EXCLUDED.notes, crm_leads.notes), + tags = EXCLUDED.tags, + updated_at = CURRENT_TIMESTAMP + RETURNING id + ` + + stmt, err := tx.Prepare(query) + if err != nil { + return err + } + defer stmt.Close() + + for i := range leads { + var returnedID string + err = stmt.QueryRow( + leads[i].ID, leads[i].TenantID, leads[i].CustomerID, leads[i].FunnelID, leads[i].StageID, leads[i].Name, leads[i].Email, leads[i].Phone, + leads[i].Source, string(leads[i].SourceMeta), leads[i].Status, leads[i].Notes, pq.Array(leads[i].Tags), + leads[i].IsActive, leads[i].CreatedBy, + ).Scan(&returnedID) + if err != nil { + return err + } + // Atualiza o ID do lead com o ID retornado (pode ser diferente em caso de conflito) + leads[i].ID = returnedID + } + + return tx.Commit() +} + +func (r *CRMRepository) GetLeadsByTenant(tenantID string) ([]domain.CRMLead, error) { + query := ` + SELECT id, tenant_id, customer_id, funnel_id, stage_id, name, email, phone, source, source_meta, + status, COALESCE(notes, ''), tags, is_active, created_by, created_at, updated_at + FROM crm_leads + WHERE tenant_id = $1 AND is_active = true + ORDER BY created_at DESC + ` + + rows, err := r.db.Query(query, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + + var leads []domain.CRMLead + for rows.Next() { + var l domain.CRMLead + err := rows.Scan( + &l.ID, &l.TenantID, &l.CustomerID, &l.FunnelID, &l.StageID, &l.Name, &l.Email, &l.Phone, &l.Source, &l.SourceMeta, + &l.Status, &l.Notes, pq.Array(&l.Tags), &l.IsActive, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt, + ) + if err != nil { + log.Printf("Error scanning lead: %v", err) + continue + } + leads = append(leads, l) + } + + return leads, nil +} + +func (r *CRMRepository) GetLeadsWithListsByTenant(tenantID string) ([]domain.CRMLeadWithLists, error) { + leads, err := r.GetLeadsByTenant(tenantID) + if err != nil { + return nil, err + } + + var leadsWithLists []domain.CRMLeadWithLists + for _, l := range leads { + lists, err := r.GetListsByLeadID(l.ID) + if err != nil { + lists = []domain.CRMList{} + } + leadsWithLists = append(leadsWithLists, domain.CRMLeadWithLists{ + CRMLead: l, + Lists: lists, + }) + } + + return leadsWithLists, nil +} + +func (r *CRMRepository) GetListsByLeadID(leadID string) ([]domain.CRMList, error) { + query := ` + SELECT l.id, l.tenant_id, l.customer_id, l.name, l.description, l.color, l.created_by, l.created_at, l.updated_at + FROM crm_lists l + JOIN crm_lead_lists cll ON l.id = cll.list_id + WHERE cll.lead_id = $1 + ` + + rows, err := r.db.Query(query, leadID) + if err != nil { + return nil, err + } + defer rows.Close() + + var lists []domain.CRMList + for rows.Next() { + var l domain.CRMList + err := rows.Scan( + &l.ID, &l.TenantID, &l.CustomerID, &l.Name, &l.Description, &l.Color, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt, + ) + if err != nil { + return nil, err + } + lists = append(lists, l) + } + + return lists, nil +} + +func (r *CRMRepository) GetLeadByID(id string, tenantID string) (*domain.CRMLead, error) { + query := ` + SELECT id, tenant_id, customer_id, funnel_id, stage_id, name, email, phone, source, source_meta, + status, COALESCE(notes, ''), tags, is_active, created_by, created_at, updated_at + FROM crm_leads + WHERE id = $1 AND tenant_id = $2 + ` + + var l domain.CRMLead + err := r.db.QueryRow(query, id, tenantID).Scan( + &l.ID, &l.TenantID, &l.CustomerID, &l.FunnelID, &l.StageID, &l.Name, &l.Email, &l.Phone, &l.Source, &l.SourceMeta, + &l.Status, &l.Notes, pq.Array(&l.Tags), &l.IsActive, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt, + ) + if err != nil { + return nil, err + } + + return &l, nil +} + +func (r *CRMRepository) UpdateLead(lead *domain.CRMLead) error { + query := ` + UPDATE crm_leads SET + customer_id = $1, + funnel_id = $2, + stage_id = $3, + name = $4, + email = $5, + phone = $6, + source = $7, + source_meta = $8, + status = $9, + notes = $10, + tags = $11, + is_active = $12 + WHERE id = $13 AND tenant_id = $14 + ` + + result, err := r.db.Exec( + query, + lead.CustomerID, lead.FunnelID, lead.StageID, lead.Name, lead.Email, lead.Phone, lead.Source, lead.SourceMeta, + lead.Status, lead.Notes, pq.Array(lead.Tags), lead.IsActive, + lead.ID, lead.TenantID, + ) + if err != nil { + return err + } + + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return fmt.Errorf("lead not found") + } + + return nil +} + +func (r *CRMRepository) DeleteLead(id string, tenantID string) error { + query := `DELETE FROM crm_leads WHERE id = $1 AND tenant_id = $2` + result, err := r.db.Exec(query, id, tenantID) + if err != nil { + return err + } + + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return fmt.Errorf("lead not found") + } + return nil +} + +func (r *CRMRepository) GetLeadByEmailOrPhone(tenantID, email, phone string) (*domain.CRMLead, error) { + query := ` + SELECT id, tenant_id, customer_id, name, email, phone, source, source_meta, + status, COALESCE(notes, ''), tags, is_active, created_by, created_at, updated_at + FROM crm_leads + WHERE tenant_id = $1 + AND ( + (email IS NOT NULL AND $2 <> '' AND LOWER(email) = LOWER($2)) + OR (phone IS NOT NULL AND $3 <> '' AND phone = $3) + ) + ORDER BY created_at DESC + LIMIT 1 + ` + + var l domain.CRMLead + err := r.db.QueryRow(query, tenantID, email, phone).Scan( + &l.ID, &l.TenantID, &l.CustomerID, &l.Name, &l.Email, &l.Phone, &l.Source, &l.SourceMeta, + &l.Status, &l.Notes, pq.Array(&l.Tags), &l.IsActive, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt, + ) + if err != nil { + return nil, err + } + + return &l, nil +} + +func (r *CRMRepository) RemoveLeadFromList(leadID, listID string) error { + query := `DELETE FROM crm_lead_lists WHERE lead_id = $1 AND list_id = $2` + _, err := r.db.Exec(query, leadID, listID) + return err +} + +func (r *CRMRepository) GetLeadLists(leadID string) ([]domain.CRMList, error) { + query := ` + SELECT l.id, l.tenant_id, l.name, COALESCE(l.description, ''), l.color, l.created_by, + l.created_at, l.updated_at + FROM crm_lists l + INNER JOIN crm_lead_lists ll ON l.id = ll.list_id + WHERE ll.lead_id = $1 + ORDER BY l.name + ` + + rows, err := r.db.Query(query, leadID) + if err != nil { + return nil, err + } + defer rows.Close() + + var lists []domain.CRMList + for rows.Next() { + var l domain.CRMList + err := rows.Scan( + &l.ID, &l.TenantID, &l.Name, &l.Description, &l.Color, &l.CreatedBy, + &l.CreatedAt, &l.UpdatedAt, + ) + if err != nil { + return nil, err + } + lists = append(lists, l) + } + + return lists, nil +} + +func (r *CRMRepository) GetListByName(tenantID, name string) (*domain.CRMList, error) { + query := ` + SELECT id, tenant_id, name, description, color, created_by, created_at, updated_at + FROM crm_lists + WHERE tenant_id = $1 AND LOWER(name) = LOWER($2) + LIMIT 1 + ` + + var l domain.CRMList + err := r.db.QueryRow(query, tenantID, name).Scan( + &l.ID, &l.TenantID, &l.Name, &l.Description, &l.Color, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt, + ) + if err != nil { + return nil, err + } + return &l, nil +} + +// CreateShareToken cria um novo token de compartilhamento +func (r *CRMRepository) CreateShareToken(token *domain.CRMShareToken) error { + query := ` + INSERT INTO crm_share_tokens (id, tenant_id, customer_id, token, expires_at, created_by, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ` + _, err := r.db.Exec(query, token.ID, token.TenantID, token.CustomerID, token.Token, token.ExpiresAt, token.CreatedBy, token.CreatedAt) + return err +} + +// GetShareTokenByToken busca um token de compartilhamento pelo token +func (r *CRMRepository) GetShareTokenByToken(token string) (*domain.CRMShareToken, error) { + query := ` + SELECT id, tenant_id, customer_id, token, expires_at, created_by, created_at + FROM crm_share_tokens + WHERE token = $1 + ` + + var st domain.CRMShareToken + err := r.db.QueryRow(query, token).Scan( + &st.ID, &st.TenantID, &st.CustomerID, &st.Token, &st.ExpiresAt, &st.CreatedBy, &st.CreatedAt, + ) + if err != nil { + return nil, err + } + return &st, nil +} + +// GetLeadsByCustomerID retorna todos os leads de um cliente específico +func (r *CRMRepository) GetLeadsByCustomerID(customerID string) ([]domain.CRMLead, error) { + query := ` + SELECT id, tenant_id, customer_id, name, email, phone, source, source_meta, + status, notes, tags, is_active, created_by, created_at, updated_at + FROM crm_leads + WHERE customer_id = $1 AND is_active = true + ORDER BY created_at DESC + ` + + rows, err := r.db.Query(query, customerID) + if err != nil { + return nil, err + } + defer rows.Close() + + var leads []domain.CRMLead + for rows.Next() { + var l domain.CRMLead + err := rows.Scan( + &l.ID, &l.TenantID, &l.CustomerID, &l.Name, &l.Email, &l.Phone, &l.Source, &l.SourceMeta, + &l.Status, &l.Notes, &l.Tags, &l.IsActive, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt, + ) + if err != nil { + return nil, err + } + leads = append(leads, l) + } + + if leads == nil { + leads = []domain.CRMLead{} + } + + return leads, nil +} + +// GetListsByCustomerID retorna todas as listas que possuem leads de um cliente específico +func (r *CRMRepository) GetListsByCustomerID(customerID string) ([]domain.CRMList, error) { + query := ` + SELECT DISTINCT l.id, l.tenant_id, l.name, l.description, l.color, l.created_by, + l.created_at, l.updated_at + FROM crm_lists l + INNER JOIN crm_lead_lists ll ON l.id = ll.list_id + INNER JOIN crm_leads le ON ll.lead_id = le.id + WHERE le.customer_id = $1 + ORDER BY l.name + ` + + rows, err := r.db.Query(query, customerID) + if err != nil { + return nil, err + } + defer rows.Close() + + var lists []domain.CRMList + for rows.Next() { + var l domain.CRMList + err := rows.Scan( + &l.ID, &l.TenantID, &l.Name, &l.Description, &l.Color, &l.CreatedBy, + &l.CreatedAt, &l.UpdatedAt, + ) + if err != nil { + return nil, err + } + lists = append(lists, l) + } + + if lists == nil { + lists = []domain.CRMList{} + } + + return lists, nil +} + +// GetCustomerByEmail busca um cliente pelo email +func (r *CRMRepository) GetCustomerByEmail(email string) (*domain.CRMCustomer, error) { + query := ` + SELECT id, tenant_id, name, email, + COALESCE(phone, '') as phone, + COALESCE(company, '') as company, + COALESCE(position, '') as position, + COALESCE(address, '') as address, + COALESCE(city, '') as city, + COALESCE(state, '') as state, + COALESCE(zip_code, '') as zip_code, + COALESCE(country, '') as country, + COALESCE(notes, '{}') as notes, + COALESCE(tags, '{}') as tags, + is_active, + created_by, + created_at, + updated_at, + COALESCE(password_hash, '') as password_hash, + has_portal_access, + portal_last_login, + portal_created_at + FROM crm_customers + WHERE email = $1 AND is_active = true + ` + + var c domain.CRMCustomer + var createdBy sql.NullString + err := r.db.QueryRow(query, email).Scan( + &c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position, + &c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags), + &c.IsActive, &createdBy, &c.CreatedAt, &c.UpdatedAt, + &c.PasswordHash, &c.HasPortalAccess, &c.PortalLastLogin, &c.PortalCreatedAt, + ) + + if err != nil { + return nil, err + } + + if createdBy.Valid { + c.CreatedBy = createdBy.String + } + + return &c, nil +} + +// UpdateCustomerLastLogin atualiza o último login do cliente no portal +func (r *CRMRepository) UpdateCustomerLastLogin(customerID string) error { + query := `UPDATE crm_customers SET portal_last_login = NOW() WHERE id = $1` + _, err := r.db.Exec(query, customerID) + return err +} + +// SetCustomerPortalAccess define o acesso ao portal e senha para um cliente +func (r *CRMRepository) SetCustomerPortalAccess(customerID, passwordHash string, hasAccess bool) error { + query := ` + UPDATE crm_customers + SET password_hash = $1, + has_portal_access = $2, + portal_created_at = CASE + WHEN portal_created_at IS NULL THEN NOW() + ELSE portal_created_at + END + WHERE id = $3 + ` + _, err := r.db.Exec(query, passwordHash, hasAccess, customerID) + return err +} + +// UpdateCustomerPassword atualiza apenas a senha do cliente +func (r *CRMRepository) UpdateCustomerPassword(customerID, passwordHash string) error { + query := ` + UPDATE crm_customers + SET password_hash = $1 + WHERE id = $2 + ` + _, err := r.db.Exec(query, passwordHash, customerID) + return err +} + +// UpdateCustomerLogo atualiza apenas o logo do cliente +func (r *CRMRepository) UpdateCustomerLogo(customerID, tenantID, logoURL string) error { + query := ` + UPDATE crm_customers + SET logo_url = $1 + WHERE id = $2 AND tenant_id = $3 + ` + _, err := r.db.Exec(query, logoURL, customerID, tenantID) + return err +} + +// GetCustomerByEmailAndTenant checks if a customer with the given email exists for the tenant +func (r *CRMRepository) GetCustomerByEmailAndTenant(email string, tenantID string) (*domain.CRMCustomer, error) { + query := ` + SELECT id, tenant_id, name, email, phone, company, position, + address, city, state, zip_code, country, notes, tags, + is_active, created_by, created_at, updated_at + FROM crm_customers + WHERE LOWER(email) = LOWER($1) AND tenant_id = $2 + LIMIT 1 + ` + + var c domain.CRMCustomer + err := r.db.QueryRow(query, email, tenantID).Scan( + &c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position, + &c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags), + &c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, + ) + + if err == sql.ErrNoRows { + return nil, nil // Not found is not an error + } + if err != nil { + return nil, err + } + + return &c, nil +} + +// TenantExists checks if a tenant with the given ID exists +func (r *CRMRepository) TenantExists(tenantID string) (bool, error) { + query := `SELECT EXISTS(SELECT 1 FROM tenants WHERE id = $1 AND is_active = true)` + var exists bool + err := r.db.QueryRow(query, tenantID).Scan(&exists) + return exists, err +} + +// EnableCustomerPortalAccess habilita o acesso ao portal para um cliente (usado na aprovação) +func (r *CRMRepository) EnableCustomerPortalAccess(customerID string) error { + query := ` + UPDATE crm_customers + SET has_portal_access = true, + portal_created_at = COALESCE(portal_created_at, NOW()) + WHERE id = $1 + ` + _, err := r.db.Exec(query, customerID) + return err +} + +// GetCustomerByCPF checks if a customer with the given CPF exists for the tenant +func (r *CRMRepository) GetCustomerByCPF(cpf string, tenantID string) (*domain.CRMCustomer, error) { + query := ` + SELECT id, tenant_id, name, email, phone, company, position, + address, city, state, zip_code, country, notes, tags, + is_active, created_by, created_at, updated_at + FROM crm_customers + WHERE tenant_id = $1 AND notes LIKE '%"cpf":"' || $2 || '"%' + LIMIT 1 + ` + + var c domain.CRMCustomer + err := r.db.QueryRow(query, tenantID, cpf).Scan( + &c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position, + &c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags), + &c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, + ) + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + return &c, nil +} + +// GetCustomerByCNPJ checks if a customer with the given CNPJ exists for the tenant +func (r *CRMRepository) GetCustomerByCNPJ(cnpj string, tenantID string) (*domain.CRMCustomer, error) { + query := ` + SELECT id, tenant_id, name, email, phone, company, position, + address, city, state, zip_code, country, notes, tags, + is_active, created_by, created_at, updated_at + FROM crm_customers + WHERE tenant_id = $1 AND notes LIKE '%"cnpj":"' || $2 || '"%' + LIMIT 1 + ` + + var c domain.CRMCustomer + err := r.db.QueryRow(query, tenantID, cnpj).Scan( + &c.ID, &c.TenantID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.Position, + &c.Address, &c.City, &c.State, &c.ZipCode, &c.Country, &c.Notes, pq.Array(&c.Tags), + &c.IsActive, &c.CreatedBy, &c.CreatedAt, &c.UpdatedAt, + ) + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + return &c, nil +} + +func (r *CRMRepository) GetLeadsByListID(listID string) ([]domain.CRMLead, error) { + query := ` + SELECT l.id, l.tenant_id, l.customer_id, l.funnel_id, l.stage_id, l.name, l.email, l.phone, + l.source, l.source_meta, l.status, COALESCE(l.notes, ''), l.tags, + l.is_active, COALESCE(l.created_by::text, '') as created_by, l.created_at, l.updated_at + FROM crm_leads l + INNER JOIN crm_lead_lists ll ON l.id = ll.lead_id + WHERE ll.list_id = $1 + ORDER BY l.created_at DESC + ` + + rows, err := r.db.Query(query, listID) + if err != nil { + return nil, err + } + defer rows.Close() + + var leads []domain.CRMLead + for rows.Next() { + var l domain.CRMLead + var sourceMeta []byte + err := rows.Scan( + &l.ID, &l.TenantID, &l.CustomerID, &l.FunnelID, &l.StageID, &l.Name, &l.Email, &l.Phone, + &l.Source, &sourceMeta, &l.Status, &l.Notes, pq.Array(&l.Tags), + &l.IsActive, &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt, + ) + if err != nil { + log.Printf("Error scanning lead from list: %v", err) + continue + } + if sourceMeta != nil { + l.SourceMeta = sourceMeta + } + leads = append(leads, l) + } + + return leads, nil +} + +// ==================== FUNNELS & STAGES ==================== + +func (r *CRMRepository) CreateFunnel(funnel *domain.CRMFunnel) error { + query := ` + INSERT INTO crm_funnels (id, tenant_id, name, description, is_default) + VALUES ($1, $2, $3, $4, $5) + RETURNING created_at, updated_at + ` + return r.db.QueryRow(query, funnel.ID, funnel.TenantID, funnel.Name, funnel.Description, funnel.IsDefault). + Scan(&funnel.CreatedAt, &funnel.UpdatedAt) +} + +func (r *CRMRepository) GetFunnelsByTenant(tenantID string) ([]domain.CRMFunnel, error) { + query := `SELECT id, tenant_id, name, COALESCE(description, ''), is_default, created_at, updated_at FROM crm_funnels WHERE tenant_id = $1 ORDER BY created_at ASC` + rows, err := r.db.Query(query, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + + var funnels []domain.CRMFunnel + for rows.Next() { + var f domain.CRMFunnel + if err := rows.Scan(&f.ID, &f.TenantID, &f.Name, &f.Description, &f.IsDefault, &f.CreatedAt, &f.UpdatedAt); err != nil { + return nil, err + } + funnels = append(funnels, f) + } + return funnels, nil +} + +func (r *CRMRepository) GetFunnelByID(id, tenantID string) (*domain.CRMFunnel, error) { + query := `SELECT id, tenant_id, name, COALESCE(description, ''), is_default, created_at, updated_at FROM crm_funnels WHERE id = $1 AND tenant_id = $2` + var f domain.CRMFunnel + err := r.db.QueryRow(query, id, tenantID).Scan(&f.ID, &f.TenantID, &f.Name, &f.Description, &f.IsDefault, &f.CreatedAt, &f.UpdatedAt) + if err != nil { + return nil, err + } + return &f, nil +} + +func (r *CRMRepository) UpdateFunnel(funnel *domain.CRMFunnel) error { + query := `UPDATE crm_funnels SET name = $1, description = $2, is_default = $3, updated_at = CURRENT_TIMESTAMP WHERE id = $4 AND tenant_id = $5` + _, err := r.db.Exec(query, funnel.Name, funnel.Description, funnel.IsDefault, funnel.ID, funnel.TenantID) + return err +} + +func (r *CRMRepository) DeleteFunnel(id, tenantID string) error { + query := `DELETE FROM crm_funnels WHERE id = $1 AND tenant_id = $2` + _, err := r.db.Exec(query, id, tenantID) + return err +} + +func (r *CRMRepository) CreateFunnelStage(stage *domain.CRMFunnelStage) error { + query := ` + INSERT INTO crm_funnel_stages (id, funnel_id, name, description, color, order_index) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING created_at, updated_at + ` + return r.db.QueryRow(query, stage.ID, stage.FunnelID, stage.Name, stage.Description, stage.Color, stage.OrderIndex). + Scan(&stage.CreatedAt, &stage.UpdatedAt) +} + +func (r *CRMRepository) GetStagesByFunnelID(funnelID string) ([]domain.CRMFunnelStage, error) { + query := `SELECT id, funnel_id, name, COALESCE(description, ''), color, order_index, created_at, updated_at FROM crm_funnel_stages WHERE funnel_id = $1 ORDER BY order_index ASC` + rows, err := r.db.Query(query, funnelID) + if err != nil { + return nil, err + } + defer rows.Close() + + var stages []domain.CRMFunnelStage + for rows.Next() { + var s domain.CRMFunnelStage + if err := rows.Scan(&s.ID, &s.FunnelID, &s.Name, &s.Description, &s.Color, &s.OrderIndex, &s.CreatedAt, &s.UpdatedAt); err != nil { + return nil, err + } + stages = append(stages, s) + } + return stages, nil +} + +func (r *CRMRepository) UpdateFunnelStage(stage *domain.CRMFunnelStage) error { + query := `UPDATE crm_funnel_stages SET name = $1, description = $2, color = $3, order_index = $4, updated_at = CURRENT_TIMESTAMP WHERE id = $5` + _, err := r.db.Exec(query, stage.Name, stage.Description, stage.Color, stage.OrderIndex, stage.ID) + return err +} + +func (r *CRMRepository) DeleteFunnelStage(id string) error { + query := `DELETE FROM crm_funnel_stages WHERE id = $1` + _, err := r.db.Exec(query, id) + return err +} + +func (r *CRMRepository) UpdateLeadStage(leadID, tenantID, funnelID, stageID string) error { + query := `UPDATE crm_leads SET funnel_id = $1, stage_id = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3 AND tenant_id = $4` + _, err := r.db.Exec(query, funnelID, stageID, leadID, tenantID) + return err +} + +func (r *CRMRepository) EnsureDefaultFunnel(tenantID string) (string, error) { + // Check if tenant already has a funnel + var funnelID string + query := `SELECT id FROM crm_funnels WHERE tenant_id = $1 LIMIT 1` + err := r.db.QueryRow(query, tenantID).Scan(&funnelID) + if err == nil { + return funnelID, nil + } + + // If not, create default using the function we defined in migration + query = `SELECT create_default_crm_funnel($1)` + err = r.db.QueryRow(query, tenantID).Scan(&funnelID) + return funnelID, err +} + + diff --git a/backend/internal/repository/user_repository.go b/backend/internal/repository/user_repository.go index 3787c63..46170f9 100644 --- a/backend/internal/repository/user_repository.go +++ b/backend/internal/repository/user_repository.go @@ -161,3 +161,73 @@ func (r *UserRepository) FindAdminByTenantID(tenantID uuid.UUID) (*domain.User, return user, nil } +// ListByTenantID returns all users for a tenant (excluding the tenant admin) +func (r *UserRepository) ListByTenantID(tenantID uuid.UUID) ([]domain.User, error) { + query := ` + SELECT id, tenant_id, email, password_hash, first_name, role, created_at, updated_at, + agency_role, created_by, collaborator_created_at + FROM users + WHERE tenant_id = $1 AND is_active = true AND role != 'SUPERADMIN' + ORDER BY created_at DESC + ` + + rows, err := r.db.Query(query, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + + var users []domain.User + for rows.Next() { + user := domain.User{} + err := rows.Scan( + &user.ID, + &user.TenantID, + &user.Email, + &user.Password, + &user.Name, + &user.Role, + &user.CreatedAt, + &user.UpdatedAt, + &user.AgencyRole, + &user.CreatedBy, + &user.CollaboratorCreatedAt, + ) + if err != nil { + return nil, err + } + users = append(users, user) + } + + return users, rows.Err() +} + +// GetByID returns a user by ID +func (r *UserRepository) GetByID(id uuid.UUID) (*domain.User, error) { + return r.FindByID(id) +} + +// Delete marks a user as inactive +func (r *UserRepository) Delete(id uuid.UUID) error { + query := ` + UPDATE users + SET is_active = false, updated_at = NOW() + WHERE id = $1 + ` + + result, err := r.db.Exec(query, id) + if err != nil { + return err + } + + rows, err := result.RowsAffected() + if err != nil { + return err + } + + if rows == 0 { + return sql.ErrNoRows + } + + return nil +} \ No newline at end of file diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index 817abb5..aa12bad 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -24,17 +24,19 @@ var ( // AuthService handles authentication business logic type AuthService struct { - userRepo *repository.UserRepository - tenantRepo *repository.TenantRepository - cfg *config.Config + userRepo *repository.UserRepository + tenantRepo *repository.TenantRepository + crmRepo *repository.CRMRepository + cfg *config.Config } // NewAuthService creates a new auth service -func NewAuthService(userRepo *repository.UserRepository, tenantRepo *repository.TenantRepository, cfg *config.Config) *AuthService { +func NewAuthService(userRepo *repository.UserRepository, tenantRepo *repository.TenantRepository, crmRepo *repository.CRMRepository, cfg *config.Config) *AuthService { return &AuthService{ - userRepo: userRepo, - tenantRepo: tenantRepo, - cfg: cfg, + userRepo: userRepo, + tenantRepo: tenantRepo, + crmRepo: crmRepo, + cfg: cfg, } } @@ -175,3 +177,158 @@ func (s *AuthService) ChangePassword(userID string, currentPassword, newPassword func parseUUID(s string) (uuid.UUID, error) { return uuid.Parse(s) } + +// GenerateCustomerToken gera um token JWT para um cliente do CRM +func (s *AuthService) GenerateCustomerToken(customerID, tenantID, email string) (string, error) { + claims := jwt.MapClaims{ + "customer_id": customerID, + "tenant_id": tenantID, + "email": email, + "type": "customer_portal", + "exp": time.Now().Add(time.Hour * 24 * 30).Unix(), // 30 dias + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(s.cfg.JWT.Secret)) +} + +// UnifiedLogin autentica qualquer tipo de usuário (agência ou cliente) e retorna token unificado +func (s *AuthService) UnifiedLogin(req domain.UnifiedLoginRequest) (*domain.UnifiedLoginResponse, error) { + email := req.Email + password := req.Password + + // TENTATIVA 1: Buscar em users (agência) + user, err := s.userRepo.FindByEmail(email) + if err == nil && user != nil { + // Verificar senha + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil { + log.Printf("❌ Password mismatch for agency user %s", email) + return nil, ErrInvalidCredentials + } + + // SUPERADMIN usa login próprio em outro domínio, não deve usar esta rota + if user.Role == "SUPERADMIN" { + log.Printf("🚫 SUPERADMIN attempted unified login - redirecting to proper endpoint") + return nil, errors.New("superadmins devem usar o painel administrativo") + } + + // Gerar token unificado para agency_user + token, err := s.generateUnifiedToken(user.ID.String(), domain.UserTypeAgency, email, user.Role, user.AgencyRole, user.TenantID) + if err != nil { + log.Printf("❌ Error generating unified token: %v", err) + return nil, err + } + + // Buscar subdomain se tiver tenant + subdomain := "" + tenantID := "" + if user.TenantID != nil { + tenantID = user.TenantID.String() + tenant, err := s.tenantRepo.FindByID(*user.TenantID) + if err == nil && tenant != nil { + subdomain = tenant.Subdomain + } + } + + log.Printf("✅ Agency user logged in: %s (type=agency_user, role=%s, agency_role=%s)", email, user.Role, user.AgencyRole) + + return &domain.UnifiedLoginResponse{ + Token: token, + UserType: domain.UserTypeAgency, + UserID: user.ID.String(), + Email: email, + Name: user.Name, + Role: user.Role, + AgencyRole: user.AgencyRole, + TenantID: tenantID, + Subdomain: subdomain, + }, nil + } + + // TENTATIVA 2: Buscar em crm_customers + log.Printf("🔍 Attempting to find customer in CRM: %s", email) + customer, err := s.crmRepo.GetCustomerByEmail(email) + log.Printf("🔍 CRM GetCustomerByEmail result: customer=%v, err=%v", customer != nil, err) + if err == nil && customer != nil { + // Verificar se tem acesso ao portal + if !customer.HasPortalAccess { + log.Printf("🚫 Customer %s has no portal access", email) + return nil, errors.New("acesso ao portal não autorizado. Entre em contato com o administrador") + } + + // Verificar senha + if customer.PasswordHash == "" { + log.Printf("❌ Customer %s has no password set", email) + return nil, ErrInvalidCredentials + } + + if err := bcrypt.CompareHashAndPassword([]byte(customer.PasswordHash), []byte(password)); err != nil { + log.Printf("❌ Password mismatch for customer %s", email) + return nil, ErrInvalidCredentials + } + + // Atualizar último login + if err := s.crmRepo.UpdateCustomerLastLogin(customer.ID); err != nil { + log.Printf("⚠️ Warning: Failed to update last login for customer %s: %v", customer.ID, err) + } + + // Gerar token unificado + tenantUUID, _ := uuid.Parse(customer.TenantID) + token, err := s.generateUnifiedToken(customer.ID, domain.UserTypeCustomer, email, "", "", &tenantUUID) + if err != nil { + log.Printf("❌ Error generating unified token: %v", err) + return nil, err + } + + // Buscar subdomain do tenant + subdomain := "" + if tenantUUID != uuid.Nil { + tenant, err := s.tenantRepo.FindByID(tenantUUID) + if err == nil && tenant != nil { + subdomain = tenant.Subdomain + } + } + + log.Printf("✅ Customer logged in: %s (tenant=%s)", email, customer.TenantID) + + return &domain.UnifiedLoginResponse{ + Token: token, + UserType: domain.UserTypeCustomer, + UserID: customer.ID, + Email: email, + Name: customer.Name, + TenantID: customer.TenantID, + Subdomain: subdomain, + }, nil + } + + // Não encontrou em nenhuma tabela + log.Printf("❌ User not found: %s", email) + return nil, ErrInvalidCredentials +} + +// generateUnifiedToken cria um JWT com claims unificadas +func (s *AuthService) generateUnifiedToken(userID string, userType domain.UserType, email, role, agencyRole string, tenantID *uuid.UUID) (string, error) { + tenantIDStr := "" + if tenantID != nil { + tenantIDStr = tenantID.String() + } + + claims := domain.UnifiedClaims{ + UserID: userID, + UserType: userType, + TenantID: tenantIDStr, + Email: email, + Role: role, + AgencyRole: agencyRole, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24 * 30)), // 30 dias + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(s.cfg.JWT.Secret)) +} + + diff --git a/backups/.superadmin_password.txt b/backups/.superadmin_password.txt new file mode 100644 index 0000000000000000000000000000000000000000..0242b981ce90cdc8efc14c1d888aa06dc312c7d3 GIT binary patch literal 62 zcmezW&ygXW!GWQc!Jk1DNJcSOGh{NPF=#SaGMF>uGiWh*0cFw|{1|*0k{ALQj2H}n N*pxw$ftP`c0RWfr3OE1& literal 0 HcmV?d00001 diff --git a/backups/aggios_backup_2025-12-13_19-56-18.sql b/backups/aggios_backup_2025-12-13_19-56-18.sql new file mode 100644 index 0000000000000000000000000000000000000000..412a23929f85e192798b2ddf76c7119cc52f2c68 GIT binary patch literal 18824 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 literal 0 HcmV?d00001 diff --git a/backups/aggios_backup_2025-12-13_20-12-49.sql b/backups/aggios_backup_2025-12-13_20-12-49.sql new file mode 100644 index 0000000000000000000000000000000000000000..19d708797917db98663c53f115df7bbd892f0526 GIT binary patch literal 19230 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 literal 0 HcmV?d00001 diff --git a/backups/aggios_backup_2025-12-13_20-17-59.sql b/backups/aggios_backup_2025-12-13_20-17-59.sql new file mode 100644 index 0000000000000000000000000000000000000000..5b4e56cf3ea99cbf1f4f365d829de8bf1c8e52f4 GIT binary patch literal 19644 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

kW626^J$;eEirytaoic{FEv9r$u{9(&75 z?||fGbe}g94O|+Hv!2rN`4IBd_tMR5)*mvpL^T+HHnQ(k5v4zH13qtV8Yy-f}m^YP2)TOPpQXvHN5j%8-eOp{iX561#wImyKTm^Y15heePx=YtTG* zB5eHHk=4LJW!i6jDY{U^lOoAy$CZ_HeB^oW^dDngw#7P<|H*J?JNW-F{;h{S8U8y1 zz4!0_yC=mtTG7*V4tV$UqlmilUZV&~e;yU-i??nU@5<_mAN6VUBtTD|j*n}o!}sp# zL7=U?)Wev(*dKUMB Equipe** +3. Clique em "Convidar Colaborador" + +### Passo 3: Convidar um colaborador + +- Preencha Nome e Email +- Clique em "Convidar" +- Copie a senha temporária (16 caracteres) +- Compartilhe com o colaborador + +## Fluxo de Funcionamento + +### Quando um Colaborador é Convidado + +1. Novo usuário é criado com `agency_role = 'collaborator'` +2. Recebe uma **senha temporária aleatória** +3. Email é adicionado à agência do owner + +### Quando um Colaborador Faz Login + +1. JWT contém `"agency_role": "collaborator"` +2. Frontend detecta a restrição + - Botões de editar/deletar desabilitados + - Mensagens de acesso restrito +3. Backend bloqueia POST/PUT/DELETE em `/api/crm/*` + - Retorna 403 Forbidden se tentar + +### Dados no JWT + +```json +{ + "user_id": "uuid", + "user_type": "agency_user", + "agency_role": "owner", // ou "collaborator" + "email": "usuario@exemplo.com", + "role": "ADMIN_AGENCIA", + "tenant_id": "uuid", + "exp": 1234567890 +} +``` + +## Banco de Dados + +### Novos Campos na Tabela `users` + +```sql +- agency_role VARCHAR(50) -- 'owner' ou 'collaborator' +- created_by UUID REFERENCES users -- Quem criou este colaborador +- collaborator_created_at TIMESTAMP -- Quando foi adicionado +``` + +## Endpoints da API + +### Listar Colaboradores +``` +GET /api/agency/collaborators +Headers: Authorization: Bearer +Resposta: Array de Collaborators +Restrição: Apenas owner pode usar +``` + +### Convidar Colaborador +``` +POST /api/agency/collaborators/invite +Body: { "email": "...", "name": "..." } +Resposta: { "temporary_password": "..." } +Restrição: Apenas owner pode usar +``` + +### Remover Colaborador +``` +DELETE /api/agency/collaborators/{id} +Restrição: Apenas owner pode usar +``` + +## Página de Interface + +**Localização:** `/configuracoes` → Aba "Equipe" + +### Funcionalidades +- ✅ Ver lista de colaboradores (dono apenas) +- ✅ Convidar novo colaborador +- ✅ Copiar senha temporária +- ✅ Remover colaborador (com confirmação) +- ✅ Ver data de adição de cada colaborador +- ✅ Indicador visual (badge) do tipo de usuário + +## Troubleshooting + +### "Apenas o dono da agência pode gerenciar colaboradores" + +**Causa:** O usuário não tem `agency_role = 'owner'` + +**Solução:** +```sql +UPDATE users +SET agency_role = 'owner' +WHERE id = 'seu-user-id'; +``` + +### Colaborador consegue editar dados (bug) + +**Causa:** A middleware de read-only não está ativa + +**Status:** Implementada em `backend/internal/api/middleware/collaborator_readonly.go` + +**Para ativar:** Descomente a linha em `main.go` que aplica `CheckCollaboratorReadOnly` + +### Senha temporária não aparece + +**Verificar:** +1. API `/api/agency/collaborators/invite` retorna 200? +2. Response JSON tem o campo `temporary_password`? +3. Verificar logs do backend para erros + +## Próximas Melhorias + +- [ ] Permitir editar nome/email do colaborador +- [ ] Definir permissões granulares por colaborador +- [ ] Histórico de ações feitas por cada colaborador +- [ ] 2FA para owners +- [ ] Auditoria de quem removeu quem diff --git a/front-end-agency/Dockerfile b/front-end-agency/Dockerfile index 47bef17..ee96a7c 100644 --- a/front-end-agency/Dockerfile +++ b/front-end-agency/Dockerfile @@ -30,6 +30,12 @@ RUN npm ci --omit=dev COPY --from=builder /app/.next ./.next COPY --from=builder /app/public ./public +# Create uploads directory +RUN mkdir -p ./public/uploads/logos && chown -R node:node ./public/uploads + +# Switch to node user +USER node + # Expose port EXPOSE 3000 diff --git a/front-end-agency/app/(agency)/AgencyLayoutClient.tsx b/front-end-agency/app/(agency)/AgencyLayoutClient.tsx index 0a07c06..7d6b34e 100644 --- a/front-end-agency/app/(agency)/AgencyLayoutClient.tsx +++ b/front-end-agency/app/(agency)/AgencyLayoutClient.tsx @@ -3,110 +3,31 @@ import { DashboardLayout } from '@/components/layout/DashboardLayout'; import { AgencyBranding } from '@/components/layout/AgencyBranding'; import AuthGuard from '@/components/auth/AuthGuard'; +import { CRMFilterProvider } from '@/contexts/CRMFilterContext'; import { useState, useEffect } from 'react'; import { HomeIcon, RocketLaunchIcon, - ChartBarIcon, - BriefcaseIcon, - LifebuoyIcon, - CreditCardIcon, - DocumentTextIcon, - FolderIcon, - ShareIcon, + UserPlusIcon, + RectangleStackIcon, + UsersIcon, + MegaphoneIcon, } from '@heroicons/react/24/outline'; const AGENCY_MENU_ITEMS = [ - { id: 'dashboard', label: 'Visão Geral', href: '/dashboard', icon: HomeIcon }, + { id: 'dashboard', label: 'Dashboard', href: '/dashboard', icon: HomeIcon }, { id: 'crm', label: 'CRM', href: '/crm', icon: RocketLaunchIcon, + requiredSolution: 'crm', subItems: [ - { label: 'Dashboard', href: '/crm' }, - { label: 'Clientes', href: '/crm/clientes' }, - { label: 'Funis', href: '/crm/funis' }, - { label: 'Negociações', href: '/crm/negociacoes' }, - ] - }, - { - id: 'erp', - label: 'ERP', - href: '/erp', - icon: ChartBarIcon, - subItems: [ - { label: 'Dashboard', href: '/erp' }, - { label: 'Fluxo de Caixa', href: '/erp/fluxo-caixa' }, - { label: 'Contas a Pagar', href: '/erp/contas-pagar' }, - { label: 'Contas a Receber', href: '/erp/contas-receber' }, - ] - }, - { - id: 'projetos', - label: 'Projetos', - href: '/projetos', - icon: BriefcaseIcon, - subItems: [ - { label: 'Dashboard', href: '/projetos' }, - { label: 'Meus Projetos', href: '/projetos/lista' }, - { label: 'Tarefas', href: '/projetos/tarefas' }, - { label: 'Cronograma', href: '/projetos/cronograma' }, - ] - }, - { - id: 'helpdesk', - label: 'Helpdesk', - href: '/helpdesk', - icon: LifebuoyIcon, - subItems: [ - { label: 'Dashboard', href: '/helpdesk' }, - { label: 'Chamados', href: '/helpdesk/chamados' }, - { label: 'Base de Conhecimento', href: '/helpdesk/kb' }, - ] - }, - { - id: 'pagamentos', - label: 'Pagamentos', - href: '/pagamentos', - icon: CreditCardIcon, - subItems: [ - { label: 'Dashboard', href: '/pagamentos' }, - { label: 'Cobranças', href: '/pagamentos/cobrancas' }, - { label: 'Assinaturas', href: '/pagamentos/assinaturas' }, - ] - }, - { - id: 'contratos', - label: 'Contratos', - href: '/contratos', - icon: DocumentTextIcon, - subItems: [ - { label: 'Dashboard', href: '/contratos' }, - { label: 'Ativos', href: '/contratos/ativos' }, - { label: 'Modelos', href: '/contratos/modelos' }, - ] - }, - { - id: 'documentos', - label: 'Documentos', - href: '/documentos', - icon: FolderIcon, - subItems: [ - { label: 'Meus Arquivos', href: '/documentos' }, - { label: 'Compartilhados', href: '/documentos/compartilhados' }, - { label: 'Lixeira', href: '/documentos/lixeira' }, - ] - }, - { - id: 'social', - label: 'Redes Sociais', - href: '/social', - icon: ShareIcon, - subItems: [ - { label: 'Dashboard', href: '/social' }, - { label: 'Agendamento', href: '/social/agendamento' }, - { label: 'Relatórios', href: '/social/relatorios' }, + { label: 'Visão Geral', href: '/crm', icon: HomeIcon }, + { label: 'Funis de Vendas', href: '/crm/funis', icon: RectangleStackIcon }, + { label: 'Clientes', href: '/crm/clientes', icon: UsersIcon }, + { label: 'Campanhas', href: '/crm/campanhas', icon: MegaphoneIcon }, + { label: 'Leads', href: '/crm/leads', icon: UserPlusIcon }, ] }, ]; @@ -148,7 +69,8 @@ export function AgencyLayoutClient({ children, colors }: AgencyLayoutClientProps // Sempre mostrar dashboard + soluções disponíveis const filtered = AGENCY_MENU_ITEMS.filter(item => { if (item.id === 'dashboard') return true; - return solutionSlugs.includes(item.id); + const requiredSolution = (item as any).requiredSolution; + return solutionSlugs.includes((requiredSolution || item.id).toLowerCase()); }); console.log('📋 Menu filtrado:', filtered.map(i => i.id)); @@ -171,11 +93,13 @@ export function AgencyLayoutClient({ children, colors }: AgencyLayoutClientProps }, []); return ( - - - - {children} - + + + + + {children} + + ); } diff --git a/front-end-agency/app/(agency)/configuracoes/page.tsx b/front-end-agency/app/(agency)/configuracoes/page.tsx index 598c0c4..dad4098 100644 --- a/front-end-agency/app/(agency)/configuracoes/page.tsx +++ b/front-end-agency/app/(agency)/configuracoes/page.tsx @@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'; import { Tab } from '@headlessui/react'; import { Button, Dialog, Input } from '@/components/ui'; import { Toaster, toast } from 'react-hot-toast'; +import TeamManagement from '@/components/team/TeamManagement'; import { BuildingOfficeIcon, PhotoIcon, @@ -1040,19 +1041,7 @@ export default function ConfiguracoesPage() { {/* Tab 3: Equipe */} -

- Gerenciamento de Equipe -

- -
- -

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

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

Campanha não encontrada

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

+ {campaign.name} +

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

Monitoramento de Leads

+

Acompanhe o progresso dos leads desta campanha no funil.

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

+ Nenhum funil configurado +

+

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

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

+ Nenhum lead encontrado +

+

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

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

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

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

Detalhes da Campanha

+
+
+
+ +

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

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

Configurações de Integração

+
+
+
+
+ +
+

Webhook de Entrada

+

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

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

Cliente Responsável

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

{campaign.customer_name}

+

Cliente Ativo

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

Esta é uma campanha geral da agência.

+
+ )} +
+ +
+

Resumo de Performance

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

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

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

Módulo de Pagamentos

+

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

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

Campanhas

+

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

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

+ Nenhuma campanha encontrada +

+

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

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

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

+

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

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