From 99d828869a7af605d6bc5d8d668f97fc88d4d598 Mon Sep 17 00:00:00 2001 From: Erik Silva Date: Wed, 17 Dec 2025 13:36:23 -0300 Subject: [PATCH] chore(release): snapshot 1.4.2 --- backend/Dockerfile | 2 +- backend/cmd/server/main.go | 107 ++- backend/generate_hash.go | 15 + backend/internal/api/handlers/backup.go | 264 ++++++ backend/internal/api/handlers/crm.go | 470 +++++++++++ backend/internal/api/handlers/plan.go | 14 +- backend/internal/api/handlers/solution.go | 252 ++++++ backend/internal/api/handlers/tenant.go | 6 +- backend/internal/domain/crm.go | 53 ++ backend/internal/domain/solution.go | 20 + backend/internal/domain/tenant.go | 14 +- backend/internal/repository/crm_repository.go | 346 ++++++++ .../repository/solution_repository.go | 300 +++++++ backend/internal/service/agency_service.go | 42 +- backend/internal/service/tenant_service.go | 82 +- docker-compose.yml | 5 + docs/backup-system.md | 186 ++++ .../app/(agency)/AgencyLayoutClient.tsx | 53 +- .../app/(agency)/contratos/page.tsx | 16 +- .../app/(agency)/crm/clientes/page.tsx | 548 ++++++++++++ .../app/(agency)/crm/funis/page.tsx | 31 + .../app/(agency)/crm/listas/page.tsx | 432 ++++++++++ .../app/(agency)/crm/negociacoes/page.tsx | 31 + front-end-agency/app/(agency)/crm/page.tsx | 153 +++- .../app/(agency)/dashboard/page.tsx | 89 +- .../app/(agency)/documentos/page.tsx | 16 +- front-end-agency/app/(agency)/erp/page.tsx | 16 +- .../app/(agency)/helpdesk/page.tsx | 16 +- .../app/(agency)/pagamentos/page.tsx | 16 +- .../app/(agency)/projetos/page.tsx | 16 +- front-end-agency/app/(agency)/social/page.tsx | 16 +- front-end-agency/app/ClientProviders.tsx | 7 + front-end-agency/app/globals.css | 21 +- front-end-agency/app/layout.tsx | 9 +- front-end-agency/app/login/page.tsx | 12 + front-end-agency/app/tokens.css | 24 + .../components/auth/AuthGuard.tsx | 11 +- .../components/auth/SolutionGuard.tsx | 74 ++ .../components/layout/AgencyBranding.tsx | 26 + .../components/layout/ConfirmDialog.tsx | 123 +++ .../components/layout/DashboardLayout.tsx | 29 +- .../components/layout/MobileBottomBar.tsx | 129 +++ .../components/layout/SidebarRail.tsx | 29 +- .../components/layout/ToastContext.tsx | 59 ++ .../components/layout/ToastNotification.tsx | 100 +++ front-end-agency/components/layout/TopBar.tsx | 85 +- .../components/ui/CommandPalette.tsx | 67 +- front-end-agency/middleware.ts | 35 +- .../app/cadastro/page.tsx | 736 ++++++++-------- front-end-dash.aggios.app/app/globals.css | 41 +- front-end-dash.aggios.app/app/layout.tsx | 9 +- front-end-dash.aggios.app/app/login/page.tsx | 44 +- .../app/superadmin/agencies/[id]/page.tsx | 440 +++++++--- .../app/superadmin/agencies/page.tsx | 254 +++++- .../app/superadmin/backup/page.tsx | 385 +++++++++ .../app/superadmin/layout.tsx | 4 + .../app/superadmin/plans/[id]/page.tsx | 513 +++++++---- .../app/superadmin/plans/page.tsx | 716 +++++++++++----- .../app/superadmin/solutions/page.tsx | 480 +++++++++++ front-end-dash.aggios.app/app/tokens.css | 24 + .../components/cadastro/DynamicBranding.tsx | 318 ++++--- .../components/layout/ConfirmDialog.tsx | 123 +++ .../components/layout/EmptyState.tsx | 33 + .../components/layout/LoadingState.tsx | 7 + .../components/layout/PageHeader.tsx | 31 + .../components/layout/Pagination.tsx | 108 +++ .../components/layout/README.md | 294 +++++++ .../components/layout/SearchBar.tsx | 24 + .../components/layout/StatusBadge.tsx | 28 + .../components/layout/StatusFilter.tsx | 61 ++ .../components/layout/ToastContext.tsx | 57 ++ .../components/layout/ToastNotification.tsx | 99 +++ .../components/plans/CreatePlanModal.tsx | 605 ++++++++----- .../components/plans/EditPlanModal.tsx | 797 ++++++++++++++++++ .../components/ui/Button.tsx | 18 +- .../components/ui/Input.tsx | 39 +- .../components/ui/SearchableSelect.tsx | 14 +- .../components/ui/Tabs.tsx | 102 +++ front-end-dash.aggios.app/next.config.ts | 10 + front-end-dash.aggios.app/package-lock.json | 28 +- front-end-dash.aggios.app/package.json | 1 - postgres/fix_admin_password.sql | 12 + .../012_add_tenant_address_fields.sql | 15 + .../migrations/013_create_solutions_table.sql | 30 + postgres/migrations/014_create_crm_tables.sql | 86 ++ .../015_create_template_solutions.sql | 16 + .../migrations/016_seed_all_solutions.sql | 28 + .../migrations/017_fix_solutions_utf8.sql | 11 + 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 ++ 95 files changed, 9933 insertions(+), 1601 deletions(-) create mode 100644 backend/generate_hash.go create mode 100644 backend/internal/api/handlers/backup.go create mode 100644 backend/internal/api/handlers/crm.go create mode 100644 backend/internal/api/handlers/solution.go create mode 100644 backend/internal/domain/crm.go create mode 100644 backend/internal/domain/solution.go create mode 100644 backend/internal/repository/crm_repository.go create mode 100644 backend/internal/repository/solution_repository.go create mode 100644 docs/backup-system.md create mode 100644 front-end-agency/app/(agency)/crm/clientes/page.tsx create mode 100644 front-end-agency/app/(agency)/crm/funis/page.tsx create mode 100644 front-end-agency/app/(agency)/crm/listas/page.tsx create mode 100644 front-end-agency/app/(agency)/crm/negociacoes/page.tsx create mode 100644 front-end-agency/app/ClientProviders.tsx create mode 100644 front-end-agency/components/auth/SolutionGuard.tsx create mode 100644 front-end-agency/components/layout/ConfirmDialog.tsx create mode 100644 front-end-agency/components/layout/MobileBottomBar.tsx create mode 100644 front-end-agency/components/layout/ToastContext.tsx create mode 100644 front-end-agency/components/layout/ToastNotification.tsx create mode 100644 front-end-dash.aggios.app/app/superadmin/backup/page.tsx create mode 100644 front-end-dash.aggios.app/app/superadmin/solutions/page.tsx create mode 100644 front-end-dash.aggios.app/components/layout/ConfirmDialog.tsx create mode 100644 front-end-dash.aggios.app/components/layout/EmptyState.tsx create mode 100644 front-end-dash.aggios.app/components/layout/LoadingState.tsx create mode 100644 front-end-dash.aggios.app/components/layout/PageHeader.tsx create mode 100644 front-end-dash.aggios.app/components/layout/Pagination.tsx create mode 100644 front-end-dash.aggios.app/components/layout/README.md create mode 100644 front-end-dash.aggios.app/components/layout/SearchBar.tsx create mode 100644 front-end-dash.aggios.app/components/layout/StatusBadge.tsx create mode 100644 front-end-dash.aggios.app/components/layout/StatusFilter.tsx create mode 100644 front-end-dash.aggios.app/components/layout/ToastContext.tsx create mode 100644 front-end-dash.aggios.app/components/layout/ToastNotification.tsx create mode 100644 front-end-dash.aggios.app/components/plans/EditPlanModal.tsx create mode 100644 front-end-dash.aggios.app/components/ui/Tabs.tsx create mode 100644 postgres/fix_admin_password.sql create mode 100644 postgres/migrations/012_add_tenant_address_fields.sql create mode 100644 postgres/migrations/013_create_solutions_table.sql create mode 100644 postgres/migrations/014_create_crm_tables.sql create mode 100644 postgres/migrations/015_create_template_solutions.sql create mode 100644 postgres/migrations/016_seed_all_solutions.sql create mode 100644 postgres/migrations/017_fix_solutions_utf8.sql create mode 100644 scripts/README.md create mode 100644 scripts/backup-db.ps1 create mode 100644 scripts/rebuild-safe.ps1 create mode 100644 scripts/reset-superadmin-password.ps1 create mode 100644 scripts/restore-db.ps1 create mode 100644 scripts/setup-backup-agendado.ps1 create mode 100644 setup-hosts.ps1 diff --git a/backend/Dockerfile b/backend/Dockerfile index e9ea472..f65be9d 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -19,7 +19,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server ./cmd/serv # Runtime image FROM alpine:latest -RUN apk --no-cache add ca-certificates tzdata +RUN apk --no-cache add ca-certificates tzdata postgresql-client WORKDIR /root/ diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index c40bd5d..20aeaa7 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -18,7 +18,7 @@ import ( func initDB(cfg *config.Config) (*sql.DB, error) { connStr := fmt.Sprintf( - "host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", + "host=%s port=%s user=%s password=%s dbname=%s sslmode=disable client_encoding=UTF8", cfg.Database.Host, cfg.Database.Port, cfg.Database.User, @@ -58,11 +58,13 @@ func main() { agencyTemplateRepo := repository.NewAgencyTemplateRepository(db) planRepo := repository.NewPlanRepository(db) subscriptionRepo := repository.NewSubscriptionRepository(db) + crmRepo := repository.NewCRMRepository(db) + solutionRepo := repository.NewSolutionRepository(db) // Initialize services authService := service.NewAuthService(userRepo, tenantRepo, cfg) - agencyService := service.NewAgencyService(userRepo, tenantRepo, cfg) - tenantService := service.NewTenantService(tenantRepo) + agencyService := service.NewAgencyService(userRepo, tenantRepo, cfg, db) + tenantService := service.NewTenantService(tenantRepo, db) companyService := service.NewCompanyService(companyRepo) planService := service.NewPlanService(planRepo, subscriptionRepo) @@ -74,6 +76,8 @@ func main() { tenantHandler := handlers.NewTenantHandler(tenantService) companyHandler := handlers.NewCompanyHandler(companyService) planHandler := handlers.NewPlanHandler(planService) + crmHandler := handlers.NewCRMHandler(crmRepo) + solutionHandler := handlers.NewSolutionHandler(solutionRepo) signupTemplateHandler := handlers.NewSignupTemplateHandler(signupTemplateRepo, userRepo, tenantRepo, agencyService) agencyTemplateHandler := handlers.NewAgencyTemplateHandler(agencyTemplateRepo, agencyService, userRepo, tenantRepo) filesHandler := handlers.NewFilesHandler(cfg) @@ -84,6 +88,9 @@ func main() { log.Fatalf("❌ Erro ao inicializar upload handler: %v", err) } + // Initialize backup handler + backupHandler := handlers.NewBackupHandler() + // Create middleware chain tenantDetector := middleware.TenantDetector(tenantRepo) corsMiddleware := middleware.CORS(cfg) @@ -140,6 +147,12 @@ func main() { router.HandleFunc("/api/admin/agencies", tenantHandler.ListAll).Methods("GET") router.HandleFunc("/api/admin/agencies/{id}", agencyHandler.HandleAgency).Methods("GET", "PATCH", "DELETE") + // SUPERADMIN: Backup & Restore + router.Handle("/api/superadmin/backups", authMiddleware(http.HandlerFunc(backupHandler.ListBackups))).Methods("GET") + router.Handle("/api/superadmin/backup/create", authMiddleware(http.HandlerFunc(backupHandler.CreateBackup))).Methods("POST") + router.Handle("/api/superadmin/backup/restore", authMiddleware(http.HandlerFunc(backupHandler.RestoreBackup))).Methods("POST") + router.Handle("/api/superadmin/backup/download/{filename}", authMiddleware(http.HandlerFunc(backupHandler.DownloadBackup))).Methods("GET") + // SUPERADMIN: Agency template management router.Handle("/api/admin/agency-templates", authMiddleware(http.HandlerFunc(agencyTemplateHandler.ListTemplates))).Methods("GET") router.Handle("/api/admin/agency-templates", authMiddleware(http.HandlerFunc(agencyTemplateHandler.CreateTemplate))).Methods("POST") @@ -167,6 +180,37 @@ func main() { // SUPERADMIN: Plans management planHandler.RegisterRoutes(router) + // SUPERADMIN: Solutions management + router.Handle("/api/admin/solutions", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + solutionHandler.GetAllSolutions(w, r) + case http.MethodPost: + solutionHandler.CreateSolution(w, r) + } + }))).Methods("GET", "POST") + + router.Handle("/api/admin/solutions/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + solutionHandler.GetSolution(w, r) + case http.MethodPut, http.MethodPatch: + solutionHandler.UpdateSolution(w, r) + case http.MethodDelete: + solutionHandler.DeleteSolution(w, r) + } + }))).Methods("GET", "PUT", "PATCH", "DELETE") + + // SUPERADMIN: Plan <-> Solutions + router.Handle("/api/admin/plans/{plan_id}/solutions", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + solutionHandler.GetPlanSolutions(w, r) + case http.MethodPut: + solutionHandler.SetPlanSolutions(w, r) + } + }))).Methods("GET", "PUT") + // ADMIN_AGENCIA: Client registration router.Handle("/api/agencies/clients/register", authMiddleware(http.HandlerFunc(agencyHandler.RegisterClient))).Methods("POST") @@ -190,6 +234,63 @@ func main() { router.Handle("/api/companies", authMiddleware(http.HandlerFunc(companyHandler.List))).Methods("GET") router.Handle("/api/companies/create", authMiddleware(http.HandlerFunc(companyHandler.Create))).Methods("POST") + // ==================== CRM ROUTES (TENANT) ==================== + + // Tenant solutions (which solutions the tenant has access to) + router.Handle("/api/tenant/solutions", authMiddleware(http.HandlerFunc(solutionHandler.GetTenantSolutions))).Methods("GET") + + // Customers + router.Handle("/api/crm/customers", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + crmHandler.GetCustomers(w, r) + case http.MethodPost: + crmHandler.CreateCustomer(w, r) + } + }))).Methods("GET", "POST") + + router.Handle("/api/crm/customers/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + crmHandler.GetCustomer(w, r) + case http.MethodPut, http.MethodPatch: + crmHandler.UpdateCustomer(w, r) + case http.MethodDelete: + crmHandler.DeleteCustomer(w, r) + } + }))).Methods("GET", "PUT", "PATCH", "DELETE") + + // Lists + router.Handle("/api/crm/lists", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + crmHandler.GetLists(w, r) + case http.MethodPost: + crmHandler.CreateList(w, r) + } + }))).Methods("GET", "POST") + + router.Handle("/api/crm/lists/{id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + crmHandler.GetList(w, r) + case http.MethodPut, http.MethodPatch: + crmHandler.UpdateList(w, r) + case http.MethodDelete: + crmHandler.DeleteList(w, r) + } + }))).Methods("GET", "PUT", "PATCH", "DELETE") + + // Customer <-> List relationship + router.Handle("/api/crm/customers/{customer_id}/lists/{list_id}", authMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + crmHandler.AddCustomerToList(w, r) + case http.MethodDelete: + crmHandler.RemoveCustomerFromList(w, r) + } + }))).Methods("POST", "DELETE") + // Apply global middlewares: tenant -> cors -> security -> rateLimit -> router handler := tenantDetector(corsMiddleware(securityMiddleware(rateLimitMiddleware(router)))) diff --git a/backend/generate_hash.go b/backend/generate_hash.go new file mode 100644 index 0000000..fcfb346 --- /dev/null +++ b/backend/generate_hash.go @@ -0,0 +1,15 @@ +package main + +import ( + "fmt" + "golang.org/x/crypto/bcrypt" +) + +func main() { + password := "Android@2020" + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + panic(err) + } + fmt.Println(string(hash)) +} diff --git a/backend/internal/api/handlers/backup.go b/backend/internal/api/handlers/backup.go new file mode 100644 index 0000000..ab542e5 --- /dev/null +++ b/backend/internal/api/handlers/backup.go @@ -0,0 +1,264 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" +) + +type BackupHandler struct { + backupDir string +} + +type BackupInfo struct { + Filename string `json:"filename"` + Size string `json:"size"` + Date string `json:"date"` + Timestamp string `json:"timestamp"` +} + +func NewBackupHandler() *BackupHandler { + // Usa o caminho montado no container + backupDir := "/backups" + + // Garante que o diretório existe + if _, err := os.Stat(backupDir); os.IsNotExist(err) { + os.MkdirAll(backupDir, 0755) + } + + return &BackupHandler{ + backupDir: backupDir, + } +} + +// ListBackups lista todos os backups disponíveis +func (h *BackupHandler) ListBackups(w http.ResponseWriter, r *http.Request) { + files, err := ioutil.ReadDir(h.backupDir) + if err != nil { + http.Error(w, "Error reading backups directory", http.StatusInternalServerError) + return + } + + var backups []BackupInfo + for _, file := range files { + if strings.HasPrefix(file.Name(), "aggios_backup_") && strings.HasSuffix(file.Name(), ".sql") { + // Extrai timestamp do nome do arquivo + timestamp := strings.TrimPrefix(file.Name(), "aggios_backup_") + timestamp = strings.TrimSuffix(timestamp, ".sql") + + // Formata a data + t, _ := time.Parse("2006-01-02_15-04-05", timestamp) + dateStr := t.Format("02/01/2006 15:04:05") + + // Formata o tamanho + sizeMB := float64(file.Size()) / 1024 + sizeStr := fmt.Sprintf("%.2f KB", sizeMB) + + backups = append(backups, BackupInfo{ + Filename: file.Name(), + Size: sizeStr, + Date: dateStr, + Timestamp: timestamp, + }) + } + } + + // Ordena por data (mais recente primeiro) + sort.Slice(backups, func(i, j int) bool { + return backups[i].Timestamp > backups[j].Timestamp + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "backups": backups, + }) +} + +// CreateBackup cria um novo backup do banco de dados +func (h *BackupHandler) CreateBackup(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + timestamp := time.Now().Format("2006-01-02_15-04-05") + filename := fmt.Sprintf("aggios_backup_%s.sql", timestamp) + filepath := filepath.Join(h.backupDir, filename) + + // Usa pg_dump diretamente (backend e postgres estão na mesma rede docker) + dbPassword := os.Getenv("DB_PASSWORD") + if dbPassword == "" { + dbPassword = "A9g10s_S3cur3_P@ssw0rd_2025!" + } + + cmd := exec.Command("pg_dump", + "-h", "postgres", + "-U", "aggios", + "-d", "aggios_db", + "--no-password") + + // Define a variável de ambiente para a senha + cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbPassword)) + + output, err := cmd.Output() + if err != nil { + http.Error(w, fmt.Sprintf("Error creating backup: %v", err), http.StatusInternalServerError) + return + } + + // Salva o backup no arquivo + err = ioutil.WriteFile(filepath, output, 0644) + if err != nil { + http.Error(w, fmt.Sprintf("Error saving backup: %v", err), http.StatusInternalServerError) + return + } + + // Limpa backups antigos (mantém apenas os últimos 10) + h.cleanOldBackups() + + fileInfo, _ := os.Stat(filepath) + sizeMB := float64(fileInfo.Size()) / 1024 + sizeStr := fmt.Sprintf("%.2f KB", sizeMB) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Backup created successfully", + "filename": filename, + "size": sizeStr, + }) +} + +// RestoreBackup restaura um backup específico +func (h *BackupHandler) RestoreBackup(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + Filename string `json:"filename"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + if req.Filename == "" { + http.Error(w, "Filename is required", http.StatusBadRequest) + return + } + + // Valida que o arquivo existe e está no diretório correto + backupPath := filepath.Join(h.backupDir, req.Filename) + if !strings.HasPrefix(backupPath, h.backupDir) { + http.Error(w, "Invalid filename", http.StatusBadRequest) + return + } + + if _, err := os.Stat(backupPath); os.IsNotExist(err) { + http.Error(w, "Backup file not found", http.StatusNotFound) + return + } + + // Lê o conteúdo do backup + backupContent, err := ioutil.ReadFile(backupPath) + if err != nil { + http.Error(w, fmt.Sprintf("Error reading backup: %v", err), http.StatusInternalServerError) + return + } + + // Restaura o backup usando psql diretamente + dbPassword := os.Getenv("DB_PASSWORD") + if dbPassword == "" { + dbPassword = "A9g10s_S3cur3_P@ssw0rd_2025!" + } + + cmd := exec.Command("psql", + "-h", "postgres", + "-U", "aggios", + "-d", "aggios_db", + "--no-password") + cmd.Stdin = strings.NewReader(string(backupContent)) + cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", dbPassword)) + + if err := cmd.Run(); err != nil { + http.Error(w, fmt.Sprintf("Error restoring backup: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Backup restored successfully", + }) +} + +// DownloadBackup permite fazer download de um backup +func (h *BackupHandler) DownloadBackup(w http.ResponseWriter, r *http.Request) { + // Extrai o filename da URL + parts := strings.Split(r.URL.Path, "/") + filename := parts[len(parts)-1] + + if filename == "" { + http.Error(w, "Filename is required", http.StatusBadRequest) + return + } + + // Valida que o arquivo existe e está no diretório correto + backupPath := filepath.Join(h.backupDir, filename) + if !strings.HasPrefix(backupPath, h.backupDir) { + http.Error(w, "Invalid filename", http.StatusBadRequest) + return + } + + if _, err := os.Stat(backupPath); os.IsNotExist(err) { + http.Error(w, "Backup file not found", http.StatusNotFound) + return + } + + // Lê o arquivo + data, err := ioutil.ReadFile(backupPath) + if err != nil { + http.Error(w, "Error reading file", http.StatusInternalServerError) + return + } + + // Define headers para download + w.Header().Set("Content-Type", "application/sql") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) + w.Write(data) +} + +// cleanOldBackups mantém apenas os últimos 10 backups +func (h *BackupHandler) cleanOldBackups() { + files, err := ioutil.ReadDir(h.backupDir) + if err != nil { + return + } + + var backupFiles []os.FileInfo + for _, file := range files { + if strings.HasPrefix(file.Name(), "aggios_backup_") && strings.HasSuffix(file.Name(), ".sql") { + backupFiles = append(backupFiles, file) + } + } + + // Ordena por data de modificação (mais recente primeiro) + sort.Slice(backupFiles, func(i, j int) bool { + return backupFiles[i].ModTime().After(backupFiles[j].ModTime()) + }) + + // Remove backups antigos (mantém os 10 mais recentes) + if len(backupFiles) > 10 { + for _, file := range backupFiles[10:] { + os.Remove(filepath.Join(h.backupDir, file.Name())) + } + } +} diff --git a/backend/internal/api/handlers/crm.go b/backend/internal/api/handlers/crm.go new file mode 100644 index 0000000..a167179 --- /dev/null +++ b/backend/internal/api/handlers/crm.go @@ -0,0 +1,470 @@ +package handlers + +import ( + "aggios-app/backend/internal/domain" + "aggios-app/backend/internal/repository" + "aggios-app/backend/internal/api/middleware" + "encoding/json" + "log" + "net/http" + + "github.com/google/uuid" + "github.com/gorilla/mux" +) + +type CRMHandler struct { + repo *repository.CRMRepository +} + +func NewCRMHandler(repo *repository.CRMRepository) *CRMHandler { + return &CRMHandler{repo: repo} +} + +// ==================== CUSTOMERS ==================== + +func (h *CRMHandler) CreateCustomer(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + userID, _ := r.Context().Value(middleware.UserIDKey).(string) + + if tenantID == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Missing tenant_id", + }) + return + } + + var customer domain.CRMCustomer + if err := json.NewDecoder(r.Body).Decode(&customer); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Invalid request body", + "message": err.Error(), + }) + return + } + + customer.ID = uuid.New().String() + customer.TenantID = tenantID + customer.CreatedBy = userID + customer.IsActive = true + + if err := h.repo.CreateCustomer(&customer); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to create customer", + "message": err.Error(), + }) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]interface{}{ + "customer": customer, + }) +} + +func (h *CRMHandler) GetCustomers(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + + if tenantID == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Missing tenant_id", + }) + return + } + + customers, err := h.repo.GetCustomersByTenant(tenantID) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to fetch customers", + "message": err.Error(), + }) + return + } + + if customers == nil { + customers = []domain.CRMCustomer{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "customers": customers, + }) +} + +func (h *CRMHandler) GetCustomer(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + + if tenantID == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Missing tenant_id", + }) + return + } + + vars := mux.Vars(r) + customerID := vars["id"] + + customer, err := h.repo.GetCustomerByID(customerID, tenantID) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Customer not found", + "message": err.Error(), + }) + return + } + + // Buscar listas do cliente + lists, _ := h.repo.GetCustomerLists(customerID) + if lists == nil { + lists = []domain.CRMList{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "customer": customer, + "lists": lists, + }) +} + +func (h *CRMHandler) UpdateCustomer(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + + if tenantID == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Missing tenant_id", + }) + return + } + + vars := mux.Vars(r) + customerID := vars["id"] + + var customer domain.CRMCustomer + if err := json.NewDecoder(r.Body).Decode(&customer); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Invalid request body", + "message": err.Error(), + }) + return + } + + customer.ID = customerID + customer.TenantID = tenantID + + if err := h.repo.UpdateCustomer(&customer); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to update customer", + "message": err.Error(), + }) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "message": "Customer updated successfully", + }) +} + +func (h *CRMHandler) DeleteCustomer(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + + if tenantID == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Missing tenant_id", + }) + return + } + + vars := mux.Vars(r) + customerID := vars["id"] + + if err := h.repo.DeleteCustomer(customerID, tenantID); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to delete customer", + "message": err.Error(), + }) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "message": "Customer deleted successfully", + }) +} + +// ==================== LISTS ==================== + +func (h *CRMHandler) CreateList(w http.ResponseWriter, r *http.Request) { + tenantIDVal := r.Context().Value(middleware.TenantIDKey) + userIDVal := r.Context().Value(middleware.UserIDKey) + + log.Printf("🔍 CreateList DEBUG: tenantID type=%T value=%v | userID type=%T value=%v", + tenantIDVal, tenantIDVal, userIDVal, userIDVal) + + tenantID, ok := tenantIDVal.(string) + if !ok || tenantID == "" { + log.Printf("❌ CreateList: Missing or invalid tenant_id") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Missing tenant_id", + }) + return + } + + userID, _ := userIDVal.(string) + + var list domain.CRMList + if err := json.NewDecoder(r.Body).Decode(&list); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Invalid request body", + "message": err.Error(), + }) + return + } + + list.ID = uuid.New().String() + list.TenantID = tenantID + list.CreatedBy = userID + + if list.Color == "" { + list.Color = "#3b82f6" + } + + if err := h.repo.CreateList(&list); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to create list", + "message": err.Error(), + }) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]interface{}{ + "list": list, + }) +} + +func (h *CRMHandler) GetLists(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + + if tenantID == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Missing tenant_id", + }) + return + } + + lists, err := h.repo.GetListsByTenant(tenantID) + 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 lists", + "message": err.Error(), + }) + return + } + + if lists == nil { + lists = []domain.CRMListWithCustomers{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "lists": lists, + }) +} + +func (h *CRMHandler) GetList(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + + if tenantID == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Missing tenant_id", + }) + return + } + + vars := mux.Vars(r) + listID := vars["id"] + + list, err := h.repo.GetListByID(listID, tenantID) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{ + "error": "List not found", + "message": err.Error(), + }) + return + } + + // Buscar clientes da lista + customers, _ := h.repo.GetListCustomers(listID, tenantID) + if customers == nil { + customers = []domain.CRMCustomer{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "list": list, + "customers": customers, + }) +} + +func (h *CRMHandler) UpdateList(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + + if tenantID == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Missing tenant_id", + }) + return + } + + vars := mux.Vars(r) + listID := vars["id"] + + var list domain.CRMList + if err := json.NewDecoder(r.Body).Decode(&list); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Invalid request body", + "message": err.Error(), + }) + return + } + + list.ID = listID + list.TenantID = tenantID + + if err := h.repo.UpdateList(&list); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to update list", + "message": err.Error(), + }) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "message": "List updated successfully", + }) +} + +func (h *CRMHandler) DeleteList(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + + if tenantID == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Missing tenant_id", + }) + return + } + + vars := mux.Vars(r) + listID := vars["id"] + + if err := h.repo.DeleteList(listID, tenantID); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to delete list", + "message": err.Error(), + }) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "message": "List deleted successfully", + }) +} + +// ==================== CUSTOMER <-> LIST ==================== + +func (h *CRMHandler) AddCustomerToList(w http.ResponseWriter, r *http.Request) { + userID, _ := r.Context().Value(middleware.UserIDKey).(string) + + vars := mux.Vars(r) + customerID := vars["customer_id"] + listID := vars["list_id"] + + if err := h.repo.AddCustomerToList(customerID, listID, userID); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to add customer to list", + "message": err.Error(), + }) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "message": "Customer added to list successfully", + }) +} + +func (h *CRMHandler) RemoveCustomerFromList(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + customerID := vars["customer_id"] + listID := vars["list_id"] + + if err := h.repo.RemoveCustomerFromList(customerID, listID); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to remove customer from list", + "message": err.Error(), + }) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "message": "Customer removed from list successfully", + }) +} + diff --git a/backend/internal/api/handlers/plan.go b/backend/internal/api/handlers/plan.go index a5eb4a5..7e1f794 100644 --- a/backend/internal/api/handlers/plan.go +++ b/backend/internal/api/handlers/plan.go @@ -46,20 +46,26 @@ func (h *PlanHandler) CreatePlan(w http.ResponseWriter, r *http.Request) { var req domain.CreatePlanRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("❌ Invalid request body: %v", err) - http.Error(w, "Invalid request body", http.StatusBadRequest) + 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 } plan, err := h.planService.CreatePlan(&req) if err != nil { log.Printf("❌ Error creating plan: %v", err) + w.Header().Set("Content-Type", "application/json") switch err { case service.ErrPlanSlugTaken: - http.Error(w, err.Error(), http.StatusConflict) + w.WriteHeader(http.StatusConflict) + json.NewEncoder(w).Encode(map[string]string{"error": "Slug already taken", "message": err.Error()}) case service.ErrInvalidUserRange: - http.Error(w, err.Error(), http.StatusBadRequest) + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "Invalid user range", "message": err.Error()}) default: - http.Error(w, "Internal server error", http.StatusInternalServerError) + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": "Internal server error", "message": err.Error()}) } return } diff --git a/backend/internal/api/handlers/solution.go b/backend/internal/api/handlers/solution.go new file mode 100644 index 0000000..931a4d2 --- /dev/null +++ b/backend/internal/api/handlers/solution.go @@ -0,0 +1,252 @@ +package handlers + +import ( + "aggios-app/backend/internal/domain" + "aggios-app/backend/internal/repository" + "aggios-app/backend/internal/api/middleware" + "encoding/json" + "log" + "net/http" + + "github.com/google/uuid" + "github.com/gorilla/mux" +) + +type SolutionHandler struct { + repo *repository.SolutionRepository +} + +func NewSolutionHandler(repo *repository.SolutionRepository) *SolutionHandler { + return &SolutionHandler{repo: repo} +} + +// ==================== CRUD SOLUTIONS (SUPERADMIN) ==================== + +func (h *SolutionHandler) CreateSolution(w http.ResponseWriter, r *http.Request) { + var solution domain.Solution + if err := json.NewDecoder(r.Body).Decode(&solution); 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 + } + + solution.ID = uuid.New().String() + + if err := h.repo.CreateSolution(&solution); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to create solution", + "message": err.Error(), + }) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]interface{}{ + "solution": solution, + }) +} + +func (h *SolutionHandler) GetAllSolutions(w http.ResponseWriter, r *http.Request) { + solutions, err := h.repo.GetAllSolutions() + 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 solutions", + "message": err.Error(), + }) + return + } + + if solutions == nil { + solutions = []domain.Solution{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "solutions": solutions, + }) +} + +func (h *SolutionHandler) GetSolution(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + solutionID := vars["id"] + + solution, err := h.repo.GetSolutionByID(solutionID) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Solution not found", + "message": err.Error(), + }) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "solution": solution, + }) +} + +func (h *SolutionHandler) UpdateSolution(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + solutionID := vars["id"] + + var solution domain.Solution + if err := json.NewDecoder(r.Body).Decode(&solution); 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 + } + + solution.ID = solutionID + + if err := h.repo.UpdateSolution(&solution); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to update solution", + "message": err.Error(), + }) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "message": "Solution updated successfully", + }) +} + +func (h *SolutionHandler) DeleteSolution(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + solutionID := vars["id"] + + if err := h.repo.DeleteSolution(solutionID); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to delete solution", + "message": err.Error(), + }) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "message": "Solution deleted successfully", + }) +} + +// ==================== TENANT SOLUTIONS (AGENCY) ==================== + +func (h *SolutionHandler) GetTenantSolutions(w http.ResponseWriter, r *http.Request) { + tenantID, _ := r.Context().Value(middleware.TenantIDKey).(string) + + log.Printf("🔍 GetTenantSolutions: tenantID=%s", tenantID) + + if tenantID == "" { + log.Printf("❌ GetTenantSolutions: Missing tenant_id") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Missing tenant_id", + }) + return + } + + solutions, err := h.repo.GetTenantSolutions(tenantID) + if err != nil { + log.Printf("❌ GetTenantSolutions: Error fetching solutions: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to fetch solutions", + "message": err.Error(), + }) + return + } + + log.Printf("✅ GetTenantSolutions: Found %d solutions for tenant %s", len(solutions), tenantID) + + if solutions == nil { + solutions = []domain.Solution{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "solutions": solutions, + }) +} + +// ==================== PLAN SOLUTIONS ==================== + +func (h *SolutionHandler) GetPlanSolutions(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + planID := vars["plan_id"] + + solutions, err := h.repo.GetPlanSolutions(planID) + 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 plan solutions", + "message": err.Error(), + }) + return + } + + if solutions == nil { + solutions = []domain.Solution{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "solutions": solutions, + }) +} + +func (h *SolutionHandler) SetPlanSolutions(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + planID := vars["plan_id"] + + var req struct { + SolutionIDs []string `json:"solution_ids"` + } + + 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 err := h.repo.SetPlanSolutions(planID, req.SolutionIDs); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to update plan solutions", + "message": err.Error(), + }) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "message": "Plan solutions updated successfully", + }) +} diff --git a/backend/internal/api/handlers/tenant.go b/backend/internal/api/handlers/tenant.go index 0173b93..601acf4 100644 --- a/backend/internal/api/handlers/tenant.go +++ b/backend/internal/api/handlers/tenant.go @@ -5,7 +5,6 @@ import ( "log" "net/http" - "aggios-app/backend/internal/domain" "aggios-app/backend/internal/service" ) @@ -28,14 +27,15 @@ func (h *TenantHandler) ListAll(w http.ResponseWriter, r *http.Request) { return } - tenants, err := h.tenantService.ListAll() + tenants, err := h.tenantService.ListAllWithDetails() if err != nil { + log.Printf("Error listing tenants with details: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if tenants == nil { - tenants = []*domain.Tenant{} + tenants = []map[string]interface{}{} } w.Header().Set("Content-Type", "application/json; charset=utf-8") diff --git a/backend/internal/domain/crm.go b/backend/internal/domain/crm.go new file mode 100644 index 0000000..972f3b3 --- /dev/null +++ b/backend/internal/domain/crm.go @@ -0,0 +1,53 @@ +package domain + +import "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"` +} + +type CRMList 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"` + Color string `json:"color" db:"color"` + 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 CRMCustomerList struct { + CustomerID string `json:"customer_id" db:"customer_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"` +} + +// DTO com informações extras +type CRMCustomerWithLists struct { + CRMCustomer + Lists []CRMList `json:"lists"` +} + +type CRMListWithCustomers struct { + CRMList + CustomerCount int `json:"customer_count"` +} diff --git a/backend/internal/domain/solution.go b/backend/internal/domain/solution.go new file mode 100644 index 0000000..2d64117 --- /dev/null +++ b/backend/internal/domain/solution.go @@ -0,0 +1,20 @@ +package domain + +import "time" + +type Solution struct { + ID string `json:"id" db:"id"` + Name string `json:"name" db:"name"` + Slug string `json:"slug" db:"slug"` + Icon string `json:"icon" db:"icon"` + Description string `json:"description" db:"description"` + IsActive bool `json:"is_active" db:"is_active"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +type PlanSolution struct { + PlanID string `json:"plan_id" db:"plan_id"` + SolutionID string `json:"solution_id" db:"solution_id"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} diff --git a/backend/internal/domain/tenant.go b/backend/internal/domain/tenant.go index e9387aa..1f3c1b0 100644 --- a/backend/internal/domain/tenant.go +++ b/backend/internal/domain/tenant.go @@ -45,7 +45,15 @@ type CreateTenantRequest struct { // AgencyDetails aggregates tenant info with its admin user for superadmin view type AgencyDetails struct { - Tenant *Tenant `json:"tenant"` - Admin *User `json:"admin,omitempty"` - AccessURL string `json:"access_url"` + Tenant *Tenant `json:"tenant"` + Admin *User `json:"admin,omitempty"` + Subscription *AgencySubscriptionInfo `json:"subscription,omitempty"` + AccessURL string `json:"access_url"` +} + +type AgencySubscriptionInfo struct { + PlanID string `json:"plan_id"` + PlanName string `json:"plan_name"` + Status string `json:"status"` + Solutions []Solution `json:"solutions"` } diff --git a/backend/internal/repository/crm_repository.go b/backend/internal/repository/crm_repository.go new file mode 100644 index 0000000..463d9f4 --- /dev/null +++ b/backend/internal/repository/crm_repository.go @@ -0,0 +1,346 @@ +package repository + +import ( + "aggios-app/backend/internal/domain" + "database/sql" + "fmt" + + "github.com/lib/pq" +) + +type CRMRepository struct { + db *sql.DB +} + +func NewCRMRepository(db *sql.DB) *CRMRepository { + return &CRMRepository{db: db} +} + +// ==================== CUSTOMERS ==================== + +func (r *CRMRepository) CreateCustomer(customer *domain.CRMCustomer) error { + query := ` + 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) + RETURNING created_at, updated_at + ` + + 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, + ).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 + FROM crm_customers + 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 customers []domain.CRMCustomer + for rows.Next() { + var c domain.CRMCustomer + 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, + ) + if err != nil { + return nil, err + } + customers = append(customers, c) + } + + return customers, nil +} + +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 + FROM crm_customers + WHERE id = $1 AND tenant_id = $2 + ` + + var c domain.CRMCustomer + 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, + ) + + if err != nil { + return nil, err + } + + return &c, nil +} + +func (r *CRMRepository) UpdateCustomer(customer *domain.CRMCustomer) error { + query := ` + 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 + ` + + 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.ID, customer.TenantID, + ) + + if err != nil { + return err + } + + rows, err := result.RowsAffected() + if err != nil { + return err + } + + if rows == 0 { + return fmt.Errorf("customer not found") + } + + return nil +} + +func (r *CRMRepository) DeleteCustomer(id string, tenantID string) error { + query := `DELETE FROM crm_customers 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("customer not found") + } + + return nil +} + +// ==================== LISTS ==================== + +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) + RETURNING created_at, updated_at + ` + + return r.db.QueryRow( + query, + list.ID, list.TenantID, 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, + l.created_at, l.updated_at, + COUNT(cl.customer_id) as customer_count + FROM crm_lists l + LEFT JOIN crm_customer_lists cl ON l.id = cl.list_id + WHERE l.tenant_id = $1 + GROUP BY l.id + ORDER BY l.created_at DESC + ` + + rows, err := r.db.Query(query, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + + var lists []domain.CRMListWithCustomers + 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, + ) + if err != nil { + return nil, err + } + lists = append(lists, l) + } + + return lists, nil +} + +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 + 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.CreatedAt, &l.UpdatedAt, + ) + + if err != nil { + return nil, err + } + + return &l, nil +} + +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 + ` + + result, err := r.db.Exec(query, list.Name, list.Description, list.Color, list.ID, list.TenantID) + if err != nil { + return err + } + + rows, err := result.RowsAffected() + if err != nil { + return err + } + + if rows == 0 { + return fmt.Errorf("list not found") + } + + return nil +} + +func (r *CRMRepository) DeleteList(id string, tenantID string) error { + query := `DELETE FROM crm_lists 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("list not found") + } + + return nil +} + +// ==================== CUSTOMER <-> LIST ==================== + +func (r *CRMRepository) AddCustomerToList(customerID, listID, addedBy string) error { + query := ` + INSERT INTO crm_customer_lists (customer_id, list_id, added_by) + VALUES ($1, $2, $3) + ON CONFLICT (customer_id, list_id) DO NOTHING + ` + + _, err := r.db.Exec(query, customerID, listID, addedBy) + return err +} + +func (r *CRMRepository) RemoveCustomerFromList(customerID, listID string) error { + query := `DELETE FROM crm_customer_lists WHERE customer_id = $1 AND list_id = $2` + + _, err := r.db.Exec(query, customerID, listID) + return err +} + +func (r *CRMRepository) GetCustomerLists(customerID string) ([]domain.CRMList, error) { + query := ` + SELECT 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_customer_lists cl ON l.id = cl.list_id + WHERE cl.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) + } + + return lists, nil +} + +func (r *CRMRepository) GetListCustomers(listID string, tenantID string) ([]domain.CRMCustomer, error) { + 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 + 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 + ORDER BY c.name + ` + + rows, err := r.db.Query(query, listID, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + + var customers []domain.CRMCustomer + for rows.Next() { + var c domain.CRMCustomer + 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, + ) + if err != nil { + return nil, err + } + customers = append(customers, c) + } + + return customers, nil +} diff --git a/backend/internal/repository/solution_repository.go b/backend/internal/repository/solution_repository.go new file mode 100644 index 0000000..471c99a --- /dev/null +++ b/backend/internal/repository/solution_repository.go @@ -0,0 +1,300 @@ +package repository + +import ( + "aggios-app/backend/internal/domain" + "database/sql" + "fmt" +) + +type SolutionRepository struct { + db *sql.DB +} + +func NewSolutionRepository(db *sql.DB) *SolutionRepository { + return &SolutionRepository{db: db} +} + +// ==================== SOLUTIONS ==================== + +func (r *SolutionRepository) CreateSolution(solution *domain.Solution) error { + query := ` + INSERT INTO solutions (id, name, slug, icon, description, is_active) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING created_at, updated_at + ` + + return r.db.QueryRow( + query, + solution.ID, solution.Name, solution.Slug, solution.Icon, + solution.Description, solution.IsActive, + ).Scan(&solution.CreatedAt, &solution.UpdatedAt) +} + +func (r *SolutionRepository) GetAllSolutions() ([]domain.Solution, error) { + query := ` + SELECT id, name, slug, icon, description, is_active, created_at, updated_at + FROM solutions + ORDER BY created_at DESC + ` + + rows, err := r.db.Query(query) + if err != nil { + return nil, err + } + defer rows.Close() + + var solutions []domain.Solution + for rows.Next() { + var s domain.Solution + err := rows.Scan( + &s.ID, &s.Name, &s.Slug, &s.Icon, &s.Description, + &s.IsActive, &s.CreatedAt, &s.UpdatedAt, + ) + if err != nil { + return nil, err + } + solutions = append(solutions, s) + } + + return solutions, nil +} + +func (r *SolutionRepository) GetActiveSolutions() ([]domain.Solution, error) { + query := ` + SELECT id, name, slug, icon, description, is_active, created_at, updated_at + FROM solutions + WHERE is_active = true + ORDER BY name + ` + + rows, err := r.db.Query(query) + if err != nil { + return nil, err + } + defer rows.Close() + + var solutions []domain.Solution + for rows.Next() { + var s domain.Solution + err := rows.Scan( + &s.ID, &s.Name, &s.Slug, &s.Icon, &s.Description, + &s.IsActive, &s.CreatedAt, &s.UpdatedAt, + ) + if err != nil { + return nil, err + } + solutions = append(solutions, s) + } + + return solutions, nil +} + +func (r *SolutionRepository) GetSolutionByID(id string) (*domain.Solution, error) { + query := ` + SELECT id, name, slug, icon, description, is_active, created_at, updated_at + FROM solutions + WHERE id = $1 + ` + + var s domain.Solution + err := r.db.QueryRow(query, id).Scan( + &s.ID, &s.Name, &s.Slug, &s.Icon, &s.Description, + &s.IsActive, &s.CreatedAt, &s.UpdatedAt, + ) + + if err != nil { + return nil, err + } + + return &s, nil +} + +func (r *SolutionRepository) GetSolutionBySlug(slug string) (*domain.Solution, error) { + query := ` + SELECT id, name, slug, icon, description, is_active, created_at, updated_at + FROM solutions + WHERE slug = $1 + ` + + var s domain.Solution + err := r.db.QueryRow(query, slug).Scan( + &s.ID, &s.Name, &s.Slug, &s.Icon, &s.Description, + &s.IsActive, &s.CreatedAt, &s.UpdatedAt, + ) + + if err != nil { + return nil, err + } + + return &s, nil +} + +func (r *SolutionRepository) UpdateSolution(solution *domain.Solution) error { + query := ` + UPDATE solutions SET + name = $1, slug = $2, icon = $3, description = $4, is_active = $5, updated_at = CURRENT_TIMESTAMP + WHERE id = $6 + ` + + result, err := r.db.Exec( + query, + solution.Name, solution.Slug, solution.Icon, solution.Description, + solution.IsActive, solution.ID, + ) + + if err != nil { + return err + } + + rows, err := result.RowsAffected() + if err != nil { + return err + } + + if rows == 0 { + return fmt.Errorf("solution not found") + } + + return nil +} + +func (r *SolutionRepository) DeleteSolution(id string) error { + query := `DELETE FROM solutions 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 fmt.Errorf("solution not found") + } + + return nil +} + +// ==================== PLAN <-> SOLUTION ==================== + +func (r *SolutionRepository) AddSolutionToPlan(planID, solutionID string) error { + query := ` + INSERT INTO plan_solutions (plan_id, solution_id) + VALUES ($1, $2) + ON CONFLICT (plan_id, solution_id) DO NOTHING + ` + + _, err := r.db.Exec(query, planID, solutionID) + return err +} + +func (r *SolutionRepository) RemoveSolutionFromPlan(planID, solutionID string) error { + query := `DELETE FROM plan_solutions WHERE plan_id = $1 AND solution_id = $2` + + _, err := r.db.Exec(query, planID, solutionID) + return err +} + +func (r *SolutionRepository) GetPlanSolutions(planID string) ([]domain.Solution, error) { + query := ` + SELECT s.id, s.name, s.slug, s.icon, s.description, s.is_active, s.created_at, s.updated_at + FROM solutions s + INNER JOIN plan_solutions ps ON s.id = ps.solution_id + WHERE ps.plan_id = $1 + ORDER BY s.name + ` + + rows, err := r.db.Query(query, planID) + if err != nil { + return nil, err + } + defer rows.Close() + + var solutions []domain.Solution + for rows.Next() { + var s domain.Solution + err := rows.Scan( + &s.ID, &s.Name, &s.Slug, &s.Icon, &s.Description, + &s.IsActive, &s.CreatedAt, &s.UpdatedAt, + ) + if err != nil { + return nil, err + } + solutions = append(solutions, s) + } + + return solutions, nil +} + +func (r *SolutionRepository) SetPlanSolutions(planID string, solutionIDs []string) error { + // Inicia transação + tx, err := r.db.Begin() + if err != nil { + return err + } + + // Remove todas as soluções antigas do plano + _, err = tx.Exec(`DELETE FROM plan_solutions WHERE plan_id = $1`, planID) + if err != nil { + tx.Rollback() + return err + } + + // Adiciona as novas soluções + stmt, err := tx.Prepare(`INSERT INTO plan_solutions (plan_id, solution_id) VALUES ($1, $2)`) + if err != nil { + tx.Rollback() + return err + } + defer stmt.Close() + + for _, solutionID := range solutionIDs { + _, err = stmt.Exec(planID, solutionID) + if err != nil { + tx.Rollback() + return err + } + } + + return tx.Commit() +} + +func (r *SolutionRepository) GetTenantSolutions(tenantID string) ([]domain.Solution, error) { + query := ` + SELECT DISTINCT s.id, s.name, s.slug, s.icon, s.description, s.is_active, s.created_at, s.updated_at + FROM solutions s + INNER JOIN plan_solutions ps ON s.id = ps.solution_id + INNER JOIN agency_subscriptions asub ON ps.plan_id = asub.plan_id + WHERE asub.agency_id = $1 AND s.is_active = true AND asub.status = 'active' + ORDER BY s.name + ` + + rows, err := r.db.Query(query, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + + var solutions []domain.Solution + for rows.Next() { + var s domain.Solution + err := rows.Scan( + &s.ID, &s.Name, &s.Slug, &s.Icon, &s.Description, + &s.IsActive, &s.CreatedAt, &s.UpdatedAt, + ) + if err != nil { + return nil, err + } + solutions = append(solutions, s) + } + + // Se não encontrou via subscription, retorna array vazio + if solutions == nil { + solutions = []domain.Solution{} + } + + return solutions, nil +} diff --git a/backend/internal/service/agency_service.go b/backend/internal/service/agency_service.go index 07cfc67..ee4eebd 100644 --- a/backend/internal/service/agency_service.go +++ b/backend/internal/service/agency_service.go @@ -4,6 +4,7 @@ import ( "aggios-app/backend/internal/config" "aggios-app/backend/internal/domain" "aggios-app/backend/internal/repository" + "database/sql" "fmt" "github.com/google/uuid" @@ -15,14 +16,16 @@ type AgencyService struct { userRepo *repository.UserRepository tenantRepo *repository.TenantRepository cfg *config.Config + db *sql.DB } // NewAgencyService creates a new agency service -func NewAgencyService(userRepo *repository.UserRepository, tenantRepo *repository.TenantRepository, cfg *config.Config) *AgencyService { +func NewAgencyService(userRepo *repository.UserRepository, tenantRepo *repository.TenantRepository, cfg *config.Config, db *sql.DB) *AgencyService { return &AgencyService{ userRepo: userRepo, tenantRepo: tenantRepo, cfg: cfg, + db: db, } } @@ -180,6 +183,43 @@ func (s *AgencyService) GetAgencyDetails(id uuid.UUID) (*domain.AgencyDetails, e details.Admin = admin } + // Buscar subscription e soluções + var subscription domain.AgencySubscriptionInfo + query := ` + SELECT + s.plan_id, + p.name as plan_name, + s.status + FROM agency_subscriptions s + JOIN plans p ON s.plan_id = p.id + WHERE s.agency_id = $1 + LIMIT 1 + ` + err = s.db.QueryRow(query, id).Scan(&subscription.PlanID, &subscription.PlanName, &subscription.Status) + if err == nil { + // Buscar soluções do plano + solutionsQuery := ` + SELECT sol.id, sol.name, sol.slug, sol.icon + FROM solutions sol + JOIN plan_solutions ps ON sol.id = ps.solution_id + WHERE ps.plan_id = $1 + ORDER BY sol.name + ` + rows, err := s.db.Query(solutionsQuery, subscription.PlanID) + if err == nil { + defer rows.Close() + var solutions []domain.Solution + for rows.Next() { + var solution domain.Solution + if err := rows.Scan(&solution.ID, &solution.Name, &solution.Slug, &solution.Icon); err == nil { + solutions = append(solutions, solution) + } + } + subscription.Solutions = solutions + details.Subscription = &subscription + } + } + return details, nil } diff --git a/backend/internal/service/tenant_service.go b/backend/internal/service/tenant_service.go index b3d971e..4432d67 100644 --- a/backend/internal/service/tenant_service.go +++ b/backend/internal/service/tenant_service.go @@ -17,12 +17,14 @@ var ( // TenantService handles tenant business logic type TenantService struct { tenantRepo *repository.TenantRepository + db *sql.DB } // NewTenantService creates a new tenant service -func NewTenantService(tenantRepo *repository.TenantRepository) *TenantService { +func NewTenantService(tenantRepo *repository.TenantRepository, db *sql.DB) *TenantService { return &TenantService{ tenantRepo: tenantRepo, + db: db, } } @@ -79,6 +81,84 @@ func (s *TenantService) ListAll() ([]*domain.Tenant, error) { return s.tenantRepo.FindAll() } +// ListAllWithDetails retrieves all tenants with their plan and solutions information +func (s *TenantService) ListAllWithDetails() ([]map[string]interface{}, error) { + tenants, err := s.tenantRepo.FindAll() + if err != nil { + return nil, err + } + + var result []map[string]interface{} + for _, tenant := range tenants { + tenantData := map[string]interface{}{ + "id": tenant.ID, + "name": tenant.Name, + "subdomain": tenant.Subdomain, + "domain": tenant.Domain, + "email": tenant.Email, + "phone": tenant.Phone, + "cnpj": tenant.CNPJ, + "is_active": tenant.IsActive, + "created_at": tenant.CreatedAt, + "logo_url": tenant.LogoURL, + "logo_horizontal_url": tenant.LogoHorizontalURL, + "primary_color": tenant.PrimaryColor, + "secondary_color": tenant.SecondaryColor, + } + + // Buscar subscription e soluções + var planName sql.NullString + var planID string + query := ` + SELECT + s.plan_id, + p.name as plan_name + FROM agency_subscriptions s + JOIN plans p ON s.plan_id = p.id + WHERE s.agency_id = $1 AND s.status = 'active' + LIMIT 1 + ` + err = s.db.QueryRow(query, tenant.ID).Scan(&planID, &planName) + if err == nil && planName.Valid { + tenantData["plan_name"] = planName.String + + // Buscar soluções do plano + solutionsQuery := ` + SELECT sol.id, sol.name, sol.slug, sol.icon + FROM solutions sol + JOIN plan_solutions ps ON sol.id = ps.solution_id + WHERE ps.plan_id = $1 + ORDER BY sol.name + ` + rows, err := s.db.Query(solutionsQuery, planID) + if err == nil { + defer rows.Close() + var solutions []map[string]interface{} + for rows.Next() { + var id, name, slug string + var icon sql.NullString + if err := rows.Scan(&id, &name, &slug, &icon); err == nil { + solution := map[string]interface{}{ + "id": id, + "name": name, + "slug": slug, + } + if icon.Valid { + solution["icon"] = icon.String + } + solutions = append(solutions, solution) + } + } + tenantData["solutions"] = solutions + } + } + + result = append(result, tenantData) + } + + return result, nil +} + // Delete removes a tenant by ID func (s *TenantService) Delete(id uuid.UUID) error { if err := s.tenantRepo.Delete(id); err != nil { diff --git a/docker-compose.yml b/docker-compose.yml index b870f74..6466682 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -104,12 +104,15 @@ services: dockerfile: Dockerfile container_name: aggios-backend restart: unless-stopped + ports: + - "8085:8080" labels: - "traefik.enable=true" - "traefik.http.routers.backend.rule=Host(`api.aggios.local`) || Host(`api.localhost`)" - "traefik.http.routers.backend.entrypoints=web" - "traefik.http.services.backend.loadbalancer.server.port=8080" environment: + TZ: America/Sao_Paulo SERVER_HOST: 0.0.0.0 SERVER_PORT: 8080 JWT_SECRET: ${JWT_SECRET:-Th1s_1s_A_V3ry_S3cur3_JWT_S3cr3t_K3y_2025_Ch@ng3_In_Pr0d!} @@ -125,6 +128,8 @@ services: MINIO_PUBLIC_URL: http://files.localhost MINIO_ROOT_USER: minioadmin MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD:-M1n10_S3cur3_P@ss_2025!} + volumes: + - ./backups:/backups depends_on: postgres: condition: service_healthy diff --git a/docs/backup-system.md b/docs/backup-system.md new file mode 100644 index 0000000..4285ffb --- /dev/null +++ b/docs/backup-system.md @@ -0,0 +1,186 @@ +# 📦 Sistema de Backup & Restore - Aggios + +## 🎯 Funcionalidades Implementadas + +### Interface Web (Superadmin) +**URL:** `http://dash.localhost/superadmin/backup` + +Disponível apenas para usuários com role `superadmin`. + +#### Recursos: +1. **Criar Backup** + - Botão para criar novo backup instantâneo + - Mostra nome do arquivo e tamanho + - Mantém automaticamente apenas os últimos 10 backups + +2. **Listar Backups** + - Exibe todos os backups disponíveis + - Informações: nome, data, tamanho + - Seleção visual do backup ativo + +3. **Restaurar Backup** + - Seleção de backup na lista + - Confirmação de segurança (alerta de sobrescrita) + - Recarrega a página após restauração + +4. **Download de Backup** + - Botão de download em cada backup + - Download direto do arquivo .sql + +### API Endpoints + +#### 1. Listar Backups +``` +GET /api/superadmin/backups +Authorization: Bearer {token} +``` + +**Resposta:** +```json +{ + "backups": [ + { + "filename": "aggios_backup_2025-12-13_20-23-08.sql", + "size": "20.49 KB", + "date": "13/12/2025 20:23:08", + "timestamp": "2025-12-13_20-23-08" + } + ] +} +``` + +#### 2. Criar Backup +``` +POST /api/superadmin/backup/create +Authorization: Bearer {token} +``` + +**Resposta:** +```json +{ + "message": "Backup created successfully", + "filename": "aggios_backup_2025-12-13_20-30-15.sql", + "size": "20.52 KB" +} +``` + +#### 3. Restaurar Backup +``` +POST /api/superadmin/backup/restore +Authorization: Bearer {token} +Content-Type: application/json + +{ + "filename": "aggios_backup_2025-12-13_20-23-08.sql" +} +``` + +**Resposta:** +```json +{ + "message": "Backup restored successfully" +} +``` + +#### 4. Download de Backup +``` +GET /api/superadmin/backup/download/{filename} +Authorization: Bearer {token} +``` + +**Resposta:** Arquivo .sql para download + +## 📂 Estrutura de Arquivos + +``` +backups/ +├── aggios_backup_2025-12-13_19-56-18.sql +├── aggios_backup_2025-12-13_20-12-49.sql +├── aggios_backup_2025-12-13_20-17-59.sql +└── aggios_backup_2025-12-13_20-23-08.sql (mais recente) +``` + +## ⚙️ Scripts PowerShell (ainda funcionam!) + +### Backup Manual +```powershell +cd g:\Projetos\aggios-app\scripts +.\backup-db.ps1 +``` + +### Restaurar Último Backup +```powershell +cd g:\Projetos\aggios-app\scripts +.\restore-db.ps1 +``` + +## 🔒 Segurança + +1. ✅ Apenas superadmins podem acessar +2. ✅ Validação de arquivos (apenas .sql na pasta backups/) +3. ✅ Proteção contra path traversal +4. ✅ Autenticação JWT obrigatória +5. ✅ Confirmação dupla antes de restaurar + +## ⚠️ Avisos Importantes + +1. **Backup Automático:** + - Ainda não configurado + - Por enquanto, fazer backups manuais antes de `docker-compose down -v` + +2. **Limite de Backups:** + - Sistema mantém apenas os **últimos 10 backups** + - Backups antigos são deletados automaticamente + +3. **Restauração:** + - ⚠️ **SOBRESCREVE TODOS OS DADOS ATUAIS** + - Sempre peça confirmação dupla + - Cria um backup automático antes? (implementar depois) + +## 🚀 Como Usar + +1. **Acesse o Superadmin:** + - Login: admin@aggios.app + - Senha: Ag@}O%}Z;if)97o*JOgNMbP2025! + +2. **No Menu Lateral:** + - Clique em "Backup & Restore" (ícone de servidor) + +3. **Criar Backup:** + - Clique em "Criar Novo Backup" + - Aguarde confirmação + +4. **Restaurar:** + - Selecione o backup desejado na lista + - Clique em "Restaurar Backup" + - Confirme o alerta + - Aguarde reload da página + +## 🐛 Troubleshooting + +### Erro ao criar backup +```bash +# Verificar se o container está rodando +docker ps | grep aggios-postgres + +# Verificar logs +docker logs aggios-backend --tail 50 +``` + +### Erro ao restaurar +```bash +# Verificar permissões +ls -la g:\Projetos\aggios-app\backups\ + +# Testar manualmente +docker exec -i aggios-postgres psql -U aggios aggios_db < backup.sql +``` + +## 📝 TODO Futuro + +- [ ] Backup automático agendado (diário) +- [ ] Backup antes de restaurar (safety) +- [ ] Upload de backup externo +- [ ] Exportar/importar apenas tabelas específicas +- [ ] Histórico de restaurações +- [ ] Notificações por email diff --git a/front-end-agency/app/(agency)/AgencyLayoutClient.tsx b/front-end-agency/app/(agency)/AgencyLayoutClient.tsx index 66f7ad0..0a07c06 100644 --- a/front-end-agency/app/(agency)/AgencyLayoutClient.tsx +++ b/front-end-agency/app/(agency)/AgencyLayoutClient.tsx @@ -3,6 +3,7 @@ import { DashboardLayout } from '@/components/layout/DashboardLayout'; import { AgencyBranding } from '@/components/layout/AgencyBranding'; import AuthGuard from '@/components/auth/AuthGuard'; +import { useState, useEffect } from 'react'; import { HomeIcon, RocketLaunchIcon, @@ -119,10 +120,60 @@ interface AgencyLayoutClientProps { } export function AgencyLayoutClient({ children, colors }: AgencyLayoutClientProps) { + const [filteredMenuItems, setFilteredMenuItems] = useState(AGENCY_MENU_ITEMS); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchTenantSolutions = async () => { + try { + console.log('🔍 Buscando soluções do tenant...'); + const response = await fetch('/api/tenant/solutions', { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}`, + }, + }); + + console.log('📡 Response status:', response.status); + + if (response.ok) { + const data = await response.json(); + console.log('📦 Dados recebidos:', data); + const solutions = data.solutions || []; + console.log('✅ Soluções:', solutions); + + // Mapear slugs de solutions para IDs de menu + const solutionSlugs = solutions.map((s: any) => s.slug.toLowerCase()); + console.log('🏷️ Slugs das soluções:', solutionSlugs); + + // 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); + }); + + console.log('📋 Menu filtrado:', filtered.map(i => i.id)); + setFilteredMenuItems(filtered); + } else { + console.error('❌ Erro na resposta:', response.status); + // Em caso de erro, mostrar todos (fallback) + setFilteredMenuItems(AGENCY_MENU_ITEMS); + } + } catch (error) { + console.error('❌ Error fetching solutions:', error); + // Em caso de erro, mostrar todos (fallback) + setFilteredMenuItems(AGENCY_MENU_ITEMS); + } finally { + setLoading(false); + } + }; + + fetchTenantSolutions(); + }, []); + return ( - + {children} diff --git a/front-end-agency/app/(agency)/contratos/page.tsx b/front-end-agency/app/(agency)/contratos/page.tsx index f005a08..fca066f 100644 --- a/front-end-agency/app/(agency)/contratos/page.tsx +++ b/front-end-agency/app/(agency)/contratos/page.tsx @@ -1,10 +1,16 @@ +'use client'; + +import { SolutionGuard } from '@/components/auth/SolutionGuard'; + export default function ContratosPage() { return ( -
-

Contratos

-
-

Gestão de Contratos e Assinaturas em breve

+ +
+

Contratos

+
+

Gestão de Contratos e Assinaturas em breve

+
-
+ ); } diff --git a/front-end-agency/app/(agency)/crm/clientes/page.tsx b/front-end-agency/app/(agency)/crm/clientes/page.tsx new file mode 100644 index 0000000..5480afc --- /dev/null +++ b/front-end-agency/app/(agency)/crm/clientes/page.tsx @@ -0,0 +1,548 @@ +"use client"; + +import { Fragment, useEffect, useState } from 'react'; +import { Menu, Transition } from '@headlessui/react'; +import ConfirmDialog from '@/components/layout/ConfirmDialog'; +import { useToast } from '@/components/layout/ToastContext'; +import { + UserIcon, + TrashIcon, + PencilIcon, + EllipsisVerticalIcon, + MagnifyingGlassIcon, + PlusIcon, + XMarkIcon, + PhoneIcon, + EnvelopeIcon, + MapPinIcon, + TagIcon, +} from '@heroicons/react/24/outline'; + +interface Customer { + id: string; + tenant_id: string; + name: string; + email: string; + phone: string; + company: string; + position: string; + address: string; + city: string; + state: string; + zip_code: string; + country: string; + tags: string[]; + notes: string; + created_at: string; + updated_at: string; +} + +export default function CustomersPage() { + const toast = useToast(); + const [customers, setCustomers] = useState([]); + const [loading, setLoading] = useState(true); + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingCustomer, setEditingCustomer] = useState(null); + + const [confirmOpen, setConfirmOpen] = useState(false); + const [customerToDelete, setCustomerToDelete] = useState(null); + + const [searchTerm, setSearchTerm] = useState(''); + + const [formData, setFormData] = useState({ + name: '', + email: '', + phone: '', + company: '', + position: '', + address: '', + city: '', + state: '', + zip_code: '', + country: 'Brasil', + tags: '', + notes: '', + }); + + useEffect(() => { + fetchCustomers(); + }, []); + + 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); + } finally { + setLoading(false); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + const url = editingCustomer + ? `/api/crm/customers/${editingCustomer.id}` + : '/api/crm/customers'; + + const method = editingCustomer ? 'PUT' : 'POST'; + + const payload = { + ...formData, + tags: formData.tags.split(',').map(t => t.trim()).filter(t => t.length > 0), + }; + + try { + const response = await fetch(url, { + method, + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + if (response.ok) { + toast.success( + editingCustomer ? 'Cliente atualizado' : 'Cliente criado', + editingCustomer ? 'O cliente foi atualizado com sucesso.' : 'O novo cliente foi criado com sucesso.' + ); + fetchCustomers(); + handleCloseModal(); + } else { + const error = await response.json(); + toast.error('Erro', error.message || 'Não foi possível salvar o cliente.'); + } + } catch (error) { + console.error('Error saving customer:', error); + toast.error('Erro', 'Ocorreu um erro ao salvar o cliente.'); + } + }; + + const handleEdit = (customer: Customer) => { + setEditingCustomer(customer); + setFormData({ + name: customer.name, + email: customer.email, + phone: customer.phone, + company: customer.company, + position: customer.position, + address: customer.address, + city: customer.city, + state: customer.state, + zip_code: customer.zip_code, + country: customer.country, + tags: customer.tags?.join(', ') || '', + notes: customer.notes, + }); + setIsModalOpen(true); + }; + + const handleDeleteClick = (id: string) => { + setCustomerToDelete(id); + setConfirmOpen(true); + }; + + const handleConfirmDelete = async () => { + if (!customerToDelete) return; + + try { + const response = await fetch(`/api/crm/customers/${customerToDelete}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}`, + }, + }); + + if (response.ok) { + setCustomers(customers.filter(c => c.id !== customerToDelete)); + toast.success('Cliente excluído', 'O cliente foi excluído com sucesso.'); + } else { + toast.error('Erro ao excluir', 'Não foi possível excluir o cliente.'); + } + } catch (error) { + console.error('Error deleting customer:', error); + toast.error('Erro ao excluir', 'Ocorreu um erro ao excluir o cliente.'); + } finally { + setConfirmOpen(false); + setCustomerToDelete(null); + } + }; + + const handleCloseModal = () => { + setIsModalOpen(false); + setEditingCustomer(null); + setFormData({ + name: '', + email: '', + phone: '', + company: '', + position: '', + address: '', + city: '', + state: '', + zip_code: '', + country: 'Brasil', + tags: '', + notes: '', + }); + }; + + const filteredCustomers = customers.filter((customer) => { + const searchLower = searchTerm.toLowerCase(); + return ( + (customer.name?.toLowerCase() || '').includes(searchLower) || + (customer.email?.toLowerCase() || '').includes(searchLower) || + (customer.company?.toLowerCase() || '').includes(searchLower) || + (customer.phone?.toLowerCase() || '').includes(searchLower) + ); + }); + + return ( +
+ {/* Header */} +
+
+

Clientes

+

+ Gerencie seus clientes e contatos +

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

+ Nenhum cliente encontrado +

+

+ {searchTerm ? 'Nenhum cliente corresponde à sua busca.' : 'Comece adicionando seu primeiro cliente.'} +

+
+ ) : ( +
+
+ + + + + + + + + + + + {filteredCustomers.map((customer) => ( + + + + + + + + ))} + +
ClienteEmpresaContatoTagsAções
+
+
+ {customer.name.substring(0, 2).toUpperCase()} +
+
+
+ {customer.name} +
+ {customer.position && ( +
+ {customer.position} +
+ )} +
+
+
+ {customer.company || '-'} + +
+ {customer.email && ( +
+ + {customer.email} +
+ )} + {customer.phone && ( +
+ + {customer.phone} +
+ )} +
+
+
+ {customer.tags && customer.tags.length > 0 ? ( + customer.tags.slice(0, 3).map((tag, idx) => ( + + {tag} + + )) + ) : ( + - + )} + {customer.tags && customer.tags.length > 3 && ( + + +{customer.tags.length - 3} + + )} +
+
+ + + + + +
+ + {({ active }) => ( + + )} + +
+
+ + {({ active }) => ( + + )} + +
+
+
+
+
+
+ )} + + {/* Modal */} + {isModalOpen && ( +
+
+
+ +
+
+ +
+ +
+
+
+ +
+
+

+ {editingCustomer ? 'Editar Cliente' : 'Novo Cliente'} +

+

+ {editingCustomer ? 'Atualize as informações do cliente.' : 'Adicione um novo cliente ao seu CRM.'} +

+
+
+ +
+
+
+ + setFormData({ ...formData, name: e.target.value })} + 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" + /> +
+ +
+ + setFormData({ ...formData, email: e.target.value })} + 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" + /> +
+ +
+ + setFormData({ ...formData, phone: e.target.value })} + 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" + /> +
+ +
+ + setFormData({ ...formData, company: e.target.value })} + 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" + /> +
+ +
+ + setFormData({ ...formData, position: e.target.value })} + 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" + /> +
+ +
+ + setFormData({ ...formData, tags: e.target.value })} + placeholder="vip, premium, lead-quente" + 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" + /> +
+ +
+ +