diff --git a/cmd/conversation.go b/cmd/conversation.go index 973e98b..2b0caec 100644 --- a/cmd/conversation.go +++ b/cmd/conversation.go @@ -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 ( diff --git a/cmd/custom_attributes.go b/cmd/custom_attributes.go new file mode 100644 index 0000000..4cdafce --- /dev/null +++ b/cmd/custom_attributes.go @@ -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 +} diff --git a/cmd/handlers.go b/cmd/handlers.go index 0d17884..f425af3 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -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) diff --git a/cmd/init.go b/cmd/init.go index 3a3c4a9..925b483 100644 --- a/cmd/init.go +++ b/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") diff --git a/cmd/main.go b/cmd/main.go index d3b4b41..5ff00b2 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -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" @@ -59,33 +60,34 @@ var ( // App is the global app context which is passed and injected in the http handlers. type App struct { - fs stuffbin.FileSystem - consts atomic.Value - auth *auth_.Auth - authz *authz.Enforcer - i18n *i18n.I18n - lo *logf.Logger - oidc *oidc.Manager - media *media.Manager - setting *setting.Manager - role *role.Manager - user *user.Manager - team *team.Manager - status *status.Manager - priority *priority.Manager - tag *tag.Manager - inbox *inbox.Manager - tmpl *template.Manager - macro *macro.Manager - conversation *conversation.Manager - automation *automation.Engine - businessHours *businesshours.Manager - sla *sla.Manager - csat *csat.Manager - view *view.Manager - ai *ai.Manager - search *search.Manager - notifier *notifier.Service + fs stuffbin.FileSystem + consts atomic.Value + auth *auth_.Auth + authz *authz.Enforcer + i18n *i18n.I18n + lo *logf.Logger + oidc *oidc.Manager + media *media.Manager + setting *setting.Manager + role *role.Manager + user *user.Manager + team *team.Manager + status *status.Manager + priority *priority.Manager + tag *tag.Manager + inbox *inbox.Manager + tmpl *template.Manager + macro *macro.Manager + conversation *conversation.Manager + automation *automation.Engine + businessHours *businesshours.Manager + sla *sla.Manager + csat *csat.Manager + view *view.Manager + ai *ai.Manager + search *search.Manager + notifier *notifier.Service + customAttribute *customAttribute.Manager // Global state that stores data on an available app update. update *AppUpdate @@ -197,33 +199,34 @@ func main() { go user.MonitorAgentAvailability(ctx) var app = &App{ - lo: lo, - fs: fs, - sla: sla, - oidc: oidc, - i18n: i18n, - auth: auth, - media: media, - setting: settings, - inbox: inbox, - user: user, - team: team, - status: status, - priority: priority, - tmpl: template, - notifier: notifier, - consts: atomic.Value{}, - conversation: conversation, - automation: automation, - businessHours: businessHours, - authz: initAuthz(i18n), - view: initView(db), - csat: initCSAT(db, i18n), - search: initSearch(db, i18n), - role: initRole(db, i18n), - tag: initTag(db, i18n), - macro: initMacro(db, i18n), - ai: initAI(db, i18n), + lo: lo, + fs: fs, + sla: sla, + oidc: oidc, + i18n: i18n, + auth: auth, + media: media, + setting: settings, + inbox: inbox, + user: user, + team: team, + status: status, + priority: priority, + tmpl: template, + notifier: notifier, + consts: atomic.Value{}, + conversation: conversation, + automation: automation, + businessHours: businessHours, + customAttribute: initCustomAttribute(db, i18n), + authz: initAuthz(i18n), + view: initView(db), + csat: initCSAT(db, i18n), + search: initSearch(db, i18n), + role: initRole(db, i18n), + tag: initTag(db, i18n), + macro: initMacro(db, i18n), + ai: initAI(db, i18n), } app.consts.Store(constants) diff --git a/cmd/middlewares.go b/cmd/middlewares.go index 533e925..7729b95 100644 --- a/cmd/middlewares.go +++ b/cmd/middlewares.go @@ -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) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index c292f55..023209c 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -15,7 +15,11 @@ - + @@ -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() ]) } diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 866200f..1790a7e 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -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, } diff --git a/frontend/src/components/datatable/DataTable.vue b/frontend/src/components/datatable/DataTable.vue index 517b867..62f1a3c 100644 --- a/frontend/src/components/datatable/DataTable.vue +++ b/frontend/src/components/datatable/DataTable.vue @@ -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() }) ) diff --git a/frontend/src/components/sidebar/Sidebar.vue b/frontend/src/components/sidebar/Sidebar.vue index e730328..552e14a 100644 --- a/frontend/src/components/sidebar/Sidebar.vue +++ b/frontend/src/components/sidebar/Sidebar.vue @@ -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) > + + diff --git a/frontend/src/features/admin/custom-attributes/dataTableColumns.js b/frontend/src/features/admin/custom-attributes/dataTableColumns.js new file mode 100644 index 0000000..a0b591f --- /dev/null +++ b/frontend/src/features/admin/custom-attributes/dataTableColumns.js @@ -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 + }) + ) + } + } +] diff --git a/frontend/src/features/admin/custom-attributes/dataTableDropdown.vue b/frontend/src/features/admin/custom-attributes/dataTableDropdown.vue new file mode 100644 index 0000000..fe87b58 --- /dev/null +++ b/frontend/src/features/admin/custom-attributes/dataTableDropdown.vue @@ -0,0 +1,100 @@ + + + diff --git a/frontend/src/features/admin/custom-attributes/formSchema.js b/frontend/src/features/admin/custom-attributes/formSchema.js new file mode 100644 index 0000000..65da82e --- /dev/null +++ b/frontend/src/features/admin/custom-attributes/formSchema.js @@ -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'], + }); + } + } + }); diff --git a/frontend/src/features/admin/roles/RoleForm.vue b/frontend/src/features/admin/roles/RoleForm.vue index 24e4e9f..8c14073 100644 --- a/frontend/src/features/admin/roles/RoleForm.vue +++ b/frontend/src/features/admin/roles/RoleForm.vue @@ -1,5 +1,5 @@ @@ -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') } ] }, { diff --git a/frontend/src/features/conversation/sidebar/ConversationInfo.vue b/frontend/src/features/conversation/sidebar/ConversationInfo.vue index 4180325..7c6e9b0 100644 --- a/frontend/src/features/conversation/sidebar/ConversationInfo.vue +++ b/frontend/src/features/conversation/sidebar/ConversationInfo.vue @@ -1,83 +1,90 @@ @@ -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 + } +} diff --git a/frontend/src/features/conversation/sidebar/ConversationSideBar.vue b/frontend/src/features/conversation/sidebar/ConversationSideBar.vue index 1d8bdf3..af11aa7 100644 --- a/frontend/src/features/conversation/sidebar/ConversationSideBar.vue +++ b/frontend/src/features/conversation/sidebar/ConversationSideBar.vue @@ -1,17 +1,14 @@