package service import ( "context" "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 } // 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) } // 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 }