package repository import ( "context" "errors" "gorm.io/gorm" "github.com/gochat/gochat/internal/model" ) // ErrAlreadyMember indicates the user is already a member of the account. var ErrAlreadyMember = errors.New("user is already a member of this account") // AgentRepo implements GORM repository for Agent (AccountUser + User) operations. // Reference: Chatwoot app/controllers/api/v1/accounts/agents_controller.rb // In Chatwoot, an "agent" is a User who belongs to an Account via AccountUser. // The agents controller returns User objects with their AccountUser association. type AgentRepo struct { db *gorm.DB } // NewAgentRepo creates a new Agent repository. func NewAgentRepo(db *gorm.DB) *AgentRepo { return &AgentRepo{db: db} } // AgentDetail represents an agent as returned by the API — User with their // AccountUser role/availability for the specific account. // Reference: Chatwoot renders User objects with included account_users. type AgentDetail struct { model.User Role string `json:"role"` Availability string `json:"availability"` AutoOffline bool `json:"auto_offline"` InvitedBy uint `json:"invited_by"` AccountUserID uint `json:"account_user_id"` CustomRoleID uint `json:"custom_role_id,omitempty"` IsNewUser bool `json:"-"` } // ListByAccount retrieves all agents (users) for an account with pagination. // Reference: Chatwoot agents_controller.rb#index → Current.account.users // Returns User objects joined with their AccountUser association for the account. func (r *AgentRepo) ListByAccount(ctx context.Context, accountID uint, offset, limit int) ([]AgentDetail, int64, error) { var count int64 if err := r.db.WithContext(ctx). Model(&model.AccountUser{}). Where("account_users.account_id = ?", accountID). Count(&count).Error; err != nil { return nil, 0, err } query := r.db.WithContext(ctx). Model(&model.AccountUser{}). Joins("JOIN users ON users.id = account_users.user_id"). Where("account_users.account_id = ?", accountID). Order("lower(users.name) ASC"). Order("users.id ASC") if offset > 0 { query = query.Offset(offset) } if limit > 0 { query = query.Limit(limit) } var accountUsers []model.AccountUser if err := query.Find(&accountUsers).Error; err != nil { return nil, 0, err } if len(accountUsers) == 0 { return []AgentDetail{}, count, nil } // Collect user IDs userIDs := make([]uint, len(accountUsers)) for i, au := range accountUsers { userIDs[i] = au.UserID } // Fetch users var users []model.User if err := r.db.WithContext(ctx). Where("id IN ?", userIDs). Find(&users).Error; err != nil { return nil, 0, err } userMap := make(map[uint]model.User, len(users)) for _, u := range users { userMap[u.ID] = u } // Build AgentDetail list in Chatwoot's order_by_full_name order. details := make([]AgentDetail, 0, len(accountUsers)) for _, au := range accountUsers { u, ok := userMap[au.UserID] if !ok { continue } d := AgentDetail{ User: u, Role: au.Role, Availability: au.Availability, AutoOffline: au.AutoOffline, InvitedBy: au.InvitedBy, AccountUserID: au.ID, CustomRoleID: au.CustomRoleID, } details = append(details, d) } return details, count, nil } // FindAgentByID retrieves a single agent (user) by ID scoped to an account. // Reference: Chatwoot agents_controller.rb#fetch_agent → agents.find(params[:id]) func (r *AgentRepo) FindAgentByID(ctx context.Context, userID, accountID uint) (*AgentDetail, error) { var au model.AccountUser if err := r.db.WithContext(ctx). Where("account_id = ? AND user_id = ?", accountID, userID). First(&au).Error; err != nil { return nil, err } var user model.User if err := r.db.WithContext(ctx).First(&user, userID).Error; err != nil { return nil, err } return &AgentDetail{ User: user, Role: au.Role, Availability: au.Availability, AutoOffline: au.AutoOffline, InvitedBy: au.InvitedBy, AccountUserID: au.ID, CustomRoleID: au.CustomRoleID, }, nil } // CreateAgent adds a user to an account (creates AccountUser). // Reference: Chatwoot agents_controller.rb#create → AgentBuilder.new.perform // If the user does not exist, creates the user first, then creates the AccountUser. func (r *AgentRepo) CreateAgent(ctx context.Context, accountID uint, inviterID uint, name, email, role, availability string, autoOffline bool, customRoleID uint) (*AgentDetail, error) { // Find or create the user var user model.User isNewUser := false err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error if err == gorm.ErrRecordNotFound { // Create new user if name == "" { name = email if atIdx := indexOfAt(email); atIdx > 0 { name = email[:atIdx] } } user = model.User{ Name: name, Email: email, Provider: "email", Active: true, } if err := r.db.WithContext(ctx).Create(&user).Error; err != nil { return nil, err } isNewUser = true } else if err != nil { return nil, err } // Check if AccountUser already exists var existingAU model.AccountUser err = r.db.WithContext(ctx). Where("account_id = ? AND user_id = ?", accountID, user.ID). First(&existingAU).Error if err == nil { // Already a member — return conflict error return nil, ErrAlreadyMember } else if err != gorm.ErrRecordNotFound { return nil, err } // Create AccountUser au := model.AccountUser{ UserID: user.ID, AccountID: accountID, Role: role, CustomRoleID: customRoleID, Availability: availability, AutoOffline: autoOffline, InvitedBy: inviterID, } if err := r.db.WithContext(ctx).Select("UserID", "AccountID", "Role", "CustomRoleID", "Availability", "AutoOffline", "InvitedBy").Create(&au).Error; err != nil { return nil, err } return &AgentDetail{ User: user, Role: au.Role, Availability: au.Availability, AutoOffline: au.AutoOffline, InvitedBy: au.InvitedBy, AccountUserID: au.ID, CustomRoleID: au.CustomRoleID, IsNewUser: isNewUser, }, nil } // UpdateAgent updates both the User (name) and AccountUser (role, availability, auto_offline). // Reference: Chatwoot agents_controller.rb#update → agent.update!(name) + current_account_user.update!(role, availability, auto_offline) func (r *AgentRepo) UpdateAgent(ctx context.Context, userID, accountID uint, name, role, availability string, autoOffline bool, autoOfflineSet bool, customRoleID *uint, customRoleIDSet bool) (*AgentDetail, error) { // Update user name if provided if name != "" { if err := r.db.WithContext(ctx). Model(&model.User{}). Where("id = ?", userID). Update("name", name).Error; err != nil { return nil, err } } // Update AccountUser attributes updates := map[string]interface{}{} if role != "" { updates["role"] = role } if availability != "" { updates["availability"] = availability } if customRoleIDSet { if customRoleID == nil { updates["custom_role_id"] = 0 } else { updates["custom_role_id"] = *customRoleID } } if autoOfflineSet { updates["auto_offline"] = autoOffline } if len(updates) > 0 { if err := r.db.WithContext(ctx). Model(&model.AccountUser{}). Where("account_id = ? AND user_id = ?", accountID, userID). Updates(updates).Error; err != nil { return nil, err } } return r.FindAgentByID(ctx, userID, accountID) } // DeleteAgent removes the AccountUser association (removes agent from account). // Reference: Chatwoot agents_controller.rb#destroy → current_account_user.destroy! // If the user has no other account memberships, deletes the user record too. func (r *AgentRepo) DeleteAgent(ctx context.Context, userID, accountID uint) error { var accountUser model.AccountUser if err := r.db.WithContext(ctx). Where("account_id = ? AND user_id = ?", accountID, userID). First(&accountUser).Error; err != nil { return err } // Delete the AccountUser after the scoped lookup succeeds. if err := r.db.WithContext(ctx).Delete(&accountUser).Error; err != nil { return err } // Check if user still has other account memberships var remainingCount int64 r.db.WithContext(ctx). Model(&model.AccountUser{}). Where("user_id = ?", userID). Count(&remainingCount) // If no remaining memberships, delete user record // Reference: Chatwoot agents_controller.rb#delete_user_record if remainingCount == 0 { if err := r.db.WithContext(ctx).Delete(&model.User{}, userID).Error; err != nil { return err } } return nil } // BulkCreateAgents adds multiple agents to an account by email. // Reference: Chatwoot agents_controller.rb#bulk_create // For each email: find or create user, then create AccountUser. // Silently skips emails that fail (duplicate, invalid, etc.) — matches Chatwoot behavior. func (r *AgentRepo) BulkCreateAgents(ctx context.Context, accountID uint, inviterID uint, emails []string) ([]AgentDetail, error) { results := make([]AgentDetail, 0, len(emails)) for _, email := range emails { name := email // Default name to email; Chatwoot uses email.split('@').first atIdx := indexOfAt(email) if atIdx > 0 { name = email[:atIdx] } detail, err := r.CreateAgent(ctx, accountID, inviterID, name, email, "agent", "offline", false, 0) if err != nil { // Silently skip — Chatwoot rescues ActiveRecord::RecordInvalid and logs continue } results = append(results, *detail) } return results, nil } // indexOfAt returns the index of '@' in the email string. func indexOfAt(email string) int { for i, c := range email { if c == '@' { return i } } return -1 } // CountByAccount returns the number of agents in an account. func (r *AgentRepo) CountByAccount(ctx context.Context, accountID uint) (int64, error) { var count int64 err := r.db.WithContext(ctx). Model(&model.AccountUser{}). Where("account_id = ?", accountID). Count(&count).Error return count, err }