mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-22 18:43:33 +00:00
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:
+1
-1
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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), "/")
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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", "", "")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user