feat: Toggle button for user to reassign replies to conversations if they are away, user status now actually affects the conversation workflow.

Online: Conversations are auto-assigned.
Auto-away (inactivity in browser): Marks agent as away without stopping assignment (nothing changes for agent).
Manual away: Prevents new conversations from being assigned. (option available in the sidebar)
Reassign replies: Customer replies unassigns the conversation, returning it to the team inbox / unassigned inbox.
This commit is contained in:
Abhinav Raut
2025-04-04 03:29:16 +05:30
parent c639bfba40
commit 2a382d6036
21 changed files with 209 additions and 111 deletions

View File

@@ -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"))

View File

@@ -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 (

View File

@@ -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

View File

@@ -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 (

View File

@@ -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,
}

View File

@@ -46,17 +46,27 @@
<span class="truncate text-xs">{{ userStore.email }}</span>
</div>
</div>
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm justify-between">
<span class="text-muted-foreground">
{{ t('navigation.away') }}
</span>
<Switch
:checked="
userStore.user.availability_status === 'away' ||
userStore.user.availability_status === 'away_manual'
"
@update:checked="(val) => userStore.updateUserAvailability(val ? 'away' : 'online')"
/>
<div class="space-y-2">
<template
v-for="(item, index) in [
{
label: t('navigation.away'),
checked: userStore.user.availability_status === 'away_manual',
action: (val) => userStore.updateUserAvailability(val ? 'away' : 'online')
},
{
label: t('navigation.reassign_replies'),
checked: userStore.user.reassign_replies,
action: (val) => userStore.toggleAssignReplies(val)
}
]"
:key="index"
>
<div class="flex items-center gap-2 px-1 text-left text-sm justify-between">
<span class="text-muted-foreground">{{ item.label }}</span>
<Switch :checked="item.checked" @update:checked="item.action" />
</div>
</template>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />

View File

@@ -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
}
})

View File

@@ -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",

View File

@@ -236,6 +236,7 @@
"navigation.views": "दृश्ये",
"navigation.edit": "संपादित करा",
"navigation.delete": "हटवा",
"navigation.reassign_replies": "प्रतिसाद पुन्हा नियुक्त करा",
"form.field.name": "नाव",
"form.field.inbox": "इनबॉक्स",
"form.field.provider": "प्रदाता",

View File

@@ -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,

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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():

View File

@@ -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

View File

@@ -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"`

View File

@@ -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;
RETURNING contact_id, id;
-- name: set-reassign-replies
UPDATE users
SET reassign_replies = $2
WHERE id = $1;

View File

@@ -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)
}

View File

@@ -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),