Files
gochat/backend/internal/handler/api/v1/account_handler.go
T

535 lines
17 KiB
Go

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": account.AudioTranscriptions,
"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
}