mirror of
				https://github.com/abhinavxd/libredesk.git
				synced 2025-11-04 05:53:30 +00:00 
			
		
		
		
	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:
		@@ -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"))
 | 
			
		||||
 
 | 
			
		||||
@@ -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 (
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										13
									
								
								cmd/users.go
									
									
									
									
									
								
							
							
						
						
									
										13
									
								
								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 (
 | 
			
		||||
 
 | 
			
		||||
@@ -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,
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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 />
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
  }
 | 
			
		||||
})
 | 
			
		||||
@@ -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",
 | 
			
		||||
 
 | 
			
		||||
@@ -236,6 +236,7 @@
 | 
			
		||||
    "navigation.views": "दृश्ये",
 | 
			
		||||
    "navigation.edit": "संपादित करा",
 | 
			
		||||
    "navigation.delete": "हटवा",
 | 
			
		||||
    "navigation.reassign_replies": "प्रतिसाद पुन्हा नियुक्त करा",
 | 
			
		||||
    "form.field.name": "नाव",
 | 
			
		||||
    "form.field.inbox": "इनबॉक्स",
 | 
			
		||||
    "form.field.provider": "प्रदाता",
 | 
			
		||||
 
 | 
			
		||||
@@ -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,
 | 
			
		||||
 
 | 
			
		||||
@@ -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)
 | 
			
		||||
 
 | 
			
		||||
@@ -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 {
 | 
			
		||||
 
 | 
			
		||||
@@ -524,11 +524,23 @@ 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')
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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)
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										18
									
								
								internal/migrations/v0.6.0.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								internal/migrations/v0.6.0.go
									
									
									
									
									
										Normal 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
 | 
			
		||||
}
 | 
			
		||||
@@ -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():
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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"`
 | 
			
		||||
 
 | 
			
		||||
@@ -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))
 | 
			
		||||
@@ -135,3 +136,8 @@ 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;
 | 
			
		||||
 | 
			
		||||
-- name: set-reassign-replies
 | 
			
		||||
UPDATE users
 | 
			
		||||
SET reassign_replies = $2
 | 
			
		||||
WHERE id = $1;
 | 
			
		||||
@@ -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)
 | 
			
		||||
	}
 | 
			
		||||
 
 | 
			
		||||
@@ -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),
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user