Files
gochat/internal/service/team_service.go
T

275 lines
8.5 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"gorm.io/gorm"
"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"
)
// TeamService implements business logic for Team operations.
// Reference: Chatwoot app/controllers/api/v1/teams_controller.rb
type TeamService struct {
teamRepo *repository.TeamRepo
teamMemberRepo *repository.TeamMemberRepo
db *gorm.DB
}
// NewTeamService creates a new Team service.
func NewTeamService(teamRepo *repository.TeamRepo, teamMemberRepo *repository.TeamMemberRepo, db *gorm.DB) *TeamService {
return &TeamService{teamRepo: teamRepo, teamMemberRepo: teamMemberRepo, db: db}
}
func (s *TeamService) DB() *gorm.DB {
if s == nil {
return nil
}
return s.db
}
// CreateTeamRequest is the DTO for creating a team.
type CreateTeamRequest struct {
Name string `json:"name" validate:"required,min=2"`
Description string `json:"description,omitempty"`
AllowAutoAssign *bool `json:"allow_auto_assign,omitempty"`
AllowAutoAssignment *bool `json:"allow_auto_assignment,omitempty"`
}
// UpdateTeamRequest is the DTO for updating a team.
type UpdateTeamRequest struct {
Name string `json:"name,omitempty" validate:"omitempty,min=2"`
Description string `json:"description,omitempty"`
AllowAutoAssign *bool `json:"allow_auto_assign,omitempty"`
AllowAutoAssignment *bool `json:"allow_auto_assignment,omitempty"`
}
// TeamMemberRequest is the DTO for adding/removing team members.
type TeamMemberRequest struct {
UserIDs []uint `json:"user_ids" validate:"required,min=1"`
}
// List retrieves all teams for an account.
func (s *TeamService) List(ctx context.Context, accountID uint, offset, limit int) ([]model.Team, int64, error) {
return s.teamRepo.ListByAccount(ctx, accountID, offset, limit)
}
// Get retrieves a single team by ID scoped to an account.
func (s *TeamService) Get(ctx context.Context, id, accountID uint) (*model.Team, error) {
team, err := s.teamRepo.FindByIDAndAccount(ctx, id, accountID)
if err != nil {
return nil, fmt.Errorf("team not found: %w", err)
}
return team, nil
}
// Create creates a new team within an account.
func (s *TeamService) Create(ctx context.Context, accountID uint, req CreateTeamRequest) (*model.Team, error) {
if err := pkgvalidator.ValidateStruct(req); err != nil {
return nil, err
}
autoAssign := true // default
if req.AllowAutoAssign != nil {
autoAssign = *req.AllowAutoAssign
} else if req.AllowAutoAssignment != nil {
autoAssign = *req.AllowAutoAssignment
}
team := &model.Team{
AccountID: accountID,
Name: req.Name,
Description: req.Description,
AllowAutoAssignment: autoAssign,
}
if err := s.teamRepo.Create(ctx, team); err != nil {
applogger.L().Errorf("failed to create team: %v", err)
return nil, fmt.Errorf("failed to create team: %w", err)
}
if !autoAssign {
team.AllowAutoAssignment = false
if err := s.db.WithContext(ctx).Model(team).Update("allow_auto_assignment", false).Error; err != nil {
return nil, fmt.Errorf("failed to update team auto assignment: %w", err)
}
}
return team, nil
}
// Update updates an existing team scoped to an account.
func (s *TeamService) Update(ctx context.Context, id, accountID uint, req UpdateTeamRequest) (*model.Team, error) {
if err := pkgvalidator.ValidateStruct(req); err != nil {
return nil, err
}
team, err := s.teamRepo.FindByIDAndAccount(ctx, id, accountID)
if err != nil {
return nil, fmt.Errorf("team not found: %w", err)
}
if req.Name != "" {
team.Name = req.Name
}
// Allow empty description to clear it
team.Description = req.Description
if req.AllowAutoAssign != nil {
team.AllowAutoAssignment = *req.AllowAutoAssign
} else if req.AllowAutoAssignment != nil {
team.AllowAutoAssignment = *req.AllowAutoAssignment
}
if err := s.teamRepo.Update(ctx, team); err != nil {
applogger.L().Errorf("failed to update team: %v", err)
return nil, fmt.Errorf("failed to update team: %w", err)
}
return team, nil
}
// Delete soft-deletes a team scoped to an account.
func (s *TeamService) Delete(ctx context.Context, id, accountID uint) error {
// Remove all members first
if err := s.teamMemberRepo.DeleteByTeam(ctx, id); err != nil {
applogger.L().Warnf("failed to remove team members during delete: %v", err)
}
if err := s.teamRepo.Delete(ctx, id, accountID); err != nil {
return fmt.Errorf("failed to delete team: %w", err)
}
return nil
}
// AddMembers adds users to a team.
func (s *TeamService) AddMembers(ctx context.Context, teamID, accountID uint, userIDs []uint) ([]model.TeamMember, error) {
// Verify team exists and belongs to account
_, err := s.teamRepo.FindByIDAndAccount(ctx, teamID, accountID)
if err != nil {
return nil, fmt.Errorf("team not found: %w", err)
}
members := make([]model.TeamMember, 0, len(userIDs))
for _, uid := range userIDs {
// Skip if already a member
existing, _ := s.teamMemberRepo.FindByTeamAndUser(ctx, teamID, uid)
if existing != nil {
continue
}
members = append(members, model.TeamMember{
TeamID: teamID,
UserID: uid,
AvailabilityStatus: "offline",
})
}
if len(members) == 0 {
return nil, errors.New("all users are already team members")
}
if err := s.teamMemberRepo.CreateBatch(ctx, members); err != nil {
applogger.L().Errorf("failed to add team members: %v", err)
return nil, fmt.Errorf("failed to add team members: %w", err)
}
return members, nil
}
// RemoveMember removes a user from a team.
func (s *TeamService) RemoveMember(ctx context.Context, teamID, userID, accountID uint) error {
// Verify team exists and belongs to account
_, err := s.teamRepo.FindByIDAndAccount(ctx, teamID, accountID)
if err != nil {
return fmt.Errorf("team not found: %w", err)
}
if err := s.teamMemberRepo.Delete(ctx, teamID, userID); err != nil {
return fmt.Errorf("failed to remove team member: %w", err)
}
return nil
}
// ListMembers retrieves all members of a team.
func (s *TeamService) ListMembers(ctx context.Context, teamID, accountID uint) ([]model.TeamMember, error) {
// Verify team exists and belongs to account
_, err := s.teamRepo.FindByIDAndAccount(ctx, teamID, accountID)
if err != nil {
return nil, fmt.Errorf("team not found: %w", err)
}
return s.teamMemberRepo.FindByTeam(ctx, teamID)
}
// UpdateMembers adds/removes members to match the provided user_ids list.
// Reference: Chatwoot team_members#update — calculates add/remove diffs from user_ids
func (s *TeamService) UpdateMembers(ctx context.Context, teamID, accountID uint, userIDs []uint) ([]model.TeamMember, error) {
// Get current member IDs
currentMembers, err := s.teamMemberRepo.FindByTeam(ctx, teamID)
if err != nil {
return nil, err
}
currentIDs := make([]uint, len(currentMembers))
for i, m := range currentMembers {
currentIDs[i] = m.UserID
}
// Calculate IDs to add and remove
toAdd := difference(userIDs, currentIDs)
toRemove := difference(currentIDs, userIDs)
// Validate new user IDs belong to the account
if len(toAdd) > 0 {
// Chatwoot validates: invalid IDs (not in account) → 401
if !s.validateUserIDsBelongToAccount(ctx, accountID, toAdd) {
return nil, fmt.Errorf("unauthorized: invalid user IDs")
}
newMembers := make([]model.TeamMember, len(toAdd))
for i, uid := range toAdd {
newMembers[i] = model.TeamMember{TeamID: teamID, UserID: uid}
}
if err := s.teamMemberRepo.CreateBatch(ctx, newMembers); err != nil {
return nil, err
}
}
// Remove members not in the new list
for _, uid := range toRemove {
s.teamMemberRepo.Delete(ctx, teamID, uid)
}
// Return updated member list
return s.teamMemberRepo.FindByTeam(ctx, teamID)
}
// difference returns elements in a that are not in b.
func difference(a, b []uint) []uint {
bSet := make(map[uint]bool, len(b))
for _, v := range b {
bSet[v] = true
}
var result []uint
for _, v := range a {
if !bSet[v] {
result = append(result, v)
}
}
return result
}
// validateUserIDsBelongToAccount checks that all user IDs belong to the given account.
// Reference: Chatwoot team_members#validate_member_id_params — invalid IDs → 401
func (s *TeamService) validateUserIDsBelongToAccount(ctx context.Context, accountID uint, userIDs []uint) bool {
for _, uid := range userIDs {
var count int64
s.db.WithContext(ctx).Model(&model.AccountUser{}).
Where("account_id = ? AND user_id = ?", accountID, uid).
Count(&count)
if count == 0 {
return false
}
}
return true
}