Files
gochat/backend/internal/service/account_service.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories:
- backend/: Go module root (cmd, internal, pkg, configs, migrations,
  docs/swagger, scripts, tests, go.mod, Makefile, .air.toml)
- deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd
- docs/: project documentation + reports/ (moved from repo root)
- AGENTS.md: new AI coding-agent guide at repo root

Update all references to the new layout:
- Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root)
- docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile,
  env_file ../../.env, volume mounts ../../backend:/app
- deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile
- CI: working-directory: backend for go commands, file deploy/docker/Dockerfile,
  coverage path backend/coverage.out, health_check backend/scripts/
- backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../
- README: architecture tree, quickstart, config paths updated

Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh,
gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to
preserve history. Build, vet, SQLite tests, and docker compose config verified.
2026-07-07 14:44:12 +08:00

503 lines
16 KiB
Go

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
}