Files
agent-desk-ng/internal/services/support_availability_service.go
mono 2f7a491666 feat: harden support, localize knowledge and add operator availability [skip ci]
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.
2026-10-16 15:21:42 +00:00

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
}