package v1 import ( "encoding/json" "net/http" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/service" applogger "github.com/gochat/gochat/pkg/logger" "github.com/gochat/gochat/pkg/pagination" "github.com/gochat/gochat/pkg/response" ) // AccountHandler handles account-related API endpoints. // Reference: Chatwoot app/controllers/api/v1/accounts_controller.rb type AccountHandler struct { svc *service.AccountService } // NewAccountHandler creates a new AccountHandler. func NewAccountHandler(svc *service.AccountService) *AccountHandler { return &AccountHandler{svc: svc} } // @Summary List accounts accessible by the current user // @Description Returns all accounts the authenticated user has access to, with pagination support // @Tags Accounts // @Accept json // @Produce json // @Param page query int false "Page number" default(1) // @Param page_size query int false "Items per page" default(25) // @Success 200 {object} []model.Account // @Failure 401 {object} model.ErrorResponse // @Failure 500 {object} model.ErrorResponse // @Security ApiKeyAuth // @Router /api/v1/accounts [get] func (h *AccountHandler) List(c *gin.Context) { userID := getUserID(c) if userID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "user not authenticated") return } pg := pagination.Parse(c) accounts, total, err := h.svc.ListByUser(c.Request.Context(), userID, pg.Offset, pg.PerPage) if err != nil { applogger.L().Errorf("List accounts: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list accounts") return } response.OKWithMeta(c, accounts, pg.Page, pg.PerPage, total) } // @Summary Get a single account by ID // @Description Retrieves detailed information about a specific account // @Tags Accounts // @Accept json // @Produce json // @Param id path uint true "Account ID" // @Success 200 {object} model.Account // @Failure 400 {object} model.ErrorResponse // @Failure 401 {object} model.ErrorResponse // @Failure 404 {object} model.ErrorResponse // @Failure 500 {object} model.ErrorResponse // @Security ApiKeyAuth // @Router /api/v1/accounts/{id} [get] func (h *AccountHandler) Get(c *gin.Context) { id := parseAccountIDParam(c) if id == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") return } account, err := h.svc.GetByID(c.Request.Context(), id) if err != nil { applogger.L().Errorf("Get account: %v", err) response.AbortWithStatusError(c, http.StatusNotFound, response.ErrAccountNotFound, "account not found") return } c.JSON(http.StatusOK, serializeAccount(account)) } // @Summary Create a new account // @Description Creates a new account and assigns the creator as administrator // @Tags Accounts // @Accept json // @Produce json // @Param body body service.CreateAccountRequest true "Account creation payload" // @Success 201 {object} model.Account // @Failure 400 {object} model.ErrorResponse // @Failure 401 {object} model.ErrorResponse // @Failure 500 {object} model.ErrorResponse // @Security ApiKeyAuth // @Router /api/v1/accounts [post] func (h *AccountHandler) Create(c *gin.Context) { userID := getUserID(c) if userID == 0 { response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "user not authenticated") return } var req service.CreateAccountRequest if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } account, err := h.svc.Create(c.Request.Context(), userID, req) if err != nil { applogger.L().Errorf("Create account: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to create account") return } c.JSON(http.StatusOK, gin.H{"data": gin.H{"id": userID, "account_id": account.ID}}) } // @Summary Update an existing account // @Description Modifies account details such as name and settings // @Tags Accounts // @Accept json // @Produce json // @Param id path uint true "Account ID" // @Param body body service.UpdateAccountRequest true "Account update payload" // @Success 200 {object} model.Account // @Failure 400 {object} model.ErrorResponse // @Failure 401 {object} model.ErrorResponse // @Failure 500 {object} model.ErrorResponse // @Security ApiKeyAuth // @Router /api/v1/accounts/{id} [put] func (h *AccountHandler) Update(c *gin.Context) { id := parseAccountIDParam(c) if id == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") return } var req service.UpdateAccountRequest if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } account, err := h.svc.Update(c.Request.Context(), id, req) if err != nil { applogger.L().Errorf("Update account: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to update account") return } c.JSON(http.StatusOK, serializeAccount(account)) } // UpdateOnboarding updates account details from the dashboard onboarding flow. // PATCH /api/v1/accounts/:account_id/onboarding // Reference: Chatwoot Api::V1::Accounts::OnboardingsController#update. func (h *AccountHandler) UpdateOnboarding(c *gin.Context) { id := parseAccountIDParam(c) if id == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") return } var req service.UpdateAccountOnboardingRequest if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } account, err := h.svc.UpdateOnboarding(c.Request.Context(), id, req) if err != nil { applogger.L().Errorf("Update onboarding account: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to update account onboarding") return } c.JSON(http.StatusOK, serializeAccount(account)) } // @Summary Delete an account // @Description Soft-deletes an account by ID // @Tags Accounts // @Accept json // @Produce json // @Param id path uint true "Account ID" // @Success 204 "No Content" // @Failure 400 {object} model.ErrorResponse // @Failure 401 {object} model.ErrorResponse // @Failure 500 {object} model.ErrorResponse // @Security ApiKeyAuth // @Router /api/v1/accounts/{id} [delete] func (h *AccountHandler) Delete(c *gin.Context) { id := parseAccountIDParam(c) if id == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") return } if err := h.svc.Delete(c.Request.Context(), id); err != nil { applogger.L().Errorf("Delete account: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to delete account") return } response.NoContent(c) } // ListUsers returns all users belonging to an account. // GET /api/v1/accounts/:id/users // Reference: Chatwoot Accounts::AccountUsersController#index func (h *AccountHandler) ListUsers(c *gin.Context) { accountID := parseAccountIDParam(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") return } pg := pagination.Parse(c) users, total, err := h.svc.ListUsers(c.Request.Context(), accountID, pg.Offset, pg.PerPage) if err != nil { applogger.L().Errorf("List account users: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list account users") return } response.OKWithMeta(c, users, pg.Page, pg.PerPage, total) } // AddUser adds a user to an account with a specified role. // POST /api/v1/accounts/:id/users // Reference: Chatwoot Accounts::AccountUsersController#create func (h *AccountHandler) AddUser(c *gin.Context) { accountID := parseAccountIDParam(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") return } var req service.AddUserRequest if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } if err := h.svc.AddUser(c.Request.Context(), accountID, req); err != nil { applogger.L().Errorf("Add user to account: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to add user to account") return } response.NoContent(c) } // RemoveUser removes a user from an account. // DELETE /api/v1/accounts/:id/users/:user_id // Reference: Chatwoot Accounts::AccountUsersController#destroy func (h *AccountHandler) RemoveUser(c *gin.Context) { accountID := parseAccountIDParam(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") return } userID, err := parseUintParam(c, "user_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid user id") return } if err := h.svc.RemoveUser(c.Request.Context(), accountID, userID); err != nil { applogger.L().Errorf("Remove user from account: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to remove user from account") return } response.NoContent(c) } // GetAll lists all accounts (platform admin level, no user-scoping). // GET /api/v1/accounts/all // Reference: Chatwoot platform admin listing all accounts func (h *AccountHandler) GetAll(c *gin.Context) { pg := pagination.Parse(c) accounts, total, err := h.svc.GetAll(c.Request.Context(), pg.Offset, pg.PerPage) if err != nil { applogger.L().Errorf("GetAll accounts: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list all accounts") return } response.OKWithMeta(c, accounts, pg.Page, pg.PerPage, total) } // GetAgents retrieves all agents (AccountUser records) for an account. // GET /api/v1/accounts/:id/agents // Reference: Chatwoot app/controllers/api/v1/accounts/agents_controller.rb#index func (h *AccountHandler) GetAgents(c *gin.Context) { accountID := parseAccountIDParam(c) if accountID == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") return } pg := pagination.Parse(c) agents, total, err := h.svc.GetAgents(c.Request.Context(), accountID, pg.Offset, pg.PerPage) if err != nil { applogger.L().Errorf("GetAgents for account %d: %v", accountID, err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to list agents") return } response.OKWithMeta(c, agents, pg.Page, pg.PerPage, total) } // UpdateSettings updates account-level settings. // PUT /api/v1/accounts/:id/settings // Reference: Chatwoot AccountsController#update (settings subset) func (h *AccountHandler) UpdateSettings(c *gin.Context) { id := parseAccountIDParam(c) if id == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") return } var req service.UpdateAccountSettingsRequest if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } account, err := h.svc.UpdateSettings(c.Request.Context(), id, req) if err != nil { applogger.L().Errorf("Update account settings: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to update account settings") return } c.JSON(http.StatusOK, serializeAccount(account)) } // --- Account extension handlers (G8) --- // Reference: Chatwoot accounts_controller.rb#update_active_at, #cache_keys // UpdateActiveAt updates the active_at timestamp for the current user in an account. // POST /api/v1/accounts/:id/update_active_at func (h *AccountHandler) UpdateActiveAt(c *gin.Context) { id := parseAccountIDParam(c) if id == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") return } userID := getUserID(c) if err := h.svc.UpdateActiveAt(c.Request.Context(), id, userID); err != nil { applogger.L().Errorf("UpdateActiveAt for account %d, user %d: %v", id, userID, err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to update active_at") return } c.Status(http.StatusOK) } // CacheKeys returns cache key identifiers for frontend cache invalidation. // GET /api/v1/accounts/:id/cache_keys func (h *AccountHandler) CacheKeys(c *gin.Context) { id := parseAccountIDParam(c) if id == 0 { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") return } userID := getUserID(c) keys, err := h.svc.CacheKeys(c.Request.Context(), id, userID) if err != nil { applogger.L().Errorf("CacheKeys for account %d, user %d: %v", id, userID, err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to get cache keys") return } c.JSON(http.StatusOK, gin.H{"cache_keys": keys}) } func serializeAccount(account *model.Account) map[string]any { if account == nil { return map[string]any{} } customAttributes := serializeAccountCustomAttributes(account) return map[string]any{ "settings": serializeAccountSettings(account), "created_at": account.CreatedAt, "domain": account.Domain, "features": parseAccountFeatures(account.FeatureFlags), "id": account.ID, "locale": nonEmpty(account.Locale, "en"), "name": account.Name, "support_email": nil, "status": nonEmpty(account.Status, "active"), "cache_keys": map[string]string{"label": "0000000000", "inbox": "0000000000", "team": "0000000000"}, "custom_attributes": customAttributes, } } func serializeAccountCustomAttributes(account *model.Account) map[string]any { attrs := account.CustomAttributesMap() out := map[string]any{ "plan_name": attrs["plan_name"], "subscribed_quantity": attrs["subscribed_quantity"], "subscription_status": attrs["subscription_status"], "subscription_ends_on": attrs["subscription_ends_on"], } copyPresentAttribute(out, attrs, "website") copyPresentAttribute(out, attrs, "industry") copyPresentAttribute(out, attrs, "company_size") copyPresentAttribute(out, attrs, "timezone") copyPresentAttribute(out, attrs, "logo") copyPresentAttribute(out, attrs, "referral_source") copyPresentAttribute(out, attrs, "brand_info") if account.OnboardingStep != "" { out["onboarding_step"] = account.OnboardingStep } else if isPresent(attrs["onboarding_step"]) { out["onboarding_step"] = attrs["onboarding_step"] } copyPresentAttribute(out, attrs, "marked_for_deletion_at") copyPresentAttribute(out, attrs, "marked_for_deletion_reason") if _, ok := out["timezone"]; !ok && account.Timezone != "" { out["timezone"] = account.Timezone } return out } func copyPresentAttribute(out map[string]any, attrs map[string]any, key string) { if value, ok := attrs[key]; ok && isPresent(value) { out[key] = value } } func isPresent(value any) bool { if value == nil { return false } if s, ok := value.(string); ok { return s != "" } return true } func serializeAccountSettings(account *model.Account) map[string]any { settings := map[string]any{ "auto_resolve_after": account.AutoResolveDuration, "auto_resolve_duration": account.AutoResolveDuration, "auto_resolve_message": "", "auto_resolve_ignore_waiting": false, "audio_transcriptions": false, "auto_resolve_label": "", "reporting_timezone": account.ReportingTimezone, } return settings } var chatwootDefaultEnabledAccountFeatures = []string{ "inbound_emails", "channel_email", "channel_facebook", "help_center", "agent_bots", "macros", "agent_management", "team_management", "inbox_management", "labels", "custom_attributes", "automations", "canned_responses", "integrations", "voice_recorder", "channel_website", "campaigns", "reports", "crm", "auto_resolve_conversations", "chatwoot_v4", "contact_chatwoot_support_team", "channel_instagram", "channel_tiktok", "assignment_v2", "captain_tasks", } func parseAccountFeatures(raw string) map[string]bool { features := defaultEnabledAccountFeatures() if raw == "" { return onlyEnabledAccountFeatures(features) } objectFlags := map[string]bool{} if err := json.Unmarshal([]byte(raw), &objectFlags); err == nil { for key, enabled := range objectFlags { features[key] = enabled } return onlyEnabledAccountFeatures(features) } arrayFlags := []string{} if err := json.Unmarshal([]byte(raw), &arrayFlags); err == nil { for _, key := range arrayFlags { features[key] = true } return onlyEnabledAccountFeatures(features) } return onlyEnabledAccountFeatures(features) } func defaultEnabledAccountFeatures() map[string]bool { features := make(map[string]bool, len(chatwootDefaultEnabledAccountFeatures)) for _, key := range chatwootDefaultEnabledAccountFeatures { features[key] = true } return features } func onlyEnabledAccountFeatures(features map[string]bool) map[string]bool { enabled := map[string]bool{} for key, value := range features { if value { enabled[key] = true } } return enabled }