package services import ( "agent-desk/internal/models" "agent-desk/internal/pkg/constants" "agent-desk/internal/pkg/dto" "agent-desk/internal/pkg/dto/request" "agent-desk/internal/pkg/dto/response" "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/errorsx" "agent-desk/internal/repositories" "errors" "github.com/mlogclub/simple/sqls" "gorm.io/gorm" "slices" "time" ) var errSupportAssignmentIneligible = errors.New("support assignment ineligible") var SupportAvailabilityService = &supportAvailabilityService{} type supportAvailabilityService struct{} type supportEligibility struct { Eligible, Online bool Reason string EffectiveStatus enums.ServiceStatus Calendar supportCalendarEvaluation ActiveCount int } func effectiveSupportStatus(profile models.AgentProfile, now time.Time) enums.ServiceStatus { if profile.ServiceStatus != enums.ServiceStatusVacation { return profile.ServiceStatus } if profile.AbsenceStartAt != nil && profile.AbsenceEndAt != nil && !now.Before(*profile.AbsenceStartAt) && now.Before(*profile.AbsenceEndAt) { return enums.ServiceStatusVacation } if profile.AvailabilityReturnStatus >= enums.ServiceStatusIdle && profile.AvailabilityReturnStatus <= enums.ServiceStatusUnavailable { return profile.AvailabilityReturnStatus } return enums.ServiceStatusUnavailable } // eligibilityReason is the only policy decision for new human assignment. All // automatic/manual/status/readiness callers provide facts to this evaluator. func eligibilityReason(profile models.AgentProfile, user, team bool, online bool, calendarOpen bool, activeCount int, automatic bool, now time.Time) string { switch { case profile.Status != enums.StatusOk: return "profile_disabled" case !user: return "user_disabled" case !team: return "team_disabled" case !calendarOpen: return "outside_hours" case !online: return "offline" } switch effectiveSupportStatus(profile, now) { case enums.ServiceStatusBusy: return "busy" case enums.ServiceStatusBreak: return "break" case enums.ServiceStatusUnavailable: return "unavailable" case enums.ServiceStatusVacation: return "vacation" case enums.ServiceStatusIdle: default: return "unavailable" } if automatic && !profile.AutoAssignEnabled { return "auto_assign_disabled" } if profile.MaxConcurrentCount < 0 || profile.MaxConcurrentCount > 0 && activeCount >= profile.MaxConcurrentCount { return "capacity" } return "eligible" } func (s *supportAvailabilityService) Evaluate(db *gorm.DB, profile models.AgentProfile, automatic bool, now time.Time, excludeConversationID int64) (supportEligibility, error) { ret := supportEligibility{EffectiveStatus: effectiveSupportStatus(profile, now)} user, err := repositories.SupportAvailabilityRepository.User(db, profile.UserID) if err != nil { return ret, err } team, err := repositories.SupportAvailabilityRepository.Team(db, profile.TeamID) if err != nil { return ret, err } calendar, err := SupportCalendarService.Evaluate(db, profile.TeamID, now) if err != nil { return ret, err } ret.Calendar = calendar online, err := s.onlineUsers(db, []int64{profile.UserID}, now) if err != nil { return ret, err } ret.Online = online[profile.UserID] count, err := repositories.SupportAvailabilityRepository.ActiveCount(db, profile.UserID, excludeConversationID) if err != nil { return ret, err } ret.ActiveCount = count userEnabled := user != nil && user.DeletedAt == nil && user.Status == enums.StatusOk && user.UserType == enums.UserTypeEmployee ret.Reason = eligibilityReason(profile, userEnabled, team != nil && team.Status == enums.StatusOk, ret.Online, calendar.Open, count, automatic, now) ret.Eligible = ret.Reason == "eligible" return ret, nil } func (s *supportAvailabilityService) onlineUsers(db *gorm.DB, userIDs []int64, now time.Time) (map[int64]bool, error) { online, err := repositories.SupportAvailabilityRepository.OnlineUsers(db, userIDs, now) if err != nil { return nil, err } // Current account/role/permission state is checked on every use, not only on // lease creation, so revoked privileges cannot linger for the lease lifetime. for userID := range online { user, err := repositories.SupportAvailabilityRepository.User(db, userID) if err != nil { return nil, err } if user == nil || user.Status != enums.StatusOk || user.DeletedAt != nil || user.UserType != enums.UserTypeEmployee { delete(online, userID) continue } _, permissions, err := AuthService.loadUserAuthScope(db, userID) if err != nil { return nil, err } if !slices.Contains(permissions, constants.PermissionConversationView.Code) { delete(online, userID) } } return online, nil } func (s *supportAvailabilityService) ForUser(userID int64, automatic bool) (*response.SupportAvailabilityResponse, error) { profile, err := repositories.SupportAvailabilityRepository.ProfileByUser(sqls.DB(), userID) if err != nil { return nil, supportAvailabilityPersistenceError(err) } if profile == nil { return nil, nil } return s.build(sqls.DB(), *profile, automatic, time.Now(), 0) } func (s *supportAvailabilityService) build(db *gorm.DB, profile models.AgentProfile, automatic bool, now time.Time, excludeConversationID int64) (*response.SupportAvailabilityResponse, error) { eligibility, err := s.Evaluate(db, profile, automatic, now, excludeConversationID) if err != nil { return nil, supportAvailabilityPersistenceError(err) } calendar, err := SupportCalendarService.get(db, profile.TeamID) if err != nil { return nil, supportAvailabilityPersistenceError(err) } return &response.SupportAvailabilityResponse{UserID: profile.UserID, ProfileID: profile.ID, TeamID: profile.TeamID, DisplayName: profile.DisplayName, ServiceStatus: profile.ServiceStatus, ReturnServiceStatus: profile.AvailabilityReturnStatus, EffectiveServiceStatus: eligibility.EffectiveStatus, AbsenceStartAt: supportTimeString(profile.AbsenceStartAt), AbsenceEndAt: supportTimeString(profile.AbsenceEndAt), Online: eligibility.Online, Eligible: eligibility.Eligible, Reason: eligibility.Reason, Calendar: *calendar, CalendarOpen: eligibility.Calendar.Open, NextOpeningAt: supportTimeString(eligibility.Calendar.NextOpeningAt)}, nil } func (s *supportAvailabilityService) List(conversationID int64, operator *dto.AuthPrincipal) ([]response.SupportAvailabilityResponse, error) { var teamIDs []int64 if conversationID > 0 { conversation := ConversationService.Get(conversationID) if conversation == nil { return nil, errorsx.InvalidParamI18n("error.e0116") } if !slices.Contains(operator.Permissions, constants.PermissionConversationView.Code) { return nil, errorsx.ForbiddenI18n("error.e0225") } if conversation.Status == enums.IMConversationStatusActive && !ConversationService.canTransferConversation(conversation, operator) { return nil, errorsx.ForbiddenI18n("error.e0223") } ai := AIAgentService.Get(conversation.AIAgentID) if ai == nil || ai.Status != enums.StatusOk { teamIDs = []int64{} } else { teamIDs = resolveDispatchTeamIDs(ai.TeamIDs) } } profiles, err := repositories.SupportAvailabilityRepository.Profiles(sqls.DB(), teamIDs) if err != nil { return nil, supportAvailabilityPersistenceError(err) } result := make([]response.SupportAvailabilityResponse, 0, len(profiles)) now := time.Now() for _, profile := range profiles { item, err := s.build(sqls.DB(), profile, false, now, conversationID) if err != nil { return nil, err } result = append(result, *item) } return result, nil } func validateSupportAbsence(req request.SupportAvailabilityRequest) (*time.Time, *time.Time, error) { if !enums.IsValidServiceStatus(req.ServiceStatus) { return nil, nil, errorsx.InvalidParamI18n("error.supportAvailability.invalidStatus") } if req.ServiceStatus != enums.ServiceStatusVacation { return nil, nil, nil } start, err1 := time.Parse(time.RFC3339, req.AbsenceStartAt) end, err2 := time.Parse(time.RFC3339, req.AbsenceEndAt) if err1 != nil || err2 != nil || !end.After(start) { return nil, nil, errorsx.InvalidParamI18n("error.supportAvailability.invalidAbsence") } return &start, &end, nil } func (s *supportAvailabilityService) Update(userID int64, req request.SupportAvailabilityRequest, operator *dto.AuthPrincipal) (*response.SupportAvailabilityResponse, error) { if operator == nil { return nil, errorsx.UnauthorizedI18n("error.auth.expired") } start, end, err := validateSupportAbsence(req) if err != nil { return nil, err } err = sqls.WithTransaction(func(ctx *sqls.TxContext) error { profile, err := repositories.SupportAvailabilityRepository.LockProfile(ctx.Tx, userID) if err != nil { return errorsx.InvalidParamI18n("error.supportAvailability.noProfile") } user, err := repositories.SupportAvailabilityRepository.User(ctx.Tx, userID) if err != nil { return supportAvailabilityPersistenceError(err) } if profile.Status != enums.StatusOk || user == nil || user.Status != enums.StatusOk || user.DeletedAt != nil || user.UserType != enums.UserTypeEmployee { return errorsx.InvalidParamI18n("error.supportAvailability.noProfile") } returnStatus := profile.AvailabilityReturnStatus if req.ServiceStatus == enums.ServiceStatusVacation && profile.ServiceStatus != enums.ServiceStatusVacation { returnStatus = profile.ServiceStatus } if req.ServiceStatus != enums.ServiceStatusVacation { returnStatus = req.ServiceStatus } return supportAvailabilityPersistenceError(repositories.AgentProfileRepository.Updates(ctx.Tx, profile.ID, map[string]any{"service_status": req.ServiceStatus, "availability_return_status": returnStatus, "absence_start_at": start, "absence_end_at": end, "last_status_at": time.Now(), "update_user_id": operator.UserID, "update_user_name": operator.Username, "updated_at": time.Now()})) }) if err != nil { return nil, err } _, _ = ConversationDispatchService.DispatchPendingConversations(0) return s.ForUser(userID, true) } func (s *supportAvailabilityService) GuardAssignment(db *gorm.DB, userID int64, automatic bool, conversation *models.Conversation, now time.Time) (*models.AgentProfile, error) { profile, err := repositories.SupportAvailabilityRepository.LockProfile(db, userID) if err != nil { return nil, errSupportAssignmentIneligible } ai := repositories.AIAgentRepository.Get(db, conversation.AIAgentID) if ai == nil || ai.Status != enums.StatusOk { return nil, errSupportAssignmentIneligible } // Empty explicit team IDs mean all enabled teams. The shared evaluator // checks this profile team using the transaction DB, not sqls.DB(). teamIDs := orderedPositiveIDs(ai.TeamIDs) if len(teamIDs) > 0 && !slices.Contains(teamIDs, profile.TeamID) { return nil, errSupportAssignmentIneligible } eligibility, err := s.Evaluate(db, *profile, automatic, now, conversation.ID) if err != nil { return nil, supportAvailabilityPersistenceError(err) } if !eligibility.Eligible { return nil, errSupportAssignmentIneligible } return profile, nil } func (s *supportAvailabilityService) PublicStatus(channel *models.Channel) (*response.PublicSupportStatusResponse, error) { result := &response.PublicSupportStatusResponse{Reason: "temporarily_absent", Timezone: "UTC"} if channel == nil || channel.Status != enums.StatusOk { return result, nil } ai := AIAgentService.Get(channel.AIAgentID) if ai == nil || ai.Status != enums.StatusOk { return result, nil } now := time.Now() teams := resolveDispatchTeamIDs(ai.TeamIDs) anyOpen := false enabledTeamCount := 0 atCapacity := false var next *time.Time nextTimezone := "" for _, id := range teams { team, err := repositories.SupportAvailabilityRepository.Team(sqls.DB(), id) if err != nil { return nil, supportAvailabilityPersistenceError(err) } if team == nil || team.Status != enums.StatusOk { continue } enabledTeamCount++ calendar, err := SupportCalendarService.Evaluate(sqls.DB(), id, now) if err != nil { return nil, supportAvailabilityPersistenceError(err) } result.Timezone = calendar.Timezone anyOpen = anyOpen || calendar.Open if calendar.NextOpeningAt != nil && (next == nil || calendar.NextOpeningAt.Before(*next)) { next = calendar.NextOpeningAt nextTimezone = calendar.Timezone } } profiles, err := repositories.SupportAvailabilityRepository.Profiles(sqls.DB(), teams) if err != nil { return nil, supportAvailabilityPersistenceError(err) } for _, profile := range profiles { eligibility, err := s.Evaluate(sqls.DB(), profile, true, now, 0) if err != nil { return nil, supportAvailabilityPersistenceError(err) } if eligibility.Eligible { result.Available = true result.Reason = "available" result.Timezone = eligibility.Calendar.Timezone return result, nil } atCapacity = atCapacity || eligibility.Reason == "capacity" } if enabledTeamCount > 0 && !anyOpen { result.Reason = "outside_hours" result.NextOpeningAt = supportTimeString(next) if nextTimezone != "" { result.Timezone = nextTimezone } } else if atCapacity { result.Reason = "capacity" } return result, nil }