Merge pull request #369 from abhinavxd/improved-views

feat: support nested AND/OR filter groups and new fields in views
This commit is contained in:
Abhinav Raut
2026-06-19 04:18:48 +05:30
committed by GitHub
29 changed files with 1458 additions and 373 deletions
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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)
}
+3
View File
@@ -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), "/")
+9
View File
@@ -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)
}
+6 -1
View File
@@ -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
@@ -0,0 +1,22 @@
<template>
<button
type="button"
class="inline-flex cursor-pointer items-center rounded-full border border-border bg-background px-3 py-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
:title="t('filter.toggleConnector')"
@click.stop="toggle"
>
{{ modelValue === LOGIC.OR ? t('admin.automation.or') : t('admin.automation.and') }}
</button>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { LOGIC } from '@/constants/filterConfig'
const modelValue = defineModel('modelValue', { required: true })
const { t } = useI18n()
const toggle = () => {
modelValue.value = modelValue.value === LOGIC.OR ? LOGIC.AND : LOGIC.OR
}
</script>
@@ -0,0 +1,84 @@
<template>
<div class="space-y-2">
<div class="max-h-[50vh] overflow-y-auto pr-1 pb-2 space-y-2">
<template v-for="(grp, gi) in modelValue.rules" :key="grp.__id">
<div v-if="gi > 0" class="flex justify-center">
<ConnectorToggle :modelValue="modelValue.logic" @update:modelValue="setLogic" />
</div>
<FilterGroupCard
:modelValue="grp"
:fields="fields"
:canRemove="modelValue.rules.length > 1"
@update:modelValue="updateGroup(gi, $event)"
@remove="removeGroup(gi)"
/>
</template>
</div>
<Button
type="button"
variant="outline"
size="sm"
:disabled="modelValue.rules.length >= MAX_FILTER_GROUPS"
@click.stop="addGroup"
>
<Plus class="w-3 h-3 mr-1" />
{{ t('filter.addGroup') }}
</Button>
</div>
</template>
<script setup>
import { watch } from 'vue'
import { Button } from '@shared-ui/components/ui/button'
import { Plus } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
import FilterGroupCard from '@/components/filter/FilterGroupCard.vue'
import ConnectorToggle from '@/components/filter/ConnectorToggle.vue'
import {
createRoot,
createGroup,
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.
defineOptions({ inheritAttrs: false })
defineProps({
fields: { type: Array, required: true }
})
const modelValue = defineModel('modelValue', { default: () => createRoot() })
const { t } = useI18n()
watch(
modelValue,
(v) => {
if (!isStrictTwoLevel(v)) modelValue.value = normalizeToTwoLevel(v)
},
{ immediate: true }
)
const setLogic = (logic) => {
modelValue.value = { ...modelValue.value, logic }
}
const updateGroup = (index, group) => {
modelValue.value = {
...modelValue.value,
rules: modelValue.value.rules.map((g, i) => (i === index ? group : g))
}
}
const addGroup = () => {
if (modelValue.value.rules.length >= MAX_FILTER_GROUPS) return
modelValue.value = { ...modelValue.value, rules: [...modelValue.value.rules, createGroup()] }
}
const removeGroup = (index) => {
let rules = modelValue.value.rules.filter((_, i) => i !== index)
if (rules.length === 0) rules = [createGroup()]
modelValue.value = { ...modelValue.value, rules }
}
</script>
@@ -0,0 +1,73 @@
<template>
<div class="rounded-lg border border-border bg-muted/30 p-3 space-y-2">
<div v-if="canRemove" class="flex justify-end">
<CloseButton :aria-label="t('filter.removeGroup')" :onClose="() => emit('remove')">
<Trash2 class="w-4 h-4" />
</CloseButton>
</div>
<template v-for="(rule, index) in group.rules" :key="rule.__id">
<ConnectorToggle
v-if="index > 0"
:modelValue="group.logic"
@update:modelValue="setLogic"
/>
<FilterRow
:modelValue="rule"
:fields="fields"
@update:modelValue="updateRule(index, $event)"
@remove="removeRule(index)"
/>
</template>
<Button
type="button"
variant="ghost"
size="sm"
class="text-foreground"
@click.stop="addCondition"
>
<Plus class="w-3 h-3 mr-1" />
{{ t('actions.addCondition') }}
</Button>
</div>
</template>
<script setup>
import { Button } from '@shared-ui/components/ui/button'
import { Plus, Trash2 } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
import CloseButton from '@/components/button/CloseButton.vue'
import FilterRow from '@/components/filter/FilterRow.vue'
import ConnectorToggle from '@/components/filter/ConnectorToggle.vue'
import { createLeaf } from '@/components/filter/filterTree'
defineProps({
fields: { type: Array, required: true },
canRemove: { type: Boolean, default: false }
})
const emit = defineEmits(['remove'])
const group = defineModel('modelValue', { required: true })
const { t } = useI18n()
const setLogic = (logic) => {
group.value = { ...group.value, logic }
}
const updateRule = (index, rule) => {
group.value = { ...group.value, rules: group.value.rules.map((r, i) => (i === index ? rule : r)) }
}
const addCondition = () => {
group.value = { ...group.value, rules: [...group.value.rules, createLeaf()] }
}
const removeRule = (index) => {
const rules = group.value.rules.filter((_, i) => i !== index)
if (rules.length === 0) {
emit('remove')
return
}
group.value = { ...group.value, rules }
}
</script>
@@ -0,0 +1,188 @@
<template>
<div class="group flex items-center gap-3">
<div class="flex gap-2 w-full">
<div
class="flex-1 rounded-md"
:class="[shake && missingField && 'animate-shake', showInvalid && missingField && 'ring-1 ring-destructive']"
>
<Select :model-value="modelValue.field" @update:model-value="onFieldChange">
<SelectTrigger>
<SelectValue :placeholder="t('placeholders.selectField')" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="field in fields" :key="field.field" :value="field.field">
{{ field.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div
class="flex-1 rounded-md"
:class="[shake && missingOperator && 'animate-shake', showInvalid && missingOperator && 'ring-1 ring-destructive']"
>
<Select
v-if="modelValue.field"
:model-value="modelValue.operator"
@update:model-value="onOperatorChange"
>
<SelectTrigger>
<SelectValue :placeholder="t('placeholders.selectOperator')" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="op in fieldOperators" :key="op" :value="op">
{{ opLabel(op) }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div
class="flex-1 rounded-md"
:class="[shake && missingValue && 'animate-shake', showInvalid && missingValue && 'ring-1 ring-destructive']"
>
<div v-if="modelValue.field && modelValue.operator">
<template v-if="modelValue.operator !== OPERATOR.SET && modelValue.operator !== OPERATOR.NOT_SET">
<SelectTag
v-if="fieldType === FIELD_TYPE.MULTI_SELECT"
v-model="leafValue"
:items="fieldOptions"
:placeholder="t('placeholders.selectTags')"
/>
<SelectComboBox
v-else-if="fieldOptions.length > 0 && modelValue.field === 'assigned_user_id'"
v-model="leafValue"
:items="fieldOptions"
:placeholder="t('placeholders.selectValue')"
type="user"
/>
<SelectComboBox
v-else-if="fieldOptions.length > 0 && modelValue.field === 'assigned_team_id'"
v-model="leafValue"
:items="fieldOptions"
:placeholder="t('placeholders.selectValue')"
type="team"
/>
<SelectComboBox
v-else-if="fieldOptions.length > 0"
v-model="leafValue"
:items="fieldOptions"
:placeholder="t('placeholders.selectValue')"
/>
<DateFilterValue
v-else-if="fieldType === FIELD_TYPE.DATE"
v-model="leafValue"
:range="modelValue.operator === OPERATOR.BETWEEN"
/>
<Input v-else v-model="leafValue" :placeholder="t('globals.terms.value')" type="text" />
</template>
</div>
</div>
</div>
<CloseButton type="button" :onClose="() => emit('remove')" />
</div>
</template>
<script setup>
import { computed, inject, ref, watch, onUnmounted, nextTick } from 'vue'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@shared-ui/components/ui/select'
import { Input } from '@shared-ui/components/ui/input'
import { useI18n } from 'vue-i18n'
import { FIELD_TYPE, OPERATOR, operatorLabel } from '@/constants/filterConfig'
import CloseButton from '@/components/button/CloseButton.vue'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
import SelectTag from '@shared-ui/components/ui/select/SelectTag.vue'
import DateFilterValue from '@/components/filter/DateFilterValue.vue'
const props = defineProps({
fields: {
type: Array,
required: true
}
})
const emit = defineEmits(['remove'])
const modelValue = defineModel('modelValue', { required: true })
const { t } = useI18n()
const fieldConfig = computed(() => props.fields.find((f) => f.field === modelValue.value.field))
const fieldOptions = computed(() => fieldConfig.value?.options || [])
const fieldOperators = computed(() => fieldConfig.value?.operators || [])
const fieldType = computed(() => fieldConfig.value?.type || '')
// "contains any of" only applies to multi-value fields; single-value text stays "contains"
const opLabel = (op) => (fieldType.value === FIELD_TYPE.MULTI_SELECT ? operatorLabel(op, t) : op)
const isEmptyValue = (v) =>
v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0)
const missingField = computed(() => !modelValue.value.field)
const missingOperator = computed(() => !!modelValue.value.field && !modelValue.value.operator)
const needsValue = computed(
() =>
!!modelValue.value.operator &&
modelValue.value.operator !== OPERATOR.SET &&
modelValue.value.operator !== OPERATOR.NOT_SET
)
const missingValue = computed(() => needsValue.value && isEmptyValue(modelValue.value.value))
const invalid = computed(() => missingField.value || missingOperator.value || missingValue.value)
const validateTick = inject('filterValidateTick', ref(0))
const showInvalid = computed(() => validateTick.value > 0)
const shake = ref(false)
let shakeTimer = null
watch(validateTick, async () => {
if (!invalid.value) return
shake.value = false
await nextTick()
shake.value = true
clearTimeout(shakeTimer)
shakeTimer = setTimeout(() => {
shake.value = false
}, 500)
})
onUnmounted(() => clearTimeout(shakeTimer))
// All edits emit a fresh leaf; the tree is never mutated in place.
const patch = (changes) => {
modelValue.value = { ...modelValue.value, ...changes }
}
const leafValue = computed({
get: () => modelValue.value.value,
set: (value) => patch({ value })
})
const onFieldChange = (field) => {
const config = props.fields.find((f) => f.field === field)
patch({
field,
model: config?.model || '',
operator: '',
value: config?.type === FIELD_TYPE.MULTI_SELECT ? [] : ''
})
}
const onOperatorChange = (operator) => {
if (modelValue.value.operator === operator) return
if (modelValue.value.operator === OPERATOR.BETWEEN || operator === OPERATOR.BETWEEN) {
patch({ operator, value: '' })
return
}
patch({ operator })
}
</script>
@@ -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)
}
@@ -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'
}
}))
@@ -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,
@@ -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',
@@ -76,7 +76,7 @@
:key="key"
:value="op"
>
{{ op }}
{{ operatorLabel(op, t) }}
</SelectItem>
</SelectGroup>
</SelectContent>
@@ -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: {
@@ -1,7 +1,7 @@
<template>
<Spinner v-if="formLoading"></Spinner>
<form @submit="onSubmit" class="space-y-6 w-full" :class="{ 'opacity-50': formLoading }">
<FormField v-slot="{ componentField }" name="name">
<FormField v-slot="{ componentField }" name="name" :validate-on-blur="false">
<FormItem>
<FormLabel>{{ t('globals.terms.name') }}</FormLabel>
<FormControl>
@@ -16,7 +16,7 @@
<FormItem>
<FormLabel>{{ t('globals.terms.filter', 2) }}</FormLabel>
<FormControl>
<FilterBuilder :fields="filterFields" :showButtons="false" v-bind="componentField" />
<FilterGroupBuilder :fields="filterFields" v-bind="componentField" />
</FormControl>
<FormDescription>{{ t('view.form.filters.description') }}</FormDescription>
<FormMessage />
@@ -73,7 +73,7 @@
</template>
<script setup>
import { ref, watch, computed } from 'vue'
import { ref, watch, computed, provide } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { Button } from '@shared-ui/components/ui/button'
@@ -87,10 +87,17 @@ import {
FormLabel,
FormMessage
} from '@shared-ui/components/ui/form'
import FilterBuilder from '@/components/filter/FilterBuilder.vue'
import FilterGroupBuilder from '@/components/filter/FilterGroupBuilder.vue'
import {
normalizeToTwoLevel,
serializeFilterTree,
deserializeFilterTree,
collectLeaves,
isPartialLeaf,
createRoot
} from '@/components/filter/filterTree'
import { useConversationFilters } from '@/composables/useConversationFilters'
import { useTeamStore } from '@/stores/team'
import { OPERATOR, FIELD_TYPE } from '@/constants/filterConfig.js'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
import {
Select,
@@ -106,6 +113,8 @@ import { z } from 'zod'
const { conversationsListFilters } = useConversationFilters()
const { t } = useI18n()
const formLoading = ref(false)
const validateTick = ref(0)
provide('filterValidateTick', validateTick)
const tStore = useTeamStore()
const props = defineProps({
initialValues: {
@@ -135,7 +144,7 @@ const submitLabel = computed(() => {
const filterFields = computed(() =>
Object.entries(conversationsListFilters.value).map(([field, value]) => ({
model: 'conversations',
model: value.model || 'conversations',
label: value.label,
field,
type: value.type,
@@ -154,22 +163,12 @@ const formSchema = toTypedSchema(
.min(2, { message: t('view.form.name.length') })
.max(140, { message: t('view.form.name.length') }),
filters: z
.array(
z.object({
model: z.string().optional(),
field: z.string().optional(),
operator: z.string().optional(),
value: z
.union([
z.string(),
z.number(),
z.boolean(),
z.array(z.union([z.string(), z.number()]))
])
.optional()
})
)
.default([]),
.object({
logic: z.string().optional(),
rules: z.array(z.any()).optional()
})
.passthrough()
.default(() => createRoot()),
visibility: z.enum(['all', 'team']),
team_id: z.string().nullable().optional()
})
@@ -185,52 +184,31 @@ const formSchema = toTypedSchema(
const form = useForm({
validationSchema: formSchema,
initialValues: {
visibility: props.initialValues.visibility || 'all'
visibility: props.initialValues.visibility || 'all',
filters: createRoot()
}
})
const onSubmit = form.handleSubmit(async (values) => {
// Make sure at least one filter is selected
if (!values.filters || values.filters.length === 0) {
const leaves = collectLeaves(values.filters)
if (leaves.length === 0) {
form.setFieldError('filters', t('view.form.filter.selectAtLeastOne'))
return
}
// Check for partial filters
const hasPartialFilters = values.filters.some(
(f) =>
!f.field ||
!f.operator ||
(![OPERATOR.SET, OPERATOR.NOT_SET].includes(f.operator) &&
(!f.value || (Array.isArray(f.value) && f.value.length === 0)))
)
if (hasPartialFilters) {
form.setFieldError('filters', t('view.form.filter.partiallyFilled'))
if (leaves.some(isPartialLeaf)) {
validateTick.value++
return
}
// Serialize array values to JSON strings for backend
if (values.filters) {
values.filters = values.filters.map((filter) => {
if (Array.isArray(filter.value)) {
const numericValues = filter.value.map((v) => {
const num = Number(v)
return isNaN(num) ? v : num
})
return { ...filter, value: JSON.stringify(numericValues) }
}
return filter
})
}
const payload = { ...values, filters: serializeFilterTree(values.filters) }
// Clear team_id if visibility is 'all', otherwise convert to number
if (values.visibility === 'all') {
values.team_id = null
if (payload.visibility === 'all') {
payload.team_id = null
} else {
values.team_id = values.team_id ? Number(values.team_id) : null
payload.team_id = payload.team_id ? Number(payload.team_id) : null
}
props.submitForm(values)
props.submitForm(payload)
})
watch(
@@ -238,25 +216,11 @@ watch(
(newValues) => {
if (Object.keys(newValues).length === 0) return
// Deserialize multi-select filter values from JSON strings to arrays
const processedVal = { ...newValues }
if (processedVal.filters) {
processedVal.filters = processedVal.filters.map((filter) => {
const field = filterFields.value.find((f) => f.field === filter.field)
const isMultiSelectField = field?.type === FIELD_TYPE.MULTI_SELECT
if (isMultiSelectField && typeof filter.value === 'string') {
try {
const parsed = JSON.parse(filter.value)
const stringValues = Array.isArray(parsed) ? parsed.map((v) => String(v)) : parsed
return { ...filter, value: stringValues }
} catch (e) {
return filter
}
}
return filter
})
}
processedVal.filters = deserializeFilterTree(
normalizeToTwoLevel(newValues.filters),
filterFields.value
)
// Convert team_id to string for the select component
if (processedVal.team_id) {
@@ -5,7 +5,7 @@
<div>
<span>{{ conversationStore.currentContactName }}</span>
</div>
<div class="flex items-center gap-1">
<div class="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger>
<div
@@ -12,7 +12,7 @@
</DialogHeader>
<form @submit.prevent="onSubmit">
<div class="grid gap-4 py-4">
<FormField v-slot="{ componentField }" name="name">
<FormField v-slot="{ componentField }" name="name" :validate-on-blur="false">
<FormItem>
<FormLabel>{{ $t('globals.terms.name') }}</FormLabel>
<FormControl>
@@ -33,11 +33,7 @@
<FormItem>
<FormLabel>{{ $t('globals.terms.filter', 2) }}</FormLabel>
<FormControl>
<FilterBuilder
:fields="filterFields"
:showButtons="false"
v-bind="componentField"
/>
<FilterGroupBuilder :fields="filterFields" v-bind="componentField" />
</FormControl>
<FormDescription> {{ $t('view.form.filters.description') }}</FormDescription>
<FormMessage />
@@ -55,7 +51,7 @@
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import { ref, computed, watch, nextTick, provide } from 'vue'
import { useForm } from 'vee-validate'
import {
Dialog,
@@ -75,16 +71,22 @@ import {
FormMessage
} from '@shared-ui/components/ui/form'
import { Input } from '@shared-ui/components/ui/input'
import FilterBuilder from '@main/components/filter/FilterBuilder.vue'
import FilterGroupBuilder from '@main/components/filter/FilterGroupBuilder.vue'
import {
normalizeToTwoLevel,
serializeFilterTree,
deserializeFilterTree,
collectLeaves,
isPartialLeaf,
createRoot
} from '@main/components/filter/filterTree'
import { useConversationFilters } from '../../composables/useConversationFilters'
import { toTypedSchema } from '@vee-validate/zod'
import { EMITTER_EVENTS } from '../../constants/emitterEvents.js'
import { useEmitter } from '../../composables/useEmitter'
import { handleHTTPError } from '@shared-ui/utils/http.js'
import { OPERATOR } from '../../constants/filterConfig.js'
import { useI18n } from 'vue-i18n'
import { z } from 'zod'
import { FIELD_TYPE } from '@/constants/filterConfig'
import api from '@/api'
const emitter = useEmitter()
@@ -93,6 +95,8 @@ const nameInputRef = ref(null)
const openDialog = defineModel('openDialog', { required: false, default: false })
watch(openDialog, (isOpen) => {
if (isOpen) {
// A cancelled edit leaves the previous view in the form; reset before a fresh create.
if (!view.value?.id) form.resetForm()
nextTick(() => {
nameInputRef.value?.$el?.focus()
})
@@ -100,11 +104,13 @@ watch(openDialog, (isOpen) => {
})
const view = defineModel('view', { required: false, default: {} })
const isSubmitting = ref(false)
const validateTick = ref(0)
provide('filterValidateTick', validateTick)
const { conversationsListFilters } = useConversationFilters()
const filterFields = computed(() =>
Object.entries(conversationsListFilters.value).map(([field, value]) => ({
model: 'conversations',
model: value.model || 'conversations',
label: value.label,
field,
type: value.type,
@@ -120,78 +126,49 @@ const formSchema = toTypedSchema(
required_error: t('globals.messages.required')
})
.min(2, { message: t('view.form.name.length') })
.max(30, { message: t('view.form.name.length') }),
.max(140, { message: t('view.form.name.length') }),
filters: z
.array(
z.object({
model: z.string().optional(),
field: z.string().optional(),
operator: z.string().optional(),
value: z
.union([
z.string(),
z.number(),
z.boolean(),
z.array(z.union([z.string(), z.number()]))
])
.optional()
})
)
.default([])
.object({
logic: z.string().optional(),
rules: z.array(z.any()).optional()
})
.passthrough()
.default(() => createRoot())
})
)
const form = useForm({
validationSchema: formSchema
validationSchema: formSchema,
initialValues: {
filters: createRoot()
}
})
const onSubmit = form.handleSubmit(async (values) => {
if (isSubmitting.value) return
// Make sure at least one filter is selected
if (!values.filters || values.filters.length === 0) {
const leaves = collectLeaves(values.filters)
if (leaves.length === 0) {
form.setFieldError('filters', t('view.form.filter.selectAtLeastOne'))
return
}
// Check for partial filters
const hasPartialFilters = values.filters.some(
(f) =>
!f.field ||
!f.operator ||
(![OPERATOR.SET, OPERATOR.NOT_SET].includes(f.operator) &&
(!f.value || (Array.isArray(f.value) && f.value.length === 0)))
)
if (hasPartialFilters) {
form.setFieldError('filters', t('view.form.filter.partiallyFilled'))
if (leaves.some(isPartialLeaf)) {
validateTick.value++
return
}
isSubmitting.value = true
try {
// Serialize array values to JSON strings for backend
if (values.filters) {
values.filters = values.filters.map((filter) => {
if (Array.isArray(filter.value)) {
// Convert string IDs to numbers for backend (tags use string IDs in frontend)
const numericValues = filter.value.map((v) => {
const num = Number(v)
return isNaN(num) ? v : num
})
return { ...filter, value: JSON.stringify(numericValues) }
}
return filter
})
}
const payload = { ...values, filters: serializeFilterTree(values.filters) }
if (values.id) {
await api.updateView(values.id, values)
if (payload.id) {
await api.updateView(payload.id, payload)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.savedSuccessfully')
})
} else {
await api.createView(values)
await api.createView(payload)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.savedSuccessfully')
})
@@ -209,33 +186,15 @@ const onSubmit = form.handleSubmit(async (values) => {
}
})
// Set form values when view prop changes
watch(
() => view.value,
(newVal) => {
if (newVal && Object.keys(newVal).length) {
// Deserialize multi-select filter values from JSON strings to arrays
const processedVal = { ...newVal }
if (processedVal.filters) {
processedVal.filters = processedVal.filters.map((filter) => {
// Multi-select fields need to be deserialized from JSON strings
const field = filterFields.value.find((f) => f.field === filter.field)
const isMultiSelectField = field?.type === FIELD_TYPE.MULTI_SELECT
if (isMultiSelectField && typeof filter.value === 'string') {
try {
const parsed = JSON.parse(filter.value)
// Convert numbers back to strings (frontend uses string IDs)
const stringValues = Array.isArray(parsed) ? parsed.map((v) => String(v)) : parsed
return { ...filter, value: stringValues }
} catch (e) {
// If parsing fails, return as-is
return filter
}
}
return filter
})
}
processedVal.filters = deserializeFilterTree(
normalizeToTwoLevel(newVal.filters),
filterFields.value
)
form.setValues(processedVal)
}
},
+15 -2
View File
@@ -489,6 +489,7 @@
"conversation.bulkActions.toolbar": "Bulk actions toolbar",
"conversation.couldNotFetch": "Could not fetch conversations",
"conversation.downloadTranscript": "Download transcript",
"conversation.filters.tooManyGroups": "Too many filter groups. A view can have at most {max}. Remove the extra groups and save again.",
"conversation.hideQuotedText": "Hide quoted text",
"conversation.mentions": "Mentions",
"conversation.myInbox": "My inbox",
@@ -541,6 +542,11 @@
"errors.canOnlyDeleteOwnNote": "You can only delete your own note",
"errors.parsingRequest": "Error parsing request",
"filter.add": "Add filter",
"filter.addGroup": "Add group",
"filter.containsAnyOf": "contains any of",
"filter.containsNoneOf": "contains none of",
"filter.removeGroup": "Remove group",
"filter.toggleConnector": "Click to switch between and / or",
"globals.messages.add": "Add",
"globals.messages.addAnnouncement": "Add announcement",
"globals.messages.addEmoji": "Add emoji",
@@ -596,6 +602,7 @@
"globals.messages.hoursSinceLastReply": "Hours since last reply",
"globals.messages.hoursSinceResolved": "Hours since resolved",
"globals.messages.import": "Import",
"globals.messages.invalidFilters": "Invalid filters.",
"globals.messages.invite": "Invite",
"globals.messages.lastUsed": "Last used",
"globals.messages.linkUrl": "Link URL",
@@ -692,6 +699,8 @@
"globals.terms.closedAt": "Closed at",
"globals.terms.collapse": "Collapse",
"globals.terms.contact": "Contact | Contacts",
"globals.terms.contactEmail": "Contact email",
"globals.terms.contactExternalId": "Contact external ID",
"globals.terms.content": "Content | Contents",
"globals.terms.contextLink": "Context link | Context links",
"globals.terms.continue": "Continue",
@@ -749,6 +758,8 @@
"globals.terms.label": "Label | Labels",
"globals.terms.language": "Language | Languages",
"globals.terms.lastActive": "Last active",
"globals.terms.lastInteractionAt": "Last interaction at",
"globals.terms.lastInteractionBy": "Last interaction by",
"globals.terms.lastLogin": "Last login",
"globals.terms.lastMessageAt": "Last message at",
"globals.terms.lastName": "Last name | Last names",
@@ -767,6 +778,7 @@
"globals.terms.microsoft": "Microsoft",
"globals.terms.myInbox": "My Inbox | My Inboxes",
"globals.terms.name": "Name | Names",
"globals.terms.nextSlaDeadline": "Next SLA deadline",
"globals.terms.none": "None",
"globals.terms.note": "Note | Notes",
"globals.terms.notification": "Notification | Notifications",
@@ -824,6 +836,7 @@
"globals.terms.smtpPort": "SMTP Port | SMTP Ports",
"globals.terms.snooze": "Snooze",
"globals.terms.snoozed": "Snoozed",
"globals.terms.snoozedUntil": "Snoozed until",
"globals.terms.solid": "Solid",
"globals.terms.someone": "Someone",
"globals.terms.sso": "SSO | SSOs",
@@ -859,6 +872,7 @@
"globals.terms.visibility": "Visibility | Visibilities",
"globals.terms.visitor": "Visitor | Visitors",
"globals.terms.waiting": "Waiting",
"globals.terms.waitingSince": "Waiting since",
"globals.terms.warning": "Warning | Warnings",
"globals.terms.webhook": "Webhook | Webhooks",
"globals.terms.week": "Week | Weeks",
@@ -1085,11 +1099,10 @@
"validation.subjectCannotBeEmpty": "Subject cannot be empty",
"validation.tooLongStatus": "Status is too long, should be at most {max} characters",
"view.form.description": "Create and save custom filter views for quick access to your conversations.",
"view.form.filter.partiallyFilled": "Please make sure you've filled the filter fields correctly.",
"view.form.filter.selectAtLeastOne": "Select at least one filter.",
"view.form.filters.description": "Set one or more filters to customize view.",
"view.form.name.description": "Enter an unique name for your view.",
"view.form.name.length": "View name should be between 2 and 30 characters.",
"view.form.name.length": "View name should be between 2 and 140 characters.",
"webhook.edit": "Edit webhook",
"webhook.new": "New webhook",
"webhook.sendTest": "Send test",
+5 -4
View File
@@ -57,8 +57,8 @@ func New(opts Opts) (*Manager, error) {
}
// GetAll retrieves all activity logs.
func (m *Manager) GetAll(order, orderBy, filtersJSON string, page, pageSize int) ([]models.ActivityLog, error) {
query, qArgs, err := m.makeQuery(page, pageSize, order, orderBy, filtersJSON)
func (m *Manager) GetAll(order, orderBy, filtersJSON string, page, pageSize int, location string) ([]models.ActivityLog, error) {
query, qArgs, err := m.makeQuery(page, pageSize, order, orderBy, filtersJSON, location)
if err != nil {
m.lo.Error("error creating activity log list query", "error", err)
return nil, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
@@ -271,7 +271,7 @@ func (m *Manager) create(activityType, activityDescription string, actorID int,
}
// makeQuery constructs the SQL query for fetching activity logs with filters and pagination.
func (m *Manager) makeQuery(page, pageSize int, order, orderBy, filtersJSON string) (string, []any, error) {
func (m *Manager) makeQuery(page, pageSize int, order, orderBy, filtersJSON, location string) (string, []any, error) {
var (
baseQuery = m.q.GetAllActivities
qArgs []any
@@ -281,7 +281,8 @@ func (m *Manager) makeQuery(page, pageSize int, order, orderBy, filtersJSON stri
OrderBy: orderBy,
Page: page,
PageSize: pageSize,
Location: location,
}, filtersJSON, dbutil.AllowedFields{
"activity_logs": {"activity_type", "actor_id", "ip", "created_at"},
})
}, nil)
}
+69 -82
View File
@@ -51,15 +51,29 @@ var (
efs embed.FS
errConversationNotFound = errors.New("conversation not found")
ErrConversationAlreadyAssigned = errors.New("conversation already assigned")
conversationsAllowedFields = []string{"status_id", "priority_id", "assigned_team_id", "assigned_user_id", "inbox_id", "last_message_at", "last_interaction_at", "created_at", "waiting_since", "next_sla_deadline_at", "priority_id"}
conversationsAllowedFields = []string{"status_id", "priority_id", "assigned_team_id", "assigned_user_id", "inbox_id", "last_message_at", "last_interaction_at", "last_interaction_sender", "created_at", "waiting_since", "next_sla_deadline_at", "snoozed_until", "sla_policy_id"}
conversationStatusAllowedFields = []string{"id", "name"}
usersAllowedFields = []string{"email"}
usersAllowedFields = []string{"email", "external_user_id"}
inboxesAllowedFields = []string{"channel"}
)
const (
conversationsListMaxPageSize = 500
)
var conversationFilterRenderers = dbutil.FieldRenderers{
"conversations": {
"tags": renderTagFilter,
},
}
var conversationListAllowedFields = dbutil.AllowedFields{
"conversations": conversationsAllowedFields,
"conversation_statuses": conversationStatusAllowedFields,
"users": usersAllowedFields,
"inboxes": inboxesAllowedFields,
}
// Manager handles the operations related to conversations
type Manager struct {
q queries
@@ -1555,35 +1569,6 @@ func (c *Manager) makeConversationsListQuery(viewingUserID, userID int, teamIDs
return "", nil, fmt.Errorf("no conversation list types specified")
}
// Parse filters to extract tag filters
var (
filters []dbutil.Filter
tagFilters []dbutil.Filter
remainingFilters []dbutil.Filter
)
if filtersJSON != "" && filtersJSON != "[]" {
if err := json.Unmarshal([]byte(filtersJSON), &filters); err != nil {
return "", nil, fmt.Errorf("invalid filters JSON: %w", err)
}
// Separate tag filters from other filters
for _, f := range filters {
if f.Field == "tags" && (f.Operator == "contains" || f.Operator == "not contains" || f.Operator == "set" || f.Operator == "not set") {
tagFilters = append(tagFilters, f)
} else {
remainingFilters = append(remainingFilters, f)
}
}
// Update filtersJSON with remaining filters for the generic builder
if len(remainingFilters) > 0 {
b, _ := json.Marshal(remainingFilters)
filtersJSON = string(b)
} else {
filtersJSON = "[]"
}
}
// Prepare the conditions based on the list types.
conditions := []string{}
for _, lt := range listTypes {
@@ -1635,52 +1620,6 @@ func (c *Manager) makeConversationsListQuery(viewingUserID, userID int, teamIDs
whereClause = "AND (" + strings.Join(conditions, " OR ") + ")"
}
// Add tag filter conditions
// TODO: Evaluate - https://github.com/Masterminds/squirrel when required.
for _, tf := range tagFilters {
switch tf.Operator {
case "contains", "not contains":
var tagIDs []int
if err := json.Unmarshal([]byte(tf.Value), &tagIDs); err != nil {
return "", nil, fmt.Errorf("invalid tag IDs in filter: %w", err)
}
if len(tagIDs) > 0 {
paramIdx := len(qArgs) + 1
switch tf.Operator {
case "contains":
// Has any of the tags
tagCondition := fmt.Sprintf(` AND conversations.id IN (
SELECT DISTINCT conversation_id
FROM conversation_tags
WHERE tag_id = ANY($%d::int[])
)`, paramIdx)
whereClause += tagCondition
case "not contains":
// Doesn't have any of the tags
tagCondition := fmt.Sprintf(` AND conversations.id NOT IN (
SELECT DISTINCT conversation_id
FROM conversation_tags
WHERE tag_id = ANY($%d::int[])
)`, paramIdx)
whereClause += tagCondition
}
qArgs = append(qArgs, pq.Array(tagIDs))
}
case "set":
// Has any tags at all
whereClause += ` AND EXISTS (
SELECT 1 FROM conversation_tags
WHERE conversation_id = conversations.id
)`
case "not set":
// Has no tags at all
whereClause += ` AND NOT EXISTS (
SELECT 1 FROM conversation_tags
WHERE conversation_id = conversations.id
)`
}
}
baseQuery = fmt.Sprintf(baseQuery, whereClause)
return dbutil.BuildPaginatedQuery(baseQuery, qArgs, dbutil.PaginationOptions{
@@ -1688,11 +1627,21 @@ func (c *Manager) makeConversationsListQuery(viewingUserID, userID int, teamIDs
OrderBy: orderBy,
Page: page,
PageSize: pageSize,
}, filtersJSON, dbutil.AllowedFields{
"conversations": conversationsAllowedFields,
"conversation_statuses": conversationStatusAllowedFields,
"users": usersAllowedFields,
})
Location: c.filterLocation(),
}, filtersJSON, conversationListAllowedFields, conversationFilterRenderers)
}
// ValidateListFilters structurally validates a conversation view's filters payload.
func (c *Manager) ValidateListFilters(filtersJSON string) error {
err := dbutil.ValidateFilters(filtersJSON, conversationListAllowedFields, conversationFilterRenderers)
if err == nil {
return nil
}
c.lo.Error("error validating view filters", "error", err)
if errors.Is(err, dbutil.ErrTooManyGroups) {
return envelope.NewError(envelope.InputError, c.i18n.Ts("conversation.filters.tooManyGroups", "max", fmt.Sprintf("%d", dbutil.MaxFilterGroups)), nil)
}
return envelope.NewError(envelope.InputError, c.i18n.T("globals.messages.invalidFilters"), nil)
}
// ProcessCSATStatus processes messages and adds CSAT submission status for CSAT messages.
@@ -1970,9 +1919,47 @@ func (c *Manager) updateAssignee(uuid string, assigneeID int, assigneeType strin
return nil
}
// filterLocation returns the configured app timezone for resolving date filters. The builder normalizes invalid/empty values to UTC.
func (c *Manager) filterLocation() string {
b, err := c.settingsStore.Get("app.timezone")
if err != nil {
return ""
}
var tz string
if err := json.Unmarshal(b, &tz); err != nil {
return ""
}
return tz
}
func nullTimeOrNil(t null.Time) any {
if !t.Valid || t.Time.IsZero() {
return nil
}
return t.Time.Format(time.RFC3339)
}
func renderTagFilter(operator, value string, paramIndex int) (string, []any, error) {
switch operator {
case "contains", "not contains":
var tagIDs []int
if err := json.Unmarshal([]byte(value), &tagIDs); err != nil {
return "", nil, fmt.Errorf("invalid tag IDs in filter: %w", err)
}
if len(tagIDs) == 0 {
return "", nil, nil
}
op := "IN"
if operator == "not contains" {
op = "NOT IN"
}
sql := fmt.Sprintf("conversations.id %s (SELECT DISTINCT conversation_id FROM conversation_tags WHERE tag_id = ANY($%d::int[]))", op, paramIndex)
return sql, []any{pq.Array(tagIDs)}, nil
case "set":
return "EXISTS (SELECT 1 FROM conversation_tags WHERE conversation_id = conversations.id)", nil, nil
case "not set":
return "NOT EXISTS (SELECT 1 FROM conversation_tags WHERE conversation_id = conversations.id)", nil, nil
default:
return "", nil, fmt.Errorf("invalid operator for tags: %s", operator)
}
}
+2 -1
View File
@@ -1477,7 +1477,8 @@ func (m *Manager) findExistingMedia(rawContentID, conversationUUID string) (stri
return storedCID, exists, mediaUUID
}
// emailFromAddress returns the From header, applying the inbox from-name template for agent senders.
// emailFromAddress returns the From header, applying the inbox from-name template for agent senders
// Falls back to the inbox's default from address if the template is empty, the sender is not an agent, or any errors occur.
func (m *Manager) emailFromAddress(inb inbox.Inbox, message models.Message) string {
from := inb.FromAddress()
+323 -116
View File
@@ -2,20 +2,52 @@ package dbutil
import (
"encoding/json"
"errors"
"fmt"
"regexp"
"slices"
"strings"
"github.com/abhinavxd/libredesk/internal/stringutil"
)
// ErrTooManyGroups is returned when a filter exceeds MaxFilterGroups. Callers map it to a user-facing error.
var ErrTooManyGroups = errors.New("too many filter groups")
var dateOnlyRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
var valueRequiredOperators = map[string]bool{
"equals": true,
"not equals": true,
"greater than": true,
"less than": true,
"in": true,
"between": true,
"contains": true,
"ilike": true,
"not contains": true,
}
// maxFilterDepth bounds group nesting (root group + one nested level).
const maxFilterDepth = 2
// MaxFilterGroups bounds how many groups a filter may contain (excluding the root).
const MaxFilterGroups = 10
// maxFilterConditions bounds how many leaf conditions a filter may contain in total.
const maxFilterConditions = 50
// maxInValues bounds how many values an "in" condition may carry.
const maxInValues = 100
// PaginationOptions represents the options for paginating a query.
type PaginationOptions struct {
Page int
PageSize int
OrderBy string
Order string
// Location is the IANA timezone date-only filters are resolved in; empty falls back to UTC.
Location string
}
// Order directions.
@@ -24,19 +56,48 @@ const (
DESC = "DESC"
)
// Filter represents a filter to be applied to a query.
type Filter struct {
Model string `json:"model"`
Field string `json:"field"`
Operator string `json:"operator"`
Value string `json:"value"`
// FilterNode is either a group (Logic + Rules) or a leaf (Model/Field/Operator/Value).
type FilterNode struct {
Logic string `json:"logic,omitempty"`
Rules []FilterNode `json:"rules,omitempty"`
Model string `json:"model,omitempty"`
Field string `json:"field,omitempty"`
Operator string `json:"operator,omitempty"`
Value string `json:"value,omitempty"`
}
func (n FilterNode) isGroup() bool {
return n.Logic != "" || len(n.Rules) > 0
}
func (n FilterNode) isEmpty() bool {
return !n.isGroup() && n.Field == ""
}
// AllowedFields is a map of model names to a list of allowed fields for that model.
type AllowedFields map[string][]string
// BuildPaginatedQuery builds a paginated query from the given base query, existing arguments, pagination options, filters JSON, and allowed fields.
func BuildPaginatedQuery(baseQuery string, existingArgs []any, opts PaginationOptions, filtersJSON string, allowedFields AllowedFields) (string, []any, error) {
// FieldRenderer renders a leaf condition for a field that does not map to a plain column,
// e.g. conversation tags rendered as a subquery. paramIndex is the next positional placeholder.
type FieldRenderer func(operator, value string, paramIndex int) (string, []any, error)
// FieldRenderers maps model -> field -> renderer.
type FieldRenderers map[string]map[string]FieldRenderer
func (r FieldRenderers) get(model, field string) (FieldRenderer, bool) {
if r == nil {
return nil, false
}
fields, ok := r[model]
if !ok {
return nil, false
}
fn, ok := fields[field]
return fn, ok
}
// BuildPaginatedQuery builds a paginated query from the given base query, existing arguments, pagination options, filters JSON, allowed fields, and optional custom field renderers.
func BuildPaginatedQuery(baseQuery string, existingArgs []any, opts PaginationOptions, filtersJSON string, allowedFields AllowedFields, renderers FieldRenderers) (string, []any, error) {
if opts.Page <= 0 {
return "", nil, fmt.Errorf("invalid page number: %d", opts.Page)
}
@@ -44,14 +105,14 @@ func BuildPaginatedQuery(baseQuery string, existingArgs []any, opts PaginationOp
return "", nil, fmt.Errorf("invalid page size: %d", opts.PageSize)
}
var filters []Filter
if filtersJSON != "" {
if err := json.Unmarshal([]byte(filtersJSON), &filters); err != nil {
return "", nil, fmt.Errorf("invalid filters JSON: %w", err)
}
root, err := parseFilters(filtersJSON)
if err != nil {
return "", nil, err
}
whereClause, filterArgs, err := buildWhereClause(filters, existingArgs, allowedFields)
loc := stringutil.NormalizeTimezone(opts.Location)
whereClause, filterArgs, err := buildWhereClause(root, existingArgs, allowedFields, renderers, loc)
if err != nil {
return "", nil, err
}
@@ -65,7 +126,6 @@ func BuildPaginatedQuery(baseQuery string, existingArgs []any, opts PaginationOp
}
if opts.OrderBy != "" {
// Validate OrderBy.
parts := strings.Split(opts.OrderBy, ".")
if len(parts) != 2 {
return "", nil, fmt.Errorf("invalid OrderBy format: %s", opts.OrderBy)
@@ -91,106 +151,253 @@ func BuildPaginatedQuery(baseQuery string, existingArgs []any, opts PaginationOp
return query, args, nil
}
// buildWhereClause builds a WHERE clause from the given filters and returns the WHERE clause and the arguments to be passed to the query.
func buildWhereClause(filters []Filter, existingArgs []interface{}, allowedFields AllowedFields) (string, []interface{}, error) {
conditions := []string{}
args := []interface{}{}
paramCount := len(existingArgs) + 1
for _, f := range filters {
modelFields, ok := allowedFields[f.Model]
if !ok {
return "", nil, fmt.Errorf("invalid model: %s", f.Model)
}
if !slices.Contains(modelFields, f.Field) {
return "", nil, fmt.Errorf("invalid field: %s for model: %s", f.Field, f.Model)
}
field := fmt.Sprintf("%s.%s", f.Model, f.Field)
switch f.Operator {
case "equals":
if dateOnlyRe.MatchString(f.Value) {
conditions = append(conditions, fmt.Sprintf("%s >= $%d::DATE AND %s < ($%d::DATE + INTERVAL '1 day')", field, paramCount, field, paramCount))
args = append(args, f.Value)
paramCount++
break
}
conditions = append(conditions, field+fmt.Sprintf(" = $%d", paramCount))
args = append(args, f.Value)
paramCount++
case "not equals":
if dateOnlyRe.MatchString(f.Value) {
conditions = append(conditions, fmt.Sprintf("(%s < $%d::DATE OR %s >= ($%d::DATE + INTERVAL '1 day'))", field, paramCount, field, paramCount))
args = append(args, f.Value)
paramCount++
break
}
conditions = append(conditions, field+fmt.Sprintf(" != $%d", paramCount))
args = append(args, f.Value)
paramCount++
case "greater than":
if dateOnlyRe.MatchString(f.Value) {
conditions = append(conditions, fmt.Sprintf("%s >= ($%d::DATE + INTERVAL '1 day')", field, paramCount))
args = append(args, f.Value)
paramCount++
break
}
conditions = append(conditions, field+fmt.Sprintf(" > $%d", paramCount))
args = append(args, f.Value)
paramCount++
case "less than":
if dateOnlyRe.MatchString(f.Value) {
conditions = append(conditions, fmt.Sprintf("%s < $%d::DATE", field, paramCount))
args = append(args, f.Value)
paramCount++
break
}
conditions = append(conditions, field+fmt.Sprintf(" < $%d", paramCount))
args = append(args, f.Value)
paramCount++
case "set":
conditions = append(conditions, field+" IS NOT NULL")
case "not set":
conditions = append(conditions, field+" IS NULL")
case "in":
var arr []string
if err := json.Unmarshal([]byte(f.Value), &arr); err != nil {
return "", nil, fmt.Errorf("invalid array format for 'in' operator: %v", err)
}
placeholders := make([]string, len(arr))
for i, v := range arr {
placeholders[i] = fmt.Sprintf("$%d", paramCount)
args = append(args, v)
paramCount++
}
conditions = append(conditions, field+" IN ("+strings.Join(placeholders, ",")+")")
case "between":
values := strings.Split(f.Value, ",")
if len(values) != 2 {
return "", nil, fmt.Errorf("between requires 2 values")
}
start := strings.TrimSpace(values[0])
end := strings.TrimSpace(values[1])
if dateOnlyRe.MatchString(start) && dateOnlyRe.MatchString(end) {
conditions = append(conditions, fmt.Sprintf("%s >= $%d::DATE AND %s < ($%d::DATE + INTERVAL '1 day')", field, paramCount, field, paramCount+1))
} else {
conditions = append(conditions, fmt.Sprintf("%s BETWEEN $%d AND $%d", field, paramCount, paramCount+1))
}
args = append(args, start, end)
paramCount += 2
case "ilike":
conditions = append(conditions, field+fmt.Sprintf(" ILIKE $%d", paramCount))
args = append(args, "%"+f.Value+"%")
paramCount++
default:
return "", nil, fmt.Errorf("invalid operator: %s", f.Operator)
}
// ValidateFilters parses and structurally validates a filters payload without running a query.
func ValidateFilters(filtersJSON string, allowedFields AllowedFields, renderers FieldRenderers) error {
root, err := parseFilters(filtersJSON)
if err != nil {
return err
}
args := []any{}
next := 1
_, err = buildNode(root, &args, &next, allowedFields, renderers, 0, "UTC")
return err
}
// parseFilters accepts either a legacy flat array of leaves or a {logic, rules} group object.
func parseFilters(filtersJSON string) (FilterNode, error) {
trimmed := strings.TrimSpace(filtersJSON)
if trimmed == "" || trimmed == "[]" || trimmed == "null" {
return FilterNode{}, nil
}
switch trimmed[0] {
// OLD: flat array of leaves (legacy), implicitly AND-ed, e.g. [{"model":"conversations","field":"status_id","operator":"equals","value":"1"}]
case '[':
var leaves []FilterNode
if err := json.Unmarshal([]byte(trimmed), &leaves); err != nil {
return FilterNode{}, fmt.Errorf("invalid filters JSON: %w", err)
}
return FilterNode{Logic: "AND", Rules: leaves}, nil
// NEW: logic + nested rules, e.g. {"logic":"AND","rules":[{"logic":"OR","rules":[<leaves>]},{"logic":"AND","rules":[<leaves>]}]}
case '{':
var node FilterNode
if err := json.Unmarshal([]byte(trimmed), &node); err != nil {
return FilterNode{}, fmt.Errorf("invalid filters JSON: %w", err)
}
return node, nil
default:
return FilterNode{}, fmt.Errorf("invalid filters JSON: expected array or object")
}
}
func buildWhereClause(root FilterNode, existingArgs []any, allowedFields AllowedFields, renderers FieldRenderers, loc string) (string, []any, error) {
args := []any{}
next := len(existingArgs) + 1
clause, err := buildNode(root, &args, &next, allowedFields, renderers, 0, loc)
if err != nil {
return "", nil, err
}
return clause, args, nil
}
func countGroups(node FilterNode) int {
if !node.isGroup() {
return 0
}
n := 1
for _, child := range node.Rules {
n += countGroups(child)
}
return n
}
func countConditions(node FilterNode) int {
if !node.isGroup() {
if node.isEmpty() {
return 0
}
return 1
}
n := 0
for _, child := range node.Rules {
n += countConditions(child)
}
return n
}
func buildNode(node FilterNode, args *[]any, next *int, allowedFields AllowedFields, renderers FieldRenderers, depth int, loc string) (string, error) {
if depth > maxFilterDepth {
return "", fmt.Errorf("filter nesting too deep")
}
if depth == 0 {
groups := 0
for _, child := range node.Rules {
groups += countGroups(child)
}
if groups > MaxFilterGroups {
return "", ErrTooManyGroups
}
if countConditions(node) > maxFilterConditions {
return "", fmt.Errorf("filter has too many conditions (max %d)", maxFilterConditions)
}
}
if node.isEmpty() {
return "", nil
}
if !node.isGroup() {
return buildLeaf(node, args, next, allowedFields, renderers, loc)
}
logic := strings.ToUpper(strings.TrimSpace(node.Logic))
if logic == "" {
logic = "AND"
}
if logic != "AND" && logic != "OR" {
return "", fmt.Errorf("invalid filter logic: %s", node.Logic)
}
parts := []string{}
for _, child := range node.Rules {
if child.isEmpty() {
continue
}
clause, err := buildNode(child, args, next, allowedFields, renderers, depth+1, loc)
if err != nil {
return "", err
}
if clause != "" {
parts = append(parts, clause)
}
}
if len(parts) == 0 {
return "", nil
}
return "(" + strings.Join(parts, " "+logic+" ") + ")", nil
}
func buildLeaf(f FilterNode, args *[]any, next *int, allowedFields AllowedFields, renderers FieldRenderers, loc string) (string, error) {
if render, ok := renderers.get(f.Model, f.Field); ok {
sql, rArgs, err := render(f.Operator, f.Value, *next)
if err != nil {
return "", err
}
*args = append(*args, rArgs...)
*next += len(rArgs)
return sql, nil
}
modelFields, ok := allowedFields[f.Model]
if !ok {
return "", fmt.Errorf("invalid model: %s", f.Model)
}
if !slices.Contains(modelFields, f.Field) {
return "", fmt.Errorf("invalid field: %s for model: %s", f.Field, f.Model)
}
if valueRequiredOperators[f.Operator] && strings.TrimSpace(f.Value) == "" {
return "", fmt.Errorf("operator %q requires a value", f.Operator)
}
field := fmt.Sprintf("%s.%s", f.Model, f.Field)
switch f.Operator {
case "equals":
if dateOnlyRe.MatchString(f.Value) {
cond := fmt.Sprintf("(%s >= ($%d::date)::timestamp AT TIME ZONE $%d AND %s < ($%d::date + 1)::timestamp AT TIME ZONE $%d)", field, *next, *next+1, field, *next, *next+1)
*args = append(*args, f.Value, loc)
*next += 2
return cond, nil
}
cond := fmt.Sprintf("%s = $%d", field, *next)
*args = append(*args, f.Value)
*next++
return cond, nil
case "not equals":
if dateOnlyRe.MatchString(f.Value) {
cond := fmt.Sprintf("(%s < ($%d::date)::timestamp AT TIME ZONE $%d OR %s >= ($%d::date + 1)::timestamp AT TIME ZONE $%d)", field, *next, *next+1, field, *next, *next+1)
*args = append(*args, f.Value, loc)
*next += 2
return cond, nil
}
cond := fmt.Sprintf("%s != $%d", field, *next)
*args = append(*args, f.Value)
*next++
return cond, nil
case "greater than":
if dateOnlyRe.MatchString(f.Value) {
cond := fmt.Sprintf("%s >= ($%d::date + 1)::timestamp AT TIME ZONE $%d", field, *next, *next+1)
*args = append(*args, f.Value, loc)
*next += 2
return cond, nil
}
cond := fmt.Sprintf("%s > $%d", field, *next)
*args = append(*args, f.Value)
*next++
return cond, nil
case "less than":
if dateOnlyRe.MatchString(f.Value) {
cond := fmt.Sprintf("%s < ($%d::date)::timestamp AT TIME ZONE $%d", field, *next, *next+1)
*args = append(*args, f.Value, loc)
*next += 2
return cond, nil
}
cond := fmt.Sprintf("%s < $%d", field, *next)
*args = append(*args, f.Value)
*next++
return cond, nil
case "set":
return field + " IS NOT NULL", nil
case "not set":
return field + " IS NULL", nil
case "in":
var arr []string
if err := json.Unmarshal([]byte(f.Value), &arr); err != nil {
return "", fmt.Errorf("invalid array format for 'in' operator: %v", err)
}
if len(arr) == 0 {
return "", fmt.Errorf("operator \"in\" requires at least one value")
}
if len(arr) > maxInValues {
return "", fmt.Errorf("operator \"in\" allows at most %d values", maxInValues)
}
placeholders := make([]string, len(arr))
for i, v := range arr {
placeholders[i] = fmt.Sprintf("$%d", *next)
*args = append(*args, v)
*next++
}
return field + " IN (" + strings.Join(placeholders, ",") + ")", nil
case "between":
values := strings.Split(f.Value, ",")
if len(values) != 2 {
return "", fmt.Errorf("between requires 2 values")
}
start := strings.TrimSpace(values[0])
end := strings.TrimSpace(values[1])
if dateOnlyRe.MatchString(start) && dateOnlyRe.MatchString(end) {
cond := fmt.Sprintf("(%s >= ($%d::date)::timestamp AT TIME ZONE $%d AND %s < ($%d::date + 1)::timestamp AT TIME ZONE $%d)", field, *next, *next+2, field, *next+1, *next+2)
*args = append(*args, start, end, loc)
*next += 3
return cond, nil
}
cond := fmt.Sprintf("%s BETWEEN $%d AND $%d", field, *next, *next+1)
*args = append(*args, start, end)
*next += 2
return cond, nil
case "contains", "ilike":
cond := fmt.Sprintf("%s ILIKE $%d", field, *next)
*args = append(*args, "%"+f.Value+"%")
*next++
return cond, nil
case "not contains":
cond := fmt.Sprintf("%s NOT ILIKE $%d", field, *next)
*args = append(*args, "%"+f.Value+"%")
*next++
return cond, nil
default:
return "", fmt.Errorf("invalid operator: %s", f.Operator)
}
if len(conditions) == 0 {
return "", nil, nil
}
return strings.Join(conditions, " AND "), args, nil
}
+246
View File
@@ -0,0 +1,246 @@
package dbutil
import (
"errors"
"fmt"
"slices"
"strings"
"testing"
)
var testAllowed = AllowedFields{
"conversations": {"status_id", "priority_id", "created_at"},
"users": {"email"},
}
var testRenderers = FieldRenderers{
"conversations": {
"tags": func(operator, value string, paramIndex int) (string, []any, error) {
switch operator {
case "contains":
return fmt.Sprintf("conversations.id IN (SELECT conversation_id FROM conversation_tags WHERE tag_id = ANY($%d::int[]))", paramIndex), []any{value}, nil
case "set":
return "EXISTS (SELECT 1 FROM conversation_tags WHERE conversation_id = conversations.id)", nil, nil
default:
return "", nil, fmt.Errorf("bad tag op")
}
},
},
}
func build(t *testing.T, filtersJSON string) (string, []any, error) {
t.Helper()
return BuildPaginatedQuery("SELECT 1 FROM conversations WHERE 1=1", nil, PaginationOptions{Page: 1, PageSize: 30}, filtersJSON, testAllowed, testRenderers)
}
func TestLegacyFlatArrayIsAnded(t *testing.T) {
q, args, err := build(t, `[{"model":"conversations","field":"status_id","operator":"equals","value":"1"},{"model":"conversations","field":"priority_id","operator":"equals","value":"2"}]`)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(q, "(conversations.status_id = $1 AND conversations.priority_id = $2)") {
t.Fatalf("expected AND join, got: %s", q)
}
if len(args) != 4 { // 2 filters + LIMIT + OFFSET
t.Fatalf("expected 4 args, got %d: %v", len(args), args)
}
}
func TestGroupOr(t *testing.T) {
q, _, err := build(t, `{"logic":"OR","rules":[{"model":"conversations","field":"status_id","operator":"equals","value":"1"},{"model":"conversations","field":"status_id","operator":"equals","value":"5"}]}`)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(q, "(conversations.status_id = $1 OR conversations.status_id = $2)") {
t.Fatalf("expected OR join, got: %s", q)
}
}
func TestNestedMixed(t *testing.T) {
q, _, err := build(t, `{"logic":"AND","rules":[{"model":"conversations","field":"priority_id","operator":"equals","value":"3"},{"logic":"OR","rules":[{"model":"conversations","field":"status_id","operator":"equals","value":"1"},{"model":"conversations","field":"status_id","operator":"equals","value":"5"}]}]}`)
if err != nil {
t.Fatal(err)
}
want := "(conversations.priority_id = $1 AND (conversations.status_id = $2 OR conversations.status_id = $3))"
if !strings.Contains(q, want) {
t.Fatalf("expected nested mixed clause %q, got: %s", want, q)
}
}
func TestTagLeafInsideOrBranch(t *testing.T) {
q, _, err := build(t, `{"logic":"OR","rules":[{"model":"conversations","field":"status_id","operator":"equals","value":"1"},{"model":"conversations","field":"tags","operator":"contains","value":"[1,2]"}]}`)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(q, "OR conversations.id IN (SELECT conversation_id FROM conversation_tags") {
t.Fatalf("expected tag subquery inside OR, got: %s", q)
}
}
func TestDepthTooDeepRejected(t *testing.T) {
_, _, err := build(t, `{"logic":"AND","rules":[{"logic":"OR","rules":[{"logic":"AND","rules":[{"model":"conversations","field":"status_id","operator":"equals","value":"1"}]}]}]}`)
if err == nil {
t.Fatal("expected depth error")
}
}
func TestInvalidLogicRejected(t *testing.T) {
_, _, err := build(t, `{"logic":"XOR","rules":[{"model":"conversations","field":"status_id","operator":"equals","value":"1"}]}`)
if err == nil {
t.Fatal("expected invalid logic error")
}
}
func TestInvalidFieldRejected(t *testing.T) {
_, _, err := build(t, `[{"model":"conversations","field":"secret","operator":"equals","value":"1"}]`)
if err == nil {
t.Fatal("expected invalid field error")
}
}
func TestEmptyFiltersNoClause(t *testing.T) {
q, _, err := build(t, `[]`)
if err != nil {
t.Fatal(err)
}
if strings.Contains(q, "WHERE 1=1 AND") {
t.Fatalf("expected no filter clause, got: %s", q)
}
}
func TestContainsOnPlainColumnIsILike(t *testing.T) {
q, args, err := build(t, `[{"model":"users","field":"email","operator":"contains","value":"gmail"}]`)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(q, "users.email ILIKE $1") {
t.Fatalf("expected ILIKE for contains, got: %s", q)
}
if args[0] != "%gmail%" {
t.Fatalf("expected wrapped pattern, got: %v", args[0])
}
q, _, err = build(t, `[{"model":"users","field":"email","operator":"not contains","value":"gmail"}]`)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(q, "users.email NOT ILIKE $1") {
t.Fatalf("expected NOT ILIKE for not contains, got: %s", q)
}
}
func TestTooManyGroupsRejected(t *testing.T) {
group := `{"logic":"AND","rules":[{"model":"conversations","field":"status_id","operator":"equals","value":"1"}]}`
groups := make([]string, MaxFilterGroups+1)
for i := range groups {
groups[i] = group
}
j := `{"logic":"OR","rules":[` + strings.Join(groups, ",") + `]}`
_, _, err := build(t, j)
if !errors.Is(err, ErrTooManyGroups) {
t.Fatalf("expected ErrTooManyGroups for more than %d groups, got: %v", MaxFilterGroups, err)
}
}
func TestTooManyConditionsRejected(t *testing.T) {
leaf := `{"model":"conversations","field":"status_id","operator":"equals","value":"1"}`
leaves := make([]string, maxFilterConditions+1)
for i := range leaves {
leaves[i] = leaf
}
if _, _, err := build(t, `[`+strings.Join(leaves, ",")+`]`); err == nil {
t.Fatalf("expected error for more than %d conditions", maxFilterConditions)
}
}
func TestTooManyInValuesRejected(t *testing.T) {
vals := make([]string, maxInValues+1)
for i := range vals {
vals[i] = `"1"`
}
if _, _, err := build(t, `[{"model":"conversations","field":"status_id","operator":"in","value":"[`+strings.ReplaceAll(strings.Join(vals, ","), `"`, `\"`)+`]"}]`); err == nil {
t.Fatal("expected error for oversized 'in' array")
}
}
func TestEmptyInRejected(t *testing.T) {
if _, _, err := build(t, `[{"model":"conversations","field":"status_id","operator":"in","value":"[]"}]`); err == nil {
t.Fatal("expected error for empty 'in' array")
}
}
func TestEmptyValueRejected(t *testing.T) {
if _, _, err := build(t, `[{"model":"conversations","field":"status_id","operator":"equals","value":""}]`); err == nil {
t.Fatal("expected error for empty value on 'equals'")
}
if _, _, err := build(t, `[{"model":"conversations","field":"status_id","operator":"set","value":""}]`); err != nil {
t.Fatalf("'set' should not require a value: %v", err)
}
}
func TestValidateFilters(t *testing.T) {
if err := ValidateFilters(`{"logic":"AND","rules":[{"model":"conversations","field":"status_id","operator":"equals","value":"1"}]}`, testAllowed, testRenderers); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if err := ValidateFilters(`{"logic":"AND","rules":[{"model":"conversations","field":"nope","operator":"equals","value":"1"}]}`, testAllowed, testRenderers); err == nil {
t.Fatal("expected validation error for bad field")
}
}
func buildTZ(t *testing.T, loc, filtersJSON string) (string, []any, error) {
t.Helper()
return BuildPaginatedQuery("SELECT 1 FROM conversations WHERE 1=1", nil, PaginationOptions{Page: 1, PageSize: 30, Location: loc}, filtersJSON, testAllowed, testRenderers)
}
func TestDateFilterResolvesInConfiguredTimezone(t *testing.T) {
q, args, err := buildTZ(t, "Asia/Kolkata", `[{"model":"conversations","field":"created_at","operator":"equals","value":"2026-06-08"}]`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(q, "AT TIME ZONE") {
t.Fatalf("expected AT TIME ZONE in query, got: %s", q)
}
if !slices.Contains(args, any("2026-06-08")) || !slices.Contains(args, any("Asia/Kolkata")) {
t.Fatalf("expected date and timezone bound as params, got args: %v", args)
}
}
func TestDateFilterOperatorsBindDateAndTimezone(t *testing.T) {
cases := []struct {
op string
value string
}{
{"equals", "2026-06-08"},
{"not equals", "2026-06-08"},
{"greater than", "2026-06-08"},
{"less than", "2026-06-08"},
{"between", "2026-06-08,2026-06-10"},
}
for _, c := range cases {
filter := fmt.Sprintf(`[{"model":"conversations","field":"created_at","operator":%q,"value":%q}]`, c.op, c.value)
q, args, err := buildTZ(t, "Asia/Kolkata", filter)
if err != nil {
t.Fatalf("op %q: unexpected error: %v", c.op, err)
}
if !strings.Contains(q, "AT TIME ZONE") {
t.Fatalf("op %q: expected AT TIME ZONE, got: %s", c.op, q)
}
if !slices.Contains(args, any("Asia/Kolkata")) {
t.Fatalf("op %q: timezone not bound as a param, args: %v", c.op, args)
}
}
}
func TestDateFilterInvalidTimezoneFallsBackToUTC(t *testing.T) {
for _, loc := range []string{"", "Mars/Olympus", "'; DROP TABLE users;--"} {
_, args, err := buildTZ(t, loc, `[{"model":"conversations","field":"created_at","operator":"equals","value":"2026-06-08"}]`)
if err != nil {
t.Fatalf("loc %q: unexpected error: %v", loc, err)
}
if !slices.Contains(args, any("UTC")) {
t.Fatalf("loc %q: expected UTC fallback in args, got: %v", loc, args)
}
if slices.Contains(args, any(loc)) && loc != "" {
t.Fatalf("loc %q: invalid timezone leaked into args: %v", loc, args)
}
}
}
+13
View File
@@ -221,6 +221,19 @@ func (m *Manager) GetAppRootURL() (string, error) {
return strings.Trim(string(rootURL), "\""), nil
}
// GetAppTimezone returns the configured app timezone, empty if unset or unreadable.
func (m *Manager) GetAppTimezone() string {
b, err := m.Get("app.timezone")
if err != nil {
return ""
}
var tz string
if err := json.Unmarshal(b, &tz); err != nil {
return ""
}
return tz
}
// encryptSettings encrypts sensitive fields in the settings JSON.
func (m *Manager) encryptSettings(data []byte) ([]byte, error) {
var settings map[string]interface{}
+26
View File
@@ -0,0 +1,26 @@
package stringutil
import (
"strings"
"time"
)
// IsValidTimezone reports whether tz is an IANA timezone name. "Local" is rejected because Go
// resolves it to the host zone but Postgres' AT TIME ZONE does not accept it.
func IsValidTimezone(tz string) bool {
tz = strings.TrimSpace(tz)
if tz == "" || tz == "Local" {
return false
}
_, err := time.LoadLocation(tz)
return err == nil
}
// NormalizeTimezone returns tz if valid, otherwise UTC.
func NormalizeTimezone(tz string) string {
tz = strings.TrimSpace(tz)
if IsValidTimezone(tz) {
return tz
}
return "UTC"
}
+86
View File
@@ -0,0 +1,86 @@
package stringutil
import (
"testing"
"time"
)
// frontendPickerTimezones mirrors frontend/apps/main/src/constants/timezones.js (the values the FE can send on save).
var frontendPickerTimezones = []string{
"UTC",
"America/New_York",
"America/Chicago",
"America/Denver",
"America/Los_Angeles",
"America/Toronto",
"America/Mexico_City",
"America/Bogota",
"America/Sao_Paulo",
"America/Buenos_Aires",
"America/Santiago",
"Europe/London",
"Europe/Berlin",
"Europe/Paris",
"Europe/Rome",
"Europe/Madrid",
"Europe/Moscow",
"Europe/Istanbul",
"Asia/Dubai",
"Asia/Kolkata",
"Asia/Bangkok",
"Asia/Singapore",
"Asia/Shanghai",
"Asia/Seoul",
"Asia/Tokyo",
"Australia/Sydney",
"Australia/Melbourne",
"Australia/Perth",
"Pacific/Auckland",
"Pacific/Honolulu",
"Africa/Cairo",
"Africa/Lagos",
"Africa/Nairobi",
"Africa/Johannesburg",
}
func TestFrontendPickerTimezonesAreValid(t *testing.T) {
for _, tz := range frontendPickerTimezones {
// Save-time validation must accept it.
if !IsValidTimezone(tz) {
t.Errorf("frontend picker offers %q but IsValidTimezone rejects it", tz)
}
// It must actually load in Go, since the date filter relies on it at query time.
if _, err := time.LoadLocation(tz); err != nil {
t.Errorf("frontend picker offers %q but time.LoadLocation fails: %v", tz, err)
}
if got := NormalizeTimezone(tz); got != tz {
t.Errorf("NormalizeTimezone(%q) = %q, want it unchanged", tz, got)
}
}
}
func TestNormalizeTimezoneFallsBackToUTC(t *testing.T) {
cases := []string{
"",
" ",
"Local", // Go resolves it, Postgres rejects it
"asia/kolkata", // wrong case, IANA is case-sensitive
"Mars/Olympus", // not a real zone
"'; DROP TABLE users;--", // injection attempt
"UTC+5", // not an IANA name
}
for _, tz := range cases {
if IsValidTimezone(tz) {
t.Errorf("expected %q to be invalid", tz)
}
if got := NormalizeTimezone(tz); got != "UTC" {
t.Errorf("NormalizeTimezone(%q) = %q, want UTC", tz, got)
}
}
}
func TestNormalizeTimezoneTrimsWhitespace(t *testing.T) {
if got := NormalizeTimezone(" Asia/Kolkata "); got != "Asia/Kolkata" {
t.Errorf("NormalizeTimezone trimmed = %q, want Asia/Kolkata", got)
}
}
+1 -1
View File
@@ -175,5 +175,5 @@ func (u *Manager) MarkInactiveUsersOffline() []models.OfflineUser {
// GetAllAgents returns a list of all agents.
func (u *Manager) GetAgents() ([]models.UserCompact, error) {
// Some dirty hack.
return u.GetAllUsers(1, 999999999, []string{models.UserTypeAgent}, "desc", "users.updated_at", "")
return u.GetAllUsers(1, 999999999, []string{models.UserTypeAgent}, "desc", "users.updated_at", "", "")
}
+2 -2
View File
@@ -93,7 +93,7 @@ func (u *Manager) UpdateContact(id int, user models.User) error {
}
// GetAllContacts returns a list of all contacts.
func (u *Manager) GetContacts(page, pageSize int, order, orderBy string, filtersJSON string) ([]models.UserCompact, error) {
func (u *Manager) GetContacts(page, pageSize int, order, orderBy string, filtersJSON, location string) ([]models.UserCompact, error) {
if pageSize > maxListPageSize {
pageSize = maxListPageSize
}
@@ -103,5 +103,5 @@ func (u *Manager) GetContacts(page, pageSize int, order, orderBy string, filters
if pageSize < 1 {
pageSize = 10
}
return u.GetAllUsers(page, pageSize, []string{models.UserTypeContact, models.UserTypeVisitor}, order, orderBy, filtersJSON)
return u.GetAllUsers(page, pageSize, []string{models.UserTypeContact, models.UserTypeVisitor}, order, orderBy, filtersJSON, location)
}
+5 -4
View File
@@ -152,8 +152,8 @@ func (u *Manager) VerifyPassword(email string, password []byte) (models.User, er
}
// GetAllUsers returns a list of all users.
func (u *Manager) GetAllUsers(page, pageSize int, userTypes []string, order, orderBy string, filtersJSON string) ([]models.UserCompact, error) {
query, qArgs, err := u.makeUserListQuery(page, pageSize, userTypes, order, orderBy, filtersJSON)
func (u *Manager) GetAllUsers(page, pageSize int, userTypes []string, order, orderBy string, filtersJSON, location string) ([]models.UserCompact, error) {
query, qArgs, err := u.makeUserListQuery(page, pageSize, userTypes, order, orderBy, filtersJSON, location)
if err != nil {
u.lo.Error("error creating user list query", "error", err)
return nil, envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil)
@@ -594,7 +594,7 @@ func updateSystemUserPassword(db *sqlx.DB, hashedPassword []byte) error {
}
// makeUserListQuery generates a query to fetch users based on the provided filters.
func (u *Manager) makeUserListQuery(page, pageSize int, userTypes []string, order, orderBy, filtersJSON string) (string, []interface{}, error) {
func (u *Manager) makeUserListQuery(page, pageSize int, userTypes []string, order, orderBy, filtersJSON, location string) (string, []interface{}, error) {
var qArgs []any
qArgs = append(qArgs, pq.Array(userTypes))
return dbutil.BuildPaginatedQuery(u.q.GetUsersCompact, qArgs, dbutil.PaginationOptions{
@@ -602,9 +602,10 @@ func (u *Manager) makeUserListQuery(page, pageSize int, userTypes []string, orde
OrderBy: orderBy,
Page: page,
PageSize: pageSize,
Location: location,
}, filtersJSON, dbutil.AllowedFields{
"users": {"email", "created_at", "updated_at"},
})
}, nil)
}
// verifyPassword compares the provided password with the stored password hash.