mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-11-02 04:53:41 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'),
|
||||
})
|
||||
|
||||
@@ -17,7 +17,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
email: '',
|
||||
teams: [],
|
||||
permissions: [],
|
||||
availability_status: 'offline'
|
||||
availability_status: 'offline',
|
||||
})
|
||||
const emitter = useEmitter()
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ईमेल",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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;
|
||||
@@ -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)
|
||||
|
||||
36
internal/migrations/v0.6.0.go
Normal file
36
internal/migrations/v0.6.0.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
FROM users u
|
||||
JOIN team_members tm ON tm.user_id = u.id
|
||||
JOIN teams t ON t.id = tm.team_id
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user