package service import ( "context" "encoding/json" "errors" "fmt" "gorm.io/gorm" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/pubsub" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" ) // AccountUserService implements business logic for AccountUser operations. // 1:1 Chatwoot: app/models/account_user.rb // Key behaviors: after_create (notify + create_notification_setting), // after_destroy (notify + remove_user_from_account), // after_save (update_presence_in_redis on availability change), // validates user_id uniqueness scoped to account_id type AccountUserService struct { repo *repository.AccountUserRepo notificationRepo *repository.NotificationSettingRepo accountRepo *repository.AccountRepo userRepo *repository.UserRepo eventBus *pubsub.EventBus } // NewAccountUserService creates a new AccountUser service. func NewAccountUserService( repo *repository.AccountUserRepo, notificationRepo *repository.NotificationSettingRepo, accountRepo *repository.AccountRepo, userRepo *repository.UserRepo, eventBus *pubsub.EventBus, ) *AccountUserService { return &AccountUserService{ repo: repo, notificationRepo: notificationRepo, accountRepo: accountRepo, userRepo: userRepo, eventBus: eventBus, } } // CreateAccountUserRequest is the DTO for adding a user to an account. // 1:1 Chatwoot: AccountUsersController#create type CreateAccountUserRequest struct { UserID uint `json:"user_id" validate:"required"` Role string `json:"role" validate:"required,oneof=agent administrator"` InviterID uint `json:"inviter_id,omitempty"` // user who invited } // AddUserToAccount adds a user to an account with a specified role. // 1:1 Chatwoot: after_create_commit — notify_creation + create_notification_setting func (s *AccountUserService) AddUserToAccount(ctx context.Context, accountID uint, req *CreateAccountUserRequest) (*model.AccountUser, error) { // 1:1 Chatwoot: validates user_id uniqueness scoped to account_id existing, err := s.repo.FindByAccountAndUser(ctx, accountID, req.UserID) if err != nil { return nil, err } if existing != nil { return nil, fmt.Errorf("user %d already belongs to account %d", req.UserID, accountID) } // Verify account exists _, err = s.accountRepo.FindByID(ctx, accountID) if err != nil { return nil, fmt.Errorf("account not found: %v", err) } // Verify user exists _, err = s.userRepo.FindByID(ctx, req.UserID) if err != nil { return nil, fmt.Errorf("user not found: %v", err) } au := &model.AccountUser{ AccountID: accountID, UserID: req.UserID, Role: req.Role, InvitedBy: req.InviterID, Availability: "offline", // 1:1 Chatwoot: default availability AutoOffline: true, // 1:1 Chatwoot: default auto_offline } if err := s.repo.Create(ctx, au); err != nil { applogger.L().Errorf("Failed to add user %d to account %d: %v", req.UserID, accountID, err) return nil, err } // 1:1 Chatwoot: after_create_commit — create_notification_setting // Default notification settings: email_conversation_assignment + push_conversation_assignment if err := s.createDefaultNotificationSetting(ctx, accountID, req.UserID); err != nil { applogger.L().Errorf("Failed to create notification setting for user %d in account %d: %v", req.UserID, accountID, err) // Non-blocking: don't fail the whole operation } // 1:1 Chatwoot: after_create_commit — publish event if s.eventBus != nil { payload, _ := json.Marshal(map[string]interface{}{ "account_id": accountID, "user_id": req.UserID, "role": req.Role, "inviter_id": req.InviterID, }) if err := s.eventBus.Publish(pubsub.TopicAccountUserCreated, payload); err != nil { applogger.L().Errorf("Failed to publish AccountUserCreated event: %v", err) } } return au, nil } // RemoveUserFromAccount removes a user from an account. // 1:1 Chatwoot: after_destroy — notify_deletion + remove_user_from_account func (s *AccountUserService) RemoveUserFromAccount(ctx context.Context, accountID, userID uint) error { au, err := s.repo.FindByAccountAndUserOrFail(ctx, accountID, userID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fmt.Errorf("user %d not found in account %d", userID, accountID) } return err } if err := s.repo.Delete(ctx, au.ID); err != nil { return err } // 1:1 Chatwoot: after_destroy — publish event if s.eventBus != nil { payload, _ := json.Marshal(map[string]interface{}{ "account_id": accountID, "user_id": userID, }) if err := s.eventBus.Publish(pubsub.TopicAccountUserDestroyed, payload); err != nil { applogger.L().Errorf("Failed to publish AccountUserDestroyed event: %v", err) } } return nil } // UpdateAvailability updates a user's availability status in an account. // 1:1 Chatwoot: after_save — update_presence_in_redis on availability change func (s *AccountUserService) UpdateAvailability(ctx context.Context, accountID, userID uint, availability string) error { if availability != "online" && availability != "offline" && availability != "busy" { return fmt.Errorf("invalid availability: %s (must be online/offline/busy)", availability) } au, err := s.repo.FindByAccountAndUserOrFail(ctx, accountID, userID) if err != nil { return err } oldAvailability := au.Availability if err := s.repo.UpdateAvailability(ctx, accountID, userID, availability); err != nil { return err } // 1:1 Chatwoot: after_save — update_presence_in_redis if saved_change_to_availability if oldAvailability != availability { applogger.L().Infof("AccountUser availability changed: user %d in account %d from %s to %s", userID, accountID, oldAvailability, availability) // Redis presence update will be handled by event bus in S10 } return nil } // UpdateRole changes a user's role in an account. func (s *AccountUserService) UpdateRole(ctx context.Context, accountID, userID uint, role string) error { if role != "agent" && role != "administrator" { return fmt.Errorf("invalid role: %s (must be agent/administrator)", role) } au, err := s.repo.FindByAccountAndUserOrFail(ctx, accountID, userID) if err != nil { return err } return s.repo.UpdateRole(ctx, au.ID, role) } // ListByAccount retrieves all account users with pagination. func (s *AccountUserService) ListByAccount(ctx context.Context, accountID uint, offset, limit int) ([]model.AccountUser, int64, error) { return s.repo.FindByAccount(ctx, accountID, offset, limit) } // GetByAccountAndUser retrieves a specific account user membership. func (s *AccountUserService) GetByAccountAndUser(ctx context.Context, accountID, userID uint) (*model.AccountUser, error) { au, err := s.repo.FindByAccountAndUser(ctx, accountID, userID) if err != nil { return nil, err } if au == nil { return nil, fmt.Errorf("user %d not found in account %d", userID, accountID) } return au, nil } // createDefaultNotificationSetting creates the default notification setting // for a newly added account user. // 1:1 Chatwoot: AccountUser#create_notification_setting func (s *AccountUserService) createDefaultNotificationSetting(ctx context.Context, accountID, userID uint) error { if s.notificationRepo == nil { return nil // repo not available, skip } // 1:1 Chatwoot: email_conversation_assignment = bit 1 → 2 // push_conversation_assignment = bit 1 → 2 setting := &model.NotificationSetting{ UserID: userID, AccountID: accountID, EmailFlags: 2, // bit 1 = email_conversation_assignment PushFlags: 2, // bit 1 = push_conversation_assignment } _, err := s.notificationRepo.Create(setting) return err } // MarkActive updates active_at timestamp for a user in an account. func (s *AccountUserService) MarkActive(ctx context.Context, accountID, userID uint) error { return s.repo.UpdateActiveAt(ctx, accountID, userID) } // SetAutoOffline updates the auto_offline setting. func (s *AccountUserService) SetAutoOffline(ctx context.Context, accountID, userID uint, autoOffline bool) error { return s.repo.UpdateAutoOffline(ctx, accountID, userID, autoOffline) } // FindOnlineAgents returns all online agents for an account. func (s *AccountUserService) FindOnlineAgents(ctx context.Context, accountID uint) ([]model.AccountUser, error) { return s.repo.FindOnlineAgentsByAccount(ctx, accountID) }