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"` } // 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_id = ?", accountID). Count(&count).Error; err != nil { return nil, 0, err } var accountUsers []model.AccountUser if err := r.db.WithContext(ctx). Where("account_id = ?", accountID). Order("id ASC"). Offset(offset).Limit(limit). 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)) auMap := make(map[uint]model.AccountUser) // userID → AccountUser for i, au := range accountUsers { userIDs[i] = au.UserID auMap[au.UserID] = au } // 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 } // Build AgentDetail list details := make([]AgentDetail, 0, len(users)) for _, u := range users { au, ok := auMap[u.ID] if !ok { continue } d := AgentDetail{ User: u, Role: au.Role, Availability: au.Availability, AutoOffline: au.AutoOffline, InvitedBy: au.InvitedBy, AccountUserID: au.ID, } 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, }, 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) (*AgentDetail, error) { // Find or create the user var user model.User err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error if err == gorm.ErrRecordNotFound { // Create new user 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 } } 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, Availability: availability, AutoOffline: autoOffline, InvitedBy: inviterID, } if err := r.db.WithContext(ctx).Create(&au).Error; err != nil { return nil, err } return &AgentDetail{ User: user, Role: au.Role, Availability: au.Availability, InvitedBy: au.InvitedBy, AccountUserID: au.ID, }, 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) (*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 } // Chatwoot always sends auto_offline (even false) via .compact filter // so we always update it when the request includes it 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 { // Delete the AccountUser if err := r.db.WithContext(ctx). Where("account_id = ? AND user_id = ?", accountID, userID). Delete(&model.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) 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 }