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 auto-assigned conversations
+
+
+
+
+ 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,