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 (
Gestão de Contratos e Assinaturas em breve
+Gestão de Contratos e Assinaturas em breve
++ Gerencie seus clientes e contatos +
++ {searchTerm ? 'Nenhum cliente corresponde à sua busca.' : 'Comece adicionando seu primeiro cliente.'} +
+| Cliente | +Empresa | +Contato | +Tags | +Ações | +
|---|---|---|---|---|
|
+
+
+
+ {customer.name.substring(0, 2).toUpperCase()}
+
+
+
+
+ {customer.name}
+
+ {customer.position && (
+
+ {customer.position}
+
+ )}
+ |
+ + {customer.company || '-'} + | +
+
+ {customer.email && (
+
+
+
+ )}
+ {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}
+
+ )}
+
+ |
+ + + | +
+ Esta funcionalidade está em desenvolvimento +
++ Organize seus clientes em listas personalizadas +
++ {searchTerm ? 'Nenhuma lista corresponde à sua busca.' : 'Comece criando sua primeira lista.'} +
++ {list.description} +
+ )} ++ Esta funcionalidade está em desenvolvimento +
+- Visão geral do relacionamento com clientes -
-- {stat.name} -
-- {stat.value} -
-+ Visão geral do relacionamento com clientes +
++ {stat.name} +
++ {stat.value} +
+Funil de Vendas (Em breve)
+ ); + })}Atividades Recentes (Em breve)
+ + {/* Quick Links */} ++ {link.description} +
+Funil de Vendas (Em breve)
+Atividades Recentes (Em breve)
+{stat.name}
+{stat.value}
+{stat.name}
+{stat.value}
{stat.name}
-{stat.value}
-Gestão Eletrônica de Documentos (GED) em breve
+Gestão Eletrônica de Documentos (GED) em breve
+Sistema Integrado de Gestão Empresarial em breve
+Sistema Integrado de Gestão Empresarial em breve
+Central de Suporte e Chamados em breve
+Central de Suporte e Chamados em breve
+Gestão de Pagamentos e Cobranças em breve
+Gestão de Pagamentos e Cobranças em breve
+Gestão de Projetos em breve
+Gestão de Projetos em breve
+Planejamento e Publicação de Posts em breve
+Planejamento e Publicação de Posts em breve
+