feat: harden support, localize knowledge and add operator availability [skip ci]

Source-only publication of reviewed commit 64e96a7f423673dc29ad56485be9fc79bce8af1c.

Passed in isolation: focused frontend/backend tests, race checks, TypeScript typecheck, lint, Go compilation, Flowgram build, and deterministic SDK build/tests.

Not accepted as a production release: Next.js export/native assembly remains blocked by environment memory limits; browser interaction and MySQL staging acceptance remain unverified. No production server, database/schema, runtime configuration, deployment, release tag, or workflow execution was performed.

Expected source tree: 0a4a7528e9.
This commit is contained in:
2026-10-16 15:21:42 +00:00
parent 299ea50fb5
commit 2f7a491666
176 changed files with 11869 additions and 2716 deletions

View File

@@ -1,3 +1,6 @@
// Modified in AgentDeskNext: authenticated support availability and calendars.
// Modified in the independent hardening fork; see docs/hardening.
package services
import (
@@ -317,17 +320,16 @@ func (s *conversationService) CreateNew(externalUser openidentity.ExternalUser,
// activity first, so the widget can offer a request list. A visitor who has not
// talked to us yet has no identity row and therefore an empty list, not an error.
//
// Scoping is by customer, not by channel: a CustomerIdentity is keyed by
// (external_source, external_id) globally, so one signed visitor reaching us
// through two channels is one person with one request history. For a guest the
// external id is issued per channel, so their identities cannot collide across
// channels anyway. Narrowing this to the current channel would silently hide the
// visitor's earlier requests.
// Public identities are scoped to a server-verified issuer and channel. Legacy
// internal integrations may still use their explicitly unscoped identity mapping.
func (s *conversationService) ListCustomerConversations(externalUser openidentity.ExternalUser, cnd *sqls.Cnd) ([]models.Conversation, *sqls.Paging, error) {
if cnd == nil {
cnd = sqls.NewCnd()
}
identity := repositories.CustomerIdentityRepository.GetBy(sqls.DB(), externalUser.ExternalSource, strings.TrimSpace(externalUser.ExternalID))
identity, err := repositories.CustomerIdentityRepository.GetUniqueBy(sqls.DB(), externalUser.IdentityScope, externalUser.ExternalSource, strings.TrimSpace(externalUser.ExternalID))
if err != nil {
return nil, nil, errorsx.UnauthorizedI18n("error.e0161")
}
if identity == nil {
paging := &sqls.Paging{}
if cnd.Paging != nil {
@@ -336,6 +338,9 @@ func (s *conversationService) ListCustomerConversations(externalUser openidentit
return []models.Conversation{}, paging, nil
}
cnd.Eq("customer_id", identity.CustomerID).Desc("last_active_at").Desc("id")
if externalUser.ChannelID > 0 {
cnd.Eq("channel_id", externalUser.ChannelID)
}
list, paging := repositories.ConversationRepository.FindPageByCnd(sqls.DB(), cnd)
if list == nil {
list = []models.Conversation{}
@@ -353,6 +358,10 @@ func (s *conversationService) AssignConversation(req request.AssignConversationR
}
var assignedEvent events.ConversationAssignedEvent
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
if _, err := repositories.SupportAvailabilityRepository.LockProfile(ctx.Tx, req.AssigneeID); err != nil {
return errorsx.InvalidParamI18n("error.supportAvailability.unavailable")
}
conversation := repositories.ConversationRepository.Get(ctx.Tx, req.ConversationID)
if conversation == nil {
return errorsx.InvalidParamI18n("error.e0116")
@@ -361,14 +370,23 @@ func (s *conversationService) AssignConversation(req request.AssignConversationR
return errorsx.InvalidParamI18n("error.e0135")
}
now := time.Now()
targetProfile, err := SupportAvailabilityService.GuardAssignment(ctx.Tx, req.AssigneeID, false, conversation, now)
if err != nil {
if err == errSupportAssignmentIneligible {
return errorsx.InvalidParamI18n("error.supportAvailability.unavailable")
}
return err
}
if err := ConversationAssignmentService.FinishActiveAssignments(ctx, req.ConversationID, now); err != nil {
return err
}
if err := ConversationAssignmentService.CreateAssignment(ctx, req.ConversationID, conversation.CurrentAssigneeID, req.AssigneeID, enums.IMAssignmentTypeAssign, req.Reason, operator, now); err != nil {
return err
}
if err := repositories.ConversationRepository.Updates(ctx.Tx, req.ConversationID, map[string]any{
if err := repositories.SupportAvailabilityRepository.AssignConversation(ctx.Tx, conversation, map[string]any{
"current_assignee_id": req.AssigneeID,
"current_team_id": targetProfile.TeamID,
"status": enums.IMConversationStatusActive,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
@@ -447,6 +465,10 @@ func (s *conversationService) TransferConversation(conversationID, toUserID int6
}
var assignedEvent events.ConversationAssignedEvent
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
if _, err := repositories.SupportAvailabilityRepository.LockProfile(ctx.Tx, toUserID); err != nil {
return errorsx.InvalidParamI18n("error.supportAvailability.unavailable")
}
conversation := repositories.ConversationRepository.Get(ctx.Tx, conversationID)
if conversation == nil {
return errorsx.InvalidParamI18n("error.e0116")
@@ -464,14 +486,23 @@ func (s *conversationService) TransferConversation(conversationID, toUserID int6
return errorsx.InvalidParamI18n("error.e0277")
}
now := time.Now()
targetProfile, err := SupportAvailabilityService.GuardAssignment(ctx.Tx, toUserID, false, conversation, now)
if err != nil {
if err == errSupportAssignmentIneligible {
return errorsx.InvalidParamI18n("error.supportAvailability.unavailable")
}
return err
}
if err := ConversationAssignmentService.FinishActiveAssignments(ctx, conversationID, now); err != nil {
return err
}
if err := ConversationAssignmentService.CreateAssignment(ctx, conversationID, conversation.CurrentAssigneeID, toUserID, enums.IMAssignmentTypeTransfer, reason, operator, now); err != nil {
return err
}
if err := repositories.ConversationRepository.Updates(ctx.Tx, conversationID, map[string]any{
if err := repositories.SupportAvailabilityRepository.AssignConversation(ctx.Tx, conversation, map[string]any{
"current_assignee_id": toUserID,
"current_team_id": targetProfile.TeamID,
"status": enums.IMConversationStatusActive,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
@@ -832,8 +863,11 @@ func (s *conversationService) IsCustomerConversationOwner(conversation *models.C
if extID == "" || strings.TrimSpace(string(externalUser.ExternalSource)) == "" || conversation.CustomerID <= 0 {
return false
}
identity := repositories.CustomerIdentityRepository.GetBy(sqls.DB(), externalUser.ExternalSource, extID)
if identity == nil {
if externalUser.ChannelID > 0 && externalUser.ChannelID != conversation.ChannelID {
return false
}
identity, err := repositories.CustomerIdentityRepository.GetUniqueBy(sqls.DB(), externalUser.IdentityScope, externalUser.ExternalSource, extID)
if err != nil || identity == nil {
return false
}
return identity.CustomerID == conversation.CustomerID