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.
1511 lines
50 KiB
Go
1511 lines
50 KiB
Go
// Modified in AgentDeskNext: authenticated support availability and calendars.
|
|
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
|
|
package services
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"reflect"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"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/pkg/utils"
|
|
|
|
"github.com/mlogclub/simple/sqls"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
var SetupReadinessService = newSetupReadinessService()
|
|
|
|
func newSetupReadinessService() *setupReadinessService {
|
|
return &setupReadinessService{}
|
|
}
|
|
|
|
type setupReadinessService struct{}
|
|
|
|
const (
|
|
QuickFixActionQuickSetupAll = "quick_setup_all"
|
|
QuickFixActionEnsureTeamSchedule = "ensure_team_and_schedule"
|
|
QuickFixActionEnableMyProfile = "enable_my_operator_profile"
|
|
QuickFixActionFixAIAgentHandoff = "fix_ai_agent_handoff"
|
|
QuickFixActionEnsureDefaultChannel = "ensure_default_channel"
|
|
)
|
|
|
|
func (s *setupReadinessService) GetReadiness(operator *dto.AuthPrincipal) (*response.SetupReadinessResponse, error) {
|
|
now := time.Now()
|
|
db := sqls.DB()
|
|
|
|
var teams []models.AgentTeam
|
|
if err := db.Where("status <> ?", enums.StatusDeleted).Order("id ASC").Find(&teams).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
activeTeamMap := make(map[int64]models.AgentTeam)
|
|
activeTeamIDs := make([]int64, 0, len(teams))
|
|
for _, team := range teams {
|
|
if team.Status == enums.StatusOk {
|
|
activeTeamMap[team.ID] = team
|
|
activeTeamIDs = append(activeTeamIDs, team.ID)
|
|
}
|
|
}
|
|
|
|
var schedules []models.AgentTeamSchedule
|
|
if err := db.Where("status <> ?", enums.StatusDeleted).Order("id ASC").Find(&schedules).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
activeScheduleNowTeamSet := make(map[int64]bool)
|
|
anyScheduleTeamSet := make(map[int64]bool)
|
|
for _, sch := range schedules {
|
|
anyScheduleTeamSet[sch.TeamID] = true
|
|
}
|
|
for _, team := range teams {
|
|
calendar, err := SupportCalendarService.get(db, team.ID)
|
|
if err != nil {
|
|
return nil, setupPersistenceError(err)
|
|
}
|
|
if calendar.Mode == "recurring" {
|
|
anyScheduleTeamSet[team.ID] = len(calendar.Weekly) > 0 || len(calendar.Exceptions) > 0
|
|
}
|
|
evaluation, err := SupportCalendarService.evaluateCalendar(db, calendar, now)
|
|
if err != nil {
|
|
return nil, setupPersistenceError(err)
|
|
}
|
|
activeScheduleNowTeamSet[team.ID] = evaluation.Open
|
|
}
|
|
|
|
var profiles []models.AgentProfile
|
|
if err := db.Where("status <> ?", enums.StatusDeleted).Order("id ASC").Find(&profiles).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var users []models.User
|
|
if err := db.Where("id IN ?", extractProfileUserIDs(profiles)).Find(&users).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
activeUserMap := make(map[int64]models.User, len(users))
|
|
for _, u := range users {
|
|
if u.Status == enums.StatusOk && u.DeletedAt == nil {
|
|
activeUserMap[u.ID] = u
|
|
}
|
|
}
|
|
|
|
activeCounts, err := ConversationDispatchService.findActiveConversationCountMap(extractProfileUserIDs(profiles))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var aiAgents []models.AIAgent
|
|
if err := db.Where("status <> ?", enums.StatusDeleted).Order("id ASC").Find(&aiAgents).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var channels []models.Channel
|
|
if err := db.Where("status <> ?", enums.StatusDeleted).Order("id ASC").Find(&channels).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Step 1: Operator Teams
|
|
step1 := s.evaluateTeamsStep(teams, activeTeamIDs)
|
|
|
|
// Step 2: Shift Schedules (Active Now)
|
|
step2 := s.evaluateSchedulesStep(teams, activeTeamMap, activeScheduleNowTeamSet, anyScheduleTeamSet)
|
|
|
|
// Step 3: Operator Profiles
|
|
step3, readyOperatorTeams := s.evaluateOperatorsStep(profiles, activeTeamMap, activeScheduleNowTeamSet, activeUserMap, activeCounts)
|
|
|
|
// Step 4: AI Agents & Handoff Configuration
|
|
step4, readyAIAgentMap := s.evaluateAIAgentsStep(aiAgents, activeTeamMap, activeScheduleNowTeamSet, readyOperatorTeams)
|
|
|
|
// Step 5: Inbound Channels
|
|
step5, channelBriefs := s.evaluateChannelsStep(channels, aiAgents)
|
|
|
|
// Step 6: End-to-End Routing Path
|
|
step6, canReceiveRequestsNow := s.evaluateEndToEndStep(channels, aiAgents, readyAIAgentMap, activeTeamMap, activeScheduleNowTeamSet, readyOperatorTeams)
|
|
|
|
steps := []response.SetupStepItem{step1, step2, step3, step4, step5, step6}
|
|
for i := range steps {
|
|
steps[i] = s.filterStepQuickFixes(steps[i], operator)
|
|
}
|
|
|
|
readySteps := 0
|
|
hasError := false
|
|
hasWarning := false
|
|
for _, st := range steps {
|
|
switch st.Status {
|
|
case "ok":
|
|
readySteps++
|
|
case "error":
|
|
hasError = true
|
|
case "warning":
|
|
hasWarning = true
|
|
}
|
|
}
|
|
|
|
overallStatus := "ok"
|
|
if hasError || !canReceiveRequestsNow {
|
|
if hasError {
|
|
overallStatus = "error"
|
|
} else {
|
|
overallStatus = "warning"
|
|
}
|
|
} else if hasWarning {
|
|
overallStatus = "warning"
|
|
}
|
|
|
|
currentOp := s.evaluateCurrentOperator(operator, profiles, activeTeamMap, activeScheduleNowTeamSet, activeCounts)
|
|
|
|
return &response.SetupReadinessResponse{
|
|
OverallStatus: overallStatus,
|
|
ReadySteps: readySteps,
|
|
TotalSteps: len(steps),
|
|
CanReceiveRequestsNow: canReceiveRequestsNow,
|
|
Steps: steps,
|
|
CurrentOperator: currentOp,
|
|
Channels: channelBriefs,
|
|
}, nil
|
|
}
|
|
|
|
func (s *setupReadinessService) evaluateTeamsStep(teams []models.AgentTeam, activeTeamIDs []int64) response.SetupStepItem {
|
|
issues := make([]response.SetupIssueItem, 0)
|
|
quickFixes := make([]string, 0)
|
|
status := "ok"
|
|
summaryCode := "ok"
|
|
|
|
if len(activeTeamIDs) == 0 {
|
|
status = "error"
|
|
summaryCode = "no_active_team"
|
|
issues = append(issues, response.SetupIssueItem{
|
|
Code: "no_active_team",
|
|
Severity: "error",
|
|
EntityType: "team",
|
|
QuickFixAction: QuickFixActionEnsureTeamSchedule,
|
|
})
|
|
quickFixes = append(quickFixes, QuickFixActionEnsureTeamSchedule)
|
|
}
|
|
|
|
return response.SetupStepItem{
|
|
Key: "teams",
|
|
StepNumber: 1,
|
|
Status: status,
|
|
SummaryCode: summaryCode,
|
|
TotalCount: len(teams),
|
|
ReadyCount: len(activeTeamIDs),
|
|
Issues: issues,
|
|
QuickFixActions: quickFixes,
|
|
ManagePath: "/dashboard/agents",
|
|
}
|
|
}
|
|
|
|
func (s *setupReadinessService) evaluateSchedulesStep(
|
|
teams []models.AgentTeam,
|
|
activeTeamMap map[int64]models.AgentTeam,
|
|
activeScheduleNowTeamSet map[int64]bool,
|
|
anyScheduleTeamSet map[int64]bool,
|
|
) response.SetupStepItem {
|
|
issues := make([]response.SetupIssueItem, 0)
|
|
quickFixes := make([]string, 0)
|
|
readyTeamCount := 0
|
|
|
|
for _, team := range teams {
|
|
if team.Status != enums.StatusOk {
|
|
continue
|
|
}
|
|
if activeScheduleNowTeamSet[team.ID] {
|
|
readyTeamCount++
|
|
continue
|
|
}
|
|
issueCode := "team_no_schedule_configured"
|
|
if anyScheduleTeamSet[team.ID] {
|
|
issueCode = "team_off_hours_now"
|
|
}
|
|
issues = append(issues, response.SetupIssueItem{
|
|
Code: issueCode,
|
|
Severity: "warning",
|
|
EntityType: "team",
|
|
EntityID: team.ID,
|
|
EntityName: team.Name,
|
|
})
|
|
}
|
|
|
|
status := "ok"
|
|
summaryCode := "ok"
|
|
if len(activeTeamMap) == 0 {
|
|
status = "error"
|
|
summaryCode = "no_active_team"
|
|
|
|
} else if readyTeamCount == 0 {
|
|
status = "error"
|
|
summaryCode = "no_schedule_now"
|
|
|
|
} else if len(issues) > 0 {
|
|
status = "warning"
|
|
summaryCode = "partial_schedule_now"
|
|
|
|
}
|
|
|
|
return response.SetupStepItem{
|
|
Key: "schedules",
|
|
StepNumber: 2,
|
|
Status: status,
|
|
SummaryCode: summaryCode,
|
|
TotalCount: len(activeTeamMap),
|
|
ReadyCount: readyTeamCount,
|
|
Issues: issues,
|
|
QuickFixActions: quickFixes,
|
|
ManagePath: "/dashboard/agent-team-schedules",
|
|
}
|
|
}
|
|
|
|
func (s *setupReadinessService) evaluateOperatorsStep(
|
|
profiles []models.AgentProfile,
|
|
activeTeamMap map[int64]models.AgentTeam,
|
|
activeScheduleNowTeamSet map[int64]bool,
|
|
activeUserMap map[int64]models.User,
|
|
activeCounts map[int64]int,
|
|
) (response.SetupStepItem, map[int64]bool) {
|
|
issues := make([]response.SetupIssueItem, 0)
|
|
quickFixes := make([]string, 0)
|
|
readyCount := 0
|
|
readyOperatorTeams := make(map[int64]bool)
|
|
|
|
for _, p := range profiles {
|
|
displayName := strings.TrimSpace(p.DisplayName)
|
|
if displayName == "" {
|
|
if u, ok := activeUserMap[p.UserID]; ok {
|
|
displayName = firstNonEmpty(u.Nickname, u.Username)
|
|
}
|
|
}
|
|
if displayName == "" {
|
|
displayName = fmt.Sprintf("#%d", p.UserID)
|
|
}
|
|
|
|
eligibility, err := SupportAvailabilityService.Evaluate(sqls.DB(), p, true, time.Now(), 0)
|
|
if err != nil {
|
|
eligibility.Reason = "availability_unavailable"
|
|
}
|
|
if !eligibility.Eligible {
|
|
code := supportSetupOperatorIssue(eligibility.Reason)
|
|
issues = append(issues, response.SetupIssueItem{Code: code, Severity: "warning", EntityType: "operator", EntityID: p.ID, EntityName: displayName})
|
|
continue
|
|
}
|
|
readyCount++
|
|
readyOperatorTeams[p.TeamID] = true
|
|
|
|
}
|
|
|
|
status := "ok"
|
|
summaryCode := "ok"
|
|
if len(profiles) == 0 {
|
|
status = "error"
|
|
summaryCode = "no_operator_profile"
|
|
issues = append(issues, response.SetupIssueItem{
|
|
Code: "no_operator_profile",
|
|
Severity: "error",
|
|
EntityType: "operator",
|
|
QuickFixAction: QuickFixActionEnableMyProfile,
|
|
})
|
|
quickFixes = append(quickFixes, QuickFixActionEnableMyProfile)
|
|
} else if readyCount == 0 {
|
|
status = "error"
|
|
summaryCode = "no_idle_operator"
|
|
quickFixes = append(quickFixes, QuickFixActionEnableMyProfile)
|
|
} else if len(issues) > 0 {
|
|
status = "warning"
|
|
summaryCode = "some_operators_unavailable"
|
|
}
|
|
|
|
return response.SetupStepItem{
|
|
Key: "operators",
|
|
StepNumber: 3,
|
|
Status: status,
|
|
SummaryCode: summaryCode,
|
|
TotalCount: len(profiles),
|
|
ReadyCount: readyCount,
|
|
Issues: issues,
|
|
QuickFixActions: quickFixes,
|
|
ManagePath: "/dashboard/agents",
|
|
}, readyOperatorTeams
|
|
}
|
|
|
|
func (s *setupReadinessService) evaluateAIAgentsStep(
|
|
aiAgents []models.AIAgent,
|
|
activeTeamMap map[int64]models.AgentTeam,
|
|
activeScheduleNowTeamSet map[int64]bool,
|
|
readyOperatorTeams map[int64]bool,
|
|
) (response.SetupStepItem, map[int64]bool) {
|
|
issues := make([]response.SetupIssueItem, 0)
|
|
quickFixes := make([]string, 0)
|
|
readyCount := 0
|
|
readyAIAgentMap := make(map[int64]bool)
|
|
|
|
activeAgentCount := 0
|
|
for _, agent := range aiAgents {
|
|
if agent.Status != enums.StatusOk {
|
|
continue
|
|
}
|
|
activeAgentCount++
|
|
if agent.PublishedRevisionID <= 0 {
|
|
issues = append(issues, response.SetupIssueItem{Code: "agent_unpublished", Severity: "error", EntityType: "ai_agent", EntityID: agent.ID, EntityName: agent.Name})
|
|
continue
|
|
}
|
|
if agent.ServiceMode == enums.IMConversationServiceModeAIOnly {
|
|
issues = append(issues, response.SetupIssueItem{
|
|
Code: "agent_ai_only_mode",
|
|
Severity: "warning",
|
|
EntityType: "ai_agent",
|
|
EntityID: agent.ID,
|
|
EntityName: agent.Name,
|
|
QuickFixAction: QuickFixActionFixAIAgentHandoff,
|
|
})
|
|
continue
|
|
}
|
|
teamIDs := utils.SplitInt64s(agent.TeamIDs)
|
|
validTeamIDs := make([]int64, 0, len(teamIDs))
|
|
for _, tid := range teamIDs {
|
|
if _, ok := activeTeamMap[tid]; ok {
|
|
validTeamIDs = append(validTeamIDs, tid)
|
|
}
|
|
}
|
|
if len(validTeamIDs) == 0 {
|
|
issues = append(issues, response.SetupIssueItem{
|
|
Code: "agent_no_teams",
|
|
Severity: "error",
|
|
EntityType: "ai_agent",
|
|
EntityID: agent.ID,
|
|
EntityName: agent.Name,
|
|
QuickFixAction: QuickFixActionFixAIAgentHandoff,
|
|
})
|
|
continue
|
|
}
|
|
if agent.ServiceMode == enums.IMConversationServiceModeAIFirst && !agent.AutoHandoffOnFailure {
|
|
issues = append(issues, response.SetupIssueItem{
|
|
Code: "agent_auto_handoff_disabled",
|
|
Severity: "warning",
|
|
EntityType: "ai_agent",
|
|
EntityID: agent.ID,
|
|
EntityName: agent.Name,
|
|
QuickFixAction: QuickFixActionFixAIAgentHandoff,
|
|
})
|
|
}
|
|
|
|
readyCount++
|
|
for _, tid := range validTeamIDs {
|
|
if activeScheduleNowTeamSet[tid] && readyOperatorTeams[tid] {
|
|
readyAIAgentMap[agent.ID] = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
status := "ok"
|
|
summaryCode := "ok"
|
|
if activeAgentCount == 0 {
|
|
status = "error"
|
|
summaryCode = "no_active_ai_agent"
|
|
issues = append(issues, response.SetupIssueItem{
|
|
Code: "no_active_ai_agent",
|
|
Severity: "error",
|
|
EntityType: "ai_agent",
|
|
QuickFixAction: QuickFixActionFixAIAgentHandoff,
|
|
})
|
|
quickFixes = append(quickFixes, QuickFixActionFixAIAgentHandoff)
|
|
} else if readyCount == 0 {
|
|
status = "error"
|
|
summaryCode = "no_handoff_ready_agent"
|
|
quickFixes = append(quickFixes, QuickFixActionFixAIAgentHandoff)
|
|
} else if len(issues) > 0 {
|
|
status = "warning"
|
|
summaryCode = "agent_has_warnings"
|
|
quickFixes = append(quickFixes, QuickFixActionFixAIAgentHandoff)
|
|
}
|
|
|
|
return response.SetupStepItem{
|
|
Key: "ai_agents",
|
|
StepNumber: 4,
|
|
Status: status,
|
|
SummaryCode: summaryCode,
|
|
TotalCount: len(aiAgents),
|
|
ReadyCount: readyCount,
|
|
Issues: issues,
|
|
QuickFixActions: quickFixes,
|
|
ManagePath: "/dashboard/ai-agents",
|
|
}, readyAIAgentMap
|
|
}
|
|
|
|
func (s *setupReadinessService) evaluateChannelsStep(
|
|
channels []models.Channel,
|
|
aiAgents []models.AIAgent,
|
|
) (response.SetupStepItem, []response.SetupChannelBrief) {
|
|
issues := make([]response.SetupIssueItem, 0)
|
|
quickFixes := make([]string, 0)
|
|
readyCount := 0
|
|
|
|
agentMap := make(map[int64]models.AIAgent, len(aiAgents))
|
|
for _, a := range aiAgents {
|
|
if a.Status == enums.StatusOk {
|
|
agentMap[a.ID] = a
|
|
}
|
|
}
|
|
|
|
briefs := make([]response.SetupChannelBrief, 0, len(channels))
|
|
activeChannelCount := 0
|
|
for _, ch := range channels {
|
|
briefs = append(briefs, response.SetupChannelBrief{
|
|
ID: ch.ID,
|
|
Name: ch.Name,
|
|
ChannelType: ch.ChannelType,
|
|
ChannelID: ch.ChannelID,
|
|
AIAgentID: ch.AIAgentID,
|
|
RolloutPercent: ch.AIAgentRolloutPercent,
|
|
Status: int(ch.Status),
|
|
})
|
|
if ch.Status != enums.StatusOk {
|
|
continue
|
|
}
|
|
activeChannelCount++
|
|
if ch.AIAgentID <= 0 {
|
|
issues = append(issues, response.SetupIssueItem{
|
|
Code: "channel_no_agent",
|
|
Severity: "error",
|
|
EntityType: "channel",
|
|
EntityID: ch.ID,
|
|
EntityName: ch.Name,
|
|
QuickFixAction: QuickFixActionEnsureDefaultChannel,
|
|
})
|
|
continue
|
|
}
|
|
if _, ok := agentMap[ch.AIAgentID]; !ok {
|
|
issues = append(issues, response.SetupIssueItem{
|
|
Code: "channel_agent_disabled",
|
|
Severity: "error",
|
|
EntityType: "channel",
|
|
EntityID: ch.ID,
|
|
EntityName: ch.Name,
|
|
QuickFixAction: QuickFixActionEnsureDefaultChannel,
|
|
})
|
|
continue
|
|
}
|
|
if agentMap[ch.AIAgentID].PublishedRevisionID <= 0 {
|
|
issues = append(issues, response.SetupIssueItem{Code: "channel_agent_unpublished", Severity: "error", EntityType: "channel", EntityID: ch.ID, EntityName: ch.Name})
|
|
continue
|
|
}
|
|
if ch.AIAgentRolloutPercent <= 0 {
|
|
issues = append(issues, response.SetupIssueItem{
|
|
Code: "channel_zero_rollout",
|
|
Severity: "error",
|
|
EntityType: "channel",
|
|
EntityID: ch.ID,
|
|
EntityName: ch.Name,
|
|
QuickFixAction: QuickFixActionEnsureDefaultChannel,
|
|
})
|
|
continue
|
|
}
|
|
readyCount++
|
|
}
|
|
|
|
status := "ok"
|
|
summaryCode := "ok"
|
|
if activeChannelCount == 0 {
|
|
status = "error"
|
|
summaryCode = "no_active_channel"
|
|
issues = append(issues, response.SetupIssueItem{
|
|
Code: "no_active_channel",
|
|
Severity: "error",
|
|
EntityType: "channel",
|
|
QuickFixAction: QuickFixActionEnsureDefaultChannel,
|
|
})
|
|
quickFixes = append(quickFixes, QuickFixActionEnsureDefaultChannel)
|
|
} else if readyCount == 0 {
|
|
status = "error"
|
|
summaryCode = "no_ready_channel"
|
|
quickFixes = append(quickFixes, QuickFixActionEnsureDefaultChannel)
|
|
} else if len(issues) > 0 {
|
|
status = "warning"
|
|
summaryCode = "some_channels_misconfigured"
|
|
quickFixes = append(quickFixes, QuickFixActionEnsureDefaultChannel)
|
|
}
|
|
|
|
return response.SetupStepItem{
|
|
Key: "channels",
|
|
StepNumber: 5,
|
|
Status: status,
|
|
SummaryCode: summaryCode,
|
|
TotalCount: len(channels),
|
|
ReadyCount: readyCount,
|
|
Issues: issues,
|
|
QuickFixActions: quickFixes,
|
|
ManagePath: "/dashboard/channels",
|
|
}, briefs
|
|
}
|
|
|
|
func (s *setupReadinessService) evaluateEndToEndStep(
|
|
channels []models.Channel,
|
|
aiAgents []models.AIAgent,
|
|
readyAIAgentMap map[int64]bool,
|
|
activeTeamMap map[int64]models.AgentTeam,
|
|
activeScheduleNowTeamSet map[int64]bool,
|
|
readyOperatorTeams map[int64]bool,
|
|
) (response.SetupStepItem, bool) {
|
|
issues := make([]response.SetupIssueItem, 0)
|
|
quickFixes := make([]string, 0)
|
|
readyChannelCount := 0
|
|
activeChannelCount := 0
|
|
|
|
for _, ch := range channels {
|
|
if ch.Status != enums.StatusOk {
|
|
continue
|
|
}
|
|
activeChannelCount++
|
|
if ch.AIAgentRolloutPercent > 0 && readyAIAgentMap[ch.AIAgentID] {
|
|
readyChannelCount++
|
|
}
|
|
}
|
|
|
|
canReceiveNow := readyChannelCount > 0
|
|
status := "ok"
|
|
summaryCode := "ok"
|
|
|
|
if !canReceiveNow {
|
|
status = "error"
|
|
summaryCode = "no_end_to_end_path"
|
|
issues = append(issues, response.SetupIssueItem{
|
|
Code: "no_end_to_end_path",
|
|
Severity: "error",
|
|
EntityType: "system",
|
|
QuickFixAction: QuickFixActionQuickSetupAll,
|
|
})
|
|
quickFixes = append(quickFixes, QuickFixActionQuickSetupAll)
|
|
}
|
|
|
|
_ = aiAgents
|
|
_ = activeTeamMap
|
|
_ = activeScheduleNowTeamSet
|
|
_ = readyOperatorTeams
|
|
|
|
return response.SetupStepItem{
|
|
Key: "end_to_end",
|
|
StepNumber: 6,
|
|
Status: status,
|
|
SummaryCode: summaryCode,
|
|
TotalCount: max(activeChannelCount, 1),
|
|
ReadyCount: readyChannelCount,
|
|
Issues: issues,
|
|
QuickFixActions: quickFixes,
|
|
ManagePath: "/dashboard/setup",
|
|
}, canReceiveNow
|
|
}
|
|
|
|
func (s *setupReadinessService) evaluateCurrentOperator(
|
|
operator *dto.AuthPrincipal,
|
|
profiles []models.AgentProfile,
|
|
activeTeamMap map[int64]models.AgentTeam,
|
|
activeScheduleNowTeamSet map[int64]bool,
|
|
activeCounts map[int64]int,
|
|
) response.CurrentOperatorDiagnostic {
|
|
if operator == nil || operator.UserID <= 0 {
|
|
return response.CurrentOperatorDiagnostic{
|
|
BlockerCodes: []string{"not_authenticated"},
|
|
}
|
|
}
|
|
|
|
diag := response.CurrentOperatorDiagnostic{
|
|
UserID: operator.UserID,
|
|
Username: operator.Username,
|
|
Nickname: operator.Nickname,
|
|
BlockerCodes: make([]string, 0),
|
|
}
|
|
|
|
var matched *models.AgentProfile
|
|
for i := range profiles {
|
|
if profiles[i].UserID == operator.UserID {
|
|
matched = &profiles[i]
|
|
break
|
|
}
|
|
}
|
|
|
|
if matched == nil {
|
|
diag.BlockerCodes = append(diag.BlockerCodes, "missing_profile")
|
|
return diag
|
|
}
|
|
|
|
diag.HasProfile = true
|
|
diag.ProfileStatusOk = matched.Status == enums.StatusOk
|
|
diag.TeamID = matched.TeamID
|
|
diag.ServiceStatus = int(matched.ServiceStatus)
|
|
diag.AutoAssignEnabled = matched.AutoAssignEnabled
|
|
diag.ActiveConversationCount = int64(activeCounts[operator.UserID])
|
|
diag.MaxConcurrentCount = matched.MaxConcurrentCount
|
|
|
|
if team, ok := activeTeamMap[matched.TeamID]; ok {
|
|
diag.TeamName = team.Name
|
|
diag.TeamActive = true
|
|
}
|
|
eligibility, err := SupportAvailabilityService.Evaluate(sqls.DB(), *matched, true, time.Now(), 0)
|
|
if err != nil {
|
|
diag.BlockerCodes = append(diag.BlockerCodes, "availability_unavailable")
|
|
return diag
|
|
}
|
|
diag.ServiceStatus = int(eligibility.EffectiveStatus)
|
|
diag.TeamHasScheduleNow = eligibility.Calendar.Open
|
|
diag.ActiveConversationCount = int64(eligibility.ActiveCount)
|
|
diag.CanReceiveNow = eligibility.Eligible
|
|
if !eligibility.Eligible {
|
|
diag.BlockerCodes = append(diag.BlockerCodes, supportSetupSkipReason(eligibility.Reason))
|
|
}
|
|
|
|
return diag
|
|
}
|
|
|
|
func (s *setupReadinessService) SimulateRoute(req request.SetupSimulateRouteRequest, operator *dto.AuthPrincipal) (*response.SetupSimulateRouteResponse, error) {
|
|
if req.BypassAI {
|
|
return nil, errorsx.InvalidParamI18n("error.setup.bypassUnsupported")
|
|
}
|
|
now := time.Now()
|
|
db := sqls.DB()
|
|
fixIfAllowed := func(action string) string {
|
|
if s.canRunQuickFixAction(operator, action) {
|
|
return action
|
|
}
|
|
return ""
|
|
}
|
|
|
|
var channel models.Channel
|
|
if req.ChannelID > 0 {
|
|
if err := db.Where("status <> ?", enums.StatusDeleted).First(&channel, req.ChannelID).Error; err != nil {
|
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, setupPersistenceError(err)
|
|
}
|
|
return &response.SetupSimulateRouteResponse{
|
|
CandidateOperators: []response.SimulatedOperatorCandidate{},
|
|
WillReachOperator: false,
|
|
FinalOutcomeCode: "blocked_no_channel",
|
|
Hops: []response.SimulatedRouteHop{
|
|
{
|
|
HopKey: "channel",
|
|
Status: "error",
|
|
ReasonCode: "blocked_no_channel",
|
|
QuickFixAction: fixIfAllowed(QuickFixActionEnsureDefaultChannel),
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
} else {
|
|
if err := db.Where("status = ?", enums.StatusOk).Order("id ASC").First(&channel).Error; err != nil {
|
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, setupPersistenceError(err)
|
|
}
|
|
if err2 := db.Where("status <> ?", enums.StatusDeleted).Order("id ASC").First(&channel).Error; err2 != nil {
|
|
if !errors.Is(err2, gorm.ErrRecordNotFound) {
|
|
return nil, setupPersistenceError(err2)
|
|
}
|
|
return &response.SetupSimulateRouteResponse{
|
|
CandidateOperators: []response.SimulatedOperatorCandidate{},
|
|
WillReachOperator: false,
|
|
FinalOutcomeCode: "blocked_no_channel",
|
|
Hops: []response.SimulatedRouteHop{
|
|
{
|
|
HopKey: "channel",
|
|
Status: "error",
|
|
ReasonCode: "blocked_no_channel",
|
|
QuickFixAction: fixIfAllowed(QuickFixActionEnsureDefaultChannel),
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
resp := &response.SetupSimulateRouteResponse{
|
|
ChannelID: channel.ID,
|
|
ChannelName: channel.Name,
|
|
ChannelType: channel.ChannelType,
|
|
Hops: make([]response.SimulatedRouteHop, 0, 5),
|
|
CandidateOperators: make([]response.SimulatedOperatorCandidate, 0),
|
|
}
|
|
|
|
// Hop 1: Channel
|
|
if channel.Status != enums.StatusOk {
|
|
resp.FinalOutcomeCode = "blocked_channel_disabled"
|
|
resp.Hops = append(resp.Hops, response.SimulatedRouteHop{
|
|
HopKey: "channel",
|
|
Status: "error",
|
|
ReasonCode: "blocked_channel_disabled",
|
|
EntityID: channel.ID,
|
|
EntityName: channel.Name,
|
|
QuickFixAction: fixIfAllowed(QuickFixActionEnsureDefaultChannel),
|
|
})
|
|
return resp, nil
|
|
}
|
|
if channel.AIAgentRolloutPercent <= 0 {
|
|
resp.FinalOutcomeCode = "blocked_zero_rollout"
|
|
resp.Hops = append(resp.Hops, response.SimulatedRouteHop{
|
|
HopKey: "channel",
|
|
Status: "error",
|
|
ReasonCode: "blocked_zero_rollout",
|
|
EntityID: channel.ID,
|
|
EntityName: channel.Name,
|
|
QuickFixAction: fixIfAllowed(QuickFixActionEnsureDefaultChannel),
|
|
})
|
|
return resp, nil
|
|
}
|
|
if channel.AIAgentRolloutPercent < 100 {
|
|
resp.FinalOutcomeCode = "partial_rollout_not_simulated"
|
|
resp.Hops = append(resp.Hops, response.SimulatedRouteHop{HopKey: "channel", Status: "warning", ReasonCode: resp.FinalOutcomeCode, EntityID: channel.ID, EntityName: channel.Name})
|
|
return resp, nil
|
|
}
|
|
if channel.AIAgentID <= 0 {
|
|
resp.FinalOutcomeCode = "blocked_missing_ai_agent"
|
|
resp.Hops = append(resp.Hops, response.SimulatedRouteHop{
|
|
HopKey: "channel",
|
|
Status: "error",
|
|
ReasonCode: "blocked_missing_ai_agent",
|
|
EntityID: channel.ID,
|
|
EntityName: channel.Name,
|
|
QuickFixAction: fixIfAllowed(QuickFixActionEnsureDefaultChannel),
|
|
})
|
|
return resp, nil
|
|
}
|
|
resp.Hops = append(resp.Hops, response.SimulatedRouteHop{
|
|
HopKey: "channel",
|
|
Status: "ok",
|
|
ReasonCode: "ok",
|
|
EntityID: channel.ID,
|
|
EntityName: channel.Name,
|
|
})
|
|
|
|
// Hop 2: AI Agent
|
|
var aiAgent models.AIAgent
|
|
agentErr := db.First(&aiAgent, channel.AIAgentID).Error
|
|
if agentErr != nil && !errors.Is(agentErr, gorm.ErrRecordNotFound) {
|
|
return nil, setupPersistenceError(agentErr)
|
|
}
|
|
if agentErr != nil || aiAgent.Status != enums.StatusOk {
|
|
resp.FinalOutcomeCode = "blocked_missing_ai_agent"
|
|
resp.Hops = append(resp.Hops, response.SimulatedRouteHop{
|
|
HopKey: "ai_agent",
|
|
Status: "error",
|
|
ReasonCode: "blocked_missing_ai_agent",
|
|
EntityID: channel.AIAgentID,
|
|
QuickFixAction: fixIfAllowed(QuickFixActionFixAIAgentHandoff),
|
|
})
|
|
return resp, nil
|
|
}
|
|
if aiAgent.PublishedRevisionID <= 0 {
|
|
resp.FinalOutcomeCode = "blocked_agent_unpublished"
|
|
resp.Hops = append(resp.Hops, response.SimulatedRouteHop{HopKey: "ai_agent", Status: "error", ReasonCode: resp.FinalOutcomeCode, EntityID: aiAgent.ID, EntityName: aiAgent.Name})
|
|
return resp, nil
|
|
}
|
|
if aiAgent.ServiceMode == enums.IMConversationServiceModeAIOnly {
|
|
resp.FinalOutcomeCode = "blocked_ai_only_mode"
|
|
resp.Hops = append(resp.Hops, response.SimulatedRouteHop{
|
|
HopKey: "ai_agent",
|
|
Status: "error",
|
|
ReasonCode: "blocked_ai_only_mode",
|
|
EntityID: aiAgent.ID,
|
|
EntityName: aiAgent.Name,
|
|
QuickFixAction: fixIfAllowed(QuickFixActionFixAIAgentHandoff),
|
|
})
|
|
return resp, nil
|
|
}
|
|
if aiAgent.ServiceMode != enums.IMConversationServiceModeHumanOnly {
|
|
resp.FinalOutcomeCode = "ai_execution_not_simulated"
|
|
resp.Hops = append(resp.Hops, response.SimulatedRouteHop{HopKey: "ai_agent", Status: "warning", ReasonCode: resp.FinalOutcomeCode, EntityID: aiAgent.ID, EntityName: aiAgent.Name})
|
|
return resp, nil
|
|
}
|
|
resp.Hops = append(resp.Hops, response.SimulatedRouteHop{HopKey: "ai_agent", Status: "ok", ReasonCode: "ok_human_only", EntityID: aiAgent.ID, EntityName: aiAgent.Name})
|
|
|
|
// Hop 3: Target Teams
|
|
requestedTeamIDs := utils.SplitInt64s(aiAgent.TeamIDs)
|
|
var teams []models.AgentTeam
|
|
if len(requestedTeamIDs) > 0 {
|
|
if err := db.Where("id IN ? AND status = ?", requestedTeamIDs, enums.StatusOk).Find(&teams).Error; err != nil {
|
|
return nil, setupPersistenceError(err)
|
|
}
|
|
}
|
|
if len(teams) == 0 {
|
|
resp.FinalOutcomeCode = "blocked_agent_no_teams"
|
|
resp.Hops = append(resp.Hops, response.SimulatedRouteHop{
|
|
HopKey: "teams",
|
|
Status: "error",
|
|
ReasonCode: "blocked_agent_no_teams",
|
|
EntityID: aiAgent.ID,
|
|
EntityName: aiAgent.Name,
|
|
QuickFixAction: fixIfAllowed(QuickFixActionFixAIAgentHandoff),
|
|
})
|
|
return resp, nil
|
|
}
|
|
|
|
teamNames := make([]string, 0, len(teams))
|
|
enabledTeamIDSet := make(map[int64]models.AgentTeam, len(teams))
|
|
enabledTeamIDs := make([]int64, 0, len(teams))
|
|
for _, t := range teams {
|
|
teamNames = append(teamNames, t.Name)
|
|
enabledTeamIDSet[t.ID] = t
|
|
enabledTeamIDs = append(enabledTeamIDs, t.ID)
|
|
}
|
|
resp.Hops = append(resp.Hops, response.SimulatedRouteHop{
|
|
HopKey: "teams",
|
|
Status: "ok",
|
|
ReasonCode: "ok",
|
|
EntityName: strings.Join(teamNames, ", "),
|
|
})
|
|
|
|
// Hop 4: Shift Schedules (Active Now)
|
|
activeScheduleTeamIDs := make([]int64, 0)
|
|
for _, teamID := range enabledTeamIDs {
|
|
calendar, err := SupportCalendarService.Evaluate(db, teamID, now)
|
|
if err != nil {
|
|
return nil, setupPersistenceError(err)
|
|
}
|
|
if calendar.Open {
|
|
activeScheduleTeamIDs = append(activeScheduleTeamIDs, teamID)
|
|
}
|
|
}
|
|
|
|
activeScheduleTeamSet := make(map[int64]bool, len(activeScheduleTeamIDs))
|
|
activeScheduleNames := make([]string, 0, len(activeScheduleTeamIDs))
|
|
for _, tid := range activeScheduleTeamIDs {
|
|
activeScheduleTeamSet[tid] = true
|
|
if t, ok := enabledTeamIDSet[tid]; ok {
|
|
activeScheduleNames = append(activeScheduleNames, t.Name)
|
|
}
|
|
}
|
|
|
|
if len(activeScheduleTeamIDs) == 0 {
|
|
resp.FinalOutcomeCode = "queued_off_hours_no_schedule"
|
|
resp.Hops = append(resp.Hops, response.SimulatedRouteHop{
|
|
HopKey: "schedules",
|
|
Status: "error",
|
|
ReasonCode: "queued_off_hours_no_schedule",
|
|
EntityName: strings.Join(teamNames, ", "),
|
|
})
|
|
candidates, err := s.buildCandidateListForTeams(enabledTeamIDSet, activeScheduleTeamSet)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp.CandidateOperators = candidates
|
|
return resp, nil
|
|
}
|
|
resp.Hops = append(resp.Hops, response.SimulatedRouteHop{
|
|
HopKey: "schedules",
|
|
Status: "ok",
|
|
ReasonCode: "ok",
|
|
EntityName: strings.Join(activeScheduleNames, ", "),
|
|
})
|
|
|
|
// Hop 5: Operator Candidate Selection
|
|
candidates, err := s.buildCandidateListForTeams(enabledTeamIDSet, activeScheduleTeamSet)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp.CandidateOperators = candidates
|
|
|
|
eligibleCandidates, _, err := ConversationDispatchService.pickDispatchCandidates(enabledTeamIDs, now)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(eligibleCandidates) == 0 {
|
|
resp.FinalOutcomeCode = "queued_no_eligible_operator"
|
|
resp.Hops = append(resp.Hops, response.SimulatedRouteHop{
|
|
HopKey: "operators",
|
|
Status: "error",
|
|
ReasonCode: "queued_no_eligible_operator",
|
|
QuickFixAction: fixIfAllowed(QuickFixActionEnableMyProfile),
|
|
})
|
|
return resp, nil
|
|
}
|
|
|
|
winner := eligibleCandidates[0]
|
|
winnerTeamName := ""
|
|
if t, ok := enabledTeamIDSet[winner.profile.TeamID]; ok {
|
|
winnerTeamName = t.Name
|
|
}
|
|
winnerDisplayName := firstNonEmpty(winner.profile.DisplayName, fmt.Sprintf("#%d", winner.profile.UserID))
|
|
matched := &response.SimulatedOperatorCandidate{
|
|
UserID: winner.profile.UserID,
|
|
DisplayName: winnerDisplayName,
|
|
TeamID: winner.profile.TeamID,
|
|
TeamName: winnerTeamName,
|
|
Eligible: true,
|
|
ActiveCount: int64(winner.activeCount),
|
|
MaxConcurrentCount: winner.profile.MaxConcurrentCount,
|
|
ServiceStatus: int(winner.profile.ServiceStatus),
|
|
AutoAssignEnabled: winner.profile.AutoAssignEnabled,
|
|
}
|
|
resp.MatchedOperator = matched
|
|
resp.WillReachOperator = true
|
|
resp.FinalOutcomeCode = "assigned_to_operator"
|
|
resp.Hops = append(resp.Hops, response.SimulatedRouteHop{
|
|
HopKey: "operators",
|
|
Status: "ok",
|
|
ReasonCode: "assigned_to_operator",
|
|
EntityID: winner.profile.UserID,
|
|
EntityName: winnerDisplayName,
|
|
})
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
func (s *setupReadinessService) buildCandidateListForTeams(
|
|
enabledTeamIDSet map[int64]models.AgentTeam,
|
|
activeScheduleTeamSet map[int64]bool,
|
|
) ([]response.SimulatedOperatorCandidate, error) {
|
|
db := sqls.DB()
|
|
teamIDs := make([]int64, 0, len(enabledTeamIDSet))
|
|
teamNameMap := make(map[int64]string, len(enabledTeamIDSet))
|
|
for id, team := range enabledTeamIDSet {
|
|
teamIDs = append(teamIDs, id)
|
|
teamNameMap[id] = team.Name
|
|
}
|
|
var allProfiles []models.AgentProfile
|
|
if err := db.Where("team_id IN ? AND status <> ?", teamIDs, enums.StatusDeleted).Order("id ASC").Find(&allProfiles).Error; err != nil {
|
|
return nil, setupPersistenceError(err)
|
|
}
|
|
var users []models.User
|
|
if err := db.Where("id IN ?", extractProfileUserIDs(allProfiles)).Find(&users).Error; err != nil {
|
|
return nil, setupPersistenceError(err)
|
|
}
|
|
userMap := make(map[int64]models.User, len(users))
|
|
for _, u := range users {
|
|
userMap[u.ID] = u
|
|
}
|
|
activeCounts, err := ConversationDispatchService.findActiveConversationCountMap(extractProfileUserIDs(allProfiles))
|
|
if err != nil {
|
|
return nil, setupPersistenceError(err)
|
|
}
|
|
result := make([]response.SimulatedOperatorCandidate, 0, len(allProfiles))
|
|
|
|
for _, p := range allProfiles {
|
|
u, hasUser := userMap[p.UserID]
|
|
displayName := strings.TrimSpace(p.DisplayName)
|
|
if displayName == "" && hasUser {
|
|
displayName = firstNonEmpty(u.Nickname, u.Username)
|
|
}
|
|
if displayName == "" {
|
|
displayName = fmt.Sprintf("#%d", p.UserID)
|
|
}
|
|
|
|
eligibility, err := SupportAvailabilityService.Evaluate(db, p, true, time.Now(), 0)
|
|
if err != nil {
|
|
return nil, setupPersistenceError(err)
|
|
}
|
|
skipReason := ""
|
|
if !eligibility.Eligible {
|
|
skipReason = supportSetupSkipReason(eligibility.Reason)
|
|
}
|
|
|
|
result = append(result, response.SimulatedOperatorCandidate{
|
|
UserID: p.UserID,
|
|
DisplayName: displayName,
|
|
TeamID: p.TeamID,
|
|
TeamName: teamNameMap[p.TeamID],
|
|
Eligible: skipReason == "",
|
|
SkipReasonCode: skipReason,
|
|
ActiveCount: int64(activeCounts[p.UserID]),
|
|
MaxConcurrentCount: p.MaxConcurrentCount,
|
|
ServiceStatus: int(eligibility.EffectiveStatus),
|
|
AutoAssignEnabled: p.AutoAssignEnabled,
|
|
})
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *setupReadinessService) hasAnyPermission(operator *dto.AuthPrincipal, codes ...string) bool {
|
|
if operator == nil || operator.UserID <= 0 {
|
|
return false
|
|
}
|
|
for _, code := range codes {
|
|
if slices.Contains(operator.Permissions, code) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *setupReadinessService) canManageTeamsAndSchedules(operator *dto.AuthPrincipal) bool {
|
|
return s.hasAnyPermission(operator, constants.PermissionAgentTeamCreate.Code, constants.PermissionAgentTeamUpdate.Code)
|
|
}
|
|
|
|
func (s *setupReadinessService) canManageOperatorProfile(operator *dto.AuthPrincipal) bool {
|
|
return s.hasAnyPermission(
|
|
operator,
|
|
constants.PermissionAgentCreate.Code,
|
|
constants.PermissionAgentUpdate.Code,
|
|
constants.PermissionAgentConfig.Code,
|
|
constants.PermissionAgentUpdateStatus.Code,
|
|
)
|
|
}
|
|
|
|
func (s *setupReadinessService) canManageAIAgents(operator *dto.AuthPrincipal) bool {
|
|
return s.hasAnyPermission(operator, constants.PermissionAIAgentCreate.Code, constants.PermissionAIAgentUpdate.Code)
|
|
}
|
|
|
|
func (s *setupReadinessService) canManageChannels(operator *dto.AuthPrincipal) bool {
|
|
return s.hasAnyPermission(operator, constants.PermissionChannelCreate.Code, constants.PermissionChannelUpdate.Code)
|
|
}
|
|
|
|
func (s *setupReadinessService) canRunQuickFixAction(operator *dto.AuthPrincipal, action string) bool {
|
|
switch action {
|
|
case QuickFixActionEnsureTeamSchedule:
|
|
return s.canManageTeamsAndSchedules(operator)
|
|
case QuickFixActionEnableMyProfile:
|
|
return s.canManageOperatorProfile(operator)
|
|
case QuickFixActionFixAIAgentHandoff:
|
|
return s.canManageAIAgents(operator)
|
|
case QuickFixActionEnsureDefaultChannel:
|
|
return s.canManageChannels(operator)
|
|
case QuickFixActionQuickSetupAll:
|
|
return s.canManageTeamsAndSchedules(operator) &&
|
|
s.canManageOperatorProfile(operator) &&
|
|
s.canManageAIAgents(operator) &&
|
|
s.canManageChannels(operator)
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (s *setupReadinessService) filterStepQuickFixes(step response.SetupStepItem, operator *dto.AuthPrincipal) response.SetupStepItem {
|
|
filteredActions := make([]string, 0, len(step.QuickFixActions))
|
|
for _, action := range step.QuickFixActions {
|
|
if s.canRunQuickFixAction(operator, action) {
|
|
filteredActions = append(filteredActions, action)
|
|
}
|
|
}
|
|
step.QuickFixActions = filteredActions
|
|
|
|
for i := range step.Issues {
|
|
if step.Issues[i].QuickFixAction != "" && !s.canRunQuickFixAction(operator, step.Issues[i].QuickFixAction) {
|
|
step.Issues[i].QuickFixAction = ""
|
|
}
|
|
}
|
|
return step
|
|
}
|
|
|
|
// A quick fix is a two-phase operation. Omitted apply intent is read-only, even
|
|
// for older clients. The fingerprint binds approval to the principal, named
|
|
// targets, current configuration, and exact proposed field changes.
|
|
type setupQuickFixPlan struct {
|
|
changes []response.SetupQuickFixChange
|
|
warnings []string
|
|
operations []func(*gorm.DB) error
|
|
snapshot any
|
|
}
|
|
|
|
func (s *setupReadinessService) ApplyQuickFix(req request.SetupQuickFixRequest, operator *dto.AuthPrincipal) (*response.SetupQuickFixResponse, error) {
|
|
if operator == nil || operator.UserID <= 0 {
|
|
return nil, errorsx.UnauthorizedI18n("error.setup.authRequired")
|
|
}
|
|
req.Action = strings.TrimSpace(req.Action)
|
|
switch req.Action {
|
|
case QuickFixActionQuickSetupAll, QuickFixActionEnsureTeamSchedule, QuickFixActionEnableMyProfile, QuickFixActionFixAIAgentHandoff, QuickFixActionEnsureDefaultChannel:
|
|
default:
|
|
return nil, errorsx.InvalidParamI18n("error.setup.invalidQuickFixAction", req.Action)
|
|
}
|
|
if req.EntityID < 0 {
|
|
return nil, errorsx.InvalidParamI18n("error.setup.targetRequired")
|
|
}
|
|
if !s.canRunQuickFixAction(operator, req.Action) {
|
|
return nil, errorsx.ForbiddenI18n("error.e0225")
|
|
}
|
|
if req.Apply && strings.TrimSpace(req.PreviewToken) == "" {
|
|
return nil, errorsx.InvalidParamI18n("error.setup.previewRequired")
|
|
}
|
|
res := &response.SetupQuickFixResponse{
|
|
AppliedActions: []string{}, Changes: []response.SetupQuickFixChange{}, WarningCodes: []string{},
|
|
}
|
|
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
|
db := ctx.Tx
|
|
if req.Apply {
|
|
// SQLite serializes writes; MySQL locks the configuration rows/ranges.
|
|
db = db.Clauses(clause.Locking{Strength: "UPDATE"})
|
|
}
|
|
plan, err := s.planQuickFix(db, req, operator)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
encoded, err := json.Marshal([]any{operator.UserID, req.Action, req.EntityID, plan.changes, plan.snapshot})
|
|
if err != nil {
|
|
return errorsx.BusinessErrorI18n(0, "error.setup.persistenceFailed")
|
|
}
|
|
res.PreviewToken = fmt.Sprintf("%x", sha256.Sum256(encoded))
|
|
res.Changes = plan.changes
|
|
res.WarningCodes = plan.warnings
|
|
if !req.Apply {
|
|
return nil
|
|
}
|
|
if req.PreviewToken != res.PreviewToken {
|
|
return errorsx.InvalidParamI18n("error.setup.previewChanged")
|
|
}
|
|
for i, operation := range plan.operations {
|
|
if err := operation(ctx.Tx); err != nil {
|
|
return err
|
|
}
|
|
res.AppliedActions = append(res.AppliedActions, plan.changes[i].Operation+"_"+plan.changes[i].EntityType)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
var appError *errorsx.I18nError
|
|
if errors.As(err, &appError) {
|
|
return nil, err
|
|
}
|
|
return nil, setupPersistenceError(err)
|
|
}
|
|
res.Applied = req.Apply
|
|
// Dispatch is deliberately outside the configuration transaction. A failure
|
|
// does not roll back committed repairs and is visible to the caller.
|
|
if res.Applied && len(res.AppliedActions) > 0 {
|
|
if _, err := ConversationDispatchService.DispatchPendingConversations(0); err != nil {
|
|
slog.Warn("setup repair committed but pending dispatch failed", "user_id", operator.UserID)
|
|
res.WarningCodes = append(res.WarningCodes, "dispatch_failed")
|
|
}
|
|
}
|
|
readiness, err := s.GetReadiness(operator)
|
|
if err != nil {
|
|
// Never report a committed mutation as a failed write that invites a retry.
|
|
res.WarningCodes = append(res.WarningCodes, "readiness_refresh_failed")
|
|
} else {
|
|
res.Readiness = readiness
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
func setupPersistenceError(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
return errorsx.BusinessErrorI18n(0, "error.setup.persistenceFailed")
|
|
}
|
|
|
|
func (s *setupReadinessService) planQuickFix(db *gorm.DB, req request.SetupQuickFixRequest, operator *dto.AuthPrincipal) (*setupQuickFixPlan, error) {
|
|
plan := &setupQuickFixPlan{changes: []response.SetupQuickFixChange{}, warnings: []string{}, operations: []func(*gorm.DB) error{}}
|
|
var teams []models.AgentTeam
|
|
var agents []models.AIAgent
|
|
var channels []models.Channel
|
|
var profiles []models.AgentProfile
|
|
// Deleted records are never update candidates. Keep them in the snapshot so
|
|
// deletion cannot silently turn an approved update into a replacement create.
|
|
// Each read gets a fresh statement; the shared handle may carry a row lock,
|
|
// but must not retain the previous Find model/table or profile filter.
|
|
for _, dest := range []any{&teams, &agents, &channels} {
|
|
if err := db.Session(&gorm.Session{}).Order("id ASC").Find(dest).Error; err != nil {
|
|
return nil, setupPersistenceError(err)
|
|
}
|
|
}
|
|
if err := db.Session(&gorm.Session{}).Where("user_id = ?", operator.UserID).Order("id ASC").Find(&profiles).Error; err != nil {
|
|
return nil, setupPersistenceError(err)
|
|
}
|
|
if req.Action == QuickFixActionEnableMyProfile && req.EntityID > 0 && (len(profiles) == 0 || profiles[0].ID != req.EntityID) {
|
|
return nil, errorsx.InvalidParamI18n("error.setup.targetUnavailable")
|
|
}
|
|
plan.snapshot = []any{teams, agents, channels, profiles}
|
|
now := time.Now()
|
|
audit := models.AuditFields{CreatedAt: now, UpdatedAt: now, CreateUserID: operator.UserID, UpdateUserID: operator.UserID, CreateUserName: operator.Username, UpdateUserName: operator.Username}
|
|
require := func(codes ...string) error {
|
|
if !s.hasAnyPermission(operator, codes...) {
|
|
return errorsx.ForbiddenI18n("error.e0225")
|
|
}
|
|
return nil
|
|
}
|
|
add := func(kind string, id int64, name, operation string, before, after map[string]any, run func(*gorm.DB) error) {
|
|
plan.changes = append(plan.changes, response.SetupQuickFixChange{EntityType: kind, EntityID: id, EntityName: name, Operation: operation, Before: before, After: after})
|
|
plan.operations = append(plan.operations, run)
|
|
}
|
|
update := func(model any, kind string, id int64, name string, before, after map[string]any, updatedAt time.Time) {
|
|
for field, value := range after {
|
|
if reflect.DeepEqual(before[field], value) {
|
|
delete(before, field)
|
|
delete(after, field)
|
|
}
|
|
}
|
|
if len(after) == 0 {
|
|
return
|
|
}
|
|
add(kind, id, name, "update", before, after, func(tx *gorm.DB) error {
|
|
updates := make(map[string]any, len(after)+3)
|
|
for key, value := range after {
|
|
updates[key] = value
|
|
}
|
|
updates["updated_at"] = now
|
|
updates["update_user_id"] = operator.UserID
|
|
updates["update_user_name"] = operator.Username
|
|
result := tx.Model(model).Where("id = ? AND status <> ? AND updated_at = ?", id, enums.StatusDeleted, updatedAt).Updates(updates)
|
|
if result.Error != nil {
|
|
return setupPersistenceError(result.Error)
|
|
}
|
|
if result.RowsAffected != 1 {
|
|
return errorsx.InvalidParamI18n("error.setup.previewChanged")
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
all := req.Action == QuickFixActionQuickSetupAll
|
|
if all && req.EntityID != 0 {
|
|
return nil, errorsx.InvalidParamI18n("error.setup.targetRequired")
|
|
}
|
|
|
|
var team *models.AgentTeam
|
|
nonDeletedTeams := 0
|
|
for i := range teams {
|
|
if teams[i].Status != enums.StatusDeleted {
|
|
nonDeletedTeams++
|
|
}
|
|
if team == nil && teams[i].Status == enums.StatusOk {
|
|
team = &teams[i]
|
|
}
|
|
}
|
|
if req.Action == QuickFixActionEnsureTeamSchedule && req.EntityID > 0 {
|
|
team = nil
|
|
for i := range teams {
|
|
if teams[i].ID == req.EntityID && teams[i].Status == enums.StatusOk {
|
|
team = &teams[i]
|
|
}
|
|
}
|
|
if team == nil {
|
|
return nil, errorsx.InvalidParamI18n("error.setup.targetUnavailable")
|
|
}
|
|
}
|
|
if (all || req.Action == QuickFixActionEnsureTeamSchedule) && team == nil && nonDeletedTeams == 0 && req.EntityID == 0 {
|
|
if err := require(constants.PermissionAgentTeamCreate.Code); err != nil {
|
|
return nil, err
|
|
}
|
|
team = &models.AgentTeam{Name: "Default Support Team", LeaderUserID: operator.UserID, Status: enums.StatusOk, AuditFields: audit}
|
|
add("team", 0, team.Name, "create", nil, map[string]any{"name": team.Name, "leader_user_id": operator.UserID, "status": enums.StatusOk}, func(tx *gorm.DB) error { return setupPersistenceError(tx.Create(team).Error) })
|
|
}
|
|
// A team being off duty is not a broken schedule. The existing schedule
|
|
// editor is the only place to add explicit hours; quick fixes never add shifts.
|
|
if all || req.Action == QuickFixActionEnsureTeamSchedule {
|
|
plan.warnings = append(plan.warnings, "schedules_unchanged")
|
|
}
|
|
if team == nil {
|
|
plan.warnings = append(plan.warnings, "active_team_required")
|
|
}
|
|
teamRef := func() any {
|
|
if team == nil {
|
|
return nil
|
|
}
|
|
if team.ID > 0 {
|
|
return team.ID
|
|
}
|
|
return team.Name
|
|
}
|
|
|
|
if all || req.Action == QuickFixActionEnableMyProfile {
|
|
if len(profiles) == 0 && team != nil {
|
|
if err := require(constants.PermissionAgentCreate.Code); err != nil {
|
|
return nil, err
|
|
}
|
|
profile := &models.AgentProfile{UserID: operator.UserID, AgentCode: fmt.Sprintf("AG%04d", operator.UserID), DisplayName: firstNonEmpty(operator.Nickname, operator.Username, fmt.Sprintf("Agent-%d", operator.UserID)), ServiceStatus: enums.ServiceStatusIdle, MaxConcurrentCount: 10, PriorityLevel: 1, AutoAssignEnabled: true, ReceiveOfflineMessage: true, Status: enums.StatusOk, AuditFields: audit}
|
|
add("operator", 0, profile.DisplayName, "create", nil, map[string]any{"user_id": operator.UserID, "team_id": teamRef(), "status": enums.StatusOk, "service_status": enums.ServiceStatusIdle, "auto_assign_enabled": true, "max_concurrent_count": 10, "priority_level": 1, "receive_offline_message": true}, func(tx *gorm.DB) error {
|
|
profile.TeamID = team.ID
|
|
return setupPersistenceError(tx.Create(profile).Error)
|
|
})
|
|
} else if !all && len(profiles) > 0 {
|
|
profile := profiles[0]
|
|
if profile.Status == enums.StatusDeleted {
|
|
return nil, errorsx.InvalidParamI18n("error.setup.targetUnavailable")
|
|
}
|
|
before, after := map[string]any{}, map[string]any{}
|
|
if profile.Status != enums.StatusOk {
|
|
if err := require(constants.PermissionAgentUpdate.Code); err != nil {
|
|
return nil, err
|
|
}
|
|
before["status"], after["status"] = profile.Status, enums.StatusOk
|
|
}
|
|
if profile.ServiceStatus == enums.ServiceStatusBusy {
|
|
if err := require(constants.PermissionAgentUpdateStatus.Code, constants.PermissionAgentUpdate.Code); err != nil {
|
|
return nil, err
|
|
}
|
|
before["service_status"], after["service_status"] = profile.ServiceStatus, enums.ServiceStatusIdle
|
|
}
|
|
if !profile.AutoAssignEnabled || profile.MaxConcurrentCount < 0 {
|
|
if err := require(constants.PermissionAgentConfig.Code, constants.PermissionAgentUpdate.Code); err != nil {
|
|
return nil, err
|
|
}
|
|
if !profile.AutoAssignEnabled {
|
|
before["auto_assign_enabled"], after["auto_assign_enabled"] = false, true
|
|
}
|
|
if profile.MaxConcurrentCount < 0 {
|
|
before["max_concurrent_count"], after["max_concurrent_count"] = profile.MaxConcurrentCount, 10
|
|
}
|
|
}
|
|
// Do not silently move an existing operator to a different team.
|
|
update(&models.AgentProfile{}, "operator", profile.ID, profile.DisplayName, before, after, profile.UpdatedAt)
|
|
}
|
|
}
|
|
|
|
var agent *models.AIAgent
|
|
nonDeletedAgents := 0
|
|
for i := range agents {
|
|
if agents[i].Status != enums.StatusDeleted {
|
|
nonDeletedAgents++
|
|
}
|
|
if agent == nil && agents[i].Status == enums.StatusOk && agents[i].RolloutPercent > 0 && agents[i].PublishedRevisionID > 0 {
|
|
agent = &agents[i]
|
|
}
|
|
}
|
|
if all || req.Action == QuickFixActionFixAIAgentHandoff {
|
|
if nonDeletedAgents == 0 && req.EntityID == 0 && team != nil {
|
|
if err := require(constants.PermissionAIAgentCreate.Code); err != nil {
|
|
return nil, err
|
|
}
|
|
agent = &models.AIAgent{Name: "Default Reception Agent", Status: enums.StatusOk, ServiceMode: enums.IMConversationServiceModeHumanOnly, RolloutPercent: 100, HandoffMode: enums.AIAgentHandoffModeDefaultTeamPool, FallbackMode: enums.AIAgentFallbackModeHandoff, AutoHandoffOnFailure: true, ReplyTimeoutSeconds: 180, MaxSteps: 6, AuditFields: audit}
|
|
add("ai_agent", 0, agent.Name, "create", nil, map[string]any{"name": agent.Name, "status": enums.StatusOk, "team_ids": teamRef(), "service_mode": agent.ServiceMode, "rollout_percent": 100, "handoff_mode": agent.HandoffMode, "fallback_mode": agent.FallbackMode, "auto_handoff_on_failure": true, "reply_timeout_seconds": 180, "max_steps": 6}, func(tx *gorm.DB) error {
|
|
agent.TeamIDs = strconv.FormatInt(team.ID, 10)
|
|
return setupPersistenceError(tx.Create(agent).Error)
|
|
})
|
|
} else if !all {
|
|
if req.EntityID <= 0 {
|
|
return nil, errorsx.InvalidParamI18n("error.setup.targetRequired")
|
|
}
|
|
var target *models.AIAgent
|
|
for i := range agents {
|
|
if agents[i].ID == req.EntityID && agents[i].Status != enums.StatusDeleted {
|
|
target = &agents[i]
|
|
}
|
|
}
|
|
if target == nil {
|
|
return nil, errorsx.InvalidParamI18n("error.setup.targetUnavailable")
|
|
}
|
|
if target.Status != enums.StatusOk || target.RolloutPercent <= 0 {
|
|
plan.warnings = append(plan.warnings, "disabled_configuration_preserved")
|
|
} else {
|
|
if err := require(constants.PermissionAIAgentUpdate.Code); err != nil {
|
|
return nil, err
|
|
}
|
|
before, after := map[string]any{}, map[string]any{}
|
|
if !target.AutoHandoffOnFailure {
|
|
before["auto_handoff_on_failure"], after["auto_handoff_on_failure"] = false, true
|
|
}
|
|
if target.ServiceMode == enums.IMConversationServiceModeAIOnly {
|
|
before["service_mode"], after["service_mode"] = target.ServiceMode, enums.IMConversationServiceModeAIFirst
|
|
}
|
|
// Team membership and AI configuration need deliberate selection in
|
|
// the existing editor, not expansion to every active team.
|
|
if len(utils.SplitInt64s(target.TeamIDs)) == 0 {
|
|
plan.warnings = append(plan.warnings, "agent_team_selection_required")
|
|
}
|
|
update(&models.AIAgent{}, "ai_agent", target.ID, target.Name, before, after, target.UpdatedAt)
|
|
}
|
|
}
|
|
}
|
|
|
|
if all || req.Action == QuickFixActionEnsureDefaultChannel {
|
|
nonDeletedChannels := 0
|
|
for _, channel := range channels {
|
|
if channel.Status != enums.StatusDeleted {
|
|
nonDeletedChannels++
|
|
}
|
|
}
|
|
if nonDeletedChannels == 0 && req.EntityID == 0 && agent != nil && agent.PublishedRevisionID > 0 {
|
|
if err := require(constants.PermissionChannelCreate.Code); err != nil {
|
|
return nil, err
|
|
}
|
|
channel := &models.Channel{Name: "Default Web Support Channel", ChannelType: string(enums.ChannelTypeWeb), ChannelID: fmt.Sprintf("web_setup_%d_%d", operator.UserID, len(channels)+1), AIAgentRolloutPercent: 100, ConfigJSON: `{"position":"right"}`, Status: enums.StatusOk, AuditFields: audit}
|
|
agentRef := any(agent.ID)
|
|
if agent.ID == 0 {
|
|
agentRef = agent.Name
|
|
}
|
|
add("channel", 0, channel.Name, "create", nil, map[string]any{"name": channel.Name, "channel_id": channel.ChannelID, "channel_type": channel.ChannelType, "ai_agent_id": agentRef, "status": enums.StatusOk, "ai_agent_rollout_percent": 100, "config_json": channel.ConfigJSON}, func(tx *gorm.DB) error {
|
|
channel.AIAgentID = agent.ID
|
|
return setupPersistenceError(tx.Create(channel).Error)
|
|
})
|
|
} else if nonDeletedChannels == 0 && req.EntityID == 0 {
|
|
// Match the normal channel editor's publication guard. Publishing an
|
|
// agent is a separate reviewed workflow, never implicit in setup.
|
|
plan.warnings = append(plan.warnings, "agent_publication_required")
|
|
} else if !all {
|
|
if req.EntityID <= 0 {
|
|
return nil, errorsx.InvalidParamI18n("error.setup.targetRequired")
|
|
}
|
|
var target *models.Channel
|
|
for i := range channels {
|
|
if channels[i].ID == req.EntityID && channels[i].Status != enums.StatusDeleted {
|
|
target = &channels[i]
|
|
}
|
|
}
|
|
if target == nil {
|
|
return nil, errorsx.InvalidParamI18n("error.setup.targetUnavailable")
|
|
}
|
|
// Never replace an explicitly configured channel route or rollout with
|
|
// the first agent in the database. Use the existing channel editor.
|
|
plan.warnings = append(plan.warnings, "channel_selection_required")
|
|
}
|
|
}
|
|
if all {
|
|
plan.warnings = append(plan.warnings, "existing_configuration_preserved")
|
|
}
|
|
return plan, nil
|
|
}
|
|
|
|
func extractProfileUserIDs(profiles []models.AgentProfile) []int64 {
|
|
ids := make([]int64, 0, len(profiles))
|
|
seen := make(map[int64]bool, len(profiles))
|
|
for _, p := range profiles {
|
|
if p.UserID > 0 && !seen[p.UserID] {
|
|
seen[p.UserID] = true
|
|
ids = append(ids, p.UserID)
|
|
}
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, v := range values {
|
|
trimmed := strings.TrimSpace(v)
|
|
if trimmed != "" {
|
|
return trimmed
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func supportSetupSkipReason(reason string) string {
|
|
switch reason {
|
|
case "eligible":
|
|
return ""
|
|
case "outside_hours":
|
|
return "team_off_hours"
|
|
case "team_disabled":
|
|
return "no_team"
|
|
case "auto_assign_disabled":
|
|
return "auto_assign_disabled"
|
|
case "busy":
|
|
return "status_busy"
|
|
case "capacity":
|
|
return "at_capacity"
|
|
default:
|
|
return reason
|
|
}
|
|
}
|
|
func supportSetupOperatorIssue(reason string) string {
|
|
switch reason {
|
|
case "profile_disabled":
|
|
return "operator_profile_disabled"
|
|
case "user_disabled":
|
|
return "operator_user_disabled"
|
|
case "team_disabled":
|
|
return "operator_no_active_team"
|
|
case "auto_assign_disabled":
|
|
return "operator_auto_assign_off"
|
|
case "busy":
|
|
return "operator_busy"
|
|
case "capacity":
|
|
return "operator_at_capacity"
|
|
default:
|
|
return "operator_" + reason
|
|
}
|
|
}
|