Files
gochat/internal/service/account_user_service.go
T

238 lines
8.3 KiB
Go

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)
}