diff --git a/cmd/teams.go b/cmd/teams.go index 746cc36..b8b66ce 100644 --- a/cmd/teams.go +++ b/cmd/teams.go @@ -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.") diff --git a/frontend/src/components/admin/team/teams/TeamForm.vue b/frontend/src/components/admin/team/teams/TeamForm.vue index 797684b..8d45bcc 100644 --- a/frontend/src/components/admin/team/teams/TeamForm.vue +++ b/frontend/src/components/admin/team/teams/TeamForm.vue @@ -20,13 +20,14 @@ - Select an unique name. + Select an unique name for the team. + Auto assignment type + + + 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. + + + + + Timezone @@ -105,7 +121,11 @@ - + {{ sla.label }} diff --git a/frontend/src/components/admin/team/teams/teamFormSchema.js b/frontend/src/components/admin/team/teams/teamFormSchema.js index 5f51e14..536746e 100644 --- a/frontend/src/components/admin/team/teams/teamFormSchema.js +++ b/frontend/src/components/admin/team/teams/teamFormSchema.js @@ -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(), diff --git a/frontend/src/composables/useConversationFilters.js b/frontend/src/composables/useConversationFilters.js index db7f0bf..9865efb 100644 --- a/frontend/src/composables/useConversationFilters.js +++ b/frontend/src/composables/useConversationFilters.js @@ -47,7 +47,7 @@ export function useConversationFilters () { })) const newConversationFilters = computed(() => ({ - email: { + contact_email: { label: 'Email', type: FIELD_TYPE.TEXT, operators: FIELD_OPERATORS.TEXT diff --git a/internal/autoassigner/autoassigner.go b/internal/autoassigner/autoassigner.go index 44913f9..8a349bf 100644 --- a/internal/autoassigner/autoassigner.go +++ b/internal/autoassigner/autoassigner.go @@ -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 diff --git a/internal/automation/evaluator.go b/internal/automation/evaluator.go index 756270d..2dcb891 100644 --- a/internal/automation/evaluator.go +++ b/internal/automation/evaluator.go @@ -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 { diff --git a/internal/automation/models/models.go b/internal/automation/models/models.go index 27e188e..62bd134 100644 --- a/internal/automation/models/models.go +++ b/internal/automation/models/models.go @@ -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" diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index 0e0a133..67ff917 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -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 { diff --git a/internal/conversation/queries.sql b/internal/conversation/queries.sql index 9855c32..e2daca6 100644 --- a/internal/conversation/queries.sql +++ b/internal/conversation/queries.sql @@ -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), diff --git a/internal/team/models/models.go b/internal/team/models/models.go index e6d3170..b542dd1 100644 --- a/internal/team/models/models.go +++ b/internal/team/models/models.go @@ -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 diff --git a/internal/team/queries.sql b/internal/team/queries.sql index c954ccd..a32dd49 100644 --- a/internal/team/queries.sql +++ b/internal/team/queries.sql @@ -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 ( diff --git a/internal/team/team.go b/internal/team/team.go index 709a57c..7f3b3e0 100644 --- a/internal/team/team.go +++ b/internal/team/team.go @@ -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) } diff --git a/schema.sql b/schema.sql index d7193c6..994bf47 100644 --- a/schema.sql +++ b/schema.sql @@ -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,