diff --git a/1. docs/planos-aggios.md b/1. docs/planos-aggios.md new file mode 100644 index 0000000..e69de29 diff --git a/1. docs/planos-roadmap.md b/1. docs/planos-roadmap.md new file mode 100644 index 0000000..e0650d2 --- /dev/null +++ b/1. docs/planos-roadmap.md @@ -0,0 +1,173 @@ +# Sistema de Planos - Roadmap + +## Status: Estrutura Frontend Criada ✅ + +### O que foi criado no Frontend: +1. **Menu Item** adicionado em `/superadmin/layout.tsx` + - Nova rota: `/superadmin/plans` + +2. **Página Principal de Planos** (`/superadmin/plans/page.tsx`) + - Lista todos os planos em grid + - Mostra: nome, descrição, faixa de usuários, preços, features, diferenciais + - Botão "Novo Plano" + - Botões Editar e Deletar + - Status visual (ativo/inativo) + +3. **Página de Edição de Plano** (`/superadmin/plans/[id]/page.tsx`) + - Formulário completo para editar: + - Informações básicas (nome, slug, descrição) + - Faixa de usuários (min/max) + - Preços (mensal/anual) + - Armazenamento (GB) + - Status (ativo/inativo) + - TODO: Editor de Features e Diferenciais + +--- + +## Próximos Passos - Backend + +### 1. Modelo de Dados (Domain) +```go +// internal/domain/plan.go +type Plan struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + MinUsers int `json:"min_users"` + MaxUsers int `json:"max_users"` // -1 = unlimited + MonthlyPrice *decimal.Decimal `json:"monthly_price"` + AnnualPrice *decimal.Decimal `json:"annual_price"` + Features pq.StringArray `json:"features"` // CRM, ERP, etc + Differentiators pq.StringArray `json:"differentiators"` + StorageGB int `json:"storage_gb"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Subscription struct { + ID string `json:"id"` + AgencyID string `json:"agency_id"` + PlanID string `json:"plan_id"` + BillingType string `json:"billing_type"` // monthly/annual + CurrentUsers int `json:"current_users"` + Status string `json:"status"` // active/suspended/cancelled + StartDate time.Time `json:"start_date"` + RenewalDate time.Time `json:"renewal_date"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} +``` + +### 2. Migrations +- `001_create_plans_table.sql` +- `002_create_agency_subscriptions_table.sql` +- `003_add_plan_id_to_agencies.sql` + +### 3. Repository +- `PlanRepository` (CRUD) +- `SubscriptionRepository` (CRUD) + +### 4. Service +- `PlanService` (validações, lógica) +- `SubscriptionService` (validar limite de usuários, etc) + +### 5. Handlers (API) +``` +GET /api/admin/plans - Listar planos +POST /api/admin/plans - Criar plano +GET /api/admin/plans/:id - Obter plano +PUT /api/admin/plans/:id - Atualizar plano +DELETE /api/admin/plans/:id - Deletar plano + +GET /api/admin/subscriptions - Listar subscrições +``` + +### 6. Seeds +- Seed dos 4 planos padrão (Ignição, Órbita, Cosmos, Enterprise) + +--- + +## Dados Padrão para Seed + +```json +[ + { + "name": "Ignição", + "slug": "ignition", + "description": "Ideal para pequenas agências iniciantes", + "min_users": 1, + "max_users": 30, + "monthly_price": 199.99, + "annual_price": 1919.90, + "features": ["CRM", "ERP", "Projetos", "Helpdesk", "Pagamentos", "Contratos", "Documentos"], + "differentiators": [], + "storage_gb": 1, + "is_active": true + }, + { + "name": "Órbita", + "slug": "orbit", + "description": "Para agências em crescimento", + "min_users": 31, + "max_users": 100, + "monthly_price": 399.99, + "annual_price": 3839.90, + "features": ["CRM", "ERP", "Projetos", "Helpdesk", "Pagamentos", "Contratos", "Documentos"], + "differentiators": ["Suporte prioritário"], + "storage_gb": 1, + "is_active": true + }, + { + "name": "Cosmos", + "slug": "cosmos", + "description": "Para agências consolidadas", + "min_users": 101, + "max_users": 300, + "monthly_price": 799.99, + "annual_price": 7679.90, + "features": ["CRM", "ERP", "Projetos", "Helpdesk", "Pagamentos", "Contratos", "Documentos"], + "differentiators": ["Gerente de conta dedicado", "API integrações"], + "storage_gb": 1, + "is_active": true + }, + { + "name": "Enterprise", + "slug": "enterprise", + "description": "Solução customizada para grandes agências", + "min_users": 301, + "max_users": -1, + "monthly_price": null, + "annual_price": null, + "features": ["CRM", "ERP", "Projetos", "Helpdesk", "Pagamentos", "Contratos", "Documentos"], + "differentiators": ["Armazenamento customizado", "Treinamento personalizado"], + "storage_gb": 1, + "is_active": true + } +] +``` + +--- + +## Integração com Agências + +Quando agência se cadastra: +1. Seleciona um plano +2. Sistema cria `Subscription` com status `active` ou `pending_payment` +3. Agência herda limite de usuários do plano +4. Ao criar usuário: validar se não ultrapassou limite + +--- + +## Features Futuras +- [ ] Editor de Features e Diferenciais (drag-drop no frontend) +- [ ] Planos promocionais (duplicar existente, editar preço) +- [ ] Validações de limite de usuários por plano +- [ ] Dashboard com uso atual vs limite +- [ ] Alertas quando próximo do limite +- [ ] Integração com Stripe/PagSeguro + +--- + +**Pronto para começar?** diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 07039c4..c40bd5d 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -56,12 +56,15 @@ func main() { companyRepo := repository.NewCompanyRepository(db) signupTemplateRepo := repository.NewSignupTemplateRepository(db) agencyTemplateRepo := repository.NewAgencyTemplateRepository(db) + planRepo := repository.NewPlanRepository(db) + subscriptionRepo := repository.NewSubscriptionRepository(db) // Initialize services authService := service.NewAuthService(userRepo, tenantRepo, cfg) agencyService := service.NewAgencyService(userRepo, tenantRepo, cfg) tenantService := service.NewTenantService(tenantRepo) companyService := service.NewCompanyService(companyRepo) + planService := service.NewPlanService(planRepo, subscriptionRepo) // Initialize handlers healthHandler := handlers.NewHealthHandler() @@ -70,6 +73,7 @@ func main() { agencyHandler := handlers.NewAgencyRegistrationHandler(agencyService, cfg) tenantHandler := handlers.NewTenantHandler(tenantService) companyHandler := handlers.NewCompanyHandler(companyService) + planHandler := handlers.NewPlanHandler(planService) signupTemplateHandler := handlers.NewSignupTemplateHandler(signupTemplateRepo, userRepo, tenantRepo, agencyService) agencyTemplateHandler := handlers.NewAgencyTemplateHandler(agencyTemplateRepo, agencyService, userRepo, tenantRepo) filesHandler := handlers.NewFilesHandler(cfg) @@ -112,6 +116,10 @@ func main() { router.HandleFunc("/api/signup-templates/slug/{slug}", signupTemplateHandler.GetTemplateBySlug).Methods("GET") router.HandleFunc("/api/signup/register", signupTemplateHandler.PublicRegister).Methods("POST") + // Public plans (for signup flow) + router.HandleFunc("/api/plans", planHandler.ListActivePlans).Methods("GET") + router.HandleFunc("/api/plans/{id}", planHandler.GetActivePlan).Methods("GET") + // File upload (public for signup, will also work with auth) router.HandleFunc("/api/upload", uploadHandler.Upload).Methods("POST") @@ -156,6 +164,9 @@ func main() { } }))).Methods("GET", "PUT", "PATCH", "DELETE") + // SUPERADMIN: Plans management + planHandler.RegisterRoutes(router) + // ADMIN_AGENCIA: Client registration router.Handle("/api/agencies/clients/register", authMiddleware(http.HandlerFunc(agencyHandler.RegisterClient))).Methods("POST") diff --git a/backend/internal/api/handlers/files.go b/backend/internal/api/handlers/files.go index 0a72e32..ad07cec 100644 --- a/backend/internal/api/handlers/files.go +++ b/backend/internal/api/handlers/files.go @@ -39,6 +39,23 @@ func (h *FilesHandler) ServeFile(w http.ResponseWriter, r *http.Request) { return } + // Whitelist de buckets públicos permitidos + allowedBuckets := map[string]bool{ + "aggios-logos": true, + } + if !allowedBuckets[bucket] { + log.Printf("🚫 Access denied to bucket: %s", bucket) + http.Error(w, "Access denied", http.StatusForbidden) + return + } + + // Proteção contra path traversal + if strings.Contains(filePath, "..") { + log.Printf("🚫 Path traversal attempt detected: %s", filePath) + http.Error(w, "Invalid path", http.StatusBadRequest) + return + } + log.Printf("📁 Serving file: bucket=%s, path=%s", bucket, filePath) // Initialize MinIO client diff --git a/backend/internal/api/handlers/plan.go b/backend/internal/api/handlers/plan.go new file mode 100644 index 0000000..a5eb4a5 --- /dev/null +++ b/backend/internal/api/handlers/plan.go @@ -0,0 +1,268 @@ +package handlers + +import ( + "encoding/json" + "log" + "net/http" + "strconv" + + "aggios-app/backend/internal/domain" + "aggios-app/backend/internal/service" + + "github.com/google/uuid" + "github.com/gorilla/mux" +) + +// PlanHandler handles plan-related endpoints +type PlanHandler struct { + planService *service.PlanService +} + +// NewPlanHandler creates a new plan handler +func NewPlanHandler(planService *service.PlanService) *PlanHandler { + return &PlanHandler{ + planService: planService, + } +} + +// RegisterRoutes registers plan routes +func (h *PlanHandler) RegisterRoutes(r *mux.Router) { + // Note: Route protection is done in main.go with authMiddleware wrapper + r.HandleFunc("/api/admin/plans", h.CreatePlan).Methods(http.MethodPost) + r.HandleFunc("/api/admin/plans", h.ListPlans).Methods(http.MethodGet) + r.HandleFunc("/api/admin/plans/{id}", h.GetPlan).Methods(http.MethodGet) + r.HandleFunc("/api/admin/plans/{id}", h.UpdatePlan).Methods(http.MethodPut) + r.HandleFunc("/api/admin/plans/{id}", h.DeletePlan).Methods(http.MethodDelete) + + // Public routes (for signup flow) + r.HandleFunc("/api/plans", h.ListActivePlans).Methods(http.MethodGet) + r.HandleFunc("/api/plans/{id}", h.GetActivePlan).Methods(http.MethodGet) +} + +// CreatePlan creates a new plan (admin only) +func (h *PlanHandler) CreatePlan(w http.ResponseWriter, r *http.Request) { + log.Printf("📋 CREATE PLAN - Method: %s", r.Method) + + 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) + return + } + + plan, err := h.planService.CreatePlan(&req) + if err != nil { + log.Printf("❌ Error creating plan: %v", err) + switch err { + case service.ErrPlanSlugTaken: + http.Error(w, err.Error(), http.StatusConflict) + case service.ErrInvalidUserRange: + http.Error(w, err.Error(), http.StatusBadRequest) + default: + http.Error(w, "Internal server error", http.StatusInternalServerError) + } + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Plan created successfully", + "plan": plan, + }) + log.Printf("✅ Plan created: %s", plan.ID) +} + +// GetPlan retrieves a plan by ID (admin only) +func (h *PlanHandler) GetPlan(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + idStr := vars["id"] + + id, err := uuid.Parse(idStr) + if err != nil { + http.Error(w, "Invalid plan ID", http.StatusBadRequest) + return + } + + plan, err := h.planService.GetPlan(id) + if err != nil { + if err == service.ErrPlanNotFound { + http.Error(w, "Plan not found", http.StatusNotFound) + } else { + http.Error(w, "Internal server error", http.StatusInternalServerError) + } + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "plan": plan, + }) +} + +// ListPlans retrieves all plans (admin only) +func (h *PlanHandler) ListPlans(w http.ResponseWriter, r *http.Request) { + plans, err := h.planService.ListPlans() + if err != nil { + log.Printf("❌ Error listing plans: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "plans": plans, + }) + log.Printf("✅ Listed %d plans", len(plans)) +} + +// ListActivePlans retrieves all active plans (public) +func (h *PlanHandler) ListActivePlans(w http.ResponseWriter, r *http.Request) { + plans, err := h.planService.ListActivePlans() + if err != nil { + log.Printf("❌ Error listing active plans: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "plans": plans, + }) +} + +// GetActivePlan retrieves an active plan by ID (public) +func (h *PlanHandler) GetActivePlan(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + idStr := vars["id"] + + id, err := uuid.Parse(idStr) + if err != nil { + http.Error(w, "Invalid plan ID", http.StatusBadRequest) + return + } + + plan, err := h.planService.GetPlan(id) + if err != nil { + if err == service.ErrPlanNotFound { + http.Error(w, "Plan not found", http.StatusNotFound) + } else { + http.Error(w, "Internal server error", http.StatusInternalServerError) + } + return + } + + // Check if plan is active + if !plan.IsActive { + http.Error(w, "Plan not available", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "plan": plan, + }) +} + +// UpdatePlan updates a plan (admin only) +func (h *PlanHandler) UpdatePlan(w http.ResponseWriter, r *http.Request) { + log.Printf("📋 UPDATE PLAN - Method: %s", r.Method) + + vars := mux.Vars(r) + idStr := vars["id"] + + id, err := uuid.Parse(idStr) + if err != nil { + http.Error(w, "Invalid plan ID", http.StatusBadRequest) + return + } + + var req domain.UpdatePlanRequest + 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) + return + } + + plan, err := h.planService.UpdatePlan(id, &req) + if err != nil { + log.Printf("❌ Error updating plan: %v", err) + switch err { + case service.ErrPlanNotFound: + http.Error(w, "Plan not found", http.StatusNotFound) + case service.ErrPlanSlugTaken: + http.Error(w, err.Error(), http.StatusConflict) + case service.ErrInvalidUserRange: + http.Error(w, err.Error(), http.StatusBadRequest) + default: + http.Error(w, "Internal server error", http.StatusInternalServerError) + } + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Plan updated successfully", + "plan": plan, + }) + log.Printf("✅ Plan updated: %s", plan.ID) +} + +// DeletePlan deletes a plan (admin only) +func (h *PlanHandler) DeletePlan(w http.ResponseWriter, r *http.Request) { + log.Printf("📋 DELETE PLAN - Method: %s", r.Method) + + vars := mux.Vars(r) + idStr := vars["id"] + + id, err := uuid.Parse(idStr) + if err != nil { + http.Error(w, "Invalid plan ID", http.StatusBadRequest) + return + } + + err = h.planService.DeletePlan(id) + if err != nil { + log.Printf("❌ Error deleting plan: %v", err) + switch err { + case service.ErrPlanNotFound: + http.Error(w, "Plan not found", http.StatusNotFound) + default: + http.Error(w, "Internal server error", http.StatusInternalServerError) + } + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Plan deleted successfully", + }) + log.Printf("✅ Plan deleted: %s", idStr) +} + +// GetPlanByUserCount returns a plan for a given user count +func (h *PlanHandler) GetPlanByUserCount(w http.ResponseWriter, r *http.Request) { + userCountStr := r.URL.Query().Get("user_count") + if userCountStr == "" { + http.Error(w, "user_count parameter required", http.StatusBadRequest) + return + } + + userCount, err := strconv.Atoi(userCountStr) + if err != nil { + http.Error(w, "Invalid user_count", http.StatusBadRequest) + return + } + + plan, err := h.planService.GetPlanByUserCount(userCount) + if err != nil { + http.Error(w, "No plan available for this user count", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "plan": plan, + }) +} diff --git a/backend/internal/api/handlers/tenant.go b/backend/internal/api/handlers/tenant.go index 816d5de..0173b93 100644 --- a/backend/internal/api/handlers/tenant.go +++ b/backend/internal/api/handlers/tenant.go @@ -2,6 +2,7 @@ package handlers import ( "encoding/json" + "log" "net/http" "aggios-app/backend/internal/domain" @@ -100,6 +101,8 @@ func (h *TenantHandler) GetPublicConfig(w http.ResponseWriter, r *http.Request) "logo_horizontal_url": tenant.LogoHorizontalURL, } + log.Printf("📤 Returning tenant config for %s: logo_url=%s", subdomain, tenant.LogoURL) + w.Header().Set("Content-Type", "application/json; charset=utf-8") json.NewEncoder(w).Encode(response) } diff --git a/backend/internal/domain/plan.go b/backend/internal/domain/plan.go new file mode 100644 index 0000000..012bc81 --- /dev/null +++ b/backend/internal/domain/plan.go @@ -0,0 +1,78 @@ +package domain + +import ( + "time" + + "github.com/google/uuid" + "github.com/lib/pq" + "github.com/shopspring/decimal" +) + +// Plan represents a subscription plan in the system +type Plan struct { + ID uuid.UUID `json:"id" db:"id"` + Name string `json:"name" db:"name"` + Slug string `json:"slug" db:"slug"` + Description string `json:"description" db:"description"` + MinUsers int `json:"min_users" db:"min_users"` + MaxUsers int `json:"max_users" db:"max_users"` // -1 means unlimited + MonthlyPrice *decimal.Decimal `json:"monthly_price" db:"monthly_price"` + AnnualPrice *decimal.Decimal `json:"annual_price" db:"annual_price"` + Features pq.StringArray `json:"features" db:"features"` + Differentiators pq.StringArray `json:"differentiators" db:"differentiators"` + StorageGB int `json:"storage_gb" db:"storage_gb"` + 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"` +} + +// CreatePlanRequest represents the request to create a new plan +type CreatePlanRequest struct { + Name string `json:"name" validate:"required"` + Slug string `json:"slug" validate:"required"` + Description string `json:"description"` + MinUsers int `json:"min_users" validate:"required,min=1"` + MaxUsers int `json:"max_users" validate:"required"` // -1 for unlimited + MonthlyPrice *float64 `json:"monthly_price"` + AnnualPrice *float64 `json:"annual_price"` + Features []string `json:"features"` + Differentiators []string `json:"differentiators"` + StorageGB int `json:"storage_gb" validate:"required,min=1"` + IsActive bool `json:"is_active"` +} + +// UpdatePlanRequest represents the request to update a plan +type UpdatePlanRequest struct { + Name *string `json:"name"` + Slug *string `json:"slug"` + Description *string `json:"description"` + MinUsers *int `json:"min_users"` + MaxUsers *int `json:"max_users"` + MonthlyPrice *float64 `json:"monthly_price"` + AnnualPrice *float64 `json:"annual_price"` + Features []string `json:"features"` + Differentiators []string `json:"differentiators"` + StorageGB *int `json:"storage_gb"` + IsActive *bool `json:"is_active"` +} + +// Subscription represents an agency's subscription to a plan +type Subscription struct { + ID uuid.UUID `json:"id" db:"id"` + AgencyID uuid.UUID `json:"agency_id" db:"agency_id"` + PlanID uuid.UUID `json:"plan_id" db:"plan_id"` + BillingType string `json:"billing_type" db:"billing_type"` // monthly or annual + CurrentUsers int `json:"current_users" db:"current_users"` + Status string `json:"status" db:"status"` // active, suspended, cancelled + StartDate time.Time `json:"start_date" db:"start_date"` + RenewalDate time.Time `json:"renewal_date" db:"renewal_date"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// CreateSubscriptionRequest represents the request to create a subscription +type CreateSubscriptionRequest struct { + AgencyID uuid.UUID `json:"agency_id" validate:"required"` + PlanID uuid.UUID `json:"plan_id" validate:"required"` + BillingType string `json:"billing_type" validate:"required,oneof=monthly annual"` +} diff --git a/backend/internal/repository/plan_repository.go b/backend/internal/repository/plan_repository.go new file mode 100644 index 0000000..d2008b1 --- /dev/null +++ b/backend/internal/repository/plan_repository.go @@ -0,0 +1,283 @@ +package repository + +import ( + "database/sql" + "time" + + "aggios-app/backend/internal/domain" + + "github.com/google/uuid" + "github.com/lib/pq" +) + +// PlanRepository handles database operations for plans +type PlanRepository struct { + db *sql.DB +} + +// NewPlanRepository creates a new plan repository +func NewPlanRepository(db *sql.DB) *PlanRepository { + return &PlanRepository{db: db} +} + +// Create creates a new plan +func (r *PlanRepository) Create(plan *domain.Plan) error { + query := ` + INSERT INTO plans (id, name, slug, description, min_users, max_users, monthly_price, annual_price, features, differentiators, storage_gb, is_active, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + RETURNING id, created_at, updated_at + ` + + now := time.Now() + plan.ID = uuid.New() + plan.CreatedAt = now + plan.UpdatedAt = now + + features := pq.Array(plan.Features) + differentiators := pq.Array(plan.Differentiators) + + return r.db.QueryRow( + query, + plan.ID, + plan.Name, + plan.Slug, + plan.Description, + plan.MinUsers, + plan.MaxUsers, + plan.MonthlyPrice, + plan.AnnualPrice, + features, + differentiators, + plan.StorageGB, + plan.IsActive, + plan.CreatedAt, + plan.UpdatedAt, + ).Scan(&plan.ID, &plan.CreatedAt, &plan.UpdatedAt) +} + +// GetByID retrieves a plan by ID +func (r *PlanRepository) GetByID(id uuid.UUID) (*domain.Plan, error) { + query := ` + SELECT id, name, slug, description, min_users, max_users, monthly_price, annual_price, features, differentiators, storage_gb, is_active, created_at, updated_at + FROM plans + WHERE id = $1 + ` + + plan := &domain.Plan{} + var features, differentiators pq.StringArray + + err := r.db.QueryRow(query, id).Scan( + &plan.ID, + &plan.Name, + &plan.Slug, + &plan.Description, + &plan.MinUsers, + &plan.MaxUsers, + &plan.MonthlyPrice, + &plan.AnnualPrice, + &features, + &differentiators, + &plan.StorageGB, + &plan.IsActive, + &plan.CreatedAt, + &plan.UpdatedAt, + ) + + if err != nil { + return nil, err + } + + plan.Features = []string(features) + plan.Differentiators = []string(differentiators) + + return plan, nil +} + +// GetBySlug retrieves a plan by slug +func (r *PlanRepository) GetBySlug(slug string) (*domain.Plan, error) { + query := ` + SELECT id, name, slug, description, min_users, max_users, monthly_price, annual_price, features, differentiators, storage_gb, is_active, created_at, updated_at + FROM plans + WHERE slug = $1 + ` + + plan := &domain.Plan{} + var features, differentiators pq.StringArray + + err := r.db.QueryRow(query, slug).Scan( + &plan.ID, + &plan.Name, + &plan.Slug, + &plan.Description, + &plan.MinUsers, + &plan.MaxUsers, + &plan.MonthlyPrice, + &plan.AnnualPrice, + &features, + &differentiators, + &plan.StorageGB, + &plan.IsActive, + &plan.CreatedAt, + &plan.UpdatedAt, + ) + + if err != nil { + return nil, err + } + + plan.Features = []string(features) + plan.Differentiators = []string(differentiators) + + return plan, nil +} + +// ListAll retrieves all plans +func (r *PlanRepository) ListAll() ([]*domain.Plan, error) { + query := ` + SELECT id, name, slug, description, min_users, max_users, monthly_price, annual_price, features, differentiators, storage_gb, is_active, created_at, updated_at + FROM plans + ORDER BY min_users ASC + ` + + rows, err := r.db.Query(query) + if err != nil { + return nil, err + } + defer rows.Close() + + var plans []*domain.Plan + + for rows.Next() { + plan := &domain.Plan{} + var features, differentiators pq.StringArray + + err := rows.Scan( + &plan.ID, + &plan.Name, + &plan.Slug, + &plan.Description, + &plan.MinUsers, + &plan.MaxUsers, + &plan.MonthlyPrice, + &plan.AnnualPrice, + &features, + &differentiators, + &plan.StorageGB, + &plan.IsActive, + &plan.CreatedAt, + &plan.UpdatedAt, + ) + + if err != nil { + return nil, err + } + + plan.Features = []string(features) + plan.Differentiators = []string(differentiators) + plans = append(plans, plan) + } + + return plans, rows.Err() +} + +// ListActive retrieves all active plans +func (r *PlanRepository) ListActive() ([]*domain.Plan, error) { + query := ` + SELECT id, name, slug, description, min_users, max_users, monthly_price, annual_price, features, differentiators, storage_gb, is_active, created_at, updated_at + FROM plans + WHERE is_active = true + ORDER BY min_users ASC + ` + + rows, err := r.db.Query(query) + if err != nil { + return nil, err + } + defer rows.Close() + + var plans []*domain.Plan + + for rows.Next() { + plan := &domain.Plan{} + var features, differentiators pq.StringArray + + err := rows.Scan( + &plan.ID, + &plan.Name, + &plan.Slug, + &plan.Description, + &plan.MinUsers, + &plan.MaxUsers, + &plan.MonthlyPrice, + &plan.AnnualPrice, + &features, + &differentiators, + &plan.StorageGB, + &plan.IsActive, + &plan.CreatedAt, + &plan.UpdatedAt, + ) + + if err != nil { + return nil, err + } + + plan.Features = []string(features) + plan.Differentiators = []string(differentiators) + plans = append(plans, plan) + } + + return plans, rows.Err() +} + +// Update updates a plan +func (r *PlanRepository) Update(plan *domain.Plan) error { + query := ` + UPDATE plans + SET name = $2, slug = $3, description = $4, min_users = $5, max_users = $6, monthly_price = $7, annual_price = $8, features = $9, differentiators = $10, storage_gb = $11, is_active = $12, updated_at = $13 + WHERE id = $1 + RETURNING updated_at + ` + + plan.UpdatedAt = time.Now() + + features := pq.Array(plan.Features) + differentiators := pq.Array(plan.Differentiators) + + return r.db.QueryRow( + query, + plan.ID, + plan.Name, + plan.Slug, + plan.Description, + plan.MinUsers, + plan.MaxUsers, + plan.MonthlyPrice, + plan.AnnualPrice, + features, + differentiators, + plan.StorageGB, + plan.IsActive, + plan.UpdatedAt, + ).Scan(&plan.UpdatedAt) +} + +// Delete deletes a plan +func (r *PlanRepository) Delete(id uuid.UUID) error { + query := `DELETE FROM plans WHERE id = $1` + result, err := r.db.Exec(query, id) + if err != nil { + return err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + + if rowsAffected == 0 { + return sql.ErrNoRows + } + + return nil +} diff --git a/backend/internal/repository/subscription_repository.go b/backend/internal/repository/subscription_repository.go new file mode 100644 index 0000000..27748d9 --- /dev/null +++ b/backend/internal/repository/subscription_repository.go @@ -0,0 +1,203 @@ +package repository + +import ( + "database/sql" + "time" + + "aggios-app/backend/internal/domain" + + "github.com/google/uuid" +) + +// SubscriptionRepository handles database operations for subscriptions +type SubscriptionRepository struct { + db *sql.DB +} + +// NewSubscriptionRepository creates a new subscription repository +func NewSubscriptionRepository(db *sql.DB) *SubscriptionRepository { + return &SubscriptionRepository{db: db} +} + +// Create creates a new subscription +func (r *SubscriptionRepository) Create(subscription *domain.Subscription) error { + query := ` + INSERT INTO agency_subscriptions (id, agency_id, plan_id, billing_type, current_users, status, start_date, renewal_date, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id, created_at, updated_at + ` + + now := time.Now() + subscription.ID = uuid.New() + subscription.CreatedAt = now + subscription.UpdatedAt = now + subscription.StartDate = now + + // Set renewal date based on billing type + if subscription.BillingType == "annual" { + subscription.RenewalDate = now.AddDate(1, 0, 0) + } else { + subscription.RenewalDate = now.AddDate(0, 1, 0) + } + + return r.db.QueryRow( + query, + subscription.ID, + subscription.AgencyID, + subscription.PlanID, + subscription.BillingType, + subscription.CurrentUsers, + subscription.Status, + subscription.StartDate, + subscription.RenewalDate, + subscription.CreatedAt, + subscription.UpdatedAt, + ).Scan(&subscription.ID, &subscription.CreatedAt, &subscription.UpdatedAt) +} + +// GetByID retrieves a subscription by ID +func (r *SubscriptionRepository) GetByID(id uuid.UUID) (*domain.Subscription, error) { + query := ` + SELECT id, agency_id, plan_id, billing_type, current_users, status, start_date, renewal_date, created_at, updated_at + FROM agency_subscriptions + WHERE id = $1 + ` + + subscription := &domain.Subscription{} + err := r.db.QueryRow(query, id).Scan( + &subscription.ID, + &subscription.AgencyID, + &subscription.PlanID, + &subscription.BillingType, + &subscription.CurrentUsers, + &subscription.Status, + &subscription.StartDate, + &subscription.RenewalDate, + &subscription.CreatedAt, + &subscription.UpdatedAt, + ) + + return subscription, err +} + +// GetByAgencyID retrieves a subscription by agency ID +func (r *SubscriptionRepository) GetByAgencyID(agencyID uuid.UUID) (*domain.Subscription, error) { + query := ` + SELECT id, agency_id, plan_id, billing_type, current_users, status, start_date, renewal_date, created_at, updated_at + FROM agency_subscriptions + WHERE agency_id = $1 AND status = 'active' + LIMIT 1 + ` + + subscription := &domain.Subscription{} + err := r.db.QueryRow(query, agencyID).Scan( + &subscription.ID, + &subscription.AgencyID, + &subscription.PlanID, + &subscription.BillingType, + &subscription.CurrentUsers, + &subscription.Status, + &subscription.StartDate, + &subscription.RenewalDate, + &subscription.CreatedAt, + &subscription.UpdatedAt, + ) + + return subscription, err +} + +// ListAll retrieves all subscriptions +func (r *SubscriptionRepository) ListAll() ([]*domain.Subscription, error) { + query := ` + SELECT id, agency_id, plan_id, billing_type, current_users, status, start_date, renewal_date, created_at, updated_at + FROM agency_subscriptions + ORDER BY created_at DESC + ` + + rows, err := r.db.Query(query) + if err != nil { + return nil, err + } + defer rows.Close() + + var subscriptions []*domain.Subscription + + for rows.Next() { + subscription := &domain.Subscription{} + err := rows.Scan( + &subscription.ID, + &subscription.AgencyID, + &subscription.PlanID, + &subscription.BillingType, + &subscription.CurrentUsers, + &subscription.Status, + &subscription.StartDate, + &subscription.RenewalDate, + &subscription.CreatedAt, + &subscription.UpdatedAt, + ) + + if err != nil { + return nil, err + } + + subscriptions = append(subscriptions, subscription) + } + + return subscriptions, rows.Err() +} + +// Update updates a subscription +func (r *SubscriptionRepository) Update(subscription *domain.Subscription) error { + query := ` + UPDATE agency_subscriptions + SET plan_id = $2, billing_type = $3, current_users = $4, status = $5, renewal_date = $6, updated_at = $7 + WHERE id = $1 + RETURNING updated_at + ` + + subscription.UpdatedAt = time.Now() + + return r.db.QueryRow( + query, + subscription.ID, + subscription.PlanID, + subscription.BillingType, + subscription.CurrentUsers, + subscription.Status, + subscription.RenewalDate, + subscription.UpdatedAt, + ).Scan(&subscription.UpdatedAt) +} + +// Delete deletes a subscription +func (r *SubscriptionRepository) Delete(id uuid.UUID) error { + query := `DELETE FROM agency_subscriptions WHERE id = $1` + result, err := r.db.Exec(query, id) + if err != nil { + return err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + + if rowsAffected == 0 { + return sql.ErrNoRows + } + + return nil +} + +// UpdateUserCount updates the current user count for a subscription +func (r *SubscriptionRepository) UpdateUserCount(agencyID uuid.UUID, userCount int) error { + query := ` + UPDATE agency_subscriptions + SET current_users = $2, updated_at = $3 + WHERE agency_id = $1 AND status = 'active' + ` + + _, err := r.db.Exec(query, agencyID, userCount, time.Now()) + return err +} diff --git a/backend/internal/service/plan_service.go b/backend/internal/service/plan_service.go new file mode 100644 index 0000000..9d95bca --- /dev/null +++ b/backend/internal/service/plan_service.go @@ -0,0 +1,286 @@ +package service + +import ( + "database/sql" + "errors" + "fmt" + + "aggios-app/backend/internal/domain" + "aggios-app/backend/internal/repository" + + "github.com/google/uuid" + "github.com/shopspring/decimal" +) + +var ( + ErrPlanNotFound = errors.New("plan not found") + ErrPlanSlugTaken = errors.New("plan slug already exists") + ErrInvalidUserRange = errors.New("invalid user range: min_users must be less than or equal to max_users") + ErrSubscriptionNotFound = errors.New("subscription not found") + ErrUserLimitExceeded = errors.New("user limit exceeded for this plan") + ErrSubscriptionExists = errors.New("agency already has an active subscription") +) + +// PlanService handles plan business logic +type PlanService struct { + planRepo *repository.PlanRepository + subscriptionRepo *repository.SubscriptionRepository +} + +// NewPlanService creates a new plan service +func NewPlanService(planRepo *repository.PlanRepository, subscriptionRepo *repository.SubscriptionRepository) *PlanService { + return &PlanService{ + planRepo: planRepo, + subscriptionRepo: subscriptionRepo, + } +} + +// CreatePlan creates a new plan +func (s *PlanService) CreatePlan(req *domain.CreatePlanRequest) (*domain.Plan, error) { + // Validate user range + if req.MinUsers > req.MaxUsers && req.MaxUsers != -1 { + return nil, ErrInvalidUserRange + } + + // Check if slug is unique + existing, _ := s.planRepo.GetBySlug(req.Slug) + if existing != nil { + return nil, ErrPlanSlugTaken + } + + plan := &domain.Plan{ + Name: req.Name, + Slug: req.Slug, + Description: req.Description, + MinUsers: req.MinUsers, + MaxUsers: req.MaxUsers, + Features: req.Features, + Differentiators: req.Differentiators, + StorageGB: req.StorageGB, + IsActive: req.IsActive, + } + + // Convert prices if provided + if req.MonthlyPrice != nil { + price := decimal.NewFromFloat(*req.MonthlyPrice) + plan.MonthlyPrice = &price + } + if req.AnnualPrice != nil { + price := decimal.NewFromFloat(*req.AnnualPrice) + plan.AnnualPrice = &price + } + + if err := s.planRepo.Create(plan); err != nil { + return nil, err + } + + return plan, nil +} + +// GetPlan retrieves a plan by ID +func (s *PlanService) GetPlan(id uuid.UUID) (*domain.Plan, error) { + plan, err := s.planRepo.GetByID(id) + if err != nil { + if err == sql.ErrNoRows { + return nil, ErrPlanNotFound + } + return nil, err + } + return plan, nil +} + +// ListPlans retrieves all plans +func (s *PlanService) ListPlans() ([]*domain.Plan, error) { + return s.planRepo.ListAll() +} + +// ListActivePlans retrieves all active plans +func (s *PlanService) ListActivePlans() ([]*domain.Plan, error) { + return s.planRepo.ListActive() +} + +// UpdatePlan updates a plan +func (s *PlanService) UpdatePlan(id uuid.UUID, req *domain.UpdatePlanRequest) (*domain.Plan, error) { + plan, err := s.planRepo.GetByID(id) + if err != nil { + if err == sql.ErrNoRows { + return nil, ErrPlanNotFound + } + return nil, err + } + + // Update fields if provided + if req.Name != nil { + plan.Name = *req.Name + } + if req.Slug != nil { + // Check if new slug is unique + existing, _ := s.planRepo.GetBySlug(*req.Slug) + if existing != nil && existing.ID != plan.ID { + return nil, ErrPlanSlugTaken + } + plan.Slug = *req.Slug + } + if req.Description != nil { + plan.Description = *req.Description + } + if req.MinUsers != nil { + plan.MinUsers = *req.MinUsers + } + if req.MaxUsers != nil { + plan.MaxUsers = *req.MaxUsers + } + if req.MonthlyPrice != nil { + price := decimal.NewFromFloat(*req.MonthlyPrice) + plan.MonthlyPrice = &price + } + if req.AnnualPrice != nil { + price := decimal.NewFromFloat(*req.AnnualPrice) + plan.AnnualPrice = &price + } + if req.Features != nil { + plan.Features = req.Features + } + if req.Differentiators != nil { + plan.Differentiators = req.Differentiators + } + if req.StorageGB != nil { + plan.StorageGB = *req.StorageGB + } + if req.IsActive != nil { + plan.IsActive = *req.IsActive + } + + // Validate user range + if plan.MinUsers > plan.MaxUsers && plan.MaxUsers != -1 { + return nil, ErrInvalidUserRange + } + + if err := s.planRepo.Update(plan); err != nil { + return nil, err + } + + return plan, nil +} + +// DeletePlan deletes a plan +func (s *PlanService) DeletePlan(id uuid.UUID) error { + // Check if plan exists + if _, err := s.planRepo.GetByID(id); err != nil { + if err == sql.ErrNoRows { + return ErrPlanNotFound + } + return err + } + + return s.planRepo.Delete(id) +} + +// CreateSubscription creates a new subscription for an agency +func (s *PlanService) CreateSubscription(req *domain.CreateSubscriptionRequest) (*domain.Subscription, error) { + // Check if plan exists + plan, err := s.planRepo.GetByID(req.PlanID) + if err != nil { + if err == sql.ErrNoRows { + return nil, ErrPlanNotFound + } + return nil, err + } + + // Check if agency already has active subscription + existing, err := s.subscriptionRepo.GetByAgencyID(req.AgencyID) + if err != nil && err != sql.ErrNoRows { + return nil, err + } + if existing != nil { + return nil, ErrSubscriptionExists + } + + subscription := &domain.Subscription{ + AgencyID: req.AgencyID, + PlanID: req.PlanID, + BillingType: req.BillingType, + Status: "active", + CurrentUsers: 0, + } + + if err := s.subscriptionRepo.Create(subscription); err != nil { + return nil, err + } + + // Load plan details + subscription.PlanID = plan.ID + + return subscription, nil +} + +// GetSubscription retrieves a subscription by ID +func (s *PlanService) GetSubscription(id uuid.UUID) (*domain.Subscription, error) { + subscription, err := s.subscriptionRepo.GetByID(id) + if err != nil { + if err == sql.ErrNoRows { + return nil, ErrSubscriptionNotFound + } + return nil, err + } + return subscription, nil +} + +// GetAgencySubscription retrieves an agency's active subscription +func (s *PlanService) GetAgencySubscription(agencyID uuid.UUID) (*domain.Subscription, error) { + subscription, err := s.subscriptionRepo.GetByAgencyID(agencyID) + if err != nil { + if err == sql.ErrNoRows { + return nil, ErrSubscriptionNotFound + } + return nil, err + } + return subscription, nil +} + +// ListSubscriptions retrieves all subscriptions +func (s *PlanService) ListSubscriptions() ([]*domain.Subscription, error) { + return s.subscriptionRepo.ListAll() +} + +// ValidateUserLimit checks if adding a user would exceed plan limit +func (s *PlanService) ValidateUserLimit(agencyID uuid.UUID, newUserCount int) error { + subscription, err := s.subscriptionRepo.GetByAgencyID(agencyID) + if err != nil { + if err == sql.ErrNoRows { + return ErrSubscriptionNotFound + } + return err + } + + plan, err := s.planRepo.GetByID(subscription.PlanID) + if err != nil { + if err == sql.ErrNoRows { + return ErrPlanNotFound + } + return err + } + + if plan.MaxUsers != -1 && newUserCount > plan.MaxUsers { + return fmt.Errorf("%w (limit: %d, requested: %d)", ErrUserLimitExceeded, plan.MaxUsers, newUserCount) + } + + return nil +} + +// GetPlanByUserCount returns the appropriate plan for a given user count +func (s *PlanService) GetPlanByUserCount(userCount int) (*domain.Plan, error) { + plans, err := s.planRepo.ListActive() + if err != nil { + return nil, err + } + + // Find the plan that fits the user count + for _, plan := range plans { + if userCount >= plan.MinUsers && (plan.MaxUsers == -1 || userCount <= plan.MaxUsers) { + return plan, nil + } + } + + return nil, fmt.Errorf("no plan found for user count: %d", userCount) +} diff --git a/docs/TEST_LOGO_UPLOAD.md b/docs/TEST_LOGO_UPLOAD.md deleted file mode 100644 index c26a685..0000000 --- a/docs/TEST_LOGO_UPLOAD.md +++ /dev/null @@ -1,15 +0,0 @@ -# Teste manual do endpoint de upload de logo - -## 1. Login e obter token -curl -X POST http://idealpages.localhost/api/auth/login \ - -H "Content-Type: application/json" \ - -d '{"email":"admin@idealpages.com","password":"admin123"}' - -## 2. Upload de logo (substituir TOKEN pelo valor retornado acima) -curl -X POST http://idealpages.localhost/api/agency/logo \ - -H "Authorization: Bearer TOKEN" \ - -F "logo=@/caminho/para/imagem.png" \ - -F "type=logo" - -## 3. Verificar se salvou no banco -docker exec aggios-postgres psql -U aggios -d aggios_db -c "SELECT id, name, logo_url FROM tenants WHERE subdomain = 'idealpages';" diff --git a/front-end-agency/app/(agency)/configuracoes/page.tsx b/front-end-agency/app/(agency)/configuracoes/page.tsx index 28034a4..598c0c4 100644 --- a/front-end-agency/app/(agency)/configuracoes/page.tsx +++ b/front-end-agency/app/(agency)/configuracoes/page.tsx @@ -127,9 +127,6 @@ export default function ConfiguracoesPage() { if (response.ok) { const data = await response.json(); - console.log('DEBUG: API response data:', data); - console.log('DEBUG: logo_url:', data.logo_url); - console.log('DEBUG: logo_horizontal_url:', data.logo_horizontal_url); const parsedAddress = parseAddressParts(data.address || ''); setAgencyData({ @@ -150,21 +147,26 @@ export default function ConfiguracoesPage() { description: data.description || '', industry: data.industry || '', teamSize: data.team_size || '', - logoUrl: data.logo_url || '', - logoHorizontalUrl: data.logo_horizontal_url || '', + logoUrl: data.logo_url || localStorage.getItem('agency-logo-url') || '', + logoHorizontalUrl: data.logo_horizontal_url || localStorage.getItem('agency-logo-horizontal-url') || '', primaryColor: data.primary_color || '#ff3a05', secondaryColor: data.secondary_color || '#ff0080', }); - // Set logo previews - console.log('DEBUG: Setting previews...'); - if (data.logo_url) { - console.log('DEBUG: Setting logoPreview to:', data.logo_url); - setLogoPreview(data.logo_url); + // Set logo previews - usar localStorage como fallback se API não retornar + const cachedLogo = localStorage.getItem('agency-logo-url'); + const cachedHorizontal = localStorage.getItem('agency-logo-horizontal-url'); + + const finalLogoUrl = data.logo_url || cachedLogo; + const finalHorizontalUrl = data.logo_horizontal_url || cachedHorizontal; + + if (finalLogoUrl) { + setLogoPreview(finalLogoUrl); + localStorage.setItem('agency-logo-url', finalLogoUrl); } - if (data.logo_horizontal_url) { - console.log('DEBUG: Setting logoHorizontalPreview to:', data.logo_horizontal_url); - setLogoHorizontalPreview(data.logo_horizontal_url); + if (finalHorizontalUrl) { + setLogoHorizontalPreview(finalHorizontalUrl); + localStorage.setItem('agency-logo-horizontal-url', finalHorizontalUrl); } } else { console.error('Erro ao buscar dados:', response.status); @@ -403,8 +405,25 @@ export default function ConfiguracoesPage() { // Atualiza localStorage imediatamente para persistência instantânea localStorage.setItem('agency-primary-color', agencyData.primaryColor); localStorage.setItem('agency-secondary-color', agencyData.secondaryColor); - if (agencyData.logoUrl) localStorage.setItem('agency-logo-url', agencyData.logoUrl); - if (agencyData.logoHorizontalUrl) localStorage.setItem('agency-logo-horizontal-url', agencyData.logoHorizontalUrl); + + // Preservar logos no localStorage (não sobrescrever com valores vazios) + // Logos são gerenciados separadamente via upload + const currentLogoCache = localStorage.getItem('agency-logo-url'); + const currentHorizontalCache = localStorage.getItem('agency-logo-horizontal-url'); + + // Só atualizar se temos valores novos no estado + if (agencyData.logoUrl) { + localStorage.setItem('agency-logo-url', agencyData.logoUrl); + } else if (!currentLogoCache && logoPreview) { + // Se não tem cache mas tem preview, usar o preview + localStorage.setItem('agency-logo-url', logoPreview); + } + + if (agencyData.logoHorizontalUrl) { + localStorage.setItem('agency-logo-horizontal-url', agencyData.logoHorizontalUrl); + } else if (!currentHorizontalCache && logoHorizontalPreview) { + localStorage.setItem('agency-logo-horizontal-url', logoHorizontalPreview); + } // Disparar evento para atualizar o tema em tempo real window.dispatchEvent(new Event('branding-update')); diff --git a/front-end-agency/app/globals.css b/front-end-agency/app/globals.css index c4de1c6..938efd3 100644 --- a/front-end-agency/app/globals.css +++ b/front-end-agency/app/globals.css @@ -47,7 +47,7 @@ html.dark { @layer base { * { - font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif; + font-family: var(--font-arimo), ui-sans-serif, system-ui, sans-serif; } a, diff --git a/front-end-agency/app/layout.tsx b/front-end-agency/app/layout.tsx index 0c48857..dd0d4d0 100644 --- a/front-end-agency/app/layout.tsx +++ b/front-end-agency/app/layout.tsx @@ -1,12 +1,12 @@ import type { Metadata } from "next"; -import { Inter, Open_Sans, Fira_Code } from "next/font/google"; +import { Arimo, Open_Sans, Fira_Code } from "next/font/google"; import "./globals.css"; import LayoutWrapper from "./LayoutWrapper"; import { ThemeProvider } from "next-themes"; import { getAgencyLogo } from "@/lib/server-api"; -const inter = Inter({ - variable: "--font-inter", +const arimo = Arimo({ + variable: "--font-arimo", subsets: ["latin"], weight: ["400", "500", "600", "700"], }); @@ -26,13 +26,18 @@ const firaCode = Fira_Code({ export async function generateMetadata(): Promise { const logoUrl = await getAgencyLogo(); + // Adicionar timestamp para forçar atualização do favicon + const faviconUrl = logoUrl + ? `${logoUrl}?v=${Date.now()}` + : '/favicon.ico'; + return { title: "Aggios - Dashboard", description: "Plataforma SaaS para agências digitais", icons: { - icon: logoUrl || '/favicon.ico', - shortcut: logoUrl || '/favicon.ico', - apple: logoUrl || '/favicon.ico', + icon: faviconUrl, + shortcut: faviconUrl, + apple: faviconUrl, }, }; } @@ -47,7 +52,7 @@ export default function RootLayout({ - + {children} diff --git a/front-end-agency/components/auth/LoginBranding.tsx b/front-end-agency/components/auth/LoginBranding.tsx index b53717b..910a9c4 100644 --- a/front-end-agency/components/auth/LoginBranding.tsx +++ b/front-end-agency/components/auth/LoginBranding.tsx @@ -1,20 +1,13 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; /** * LoginBranding - Aplica cor primária da agência na página de login * Busca cor do localStorage ou da API se não houver cache */ export function LoginBranding() { - const [mounted, setMounted] = useState(false); - useEffect(() => { - setMounted(true); - }, []); - - useEffect(() => { - if (!mounted) return; const hexToRgb = (hex: string) => { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); @@ -41,26 +34,19 @@ export function LoginBranding() { if (typeof window === 'undefined' || typeof document === 'undefined') return; try { - console.log('🎨 LoginBranding: Atualizando favicon para:', url); const newHref = `${url}${url.includes('?') ? '&' : '?'}v=${Date.now()}`; - - // Buscar TODOS os links de ícone existentes const existingLinks = document.querySelectorAll("link[rel*='icon']"); if (existingLinks.length > 0) { - // Atualizar href de todos os links existentes (SEM REMOVER) existingLinks.forEach(link => { link.setAttribute('href', newHref); }); - console.log(`✅ ${existingLinks.length} favicons atualizados`); } else { - // Criar novo link apenas se não existir nenhum const newLink = document.createElement('link'); newLink.rel = 'icon'; newLink.type = 'image/x-icon'; newLink.href = newHref; document.head.appendChild(newLink); - console.log('✅ Novo favicon criado'); } } catch (error) { console.error('❌ Erro ao atualizar favicon:', error); @@ -88,18 +74,15 @@ export function LoginBranding() { if (response.ok) { const data = await response.json(); - console.log('LoginBranding: Dados recebidos:', data); if (data.primary_color) { applyTheme(data.primary_color); localStorage.setItem('agency-primary-color', data.primary_color); - console.log('LoginBranding: Cor aplicada!'); } if (data.logo_url) { updateFavicon(data.logo_url); localStorage.setItem('agency-logo-url', data.logo_url); - console.log('LoginBranding: Favicon aplicado!'); } return; } else { @@ -107,7 +90,6 @@ export function LoginBranding() { } // 2. Fallback para cache - console.log('LoginBranding: Tentando cache'); const cachedPrimary = localStorage.getItem('agency-primary-color'); const cachedLogo = localStorage.getItem('agency-logo-url'); @@ -132,7 +114,7 @@ export function LoginBranding() { }; loadBranding(); - }, [mounted]); + }, []); return null; } diff --git a/front-end-agency/components/layout/AgencyBranding.tsx b/front-end-agency/components/layout/AgencyBranding.tsx index 5f44b91..6b85836 100644 --- a/front-end-agency/components/layout/AgencyBranding.tsx +++ b/front-end-agency/components/layout/AgencyBranding.tsx @@ -11,20 +11,10 @@ interface AgencyBrandingProps { /** * AgencyBranding - Aplica as cores da agência via CSS Variables - * O favicon agora é tratado via Metadata API no layout (server-side) + * O favicon é atualizado dinamicamente via DOM */ export function AgencyBranding({ colors }: AgencyBrandingProps) { - const [mounted, setMounted] = useState(false); - - const [debugInfo, setDebugInfo] = useState('Iniciando...'); - useEffect(() => { - setMounted(true); - }, []); - - useEffect(() => { - if (!mounted) return; - const hexToRgb = (hex: string) => { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); return result ? `${parseInt(result[1], 16)} ${parseInt(result[2], 16)} ${parseInt(result[3], 16)}` : null; @@ -67,33 +57,25 @@ export function AgencyBranding({ colors }: AgencyBrandingProps) { if (typeof window === 'undefined' || typeof document === 'undefined') return; try { - setDebugInfo(`Tentando atualizar favicon: ${url}`); - console.log('🎨 AgencyBranding: Atualizando favicon para:', url); - const newHref = `${url}${url.includes('?') ? '&' : '?'}v=${Date.now()}`; - // Buscar TODOS os links de ícone existentes + // Buscar TODOS os links de ícone (como estava funcionando antes) const existingLinks = document.querySelectorAll("link[rel*='icon']"); if (existingLinks.length > 0) { - // Atualizar href de todos os links existentes (SEM REMOVER) existingLinks.forEach(link => { link.setAttribute('href', newHref); }); - setDebugInfo(`Favicon atualizado (${existingLinks.length} links)`); console.log(`✅ ${existingLinks.length} favicons atualizados`); } else { - // Criar novo link apenas se não existir nenhum const newLink = document.createElement('link'); newLink.rel = 'icon'; newLink.type = 'image/x-icon'; newLink.href = newHref; document.head.appendChild(newLink); - setDebugInfo('Novo favicon criado'); - console.log('✅ Novo favicon criado'); + console.log('✅ Favicon criado'); } } catch (error) { - setDebugInfo(`Erro: ${error}`); console.error('❌ Erro ao atualizar favicon:', error); } }; @@ -111,32 +93,23 @@ export function AgencyBranding({ colors }: AgencyBrandingProps) { } } - // Atualizar favicon se houver logo salvo (após montar) + // Atualizar favicon se houver logo salvo const cachedLogo = localStorage.getItem('agency-logo-url'); if (cachedLogo) { - console.log('🔍 Logo encontrado no cache:', cachedLogo); updateFavicon(cachedLogo); - } else { - setDebugInfo('Nenhum logo no cache'); - console.log('⚠️ Nenhum logo encontrado no cache'); } - // Listener para atualizações em tempo real (ex: da página de configurações) + // Listener para atualizações em tempo real const handleUpdate = () => { - console.log('🔔 Evento branding-update recebido!'); - setDebugInfo('Evento branding-update recebido'); - const cachedPrimary = localStorage.getItem('agency-primary-color'); const cachedSecondary = localStorage.getItem('agency-secondary-color'); const cachedLogo = localStorage.getItem('agency-logo-url'); if (cachedPrimary && cachedSecondary) { - console.log('🎨 Aplicando cores do cache'); applyTheme(cachedPrimary, cachedSecondary); } if (cachedLogo) { - console.log('🖼️ Atualizando favicon do cache:', cachedLogo); updateFavicon(cachedLogo); } }; @@ -146,24 +119,8 @@ export function AgencyBranding({ colors }: AgencyBrandingProps) { return () => { window.removeEventListener('branding-update', handleUpdate); }; - }, [mounted, colors]); + }, [colors]); - if (!mounted) return null; - - return ( -
- DEBUG: {debugInfo} -
- ); + // Componente não renderiza nada visualmente (apenas efeitos colaterais) + return null; } diff --git a/front-end-agency/components/layout/SidebarRail.tsx b/front-end-agency/components/layout/SidebarRail.tsx index 2e19659..d680f8d 100644 --- a/front-end-agency/components/layout/SidebarRail.tsx +++ b/front-end-agency/components/layout/SidebarRail.tsx @@ -69,18 +69,22 @@ export const SidebarRail: React.FC = ({ if (res.ok) { const data = await res.json(); if (currentUser) { + // Usar localStorage como fallback se API não retornar logo + const cachedLogo = localStorage.getItem('agency-logo-url'); + const finalLogoUrl = data.logo_url || cachedLogo; + const updatedUser = { ...currentUser, company: data.name || currentUser.company, - logoUrl: data.logo_url + logoUrl: finalLogoUrl }; setUser(updatedUser); saveAuth(token, updatedUser); // Persistir atualização - // Atualizar localStorage do logo para uso do favicon - if (data.logo_url) { - console.log('📝 Salvando logo no localStorage:', data.logo_url); - localStorage.setItem('agency-logo-url', data.logo_url); + // Atualizar localStorage do logo (preservar se já existe) + if (finalLogoUrl) { + console.log('📝 Salvando logo no localStorage:', finalLogoUrl); + localStorage.setItem('agency-logo-url', finalLogoUrl); window.dispatchEvent(new Event('auth-update')); // Notificar favicon window.dispatchEvent(new Event('branding-update')); // Notificar AgencyBranding } diff --git a/front-end-agency/lib/server-api.ts b/front-end-agency/lib/server-api.ts index 5363e34..e16db9e 100644 --- a/front-end-agency/lib/server-api.ts +++ b/front-end-agency/lib/server-api.ts @@ -23,9 +23,15 @@ export async function getAgencyBranding(): Promise { // Pegar o hostname do request const headersList = await headers(); const hostname = headersList.get('host') || ''; - const subdomain = hostname.split('.')[0]; + + // Extrair subdomain (remover porta se houver) + const hostnameWithoutPort = hostname.split(':')[0]; + const subdomain = hostnameWithoutPort.split('.')[0]; + + console.log(`[ServerAPI] Full hostname: ${hostname}, Without port: ${hostnameWithoutPort}, Subdomain: ${subdomain}`); if (!subdomain || subdomain === 'localhost' || subdomain === 'www') { + console.log(`[ServerAPI] Invalid subdomain, skipping: ${subdomain}`); return null; } diff --git a/front-end-agency/middleware.ts b/front-end-agency/middleware.ts index cb1d05f..a0fd1a6 100644 --- a/front-end-agency/middleware.ts +++ b/front-end-agency/middleware.ts @@ -7,11 +7,16 @@ export async function middleware(request: NextRequest) { const apiBase = process.env.API_INTERNAL_URL || 'http://backend:8080'; - // Extrair subdomínio - const subdomain = hostname.split('.')[0]; + // Extrair subdomínio (remover porta se houver) + const hostnameWithoutPort = hostname.split(':')[0]; + const subdomain = hostnameWithoutPort.split('.')[0]; - // Validar subdomínio de agência ({subdomain}.localhost) - if (hostname.includes('.')) { + // Rotas públicas que não precisam de validação de tenant + const publicPaths = ['/login', '/cadastro', '/']; + const isPublicPath = publicPaths.some(path => url.pathname === path || url.pathname.startsWith(path + '/')); + + // Validar subdomínio de agência ({subdomain}.localhost) apenas se não for rota pública + if (hostname.includes('.') && !isPublicPath) { try { const res = await fetch(`${apiBase}/api/tenant/check?subdomain=${subdomain}`, { cache: 'no-store', diff --git a/front-end-dash.aggios.app/app/superadmin/layout.tsx b/front-end-dash.aggios.app/app/superadmin/layout.tsx index ea7fad0..11ea905 100644 --- a/front-end-dash.aggios.app/app/superadmin/layout.tsx +++ b/front-end-dash.aggios.app/app/superadmin/layout.tsx @@ -7,11 +7,13 @@ import { LinkIcon, DocumentTextIcon, Cog6ToothIcon, + SparklesIcon, } from '@heroicons/react/24/outline'; const SUPERADMIN_MENU_ITEMS = [ { id: 'dashboard', label: 'Dashboard', href: '/superadmin', icon: HomeIcon }, { id: 'agencies', label: 'Agências', href: '/superadmin/agencies', icon: BuildingOfficeIcon }, + { id: 'plans', label: 'Planos', href: '/superadmin/plans', icon: SparklesIcon }, { id: 'templates', label: 'Templates', href: '/superadmin/signup-templates', icon: LinkIcon }, { id: 'agency-templates', label: 'Templates Agência', href: '/superadmin/agency-templates', icon: DocumentTextIcon }, { id: 'settings', label: 'Configurações', href: '/superadmin/settings', icon: Cog6ToothIcon }, diff --git a/front-end-dash.aggios.app/app/superadmin/plans/[id]/page.tsx b/front-end-dash.aggios.app/app/superadmin/plans/[id]/page.tsx new file mode 100644 index 0000000..9c04cda --- /dev/null +++ b/front-end-dash.aggios.app/app/superadmin/plans/[id]/page.tsx @@ -0,0 +1,371 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter, useParams } from 'next/navigation'; +import { ArrowLeftIcon, CheckCircleIcon } from '@heroicons/react/24/outline'; + +interface Plan { + id: string; + name: string; + slug: string; + description: string; + min_users: number; + max_users: number; + monthly_price: number | null; + annual_price: number | null; + features: string[]; + differentiators: string[]; + storage_gb: number; + is_active: boolean; + created_at: string; +} + +export default function EditPlanPage() { + const router = useRouter(); + const params = useParams(); + const planId = params.id as string; + + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const [plan, setPlan] = useState(null); + const [formData, setFormData] = useState>({}); + + useEffect(() => { + const token = localStorage.getItem('token'); + if (!token) { + router.push('/login'); + return; + } + + fetchPlan(); + }, [planId, router]); + + const fetchPlan = async () => { + try { + setLoading(true); + const token = localStorage.getItem('token'); + const response = await fetch(`/api/admin/plans/${planId}`, { + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error('Erro ao carregar plano'); + } + + const data = await response.json(); + setPlan(data.plan); + setFormData(data.plan); + } catch (err) { + setError(err instanceof Error ? err.message : 'Erro ao carregar plano'); + } finally { + setLoading(false); + } + }; + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value, type } = e.target; + + if (type === 'checkbox') { + setFormData(prev => ({ + ...prev, + [name]: (e.target as HTMLInputElement).checked, + })); + } else if (type === 'number') { + setFormData(prev => ({ + ...prev, + [name]: value === '' ? null : parseFloat(value), + })); + } else { + setFormData(prev => ({ + ...prev, + [name]: value, + })); + } + }; + + const handleSave = async () => { + try { + setSaving(true); + setError(null); + setSuccess(false); + + const token = localStorage.getItem('token'); + + // Parse features e differentiators + const features = (formData.features as any) + .split(',') + .map((f: string) => f.trim()) + .filter((f: string) => f.length > 0); + + const differentiators = (formData.differentiators as any) + .split(',') + .map((d: string) => d.trim()) + .filter((d: string) => d.length > 0); + + const payload = { + ...formData, + features, + differentiators, + }; + + const response = await fetch(`/api/admin/plans/${planId}`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Erro ao atualizar plano'); + } + + setSuccess(true); + setTimeout(() => setSuccess(false), 3000); + } catch (err) { + setError(err instanceof Error ? err.message : 'Erro ao atualizar plano'); + } finally { + setSaving(false); + } + }; + + if (loading) { + return ( +
+
+
+

Carregando plano...

+
+
+ ); + } + + if (!plan) { + return ( +
+

Plano não encontrado

+
+ ); + } + + return ( +
+ {/* Header */} +
+ +
+

Editar Plano

+

{plan.name}

+
+
+ + {/* Success Message */} + {success && ( +
+ +

Plano atualizado com sucesso!

+
+ )} + + {/* Error Message */} + {error && ( +
+

{error}

+
+ )} + + {/* Form Card */} +
+
{ e.preventDefault(); handleSave(); }}> + {/* Row 1: Nome e Slug */} +
+
+ + +
+
+ + +
+
+ + {/* Descrição */} +
+ +