Skip to content

Commit 2fc642c

Browse files
committed
feat: allow using contact custom attributes in automation rules
1 parent 488f14e commit 2fc642c

File tree

13 files changed

+256
-56
lines changed

13 files changed

+256
-56
lines changed

cmd/custom_attributes.go

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"slices"
45
"strconv"
56

67
cmodels "github.com/abhinavxd/libredesk/internal/custom_attribute/models"
@@ -9,6 +10,22 @@ import (
910
"github.com/zerodha/fastglue"
1011
)
1112

13+
var (
14+
// disallowedKeys contains keys that are not allowed for custom attributes as they're the default fields.
15+
disallowedKeys = []string{
16+
"contact_email",
17+
"content",
18+
"subject",
19+
"status",
20+
"priority",
21+
"assigned_team",
22+
"assigned_user",
23+
"hours_since_created",
24+
"hours_since_resolved",
25+
"inbox",
26+
}
27+
)
28+
1229
// handleGetCustomAttribute retrieves a custom attribute by its ID.
1330
func handleGetCustomAttribute(r *fastglue.Request) error {
1431
var (
@@ -76,7 +93,6 @@ func handleUpdateCustomAttribute(r *fastglue.Request) error {
7693
if err = app.customAttribute.Update(id, attribute); err != nil {
7794
return sendErrorEnvelope(r, err)
7895
}
79-
8096
return r.SendEnvelope(true)
8197
}
8298

@@ -89,11 +105,9 @@ func handleDeleteCustomAttribute(r *fastglue.Request) error {
89105
if err != nil || id <= 0 {
90106
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
91107
}
92-
93108
if err = app.customAttribute.Delete(id); err != nil {
94109
return sendErrorEnvelope(r, err)
95110
}
96-
97111
return r.SendEnvelope(true)
98112
}
99113

@@ -114,5 +128,8 @@ func validateCustomAttribute(app *App, attribute cmodels.CustomAttribute) error
114128
if attribute.Key == "" {
115129
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`key`"), nil)
116130
}
131+
if slices.Contains(disallowedKeys, attribute.Key) {
132+
return envelope.NewError(envelope.InputError, app.i18n.T("admin.customAttributes.keyNotAllowed"), nil)
133+
}
117134
return nil
118135
}

frontend/src/composables/useConversationFilters.js

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useInboxStore } from '@/stores/inbox'
44
import { useUsersStore } from '@/stores/users'
55
import { useTeamStore } from '@/stores/team'
66
import { useSlaStore } from '@/stores/sla'
7+
import { useCustomAttributeStore } from '@/stores/customAttributes'
78
import { FIELD_TYPE, FIELD_OPERATORS } from '@/constants/filterConfig'
89

910
export function useConversationFilters () {
@@ -12,6 +13,25 @@ export function useConversationFilters () {
1213
const uStore = useUsersStore()
1314
const tStore = useTeamStore()
1415
const slaStore = useSlaStore()
16+
const customAttributeStore = useCustomAttributeStore()
17+
18+
const customAttributeDataTypeToFieldType = {
19+
'text': FIELD_TYPE.TEXT,
20+
'number': FIELD_TYPE.NUMBER,
21+
'checkbox': FIELD_TYPE.BOOLEAN,
22+
'date': FIELD_TYPE.DATE,
23+
'link': FIELD_TYPE.TEXT,
24+
'list': FIELD_TYPE.SELECT,
25+
}
26+
27+
const customAttributeDataTypeToFieldOperators = {
28+
'text': FIELD_OPERATORS.TEXT,
29+
'number': FIELD_OPERATORS.NUMBER,
30+
'checkbox': FIELD_OPERATORS.BOOLEAN,
31+
'date': FIELD_OPERATORS.DATE,
32+
'link': FIELD_OPERATORS.TEXT,
33+
'list': FIELD_OPERATORS.SELECT,
34+
}
1535

1636
const conversationsListFilters = computed(() => ({
1737
status_id: {
@@ -46,6 +66,23 @@ export function useConversationFilters () {
4666
}
4767
}))
4868

69+
const contactCustomAttributes = computed(() => {
70+
return customAttributeStore.contactAttributeOptions
71+
.filter(attribute => attribute.applies_to === 'contact')
72+
.reduce((acc, attribute) => {
73+
acc[attribute.key] = {
74+
label: attribute.label,
75+
type: customAttributeDataTypeToFieldType[attribute.data_type] || FIELD_TYPE.TEXT,
76+
operators: customAttributeDataTypeToFieldOperators[attribute.data_type] || FIELD_OPERATORS.TEXT,
77+
options: attribute.values.map(value => ({
78+
label: value,
79+
value: value
80+
})) || [],
81+
}
82+
return acc
83+
}, {})
84+
})
85+
4986
const newConversationFilters = computed(() => ({
5087
contact_email: {
5188
label: 'Email',
@@ -223,6 +260,7 @@ export function useConversationFilters () {
223260
conversationFilters,
224261
newConversationFilters,
225262
conversationActions,
226-
macroActions
263+
macroActions,
264+
contactCustomAttributes,
227265
}
228266
}

frontend/src/constants/filterConfig.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ export const FIELD_TYPE = {
33
TAG: 'tag',
44
TEXT: 'text',
55
NUMBER: 'number',
6-
RICHTEXT: 'richtext'
6+
RICHTEXT: 'richtext',
7+
BOOLEAN: 'boolean',
8+
DATE: 'date',
79
}
810

911
export const OPERATOR = {
@@ -19,6 +21,7 @@ export const OPERATOR = {
1921

2022
export const FIELD_OPERATORS = {
2123
SELECT: [OPERATOR.EQUALS, OPERATOR.NOT_EQUALS, OPERATOR.SET, OPERATOR.NOT_SET],
24+
BOOLEAN: [OPERATOR.EQUALS, OPERATOR.NOT_EQUALS],
2225
TEXT: [
2326
OPERATOR.EQUALS,
2427
OPERATOR.NOT_EQUALS,
@@ -27,5 +30,13 @@ export const FIELD_OPERATORS = {
2730
OPERATOR.CONTAINS,
2831
OPERATOR.NOT_CONTAINS
2932
],
30-
NUMBER: [OPERATOR.GREATER_THAN, OPERATOR.LESS_THAN]
33+
DATE: [
34+
OPERATOR.EQUALS,
35+
OPERATOR.NOT_EQUALS,
36+
OPERATOR.SET,
37+
OPERATOR.NOT_SET,
38+
OPERATOR.GREATER_THAN,
39+
OPERATOR.LESS_THAN
40+
],
41+
NUMBER: [OPERATOR.EQUALS, OPERATOR.NOT_EQUALS],
3142
}

frontend/src/features/admin/automation/RuleBox.vue

Lines changed: 82 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,20 @@
4141
</SelectTrigger>
4242
<SelectContent>
4343
<SelectGroup>
44+
<!-- Conversation fields -->
4445
<SelectLabel>{{ $t('globals.terms.conversation') }}</SelectLabel>
4546
<SelectItem v-for="(field, key) in currentFilters" :key="key" :value="key">
4647
{{ field.label }}
4748
</SelectItem>
49+
<!-- Contact custom attributes -->
50+
<SelectLabel>{{ $t('globals.terms.contact') }}</SelectLabel>
51+
<SelectItem
52+
v-for="(field, key) in contactCustomAttributes"
53+
:key="key"
54+
:value="key"
55+
>
56+
{{ field.label }}
57+
</SelectItem>
4858
</SelectGroup>
4959
</SelectContent>
5060
</Select>
@@ -60,7 +70,7 @@
6070
<SelectContent>
6171
<SelectGroup>
6272
<SelectItem
63-
v-for="(op, key) in getFieldOperators(rule.field)"
73+
v-for="(op, key) in getFieldOperators(rule.field, rule.field_type)"
6474
:key="key"
6575
:value="op"
6676
>
@@ -94,7 +104,7 @@
94104
<div v-if="inputType(index) === 'select'">
95105
<ComboBox
96106
v-model="rule.value"
97-
:items="getFieldOptions(rule.field)"
107+
:items="getFieldOptions(rule.field, rule.field_type)"
98108
@select="handleValueChange($event, index)"
99109
>
100110
<template #item="{ item }">
@@ -128,7 +138,7 @@
128138
<div v-if="selected" class="flex items-center gap-2">
129139
<Avatar class="w-7 h-7">
130140
<AvatarImage
131-
:src="selected.avatar_url ?? ''"
141+
:src="selected.avatar_url || ''"
132142
:alt="selected.label.slice(0, 2)"
133143
/>
134144
<AvatarFallback>
@@ -167,8 +177,39 @@
167177
{{ $t('globals.messages.pressEnterToSelectAValue') }}
168178
</p>
169179
</div>
180+
181+
<!-- Date input -->
182+
<Input
183+
type="date"
184+
:placeholder="t('form.field.setValue')"
185+
v-if="inputType(index) === 'date'"
186+
v-model="rule.value"
187+
@update:modelValue="(value) => handleValueChange(value, index)"
188+
/>
189+
190+
<!-- Boolean / Checkbox input -->
191+
<Select
192+
v-model="rule.value"
193+
@update:modelValue="(value) => handleValueChange(value, index)"
194+
v-if="inputType(index) === 'boolean'"
195+
>
196+
<SelectTrigger>
197+
<SelectValue :placeholder="t('form.field.selectValue')" />
198+
</SelectTrigger>
199+
<SelectContent>
200+
<SelectGroup>
201+
<SelectItem value="true">True</SelectItem>
202+
<SelectItem value="false">False</SelectItem>
203+
</SelectGroup>
204+
</SelectContent>
205+
</Select>
170206
</div>
171207

208+
<!-- Placeholder for spacing -->
209+
<div v-else class="flex-1">
210+
</div>
211+
212+
172213
<!-- Remove condition -->
173214
<div class="cursor-pointer mt-2" @click.prevent="removeCondition(index)">
174215
<X size="16" />
@@ -242,7 +283,12 @@ const props = defineProps({
242283
}
243284
})
244285
245-
const { conversationFilters, newConversationFilters } = useConversationFilters()
286+
const fieldTypeConstants = {
287+
conversation: 'conversation',
288+
contact_custom_attribute: 'contact_custom_attribute'
289+
}
290+
const { conversationFilters, newConversationFilters, contactCustomAttributes } =
291+
useConversationFilters()
246292
const { ruleGroup } = toRefs(props)
247293
const emit = defineEmits(['update-group', 'add-condition', 'remove-condition'])
248294
const { t } = useI18n()
@@ -272,9 +318,16 @@ const handleGroupOperator = (value) => {
272318
}
273319
274320
const handleFieldChange = (value, ruleIndex) => {
321+
// Set the field type based on the selected field value.
322+
let fieldType = fieldTypeConstants.conversation
323+
if (contactCustomAttributes.value[value]) {
324+
fieldType = fieldTypeConstants.contact_custom_attribute
325+
}
326+
275327
ruleGroup.value.rules[ruleIndex].operator = ''
276328
ruleGroup.value.rules[ruleIndex].value = ''
277329
ruleGroup.value.rules[ruleIndex].field = value
330+
ruleGroup.value.rules[ruleIndex].field_type = fieldType
278331
emitUpdate()
279332
}
280333
@@ -326,19 +379,39 @@ const emitUpdate = () => {
326379
emit('update-group', ruleGroup, props.groupIndex)
327380
}
328381
329-
const getFieldOperators = (field) => {
330-
return currentFilters.value[field]?.operators || []
382+
const getFieldOperators = (field, fieldType) => {
383+
if (fieldType === fieldTypeConstants.contact_custom_attribute) {
384+
return contactCustomAttributes.value[field]?.operators || []
385+
}
386+
if (fieldType === fieldTypeConstants.conversation) {
387+
return currentFilters.value[field]?.operators || []
388+
}
389+
return []
331390
}
332391
333-
const getFieldOptions = (field) => {
334-
return currentFilters.value[field]?.options || []
392+
const getFieldOptions = (field, fieldType) => {
393+
if (fieldType === fieldTypeConstants.contact_custom_attribute) {
394+
return contactCustomAttributes.value[field]?.options || []
395+
}
396+
if (fieldType === fieldTypeConstants.conversation) {
397+
return currentFilters.value[field]?.options || []
398+
}
399+
return []
335400
}
336401
337402
const inputType = (index) => {
338403
const field = ruleGroup.value.rules[index]?.field
404+
const fieldType = ruleGroup.value.rules[index]?.field_type
339405
const operator = ruleGroup.value.rules[index]?.operator
340406
if (['contains', 'not contains'].includes(operator)) return 'tag'
341-
if (field) return currentFilters.value[field].type
407+
if (field && fieldType) {
408+
if (fieldType === fieldTypeConstants.contact_custom_attribute) {
409+
return contactCustomAttributes.value[field]?.type || ''
410+
}
411+
if (fieldType === fieldTypeConstants.conversation) {
412+
return currentFilters.value[field]?.type || ''
413+
}
414+
}
342415
return ''
343416
}
344417

frontend/src/features/admin/custom-attributes/CustomAttributesForm.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@
6666
<FormItem>
6767
<FormLabel>{{ $t('form.field.type') }}</FormLabel>
6868
<FormControl>
69-
<Select v-bind="componentField">
69+
<Select v-bind="componentField" :disabled="form.values.id && form.values.id > 0">
7070
<SelectTrigger>
7171
<SelectValue />
7272
</SelectTrigger>

frontend/src/features/admin/custom-attributes/dataTableColumns.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,15 @@ export const createColumns = (t) => [
2121
return h('div', { class: 'text-center font-medium' }, row.getValue('key'))
2222
}
2323
},
24+
{
25+
accessorKey: 'data_type',
26+
header: function () {
27+
return h('div', { class: 'text-center' }, t('form.field.type'))
28+
},
29+
cell: function ({ row }) {
30+
return h('div', { class: 'text-center font-medium' }, row.getValue('data_type'))
31+
}
32+
},
2433
{
2534
accessorKey: 'applies_to',
2635
header: function () {

frontend/src/features/contact/ContactNotes.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
<Card
5656
v-for="note in notes"
5757
:key="note.id"
58-
class="overflow-hidden border-gray-2 00 hover:border-gray-300 transition-all duration-200 box hover:shadow"
58+
class="overflow-hidden border-gray-2 hover:border-gray-300 transition-all duration-200 box hover:shadow"
5959
>
6060
<!-- Header -->
6161
<CardHeader class="bg-gray-50/50 border-b p-2">

i18n/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -613,5 +613,6 @@
613613
"contact.notes.help": " Add note for this contact to keep track of important information and conversations.",
614614
"admin.customAttributes.deleteConfirmation": "This action cannot be undone. This will permanently delete this custom attribute.",
615615
"admin.customAttributes.regex.description": "Regex to validate the value of this custom attribute. Leave empty to skip validation.",
616-
"admin.customAttributes.regexHint.description": "Regex pattern hint."
616+
"admin.customAttributes.regexHint.description": "Regex pattern hint.",
617+
"admin.customAttributes.keyNotAllowed": "The provided key is not allowed as it conflicts with default attributes. Please use a different key."
617618
}

internal/automation/automation.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import (
2525
var (
2626
//go:embed queries.sql
2727
efs embed.FS
28-
// MaxQueueSize defines the maximum size of the task queues.
28+
// MaxQueueSize is the maximum size of the task queue.
2929
MaxQueueSize = 5000
3030
)
3131

0 commit comments

Comments
 (0)