package service import ( "context" "encoding/json" "errors" "fmt" "time" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" pkgvalidator "github.com/gochat/gochat/pkg/validator" ) // AccountService implements business logic for Account operations. // Reference: Chatwoot app/controllers/api/v1/accounts_controller.rb type AccountService struct { repo *repository.AccountRepo } const chatwootMaxLimit = 100000 // NewAccountService creates a new Account service. func NewAccountService(repo *repository.AccountRepo) *AccountService { return &AccountService{repo: repo} } // ListByUser retrieves all accounts accessible by a user. func (s *AccountService) ListByUser(ctx context.Context, userID uint, offset, limit int) ([]model.Account, int64, error) { return s.repo.FindByUser(ctx, userID, offset, limit) } // GetByID retrieves a single account. func (s *AccountService) GetByID(ctx context.Context, id uint) (*model.Account, error) { return s.repo.FindByID(ctx, id) } // GetByUserAndID retrieves an account only when the user belongs to it. func (s *AccountService) GetByUserAndID(ctx context.Context, userID, accountID uint) (*model.Account, error) { return s.repo.FindByUserAndID(ctx, userID, accountID) } // CreateAccountRequest is the DTO for creating an account. type CreateAccountRequest struct { Name string `json:"name,omitempty" validate:"omitempty,min=2"` AccountName string `json:"account_name,omitempty" validate:"omitempty,min=2"` Locale string `json:"locale,omitempty" validate:"omitempty,len=2"` Domain string `json:"domain,omitempty" validate:"omitempty,min=3"` } // Create creates a new account and assigns the creator as administrator. func (s *AccountService) Create(ctx context.Context, userID uint, req CreateAccountRequest) (*model.Account, error) { if req.Name == "" { req.Name = req.AccountName } if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } if req.Name == "" { return nil, errors.New("account_name is required") } account := &model.Account{ Name: req.Name, Locale: req.Locale, Domain: req.Domain, Status: "active", } if err := s.repo.Create(ctx, account); err != nil { applogger.L().Errorf("Failed to create account: %v", err) return nil, err } // Assign creator as administrator if err := s.repo.AddUserToAccount(ctx, account.ID, userID, "administrator"); err != nil { applogger.L().Errorf("Failed to assign creator to account: %v", err) return nil, err } return account, nil } // UpdateAccountRequest is the DTO for updating an account. type UpdateAccountRequest struct { Name string `json:"name,omitempty" validate:"omitempty,min=2"` Locale string `json:"locale,omitempty" validate:"omitempty,len=2"` Domain string `json:"domain,omitempty" validate:"omitempty,min=3"` SupportEmail string `json:"support_email,omitempty"` FeatureFlags string `json:"feature_flags,omitempty"` Status string `json:"status,omitempty" validate:"omitempty,oneof=active inactive"` AutoResolveDuration int `json:"auto_resolve_duration,omitempty" validate:"omitempty,gte=0"` AutoResolveAfter int `json:"auto_resolve_after,omitempty" validate:"omitempty,gte=0"` AutoResolveMessage string `json:"auto_resolve_message,omitempty"` AutoResolveIgnoreWaiting *bool `json:"auto_resolve_ignore_waiting,omitempty"` AudioTranscriptions *bool `json:"audio_transcriptions,omitempty"` AutoResolveLabel string `json:"auto_resolve_label,omitempty"` } // UpdateAccountOnboardingRequest matches Chatwoot's account onboarding payload. // Reference: Api::V1::Accounts::OnboardingsController#update. type UpdateAccountOnboardingRequest struct { Name *string `json:"name,omitempty"` Locale *string `json:"locale,omitempty"` Industry *string `json:"industry,omitempty"` CompanySize *string `json:"company_size,omitempty"` Timezone *string `json:"timezone,omitempty"` ReferralSource *string `json:"referral_source,omitempty"` UserRole *string `json:"user_role,omitempty"` Website *string `json:"website,omitempty"` } // Update modifies an existing account. func (s *AccountService) Update(ctx context.Context, id uint, req UpdateAccountRequest) (*model.Account, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } account, err := s.repo.FindByID(ctx, id) if err != nil { return nil, err } if req.Name != "" { account.Name = req.Name } if req.Locale != "" { account.Locale = req.Locale } if req.Domain != "" { account.Domain = req.Domain } if req.FeatureFlags != "" { account.FeatureFlags = req.FeatureFlags } if req.Status != "" { account.Status = req.Status } if req.AutoResolveAfter > 0 { account.AutoResolveDuration = req.AutoResolveAfter } else if req.AutoResolveDuration > 0 { account.AutoResolveDuration = req.AutoResolveDuration } if err := s.repo.Update(ctx, account); err != nil { return nil, err } return account, nil } // UpdateOnboarding updates account details and onboarding custom attributes. func (s *AccountService) UpdateOnboarding(ctx context.Context, id uint, req UpdateAccountOnboardingRequest) (*model.Account, error) { account, err := s.repo.FindByID(ctx, id) if err != nil { return nil, err } attrs := account.CustomAttributesMap() finalize := attrs["onboarding_step"] == "account_details" || account.OnboardingStep == "account_details" if req.Name != nil { account.Name = *req.Name } if req.Locale != nil { account.Locale = *req.Locale } mergeStringAttribute(attrs, "industry", req.Industry) mergeStringAttribute(attrs, "company_size", req.CompanySize) mergeStringAttribute(attrs, "timezone", req.Timezone) mergeStringAttribute(attrs, "referral_source", req.ReferralSource) mergeStringAttribute(attrs, "user_role", req.UserRole) mergeStringAttribute(attrs, "website", req.Website) if req.Timezone != nil { account.Timezone = *req.Timezone } if finalize { delete(attrs, "onboarding_step") account.OnboardingStep = "" } if err := account.SetCustomAttributesMap(attrs); err != nil { return nil, err } if err := s.repo.Update(ctx, account); err != nil { return nil, err } return account, nil } func mergeStringAttribute(attrs map[string]any, key string, value *string) { if value != nil { attrs[key] = *value } } // Delete soft-deletes an account. func (s *AccountService) Delete(ctx context.Context, id uint) error { if s.repo == nil { return errors.New("account repository is not initialized") } return s.repo.Delete(ctx, id) } // ListUsers retrieves all users belonging to an account. func (s *AccountService) ListUsers(ctx context.Context, accountID uint, offset, limit int) ([]model.User, int64, error) { return s.repo.FindUsersByAccount(ctx, accountID, offset, limit) } // AddUserRequest is the DTO for adding a user to an account. type AddUserRequest struct { UserID uint `json:"user_id" validate:"required"` Role string `json:"role" validate:"required,oneof=agent administrator"` } // AddUser adds a user to an account with a specified role. func (s *AccountService) AddUser(ctx context.Context, accountID uint, req AddUserRequest) error { if err := pkgvalidator.ValidateStruct(req); err != nil { return err } return s.repo.AddUserToAccount(ctx, accountID, req.UserID, req.Role) } // RemoveUser removes a user from an account. func (s *AccountService) RemoveUser(ctx context.Context, accountID, userID uint) error { if accountID == 0 || userID == 0 { return errors.New("account_id and user_id are required") } return s.repo.RemoveUserFromAccount(ctx, accountID, userID) } // UpdateAccountSettingsRequest is the DTO for updating account settings. type UpdateAccountSettingsRequest struct { AutoResolveDuration int `json:"auto_resolve_duration" validate:"gte=0"` Locale string `json:"locale" validate:"omitempty,len=2"` ReportingTimezone string `json:"reporting_timezone" validate:"omitempty"` } // UpdateSettings updates account-level settings. func (s *AccountService) UpdateSettings(ctx context.Context, id uint, req UpdateAccountSettingsRequest) (*model.Account, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } account, err := s.repo.FindByID(ctx, id) if err != nil { return nil, err } account.AutoResolveDuration = req.AutoResolveDuration if req.Locale != "" { account.Locale = req.Locale } if req.ReportingTimezone != "" { if _, err := time.LoadLocation(req.ReportingTimezone); err != nil { return nil, fmt.Errorf("invalid reporting_timezone") } account.ReportingTimezone = req.ReportingTimezone } if err := s.repo.Update(ctx, account); err != nil { return nil, err } return account, nil } // GetAll retrieves all accounts with pagination. // Reference: Chatwoot platform admin listing all accounts. func (s *AccountService) GetAll(ctx context.Context, offset, limit int) ([]model.Account, int64, error) { return s.repo.FindAll(ctx, offset, limit) } // GetAgents retrieves all agents (AccountUser records) for an account with pagination. // Reference: Chatwoot app/controllers/api/v1/accounts/agents_controller.rb#index func (s *AccountService) GetAgents(ctx context.Context, accountID uint, offset, limit int) ([]model.AccountUser, int64, error) { return s.repo.FindAgentsByAccount(ctx, accountID, offset, limit) } // --- Account extension operations (G8) --- // Reference: Chatwoot accounts_controller.rb#update_active_at, #cache_keys // UpdateActiveAt updates the active_at timestamp for a user in an account. func (s *AccountService) UpdateActiveAt(ctx context.Context, accountID, userID uint) error { return s.repo.UpdateActiveAt(ctx, accountID, userID, time.Now()) } // CacheKeys returns cache key identifiers for frontend cache invalidation. // The keys are derived from the account's updatedAt timestamp and user membership. // Reference: Chatwoot accounts_controller.rb#cache_keys func (s *AccountService) CacheKeys(ctx context.Context, accountID, userID uint) (map[string]string, error) { if _, err := s.repo.FindByID(ctx, accountID); err != nil { return nil, err } if _, err := s.repo.FindAccountUserByUserAndAccount(ctx, accountID, userID); err != nil { return nil, err } keys := map[string]string{ "label": "0000000000", "inbox": "0000000000", "team": "0000000000", } return keys, nil } // EnterpriseLimits returns the Chatwoot enterprise account limit payload consumed // by EnterpriseAccountAPI.getLimits in the reused dashboard frontend. func (s *AccountService) EnterpriseLimits(ctx context.Context, accountID, userID uint) (map[string]any, error) { account, err := s.repo.FindByUserAndID(ctx, userID, accountID) if err != nil { return nil, err } agentsConsumed, err := s.repo.CountUsersByAccount(ctx, account.ID) if err != nil { return nil, err } documentsConsumed, err := s.repo.CountCaptainDocumentsByAccount(ctx, account.ID) if err != nil { return nil, err } limits := map[string]any{ "conversation": map[string]any{}, "non_web_inboxes": map[string]any{}, "agents": map[string]any{ "allowed": accountUsageLimit(account.AgentLimit), "consumed": agentsConsumed, }, "captain": captainUsageLimits(account, documentsConsumed), } if accountDefaultCloudPlan(account) { conversationConsumed, err := s.repo.CountConversationsSince(ctx, account.ID, time.Now().AddDate(0, 0, -30)) if err != nil { return nil, err } nonWebConsumed, err := s.repo.CountNonWebInboxes(ctx, account.ID) if err != nil { return nil, err } limits = map[string]any{ "conversation": map[string]any{ "allowed": 500, "consumed": conversationConsumed, }, "non_web_inboxes": map[string]any{ "allowed": 0, "consumed": nonWebConsumed, }, "agents": map[string]any{ "allowed": 2, "consumed": agentsConsumed, }, } } return map[string]any{"id": account.ID, "limits": limits}, nil } // MarkForDeletion mirrors Enterprise::Account#mark_for_deletion by storing the // scheduled deletion timestamp and reason in account custom_attributes. func (s *AccountService) MarkForDeletion(ctx context.Context, accountID, userID uint, reason string) (*model.Account, error) { account, err := s.repo.FindByUserAndID(ctx, userID, accountID) if err != nil { return nil, err } attrs := account.CustomAttributesMap() if reason != "manual_deletion" { reason = "inactivity" } attrs["marked_for_deletion_at"] = time.Now().AddDate(0, 0, 7).Format(time.RFC3339) attrs["marked_for_deletion_reason"] = reason if err := account.SetCustomAttributesMap(attrs); err != nil { return nil, err } if err := s.repo.Update(ctx, account); err != nil { return nil, err } return account, nil } // UnmarkForDeletion removes Chatwoot's scheduled deletion custom attributes. func (s *AccountService) UnmarkForDeletion(ctx context.Context, accountID, userID uint) (*model.Account, error) { account, err := s.repo.FindByUserAndID(ctx, userID, accountID) if err != nil { return nil, err } attrs := account.CustomAttributesMap() delete(attrs, "marked_for_deletion_at") delete(attrs, "marked_for_deletion_reason") if err := account.SetCustomAttributesMap(attrs); err != nil { return nil, err } if err := s.repo.Update(ctx, account); err != nil { return nil, err } return account, nil } // EnsureEnterpriseAccountCustomerCreationFlag tracks the Cloud subscription side // effect that prevents duplicate Stripe customer creation jobs in Chatwoot. func (s *AccountService) EnsureEnterpriseAccountCustomerCreationFlag(ctx context.Context, accountID, userID uint) error { account, err := s.repo.FindByUserAndID(ctx, userID, accountID) if err != nil { return err } attrs := account.CustomAttributesMap() if attrs["stripe_customer_id"] != nil || attrs["is_creating_customer"] != nil { return nil } attrs["is_creating_customer"] = true if err := account.SetCustomAttributesMap(attrs); err != nil { return err } return s.repo.Update(ctx, account) } func accountUsageLimit(limit int) int { if limit > 0 { return limit } return chatwootMaxLimit } func accountJSONMap(raw []byte) map[string]any { out := map[string]any{} if len(raw) > 0 { _ = json.Unmarshal(raw, &out) } return out } func accountDefaultCloudPlan(account *model.Account) bool { attrs := account.CustomAttributesMap() if value, ok := attrs["default_plan"]; ok { if enabled, ok := value.(bool); ok { return enabled } } return false } func captainUsageLimits(account *model.Account, documentsConsumed int64) map[string]any { limits := accountJSONMap(account.Limits) attrs := account.CustomAttributesMap() documentsAllowed := intFromAccountMap(limits, "captain_documents", chatwootMaxLimit) responsesAllowed := intFromAccountMap(limits, "captain_responses", chatwootMaxLimit) responsesConsumed := intFromAccountMap(attrs, "captain_responses_usage", 0) if docsAttr, ok := optionalIntFromAccountMap(attrs, "captain_documents_usage"); ok { documentsConsumed = int64(docsAttr) } return map[string]any{ "documents": captainLimitBlock(documentsAllowed, int(documentsConsumed)), "responses": captainLimitBlock(responsesAllowed, responsesConsumed), } } func captainLimitBlock(total, consumed int) map[string]any { if consumed < 0 { consumed = 0 } available := total - consumed if available < 0 { available = 0 } if available > total { available = total } return map[string]any{"total_count": total, "current_available": available, "consumed": consumed} } func intFromAccountMap(values map[string]any, key string, fallback int) int { if value, ok := optionalIntFromAccountMap(values, key); ok { return value } return fallback } func optionalIntFromAccountMap(values map[string]any, key string) (int, bool) { value, ok := values[key] if !ok || value == nil { return 0, false } switch v := value.(type) { case int: return v, true case int64: return int(v), true case float64: return int(v), true case json.Number: n, err := v.Int64() return int(n), err == nil case string: var n int if _, err := fmt.Sscanf(v, "%d", &n); err == nil { return n, true } } return 0, false }