Files
agent-desk-ng/internal/services/setup_readiness_service.go
mono 63e0457886 fix: harden identity, live access, drafts and setup (review only)
Source-reviewed hardening changes and regression tests. UNBUILT / UNTESTED: not a deployable release. Go formatting, compilation, automated regression tests, full frontend validation and staging acceptance remain required. See docs/hardening/VALIDATION.md. Main remains the original clean snapshot; no upstream Git history imported.
2026-10-16 12:15:20 +00:00

1413 lines
51 KiB
Go

// 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
if sch.Status != enums.StatusOk {
continue
}
if !sch.StartAt.After(now) && sch.EndAt.After(now) {
activeScheduleNowTeamSet[sch.TeamID] = true
}
}
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)
}
if p.Status != enums.StatusOk {
issues = append(issues, response.SetupIssueItem{
Code: "operator_profile_disabled",
Severity: "warning",
EntityType: "operator",
EntityID: p.ID,
EntityName: displayName,
QuickFixAction: QuickFixActionEnableMyProfile,
})
continue
}
if _, userOk := activeUserMap[p.UserID]; !userOk {
issues = append(issues, response.SetupIssueItem{
Code: "operator_user_disabled",
Severity: "warning",
EntityType: "operator",
EntityID: p.ID,
EntityName: displayName,
})
continue
}
if _, teamOk := activeTeamMap[p.TeamID]; !teamOk {
issues = append(issues, response.SetupIssueItem{
Code: "operator_no_active_team",
Severity: "error",
EntityType: "operator",
EntityID: p.ID,
EntityName: displayName,
QuickFixAction: QuickFixActionEnableMyProfile,
})
continue
}
if !p.AutoAssignEnabled {
issues = append(issues, response.SetupIssueItem{
Code: "operator_auto_assign_off",
Severity: "warning",
EntityType: "operator",
EntityID: p.ID,
EntityName: displayName,
QuickFixAction: QuickFixActionEnableMyProfile,
})
continue
}
if p.ServiceStatus != enums.ServiceStatusIdle {
issues = append(issues, response.SetupIssueItem{
Code: "operator_busy",
Severity: "warning",
EntityType: "operator",
EntityID: p.ID,
EntityName: displayName,
QuickFixAction: QuickFixActionEnableMyProfile,
})
continue
}
activeCount := activeCounts[p.UserID]
if p.MaxConcurrentCount <= 0 || activeCount >= p.MaxConcurrentCount {
issues = append(issues, response.SetupIssueItem{
Code: "operator_at_capacity",
Severity: "warning",
EntityType: "operator",
EntityID: p.ID,
EntityName: displayName,
QuickFixAction: QuickFixActionEnableMyProfile,
})
continue
}
readyCount++
if activeScheduleNowTeamSet[p.TeamID] {
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 !diag.ProfileStatusOk {
diag.BlockerCodes = append(diag.BlockerCodes, "profile_disabled")
}
if team, ok := activeTeamMap[matched.TeamID]; ok {
diag.TeamName = team.Name
diag.TeamActive = true
diag.TeamHasScheduleNow = activeScheduleNowTeamSet[team.ID]
if !diag.TeamHasScheduleNow {
diag.BlockerCodes = append(diag.BlockerCodes, "team_off_hours")
}
} else {
diag.BlockerCodes = append(diag.BlockerCodes, "no_team")
}
if !matched.AutoAssignEnabled {
diag.BlockerCodes = append(diag.BlockerCodes, "auto_assign_disabled")
}
if matched.ServiceStatus != enums.ServiceStatusIdle {
diag.BlockerCodes = append(diag.BlockerCodes, "status_busy")
}
if matched.MaxConcurrentCount <= 0 || int(diag.ActiveConversationCount) >= matched.MaxConcurrentCount {
diag.BlockerCodes = append(diag.BlockerCodes, "at_capacity")
}
diag.CanReceiveNow = len(diag.BlockerCodes) == 0
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)
if err := db.Model(&models.AgentTeamSchedule{}).Distinct("team_id").Where("team_id IN ? AND status = ? AND start_at <= ? AND end_at > ?", enabledTeamIDs, enums.StatusOk, now, now).Pluck("team_id", &activeScheduleTeamIDs).Error; err != nil { return nil, setupPersistenceError(err) }
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)
}
skipReason := ""
switch {
case p.Status != enums.StatusOk:
skipReason = "profile_disabled"
case !hasUser || u.Status != enums.StatusOk || u.DeletedAt != nil:
skipReason = "user_disabled"
case enabledTeamIDSet[p.TeamID].ID == 0:
skipReason = "team_not_in_agent"
case !activeScheduleTeamSet[p.TeamID]:
skipReason = "team_off_hours"
case !p.AutoAssignEnabled:
skipReason = "auto_assign_off"
case p.ServiceStatus != enums.ServiceStatusIdle:
skipReason = "status_busy"
case p.MaxConcurrentCount <= 0 || activeCounts[p.UserID] >= p.MaxConcurrentCount:
skipReason = "at_capacity"
}
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(p.ServiceStatus),
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.ServiceStatusIdle {
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 ""
}