mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-10-24 00:23:36 +00:00
feat: custom attributes for contacts and conversations
This commit is contained in:
@@ -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
118
cmd/custom_attributes.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
15
cmd/init.go
15
cmd/init.go
@@ -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")
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const EMITTER_EVENTS = {
|
||||
EDIT_MODEL: 'edit-model',
|
||||
REFRESH_LIST: 'refresh-list',
|
||||
SHOW_TOAST: 'show-toast',
|
||||
SHOW_SOONER: 'show-sooner',
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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>
|
||||
81
frontend/src/features/admin/custom-attributes/formSchema.js
Normal file
81
frontend/src/features/admin/custom-attributes/formSchema.js
Normal 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'],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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') }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
268
frontend/src/features/conversation/sidebar/CustomAttributes.vue
Normal file
268
frontend/src/features/conversation/sidebar/CustomAttributes.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
47
frontend/src/stores/customAttributes.js
Normal file
47
frontend/src/stores/customAttributes.js
Normal 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,
|
||||
}
|
||||
})
|
||||
218
frontend/src/views/admin/custom-attributes/CustomAttributes.vue
Normal file
218
frontend/src/views/admin/custom-attributes/CustomAttributes.vue
Normal 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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
18
i18n/en.json
18
i18n/en.json
@@ -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."
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
109
internal/custom_attribute/custom_attribute.go
Normal file
109
internal/custom_attribute/custom_attribute.go
Normal 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
|
||||
}
|
||||
20
internal/custom_attribute/models/models.go
Normal file
20
internal/custom_attribute/models/models.go
Normal 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"`
|
||||
}
|
||||
63
internal/custom_attribute/queries.sql
Normal file
63
internal/custom_attribute/queries.sql
Normal 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;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 (
|
||||
|
||||
20
schema.sql
20
schema.sql
@@ -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}'
|
||||
);
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user