feat: max active conversation auto assignement limits to agents

This commit is contained in:
Abhinav Raut
2025-01-25 16:40:11 +05:30
parent fddd40bd11
commit 140dae305c
13 changed files with 111 additions and 52 deletions

View File

@@ -52,15 +52,16 @@ func handleGetTeam(r *fastglue.Request) error {
// handleCreateTeam creates a new team.
func handleCreateTeam(r *fastglue.Request) error {
var (
app = r.Context.(*App)
name = string(r.RequestCtx.PostArgs().Peek("name"))
timezone = string(r.RequestCtx.PostArgs().Peek("timezone"))
emoji = string(r.RequestCtx.PostArgs().Peek("emoji"))
conversationAssignmentType = string(r.RequestCtx.PostArgs().Peek("conversation_assignment_type"))
businessHrsID, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("business_hours_id")))
slaPolicyID, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("sla_policy_id")))
app = r.Context.(*App)
name = string(r.RequestCtx.PostArgs().Peek("name"))
timezone = string(r.RequestCtx.PostArgs().Peek("timezone"))
emoji = string(r.RequestCtx.PostArgs().Peek("emoji"))
conversationAssignmentType = string(r.RequestCtx.PostArgs().Peek("conversation_assignment_type"))
businessHrsID, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("business_hours_id")))
slaPolicyID, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("sla_policy_id")))
maxAutoAssignedConversations, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("max_auto_assigned_conversations")))
)
if err := app.team.Create(name, timezone, conversationAssignmentType, null.NewInt(businessHrsID, businessHrsID != 0), null.NewInt(slaPolicyID, slaPolicyID != 0), emoji); err != nil {
if err := app.team.Create(name, timezone, conversationAssignmentType, null.NewInt(businessHrsID, businessHrsID != 0), null.NewInt(slaPolicyID, slaPolicyID != 0), emoji, maxAutoAssignedConversations); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Team created successfully.")
@@ -69,19 +70,20 @@ func handleCreateTeam(r *fastglue.Request) error {
// handleUpdateTeam updates an existing team.
func handleUpdateTeam(r *fastglue.Request) error {
var (
app = r.Context.(*App)
name = string(r.RequestCtx.PostArgs().Peek("name"))
timezone = string(r.RequestCtx.PostArgs().Peek("timezone"))
emoji = string(r.RequestCtx.PostArgs().Peek("emoji"))
conversationAssignmentType = string(r.RequestCtx.PostArgs().Peek("conversation_assignment_type"))
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
businessHrsID, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("business_hours_id")))
slaPolicyID, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("sla_policy_id")))
app = r.Context.(*App)
name = string(r.RequestCtx.PostArgs().Peek("name"))
timezone = string(r.RequestCtx.PostArgs().Peek("timezone"))
emoji = string(r.RequestCtx.PostArgs().Peek("emoji"))
conversationAssignmentType = string(r.RequestCtx.PostArgs().Peek("conversation_assignment_type"))
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
businessHrsID, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("business_hours_id")))
slaPolicyID, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("sla_policy_id")))
maxAutoAssignedConversations, _ = strconv.Atoi(string(r.RequestCtx.PostArgs().Peek("max_auto_assigned_conversations")))
)
if id < 1 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid team `id`", nil, envelope.InputError)
}
if err := app.team.Update(id, name, timezone, conversationAssignmentType, null.NewInt(businessHrsID, businessHrsID != 0), null.NewInt(slaPolicyID, slaPolicyID != 0), emoji); err != nil {
if err := app.team.Update(id, name, timezone, conversationAssignmentType, null.NewInt(businessHrsID, businessHrsID != 0), null.NewInt(slaPolicyID, slaPolicyID != 0), emoji, maxAutoAssignedConversations); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Team updated successfully.")

View File

@@ -20,13 +20,14 @@
<FormControl>
<Input type="text" placeholder="Name" v-bind="componentField" />
</FormControl>
<FormDescription>Select an unique name.</FormDescription>
<FormDescription>Select an unique name for the team.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField name="conversation_assignment_type" v-slot="{ componentField }">
<FormItem>
<FormLabel>Auto assignment type</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
@@ -49,6 +50,21 @@
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="max_auto_assigned_conversations">
<FormItem>
<FormLabel>Maximum auto-assigned conversations</FormLabel>
<FormControl>
<Input type="number" placeholder="0" v-bind="componentField" />
</FormControl>
<FormDescription>
Maximum number of active conversations that can be auto-assigned to an agent at once.
Conversations in "Resolved" or "Closed" states do not count toward this limit. Set to 0
for unlimited.
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="timezone">
<FormItem>
<FormLabel>Timezone</FormLabel>
@@ -105,7 +121,11 @@
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="sla in slaStore.options" :key="sla.value" :value="parseInt(sla.value)">
<SelectItem
v-for="sla in slaStore.options"
:key="sla.value"
:value="parseInt(sla.value)"
>
{{ sla.label }}
</SelectItem>
</SelectGroup>

View File

@@ -10,6 +10,7 @@ export const teamFormSchema = z.object({
}),
emoji: z.string({ required_error: 'Emoji is required.' }),
conversation_assignment_type: z.string({ required_error: 'Conversation assignment type is required.' }),
max_auto_assigned_conversations: z.coerce.number().optional().default(0),
timezone: z.string({ required_error: 'Timezone is required.' }),
business_hours_id: z.number().optional().nullable(),
sla_policy_id: z.number().optional().nullable(),

View File

@@ -47,7 +47,7 @@ export function useConversationFilters () {
}))
const newConversationFilters = computed(() => ({
email: {
contact_email: {
label: 'Email',
type: FIELD_TYPE.TEXT,
operators: FIELD_OPERATORS.TEXT

View File

@@ -27,6 +27,7 @@ const (
type conversationStore interface {
GetUnassignedConversations() ([]models.Conversation, error)
UpdateConversationUserAssignee(conversationUUID string, userID int, user umodels.User) error
ActiveUserConversationsCount(userID int) (int, error)
}
type teamStore interface {
@@ -37,10 +38,10 @@ type teamStore interface {
// Engine represents a manager for assigning unassigned conversations
// to team agents in a round-robin pattern.
type Engine struct {
// TODO: Implement a persistent store for the balancer.
roundRobinBalancer map[int]*balance.Balance
// Mutex to protect the balancer map
balanceMu sync.Mutex
balanceMu sync.Mutex
teamMaxAutoAssignments map[int]int
systemUser umodels.User
conversationStore conversationStore
@@ -55,10 +56,11 @@ type Engine struct {
// conversation manager, and logger.
func New(teamStore teamStore, conversationStore conversationStore, systemUser umodels.User, lo *logf.Logger) (*Engine, error) {
var e = Engine{
conversationStore: conversationStore,
teamStore: teamStore,
systemUser: systemUser,
lo: lo,
conversationStore: conversationStore,
teamStore: teamStore,
systemUser: systemUser,
lo: lo,
teamMaxAutoAssignments: make(map[int]int),
}
balancer, err := e.populateTeamBalancer()
if err != nil {
@@ -136,6 +138,7 @@ func (e *Engine) populateTeamBalancer() (map[int]*balance.Balance, error) {
for _, team := range teams {
if team.ConversationAssignmentType != AssignmentTypeRoundRobin {
e.lo.Warn("unsupported conversation assignment type", "team_id", team.ID, "type", team.ConversationAssignmentType)
continue
}
@@ -151,6 +154,9 @@ func (e *Engine) populateTeamBalancer() (map[int]*balance.Balance, error) {
}
balancer[team.ID].Add(strconv.Itoa(user.ID), 1)
}
// Set max auto assigned conversations for the team.
e.teamMaxAutoAssignments[team.ID] = team.MaxAutoAssignedConversations
}
return balancer, nil
}
@@ -169,9 +175,11 @@ func (e *Engine) assignConversations() error {
for _, conversation := range unassignedConversations {
// Get user from the pool.
userIDStr, err := e.getUserFromPool(conversation)
userIDStr, err := e.getUserFromPool(conversation.AssignedTeamID.Int)
if err != nil {
e.lo.Error("error fetching user from balancer pool", "conversation_uuid", conversation.UUID, "error", err)
if err != ErrTeamNotFound {
e.lo.Error("error fetching user from balancer pool", "conversation_uuid", conversation.UUID, "error", err)
}
continue
}
@@ -182,7 +190,20 @@ func (e *Engine) assignConversations() error {
continue
}
// Assign conversation.
// Get active conversations count for the user.
activeConversationsCount, err := e.conversationStore.ActiveUserConversationsCount(userID)
if err != nil {
e.lo.Error("error fetching active conversations count for user", "user_id", userID, "error", err)
continue
}
// Check if user has reached the max auto assigned conversations limit.
if activeConversationsCount >= e.teamMaxAutoAssignments[conversation.AssignedTeamID.Int] {
e.lo.Debug("user has reached max auto assigned conversations limit, skipping auto assignment", "user_id", userID, "user_active_conversations_count", activeConversationsCount, "max_auto_assigned_conversations", e.teamMaxAutoAssignments[conversation.AssignedTeamID.Int])
continue
}
// Assign conversation to user.
if err := e.conversationStore.UpdateConversationUserAssignee(conversation.UUID, userID, e.systemUser); err != nil {
e.lo.Error("error assigning conversation", "conversation_uuid", conversation.UUID, "error", err)
continue
@@ -192,13 +213,12 @@ func (e *Engine) assignConversations() error {
}
// getUserFromPool returns user ID from the team balancer pool.
func (e *Engine) getUserFromPool(conversation models.Conversation) (string, error) {
func (e *Engine) getUserFromPool(assignedTeamID int) (string, error) {
e.balanceMu.Lock()
defer e.balanceMu.Unlock()
pool, ok := e.roundRobinBalancer[conversation.AssignedTeamID.Int]
pool, ok := e.roundRobinBalancer[assignedTeamID]
if !ok {
e.lo.Warn("team not found in balancer", "team_id", conversation.AssignedTeamID.Int)
return "", ErrTeamNotFound
}
return pool.Get(), nil

View File

@@ -28,7 +28,6 @@ func (e *Engine) evalConversationRules(rules []models.Rule, conversation cmodels
for idx, group := range rule.Groups {
if len(group.Rules) == 0 {
e.lo.Debug("no rules found in group, skipping rule group evaluation", "group_num", idx+1, "conversation_uuid", conversation.UUID)
groupEvalResults = append(groupEvalResults, true)
continue
}
result := e.evaluateGroup(group.Rules, group.LogicalOp, conversation)
@@ -56,6 +55,7 @@ func (e *Engine) evalConversationRules(rules []models.Rule, conversation cmodels
// evaluateFinalResult computes the final result of multiple group evaluations
// based on the specified logical operator (AND/OR).
func evaluateFinalResult(results []bool, operator string) bool {
fmt.Println("GROUP RESULTS: ", results)
if operator == models.OperatorAnd {
for _, result := range results {
if !result {

View File

@@ -41,8 +41,8 @@ const (
ConversationAssignedTeam = "assigned_team"
ConversationHoursSinceCreated = "hours_since_created"
ConversationHoursSinceResolved = "hours_since_resolved"
ContactEmail = "contact_email"
ConversationInbox = "inbox"
ContactEmail = "contact_email"
EventConversationUserAssigned = "conversation.user.assigned"
EventConversationTeamAssigned = "conversation.team.assigned"

View File

@@ -170,6 +170,7 @@ type queries struct {
GetUnassignedConversations *sqlx.Stmt `query:"get-unassigned-conversations"`
GetConversations string `query:"get-conversations"`
GetConversationParticipants *sqlx.Stmt `query:"get-conversation-participants"`
GetUserActiveConversationsCount *sqlx.Stmt `query:"get-user-active-conversations-count"`
UpdateConversationFirstReplyAt *sqlx.Stmt `query:"update-conversation-first-reply-at"`
UpdateConversationAssigneeLastSeen *sqlx.Stmt `query:"update-conversation-assignee-last-seen"`
UpdateConversationAssignedUser *sqlx.Stmt `query:"update-conversation-assigned-user"`
@@ -354,6 +355,16 @@ func (c *Manager) ReOpenConversation(conversationUUID string, actor umodels.User
return nil
}
// ActiveUserConversationsCount returns the count of active conversations for a user. i.e. conversations not closed or resolved status.
func (c *Manager) ActiveUserConversationsCount(userID int) (int, error) {
var count int
if err := c.q.GetUserActiveConversationsCount.Get(&count, userID); err != nil {
c.lo.Error("error fetching active conversation count", "error", err)
return count, envelope.NewError(envelope.GeneralError, "Error fetching active conversation count", nil)
}
return count, nil
}
// UpdateConversationLastMessage updates the last message details for a conversation.
func (c *Manager) UpdateConversationLastMessage(convesationID int, conversationUUID, lastMessage string, lastMessageAt time.Time) error {
if _, err := c.q.UpdateConversationLastMessage.Exec(convesationID, conversationUUID, lastMessage, lastMessageAt); err != nil {

View File

@@ -184,6 +184,9 @@ SET status_id = (SELECT id FROM conversation_statuses WHERE name = $2),
updated_at = now()
WHERE uuid = $1;
-- name: get-user-active-conversations-count
SELECT COUNT(*) FROM conversations WHERE status_id IN (SELECT id FROM conversation_statuses WHERE name NOT IN ('Resolved', 'Closed')) and assigned_user_id = $1;
-- name: update-conversation-priority
UPDATE conversations
SET priority_id = (SELECT id FROM conversation_priorities WHERE name = $2),

View File

@@ -10,15 +10,16 @@ import (
)
type Team struct {
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Emoji null.String `db:"emoji" json:"emoji"`
Name string `db:"name" json:"name"`
ConversationAssignmentType string `db:"conversation_assignment_type" json:"conversation_assignment_type,omitempty"`
Timezone string `db:"timezone" json:"timezone,omitempty"`
BusinessHoursID null.Int `db:"business_hours_id" json:"business_hours_id,omitempty"`
SLAPolicyID null.Int `db:"sla_policy_id" json:"sla_policy_id,omitempty"`
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Emoji null.String `db:"emoji" json:"emoji"`
Name string `db:"name" json:"name"`
ConversationAssignmentType string `db:"conversation_assignment_type" json:"conversation_assignment_type,omitempty"`
Timezone string `db:"timezone" json:"timezone,omitempty"`
BusinessHoursID null.Int `db:"business_hours_id" json:"business_hours_id,omitempty"`
SLAPolicyID null.Int `db:"sla_policy_id" json:"sla_policy_id,omitempty"`
MaxAutoAssignedConversations int `db:"max_auto_assigned_conversations" json:"max_auto_assigned_conversations"`
}
type Teams []Team

View File

@@ -1,14 +1,14 @@
-- name: get-teams
SELECT id, emoji, created_at, updated_at, name, conversation_assignment_type, timezone from teams order by updated_at desc;
SELECT id, emoji, created_at, updated_at, name, conversation_assignment_type, timezone, max_auto_assigned_conversations from teams order by updated_at desc;
-- name: get-teams-compact
SELECT id, name, emoji from teams order by name;
-- name: get-user-teams
SELECT id, emoji, created_at, updated_at, name, conversation_assignment_type, timezone from teams WHERE id IN (SELECT team_id FROM team_members WHERE user_id = $1) order by updated_at desc;
SELECT id, emoji, created_at, updated_at, name, conversation_assignment_type, timezone, max_auto_assigned_conversations from teams WHERE id IN (SELECT team_id FROM team_members WHERE user_id = $1) order by updated_at desc;
-- name: get-team
SELECT id, emoji, name, conversation_assignment_type, timezone, business_hours_id, sla_policy_id from teams where id = $1;
SELECT id, emoji, name, conversation_assignment_type, timezone, business_hours_id, sla_policy_id, max_auto_assigned_conversations from teams where id = $1;
-- name: get-team-members
SELECT u.id, t.id as team_id
@@ -18,10 +18,10 @@ JOIN teams t ON t.id = tm.team_id
WHERE t.id = $1;
-- name: insert-team
INSERT INTO teams (name, timezone, conversation_assignment_type, business_hours_id, sla_policy_id, emoji) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id;
INSERT INTO teams (name, timezone, conversation_assignment_type, business_hours_id, sla_policy_id, emoji, max_auto_assigned_conversations) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id;
-- name: update-team
UPDATE teams set name = $2, timezone = $3, conversation_assignment_type = $4, business_hours_id = $5, sla_policy_id = $6, emoji = $7, updated_at = now() where id = $1;
UPDATE teams set name = $2, timezone = $3, conversation_assignment_type = $4, business_hours_id = $5, sla_policy_id = $6, emoji = $7, max_auto_assigned_conversations = $8, updated_at = now() where id = $1;
-- name: upsert-user-teams
WITH delete_old_teams AS (

View File

@@ -101,8 +101,8 @@ func (u *Manager) Get(id int) (models.Team, error) {
}
// Create creates a new team.
func (u *Manager) Create(name, timezone, conversationAssignmentType string, businessHrsID, slaPolicyID null.Int, emoji string) error {
if _, err := u.q.InsertTeam.Exec(name, timezone, conversationAssignmentType, businessHrsID, slaPolicyID, emoji); err != nil {
func (u *Manager) Create(name, timezone, conversationAssignmentType string, businessHrsID, slaPolicyID null.Int, emoji string, maxAutoAssignedConversations int) error {
if _, err := u.q.InsertTeam.Exec(name, timezone, conversationAssignmentType, businessHrsID, slaPolicyID, emoji, maxAutoAssignedConversations); err != nil {
if dbutil.IsUniqueViolationError(err) {
return envelope.NewError(envelope.GeneralError, "Team with the same name already exists", nil)
}
@@ -113,8 +113,8 @@ func (u *Manager) Create(name, timezone, conversationAssignmentType string, busi
}
// Update updates an existing team.
func (u *Manager) Update(id int, name, timezone, conversationAssignmentType string, businessHrsID, slaPolicyID null.Int, emoji string) error {
if _, err := u.q.UpdateTeam.Exec(id, name, timezone, conversationAssignmentType, businessHrsID, slaPolicyID, emoji); err != nil {
func (u *Manager) Update(id int, name, timezone, conversationAssignmentType string, businessHrsID, slaPolicyID null.Int, emoji string, maxAutoAssignedConversations int) error {
if _, err := u.q.UpdateTeam.Exec(id, name, timezone, conversationAssignmentType, businessHrsID, slaPolicyID, emoji, maxAutoAssignedConversations); err != nil {
u.lo.Error("error updating team", "error", err)
return envelope.NewError(envelope.GeneralError, "Error updating team", nil)
}

View File

@@ -37,6 +37,7 @@ CREATE TABLE teams (
"name" TEXT NOT NULL,
emoji TEXT NULL,
conversation_assignment_type conversation_assignment_type NOT NULL,
max_auto_assigned_conversations INT DEFAULT 0 NOT NULL,
business_hours_id INT REFERENCES business_hours(id) ON DELETE SET NULL ON UPDATE CASCADE NULL,
sla_policy_id INT REFERENCES sla_policies(id) ON DELETE SET NULL ON UPDATE CASCADE NULL,
timezone TEXT NULL,