From 3247a3aa58acf40f986721a1b5c266eaa2900537 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Fri, 5 Jun 2026 14:44:52 +0530 Subject: [PATCH 01/12] feat: support nested AND/OR filter groups and new fields in views --- cmd/views.go | 9 + .../src/components/filter/ConnectorToggle.vue | 22 ++ .../components/filter/FilterGroupBuilder.vue | 49 +++ .../src/components/filter/FilterGroupCard.vue | 48 +++ .../main/src/components/filter/FilterRow.vue | 175 +++++++++ .../main/src/components/filter/filterTree.js | 102 +++++ .../src/composables/useConversationFilters.js | 62 +++ .../apps/main/src/constants/filterConfig.js | 16 + .../src/features/admin/agents/AgentForm.vue | 6 +- .../src/features/admin/automation/RuleBox.vue | 3 +- .../admin/shared-views/SharedViewForm.vue | 110 ++---- .../apps/main/src/features/view/ViewForm.vue | 121 ++---- i18n/en-US.json | 14 +- internal/activity_log/activity_log.go | 2 +- internal/conversation/conversation.go | 133 +++---- internal/dbutil/builder.go | 357 ++++++++++++------ internal/dbutil/builder_test.go | 136 +++++++ internal/user/user.go | 2 +- 18 files changed, 1009 insertions(+), 358 deletions(-) create mode 100644 frontend/apps/main/src/components/filter/ConnectorToggle.vue create mode 100644 frontend/apps/main/src/components/filter/FilterGroupBuilder.vue create mode 100644 frontend/apps/main/src/components/filter/FilterGroupCard.vue create mode 100644 frontend/apps/main/src/components/filter/FilterRow.vue create mode 100644 frontend/apps/main/src/components/filter/filterTree.js create mode 100644 internal/dbutil/builder_test.go diff --git a/cmd/views.go b/cmd/views.go index cfb8d5b9..1c945694 100644 --- a/cmd/views.go +++ b/cmd/views.go @@ -47,6 +47,9 @@ func handleCreateUserView(r *fastglue.Request) error { if string(view.Filters) == "" { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`Filters`"), nil, envelope.InputError) } + if err := app.conversation.ValidateListFilters(string(view.Filters)); err != nil { + return sendErrorEnvelope(r, err) + } createdView, err := app.view.Create(view.Name, view.Filters, user.ID) if err != nil { return sendErrorEnvelope(r, err) @@ -106,6 +109,9 @@ func handleUpdateUserView(r *fastglue.Request) error { if string(view.Filters) == "" { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`filters`"), nil, envelope.InputError) } + if err := app.conversation.ValidateListFilters(string(view.Filters)); err != nil { + return sendErrorEnvelope(r, err) + } v, err := app.view.Get(id) if err != nil { return sendErrorEnvelope(r, err) @@ -264,6 +270,9 @@ func validateSharedView(app *App, view vmodels.View) error { if string(view.Filters) == "" { return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`filters`"), nil) } + if err := app.conversation.ValidateListFilters(string(view.Filters)); err != nil { + return err + } if view.Visibility != vmodels.VisibilityAll && view.Visibility != vmodels.VisibilityTeam { return envelope.NewError(envelope.InputError, app.i18n.T("globals.messages.somethingWentWrong"), nil) } diff --git a/frontend/apps/main/src/components/filter/ConnectorToggle.vue b/frontend/apps/main/src/components/filter/ConnectorToggle.vue new file mode 100644 index 00000000..fbdcc9cf --- /dev/null +++ b/frontend/apps/main/src/components/filter/ConnectorToggle.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/apps/main/src/components/filter/FilterGroupBuilder.vue b/frontend/apps/main/src/components/filter/FilterGroupBuilder.vue new file mode 100644 index 00000000..e4ee6a08 --- /dev/null +++ b/frontend/apps/main/src/components/filter/FilterGroupBuilder.vue @@ -0,0 +1,49 @@ + + + diff --git a/frontend/apps/main/src/components/filter/FilterGroupCard.vue b/frontend/apps/main/src/components/filter/FilterGroupCard.vue new file mode 100644 index 00000000..213a5c70 --- /dev/null +++ b/frontend/apps/main/src/components/filter/FilterGroupCard.vue @@ -0,0 +1,48 @@ + + + diff --git a/frontend/apps/main/src/components/filter/FilterRow.vue b/frontend/apps/main/src/components/filter/FilterRow.vue new file mode 100644 index 00000000..ee0897e4 --- /dev/null +++ b/frontend/apps/main/src/components/filter/FilterRow.vue @@ -0,0 +1,175 @@ + + + diff --git a/frontend/apps/main/src/components/filter/filterTree.js b/frontend/apps/main/src/components/filter/filterTree.js new file mode 100644 index 00000000..5f8d7748 --- /dev/null +++ b/frontend/apps/main/src/components/filter/filterTree.js @@ -0,0 +1,102 @@ +import { FIELD_TYPE, LOGIC, OPERATOR } from '@/constants/filterConfig' + +let _seq = 0 +const uid = () => `f${++_seq}` + +export const createLeaf = () => ({ __id: uid(), model: '', field: '', operator: '', value: '' }) + +export const createGroup = (logic = LOGIC.AND) => ({ __id: uid(), logic, rules: [createLeaf()] }) + +export const createRoot = (logic = LOGIC.AND) => ({ __id: uid(), logic, rules: [createGroup()] }) + +export const isGroupNode = (node) => + !!node && (Array.isArray(node.rules) || typeof node.logic === 'string') + +export const collectLeaves = (node) => + isGroupNode(node) ? (node.rules || []).flatMap(collectLeaves) : [node] + +export const isPartialLeaf = (leaf) => + !leaf.field || + !leaf.operator || + (![OPERATOR.SET, OPERATOR.NOT_SET].includes(leaf.operator) && + (leaf.value === undefined || + leaf.value === null || + leaf.value === '' || + (Array.isArray(leaf.value) && leaf.value.length === 0))) + +const keyed = (node) => ({ __id: node.__id || uid(), ...node }) + +const toStringIdArray = (value) => { + if (Array.isArray(value)) return value + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? parsed.map((v) => String(v)) : [] + } catch { + return [] + } + } + return [] +} + +const withIds = (node) => { + if (isGroupNode(node)) { + return { __id: node.__id || uid(), logic: node.logic || LOGIC.AND, rules: (node.rules || []).map(withIds) } + } + return keyed(node) +} + +// normalizeToTwoLevel coerces any stored shape (legacy flat array, old nested tree, new two-level) +// into a strict { logic, rules: [ {logic, rules:[leaf,...]}, ... ] }: the top level holds only groups, +// and each group holds only leaves. Existing groups are preserved; loose leaves are collected into one +// group so semantics (e.g. status AND (high OR low)) survive the coercion. +export const normalizeToTwoLevel = (filters) => { + if (Array.isArray(filters)) { + return withIds({ logic: LOGIC.AND, rules: [{ logic: LOGIC.AND, rules: filters.map((f) => ({ ...f })) }] }) + } + if (!isGroupNode(filters)) return createRoot() + + const outerLogic = filters.logic || LOGIC.AND + const topRules = filters.rules || [] + const groups = [] + const looseLeaves = [] + for (const r of topRules) { + if (isGroupNode(r)) { + groups.push({ logic: r.logic || LOGIC.AND, rules: collectLeaves(r).map((l) => ({ ...l })) }) + } else { + looseLeaves.push({ ...r }) + } + } + if (looseLeaves.length) groups.unshift({ logic: outerLogic, rules: looseLeaves }) + if (groups.length === 0) groups.push({ logic: LOGIC.AND, rules: [createLeaf()] }) + return withIds({ logic: outerLogic, rules: groups }) +} + +// serializeFilterTree drops UI-only __id and converts multi-select array values to JSON strings of numeric IDs. +export const serializeFilterTree = (node) => { + if (isGroupNode(node)) { + return { logic: node.logic || LOGIC.AND, rules: (node.rules || []).map(serializeFilterTree) } + } + const leaf = { model: node.model, field: node.field, operator: node.operator, value: node.value } + if (Array.isArray(leaf.value)) { + leaf.value = JSON.stringify( + leaf.value.map((v) => { + const num = Number(v) + return isNaN(num) ? v : num + }) + ) + } + return leaf +} + +// deserializeFilterTree restores multi-select string values to string-ID arrays and keeps __id for stable keys. +export const deserializeFilterTree = (node, fields) => { + if (isGroupNode(node)) { + return { __id: node.__id || uid(), logic: node.logic || LOGIC.AND, rules: (node.rules || []).map((n) => deserializeFilterTree(n, fields)) } + } + const field = fields.find((f) => f.field === node.field) + if (field?.type === FIELD_TYPE.MULTI_SELECT) { + return keyed({ ...node, value: toStringIdArray(node.value) }) + } + return keyed(node) +} diff --git a/frontend/apps/main/src/composables/useConversationFilters.js b/frontend/apps/main/src/composables/useConversationFilters.js index 537cbcbb..cc09e94e 100644 --- a/frontend/apps/main/src/composables/useConversationFilters.js +++ b/frontend/apps/main/src/composables/useConversationFilters.js @@ -78,6 +78,68 @@ export function useConversationFilters () { label: t('globals.terms.createdAt'), type: FIELD_TYPE.DATE, operators: FIELD_OPERATORS.DATE + }, + waiting_since: { + label: t('globals.terms.waitingSince'), + type: FIELD_TYPE.DATE, + operators: FIELD_OPERATORS.DATE + }, + snoozed_until: { + label: t('globals.terms.snoozedUntil'), + type: FIELD_TYPE.DATE, + operators: FIELD_OPERATORS.DATE + }, + last_message_at: { + label: t('globals.terms.lastMessageAt'), + type: FIELD_TYPE.DATE, + operators: FIELD_OPERATORS.DATE + }, + last_interaction_at: { + label: t('globals.terms.lastInteractionAt'), + type: FIELD_TYPE.DATE, + operators: FIELD_OPERATORS.DATE + }, + next_sla_deadline_at: { + label: t('globals.terms.nextSlaDeadline'), + type: FIELD_TYPE.DATE, + operators: FIELD_OPERATORS.DATE + }, + email: { + label: t('globals.terms.contactEmail'), + type: FIELD_TYPE.TEXT, + operators: FIELD_OPERATORS.TEXT, + model: 'users' + }, + external_user_id: { + label: t('globals.terms.contactExternalId'), + type: FIELD_TYPE.TEXT, + operators: FIELD_OPERATORS.SELECT, + model: 'users' + }, + last_interaction_sender: { + label: t('globals.terms.lastInteractionBy'), + type: FIELD_TYPE.SELECT, + operators: FIELD_OPERATORS.SELECT, + options: [ + { label: t('globals.terms.contact'), value: 'contact' }, + { label: t('globals.terms.agent'), value: 'agent' } + ] + }, + sla_policy_id: { + label: t('globals.terms.slaPolicy'), + type: FIELD_TYPE.SELECT, + operators: FIELD_OPERATORS.SELECT, + options: slaStore.options + }, + channel: { + label: t('globals.terms.channel'), + type: FIELD_TYPE.SELECT, + operators: FIELD_OPERATORS.SELECT, + options: [ + { label: t('globals.terms.email'), value: 'email' }, + { label: t('globals.terms.liveChat'), value: 'livechat' } + ], + model: 'inboxes' } })) diff --git a/frontend/apps/main/src/constants/filterConfig.js b/frontend/apps/main/src/constants/filterConfig.js index 08435dc5..bdfa7d6b 100644 --- a/frontend/apps/main/src/constants/filterConfig.js +++ b/frontend/apps/main/src/constants/filterConfig.js @@ -1,3 +1,8 @@ +export const LOGIC = { + AND: 'AND', + OR: 'OR' +} + export const FIELD_TYPE = { SELECT: 'select', TAG: 'tag', @@ -21,6 +26,17 @@ export const OPERATOR = { BETWEEN: 'between' } +// operatorLabel returns a clearer display label for operators whose meaning is ambiguous with +// multiple values (contains = matches ANY of the values). Other operators display as-is. +const OPERATOR_LABEL_KEYS = { + [OPERATOR.CONTAINS]: 'filter.containsAnyOf', + [OPERATOR.NOT_CONTAINS]: 'filter.containsNoneOf' +} +export const operatorLabel = (op, t) => { + const key = OPERATOR_LABEL_KEYS[op] + return key ? t(key) : op +} + export const FIELD_OPERATORS = { SELECT: [OPERATOR.EQUALS, OPERATOR.NOT_EQUALS, OPERATOR.SET, OPERATOR.NOT_SET], BOOLEAN: [OPERATOR.EQUALS, OPERATOR.NOT_EQUALS], diff --git a/frontend/apps/main/src/features/admin/agents/AgentForm.vue b/frontend/apps/main/src/features/admin/agents/AgentForm.vue index d72bc98c..2f5957d7 100644 --- a/frontend/apps/main/src/features/admin/agents/AgentForm.vue +++ b/frontend/apps/main/src/features/admin/agents/AgentForm.vue @@ -384,9 +384,9 @@ const isAPIKeyLoading = ref(false) onMounted(async () => { try { - const [teamsResp, rolesResp] = await Promise.allSettled([api.getTeams(), api.getRoles()]) - teams.value = teamsResp.value.data.data - roles.value = rolesResp.value.data.data + const [teamsResp, rolesResp] = await Promise.allSettled([api.getTeamsCompact(), api.getRoles()]) + if (teamsResp.status === 'fulfilled') teams.value = teamsResp.value.data.data + if (rolesResp.status === 'fulfilled') roles.value = rolesResp.value.data.data } catch (err) { emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { variant: 'destructive', diff --git a/frontend/apps/main/src/features/admin/automation/RuleBox.vue b/frontend/apps/main/src/features/admin/automation/RuleBox.vue index 6ecd273e..0c2f3756 100644 --- a/frontend/apps/main/src/features/admin/automation/RuleBox.vue +++ b/frontend/apps/main/src/features/admin/automation/RuleBox.vue @@ -76,7 +76,7 @@ :key="key" :value="op" > - {{ op }} + {{ operatorLabel(op, t) }} @@ -221,6 +221,7 @@ import { Input } from '@shared-ui/components/ui/input' import { useI18n } from 'vue-i18n' import { useConversationFilters } from '../../../composables/useConversationFilters' import SelectComboBox from '@main/components/combobox/SelectCombobox.vue' +import { operatorLabel } from '@/constants/filterConfig' const props = defineProps({ ruleGroup: { diff --git a/frontend/apps/main/src/features/admin/shared-views/SharedViewForm.vue b/frontend/apps/main/src/features/admin/shared-views/SharedViewForm.vue index 05ae829b..1841808b 100644 --- a/frontend/apps/main/src/features/admin/shared-views/SharedViewForm.vue +++ b/frontend/apps/main/src/features/admin/shared-views/SharedViewForm.vue @@ -1,7 +1,7 @@ diff --git a/frontend/apps/main/src/components/filter/FilterGroupCard.vue b/frontend/apps/main/src/components/filter/FilterGroupCard.vue index e814014d..34ab3a20 100644 --- a/frontend/apps/main/src/components/filter/FilterGroupCard.vue +++ b/frontend/apps/main/src/components/filter/FilterGroupCard.vue @@ -13,8 +13,17 @@ @@ -33,6 +39,7 @@ import { normalizeToTwoLevel, isStrictTwoLevel } from '@/components/filter/filterTree' +import { MAX_FILTER_GROUPS } from '@/constants/filterConfig' // vee-validate's componentField carries onInput/onChange listeners; without this they fall // through to the root div and bubbled keystrokes overwrite the whole filters value. @@ -63,6 +70,7 @@ const updateGroup = (index, group) => { } const addGroup = () => { + if (modelValue.value.rules.length >= MAX_FILTER_GROUPS) return modelValue.value = { ...modelValue.value, rules: [...modelValue.value.rules, createGroup()] } } diff --git a/frontend/apps/main/src/constants/filterConfig.js b/frontend/apps/main/src/constants/filterConfig.js index 3f924707..428cde51 100644 --- a/frontend/apps/main/src/constants/filterConfig.js +++ b/frontend/apps/main/src/constants/filterConfig.js @@ -3,6 +3,9 @@ export const LOGIC = { OR: 'OR' } +// Mirrors dbutil.MaxFilterGroups on the backend. +export const MAX_FILTER_GROUPS = 10 + export const FIELD_TYPE = { SELECT: 'select', TAG: 'tag', From 85a200247b3155a581b8df8b1c2174c7a5123dbb Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Tue, 16 Jun 2026 23:25:50 +0530 Subject: [PATCH 10/12] keeps filter builder Add group button visible and tidies group controls --- .../components/filter/FilterGroupBuilder.vue | 26 ++++++++++--------- .../src/components/filter/FilterGroupCard.vue | 17 +++++------- .../admin/shared-views/SharedViewForm.vue | 4 +-- .../features/conversation/Conversation.vue | 2 +- .../apps/main/src/features/view/ViewForm.vue | 4 +-- 5 files changed, 23 insertions(+), 30 deletions(-) diff --git a/frontend/apps/main/src/components/filter/FilterGroupBuilder.vue b/frontend/apps/main/src/components/filter/FilterGroupBuilder.vue index b0129771..41c37ca5 100644 --- a/frontend/apps/main/src/components/filter/FilterGroupBuilder.vue +++ b/frontend/apps/main/src/components/filter/FilterGroupBuilder.vue @@ -1,17 +1,19 @@