Source-only publication of reviewed commit 64e96a7f423673dc29ad56485be9fc79bce8af1c.
Passed in isolation: focused frontend/backend tests, race checks, TypeScript typecheck, lint, Go compilation, Flowgram build, and deterministic SDK build/tests.
Not accepted as a production release: Next.js export/native assembly remains blocked by environment memory limits; browser interaction and MySQL staging acceptance remain unverified. No production server, database/schema, runtime configuration, deployment, release tag, or workflow execution was performed.
Expected source tree: 0a4a7528e9.
330 lines
13 KiB
Go
330 lines
13 KiB
Go
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
|
|
}
|