feat: custom attributes for contacts and conversations

This commit is contained in:
Abhinav Raut
2025-04-13 17:58:36 +05:30
parent 4e893ef876
commit d69a8c58d1
40 changed files with 1933 additions and 278 deletions

View File

@@ -478,6 +478,64 @@ func handleUpdateConversationtags(r *fastglue.Request) error {
return r.SendEnvelope(true)
}
// handleUpdateConversationCustomAttributes updates custom attributes of a conversation.
func handleUpdateConversationCustomAttributes(r *fastglue.Request) error {
var (
app = r.Context.(*App)
attributes = map[string]any{}
auser = r.RequestCtx.UserValue("user").(amodels.User)
uuid = r.RequestCtx.UserValue("uuid").(string)
)
if err := r.Decode(&attributes, ""); err != nil {
app.lo.Error("error unmarshalling custom attributes JSON", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
// Enforce conversation access.
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
_, err = enforceConversationAccess(app, uuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Update custom attributes.
if err := app.conversation.UpdateConversationCustomAttributes(uuid, attributes); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleUpdateContactCustomAttributes updates custom attributes of a contact.
func handleUpdateContactCustomAttributes(r *fastglue.Request) error {
var (
app = r.Context.(*App)
attributes = map[string]any{}
auser = r.RequestCtx.UserValue("user").(amodels.User)
uuid = r.RequestCtx.UserValue("uuid").(string)
)
if err := r.Decode(&attributes, ""); err != nil {
app.lo.Error("error unmarshalling custom attributes JSON", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
// Enforce conversation access.
user, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
conversation, err := enforceConversationAccess(app, uuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
if err := app.user.UpdateCustomAttributes(conversation.ContactID, attributes); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleDashboardCounts retrieves general dashboard counts for all users.
func handleDashboardCounts(r *fastglue.Request) error {
var (

118
cmd/custom_attributes.go Normal file
View File

@@ -0,0 +1,118 @@
package main
import (
"strconv"
cmodels "github.com/abhinavxd/libredesk/internal/custom_attribute/models"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
// handleGetCustomAttribute retrieves a custom attribute by its ID.
func handleGetCustomAttribute(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
attribute, err := app.customAttribute.Get(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(attribute)
}
// handleGetCustomAttributes retrieves all custom attributes from the database.
func handleGetCustomAttributes(r *fastglue.Request) error {
var (
app = r.Context.(*App)
appliesTo = string(r.RequestCtx.QueryArgs().Peek("applies_to"))
)
attributes, err := app.customAttribute.GetAll(appliesTo)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(attributes)
}
// handleCreateCustomAttribute creates a new custom attribute in the database.
func handleCreateCustomAttribute(r *fastglue.Request) error {
var (
app = r.Context.(*App)
attribute = cmodels.CustomAttribute{}
)
if err := r.Decode(&attribute, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
if err := validateCustomAttribute(app, attribute); err != nil {
return sendErrorEnvelope(r, err)
}
if err := app.customAttribute.Create(attribute); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleUpdateCustomAttribute updates an existing custom attribute in the database.
func handleUpdateCustomAttribute(r *fastglue.Request) error {
var (
app = r.Context.(*App)
attribute = cmodels.CustomAttribute{}
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&attribute, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
}
if err := validateCustomAttribute(app, attribute); err != nil {
return sendErrorEnvelope(r, err)
}
if err = app.customAttribute.Update(id, attribute); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleDeleteCustomAttribute deletes a custom attribute from the database.
func handleDeleteCustomAttribute(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
}
if err = app.customAttribute.Delete(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// validateCustomAttribute validates a custom attribute.
func validateCustomAttribute(app *App, attribute cmodels.CustomAttribute) error {
if attribute.Name == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil)
}
if attribute.AppliesTo == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`applies_to`"), nil)
}
if attribute.DataType == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`type`"), nil)
}
if attribute.Description == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`description`"), nil)
}
if attribute.Key == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`key`"), nil)
}
return nil
}

View File

@@ -60,6 +60,8 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.POST("/api/v1/conversations/{cuuid}/messages", perm(handleSendMessage, "messages:write"))
g.PUT("/api/v1/conversations/{cuuid}/messages/{uuid}/retry", perm(handleRetryMessage, "messages:write"))
g.POST("/api/v1/conversations", perm(handleCreateConversation, "conversations:write"))
g.PUT("/api/v1/conversations/{uuid}/custom-attributes", auth(handleUpdateConversationCustomAttributes))
g.PUT("/api/v1/conversations/{uuid}/contacts/custom-attributes", auth(handleUpdateContactCustomAttributes))
// Search.
g.GET("/api/v1/conversations/search", perm(handleSearchConversations, "conversations:read"))
@@ -180,6 +182,13 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.POST("/api/v1/ai/completion", auth(handleAICompletion))
g.PUT("/api/v1/ai/provider", perm(handleUpdateAIProvider, "ai:manage"))
// Custom attributes.
g.GET("/api/v1/custom-attributes", auth(handleGetCustomAttributes))
g.POST("/api/v1/custom-attributes", perm(handleCreateCustomAttribute, "custom_attributes:manage"))
g.GET("/api/v1/custom-attributes/{id}", perm(handleGetCustomAttribute, "custom_attributes:manage"))
g.PUT("/api/v1/custom-attributes/{id}", perm(handleUpdateCustomAttribute, "custom_attributes:manage"))
g.DELETE("/api/v1/custom-attributes/{id}", perm(handleDeleteCustomAttribute, "custom_attributes:manage"))
// WebSocket.
g.GET("/ws", auth(func(r *fastglue.Request) error {
return handleWS(r, hub)

View File

@@ -23,6 +23,7 @@ import (
"github.com/abhinavxd/libredesk/internal/conversation/priority"
"github.com/abhinavxd/libredesk/internal/conversation/status"
"github.com/abhinavxd/libredesk/internal/csat"
customAttribute "github.com/abhinavxd/libredesk/internal/custom_attribute"
"github.com/abhinavxd/libredesk/internal/inbox"
"github.com/abhinavxd/libredesk/internal/inbox/channel/email"
imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
@@ -793,6 +794,20 @@ func initSearch(db *sqlx.DB, i18n *i18n.I18n) *search.Manager {
return m
}
// initCustomAttribute inits custom attribute manager.
func initCustomAttribute(db *sqlx.DB, i18n *i18n.I18n) *customAttribute.Manager {
lo := initLogger("custom-attribute")
m, err := customAttribute.New(customAttribute.Opts{
DB: db,
Lo: lo,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing custom attribute manager: %v", err)
}
return m
}
// initLogger initializes a logf logger.
func initLogger(src string) *logf.Logger {
lvl, env := ko.MustString("app.log_level"), ko.MustString("app.env")

View File

@@ -19,6 +19,7 @@ import (
businesshours "github.com/abhinavxd/libredesk/internal/business_hours"
"github.com/abhinavxd/libredesk/internal/colorlog"
"github.com/abhinavxd/libredesk/internal/csat"
customAttribute "github.com/abhinavxd/libredesk/internal/custom_attribute"
"github.com/abhinavxd/libredesk/internal/macro"
notifier "github.com/abhinavxd/libredesk/internal/notification"
"github.com/abhinavxd/libredesk/internal/search"
@@ -86,6 +87,7 @@ type App struct {
ai *ai.Manager
search *search.Manager
notifier *notifier.Service
customAttribute *customAttribute.Manager
// Global state that stores data on an available app update.
update *AppUpdate
@@ -216,6 +218,7 @@ func main() {
conversation: conversation,
automation: automation,
businessHours: businessHours,
customAttribute: initCustomAttribute(db, i18n),
authz: initAuthz(i18n),
view: initView(db),
csat: initCSAT(db, i18n),

View File

@@ -79,6 +79,7 @@ func perm(handler fastglue.FastRequestHandler, perm string) fastglue.FastRequest
hdrToken = string(r.RequestCtx.Request.Header.Peek("X-CSRFTOKEN"))
)
// Match CSRF token from cookie and header.
if cookieToken == "" || hdrToken == "" || cookieToken != hdrToken {
app.lo.Error("csrf token mismatch", "cookie_token", cookieToken, "header_token", hdrToken)
return r.SendErrorEnvelope(http.StatusForbidden, app.i18n.T("auth.csrfTokenMismatch"), nil, envelope.PermissionError)

View File

@@ -15,7 +15,11 @@
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild :isActive="route.path.startsWith('/contacts')" v-if="userStore.can('contacts:manage')">
<SidebarMenuButton
asChild
:isActive="route.path.startsWith('/contacts')"
v-if="userStore.can('contacts:manage')"
>
<router-link :to="{ name: 'contacts' }">
<BookUser />
</router-link>
@@ -94,6 +98,7 @@ import { useTeamStore } from '@/stores/team'
import { useSlaStore } from '@/stores/sla'
import { useMacroStore } from '@/stores/macro'
import { useTagStore } from '@/stores/tag'
import { useCustomAttributeStore } from '@/stores/customAttributes'
import { useIdleDetection } from '@/composables/useIdleDetection'
import PageHeader from './components/layout/PageHeader.vue'
import ViewForm from '@/features/view/ViewForm.vue'
@@ -129,6 +134,7 @@ const inboxStore = useInboxStore()
const slaStore = useSlaStore()
const macroStore = useMacroStore()
const tagStore = useTagStore()
const customAttributeStore = useCustomAttributeStore()
const userViews = ref([])
const view = ref({})
const openCreateViewForm = ref(false)
@@ -144,7 +150,7 @@ onMounted(() => {
initStores()
})
// initialize data stores
// Initialize data stores
const initStores = async () => {
if (!userStore.userID) {
await userStore.getCurrentUser()
@@ -158,7 +164,8 @@ const initStores = async () => {
inboxStore.fetchInboxes(),
slaStore.fetchSlas(),
macroStore.loadMacros(),
tagStore.fetchTags()
tagStore.fetchTags(),
customAttributeStore.fetchCustomAttributes()
])
}

View File

@@ -33,6 +33,23 @@ http.interceptors.request.use((request) => {
return request
})
const getCustomAttributes = (appliesTo) => http.get('/api/v1/custom-attributes', {
params: { applies_to: appliesTo }
})
const createCustomAttribute = (data) =>
http.post('/api/v1/custom-attributes', data, {
headers: {
'Content-Type': 'application/json'
}
})
const getCustomAttribute = (id) => http.get(`/api/v1/custom-attributes/${id}`)
const updateCustomAttribute = (id, data) =>
http.put(`/api/v1/custom-attributes/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteCustomAttribute = (id) => http.delete(`/api/v1/custom-attributes/${id}`)
const searchConversations = (params) => http.get('/api/v1/conversations/search', { params })
const searchMessages = (params) => http.get('/api/v1/messages/search', { params })
const searchContacts = (params) => http.get('/api/v1/contacts/search', { params })
@@ -201,6 +218,18 @@ const getTags = () => http.get('/api/v1/tags')
const upsertTags = (uuid, data) => http.post(`/api/v1/conversations/${uuid}/tags`, data)
const updateAssignee = (uuid, assignee_type, data) => http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}`, data)
const removeAssignee = (uuid, assignee_type) => http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}/remove`)
const updateContactCustomAttribute = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/contacts/custom-attributes`, data,
{
headers: {
'Content-Type': 'application/json'
}
})
const updateConversationCustomAttribute = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/custom-attributes`, data,
{
headers: {
'Content-Type': 'application/json'
}
})
const createConversation = (data) => http.post('/api/v1/conversations', data)
const updateConversationStatus = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/status`, data)
const updateConversationPriority = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/priority`, data)
@@ -338,6 +367,8 @@ export default {
updateConversationStatus,
updateConversationPriority,
upsertTags,
updateConversationCustomAttribute,
updateContactCustomAttribute,
uploadMedia,
updateAssigneeLastSeen,
updateUser,
@@ -399,4 +430,9 @@ export default {
getContacts,
getContact,
updateContact,
getCustomAttributes,
createCustomAttribute,
updateCustomAttribute,
deleteCustomAttribute,
getCustomAttribute,
}

View File

@@ -68,7 +68,7 @@ const emptyText = computed(
() =>
props.emptyText ||
t('globals.messages.noResults', {
name: t('globals.terms.result', 2)
name: t('globals.terms.result', 2).toLowerCase()
})
)

View File

@@ -27,11 +27,11 @@ import { useAppSettingsStore } from '@/stores/appSettings'
import {
ChevronRight,
EllipsisVertical,
Plus,
CircleUserRound,
User,
UserSearch,
UsersRound,
Search
Search,
Plus
} from 'lucide-vue-next'
import {
DropdownMenu,
@@ -92,9 +92,7 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
>
<!-- Contacts sidebar -->
<template
v-if="
route.matched.some((record) => record.name && record.name.startsWith('contact'))
"
v-if="route.matched.some((record) => record.name && record.name.startsWith('contact'))"
>
<Sidebar collapsible="offcanvas" class="border-r ml-12">
<SidebarHeader>
@@ -285,24 +283,12 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
</div>
<div class="ml-auto">
<div class="flex items-center space-x-2">
<div
class="flex items-center bg-accent p-2 rounded-full cursor-pointer"
@click="emit('createConversation')"
>
<Plus
class="transition-transform duration-200 hover:scale-110"
size="15"
stroke-width="2.5"
/>
</div>
<router-link :to="{ name: 'search' }">
<div class="flex items-center bg-accent p-2 rounded-full">
<Search
class="transition-transform duration-200 hover:scale-110 cursor-pointer"
size="15"
stroke-width="2.5"
/>
</div>
<button
class="flex items-center bg-accent p-2 rounded-full hover:scale-110 transition-transform duration-100"
>
<Search size="15" stroke-width="2.5" />
</button>
</router-link>
</div>
</div>
@@ -315,10 +301,24 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<SidebarContent>
<SidebarGroup>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<a href="#" @click="emit('createConversation')">
<Plus />
<span
>{{
t('globals.messages.new', {
name: t('globals.terms.conversation').toLowerCase()
})
}}
</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild :isActive="isActiveParent('/inboxes/assigned')">
<router-link :to="{ name: 'inbox', params: { type: 'assigned' } }">
<CircleUserRound />
<User />
<span>{{ t('navigation.myInbox') }}</span>
</router-link>
</SidebarMenuButton>
@@ -390,7 +390,7 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<SidebarMenuItem>
<CollapsibleTrigger as-child>
<SidebarMenuButton asChild>
<router-link to="#">
<router-link to="#" class="group/item">
<!-- <SlidersHorizontal /> -->
<span>
{{ t('navigation.views') }}
@@ -399,7 +399,7 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
<Plus
size="18"
@click.stop="openCreateViewDialog"
class="rounded-lg cursor-pointer opacity-0 transition-all duration-200 group-hover:opacity-100 hover:bg-gray-200 hover:shadow-sm text-gray-600 hover:text-gray-800 transform hover:scale-105 active:scale-100 p-1"
class="rounded-lg cursor-pointer opacity-0 transition-all duration-200 group-hover/item:opacity-100 hover:bg-gray-200 hover:shadow-sm text-gray-600 hover:text-gray-800 transform hover:scale-105 active:scale-100 p-1"
/>
</div>
<ChevronRight

View File

@@ -1,4 +1,5 @@
export const EMITTER_EVENTS = {
EDIT_MODEL: 'edit-model',
REFRESH_LIST: 'refresh-list',
SHOW_TOAST: 'show-toast',
SHOW_SOONER: 'show-sooner',

View File

@@ -116,6 +116,16 @@ export const adminNavItems = [
permission: 'oidc:manage'
}
]
},
{
titleKey: 'globals.terms.customAttribute',
children: [
{
titleKey: 'globals.terms.customAttribute',
href: '/admin/custom-attributes',
permission: 'custom_attributes:manage'
}
]
}
]

View File

@@ -0,0 +1,191 @@
<template>
<form class="space-y-6 w-full">
<FormField v-slot="{ componentField }" name="applies_to">
<FormItem>
<FormLabel>{{ $t('form.field.appliesTo') }}</FormLabel>
<FormControl>
<Select v-bind="componentField" :modelValue="componentField.modelValue">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="contact">
{{ $t('globals.terms.contact') }}
</SelectItem>
<SelectItem value="conversation">
{{ $t('globals.terms.conversation') }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormDescription> </FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel>{{ $t('form.field.name') }}</FormLabel>
<FormControl>
<Input type="text" placeholder="" v-bind="componentField" />
</FormControl>
<FormDescription />
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="key">
<FormItem>
<FormLabel>{{ $t('form.field.key') }}</FormLabel>
<FormControl>
<Input
type="text"
v-bind="componentField"
:readonly="form.values.id && form.values.id > 0"
/>
</FormControl>
<FormDescription></FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>{{ $t('form.field.description') }}</FormLabel>
<FormControl>
<Input type="text" v-bind="componentField" />
</FormControl>
<FormDescription />
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="data_type">
<FormItem>
<FormLabel>{{ $t('form.field.type') }}</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="text"> Text </SelectItem>
<SelectItem value="number"> Number </SelectItem>
<SelectItem value="checkbox"> Checkbox </SelectItem>
<SelectItem value="date"> Date </SelectItem>
<SelectItem value="link"> Link </SelectItem>
<SelectItem value="list"> List </SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormDescription> </FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField name="values" v-slot="{ componentField, handleChange }">
<FormItem v-show="form.values.data_type === 'list'">
<FormLabel>
{{ $t('form.field.listValues') }}
</FormLabel>
<FormControl>
<TagsInput :modelValue="componentField.modelValue" @update:modelValue="handleChange">
<TagsInputItem v-for="item in componentField.modelValue" :key="item" :value="item">
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput placeholder="" />
</TagsInput>
</FormControl>
<FormDescription> </FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField name="regex" v-slot="{ componentField }">
<FormItem v-show="form.values.data_type === 'text'">
<FormLabel> {{ $t('form.field.regex') }} ({{ $t('form.field.optional') }}) </FormLabel>
<FormControl>
<Input type="text" v-bind="componentField" />
</FormControl>
<FormDescription>
{{ $t('admin.customAttributes.regex.description') }} e.g. ^[a-zA-Z]*$</FormDescription
>
<FormMessage />
</FormItem>
</FormField>
<!-- Form submit button slot -->
<slot name="footer"></slot>
</form>
</template>
<script setup>
import { watch } from 'vue'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@/components/ui/form'
import {
TagsInput,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText
} from '@/components/ui/tags-input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Input } from '@/components/ui/input'
const props = defineProps({
form: {
type: Object,
required: true
}
})
// Function to generate the key from the name
const generateKeyFromName = (name) => {
if (!name) return ''
// Remove invalid characters (allow only lowercase letters, numbers, and underscores)
return (
name
.toLowerCase()
.trim()
// Replace spaces with underscores
.replace(/\s+/g, '_')
// Remove any other invalid characters
.replace(/[^a-z0-9_]/g, '')
)
}
// Watch for changes in the name field and update the key field
watch(
() => props.form.values.name,
(newName) => {
// Don't update if the form is in edit mode
if (props.form.values.id && props.form.values.id > 0) return
const generatedKey = generateKeyFromName(newName)
// Check if the generated key is different from the current key
if (generatedKey !== props.form.values.key) {
// Clear the error if it exists and set the new key
props.form.setFieldError('key', undefined)
props.form.setFieldValue('key', generatedKey)
}
}
)
</script>

View File

@@ -0,0 +1,73 @@
import { h } from 'vue'
import dataTableDropdown from '@/features/admin/custom-attributes/dataTableDropdown.vue'
import { format } from 'date-fns'
export const createColumns = (t) => [
{
accessorKey: 'name',
header: function () {
return h('div', { class: 'text-center' }, t('form.field.name'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
}
},
{
accessorKey: 'key',
header: function () {
return h('div', { class: 'text-center' }, t('form.field.key'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('key'))
}
},
{
accessorKey: 'applies_to',
header: function () {
return h('div', { class: 'text-center' }, t('form.field.appliesTo'))
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('applies_to'))
}
},
{
accessorKey: 'created_at',
header: function () {
return h('div', { class: 'text-center' }, t('form.field.createdAt'))
},
cell: function ({ row }) {
return h(
'div',
{ class: 'text-center font-medium' },
format(row.getValue('created_at'), 'PPpp')
)
}
},
{
accessorKey: 'updated_at',
header: function () {
return h('div', { class: 'text-center' }, t('form.field.updatedAt'))
},
cell: function ({ row }) {
return h(
'div',
{ class: 'text-center font-medium' },
format(row.getValue('updated_at'), 'PPpp')
)
}
},
{
id: 'actions',
enableHiding: false,
cell: ({ row }) => {
const customAttribute = row.original
return h(
'div',
{ class: 'relative' },
h(dataTableDropdown, {
customAttribute
})
)
}
}
]

View File

@@ -0,0 +1,100 @@
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" class="w-8 h-8 p-0">
<span class="sr-only"></span>
<MoreHorizontal class="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="editCustomAttribute">
{{ $t('globals.buttons.edit') }}
</DropdownMenuItem>
<DropdownMenuItem @click="() => (alertOpen = true)">
{{ $t('globals.buttons.delete') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog :open="alertOpen" @update:open="alertOpen = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ $t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
<AlertDialogDescription>{{
$t('admin.customAttributes.deleteConfirmation')
}}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{{ $t('globals.buttons.cancel') }}</AlertDialogCancel>
<AlertDialogAction @click="handleDelete">
{{ $t('globals.buttons.delete') }}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template>
<script setup>
import { ref } from 'vue'
import { MoreHorizontal } from 'lucide-vue-next'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { useEmitter } from '@/composables/useEmitter'
import { handleHTTPError } from '@/utils/http'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import api from '@/api'
const alertOpen = ref(false)
const emit = useEmitter()
const props = defineProps({
customAttribute: {
type: Object,
required: true,
default: () => ({
id: ''
})
}
})
async function handleDelete() {
try {
await api.deleteCustomAttribute(props.customAttribute.id)
alertOpen.value = false
emitRefreshCustomAttributeList()
} catch (error) {
emit.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
const emitRefreshCustomAttributeList = () => {
emit.emit(EMITTER_EVENTS.REFRESH_LIST, {
model: 'custom-attributes'
})
}
const editCustomAttribute = () => {
emit.emit(EMITTER_EVENTS.EDIT_MODEL, {
model: 'custom-attributes',
data: props.customAttribute
})
}
</script>

View File

@@ -0,0 +1,81 @@
import * as z from 'zod'
export const createFormSchema = (t) => z.object({
id: z.number().optional(),
applies_to: z.enum(['contact', 'conversation'], {
required_error: t('globals.messages.required'),
}),
name: z
.string({
required_error: t('globals.messages.required'),
})
.min(3, {
message: t('form.error.minmax', {
min: 3,
max: 140,
})
})
.max(140, {
message: t('form.error.minmax', {
min: 3,
max: 140,
})
}),
key: z
.string({
required_error: t('globals.messages.required'),
})
.min(3, {
message: t('form.error.minmax', {
min: 3,
max: 140,
})
})
.max(140, {
message: t('form.error.minmax', {
min: 3,
max: 140,
})
})
.regex(/^[a-z0-9_]+$/, {
message: t('globals.messages.invalid', {
name: t('globals.terms.key'),
}),
}),
description: z
.string({
required_error: t('globals.messages.required'),
})
.min(3, {
message: t('form.error.minmax', {
min: 3,
max: 300,
})
})
.max(300, {
message: t('form.error.minmax', {
min: 3,
max: 300,
})
}),
data_type: z.enum(['text', 'number', 'checkbox', 'date', 'link', 'list'], {
required_error: t('globals.messages.required'),
}),
regex: z.string().optional(),
values: z.array(z.string())
.default([])
})
.superRefine((data, ctx) => {
if (data.data_type === 'list') {
// If data_type is 'list', values should be defined and have at least one item.
if (!data.values || data.values.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.too_small,
minimum: 1,
type: "array",
inclusive: true,
message: t('globals.messages.required'),
path: ['values'],
});
}
}
});

View File

@@ -1,5 +1,5 @@
<template>
<form @submit.prevent="onSubmit" class="space-y-6">
<form @submit.prevent="onSubmit" class="space-y-8">
<FormField v-slot="{ componentField }" name="name">
<FormItem v-auto-animate>
<FormLabel>{{ $t('form.field.name') }}</FormLabel>
@@ -23,31 +23,42 @@
</FormItem>
</FormField>
<p class="text-base">{{ $t('admin.role.setPermissionsForThisRole') }}</p>
<div>
<div class="mb-5 text-lg">{{ $t('admin.role.setPermissionsForThisRole') }}</div>
<div v-for="entity in permissions" :key="entity.name" class="box p-4">
<p class="text-lg mb-5">{{ entity.name }}</p>
<div class="space-y-4">
<div class="space-y-6">
<div
v-for="entity in permissions"
:key="entity.name"
class="rounded-lg border border-border bg-card"
>
<div class="border-b border-border bg-muted/30 px-5 py-3">
<h4 class="font-medium text-card-foreground">{{ entity.name }}</h4>
</div>
<div class="p-5">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<FormField
v-for="permission in entity.permissions"
:key="permission.name"
type="checkbox"
:name="permission.name"
>
<FormItem class="flex flex-col gap-y-5 space-y-0 rounded-lg">
<div class="flex space-x-3">
<FormItem class="flex items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
:checked="selectedPermissions.includes(permission.name)"
@update:checked="(newValue) => handleChange(newValue, permission.name)"
/>
<FormLabel>{{ permission.label }}</FormLabel>
</FormControl>
</div>
<FormLabel class="font-normal text-sm">{{ permission.label }}</FormLabel>
</FormItem>
</FormField>
</div>
</div>
</div>
</div>
</div>
<Button type="submit" :isLoading="isLoading">{{ submitLabel }}</Button>
</form>
</template>
@@ -140,7 +151,8 @@ const permissions = ref([
{ name: 'reports:manage', label: t('admin.role.reports.manage') },
{ name: 'business_hours:manage', label: t('admin.role.businessHours.manage') },
{ name: 'sla:manage', label: t('admin.role.sla.manage') },
{ name: 'ai:manage', label: t('admin.role.ai.manage') }
{ name: 'ai:manage', label: t('admin.role.ai.manage') },
{ name: 'custom_attributes:manage', label: t('admin.role.customAttributes.manage') }
]
},
{

View File

@@ -1,5 +1,6 @@
<template>
<div class="flex flex-col gap-1 mb-5">
<div class="space-y-4">
<div class="flex flex-col">
<p class="font-medium">{{ $t('form.field.subject') }}</p>
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
<p v-else>
@@ -7,14 +8,14 @@
</p>
</div>
<div class="flex flex-col gap-1 mb-5">
<div class="flex flex-col">
<p class="font-medium">{{ $t('form.field.referenceNumber') }}</p>
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
<p v-else>
{{ conversation.reference_number }}
</p>
</div>
<div class="flex flex-col gap-1 mb-5">
<div class="flex flex-col">
<p class="font-medium">{{ $t('form.field.initiatedAt') }}</p>
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
<p v-if="conversation.created_at">
@@ -23,7 +24,7 @@
<p v-else>-</p>
</div>
<div class="flex flex-col gap-1 mb-5">
<div class="flex flex-col">
<div class="flex justify-start items-center space-x-2">
<p class="font-medium">{{ $t('form.field.firstReplyAt') }}</p>
<SlaBadge
@@ -42,7 +43,7 @@
</div>
</div>
<div class="flex flex-col gap-1 mb-5">
<div class="flex flex-col">
<div class="flex justify-start items-center space-x-2">
<p class="font-medium">{{ $t('form.field.resolvedAt') }}</p>
<SlaBadge
@@ -61,7 +62,7 @@
</div>
</div>
<div class="flex flex-col gap-1 mb-5" v-if="conversation.closed_at">
<div class="flex flex-col" v-if="conversation.closed_at">
<p class="font-medium">{{ $t('form.field.closedAt') }}</p>
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
<p v-else>
@@ -69,16 +70,22 @@
</p>
</div>
<div class="flex flex-col gap-1 mb-5">
<div class="flex flex-col" v-if="conversation.sla_policy_name">
<p class="font-medium">{{ $t('form.field.slaPolicy') }}</p>
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
<div v-else>
<p v-if="conversation.sla_policy_name">
<div>
<p>
{{ conversation.sla_policy_name }}
</p>
<p v-else>-</p>
</div>
</div>
<CustomAttributes
v-if="customAttributeStore.conversationAttributeOptions.length > 0"
:attributes="customAttributeStore.conversationAttributeOptions"
:custom-attributes="conversation.custom_attributes || {}"
@update:setattributes="updateCustomAttributes"
/>
</div>
</template>
<script setup>
@@ -87,7 +94,37 @@ import { format } from 'date-fns'
import SlaBadge from '@/features/sla/SlaBadge.vue'
import { useConversationStore } from '@/stores/conversation'
import { Skeleton } from '@/components/ui/skeleton'
import CustomAttributes from '@/features/conversation/sidebar/CustomAttributes.vue'
import { useCustomAttributeStore } from '@/stores/customAttributes'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { handleHTTPError } from '@/utils/http'
import api from '@/api'
import { useI18n } from 'vue-i18n'
const emitter = useEmitter()
const { t } = useI18n()
const customAttributeStore = useCustomAttributeStore()
const conversationStore = useConversationStore()
const conversation = computed(() => conversationStore.current)
customAttributeStore.fetchCustomAttributes()
const updateCustomAttributes = async (attributes) => {
let previousAttributes = conversationStore.current.custom_attributes
try {
conversationStore.current.custom_attributes = attributes
await api.updateConversationCustomAttribute(conversation.value.uuid, attributes)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.updatedSuccessfully', {
name: t('globals.terms.attribute')
})
})
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
conversationStore.current.custom_attributes = previousAttributes
}
}
</script>

View File

@@ -1,17 +1,14 @@
<template>
<div>
<ConversationSideBarContact class="p-4" />
<Accordion
type="multiple"
collapsible
:default-value="['Actions', 'Information', 'Previous conversations']"
>
<AccordionItem value="Actions" class="border-0 mb-2">
<Accordion type="multiple" collapsible v-model="accordionState">
<AccordionItem value="actions" class="border-0 mb-2">
<AccordionTrigger class="bg-muted px-4 py-3 text-sm font-medium rounded-lg mx-2">
{{ $t('conversation.sidebar.action', 2) }}
</AccordionTrigger>
<AccordionContent class="space-y-4 p-4">
<!-- `Agent, team, and priority assignment -->
<AccordionContent class="space-y-4 p-4">
<!-- Agent assignment -->
<ComboBox
v-model="assignedUserID"
@@ -115,6 +112,7 @@
</template>
</ComboBox>
<!-- Tags assignment -->
<SelectTag
v-if="conversationStore.current"
v-model="conversationStore.current.tags"
@@ -124,7 +122,8 @@
</AccordionContent>
</AccordionItem>
<AccordionItem value="Information" class="border-0 mb-2">
<!-- Information -->
<AccordionItem value="information" class="border-0 mb-2">
<AccordionTrigger class="bg-muted px-4 py-3 text-sm font-medium rounded-lg mx-2">
{{ $t('conversation.sidebar.information') }}
</AccordionTrigger>
@@ -133,48 +132,31 @@
</AccordionContent>
</AccordionItem>
<AccordionItem value="Previous conversations" class="border-0 mb-2">
<!-- Contact attributes -->
<AccordionItem
value="contact_attributes"
class="border-0 mb-2"
v-if="customAttributeStore.contactAttributeOptions.length > 0"
>
<AccordionTrigger class="bg-muted px-4 py-3 text-sm font-medium rounded-lg mx-2">
{{ $t('conversation.sidebar.contactAttributes') }}
</AccordionTrigger>
<AccordionContent class="p-4">
<CustomAttributes
:attributes="customAttributeStore.contactAttributeOptions"
:customAttributes="conversationStore.current?.contact?.custom_attributes || {}"
@update:setattributes="updateContactCustomAttributes"
/>
</AccordionContent>
</AccordionItem>
<!-- Previous conversations -->
<AccordionItem value="previous_conversations" class="border-0 mb-2">
<AccordionTrigger class="bg-muted px-4 py-3 text-sm font-medium rounded-lg mx-2">
{{ $t('conversation.sidebar.previousConvo') }}
</AccordionTrigger>
<AccordionContent class="p-4">
<div
v-if="
conversationStore.current?.previous_conversations?.length === 0 ||
conversationStore.conversation?.loading
"
class="text-center text-sm text-muted-foreground py-4"
>
{{ $t('conversation.sidebar.noPreviousConvo') }}
</div>
<div v-else class="space-y-3">
<router-link
v-for="conversation in conversationStore.current.previous_conversations"
:key="conversation.uuid"
:to="{
name: 'inbox-conversation',
params: {
uuid: conversation.uuid,
type: 'assigned'
}
}"
class="block p-2 rounded-md hover:bg-muted"
>
<div class="flex items-center justify-between">
<div class="flex flex-col">
<span class="font-medium text-sm">
{{ conversation.contact.first_name }} {{ conversation.contact.last_name }}
</span>
<span class="text-xs text-muted-foreground truncate max-w-[200px]">
{{ conversation.last_message }}
</span>
</div>
<span class="text-xs text-muted-foreground" v-if="conversation.last_message_at">
{{ format(new Date(conversation.last_message_at), 'h') + ' h' }}
</span>
</div>
</router-link>
</div>
<PreviousConversations />
</AccordionContent>
</AccordionItem>
</Accordion>
@@ -186,7 +168,6 @@ import { ref, onMounted, watch, computed } from 'vue'
import { useConversationStore } from '@/stores/conversation'
import { useUsersStore } from '@/stores/users'
import { useTeamStore } from '@/stores/team'
import { format } from 'date-fns'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
Accordion,
@@ -203,15 +184,23 @@ import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { CircleAlert, SignalLow, SignalMedium, SignalHigh, Users } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
import { useStorage } from '@vueuse/core'
import CustomAttributes from '@/features/conversation/sidebar/CustomAttributes.vue'
import { useCustomAttributeStore } from '@/stores/customAttributes'
import PreviousConversations from '@/features/conversation/sidebar/PreviousConversations.vue'
import api from '@/api'
const customAttributeStore = useCustomAttributeStore()
const emitter = useEmitter()
const conversationStore = useConversationStore()
const usersStore = useUsersStore()
const teamsStore = useTeamStore()
const tags = ref([])
// Save the accordion state in local storage
const accordionState = useStorage('conversation-sidebar-accordion', [])
const { t } = useI18n()
let isConversationChange = false
customAttributeStore.fetchCustomAttributes()
// Watch for changes in the current conversation and set the flag
watch(
@@ -332,4 +321,23 @@ const getPriorityIcon = (value) => {
return CircleAlert
}
}
const updateContactCustomAttributes = async (attributes) => {
let previousAttributes = conversationStore.current.contact.custom_attributes
try {
conversationStore.current.contact.custom_attributes = attributes
await api.updateContactCustomAttribute(conversationStore.current.uuid, attributes)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.updatedSuccessfully', {
name: t('globals.terms.attribute')
})
})
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
conversationStore.current.contact.custom_attributes = previousAttributes
}
}
</script>

View File

@@ -0,0 +1,268 @@
<template>
<div class="space-y-4">
<div class="relative group/item" v-for="attribute in attributes" :key="attribute.id">
<!-- Label -->
<div class="font-medium flex items-center" v-if="attribute.data_type !== 'checkbox'">
<div class="flex items-center gap-1">
<p>
{{ attribute.name }}
</p>
<Tooltip>
<TooltipTrigger>
<Info class="text-muted-foreground" size="16" />
</TooltipTrigger>
<TooltipContent>
{{ attribute.description }}
</TooltipContent>
</Tooltip>
</div>
</div>
<!-- Checkbox -->
<div class="font-medium flex items-center gap-2" v-else>
<Checkbox
v-if="attribute.data_type === 'checkbox'"
:disabled="loading"
@update:checked="
(value) => {
editingValue = value
saveAttribute(attribute.key)
}
"
:checked="customAttributes?.[attribute.key]"
/>
<p>
{{ attribute.name }}
</p>
<Tooltip>
<TooltipTrigger>
<Info class="text-muted-foreground" size="16" />
</TooltipTrigger>
<TooltipContent>
{{ attribute.description }}
</TooltipContent>
</Tooltip>
</div>
<Skeleton v-if="loading" class="w-32 h-4" />
<!-- Value -->
<template v-else-if="attribute.data_type !== 'checkbox'">
<div
v-if="!editingAttributeKey || editingAttributeKey !== attribute.key"
class="flex items-center gap-2"
>
<span class="break-all" v-if="attribute.data_type !== 'checkbox'">
{{ customAttributes?.[attribute.key] ?? '-' }}
</span>
<Pencil
size="12"
class="text-muted-foreground cursor-pointer flex-shrink-0 opacity-0 group-hover/item:opacity-100 transition-opacity duration-200"
@click="startEditing(attribute)"
/>
<Trash2
v-if="customAttributes?.[attribute.key]"
size="12"
class="text-muted-foreground cursor-pointer flex-shrink-0 absolute right-0 top-1"
@click="deleteAttribute(attribute)"
/>
</div>
<div v-else>
<div class="flex items-center gap-2">
<template v-if="attribute.data_type === 'text'">
<Input
v-model="editingValue"
type="text"
@keydown.enter="saveAttribute(attribute.key)"
/>
</template>
<template v-else-if="attribute.data_type === 'number'">
<Input
v-model="editingValue"
type="number"
@keydown.enter="saveAttribute(attribute.key)"
/>
</template>
<template v-else-if="attribute.data_type === 'checkbox'">
<Checkbox v-model:checked="editingValue" />
</template>
<template v-else-if="attribute.data_type === 'date'">
<Input v-model="editingValue" type="date" />
</template>
<template v-else-if="attribute.data_type === 'link'">
<Input
v-model="editingValue"
type="url"
@keydown.enter="saveAttribute(attribute.key)"
/>
</template>
<template v-else-if="attribute.data_type === 'list'">
<Select v-model="editingValue">
<SelectTrigger>
<SelectValue :placeholder="t('form.field.selectValue')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option in attribute.values" :key="option" :value="option">
{{ option }}
</SelectItem>
</SelectContent>
</Select>
</template>
<Check
size="20"
class="text-muted-foreground cursor-pointer"
@click="saveAttribute(attribute.key)"
/>
<X size="20" class="text-muted-foreground cursor-pointer" @click="cancelEditing" />
</div>
<p v-if="errorMessage" class="text-red-500 text-xs mt-1">
{{ errorMessage }}
</p>
</div>
</template>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import * as z from 'zod'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Pencil, Trash2, Check, X, Info } from 'lucide-vue-next'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useI18n } from 'vue-i18n'
const props = defineProps({
attributes: {
type: Array,
required: true
},
customAttributes: {
type: Object,
required: true
},
loading: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:setattributes'])
const { t } = useI18n()
const errorMessage = ref('')
const editingAttributeKey = ref(null)
const editingValue = ref(null)
const startEditing = (attribute) => {
errorMessage.value = ''
editingAttributeKey.value = attribute.key
const currentValue = props.customAttributes?.[attribute.key]
editingValue.value = attribute.data_type === 'checkbox' ? !!currentValue : (currentValue ?? null)
}
const cancelEditing = () => {
editingAttributeKey.value = null
editingValue.value = null
}
const getValidationSchema = (attribute) => {
switch (attribute.data_type) {
case 'text': {
let schema = z.string().min(1, t('globals.messages.required'))
// If regex is provided and valid, add it to the schema validation.
if (attribute.regex) {
try {
console.log('Creating regex:', attribute.regex)
const regex = new RegExp(attribute.regex)
schema = schema.regex(regex, {
message: t('globals.messages.invalid', { name: t('form.field.value').toLowerCase() })
})
} catch (err) {
console.error('Error creating regex:', err)
}
}
return schema.nullable()
}
case 'number':
return z.preprocess(
(val) => Number(val),
z
.number({
invalid_type_error: t('globals.messages.invalid', {
name: t('form.field.value').toLowerCase()
})
})
.nullable()
)
case 'checkbox':
return z.boolean().nullable()
case 'date':
return z
.string()
.refine(
(val) => !isNaN(Date.parse(val)),
t('globals.messages.invalid', {
name: t('form.field.value').toLowerCase()
})
)
.nullable()
case 'link':
return z
.string()
.url(
t('globals.messages.invalid', {
name: t('form.field.url', 2).toLowerCase()
})
)
.nullable()
case 'list':
return z
.string()
.refine((val) => attribute.values.includes(val), {
message: t('globals.messages.invalid', {
name: t('form.field.value').toLowerCase()
})
})
.nullable()
default:
return z.any()
}
}
const saveAttribute = (key) => {
const attribute = props.attributes.find((attr) => attr.key === key)
if (!attribute) return
try {
const schema = getValidationSchema(attribute)
schema.parse(editingValue.value)
} catch (validationError) {
if (validationError instanceof z.ZodError) {
errorMessage.value = validationError.errors[0].message
return
}
errorMessage.value = validationError
return
}
const updatedAttributes = { ...(props.customAttributes || {}) }
updatedAttributes[attribute.key] = editingValue.value
emit('update:setattributes', updatedAttributes)
cancelEditing()
}
const deleteAttribute = (attribute) => {
const updatedAttributes = { ...(props.customAttributes || {}) }
delete updatedAttributes[attribute.key]
emit('update:setattributes', updatedAttributes)
if (editingAttributeKey.value === attribute.key) cancelEditing()
}
</script>

View File

@@ -0,0 +1,46 @@
<template>
<div
v-if="
conversationStore.current?.previous_conversations?.length === 0 ||
conversationStore.conversation?.loading
"
class="text-center text-sm text-muted-foreground py-4"
>
{{ $t('conversation.sidebar.noPreviousConvo') }}
</div>
<div v-else class="space-y-3">
<router-link
v-for="conversation in conversationStore.current.previous_conversations"
:key="conversation.uuid"
:to="{
name: 'inbox-conversation',
params: {
uuid: conversation.uuid,
type: 'assigned'
}
}"
class="block p-2 rounded-md hover:bg-muted"
>
<div class="flex items-center justify-between">
<div class="flex flex-col">
<span class="font-medium text-sm">
{{ conversation.contact.first_name }} {{ conversation.contact.last_name }}
</span>
<span class="text-xs text-muted-foreground truncate max-w-[200px]">
{{ conversation.last_message }}
</span>
</div>
<span class="text-xs text-muted-foreground" v-if="conversation.last_message_at">
{{ format(new Date(conversation.last_message_at), 'h') + ' h' }}
</span>
</div>
</router-link>
</div>
</template>
<script setup>
import { useConversationStore } from '@/stores/conversation'
import { format } from 'date-fns'
const conversationStore = useConversationStore()
</script>

View File

@@ -171,6 +171,12 @@ const routes = [
component: AdminLayout,
meta: { title: 'Admin' },
children: [
{
path: 'custom-attributes',
name: 'custom-attributes',
component: () => import('@/views/admin/custom-attributes/CustomAttributes.vue'),
meta: { title: 'Custom attributes' }
},
{
path: 'general',
name: 'general',

View File

@@ -0,0 +1,47 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { handleHTTPError } from '@/utils/http'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents'
import api from '@/api'
export const useCustomAttributeStore = defineStore('customAttributes', () => {
const attributes = ref([])
const emitter = useEmitter()
const contactAttributeOptions = computed(() => {
return attributes.value
.filter(att => att.applies_to === 'contact')
.map(att => ({
label: att.name,
value: String(att.id),
...att,
}))
})
const conversationAttributeOptions = computed(() => {
return attributes.value
.filter(att => att.applies_to === 'conversation')
.map(att => ({
label: att.name,
value: String(att.id),
...att,
}))
})
const fetchCustomAttributes = async () => {
if (attributes.value.length) return
try {
const response = await api.getCustomAttributes()
attributes.value = response?.data?.data || []
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
return {
attributes,
conversationAttributeOptions,
contactAttributeOptions,
fetchCustomAttributes,
}
})

View File

@@ -0,0 +1,218 @@
<template>
<div>
<Spinner v-if="isLoading" />
<AdminPageWithHelp>
<template #content>
<div :class="{ 'opacity-50 transition-opacity duration-300': isLoading }">
<div class="flex justify-between mb-5">
<div></div>
<div class="flex justify-end mb-4">
<Dialog v-model:open="dialogOpen">
<DialogTrigger as-child @click="newCustomAttribute">
<Button class="ml-auto">
{{
$t('globals.messages.new', {
name: $t('globals.terms.customAttribute').toLowerCase()
})
}}
</Button>
</DialogTrigger>
<DialogContent class="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>
{{
isEditing
? $t('globals.messages.edit', {
name: $t('globals.terms.customAttribute').toLowerCase()
})
: $t('globals.messages.new', {
name: $t('globals.terms.customAttribute').toLowerCase()
})
}}
</DialogTitle>
<DialogDescription> </DialogDescription>
</DialogHeader>
<CustomAttributesForm @submit.prevent="onSubmit" :form="form">
<template #footer>
<DialogFooter class="mt-10">
<Button type="submit" :isLoading="isLoading" :disabled="isLoading">
{{
isEditing ? $t('globals.buttons.update') : $t('globals.buttons.create')
}}
</Button>
</DialogFooter>
</template>
</CustomAttributesForm>
</DialogContent>
</Dialog>
</div>
</div>
<div>
<Tabs default-value="contact" v-model="appliesTo">
<TabsList class="grid w-full grid-cols-2 mb-5">
<TabsTrigger value="contact">
{{ $t('globals.terms.contact') }}
</TabsTrigger>
<TabsTrigger value="conversation">
{{ $t('globals.terms.conversation') }}
</TabsTrigger>
</TabsList>
<TabsContent value="contact">
<DataTable :columns="createColumns(t)" :data="customAttributes" />
</TabsContent>
<TabsContent value="conversation">
<DataTable :columns="createColumns(t)" :data="customAttributes" />
</TabsContent>
</Tabs>
</div>
</div>
</template>
<template #help>
<p>
Custom attributes help you set additional details about your contacts or conversations
such as the subscription plan or the date of their first purchase.
</p>
</template>
</AdminPageWithHelp>
</div>
</template>
<script setup>
import { ref, onMounted, watch, onUnmounted } from 'vue'
import DataTable from '@/components/datatable/DataTable.vue'
import { createColumns } from '@/features/admin/custom-attributes/dataTableColumns.js'
import CustomAttributesForm from '@/features/admin/custom-attributes/CustomAttributesForm.vue'
import { Button } from '@/components/ui/button'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { createFormSchema } from '@/features/admin/custom-attributes/formSchema.js'
import { Spinner } from '@/components/ui/spinner'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger
} from '@/components/ui/dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useStorage } from '@vueuse/core'
import AdminPageWithHelp from '@/layouts/admin/AdminPageWithHelp.vue'
import { useI18n } from 'vue-i18n'
import { handleHTTPError } from '@/utils/http'
import api from '@/api'
const appliesTo = useStorage('appliesTo', 'contact')
const { t } = useI18n()
const customAttributes = ref([])
const isLoading = ref(false)
const emitter = useEmitter()
const dialogOpen = ref(false)
const isEditing = ref(false)
onMounted(async () => {
fetchAll()
emitter.on(EMITTER_EVENTS.REFRESH_LIST, (data) => {
if (data?.model === 'custom-attributes') fetchAll()
})
// Listen to the edit model event, this is emitted from the dropdown menu
// in the datatable.
emitter.on(EMITTER_EVENTS.EDIT_MODEL, (data) => {
if (data?.model === 'custom-attributes') {
form.setValues(data.data)
form.setErrors({})
isEditing.value = true
dialogOpen.value = true
}
})
})
onUnmounted(() => {
emitter.off(EMITTER_EVENTS.REFRESH_LIST)
emitter.off(EMITTER_EVENTS.EDIT_MODEL)
})
const newCustomAttribute = () => {
form.resetForm()
form.setErrors({})
isEditing.value = false
dialogOpen.value = true
}
const form = useForm({
validationSchema: toTypedSchema(createFormSchema(t)),
initialValues: {
id: 0,
name: '',
data_type: 'text',
applies_to: appliesTo.value,
values: []
}
})
const fetchAll = async () => {
if (!appliesTo.value) return
try {
isLoading.value = true
const resp = await api.getCustomAttributes(appliesTo.value)
customAttributes.value = resp.data.data
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
isLoading.value = false
}
}
const onSubmit = form.handleSubmit(async (values) => {
try {
isLoading.value = true
if (values.id) {
await api.updateCustomAttribute(values.id, values)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.updatedSuccessfully', {
name: t('globals.terms.customAttribute')
})
})
} else {
await api.createCustomAttribute(values)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.createdSuccessfully', {
name: t('globals.terms.customAttribute')
})
})
}
dialogOpen.value = false
fetchAll()
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
isLoading.value = false
}
})
watch(
appliesTo,
(newVal) => {
form.resetForm({
values: {
...form.values,
applies_to: newVal
}
})
fetchAll()
},
{ immediate: true }
)
</script>

View File

@@ -32,9 +32,9 @@
<StatusForm @submit.prevent="onSubmit">
<template #footer>
<DialogFooter class="mt-10">
<Button type="submit" :isLoading="isLoading" :disabled="isLoading">{{
$t('globals.buttons.save')
}}</Button>
<Button type="submit" :isLoading="isLoading" :disabled="isLoading">
{{ $t('globals.buttons.save') }}
</Button>
</DialogFooter>
</template>
</StatusForm>
@@ -61,7 +61,6 @@ import DataTable from '@/components/datatable/DataTable.vue'
import AdminPageWithHelp from '@/layouts/admin/AdminPageWithHelp.vue'
import { createColumns } from '@/features/admin/status/dataTableColumns.js'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/spinner'
import StatusForm from '@/features/admin/status/StatusForm.vue'
import {

View File

@@ -72,6 +72,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useStorage } from '@vueuse/core'
import AdminPageWithHelp from '@/layouts/admin/AdminPageWithHelp.vue'
import { useI18n } from 'vue-i18n'
import { handleHTTPError } from '@/utils/http'
import api from '@/api'
const templateType = useStorage('templateType', 'email_outgoing')
@@ -98,7 +99,7 @@ const fetchAll = async () => {
} catch (error) {
emit.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: error.message
description: handleHTTPError(error).message
})
} finally {
isLoading.value = false

View File

@@ -7,7 +7,7 @@
"globals.terms.team": "Team | Teams",
"globals.terms.message": "Message | Messages",
"globals.terms.activityMessage": "Activity Message | Activity Messages",
"globals.terms.conversation": "Conversations | Conversations",
"globals.terms.conversation": "Conversation | Conversations",
"globals.terms.provider": "Provider | Providers",
"globals.terms.state": "State | States",
"globals.terms.session": "Session | Sessions",
@@ -64,6 +64,8 @@
"globals.terms.offline": "Offline | Offline",
"globals.terms.away": "Away | Away",
"globals.terms.admin": "Admin | Admins",
"globals.terms.customAttribute": "Custom attribute | Custom attributes",
"globals.terms.attribute": "Attribute | Attributes",
"globals.terms.tryAgain": "Try again",
"globals.terms.search": "Search",
"globals.terms.live": "Live",
@@ -74,6 +76,8 @@
"globals.terms.unassigned": "Unassigned",
"globals.terms.pending": "Pending",
"globals.terms.active": "Active",
"globals.terms.url": "URL | URLs",
"globals.terms.key": "Key | Keys",
"globals.messages.badRequest": "Bad request",
"globals.messages.adjustFilters": "Try adjusting filters",
"globals.messages.errorUpdating": "Error updating {name}",
@@ -243,6 +247,12 @@
"navigation.reassignReplies": "Reassign replies",
"navigation.allContacts": "All Contacts",
"form.field.name": "Name",
"form.field.regex": "Regex",
"form.field.key": "Key",
"form.field.type": "Type",
"form.field.listValues": "List values",
"form.field.optional": "Optional",
"form.field.appliesTo": "Applies to",
"form.field.createdOn": "Created on",
"form.field.awayReassigning": "Away and reassigning",
"form.field.select": "Select {name}",
@@ -468,6 +478,7 @@
"admin.role.sla.manage": "Manage SLA Policies",
"admin.role.ai.manage": "Manage AI Features",
"admin.role.contacts.manage": "Manage Contacts",
"admin.role.customAttributes.manage": "Manage Custom Attributes",
"admin.automation.newConversation.description": "Rules that run when a new conversation is created, drag and drop to reorder rules.",
"admin.automation.conversationUpdate": "Conversation Update",
"admin.automation.conversationUpdate.description": "Rules that run when a conversation is updated.",
@@ -578,6 +589,7 @@
"conversation.hideQuotedText": "Hide quoted text",
"conversation.sidebar.action": "Action | Actions",
"conversation.sidebar.information": "Information",
"conversation.sidebar.contactAttributes": "Contact attributes",
"conversation.sidebar.previousConvo": "Previous conversations",
"conversation.sidebar.noPreviousConvo": "No previous conversations",
"conversation.sidebar.notAvailable": "Not available",
@@ -593,5 +605,7 @@
"replyBox.correctEmailErrors": "Please correct the email errors before sending.",
"contact.blockConfirm": "Are you sure you want to block this contact? They will no longer be able to interact with you.",
"contact.unblockConfirm": "Are you sure you want to unblock this contact? They will be able to interact with you again.",
"contact.alreadyExistsWithEmail": "Another contact with same email already exists"
"contact.alreadyExistsWithEmail": "Another contact with same email already exists",
"admin.customAttributes.deleteConfirmation": "This action cannot be undone. This will permanently delete this custom attribute.",
"admin.customAttributes.regex.description": "Regex to validate the value of this custom attribute. Leave empty to skip validation."
}

View File

@@ -94,9 +94,10 @@ func (e *Enforcer) Enforce(user umodels.User, obj, act string) (bool, error) {
// Access can be granted under the following conditions:
// 1. User has the "read_all" permission, allowing access to all conversations.
// 2. User has the "read_assigned" permission and is the assigned user.
// 3. User has the "read_team_inbox" permission and is part of the assigned team, with the conversation unassigned to any specific user.
// 4. User has the "read_unassigned" permission and the conversation is unassigned to any user or team.
// Returns true if access is granted, false otherwise. In case of an error while checking permissions, returns false and the error.
// 3. User has the "read_team_inbox" permission and is part of the assigned team, with the conversation NOT assigned to any user.
// 4. User has the "read_unassigned" permission and the conversation is not assigned to any user or team.
// 5. User has the "read" permission, allowing access to the conversation.
// Returns true if access is granted, false otherwise. In case of an error while checking permissions returns false and the error.
func (e *Enforcer) EnforceConversationAccess(user umodels.User, conversation cmodels.Conversation) (bool, error) {
checkPermission := func(action string) (bool, error) {
allowed, err := e.Enforce(user, "conversations", action)

View File

@@ -69,6 +69,9 @@ const (
// Contacts
PermContactsManage = "contacts:manage"
// Custom attributes
PermCustomAttributesManage = "custom_attributes:manage"
)
var validPermissions = map[string]struct{}{
@@ -103,6 +106,7 @@ var validPermissions = map[string]struct{}{
PermOIDCManage: {},
PermAIManage: {},
PermContactsManage: {},
PermCustomAttributesManage: {},
}
// IsValidPermission returns true if it's a valid permission.

View File

@@ -197,7 +197,7 @@ type queries struct {
UpdateConversationAssigneeLastSeen *sqlx.Stmt `query:"update-conversation-assignee-last-seen"`
UpdateConversationAssignedUser *sqlx.Stmt `query:"update-conversation-assigned-user"`
UpdateConversationAssignedTeam *sqlx.Stmt `query:"update-conversation-assigned-team"`
RemoveConversationAssignee *sqlx.Stmt `query:"remove-conversation-assignee"`
UpdateConversationCustomAttributes *sqlx.Stmt `query:"update-conversation-custom-attributes"`
UpdateConversationPriority *sqlx.Stmt `query:"update-conversation-priority"`
UpdateConversationStatus *sqlx.Stmt `query:"update-conversation-status"`
UpdateConversationLastMessage *sqlx.Stmt `query:"update-conversation-last-message"`
@@ -209,6 +209,7 @@ type queries struct {
ReOpenConversation *sqlx.Stmt `query:"re-open-conversation"`
UnsnoozeAll *sqlx.Stmt `query:"unsnooze-all"`
DeleteConversation *sqlx.Stmt `query:"delete-conversation"`
RemoveConversationAssignee *sqlx.Stmt `query:"remove-conversation-assignee"`
// Dashboard queries.
GetDashboardCharts string `query:"get-dashboard-charts"`
@@ -887,6 +888,20 @@ func (m *Manager) DeleteConversation(uuid string) error {
return nil
}
// UpdateConversationCustomAttributes updates the custom attributes of a conversation.
func (c *Manager) UpdateConversationCustomAttributes(uuid string, customAttributes map[string]any) error {
jsonb, err := json.Marshal(customAttributes)
if err != nil {
c.lo.Error("error marshalling custom attributes", "error", err)
return envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.conversation}"), nil)
}
if _, err := c.q.UpdateConversationCustomAttributes.Exec(uuid, jsonb); err != nil {
c.lo.Error("error updating conversation custom attributes", "error", err)
return envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.conversation}"), nil)
}
return nil
}
// addConversationParticipant adds a user as participant to a conversation.
func (c *Manager) addConversationParticipant(userID int, conversationUUID string) error {
if _, err := c.q.InsertConversationParticipant.Exec(userID, conversationUUID); err != nil && !dbutil.IsUniqueViolationError(err) {

View File

@@ -76,7 +76,7 @@ type Conversation struct {
InboxChannel string `db:"inbox_channel" json:"inbox_channel"`
Tags null.JSON `db:"tags" json:"tags"`
Meta pq.StringArray `db:"meta" json:"meta"`
CustomAttributes pq.StringArray `db:"custom_attributes" json:"custom_attributes"`
CustomAttributes json.RawMessage `db:"custom_attributes" json:"custom_attributes"`
LastMessageAt null.Time `db:"last_message_at" json:"last_message_at"`
LastMessage null.String `db:"last_message" json:"last_message"`
LastMessageSender null.String `db:"last_message_sender" json:"last_message_sender"`

View File

@@ -118,6 +118,7 @@ SELECT
c.sla_policy_id,
sla.name as sla_policy_name,
c.last_message,
c.custom_attributes,
(SELECT COALESCE(
(SELECT json_agg(t.name)
FROM tags t
@@ -125,6 +126,7 @@ SELECT
WHERE ct.conversation_id = c.id),
'[]'::json
)) AS tags,
ct.id as "contact.id",
ct.created_at as "contact.created_at",
ct.updated_at as "contact.updated_at",
ct.first_name as "contact.first_name",
@@ -133,6 +135,7 @@ SELECT
ct.avatar_url as "contact.avatar_url",
ct.phone_number as "contact.phone_number",
ct.phone_number_calling_code as "contact.phone_number_calling_code",
ct.custom_attributes as "contact.custom_attributes",
COALESCE(lr.cc, '[]'::jsonb) as cc,
COALESCE(lr.bcc, '[]'::jsonb) as bcc,
as_latest.first_response_deadline_at,
@@ -258,7 +261,7 @@ SELECT json_build_object(
'unassigned', COUNT(CASE WHEN c.assigned_user_id IS NULL THEN 1 END),
'pending', COUNT(CASE WHEN c.first_reply_at IS NOT NULL THEN 1 END),
'agents_online', (SELECT COUNT(*) FROM users WHERE availability_status = 'online' AND type = 'agent' AND deleted_at is null),
'agents_away', (SELECT COUNT(*) FROM users WHERE availability_status in ('away', 'away_manual') AND type = 'agent' AND deleted_at is null),
'agents_away', (SELECT COUNT(*) FROM users WHERE availability_status = 'away_manual' AND type = 'agent' AND deleted_at is null),
'agents_offline', (SELECT COUNT(*) FROM users WHERE availability_status = 'offline' AND type = 'agent' AND deleted_at is null)
)
FROM conversations c
@@ -368,6 +371,11 @@ SET assigned_user_id = NULL,
updated_at = now()
WHERE assigned_user_id = $1 AND status_id in (SELECT id FROM conversation_statuses WHERE name NOT IN ('Resolved', 'Closed'));
-- name: update-conversation-custom-attributes
UPDATE conversations
SET custom_attributes = $2,
updated_at = now()
WHERE uuid = $1;
-- MESSAGE queries.
-- name: get-message-source-ids

View File

@@ -0,0 +1,109 @@
// Package customAttribute handles the management of custom attributes for contacts and conversations.
package customAttribute
import (
"database/sql"
"embed"
"github.com/abhinavxd/libredesk/internal/custom_attribute/models"
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/jmoiron/sqlx"
"github.com/knadh/go-i18n"
"github.com/lib/pq"
"github.com/zerodha/logf"
)
var (
//go:embed queries.sql
efs embed.FS
)
// Manager manages custom attributes.
type Manager struct {
q queries
lo *logf.Logger
i18n *i18n.I18n
}
// Opts contains options for initializing the Manager.
type Opts struct {
DB *sqlx.DB
Lo *logf.Logger
I18n *i18n.I18n
}
// queries contains prepared SQL queries.
type queries struct {
GetCustomAttribute *sqlx.Stmt `query:"get-custom-attribute"`
GetAllCustomAttributes *sqlx.Stmt `query:"get-all-custom-attributes"`
InsertCustomAttribute *sqlx.Stmt `query:"insert-custom-attribute"`
DeleteCustomAttribute *sqlx.Stmt `query:"delete-custom-attribute"`
UpdateCustomAttribute *sqlx.Stmt `query:"update-custom-attribute"`
}
// New creates and returns a new instance of the Manager.
func New(opts Opts) (*Manager, error) {
var q queries
if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
return nil, err
}
return &Manager{
q: q,
lo: opts.Lo,
i18n: opts.I18n,
}, nil
}
// Get retrieves a custom attribute by ID.
func (m *Manager) Get(id int) (models.CustomAttribute, error) {
var customAttribute models.CustomAttribute
if err := m.q.GetCustomAttribute.Get(&customAttribute, id); err != nil {
if err == sql.ErrNoRows {
return customAttribute, envelope.NewError(envelope.NotFoundError, m.i18n.Ts("globals.messages.notFound", "name", m.i18n.P("globals.terms.customAttribute")), nil)
}
m.lo.Error("error fetching custom attribute", "error", err)
return customAttribute, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorFetching", "name", m.i18n.P("globals.terms.customAttribute")), nil)
}
return customAttribute, nil
}
// GetAll retrieves all custom attributes.
func (m *Manager) GetAll(appliesTo string) ([]models.CustomAttribute, error) {
var customAttributes = make([]models.CustomAttribute, 0)
if err := m.q.GetAllCustomAttributes.Select(&customAttributes, appliesTo); err != nil {
m.lo.Error("error fetching custom attributes", "error", err)
return nil, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorFetching", "name", m.i18n.P("globals.terms.customAttribute")), nil)
}
return customAttributes, nil
}
// Create creates a new custom attribute.
func (m *Manager) Create(attr models.CustomAttribute) error {
if _, err := m.q.InsertCustomAttribute.Exec(attr.AppliesTo, attr.Name, attr.Description, attr.Key, pq.Array(attr.Values), attr.DataType, attr.Regex); err != nil {
if dbutil.IsUniqueViolationError(err) {
return envelope.NewError(envelope.InputError, m.i18n.Ts("globals.messages.errorAlreadyExists", "name", m.i18n.P("globals.terms.customAttribute")), nil)
}
m.lo.Error("error inserting custom attribute", "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.customAttribute}"), nil)
}
return nil
}
// Update updates a custom attribute by ID.
func (m *Manager) Update(id int, attr models.CustomAttribute) error {
if _, err := m.q.UpdateCustomAttribute.Exec(id, attr.AppliesTo, attr.Name, attr.Description, pq.Array(attr.Values), attr.DataType, attr.Regex); err != nil {
m.lo.Error("error updating custom attribute", "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.customAttribute}"), nil)
}
return nil
}
// Delete deletes a custom attribute by ID.
func (m *Manager) Delete(id int) error {
if _, err := m.q.DeleteCustomAttribute.Exec(id); err != nil {
m.lo.Error("error deleting custom attribute", "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.customAttribute}"), nil)
}
return nil
}

View File

@@ -0,0 +1,20 @@
package models
import (
"time"
"github.com/lib/pq"
)
type CustomAttribute struct {
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
AppliesTo string `db:"applies_to" json:"applies_to"`
Name string `db:"name" json:"name"`
Description string `db:"description" json:"description"`
Key string `db:"key" json:"key"`
Values pq.StringArray `db:"values" json:"values"`
DataType string `db:"data_type" json:"data_type"`
Regex string `db:"regex" json:"regex"`
}

View File

@@ -0,0 +1,63 @@
-- name: get-all-custom-attributes
SELECT
id,
created_at,
updated_at,
applies_to,
name,
description,
key,
values,
data_type,
regex
FROM
custom_attribute_definitions
WHERE
CASE WHEN $1 = '' THEN TRUE
ELSE applies_to = $1
END
ORDER BY
updated_at DESC;
-- name: get-custom-attribute
SELECT
id,
created_at,
updated_at,
applies_to,
name,
description,
key,
values,
data_type,
regex
FROM
custom_attribute_definitions
WHERE
id = $1;
-- name: insert-custom-attribute
INSERT INTO
custom_attribute_definitions (applies_to, name, description, key, values, data_type, regex)
VALUES
($1, $2, $3, $4, $5, $6, $7)
-- name: delete-custom-attribute
DELETE FROM
custom_attribute_definitions
WHERE
id = $1;
-- name: update-custom-attribute
UPDATE
custom_attribute_definitions
SET
applies_to = $2,
name = $3,
description = $4,
values = $5,
data_type = $6,
regex = $7,
updated_at = NOW()
WHERE
id = $1;

View File

@@ -75,5 +75,37 @@ func V0_6_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
return err
}
// Add `custom_attributes:manage` permission to Admin role
_, err = db.Exec(`
UPDATE roles
SET permissions = array_append(permissions, 'custom_attributes:manage')
WHERE name = 'Admin' AND NOT ('custom_attributes:manage' = ANY(permissions));
`)
if err != nil {
return err
}
// Create table for custom attribute definitions
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS custom_attribute_definitions (
id SERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
"name" TEXT NOT NULL,
description TEXT NOT NULL,
applies_to TEXT NOT NULL,
key TEXT NOT NULL,
values TEXT[] DEFAULT '{}'::TEXT[] NOT NULL,
data_type TEXT NOT NULL,
regex TEXT NULL,
CONSTRAINT constraint_custom_attribute_definitions_on_name CHECK (length("name") <= 140),
CONSTRAINT constraint_custom_attribute_definitions_on_description CHECK (length(description) <= 300),
CONSTRAINT constraint_custom_attribute_definitions_on_key CHECK (length(key) <= 140),
CONSTRAINT constraint_custom_attribute_definitions_key_applies_to_unique UNIQUE (key, applies_to)
);
`)
if err != nil {
return err
}
return nil
}

View File

@@ -1,6 +1,7 @@
package models
import (
"encoding/json"
"time"
tmodels "github.com/abhinavxd/libredesk/internal/team/models"
@@ -42,7 +43,7 @@ type User struct {
Roles pq.StringArray `db:"roles" json:"roles"`
Permissions pq.StringArray `db:"permissions" json:"permissions"`
Meta pq.StringArray `db:"meta" json:"meta"`
CustomAttributes pq.StringArray `db:"custom_attributes" json:"custom_attributes"`
CustomAttributes json.RawMessage `db:"custom_attributes" json:"custom_attributes"`
Teams tmodels.Teams `db:"teams" json:"teams"`
ContactChannelID int `db:"contact_channel_id" json:"contact_channel_id,omitempty"`
NewPassword string `db:"-" json:"new_password,omitempty"`

View File

@@ -95,6 +95,12 @@ SET first_name = COALESCE($2, first_name),
updated_at = now()
WHERE id = $1;
-- name: update-custom-attributes
UPDATE users
SET custom_attributes = $2,
updated_at = now()
WHERE id = $1;
-- name: update-avatar
UPDATE users
SET avatar_url = $2, updated_at = now()

View File

@@ -5,6 +5,7 @@ import (
"context"
"database/sql"
"embed"
"encoding/json"
"errors"
"fmt"
"os"
@@ -63,6 +64,7 @@ type queries struct {
GetAgentsCompact *sqlx.Stmt `query:"get-agents-compact"`
UpdateContact *sqlx.Stmt `query:"update-contact"`
UpdateAgent *sqlx.Stmt `query:"update-agent"`
UpdateCustomAttributes *sqlx.Stmt `query:"update-custom-attributes"`
UpdateAvatar *sqlx.Stmt `query:"update-avatar"`
UpdateAvailability *sqlx.Stmt `query:"update-availability"`
UpdateLastActiveAt *sqlx.Stmt `query:"update-last-active-at"`
@@ -353,6 +355,23 @@ func (u *Manager) MonitorAgentAvailability(ctx context.Context) {
}
}
// UpdateCustomAttributes updates the custom attributes of an user.
func (u *Manager) UpdateCustomAttributes(id int, customAttributes map[string]any) error {
// Convert custom attributes to JSON.
jsonb, err := json.Marshal(customAttributes)
if err != nil {
u.lo.Error("error marshalling custom attributes to JSON", "error", err)
return envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}"), nil)
}
// Update custom attributes in the database.
if _, err := u.q.UpdateCustomAttributes.Exec(id, jsonb); err != nil {
u.lo.Error("error updating user custom attributes", "error", err)
return envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}"), nil)
}
return nil
}
// makeUserListQuery generates a query to fetch users based on the provided filters.
func (u *Manager) makeUserListQuery(page, pageSize int, typ, order, orderBy, filtersJSON string) (string, []interface{}, error) {
var (

View File

@@ -496,6 +496,24 @@ CREATE TABLE ai_prompts (
);
CREATE INDEX index_ai_prompts_on_key ON ai_prompts USING btree (key);
DROP TABLE IF EXISTS custom_attribute_definitions CASCADE;
CREATE TABLE custom_attribute_definitions (
id SERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
"name" TEXT NOT NULL,
description TEXT NOT NULL,
applies_to TEXT NOT NULL,
key TEXT NOT NULL,
values TEXT[] DEFAULT '{}'::TEXT[] NOT NULL,
data_type TEXT NOT NULL,
regex TEXT NULL,
CONSTRAINT constraint_custom_attribute_definitions_on_name CHECK (length("name") <= 140),
CONSTRAINT constraint_custom_attribute_definitions_on_description CHECK (length(description) <= 300),
CONSTRAINT constraint_custom_attribute_definitions_on_key CHECK (length(key) <= 140),
CONSTRAINT constraint_custom_attribute_definitions_key_applies_to_unique UNIQUE (key, applies_to)
);
INSERT INTO ai_providers
("name", provider, config, is_default)
VALUES('openai', 'openai', '{"api_key": ""}'::jsonb, true);
@@ -565,7 +583,7 @@ VALUES
(
'Admin',
'Role for users who have complete access to everything.',
'{contacts:manage,conversations:write,ai:manage,general_settings:manage,notification_settings:manage,oidc:manage,conversations:read_all,conversations:read_unassigned,conversations:read_assigned,conversations:read_team_inbox,conversations:read,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write,view:manage,status:manage,tags:manage,macros:manage,users:manage,teams:manage,automations:manage,inboxes:manage,roles:manage,reports:manage,templates:manage,business_hours:manage,sla:manage}'
'{custom_attributes:manage,contacts:manage,conversations:write,ai:manage,general_settings:manage,notification_settings:manage,oidc:manage,conversations:read_all,conversations:read_unassigned,conversations:read_assigned,conversations:read_team_inbox,conversations:read,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write,view:manage,status:manage,tags:manage,macros:manage,users:manage,teams:manage,automations:manage,inboxes:manage,roles:manage,reports:manage,templates:manage,business_hours:manage,sla:manage}'
);