diff --git a/cmd/actvity_log.go b/cmd/actvity_log.go index efbf1670..e0012439 100644 --- a/cmd/actvity_log.go +++ b/cmd/actvity_log.go @@ -15,7 +15,7 @@ func handleGetActivityLogs(r *fastglue.Request) error { total = 0 ) page, pageSize := getPagination(r) - logs, err := app.activityLog.GetAll(order, orderBy, filters, page, pageSize) + logs, err := app.activityLog.GetAll(order, orderBy, filters, page, pageSize, app.setting.GetAppTimezone()) if err != nil { return sendErrorEnvelope(r, err) } diff --git a/cmd/contacts.go b/cmd/contacts.go index 68de7c59..c53ecc4b 100644 --- a/cmd/contacts.go +++ b/cmd/contacts.go @@ -32,7 +32,7 @@ func handleGetContacts(r *fastglue.Request) error { total = 0 ) page, pageSize := getPagination(r) - contacts, err := app.user.GetContacts(page, pageSize, order, orderBy, filters) + contacts, err := app.user.GetContacts(page, pageSize, order, orderBy, filters, app.setting.GetAppTimezone()) if err != nil { return sendErrorEnvelope(r, err) } diff --git a/cmd/settings.go b/cmd/settings.go index cd057401..ab90ccd3 100644 --- a/cmd/settings.go +++ b/cmd/settings.go @@ -52,6 +52,9 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error { req.FaviconURL = strings.TrimSpace(req.FaviconURL) req.LogoURL = strings.TrimSpace(req.LogoURL) req.Timezone = strings.TrimSpace(req.Timezone) + if req.Timezone != "" && !stringutil.IsValidTimezone(req.Timezone) { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid timezone.", nil, envelope.InputError) + } // Trim whitespace and trailing slash from root URL. req.RootURL = strings.TrimRight(strings.TrimSpace(req.RootURL), "/") 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/App.vue b/frontend/apps/main/src/App.vue index d9763de0..5cc1c235 100644 --- a/frontend/apps/main/src/App.vue +++ b/frontend/apps/main/src/App.vue @@ -99,7 +99,7 @@ :userTeams="userStore.teams" :userViews="userViews" :sharedViews="sharedViewStore.sharedViewList" - @create-view="openCreateViewForm = true" + @create-view="createView" @edit-view="editView" @delete-view="deleteView" @create-conversation="() => (openCreateConversationDialog = true)" @@ -259,6 +259,11 @@ const initStores = async () => { ]) } +const createView = () => { + view.value = {} + openCreateViewForm.value = true +} + const editView = (v) => { view.value = { ...v } openCreateViewForm.value = true 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 @@ + + + {{ modelValue === LOGIC.OR ? t('admin.automation.or') : t('admin.automation.and') }} + + + + 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..ce9d9646 --- /dev/null +++ b/frontend/apps/main/src/components/filter/FilterGroupBuilder.vue @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + {{ t('filter.addGroup') }} + + + + + 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..f88d4244 --- /dev/null +++ b/frontend/apps/main/src/components/filter/FilterGroupCard.vue @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + {{ t('actions.addCondition') }} + + + + + 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..3ea6fff7 --- /dev/null +++ b/frontend/apps/main/src/components/filter/FilterRow.vue @@ -0,0 +1,188 @@ + + + + + + + + + + + + {{ field.label }} + + + + + + + + + + + + + + + {{ opLabel(op) }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..65a40da7 --- /dev/null +++ b/frontend/apps/main/src/components/filter/filterTree.js @@ -0,0 +1,113 @@ +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') + +// Strict two-level shape: an object root holding only groups, each group holding only leaves. +export const isStrictTwoLevel = (node) => + !!node && + typeof node === 'object' && + !Array.isArray(node) && + Array.isArray(node.rules) && + node.rules.length > 0 && + node.rules.every( + (g) => isGroupNode(g) && Array.isArray(g.rules) && g.rules.every((r) => !isGroupNode(r)) + ) + +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.map((v) => String(v)) + 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..0a8d4105 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.TEXT_EXACT, + 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..428cde51 100644 --- a/frontend/apps/main/src/constants/filterConfig.js +++ b/frontend/apps/main/src/constants/filterConfig.js @@ -1,3 +1,11 @@ +export const LOGIC = { + AND: 'AND', + OR: 'OR' +} + +// Mirrors dbutil.MaxFilterGroups on the backend. +export const MAX_FILTER_GROUPS = 10 + export const FIELD_TYPE = { SELECT: 'select', TAG: 'tag', @@ -21,6 +29,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], @@ -32,6 +51,8 @@ export const FIELD_OPERATORS = { OPERATOR.CONTAINS, OPERATOR.NOT_CONTAINS ], + // For text columns that do not support partial matching, only allow exact match operators. + TEXT_EXACT: [OPERATOR.EQUALS, OPERATOR.NOT_EQUALS, OPERATOR.SET, OPERATOR.NOT_SET], DATE: [ 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..f4e4ffd0 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 @@ - + {{ t('globals.terms.name') }} @@ -16,7 +16,7 @@ {{ t('globals.terms.filter', 2) }} - + {{ t('view.form.filters.description') }} @@ -73,7 +73,7 @@