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') }} - - +
+
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),