Merge pull request #45 from abhinavxd/feat/agent-vacation-mode

feat: Toggle button to reassign replies to conversations aka vacation mode
This commit is contained in:
Abhinav Raut
2025-04-05 19:36:00 +05:30
committed by GitHub
22 changed files with 358 additions and 124 deletions

View File

@@ -47,6 +47,12 @@ func handleLogin(r *fastglue.Request) error {
app.lo.Error("error setting csrf cookie", "error", err)
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSaving", "name", "{globals.terms.session}"), nil))
}
// Update last login time.
if err := app.user.UpdateLastLoginAt(user.ID); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(user)
}

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

@@ -16,7 +16,8 @@
'bg-green-500': userStore.user.availability_status === 'online',
'bg-amber-500':
userStore.user.availability_status === 'away' ||
userStore.user.availability_status === 'away_manual',
userStore.user.availability_status === 'away_manual' ||
userStore.user.availability_status === 'away_and_reassigning',
'bg-gray-400': userStore.user.availability_status === 'offline'
}"
></div>
@@ -46,17 +47,35 @@
<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">
<!-- Away switch is checked with 'away_manual' or 'away_and_reassigning' -->
<div class="flex items-center gap-2 px-1 text-left text-sm justify-between">
<span class="text-muted-foreground">{{ t('navigation.away') }}</span>
<Switch
:checked="
['away_manual', 'away_and_reassigning'].includes(userStore.user.availability_status)
"
@update:checked="
(val) => {
const newStatus = val ? 'away_manual' : 'online'
userStore.updateUserAvailability(newStatus)
}
"
/>
</div>
<!-- Reassign Replies Switch is checked with 'away_and_reassigning' -->
<div class="flex items-center gap-2 px-1 text-left text-sm justify-between">
<span class="text-muted-foreground">{{ t('navigation.reassignReplies') }}</span>
<Switch
:checked="userStore.user.availability_status === 'away_and_reassigning'"
@update:checked="
(val) => {
const newStatus = val ? 'away_and_reassigning' : 'away_manual'
userStore.updateUserAvailability(newStatus)
}
"
/>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />

View File

@@ -2,7 +2,7 @@
import { AvatarImage } from 'radix-vue'
const props = defineProps({
src: { type: String, required: true },
src: { type: String, required: true, default: '' },
asChild: { type: Boolean, required: false },
as: { type: null, required: false }
})

View File

@@ -1,5 +1,58 @@
<template>
<form @submit.prevent="onSubmit" class="space-y-6">
<form @submit.prevent="onSubmit" class="space-y-8">
<!-- Summary Section -->
<div class="bg-muted/30 box py-6 px-3" v-if="!isNewForm">
<div class="flex items-start gap-6">
<Avatar class="w-20 h-20">
<AvatarImage :src="props.initialValues.avatar_url" :alt="Avatar" />
<AvatarFallback>
{{ getInitials(props.initialValues.first_name, props.initialValues.last_name) }}
</AvatarFallback>
</Avatar>
<div class="space-y-4 flex-2">
<div class="flex items-center gap-3">
<h3 class="text-lg font-semibold text-gray-900">
{{ props.initialValues.first_name }} {{ props.initialValues.last_name }}
</h3>
<Badge :class="['px-2 rounded-full text-xs font-medium', availabilityStatus.color]">
{{ availabilityStatus.text }}
</Badge>
</div>
<div class="flex flex-wrap items-center gap-6">
<div class="flex items-center gap-2">
<Clock class="w-5 h-5 text-gray-400" />
<div>
<p class="text-sm text-gray-500">{{ $t('form.field.lastActive') }}</p>
<p class="text-sm font-medium text-gray-700">
{{
props.initialValues.last_active_at
? format(new Date(props.initialValues.last_active_at), 'PPpp')
: 'N/A'
}}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<LogIn class="w-5 h-5 text-gray-400" />
<div>
<p class="text-sm text-gray-500">{{ $t('form.field.lastLogin') }}</p>
<p class="text-sm font-medium text-gray-700">
{{
props.initialValues.last_login_at
? format(new Date(props.initialValues.last_login_at), 'PPpp')
: 'N/A'
}}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Form Fields -->
<FormField v-slot="{ field }" name="first_name">
<FormItem v-auto-animate>
<FormLabel>{{ $t('form.field.firstName') }}</FormLabel>
@@ -60,6 +113,35 @@
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="availability_status" v-if="!isNewForm">
<FormItem>
<FormLabel>{{ t('form.field.availabilityStatus') }}</FormLabel>
<FormControl>
<Select v-bind="componentField" v-model="componentField.modelValue">
<SelectTrigger>
<SelectValue
:placeholder="
t('form.field.select', {
name: t('form.field.availabilityStatus')
})
"
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="active_group">{{ t('globals.terms.active') }}</SelectItem>
<SelectItem value="away_manual">{{ t('globals.terms.away') }}</SelectItem>
<SelectItem value="away_and_reassigning">
{{ t('form.field.awayReassigning') }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ field }" name="new_password" v-if="!isNewForm">
<FormItem v-auto-animate>
<FormLabel>{{ t('form.field.setPassword') }}</FormLabel>
@@ -107,10 +189,22 @@ import { createFormSchema } from './formSchema.js'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { vAutoAnimate } from '@formkit/auto-animate/vue'
import { Badge } from '@/components/ui/badge'
import { Clock, LogIn } from 'lucide-vue-next'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { SelectTag } from '@/components/ui/select'
import { Input } from '@/components/ui/input'
import { useI18n } from 'vue-i18n'
import { format } from 'date-fns'
import api from '@/api'
const props = defineProps({
@@ -151,6 +245,15 @@ onMounted(async () => {
}
})
const availabilityStatus = computed(() => {
const status = form.values.availability_status
if (status === 'active_group') return { text: t('globals.terms.active'), color: 'bg-green-500' }
if (status === 'away_manual') return { text: t('globals.terms.away'), color: 'bg-yellow-500' }
if (status === 'away_and_reassigning')
return { text: t('form.field.awayReassigning'), color: 'bg-orange-500' }
return { text: t('globals.terms.offline'), color: 'bg-gray-400' }
})
const teamOptions = computed(() =>
teams.value.map((team) => ({ label: team.name, value: team.name }))
)
@@ -163,16 +266,33 @@ const form = useForm({
})
const onSubmit = form.handleSubmit((values) => {
if (values.availability_status === 'active_group') {
values.availability_status = 'offline'
}
values.teams = values.teams.map((team) => ({ name: team }))
props.submitForm(values)
})
const getInitials = (firstName, lastName) => {
if (!firstName && !lastName) return ''
if (!firstName) return lastName.charAt(0).toUpperCase()
if (!lastName) return firstName.charAt(0).toUpperCase()
return `${firstName.charAt(0).toUpperCase()}${lastName.charAt(0).toUpperCase()}`
}
watch(
() => props.initialValues,
(newValues) => {
// Hack.
if (Object.keys(newValues).length) {
if (Object.keys(newValues).length > 0) {
setTimeout(() => {
if (
newValues.availability_status === 'away' ||
newValues.availability_status === 'offline' ||
newValues.availability_status === 'online'
) {
newValues.availability_status = 'active_group'
}
form.setValues(newValues)
form.setFieldValue(
'teams',

View File

@@ -45,5 +45,6 @@ export const createFormSchema = (t) => z.object({
})
})
.optional(),
enabled: z.boolean().optional().default(true)
enabled: z.boolean().optional().default(true),
availability_status: z.string().optional().default('offline'),
})

View File

@@ -17,7 +17,7 @@ export const useUserStore = defineStore('user', () => {
email: '',
teams: [],
permissions: [],
availability_status: 'offline'
availability_status: 'offline',
})
const emitter = useEmitter()

View File

@@ -73,6 +73,7 @@
"globals.terms.awaitingResponse": "Awaiting Response",
"globals.terms.unassigned": "Unassigned",
"globals.terms.pending": "Pending",
"globals.terms.active": "Active",
"globals.messages.badRequest": "Bad request",
"globals.messages.adjustFilters": "Try adjusting filters",
"globals.messages.errorUpdating": "Error updating {name}",
@@ -236,7 +237,13 @@
"navigation.views": "Views",
"navigation.edit": "Edit",
"navigation.delete": "Delete",
"navigation.reassignReplies": "Reassign replies",
"form.field.name": "Name",
"form.field.awayReassigning": "Away and reassigning",
"form.field.select": "Select {name}",
"form.field.availabilityStatus": "Availability Status",
"form.field.lastActive": "Last active",
"form.field.lastLogin": "Last login",
"form.field.inbox": "Inbox",
"form.field.provider": "Provider",
"form.field.providerURL": "Provider URL",
@@ -255,7 +262,6 @@
"form.field.default": "Default",
"form.field.channel": "Channel",
"form.field.configure": "Configure",
"form.field.select": "Select",
"form.field.date": "Date",
"form.field.description": "Description",
"form.field.email": "Email",

View File

@@ -73,6 +73,7 @@
"globals.terms.awaitingResponse": "प्रतिसाचाची वाट पाहत आहे",
"globals.terms.unassigned": "नियुक्त नाही",
"globals.terms.pending": "प्रलंबित",
"globals.terms.active": "सक्रिय",
"globals.messages.badRequest": "चुकीची विनंती",
"globals.messages.adjustFilters": "फिल्टर्स समायोजित करा",
"globals.messages.errorUpdating": "{name} अद्ययावत करताना त्रुटी",
@@ -236,7 +237,13 @@
"navigation.views": "दृश्ये",
"navigation.edit": "संपादित करा",
"navigation.delete": "हटवा",
"navigation.reassignReplies": "प्रतिसाद पुन्हा नियुक्त करा",
"form.field.name": "नाव",
"form.field.awayReassigning": "दूर आणि पुन्हा नियुक्त करत आहे",
"form.field.select": "{name} निवडा",
"form.field.availabilityStatus": "उपलब्धता स्थिती",
"form.field.lastActive": "शेवटचे सक्रिय",
"form.field.lastLogin": "शेवटचे लॉगिन",
"form.field.inbox": "इनबॉक्स",
"form.field.provider": "प्रदाता",
"form.field.providerURL": "प्रदाता URL",
@@ -255,7 +262,6 @@
"form.field.default": "डिफॉल्ट",
"form.field.channel": "चॅनेल",
"form.field.configure": "कॉन्फिगर करा",
"form.field.select": "निवडा",
"form.field.date": "तारीख",
"form.field.description": "वर्णन",
"form.field.email": "ईमेल",

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` or `away_and_reassigning`
if user.AvailabilityStatus == umodels.AwayManual || user.AvailabilityStatus == umodels.AwayAndReassigning {
e.lo.Debug("user is away, skipping autoasssignment ", "team_id", team.ID, "user_id", user.ID, "availability_status", user.AvailabilityStatus)
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 unset the assigned user if they are away and reassigning.
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.availability_status = 'away_and_reassigning'
) 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,36 @@
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 last_login_at TIMESTAMPTZ NULL;
`)
if err != nil {
return err
}
_, err = db.Exec(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_enum e
JOIN pg_type t ON t.oid = e.enumtypid
WHERE t.typname = 'user_availability_status'
AND e.enumlabel = 'away_and_reassigning'
) THEN
ALTER TYPE user_availability_status ADD VALUE 'away_and_reassigning';
END IF;
END
$$;
`)
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
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,11 +8,19 @@ import (
"github.com/volatiletech/null/v9"
)
var (
Online = "online"
Offline = "offline"
Away = "away"
AwayManual = "away_manual"
const (
SystemUserEmail = "System"
// User types
UserTypeAgent = "agent"
UserTypeContact = "contact"
// User availability statuses
Online = "online"
Offline = "offline"
Away = "away"
AwayManual = "away_manual"
AwayAndReassigning = "away_and_reassigning"
)
type User struct {
@@ -28,6 +36,8 @@ type User struct {
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
Enabled bool `db:"enabled" json:"enabled"`
Password string `db:"password" json:"-"`
LastActiveAt null.Time `db:"last_active_at" json:"last_active_at"`
LastLoginAt null.Time `db:"last_login_at" json:"last_login_at"`
Roles pq.StringArray `db:"roles" json:"roles"`
Permissions pq.StringArray `db:"permissions" json:"permissions"`
Meta pq.StringArray `db:"meta" json:"meta"`

View File

@@ -24,6 +24,7 @@ SELECT
u.id,
u.email,
u.password,
u.type,
u.created_at,
u.updated_at,
u.enabled,
@@ -31,6 +32,8 @@ SELECT
u.first_name,
u.last_name,
u.availability_status,
u.last_active_at,
u.last_login_at,
array_agg(DISTINCT r.name) as roles,
COALESCE(
(SELECT json_agg(json_build_object('id', t.id, 'name', t.name, 'emoji', t.emoji))
@@ -74,6 +77,7 @@ SET first_name = COALESCE($2, first_name),
avatar_url = COALESCE($6, avatar_url),
password = COALESCE($7, password),
enabled = COALESCE($8, enabled),
availability_status = COALESCE($9, availability_status),
updated_at = now()
WHERE id = $1 AND type = 'agent';
@@ -134,4 +138,10 @@ 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: update-last-login-at
UPDATE users
SET last_login_at = now(),
updated_at = now()
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.
@@ -69,6 +64,7 @@ type queries struct {
UpdateAvailability *sqlx.Stmt `query:"update-availability"`
UpdateLastActiveAt *sqlx.Stmt `query:"update-last-active-at"`
UpdateInactiveOffline *sqlx.Stmt `query:"update-inactive-offline"`
UpdateLastLoginAt *sqlx.Stmt `query:"update-last-login-at"`
SoftDeleteUser *sqlx.Stmt `query:"soft-delete-user"`
SetUserPassword *sqlx.Stmt `query:"set-user-password"`
SetResetPasswordToken *sqlx.Stmt `query:"set-reset-password-token"`
@@ -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.
@@ -227,13 +223,22 @@ func (u *Manager) Update(id int, user models.User) error {
u.lo.Debug("setting new password for user", "user_id", id)
}
if _, err := u.q.UpdateUser.Exec(id, user.FirstName, user.LastName, user.Email, pq.Array(user.Roles), user.AvatarURL, hashedPassword, user.Enabled); err != nil {
if _, err := u.q.UpdateUser.Exec(id, user.FirstName, user.LastName, user.Email, pq.Array(user.Roles), user.AvatarURL, hashedPassword, user.Enabled, user.AvailabilityStatus); err != nil {
u.lo.Error("error updating user", "error", err)
return envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}"), nil)
}
return nil
}
// UpdateLastLoginAt updates the last login timestamp of an user.
func (u *Manager) UpdateLastLoginAt(id int) error {
if _, err := u.q.UpdateLastLoginAt.Exec(id); err != nil {
u.lo.Error("error updating user last login at", "error", err)
return envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}"), nil)
}
return nil
}
// SoftDelete soft deletes an user.
func (u *Manager) SoftDelete(id int) error {
// Disallow if user is system user.
@@ -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

@@ -13,7 +13,7 @@ DROP TYPE IF EXISTS "automation_execution_mode" CASCADE; CREATE TYPE "automation
DROP TYPE IF EXISTS "macro_visibility" CASCADE; CREATE TYPE "macro_visibility" AS ENUM ('all', 'team', 'user');
DROP TYPE IF EXISTS "media_disposition" CASCADE; CREATE TYPE "media_disposition" AS ENUM ('inline', 'attachment');
DROP TYPE IF EXISTS "media_store" CASCADE; CREATE TYPE "media_store" AS ENUM ('s3', 'fs');
DROP TYPE IF EXISTS "user_availability_status" CASCADE; CREATE TYPE "user_availability_status" AS ENUM ('online', 'away', 'away_manual', 'offline');
DROP TYPE IF EXISTS "user_availability_status" CASCADE; CREATE TYPE "user_availability_status" AS ENUM ('online', 'away', 'away_manual', 'offline', 'away_and_reassigning');
DROP TYPE IF EXISTS "applied_sla_status" CASCADE; CREATE TYPE "applied_sla_status" AS ENUM ('pending', 'breached', 'met', 'partially_met');
DROP TYPE IF EXISTS "sla_metric" CASCADE; CREATE TYPE "sla_metric" AS ENUM ('first_response', 'resolution');
DROP TYPE IF EXISTS "sla_notification_type" CASCADE; CREATE TYPE "sla_notification_type" AS ENUM ('warning', 'breach');
@@ -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,
last_login_at TIMESTAMPTZ 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),