diff --git a/cmd/handlers.go b/cmd/handlers.go
index b4e6725a..bc1372db 100644
--- a/cmd/handlers.go
+++ b/cmd/handlers.go
@@ -98,6 +98,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.PUT("/api/v1/users/me", auth(handleUpdateCurrentUser))
g.GET("/api/v1/users/me/teams", auth(handleGetCurrentUserTeams))
g.PUT("/api/v1/users/me/availability", auth(handleUpdateUserAvailability))
+ g.PUT("/api/v1/users/me/reassign-replies/toggle", auth(handleToggleReassignReplies))
g.DELETE("/api/v1/users/me/avatar", auth(handleDeleteAvatar))
g.GET("/api/v1/users/compact", auth(handleGetUsersCompact))
g.GET("/api/v1/users", perm(handleGetUsers, "users:manage"))
diff --git a/cmd/middlewares.go b/cmd/middlewares.go
index 7dcd1748..b578b956 100644
--- a/cmd/middlewares.go
+++ b/cmd/middlewares.go
@@ -11,8 +11,8 @@ import (
"github.com/zerodha/simplesessions/v3"
)
-// tryAuth is a middleware that attempts to authenticate the user and add them to the context
-// but doesn't enforce authentication. Handlers can check if user exists in context optionally.
+// tryAuth attempts to authenticate the user and add them to the context but doesn't enforce authentication.
+// Handlers can check if user exists in context optionally.
func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
app := r.Context.(*App)
@@ -41,7 +41,7 @@ func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
}
}
-// auth makes sure the user is logged in.
+// auth validates the session and adds the user to the request context.
func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
var app = r.Context.(*App)
@@ -69,7 +69,8 @@ func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
}
}
-// perm does session validation, CSRF, and permission enforcement.
+// perm matches the CSRF token and checks if the user has the required permission to access the endpoint.
+// and sets the user in the request context.
func perm(handler fastglue.FastRequestHandler, perm string) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
var (
diff --git a/cmd/upgrade.go b/cmd/upgrade.go
index 87c4f8ce..1b0e8dd2 100644
--- a/cmd/upgrade.go
+++ b/cmd/upgrade.go
@@ -33,6 +33,7 @@ var migList = []migFunc{
{"v0.3.0", migrations.V0_3_0},
{"v0.4.0", migrations.V0_4_0},
{"v0.5.0", migrations.V0_5_0},
+ {"v0.6.0", migrations.V0_6_0},
}
// upgrade upgrades the database to the current version by running SQL migration files
diff --git a/cmd/users.go b/cmd/users.go
index a797c755..eb57c09a 100644
--- a/cmd/users.go
+++ b/cmd/users.go
@@ -76,6 +76,19 @@ func handleUpdateUserAvailability(r *fastglue.Request) error {
return r.SendEnvelope(true)
}
+// handleToggleReassignReplies toggles the reassign replies setting for the current user.
+func handleToggleReassignReplies(r *fastglue.Request) error {
+ var (
+ app = r.Context.(*App)
+ auser = r.RequestCtx.UserValue("user").(amodels.User)
+ enabled = r.RequestCtx.PostArgs().GetBool("enabled")
+ )
+ if err := app.user.ToggleReassignReplies(auser.ID, enabled); err != nil {
+ return sendErrorEnvelope(r, err)
+ }
+ return r.SendEnvelope(true)
+}
+
// handleGetCurrentUserTeams returns the teams of a user.
func handleGetCurrentUserTeams(r *fastglue.Request) error {
var (
diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js
index 93e0dd6b..5f8bb9f6 100644
--- a/frontend/src/api/index.js
+++ b/frontend/src/api/index.js
@@ -276,6 +276,7 @@ const deleteView = (id) => http.delete(`/api/v1/views/me/${id}`)
const getAiPrompts = () => http.get('/api/v1/ai/prompts')
const aiCompletion = (data) => http.post('/api/v1/ai/completion', data)
const updateAIProvider = (data) => http.put('/api/v1/ai/provider', data)
+const toggleReassignReplies = (data) => http.put('/api/v1/users/me/reassign-replies/toggle', data)
export default {
login,
@@ -390,4 +391,5 @@ export default {
searchMessages,
searchContacts,
removeAssignee,
+ toggleReassignReplies,
}
diff --git a/frontend/src/components/sidebar/SidebarNavUser.vue b/frontend/src/components/sidebar/SidebarNavUser.vue
index ae537f9e..f4789978 100644
--- a/frontend/src/components/sidebar/SidebarNavUser.vue
+++ b/frontend/src/components/sidebar/SidebarNavUser.vue
@@ -46,17 +46,27 @@
{{ userStore.email }}
-
-
- {{ t('navigation.away') }}
-
-
userStore.updateUserAvailability(val ? 'away' : 'online')"
- />
+
+
+
+ {{ item.label }}
+
+
+
diff --git a/frontend/src/stores/user.js b/frontend/src/stores/user.js
index b643e0cc..212df5e9 100644
--- a/frontend/src/stores/user.js
+++ b/frontend/src/stores/user.js
@@ -17,7 +17,8 @@ export const useUserStore = defineStore('user', () => {
email: '',
teams: [],
permissions: [],
- availability_status: 'offline'
+ availability_status: 'offline',
+ reassign_replies: false
})
const emitter = useEmitter()
@@ -105,6 +106,22 @@ export const useUserStore = defineStore('user', () => {
}
}
+ const toggleAssignReplies = async (enabled) => {
+ const prev = user.value.reassign_replies
+ user.value.reassign_replies = enabled
+ try {
+ await api.toggleReassignReplies({
+ enabled: enabled
+ })
+ } catch (error) {
+ user.value.reassign_replies = prev
+ emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
+ variant: 'destructive',
+ description: handleHTTPError(error).message
+ })
+ }
+ }
+
return {
user,
userID,
@@ -123,6 +140,7 @@ export const useUserStore = defineStore('user', () => {
clearAvatar,
setAvatar,
updateUserAvailability,
+ toggleAssignReplies,
can
}
})
\ No newline at end of file
diff --git a/i18n/en.json b/i18n/en.json
index f9d07c67..74a83716 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -236,6 +236,7 @@
"navigation.views": "Views",
"navigation.edit": "Edit",
"navigation.delete": "Delete",
+ "navigation.reassign_replies": "Reassign replies",
"form.field.name": "Name",
"form.field.inbox": "Inbox",
"form.field.provider": "Provider",
diff --git a/i18n/mr.json b/i18n/mr.json
index c3e96130..5f75d444 100644
--- a/i18n/mr.json
+++ b/i18n/mr.json
@@ -236,6 +236,7 @@
"navigation.views": "दृश्ये",
"navigation.edit": "संपादित करा",
"navigation.delete": "हटवा",
+ "navigation.reassign_replies": "प्रतिसाद पुन्हा नियुक्त करा",
"form.field.name": "नाव",
"form.field.inbox": "इनबॉक्स",
"form.field.provider": "प्रदाता",
diff --git a/internal/autoassigner/autoassigner.go b/internal/autoassigner/autoassigner.go
index ce9f816f..9bd8a0b0 100644
--- a/internal/autoassigner/autoassigner.go
+++ b/internal/autoassigner/autoassigner.go
@@ -64,16 +64,12 @@ func New(teamStore teamStore, conversationStore conversationStore, systemUser um
teamMaxAutoAssignments: make(map[int]int),
roundRobinBalancer: make(map[int]*balance.Balance),
}
- if err := e.populateTeamBalancer(); err != nil {
- return nil, err
- }
return &e, nil
}
// Run initiates the conversation assignment process and is to be invoked as a goroutine.
// This function continuously assigns unassigned conversations to agents at regular intervals.
func (e *Engine) Run(ctx context.Context, autoAssignInterval time.Duration) {
- time.Sleep(2 * time.Second)
ticker := time.NewTicker(autoAssignInterval)
defer ticker.Stop()
@@ -159,8 +155,14 @@ func (e *Engine) populateTeamBalancer() error {
balancer := e.roundRobinBalancer[team.ID]
existingUsers := make(map[string]struct{})
-
for _, user := range users {
+ // Skip user if availability status is `away_manual`
+ if user.AvailabilityStatus == umodels.AwayManual {
+ e.lo.Debug("skipping user with away_manual status", "user_id", user.ID)
+ continue
+ }
+
+ // Add user to the balancer pool
uid := strconv.Itoa(user.ID)
existingUsers[uid] = struct{}{}
if err := balancer.Add(uid, 1); err != nil {
@@ -227,7 +229,7 @@ func (e *Engine) assignConversations() error {
teamMaxAutoAssignments := e.teamMaxAutoAssignments[conversation.AssignedTeamID.Int]
// Check if user has reached the max auto assigned conversations limit,
- // If the limit is set to 0, it means there is no limit.
+ // 0 is unlimited.
if teamMaxAutoAssignments != 0 {
if activeConversationsCount >= teamMaxAutoAssignments {
e.lo.Debug("user has reached max auto assigned conversations limit, skipping auto assignment", "user_id", userID,
diff --git a/internal/conversation/message.go b/internal/conversation/message.go
index 9fc607e1..0e454528 100644
--- a/internal/conversation/message.go
+++ b/internal/conversation/message.go
@@ -26,30 +26,6 @@ import (
)
const (
- MessageIncoming = "incoming"
- MessageOutgoing = "outgoing"
- MessageActivity = "activity"
-
- SenderTypeAgent = "agent"
- SenderTypeContact = "contact"
-
- MessageStatusPending = "pending"
- MessageStatusSent = "sent"
- MessageStatusFailed = "failed"
- MessageStatusReceived = "received"
-
- ActivityStatusChange = "status_change"
- ActivityPriorityChange = "priority_change"
- ActivityAssignedUserChange = "assigned_user_change"
- ActivityAssignedTeamChange = "assigned_team_change"
- ActivitySelfAssign = "self_assign"
- ActivityTagChange = "tag_change"
- ActivitySLASet = "sla_set"
-
- ContentTypeText = "text"
- ContentTypeHTML = "html"
-
- maxLastMessageLen = 45
maxMessagesPerPage = 100
)
@@ -154,7 +130,7 @@ func (m *Manager) sendOutgoingMessage(message models.Message) {
handleError := func(err error, errorMsg string) bool {
if err != nil {
m.lo.Error(errorMsg, "error", err, "message_id", message.ID)
- m.UpdateMessageStatus(message.UUID, MessageStatusFailed)
+ m.UpdateMessageStatus(message.UUID, models.MessageStatusFailed)
return true
}
return false
@@ -166,7 +142,7 @@ func (m *Manager) sendOutgoingMessage(message models.Message) {
return
}
- // Render content in template
+ // Render content template
if err := m.RenderContentInTemplate(inbox.Channel(), &message); err != nil {
handleError(err, "error rendering content in template")
return
@@ -209,7 +185,7 @@ func (m *Manager) sendOutgoingMessage(message models.Message) {
}
// Update status of the message.
- m.UpdateMessageStatus(message.UUID, MessageStatusSent)
+ m.UpdateMessageStatus(message.UUID, models.MessageStatusSent)
// Update first reply time if the sender is not the system user.
// All automated messages are sent by the system user.
@@ -315,7 +291,7 @@ func (m *Manager) UpdateMessageStatus(uuid string, status string) error {
// MarkMessageAsPending updates message status to `Pending`, so if it's a outgoing message it can be picked up again by a worker.
func (m *Manager) MarkMessageAsPending(uuid string) error {
- if err := m.UpdateMessageStatus(uuid, MessageStatusPending); err != nil {
+ if err := m.UpdateMessageStatus(uuid, models.MessageStatusPending); err != nil {
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil)
}
return nil
@@ -327,11 +303,11 @@ func (m *Manager) SendPrivateNote(media []mmodels.Media, senderID int, conversat
message := models.Message{
ConversationUUID: conversationUUID,
SenderID: senderID,
- Type: MessageOutgoing,
- SenderType: SenderTypeAgent,
- Status: MessageStatusSent,
+ Type: models.MessageOutgoing,
+ SenderType: models.SenderTypeAgent,
+ Status: models.MessageStatusSent,
Content: content,
- ContentType: ContentTypeHTML,
+ ContentType: models.ContentTypeHTML,
Private: true,
Media: media,
}
@@ -369,11 +345,11 @@ func (m *Manager) SendReply(media []mmodels.Media, inboxID, senderID int, conver
message := models.Message{
ConversationUUID: conversationUUID,
SenderID: senderID,
- Type: MessageOutgoing,
- SenderType: SenderTypeAgent,
- Status: MessageStatusPending,
+ Type: models.MessageOutgoing,
+ SenderType: models.SenderTypeAgent,
+ Status: models.MessageStatusPending,
Content: content,
- ContentType: ContentTypeHTML,
+ ContentType: models.ContentTypeHTML,
Private: false,
Media: media,
Meta: string(metaJSON),
@@ -386,7 +362,7 @@ func (m *Manager) SendReply(media []mmodels.Media, inboxID, senderID int, conver
func (m *Manager) InsertMessage(message *models.Message) error {
// Private message is always sent.
if message.Private {
- message.Status = MessageStatusSent
+ message.Status = models.MessageStatusSent
}
// Handle empty meta.
@@ -432,7 +408,7 @@ func (m *Manager) InsertMessage(message *models.Message) error {
func (m *Manager) RecordAssigneeUserChange(conversationUUID string, assigneeID int, actor umodels.User) error {
// Self assignment.
if assigneeID == actor.ID {
- return m.InsertConversationActivity(ActivitySelfAssign, conversationUUID, actor.FullName(), actor)
+ return m.InsertConversationActivity(models.ActivitySelfAssign, conversationUUID, actor.FullName(), actor)
}
// Assignment to another user.
@@ -440,7 +416,7 @@ func (m *Manager) RecordAssigneeUserChange(conversationUUID string, assigneeID i
if err != nil {
return err
}
- return m.InsertConversationActivity(ActivityAssignedUserChange, conversationUUID, assignee.FullName(), actor)
+ return m.InsertConversationActivity(models.ActivityAssignedUserChange, conversationUUID, assignee.FullName(), actor)
}
// RecordAssigneeTeamChange records an activity for a team assignee change.
@@ -449,27 +425,27 @@ func (m *Manager) RecordAssigneeTeamChange(conversationUUID string, teamID int,
if err != nil {
return err
}
- return m.InsertConversationActivity(ActivityAssignedTeamChange, conversationUUID, team.Name, actor)
+ return m.InsertConversationActivity(models.ActivityAssignedTeamChange, conversationUUID, team.Name, actor)
}
// RecordPriorityChange records an activity for a priority change.
func (m *Manager) RecordPriorityChange(priority, conversationUUID string, actor umodels.User) error {
- return m.InsertConversationActivity(ActivityPriorityChange, conversationUUID, priority, actor)
+ return m.InsertConversationActivity(models.ActivityPriorityChange, conversationUUID, priority, actor)
}
// RecordStatusChange records an activity for a status change.
func (m *Manager) RecordStatusChange(status, conversationUUID string, actor umodels.User) error {
- return m.InsertConversationActivity(ActivityStatusChange, conversationUUID, status, actor)
+ return m.InsertConversationActivity(models.ActivityStatusChange, conversationUUID, status, actor)
}
// RecordSLASet records an activity for an SLA set.
func (m *Manager) RecordSLASet(conversationUUID string, slaName string, actor umodels.User) error {
- return m.InsertConversationActivity(ActivitySLASet, conversationUUID, slaName, actor)
+ return m.InsertConversationActivity(models.ActivitySLASet, conversationUUID, slaName, actor)
}
// RecordTagChange records an activity for a tag change.
func (m *Manager) RecordTagChange(conversationUUID string, tag string, actor umodels.User) error {
- return m.InsertConversationActivity(ActivityTagChange, conversationUUID, tag, actor)
+ return m.InsertConversationActivity(models.ActivityTagChange, conversationUUID, tag, actor)
}
// InsertConversationActivity inserts an activity message.
@@ -481,14 +457,14 @@ func (m *Manager) InsertConversationActivity(activityType, conversationUUID, new
}
message := models.Message{
- Type: MessageActivity,
- Status: MessageStatusSent,
+ Type: models.MessageActivity,
+ Status: models.MessageStatusSent,
Content: content,
- ContentType: ContentTypeText,
+ ContentType: models.ContentTypeText,
ConversationUUID: conversationUUID,
Private: true,
SenderID: actor.ID,
- SenderType: SenderTypeAgent,
+ SenderType: models.SenderTypeAgent,
}
if err := m.InsertMessage(&message); err != nil {
@@ -512,19 +488,19 @@ func (m *Manager) getConversationUUIDFromMessageUUID(uuid string) (string, error
func (m *Manager) getMessageActivityContent(activityType, newValue, actorName string) (string, error) {
var content = ""
switch activityType {
- case ActivityAssignedUserChange:
+ case models.ActivityAssignedUserChange:
content = fmt.Sprintf("Assigned to %s by %s", newValue, actorName)
- case ActivityAssignedTeamChange:
+ case models.ActivityAssignedTeamChange:
content = fmt.Sprintf("Assigned to %s team by %s", newValue, actorName)
- case ActivitySelfAssign:
+ case models.ActivitySelfAssign:
content = fmt.Sprintf("%s self-assigned this conversation", actorName)
- case ActivityPriorityChange:
+ case models.ActivityPriorityChange:
content = fmt.Sprintf("%s set priority to %s", actorName, newValue)
- case ActivityStatusChange:
+ case models.ActivityStatusChange:
content = fmt.Sprintf("%s marked the conversation as %s", actorName, newValue)
- case ActivityTagChange:
+ case models.ActivityTagChange:
content = fmt.Sprintf("%s added tag %s", actorName, newValue)
- case ActivitySLASet:
+ case models.ActivitySLASet:
content = fmt.Sprintf("%s set %s SLA", actorName, newValue)
default:
return "", fmt.Errorf("invalid activity type %s", activityType)
diff --git a/internal/conversation/models/models.go b/internal/conversation/models/models.go
index ab476642..3cda6f15 100644
--- a/internal/conversation/models/models.go
+++ b/internal/conversation/models/models.go
@@ -26,6 +26,29 @@ var (
AssignedConversations = "assigned"
UnassignedConversations = "unassigned"
TeamUnassignedConversations = "team_unassigned"
+
+ MessageIncoming = "incoming"
+ MessageOutgoing = "outgoing"
+ MessageActivity = "activity"
+
+ SenderTypeAgent = "agent"
+ SenderTypeContact = "contact"
+
+ MessageStatusPending = "pending"
+ MessageStatusSent = "sent"
+ MessageStatusFailed = "failed"
+ MessageStatusReceived = "received"
+
+ ActivityStatusChange = "status_change"
+ ActivityPriorityChange = "priority_change"
+ ActivityAssignedUserChange = "assigned_user_change"
+ ActivityAssignedTeamChange = "assigned_team_change"
+ ActivitySelfAssign = "self_assign"
+ ActivityTagChange = "tag_change"
+ ActivitySLASet = "sla_set"
+
+ ContentTypeText = "text"
+ ContentTypeHTML = "html"
)
type Conversation struct {
diff --git a/internal/conversation/queries.sql b/internal/conversation/queries.sql
index 7d6df59a..ea3f84e6 100644
--- a/internal/conversation/queries.sql
+++ b/internal/conversation/queries.sql
@@ -524,13 +524,25 @@ SET
WHERE uuid = $1;
-- name: re-open-conversation
--- Open conversation if it is not already open.
+-- Open conversation if it is not already open and remove the assigned user if the user has reassign_replies set to true.
UPDATE conversations
-SET status_id = (SELECT id FROM conversation_statuses WHERE name = 'Open'), snoozed_until = NULL,
- updated_at = now()
-WHERE uuid = $1 and status_id in (
+SET
+ status_id = (SELECT id FROM conversation_statuses WHERE name = 'Open'),
+ snoozed_until = NULL,
+ updated_at = now(),
+ assigned_user_id = CASE
+ WHEN EXISTS (
+ SELECT 1 FROM users
+ WHERE users.id = conversations.assigned_user_id
+ AND users.reassign_replies = TRUE
+ ) THEN NULL
+ ELSE assigned_user_id
+ END
+WHERE
+ uuid = $1
+ AND status_id IN (
SELECT id FROM conversation_statuses WHERE name NOT IN ('Open')
-)
+ )
-- name: delete-conversation
DELETE FROM conversations WHERE uuid = $1;
\ No newline at end of file
diff --git a/internal/inbox/channel/email/imap.go b/internal/inbox/channel/email/imap.go
index 8f09fe69..9bd176c1 100644
--- a/internal/inbox/channel/email/imap.go
+++ b/internal/inbox/channel/email/imap.go
@@ -9,9 +9,7 @@ import (
"time"
"github.com/abhinavxd/libredesk/internal/attachment"
- "github.com/abhinavxd/libredesk/internal/conversation"
"github.com/abhinavxd/libredesk/internal/conversation/models"
- "github.com/abhinavxd/libredesk/internal/user"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
@@ -210,7 +208,7 @@ func (e *Email) processEnvelope(ctx context.Context, client *imapclient.Client,
SourceChannel: null.NewString(e.Channel(), true),
SourceChannelID: null.NewString(env.From[0].Addr(), true),
Email: null.NewString(env.From[0].Addr(), true),
- Type: user.UserTypeContact,
+ Type: umodels.UserTypeContact,
}
// Set CC addresses in meta.
@@ -230,10 +228,10 @@ func (e *Email) processEnvelope(ctx context.Context, client *imapclient.Client,
incomingMsg := models.IncomingMessage{
Message: models.Message{
Channel: e.Channel(),
- SenderType: conversation.SenderTypeContact,
- Type: conversation.MessageIncoming,
+ SenderType: models.SenderTypeContact,
+ Type: models.MessageIncoming,
InboxID: inboxID,
- Status: conversation.MessageStatusReceived,
+ Status: models.MessageStatusReceived,
Subject: env.Subject,
SourceID: null.StringFrom(env.MessageID),
Meta: string(meta),
@@ -324,14 +322,14 @@ func (e *Email) processFullMessage(item imapclient.FetchItemDataBodySection, inc
// Set message content - prioritize combined HTML
if allHTML.Len() > 0 {
incomingMsg.Message.Content = allHTML.String()
- incomingMsg.Message.ContentType = conversation.ContentTypeHTML
+ incomingMsg.Message.ContentType = models.ContentTypeHTML
e.lo.Debug("extracted HTML content from parts", "message_id", incomingMsg.Message.SourceID.String, "content", incomingMsg.Message.Content)
} else if len(envelope.HTML) > 0 {
incomingMsg.Message.Content = envelope.HTML
- incomingMsg.Message.ContentType = conversation.ContentTypeHTML
+ incomingMsg.Message.ContentType = models.ContentTypeHTML
} else if len(envelope.Text) > 0 {
incomingMsg.Message.Content = envelope.Text
- incomingMsg.Message.ContentType = conversation.ContentTypeText
+ incomingMsg.Message.ContentType = models.ContentTypeText
}
e.lo.Debug("envelope HTML content", "message_id", incomingMsg.Message.SourceID.String, "content", incomingMsg.Message.Content)
diff --git a/internal/migrations/v0.6.0.go b/internal/migrations/v0.6.0.go
new file mode 100644
index 00000000..d0ecd8a7
--- /dev/null
+++ b/internal/migrations/v0.6.0.go
@@ -0,0 +1,18 @@
+package migrations
+
+import (
+ "github.com/jmoiron/sqlx"
+ "github.com/knadh/koanf/v2"
+ "github.com/knadh/stuffbin"
+)
+
+// V0_6_0 updates the database schema to v0.6.0.
+func V0_6_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
+ _, err := db.Exec(`
+ ALTER TABLE users ADD COLUMN IF NOT EXISTS reassign_replies BOOL DEFAULT FALSE NOT NULL;
+ `)
+ if err != nil {
+ return err
+ }
+ return nil
+}
diff --git a/internal/sla/sla.go b/internal/sla/sla.go
index 2485449a..57f7dd29 100644
--- a/internal/sla/sla.go
+++ b/internal/sla/sla.go
@@ -272,6 +272,7 @@ func (m *Manager) Run(ctx context.Context, evalInterval time.Duration) {
// SendNotifications picks scheduled SLA notifications from the database and sends them to agents as emails.
func (m *Manager) SendNotifications(ctx context.Context) error {
+ time.Sleep(10 * time.Second)
for {
select {
case <-ctx.Done():
diff --git a/internal/team/queries.sql b/internal/team/queries.sql
index c777cf47..efed9f82 100644
--- a/internal/team/queries.sql
+++ b/internal/team/queries.sql
@@ -11,7 +11,7 @@ SELECT id, emoji, created_at, updated_at, name, conversation_assignment_type, ti
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
+SELECT u.id, t.id as team_id, u.availability_status, u.reassign_replies
FROM users u
JOIN team_members tm ON tm.user_id = u.id
JOIN teams t ON t.id = tm.team_id
diff --git a/internal/user/models/models.go b/internal/user/models/models.go
index 15cf0447..5f46f43a 100644
--- a/internal/user/models/models.go
+++ b/internal/user/models/models.go
@@ -8,7 +8,14 @@ import (
"github.com/volatiletech/null/v9"
)
-var (
+const (
+ SystemUserEmail = "System"
+
+ // User types
+ UserTypeAgent = "agent"
+ UserTypeContact = "contact"
+
+ // User availability statuses
Online = "online"
Offline = "offline"
Away = "away"
@@ -28,6 +35,7 @@ type User struct {
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
Enabled bool `db:"enabled" json:"enabled"`
Password string `db:"password" json:"-"`
+ ReassignReplies bool `db:"reassign_replies" json:"reassign_replies"`
Roles pq.StringArray `db:"roles" json:"roles"`
Permissions pq.StringArray `db:"permissions" json:"permissions"`
Meta pq.StringArray `db:"meta" json:"meta"`
diff --git a/internal/user/queries.sql b/internal/user/queries.sql
index da4abed1..31d7ef6d 100644
--- a/internal/user/queries.sql
+++ b/internal/user/queries.sql
@@ -31,6 +31,7 @@ SELECT
u.first_name,
u.last_name,
u.availability_status,
+ u.reassign_replies,
array_agg(DISTINCT r.name) as roles,
COALESCE(
(SELECT json_agg(json_build_object('id', t.id, 'name', t.name, 'emoji', t.emoji))
@@ -134,4 +135,9 @@ WITH contact AS (
INSERT INTO contact_channels (contact_id, inbox_id, identifier)
VALUES ((SELECT id FROM contact), $6, $7)
ON CONFLICT (contact_id, inbox_id) DO UPDATE SET updated_at = now()
-RETURNING contact_id, id;
\ No newline at end of file
+RETURNING contact_id, id;
+
+-- name: set-reassign-replies
+UPDATE users
+SET reassign_replies = $2
+WHERE id = $1;
\ No newline at end of file
diff --git a/internal/user/user.go b/internal/user/user.go
index 5f3c192f..90648530 100644
--- a/internal/user/user.go
+++ b/internal/user/user.go
@@ -27,23 +27,18 @@ import (
"golang.org/x/crypto/bcrypt"
)
-const (
- systemUserEmail = "System"
- minSystemUserPassword = 10
- maxSystemUserPassword = 72
- UserTypeAgent = "agent"
- UserTypeContact = "contact"
-)
-
var (
//go:embed queries.sql
efs embed.FS
+ minPassword = 10
+ maxPassword = 72
+
// ErrPasswordTooLong is returned when the password passed to
// GenerateFromPassword is too long (i.e. > 72 bytes).
ErrPasswordTooLong = errors.New("password length exceeds 72 bytes")
- PasswordHint = fmt.Sprintf("Password must be %d-%d characters long should contain at least one uppercase letter, one lowercase letter, one number, and one special character.", minSystemUserPassword, maxSystemUserPassword)
+ PasswordHint = fmt.Sprintf("Password must be %d-%d characters long should contain at least one uppercase letter, one lowercase letter, one number, and one special character.", minPassword, maxPassword)
)
// Manager handles user-related operations.
@@ -72,6 +67,7 @@ type queries struct {
SoftDeleteUser *sqlx.Stmt `query:"soft-delete-user"`
SetUserPassword *sqlx.Stmt `query:"set-user-password"`
SetResetPasswordToken *sqlx.Stmt `query:"set-reset-password-token"`
+ SetReassignReplies *sqlx.Stmt `query:"set-reassign-replies"`
ResetPassword *sqlx.Stmt `query:"reset-password"`
InsertAgent *sqlx.Stmt `query:"insert-agent"`
InsertContact *sqlx.Stmt `query:"insert-contact"`
@@ -93,7 +89,7 @@ func New(i18n *i18n.I18n, opts Opts) (*Manager, error) {
// VerifyPassword authenticates an user by email and password.
func (u *Manager) VerifyPassword(email string, password []byte) (models.User, error) {
var user models.User
- if err := u.q.GetUser.Get(&user, 0, email, UserTypeAgent); err != nil {
+ if err := u.q.GetUser.Get(&user, 0, email, models.UserTypeAgent); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return user, envelope.NewError(envelope.InputError, u.i18n.T("user.invalidEmailPassword"), nil)
}
@@ -154,17 +150,17 @@ func (u *Manager) CreateAgent(user *models.User) error {
// GetAgent retrieves an agent by ID.
func (u *Manager) GetAgent(id int) (models.User, error) {
- return u.Get(id, UserTypeAgent)
+ return u.Get(id, models.UserTypeAgent)
}
// GetAgentByEmail retrieves an agent by email.
func (u *Manager) GetAgentByEmail(email string) (models.User, error) {
- return u.GetByEmail(email, UserTypeAgent)
+ return u.GetByEmail(email, models.UserTypeAgent)
}
// GetContact retrieves a contact by ID.
func (u *Manager) GetContact(id int) (models.User, error) {
- return u.Get(id, UserTypeContact)
+ return u.Get(id, models.UserTypeContact)
}
// Get retrieves an user by ID.
@@ -196,7 +192,7 @@ func (u *Manager) GetByEmail(email, type_ string) (models.User, error) {
// GetSystemUser retrieves the system user.
func (u *Manager) GetSystemUser() (models.User, error) {
- return u.GetByEmail(systemUserEmail, UserTypeAgent)
+ return u.GetByEmail(models.SystemUserEmail, models.UserTypeAgent)
}
// UpdateAvatar updates the user avatar.
@@ -297,6 +293,15 @@ func (u *Manager) UpdateAvailability(id int, status string) error {
return nil
}
+// ToggleReassignReplies toggles the reassign replies status of an user.
+func (u *Manager) ToggleReassignReplies(id int, reassign bool) error {
+ if _, err := u.q.SetReassignReplies.Exec(id, reassign); err != nil {
+ u.lo.Error("error updating user reassign replies", "error", err)
+ return envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}"), nil)
+ }
+ return nil
+}
+
// UpdateLastActive updates the last active timestamp of an user.
func (u *Manager) UpdateLastActive(id int) error {
if _, err := u.q.UpdateLastActiveAt.Exec(id); err != nil {
@@ -397,7 +402,7 @@ func CreateSystemUser(ctx context.Context, password string, db *sqlx.DB) error {
SELECT sys_user.id, roles.id
FROM sys_user, roles
WHERE roles.name = $6`,
- systemUserEmail, UserTypeAgent, "System", "", hashedPassword, rmodels.RoleAdmin)
+ models.SystemUserEmail, models.UserTypeAgent, "System", "", hashedPassword, rmodels.RoleAdmin)
if err != nil {
return fmt.Errorf("failed to create system user: %v", err)
}
@@ -407,7 +412,7 @@ func CreateSystemUser(ctx context.Context, password string, db *sqlx.DB) error {
// IsStrongPassword checks if the password meets the required strength for system user.
func IsStrongPassword(password string) bool {
- if len(password) < minSystemUserPassword || len(password) > maxSystemUserPassword {
+ if len(password) < minPassword || len(password) > maxPassword {
return false
}
hasUppercase := regexp.MustCompile(`[A-Z]`).MatchString(password)
@@ -447,7 +452,7 @@ func promptAndHashPassword(ctx context.Context) ([]byte, error) {
// updateSystemUserPassword updates the password of the system user in the database.
func updateSystemUserPassword(db *sqlx.DB, hashedPassword []byte) error {
- _, err := db.Exec(`UPDATE users SET password = $1 WHERE email = $2`, hashedPassword, systemUserEmail)
+ _, err := db.Exec(`UPDATE users SET password = $1 WHERE email = $2`, hashedPassword, models.SystemUserEmail)
if err != nil {
return fmt.Errorf("failed to update system user password: %v", err)
}
diff --git a/schema.sql b/schema.sql
index 2b6e88c6..673107f1 100644
--- a/schema.sql
+++ b/schema.sql
@@ -125,6 +125,7 @@ CREATE TABLE users (
reset_password_token_expiry TIMESTAMPTZ NULL,
availability_status user_availability_status DEFAULT 'offline' NOT NULL,
last_active_at TIMESTAMPTZ NULL,
+ reassign_replies BOOL DEFAULT FALSE NOT NULL,
CONSTRAINT constraint_users_on_country CHECK (LENGTH(country) <= 140),
CONSTRAINT constraint_users_on_phone_number CHECK (LENGTH(phone_number) <= 20),
CONSTRAINT constraint_users_on_email_length CHECK (LENGTH(email) <= 320),