From 0ea77fec04db039b1b09a490ac18ad63ab6d230a Mon Sep 17 00:00:00 2001 From: Aziel Date: Sun, 15 Feb 2026 13:02:14 -0500 Subject: [PATCH 01/12] feat(portal): introduce client portal module architecture - access: Define AccessRule entity for routing and permissions. - auth: Implement user authentication system (Domain, Application Service, and infrastructure handlers). - repo: Add GORM repository implementation for auth persistence. - shared: Add JWT security utilities for token generation and validation. --- src/clients_portal/access/domain/rule.go | 45 +++++++ .../auth/application/service.go | 117 ++++++++++++++++++ src/clients_portal/auth/domain/user.go | 68 ++++++++++ .../auth/infrastructure/handlers.go | 103 +++++++++++++++ .../auth/infrastructure/middleware.go | 70 +++++++++++ .../auth/repository/gorm_repo.go | 65 ++++++++++ src/clients_portal/shared/security/jwt.go | 68 ++++++++++ 7 files changed, 536 insertions(+) create mode 100644 src/clients_portal/access/domain/rule.go create mode 100644 src/clients_portal/auth/application/service.go create mode 100644 src/clients_portal/auth/domain/user.go create mode 100644 src/clients_portal/auth/infrastructure/handlers.go create mode 100644 src/clients_portal/auth/infrastructure/middleware.go create mode 100644 src/clients_portal/auth/repository/gorm_repo.go create mode 100644 src/clients_portal/shared/security/jwt.go diff --git a/src/clients_portal/access/domain/rule.go b/src/clients_portal/access/domain/rule.go new file mode 100644 index 0000000..da99e5a --- /dev/null +++ b/src/clients_portal/access/domain/rule.go @@ -0,0 +1,45 @@ +package domain + +import ( + "time" + + "github.com/google/uuid" +) + +// RuleType defines how the rule is evaluated +type RuleType string + +const ( + RuleTypeRouting RuleType = "ROUTING" // Redirect to a specific bot + RuleTypePermission RuleType = "PERMISSION" // Allow/Deny feature access + RuleTypeLimit RuleType = "LIMIT" // Usage thresholds +) + +// AccessRule represents a dynamic configuration for a client's workspace +type AccessRule struct { + ID string `json:"id" gorm:"primaryKey"` + PortalUserID string `json:"portal_user_id" gorm:"index;not null"` // Owner of the rule + ClientID string `json:"client_id" gorm:"index"` // Associated CRM Client (optional) + + Type RuleType `json:"type" gorm:"not null"` + TargetID string `json:"target_id"` // ID of Bot, Channel or Feature + + // Criteria (Condition for the rule to apply) + ConditionKey string `json:"condition_key"` // e.g., "phone_number", "platform" + ConditionValue string `json:"condition_value"` // e.g., "51999888777", "whatsapp" + + Enabled bool `json:"enabled" gorm:"default:true"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func NewAccessRule(portalUserID, clientID string, ruleType RuleType, targetID string) *AccessRule { + return &AccessRule{ + ID: uuid.New().String(), + PortalUserID: portalUserID, + ClientID: clientID, + Type: ruleType, + TargetID: targetID, + Enabled: true, + } +} diff --git a/src/clients_portal/auth/application/service.go b/src/clients_portal/auth/application/service.go new file mode 100644 index 0000000..f62c0d7 --- /dev/null +++ b/src/clients_portal/auth/application/service.go @@ -0,0 +1,117 @@ +package application + +import ( + "context" + "errors" + + crmDomain "github.com/AzielCF/az-wap/clients/domain" + "github.com/AzielCF/az-wap/clients_portal/auth/domain" + portalSecurity "github.com/AzielCF/az-wap/clients_portal/shared/security" +) + +type AuthService struct { + repo domain.IAuthRepository + clientRepo crmDomain.ClientRepository +} + +func NewAuthService(repo domain.IAuthRepository, clientRepo crmDomain.ClientRepository) *AuthService { + return &AuthService{ + repo: repo, + clientRepo: clientRepo, + } +} + +// Login verifies credentials and returns a JWT token +func (s *AuthService) Login(ctx context.Context, username, password string) (string, *domain.PortalUser, error) { + // 1. Find user + user, err := s.repo.GetByUsername(ctx, username) + if err != nil { + return "", nil, errors.New("invalid credentials") // Do not reveal if user exists + } + + // 2. Verify password + if !portalSecurity.CheckPasswordHash(password, user.PasswordHash) { + return "", nil, errors.New("invalid credentials") + } + + // 3. Generate Token + token, err := portalSecurity.GenerateToken(user.ID, user.ClientID, user.Role) + if err != nil { + return "", nil, errors.New("failed to generate token") + } + + // 4. Update last_login (background) + go s.repo.UpdateLastLogin(context.Background(), user.ID) + + return token, user, nil +} + +// Register creates a new portal user +func (s *AuthService) Register(ctx context.Context, clientID, username, password, fullName string, role domain.PortalRole) (*domain.PortalUser, error) { + // 1. Validate duplicates + existing, _ := s.repo.GetByUsername(ctx, username) + if existing != nil { + return nil, errors.New("username already exists") + } + + // 2. Hash password + hash, err := portalSecurity.HashPassword(password) + if err != nil { + return nil, errors.New("failed to hash password") + } + + // 3. Create user + user := domain.NewPortalUser(clientID, username, hash, role) + user.FullName = fullName + + if err := s.repo.Create(ctx, user); err != nil { + return nil, err + } + + return user, nil +} + +// ValidateToken verifies a token and returns the associated user +func (s *AuthService) ValidateToken(ctx context.Context, tokenString string) (*domain.PortalUser, error) { + claims, err := portalSecurity.ValidateToken(tokenString) + if err != nil { + return nil, err + } + + // Optional: Check if user exists/active in DB + user, err := s.repo.GetByID(ctx, claims.UserID) + if err != nil { + return nil, errors.New("user context not found") + } + + if !user.Active { + return nil, errors.New("user account is inactive") + } + + return user, nil +} + +// GetUserProfile returns a combined view of the user and their account information +func (s *AuthService) GetUserProfile(ctx context.Context, userID string) (*domain.PortalProfile, error) { + // 1. Get User + user, err := s.repo.GetByID(ctx, userID) + if err != nil { + return nil, err + } + + // 2. Get associated Client info from CRM + client, err := s.clientRepo.GetByID(ctx, user.ClientID) + if err != nil { + // If client is not found, we still return the user but with empty account info + return &domain.PortalProfile{ + User: user, + Account: nil, + }, nil + } + + // 3. Return combined profile + return &domain.PortalProfile{ + User: user, + Account: client, + }, nil +} diff --git a/src/clients_portal/auth/domain/user.go b/src/clients_portal/auth/domain/user.go new file mode 100644 index 0000000..5b76c04 --- /dev/null +++ b/src/clients_portal/auth/domain/user.go @@ -0,0 +1,68 @@ +package domain + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +// PortalRole defines the access level within the portal +type PortalRole string + +const ( + RoleOwner PortalRole = "OWNER" // Full access (Billing, Configuration) + RoleManager PortalRole = "MANAGER" // Team and channel management + RoleMember PortalRole = "MEMBER" // Read-only / Operative +) + +// PortalUser represents a user who can log in to the Client Portal +type PortalUser struct { + ID string `json:"id" gorm:"primaryKey"` + ClientID string `json:"client_id" gorm:"index"` // Link to CRM (Optional) + Username string `json:"username" gorm:"unique;not null"` // Phone or Email + PasswordHash string `json:"-"` + FullName string `json:"full_name"` + Role PortalRole `json:"role" gorm:"default:'MEMBER'"` + Active bool `json:"active" gorm:"default:true"` + LastLoginAt *time.Time `json:"last_login_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// PortalProfile represents a combined view of the user and their account/client info +type PortalProfile struct { + User *PortalUser `json:"user"` + Account any `json:"account"` // Combined with domain.Client info +} + +// NewPortalUser creates a new instance with a generated ID +func NewPortalUser(clientID, username, passwordHash string, role PortalRole) *PortalUser { + return &PortalUser{ + ID: uuid.New().String(), + ClientID: clientID, + Username: username, + PasswordHash: passwordHash, + Role: role, + Active: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } +} + +// IAuthRepository defines the persistence for authentication +type IAuthRepository interface { + Create(ctx context.Context, user *PortalUser) error + GetByUsername(ctx context.Context, username string) (*PortalUser, error) + GetByID(ctx context.Context, id string) (*PortalUser, error) + UpdateLastLogin(ctx context.Context, id string) error + ListByClient(ctx context.Context, clientID string) ([]*PortalUser, error) +} + +// IAuthService defines the business logic for authentication +type IAuthService interface { + Login(ctx context.Context, username, password string) (string, *PortalUser, error) // Returns Token + User + Register(ctx context.Context, clientID, username, password, fullName string, role PortalRole) (*PortalUser, error) + ValidateToken(ctx context.Context, token string) (*PortalUser, error) + GetUserProfile(ctx context.Context, userID string) (*PortalProfile, error) +} diff --git a/src/clients_portal/auth/infrastructure/handlers.go b/src/clients_portal/auth/infrastructure/handlers.go new file mode 100644 index 0000000..aa3555b --- /dev/null +++ b/src/clients_portal/auth/infrastructure/handlers.go @@ -0,0 +1,103 @@ +package infrastructure + +import ( + "strings" + + "github.com/AzielCF/az-wap/clients_portal/auth/application" + "github.com/AzielCF/az-wap/clients_portal/auth/domain" + "github.com/gofiber/fiber/v2" +) + +type AuthHandler struct { + authService *application.AuthService +} + +func NewAuthHandler(service *application.AuthService) *AuthHandler { + return &AuthHandler{authService: service} +} + +// Request Models +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type RegisterRequest struct { + ClientID string `json:"client_id"` // Typically comes from an admin token or context + Username string `json:"username"` + Password string `json:"password"` + FullName string `json:"full_name"` + Role domain.PortalRole `json:"role"` // Optional, default MEMBER +} + +// Login handles portal user authentication +func (h *AuthHandler) Login(c *fiber.Ctx) error { + var req LoginRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + + token, user, err := h.authService.Login(c.Context(), req.Username, req.Password) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid credentials"}) + } + + return c.JSON(fiber.Map{ + "token": token, + "user": fiber.Map{ + "id": user.ID, + "username": user.Username, + "full_name": user.FullName, + "role": user.Role, + "client_id": user.ClientID, + }, + }) +} + +// Register handles new user registration (ideally protected by Admin/Owner role) +func (h *AuthHandler) Register(c *fiber.Ctx) error { + var req RegisterRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + + // Basic validation + if req.Username == "" || req.Password == "" || req.ClientID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing required fields"}) + } + + // Default role + if req.Role == "" { + req.Role = domain.RoleMember + } + + user, err := h.authService.Register(c.Context(), req.ClientID, req.Username, req.Password, req.FullName, req.Role) + if err != nil { + if strings.Contains(err.Error(), "exists") { + return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "username already exists"}) + } + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.Status(fiber.StatusCreated).JSON(fiber.Map{ + "message": "user created successfully", + "user_id": user.ID, + }) +} + +// Me returns the logged-in user information including account details +func (h *AuthHandler) Me(c *fiber.Ctx) error { + // Get UserID from context (injected by middleware) + userID, ok := c.Locals("portal_user_id").(string) + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"}) + } + + // Get full profile using the service + profile, err := h.authService.GetUserProfile(c.Context(), userID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to fetch profile"}) + } + + return c.JSON(profile) +} diff --git a/src/clients_portal/auth/infrastructure/middleware.go b/src/clients_portal/auth/infrastructure/middleware.go new file mode 100644 index 0000000..a79bcb7 --- /dev/null +++ b/src/clients_portal/auth/infrastructure/middleware.go @@ -0,0 +1,70 @@ +package infrastructure + +import ( + "strings" + + "github.com/AzielCF/az-wap/clients_portal/auth/domain" + portalSecurity "github.com/AzielCF/az-wap/clients_portal/shared/security" + "github.com/gofiber/fiber/v2" +) + +// Config for middleware (future: support BetterAuth remote keys) +type AuthConfig struct { + SecretKey []byte +} + +// NewAuthMiddleware creates the middleware to protect portal routes +func NewAuthMiddleware(userRepo domain.IAuthRepository) fiber.Handler { + return func(c *fiber.Ctx) error { + // 1. Extract token + authHeader := c.Get("Authorization") + if authHeader == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "missing authorization header"}) + } + + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid authorization format"}) + } + + tokenString := parts[1] + + // 2. Validate token (This is where we would switch logic if using BetterAuth) + // In the future, we could validate against an external public key here. + claims, err := portalSecurity.ValidateToken(tokenString) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid or expired token"}) + } + + // 3. (Optional) Check user existence in our local DB + // If using BetterAuth, maybe "sync" the user or trust claims. + // For now, check DB. + // user, err := userRepo.GetByID(c.Context(), claims.UserID) + // if err != nil { + // return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "user not found"}) + // } + + // 4. Inject context for next handlers + c.Locals("portal_user_id", claims.UserID) + c.Locals("portal_client_id", claims.ClientID) + c.Locals("portal_role", claims.Role) + + return c.Next() + } +} + +// RequireRole is an additional middleware for granular permissions +func RequireRole(requiredRole domain.PortalRole) fiber.Handler { + return func(c *fiber.Ctx) error { + role, ok := c.Locals("portal_role").(domain.PortalRole) + if !ok { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "role not found in context"}) + } + + if role != requiredRole && role != domain.RoleOwner { // Owner can always do everything + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "insufficient permissions"}) + } + + return c.Next() + } +} diff --git a/src/clients_portal/auth/repository/gorm_repo.go b/src/clients_portal/auth/repository/gorm_repo.go new file mode 100644 index 0000000..574acd3 --- /dev/null +++ b/src/clients_portal/auth/repository/gorm_repo.go @@ -0,0 +1,65 @@ +package repository + +import ( + "context" + "errors" + "time" + + "github.com/AzielCF/az-wap/clients_portal/auth/domain" + "gorm.io/gorm" +) + +type GormAuthRepository struct { + db *gorm.DB +} + +func NewGormAuthRepository(db *gorm.DB) *GormAuthRepository { + return &GormAuthRepository{db: db} +} + +// AutoMigrate ensures the table exists +func (r *GormAuthRepository) AutoMigrate() error { + return r.db.AutoMigrate(&domain.PortalUser{}) +} + +func (r *GormAuthRepository) Create(ctx context.Context, user *domain.PortalUser) error { + return r.db.WithContext(ctx).Create(user).Error +} + +func (r *GormAuthRepository) GetByUsername(ctx context.Context, username string) (*domain.PortalUser, error) { + var user domain.PortalUser + err := r.db.WithContext(ctx).Where("username = ?", username).First(&user).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("user not found") + } + return &user, err +} + +func (r *GormAuthRepository) GetByID(ctx context.Context, id string) (*domain.PortalUser, error) { + var user domain.PortalUser + err := r.db.WithContext(ctx).Where("id = ?", id).First(&user).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("user not found") + } + return &user, err +} + +func (r *GormAuthRepository) UpdateLastLogin(ctx context.Context, id string) error { + now := time.Now() + // Use Updates for efficiency + return r.db.WithContext(ctx).Model(&domain.PortalUser{}). + Where("id = ?", id). + Updates(map[string]interface{}{ + "last_login_at": now, + "updated_at": now, + }).Error +} + +func (r *GormAuthRepository) ListByClient(ctx context.Context, clientID string) ([]*domain.PortalUser, error) { + var users []*domain.PortalUser + err := r.db.WithContext(ctx). + Where("client_id = ?", clientID). + Order("created_at DESC"). + Find(&users).Error + return users, err +} diff --git a/src/clients_portal/shared/security/jwt.go b/src/clients_portal/shared/security/jwt.go new file mode 100644 index 0000000..447a3d9 --- /dev/null +++ b/src/clients_portal/shared/security/jwt.go @@ -0,0 +1,68 @@ +package security + +import ( + "errors" + "time" + + "github.com/AzielCF/az-wap/clients_portal/auth/domain" + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +// TODO: Move key to .env +var JwtSecretKey = []byte("super-secret-portal-key-change-me") + +type PortalClaims struct { + UserID string `json:"uid"` + ClientID string `json:"cid"` + Role domain.PortalRole `json:"role"` + jwt.RegisteredClaims +} + +// GenerateToken creates a new JWT for the portal user +func GenerateToken(userID, clientID string, role domain.PortalRole) (string, error) { + expirationTime := time.Now().Add(24 * time.Hour) // Token valid for 1 day + + claims := &PortalClaims{ + UserID: userID, + ClientID: clientID, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expirationTime), + IssuedAt: jwt.NewNumericDate(time.Now()), + Issuer: "az-wap-portal", + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(JwtSecretKey) +} + +// ValidateToken parses and validates a JWT token +func ValidateToken(tokenString string) (*PortalClaims, error) { + token, err := jwt.ParseWithClaims(tokenString, &PortalClaims{}, func(token *jwt.Token) (interface{}, error) { + return JwtSecretKey, nil + }) + + if err != nil { + return nil, err + } + + if claims, ok := token.Claims.(*PortalClaims); ok && token.Valid { + return claims, nil + } + + return nil, errors.New("invalid token") +} + +// HashPassword encrypts the password using bcrypt +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(bytes), err +} + +// CheckPasswordHash verifies if the password matches the hash +func CheckPasswordHash(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} From 9c055166c0c49b7164a83faa08efa1d3494ad74d Mon Sep 17 00:00:00 2001 From: Aziel Date: Sun, 15 Feb 2026 13:03:32 -0500 Subject: [PATCH 02/12] feat(rest): initialize client portal authentication routes and middleware. Also add --basic-auth CLI flag support --- src/cmd/rest.go | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/src/cmd/rest.go b/src/cmd/rest.go index 21d3aaf..b522fca 100644 --- a/src/cmd/rest.go +++ b/src/cmd/rest.go @@ -8,7 +8,12 @@ import ( "syscall" "time" + clientsRepo "github.com/AzielCF/az-wap/clients/repository" + portalAuthApp "github.com/AzielCF/az-wap/clients_portal/auth/application" + portalAuthInfra "github.com/AzielCF/az-wap/clients_portal/auth/infrastructure" + portalAuthRepo "github.com/AzielCF/az-wap/clients_portal/auth/repository" coreconfig "github.com/AzielCF/az-wap/core/config" + coreDB "github.com/AzielCF/az-wap/core/database" "github.com/AzielCF/az-wap/ui/rest" "github.com/AzielCF/az-wap/ui/rest/middleware" "github.com/AzielCF/az-wap/ui/websocket" @@ -33,9 +38,15 @@ var restCmd = &cobra.Command{ } func init() { + restCmd.Flags().String("basic-auth", "", "Basic auth for API (format: user:pass,user2:pass2)") rootCmd.AddCommand(restCmd) } -func restServer(_ *cobra.Command, _ []string) { +func restServer(cmd *cobra.Command, _ []string) { + // Override basic auth if flag is provided + if baFlag, _ := cmd.Flags().GetString("basic-auth"); baFlag != "" { + coreconfig.Global.App.BasicAuth = strings.Split(baFlag, ",") + } + fiberConfig := fiber.Config{ EnableTrustedProxyCheck: true, BodyLimit: int(coreconfig.Global.Whatsapp.MaxVideoSize), @@ -152,6 +163,33 @@ func restServer(_ *cobra.Command, _ []string) { rest.InitRestMCP(apiGroup, mcpUsecase) rest.InitRestHealth(apiGroup, healthUsecase) + // --- CLIENTS PORTAL (NEW) --- + // 1. Initialize Portal Auth Components + portalAuthRepository := portalAuthRepo.NewGormAuthRepository(coreDB.GlobalDB) + // Inject existing CRM Client Repository + crmClientRepo := clientsRepo.NewClientGormRepository(coreDB.GlobalDB) + // Auto-migrate portal users table + if err := portalAuthRepository.AutoMigrate(); err != nil { + logrus.Errorf("Failed to migrate portal tables: %v", err) + } + + portalAuthService := portalAuthApp.NewAuthService(portalAuthRepository, crmClientRepo) + portalAuthHandler := portalAuthInfra.NewAuthHandler(portalAuthService) + portalAuthMiddleware := portalAuthInfra.NewAuthMiddleware(portalAuthRepository) + + // 2. Create API Group for Portal (Isolated from Admin) + portalGroup := app.Group(coreconfig.Global.App.BasePath + "/api/portal") + + // Public Portal Routes (Login) + portalGroup.Post("/login", portalAuthHandler.Login) + // TODO: Register should be protected or invitation-only, currently open for initial testing or move to admin + // portalGroup.Post("/register", portalAuthHandler.Register) + + // Protected Portal Routes + portalProtected := portalGroup.Group("/") + portalProtected.Use(portalAuthMiddleware) + portalProtected.Get("/me", portalAuthHandler.Me) // Logged-in user profile + // Unified Monitoring System (Multi-server aware) rest.InitRestMonitoring(apiGroup, monitorStore, workspaceManager, contextCacheStore) From ffacb1f0db20078d7649f185fa0003bf9d46b2e1 Mon Sep 17 00:00:00 2001 From: Aziel Date: Fri, 20 Feb 2026 08:56:16 -0500 Subject: [PATCH 03/12] feat(botengine): add variant specific configurations including tool access and capabilities - Added \AllowedMCPs\, \Description\, and nullable capability flags to \BotVariant\ domain model - Implemented \SanitizeVariants\ in Bot entity to discard unnamed variants and purge empty identifiers - Updated Create and Update usecases to call \SanitizeVariants\ before persisting - Refactored frontend \BotFormModal.vue\ to scope capability toggles and MCP server configurations per variant - Introduced new variant selection and inheritance flows in the UI header --- src/botengine/application/bot.go | 6 + src/botengine/domain/bot/bot.go | 78 +- src/frontend/src/components/AppModal.vue | 9 +- src/frontend/src/components/AppTabModal.vue | 4 + .../src/components/bots/BotFormModal.vue | 878 ++++++++++++++++++ 5 files changed, 964 insertions(+), 11 deletions(-) create mode 100644 src/frontend/src/components/bots/BotFormModal.vue diff --git a/src/botengine/application/bot.go b/src/botengine/application/bot.go index 2923878..ec25cca 100644 --- a/src/botengine/application/bot.go +++ b/src/botengine/application/bot.go @@ -112,8 +112,11 @@ func (s *botService) Create(ctx context.Context, req domainBot.CreateBotRequest) ChatwootCredentialID: strings.TrimSpace(req.ChatwootCredentialID), ChatwootBotToken: strings.TrimSpace(req.ChatwootBotToken), Whitelist: req.Whitelist, + Variants: req.Variants, } + bot.SanitizeVariants() + if err := s.repo.Create(ctx, bot); err != nil { return domainBot.Bot{}, err } @@ -209,6 +212,9 @@ func (s *botService) Update(ctx context.Context, id string, req domainBot.Update updated.ChatwootCredentialID = strings.TrimSpace(req.ChatwootCredentialID) updated.ChatwootBotToken = strings.TrimSpace(req.ChatwootBotToken) updated.Whitelist = req.Whitelist + updated.Variants = req.Variants + + updated.SanitizeVariants() if err := s.repo.Update(ctx, updated); err != nil { return domainBot.Bot{}, err diff --git a/src/botengine/domain/bot/bot.go b/src/botengine/domain/bot/bot.go index 40da8ad..ec33e54 100644 --- a/src/botengine/domain/bot/bot.go +++ b/src/botengine/domain/bot/bot.go @@ -2,6 +2,7 @@ package bot import ( "context" + "strings" domainHealth "github.com/AzielCF/az-wap/domains/health" ) @@ -39,8 +40,23 @@ type Bot struct { ChatwootCredentialID string `json:"chatwoot_credential_id,omitempty"` ChatwootBotToken string `json:"chatwoot_bot_token,omitempty"` // New fields added - ChatwootCredential ChatwootCredential `json:"chatwoot_credential,omitempty"` - Whitelist []string `json:"whitelist,omitempty"` + ChatwootCredential ChatwootCredential `json:"chatwoot_credential,omitempty"` + Whitelist []string `json:"whitelist,omitempty"` + Variants map[string]BotVariant `json:"variants,omitempty"` +} + +type BotVariant struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + SystemPrompt string `json:"system_prompt"` + AllowedTools []string `json:"allowed_tools,omitempty"` + AllowedMCPs []string `json:"allowed_mcps,omitempty"` + + AudioEnabled *bool `json:"audio_enabled,omitempty"` + ImageEnabled *bool `json:"image_enabled,omitempty"` + VideoEnabled *bool `json:"video_enabled,omitempty"` + DocumentEnabled *bool `json:"document_enabled,omitempty"` + MemoryEnabled *bool `json:"memory_enabled,omitempty"` } type ChatwootCredential struct { @@ -67,9 +83,10 @@ type CreateBotRequest struct { MultimodalModel string `json:"multimodal_model"` CredentialID string `json:"credential_id"` // Optional Chatwoot config - ChatwootCredentialID string `json:"chatwoot_credential_id"` - ChatwootBotToken string `json:"chatwoot_bot_token"` - Whitelist []string `json:"whitelist"` + ChatwootCredentialID string `json:"chatwoot_credential_id"` + ChatwootBotToken string `json:"chatwoot_bot_token"` + Whitelist []string `json:"whitelist"` + Variants map[string]BotVariant `json:"variants"` } type UpdateBotRequest struct { @@ -91,9 +108,10 @@ type UpdateBotRequest struct { MultimodalModel string `json:"multimodal_model"` CredentialID string `json:"credential_id"` // Optional Chatwoot config - ChatwootCredentialID string `json:"chatwoot_credential_id"` - ChatwootBotToken string `json:"chatwoot_bot_token"` - Whitelist []string `json:"whitelist"` + ChatwootCredentialID string `json:"chatwoot_credential_id"` + ChatwootBotToken string `json:"chatwoot_bot_token"` + Whitelist []string `json:"whitelist"` + Variants map[string]BotVariant `json:"variants"` } type IBotUsecase interface { @@ -106,3 +124,47 @@ type IBotUsecase interface { SetHealthUsecase(h domainHealth.IHealthUsecase) Shutdown() } + +func (b *Bot) SanitizeVariants() { + if b.Variants == nil { + return + } + + cleaned := make(map[string]BotVariant) + for key, variant := range b.Variants { + name := strings.TrimSpace(variant.Name) + if name == "" { + continue // Ignorar variantes sin nombre + } + + variant.Name = name + variant.Description = strings.TrimSpace(variant.Description) + variant.SystemPrompt = strings.TrimSpace(variant.SystemPrompt) + + // Clean tools string slice + var validTools []string + for _, t := range variant.AllowedTools { + if trimmed := strings.TrimSpace(t); trimmed != "" { + validTools = append(validTools, trimmed) + } + } + variant.AllowedTools = validTools + + // Clean mcps string slice + var validMCPs []string + for _, m := range variant.AllowedMCPs { + if trimmed := strings.TrimSpace(m); trimmed != "" { + validMCPs = append(validMCPs, trimmed) + } + } + variant.AllowedMCPs = validMCPs + + cleaned[key] = variant + } + + if len(cleaned) == 0 { + b.Variants = nil + } else { + b.Variants = cleaned + } +} diff --git a/src/frontend/src/components/AppModal.vue b/src/frontend/src/components/AppModal.vue index b441179..7a65920 100644 --- a/src/frontend/src/components/AppModal.vue +++ b/src/frontend/src/components/AppModal.vue @@ -19,9 +19,12 @@ function close() {