package v1 import ( "errors" "net/http" "strconv" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" applogger "github.com/gochat/gochat/pkg/logger" "github.com/gochat/gochat/pkg/response" ) // AgentHandler handles agent CRUD + bulk_create endpoints. // Reference: Chatwoot app/controllers/api/v1/accounts/agents_controller.rb // An "agent" in Chatwoot is a User with an AccountUser membership in a specific account. type AgentHandler struct { svc *service.AgentService } // NewAgentHandler creates a new AgentHandler. func NewAgentHandler(svc *service.AgentService) *AgentHandler { return &AgentHandler{svc: svc} } // List returns all agents in the account. // GET /api/v1/accounts/:account_id/agents // Reference: Chatwoot agents_controller.rb#index func (h *AgentHandler) List(c *gin.Context) { accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") return } agents, total, err := h.svc.List(c.Request.Context(), accountID, 0, 0) if err != nil { applogger.L().Errorf("List agents for account %d: %v", accountID, err) handleServiceError(c, err) return } _ = total c.JSON(http.StatusOK, serializeAgentDetails(agents, accountID)) } // Get returns a single agent by ID. // GET /api/v1/accounts/:account_id/agents/:id func (h *AgentHandler) Get(c *gin.Context) { accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") return } id, err := strconv.ParseUint(c.Param("agent_id"), 10, 32) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid agent id") return } agent, svcErr := h.svc.Get(c.Request.Context(), uint(id), accountID) if svcErr != nil { applogger.L().Errorf("Get agent %d for account %d: %v", id, accountID, svcErr) handleServiceError(c, svcErr) return } c.JSON(http.StatusOK, serializeAgentDetail(agent, accountID)) } // Create adds an agent to the account. // POST /api/v1/accounts/:account_id/agents // Reference: Chatwoot agents_controller.rb#create → AgentBuilder.new.perform func (h *AgentHandler) Create(c *gin.Context) { accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") return } userID := getUserID(c) if userID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "user not authenticated") return } var req service.CreateAgentRequest if err := bindJSONWrappedOrRaw(c, "agent", &req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } // Chatwoot: validate_limit → can_add_agent? — returns 402 if limit exceeded canAdd, err := h.svc.CanAddAgent(c.Request.Context(), accountID) if err != nil { applogger.L().Errorf("Check agent limit for account %d: %v", accountID, err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, err.Error()) return } if !canAdd { response.AbortWithStatusError(c, http.StatusPaymentRequired, response.ErrPaymentRequired, "Account limit exceeded. Please purchase more licenses") return } agent, svcErr := h.svc.Create(c.Request.Context(), accountID, userID, req) if svcErr != nil { if errors.Is(svcErr, repository.ErrAlreadyMember) { c.JSON(http.StatusUnprocessableEntity, gin.H{ "message": "User has already been taken", "attributes": []string{"user_id"}, }) return } applogger.L().Errorf("Create agent for account %d: %v", accountID, svcErr) handleServiceError(c, svcErr) return } c.JSON(http.StatusOK, serializeAgentDetail(agent, accountID)) } // Update modifies agent details (name on User, role/availability on AccountUser). // PUT /api/v1/accounts/:account_id/agents/:id // Reference: Chatwoot agents_controller.rb#update func (h *AgentHandler) Update(c *gin.Context) { accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") return } id, err := strconv.ParseUint(c.Param("agent_id"), 10, 32) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid agent id") return } var req service.UpdateAgentRequest if err := bindJSONWrappedOrRaw(c, "agent", &req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } agent, svcErr := h.svc.Update(c.Request.Context(), uint(id), accountID, req) if svcErr != nil { if errors.Is(svcErr, service.ErrAgentNameBlank) { c.JSON(http.StatusUnprocessableEntity, gin.H{ "message": "Name can't be blank", "attributes": []string{"name"}, }) return } applogger.L().Errorf("Update agent %d for account %d: %v", id, accountID, svcErr) handleServiceError(c, svcErr) return } c.JSON(http.StatusOK, serializeAgentDetail(agent, accountID)) } // Delete removes an agent from the account. // DELETE /api/v1/accounts/:account_id/agents/:id // Reference: Chatwoot agents_controller.rb#destroy → current_account_user.destroy! func (h *AgentHandler) Delete(c *gin.Context) { accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") return } id, err := strconv.ParseUint(c.Param("agent_id"), 10, 32) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid agent id") return } svcErr := h.svc.Delete(c.Request.Context(), uint(id), accountID) if svcErr != nil { applogger.L().Errorf("Delete agent %d for account %d: %v", id, accountID, svcErr) handleServiceError(c, svcErr) return } c.Status(http.StatusOK) } // BulkCreate adds multiple agents to the account by email. // POST /api/v1/accounts/:account_id/agents/bulk_create // Reference: Chatwoot agents_controller.rb#bulk_create func (h *AgentHandler) BulkCreate(c *gin.Context) { accountID := getAccountID(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") return } userID := getUserID(c) if userID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "user not authenticated") return } var req service.BulkCreateAgentRequest if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } // Chatwoot: validate_limit_for_bulk_create — emails.count <= available_agent_count canAdd, err := h.svc.CanAddAgents(c.Request.Context(), accountID, len(req.Emails)) if err != nil { applogger.L().Errorf("Check agent limit for account %d: %v", accountID, err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, err.Error()) return } if !canAdd { response.AbortWithStatusError(c, http.StatusPaymentRequired, response.ErrPaymentRequired, "Account limit exceeded. Please purchase more licenses") return } _, svcErr := h.svc.BulkCreate(c.Request.Context(), accountID, userID, req) if svcErr != nil { applogger.L().Errorf("BulkCreate agents for account %d: %v", accountID, svcErr) handleServiceError(c, svcErr) return } c.Status(http.StatusOK) } func serializeAgentDetails(agents []repository.AgentDetail, accountID uint) []map[string]any { payload := make([]map[string]any, 0, len(agents)) for i := range agents { payload = append(payload, serializeAgentDetail(&agents[i], accountID)) } return payload } func serializeAgentDetail(agent *repository.AgentDetail, accountID uint) map[string]any { if agent == nil { return map[string]any{} } payload := serializeAgentUser(&agent.User, accountID, agent.Role, agent.Availability, agent.AutoOffline, agent.CustomRoleID) if agent.InvitedBy != 0 { payload["invited_by"] = agent.InvitedBy } if agent.AccountUserID != 0 { payload["account_user_id"] = agent.AccountUserID } return payload } func serializeAgentUser(user *model.User, accountID uint, role string, availability string, autoOffline bool, customRoleID uint) map[string]any { if user == nil { return map[string]any{} } availabilityStatus := nonEmpty(availability, availabilityStatus(user.Available)) payload := map[string]any{ "id": user.ID, "account_id": accountID, "availability_status": availabilityStatus, "auto_offline": autoOffline, "confirmed": user.ConfirmedAt != nil, "email": user.Email, "provider": nonEmpty(user.Provider, "email"), "available_name": nonEmpty(user.DisplayName, user.Name), "name": user.Name, "role": nonEmpty(role, user.Role), "thumbnail": user.AvatarURL, } if attrs := jsonObject(user.CustomAttributes); len(attrs) > 0 { payload["custom_attributes"] = attrs } if customRoleID != 0 { payload["custom_role_id"] = customRoleID } else if user.CustomRoleID != nil && *user.CustomRoleID != 0 { payload["custom_role_id"] = *user.CustomRoleID } return payload }