@@ -46,12 +54,14 @@
diff --git a/frontend/apps/main/src/features/conversation/list/ConversationListItem.vue b/frontend/apps/main/src/features/conversation/list/ConversationListItem.vue
index 81671269..41992fb0 100644
--- a/frontend/apps/main/src/features/conversation/list/ConversationListItem.vue
+++ b/frontend/apps/main/src/features/conversation/list/ConversationListItem.vue
@@ -84,7 +84,10 @@
-
+
+ {{ $t('globals.terms.typing') }}
+
+
{{ $t('globals.terms.draft') }}:
{{ draftPreview }}
@@ -195,9 +198,9 @@ const CHANNEL_ICONS = {
const channelIcon = computed(() => CHANNEL_ICONS[props.conversation.inbox_channel] || Mail)
const conversationRoute = computed(() => {
- const baseRoute = route.name.includes('team')
+ const baseRoute = route.params.teamID
? 'team-inbox-conversation'
- : route.name.includes('view')
+ : route.params.viewID
? 'view-inbox-conversation'
: 'inbox-conversation'
return {
@@ -243,6 +246,8 @@ const hasDraftForConversation = computed(() => {
return conversationStore.hasDraft(props.conversation.uuid)
})
+const isTyping = computed(() => conversationStore.typingByUUID[props.conversation.uuid] === true)
+
const draftPreview = computed(() => {
const draft = conversationStore.getDraft(props.conversation.uuid)
if (!draft?.content) return ''
diff --git a/frontend/apps/main/src/features/conversation/list/ConversationListItemSkeleton.vue b/frontend/apps/main/src/features/conversation/list/ConversationListItemSkeleton.vue
index f09d571a..f3396b4f 100644
--- a/frontend/apps/main/src/features/conversation/list/ConversationListItemSkeleton.vue
+++ b/frontend/apps/main/src/features/conversation/list/ConversationListItemSkeleton.vue
@@ -1,14 +1,74 @@
-
-
-
-
-
-
+
+
+
diff --git a/frontend/apps/main/src/features/conversation/message/AssignSelfNudge.vue b/frontend/apps/main/src/features/conversation/message/AssignSelfNudge.vue
new file mode 100644
index 00000000..095f13f9
--- /dev/null
+++ b/frontend/apps/main/src/features/conversation/message/AssignSelfNudge.vue
@@ -0,0 +1,39 @@
+
+
+
+
+ {{ $t('conversation.assignSelfPrompt') }}
+
+
+
+
+
+
+
+
diff --git a/frontend/apps/main/src/features/conversation/message/CSATResponseDisplay.vue b/frontend/apps/main/src/features/conversation/message/CSATResponseDisplay.vue
index 8f526c80..93a97df2 100644
--- a/frontend/apps/main/src/features/conversation/message/CSATResponseDisplay.vue
+++ b/frontend/apps/main/src/features/conversation/message/CSATResponseDisplay.vue
@@ -7,8 +7,8 @@
- {{ getRatingEmoji(csatResponse.rating) }}
- {{ getRatingText(csatResponse.rating) }}
+ {{ csatRatingEmoji(csatResponse.rating) }}
+ {{ t(csatRatingTextKey(csatResponse.rating)) }}
{{ csatResponse.rating }}/5
@@ -29,6 +29,7 @@
diff --git a/frontend/apps/main/src/features/conversation/message/MessageList.vue b/frontend/apps/main/src/features/conversation/message/MessageList.vue
index 1e0cb289..274cda8d 100644
--- a/frontend/apps/main/src/features/conversation/message/MessageList.vue
+++ b/frontend/apps/main/src/features/conversation/message/MessageList.vue
@@ -1,34 +1,42 @@
-
-
+
+
-
+
+
-
+
diff --git a/frontend/apps/main/src/features/conversation/message/MessagesSkeleton.vue b/frontend/apps/main/src/features/conversation/message/MessagesSkeleton.vue
index 23646855..ffeb7408 100644
--- a/frontend/apps/main/src/features/conversation/message/MessagesSkeleton.vue
+++ b/frontend/apps/main/src/features/conversation/message/MessagesSkeleton.vue
@@ -1,34 +1,85 @@
-
-
-
-
-
-
-
-
-
+
+
+
-
-
-
-
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/apps/main/src/features/conversation/sidebar/ConversationInfo.vue b/frontend/apps/main/src/features/conversation/sidebar/ConversationInfo.vue
index a43943aa..4fb84b20 100644
--- a/frontend/apps/main/src/features/conversation/sidebar/ConversationInfo.vue
+++ b/frontend/apps/main/src/features/conversation/sidebar/ConversationInfo.vue
@@ -1,23 +1,32 @@
+
+
-
-
-
-
+
-
@@ -34,13 +43,10 @@
:key="`${conversation.uuid}-${conversation.first_response_deadline_at}-${conversation.first_reply_at}`"
/>
-
-
-
-
-
+
+
@@ -53,13 +59,10 @@
:key="`${conversation.uuid}-${conversation.resolution_deadline_at}-${conversation.resolved_at}`"
/>
-
-
-
-
-
+
+
@@ -72,7 +75,6 @@
:key="`${conversation.uuid}-${conversation.next_response_deadline_at}-${conversation.next_response_met_at}`"
/>
-
@@ -81,8 +83,7 @@
-
-
@@ -101,20 +102,48 @@
:custom-attributes="conversation.custom_attributes || {}"
@update:setattributes="updateCustomAttributes"
/>
+
+
+
+
+ {{ csatRatingEmoji(conversation.csat_rating) }}
+
+ {{ conversation.csat_rating }}/5
+
+
+
+
+
+
+
+
diff --git a/frontend/apps/main/src/views/inbox/InboxView.vue b/frontend/apps/main/src/views/inbox/InboxView.vue
index 6c0e1ef8..fdf54fc2 100644
--- a/frontend/apps/main/src/views/inbox/InboxView.vue
+++ b/frontend/apps/main/src/views/inbox/InboxView.vue
@@ -6,6 +6,7 @@
diff --git a/frontend/apps/main/src/views/reports/OverviewView.vue b/frontend/apps/main/src/views/reports/OverviewView.vue
index e7fefa07..8ec773f3 100644
--- a/frontend/apps/main/src/views/reports/OverviewView.vue
+++ b/frontend/apps/main/src/views/reports/OverviewView.vue
@@ -35,7 +35,7 @@
{{ $t('report.csat.cardTitle', { days: csatDays }) }}
-
+
handleFilterChange('csat', d)" :label="''" />
@@ -61,7 +61,10 @@
{{ $t('report.messages.cardTitle', { days: messageVolumeDays }) }}
-
+
handleFilterChange('messageVolume', d)"
+ :label="''"
+ />
@@ -96,7 +99,7 @@
{{ slaCardTitle }}
-
+
handleFilterChange('sla', d)" :label="''" />
@@ -201,7 +204,10 @@
{{ $t('report.tags.cardTitle', { days: tagDistributionDays }) }}
-
+
handleFilterChange('tagDistribution', d)"
+ :label="''"
+ />
@@ -240,7 +246,7 @@
{{ $t('report.chart.title') }}
-
+
handleFilterChange('chart', d)" :label="''" />
@@ -265,6 +271,7 @@ import api from '../../api'
const emitter = useEmitter()
const { t } = useI18n()
const isLoading = ref(false)
+const initialized = ref(false)
const lastUpdate = ref(new Date())
const cardCounts = ref({})
const chartData = ref({ status_summary: [] })
@@ -292,7 +299,6 @@ const slaCounts = ref({
resolution_compliance_percent: 0
})
-// New data refs
const csatData = ref({
average_rating: 0,
response_rate: 0,
@@ -314,14 +320,53 @@ const tagDistributionData = ref({
tagged_percentage: 0
})
-// Date filter state
-const slaDays = ref(30)
-const chartDays = ref(90)
-const csatDays = ref(30)
-const messageVolumeDays = ref(30)
-const tagDistributionDays = ref(30)
+const sections = {
+ sla: {
+ days: ref(30),
+ fetch: async (days) => {
+ const { data } = await api.getOverviewSLA({ days })
+ slaCounts.value = { ...slaCounts.value, ...data.data }
+ }
+ },
+ chart: {
+ days: ref(90),
+ fetch: async (days) => {
+ const { data } = await api.getOverviewCharts({ days })
+ chartData.value = {
+ new_conversations: data.data.new_conversations || [],
+ resolved_conversations: data.data.resolved_conversations || [],
+ messages_sent: data.data.messages_sent || []
+ }
+ }
+ },
+ csat: {
+ days: ref(30),
+ fetch: async (days) => {
+ const { data } = await api.getOverviewCSAT({ days })
+ csatData.value = { ...csatData.value, ...data.data }
+ }
+ },
+ messageVolume: {
+ days: ref(30),
+ fetch: async (days) => {
+ const { data } = await api.getOverviewMessageVolume({ days })
+ messageVolumeData.value = { ...messageVolumeData.value, ...data.data }
+ }
+ },
+ tagDistribution: {
+ days: ref(30),
+ fetch: async (days) => {
+ const { data } = await api.getOverviewTagDistribution({ days })
+ tagDistributionData.value = { ...tagDistributionData.value, ...data.data }
+ }
+ }
+}
+
+const slaDays = sections.sla.days
+const csatDays = sections.csat.days
+const messageVolumeDays = sections.messageVolume.days
+const tagDistributionDays = sections.tagDistribution.days
-// Format helpers
const formatRating = (value) => {
if (!value) return '0.0'
return Number(value).toFixed(1)
@@ -346,7 +391,6 @@ const formattedSlaCounts = computed(() => ({
avg_resolution_time_sec: formatDuration(slaCounts.value.avg_resolution_time_sec, false)
}))
-// Dynamic SLA card title based on selected days
const slaCardTitle = computed(() => t('report.sla.cardTitle', { days: slaDays.value }))
const lastUpdateFormatted = computed(() => lastUpdate.value.toLocaleTimeString())
@@ -415,105 +459,20 @@ const fetchCardStats = async () => {
}
}
-const fetchSLAStats = async (days = slaDays.value) => {
+const runSectionFetch = async (key, days) => {
try {
- const { data } = await api.getOverviewSLA({ days })
- slaCounts.value = { ...slaCounts.value, ...data.data }
+ await sections[key].fetch(days)
} catch (error) {
showError(error)
}
}
-const fetchChartData = async (days = chartDays.value) => {
- try {
- const { data } = await api.getOverviewCharts({ days })
- chartData.value = {
- new_conversations: data.data.new_conversations || [],
- resolved_conversations: data.data.resolved_conversations || [],
- messages_sent: data.data.messages_sent || []
- }
- } catch (error) {
- showError(error)
- }
-}
-
-const fetchCSATStats = async (days = csatDays.value) => {
- try {
- const { data } = await api.getOverviewCSAT({ days })
- csatData.value = { ...csatData.value, ...data.data }
- } catch (error) {
- showError(error)
- }
-}
-
-const fetchMessageVolumeStats = async (days = messageVolumeDays.value) => {
- try {
- const { data } = await api.getOverviewMessageVolume({ days })
- messageVolumeData.value = { ...messageVolumeData.value, ...data.data }
- } catch (error) {
- showError(error)
- }
-}
-
-const fetchTagDistributionStats = async (days = tagDistributionDays.value) => {
- try {
- const { data } = await api.getOverviewTagDistribution({ days })
- tagDistributionData.value = { ...tagDistributionData.value, ...data.data }
- } catch (error) {
- showError(error)
- }
-}
-
-// Date filter handlers
-const handleSlaFilterChange = async (days) => {
- slaDays.value = days
+const handleFilterChange = async (key, days) => {
+ if (!initialized.value) return
+ sections[key].days.value = days
isLoading.value = true
try {
- await fetchSLAStats(days)
- } finally {
- isLoading.value = false
- lastUpdate.value = new Date()
- }
-}
-
-const handleChartFilterChange = async (days) => {
- chartDays.value = days
- isLoading.value = true
- try {
- await fetchChartData(days)
- } finally {
- isLoading.value = false
- lastUpdate.value = new Date()
- }
-}
-
-const handleCSATFilterChange = async (days) => {
- csatDays.value = days
- isLoading.value = true
- try {
- await fetchCSATStats(days)
- } finally {
- isLoading.value = false
- lastUpdate.value = new Date()
- }
-}
-
-const handleMessageVolumeFilterChange = async (days) => {
- messageVolumeDays.value = days
- isLoading.value = true
- try {
- await fetchMessageVolumeStats(days)
- } finally {
- isLoading.value = false
- lastUpdate.value = new Date()
- }
-}
-
-const handleTagDistributionFilterChange = async (days) => {
- tagDistributionDays.value = days
- isLoading.value = true
- try {
- await fetchTagDistributionStats(days)
+ await runSectionFetch(key, days)
} finally {
isLoading.value = false
lastUpdate.value = new Date()
@@ -525,15 +484,12 @@ const loadDashboardData = async () => {
try {
await Promise.allSettled([
fetchCardStats(),
- fetchSLAStats(),
- fetchChartData(),
- fetchCSATStats(),
- fetchMessageVolumeStats(),
- fetchTagDistributionStats()
+ ...Object.keys(sections).map((key) => runSectionFetch(key, sections[key].days.value))
])
} finally {
isLoading.value = false
lastUpdate.value = new Date()
+ initialized.value = true
}
}
@@ -564,10 +520,6 @@ onUnmounted(() => {
@apply text-3xl font-bold tracking-tight;
}
-.metric-value {
- @apply text-3xl font-bold tracking-tight;
-}
-
.metric-label {
@apply text-xs text-muted-foreground uppercase tracking-wider;
}
diff --git a/frontend/apps/main/src/websocket.js b/frontend/apps/main/src/websocket.js
index 3680716a..a62a3e90 100644
--- a/frontend/apps/main/src/websocket.js
+++ b/frontend/apps/main/src/websocket.js
@@ -1,5 +1,6 @@
import { useConversationStore } from './stores/conversation'
import { useNotificationStore } from './stores/notification'
+import { useUsersStore } from './stores/users'
import { WS_EVENT, WS_EPHEMERAL_TYPES } from './constants/websocket'
import { playNotificationSound } from '@shared-ui/composables/useNotificationSound'
@@ -11,14 +12,15 @@ export class WebSocketClient {
this.reconnectAttempts = 0
this.maxReconnectAttempts = 50
this.isReconnecting = false
+ this.reconnectTimer = null
this.manualClose = false
this.pingInterval = null
this.lastPong = Date.now()
this.convStore = useConversationStore()
this.notificationStore = useNotificationStore()
+ this.usersStore = useUsersStore()
this.messageQueue = []
this.maxQueueSize = 50
- // 30 sec.
this.queueTimeoutMs = 30000
}
@@ -44,13 +46,20 @@ export class WebSocketClient {
handleOpen () {
console.log('WebSocket connected')
+ const wasReconnect = this.reconnectAttempts > 0
this.reconnectInterval = 1000
this.reconnectAttempts = 0
this.isReconnecting = false
this.lastPong = Date.now()
this.setupPing()
- // Send any queued messages after connection is established.
this.flushMessageQueue()
+ if (wasReconnect) {
+ // RESUB!
+ const uuids = this.convStore.conversations.data?.map(c => c.uuid) || []
+ this.subscribeListReplace(uuids)
+ const openUUID = this.convStore.conversation.data?.uuid
+ if (openUUID) this.subscribeToConversation(openUUID)
+ }
}
handleMessage (event) {
@@ -64,29 +73,55 @@ export class WebSocketClient {
const data = JSON.parse(event.data)
const handlers = {
- // On new message, refresh list and fetch message if it's in current conversation.
[WS_EVENT.NEW_MESSAGE]: () => {
- if (data.data.sender_type === 'contact' && document.hidden) {
- const uuid = data.data.conversation_uuid
- if (this.convStore.isConversationInList(uuid)) {
+ const uuid = data.data.conversation_uuid
+ const isOpen = this.convStore.conversation.data?.uuid === uuid
+ const isFromContact = data.data.sender_type === 'contact'
+ const convPayload = data.data.conversation
+
+ if (convPayload) {
+ this.convStore.handleConvPush(convPayload)
+ } else {
+ this.convStore.mergeConversationUpdate({
+ uuid,
+ last_message: data.data.preview,
+ last_message_at: data.data.created_at,
+ last_message_sender: data.data.sender_type,
+ })
+ }
+
+ if (isFromContact && document.hidden) {
+ if (isOpen || this.convStore.isConversationInList(uuid)) {
playNotificationSound()
} else {
this.convStore.addPendingNotification(uuid)
}
}
- this.convStore.refreshConversationList()
+
+ if (!isOpen && this.convStore.isConversationInList(uuid)) {
+ this.convStore.incrementUnread(uuid)
+ }
+
this.convStore.updateConversationMessage(data.data)
},
+ [WS_EVENT.NEW_CONVERSATION]: () => {
+ if (data.data && data.data.uuid) {
+ this.convStore.handleConvPush(data.data)
+ } else {
+ this.convStore.refreshConversationList()
+ }
+ },
// Property updates for conversation and message.
[WS_EVENT.MESSAGE_UPDATE]: () => this.convStore.mergeMessageUpdate(data.data),
[WS_EVENT.CONVERSATION_UPDATE]: () => this.convStore.mergeConversationUpdate(data.data),
[WS_EVENT.CONTACT_UPDATE]: () => this.convStore.mergeContactUpdate(data.data),
- [WS_EVENT.CONVERSATION_SUBSCRIBED]: () => { },
[WS_EVENT.TYPING]: () => {
this.convStore.updateTypingStatus(data.data)
},
// New notification.
- [WS_EVENT.NEW_NOTIFICATION]: () => this.notificationStore.addNotification(data.data)
+ [WS_EVENT.NEW_NOTIFICATION]: () => this.notificationStore.addNotification(data.data),
+ [WS_EVENT.AGENT_AVAILABILITY_UPDATE]: () =>
+ this.usersStore.setAvailability(data.data.agent_id, data.data.availability_status),
}
const handler = handlers[data.type]
@@ -118,8 +153,9 @@ export class WebSocketClient {
this.isReconnecting = true
this.reconnectAttempts++
- setTimeout(() => {
+ this.reconnectTimer = setTimeout(() => {
this.isReconnecting = false
+ this.reconnectTimer = null
this.connect()
this.reconnectInterval = Math.min(this.reconnectInterval * 1.5, this.maxReconnectInterval)
}, this.reconnectInterval)
@@ -127,10 +163,18 @@ export class WebSocketClient {
setupNetworkListeners () {
window.addEventListener('online', () => {
- if (this.socket?.readyState !== WebSocket.OPEN) {
- this.reconnectInterval = 1000
- this.reconnect()
+ // Clear any pending reconnect attempts.
+ if (this.reconnectTimer) {
+ clearTimeout(this.reconnectTimer)
+ this.reconnectTimer = null
}
+ this.reconnectAttempts = 0
+ this.reconnectInterval = 1000
+ this.isReconnecting = false
+ if (this.socket) {
+ this.socket.close()
+ }
+ this.reconnect()
})
window.addEventListener('focus', () => {
@@ -146,8 +190,8 @@ export class WebSocketClient {
if (this.socket?.readyState === WebSocket.OPEN) {
try {
this.socket.send('ping')
- if (Date.now() - this.lastPong > 60000) {
- console.warn('No pong received in 60 seconds, closing connection')
+ if (Date.now() - this.lastPong > 90000) {
+ console.warn('No pong received in 90 seconds, closing connection')
this.socket.close()
}
} catch (e) {
@@ -155,7 +199,7 @@ export class WebSocketClient {
this.reconnect()
}
}
- }, 5000)
+ }, 30000)
}
clearPing () {
@@ -240,6 +284,10 @@ export class WebSocketClient {
this.send(subscribeMessage)
}
+ subscribeListReplace (uuids) {
+ this.send({ type: WS_EVENT.LIST_SUBSCRIBE_REPLACE, data: { uuids: uuids || [] } })
+ }
+
sendTypingIndicator (conversationUUID, isTyping, isPrivateMessage) {
if (!conversationUUID) return
@@ -276,5 +324,6 @@ export function initWS () {
export const sendMessage = message => wsClient?.send(message)
export const subscribeToConversation = conversationUUID => wsClient?.subscribeToConversation(conversationUUID)
+export const subscribeListReplace = uuids => wsClient?.subscribeListReplace(uuids)
export const sendTypingIndicator = (conversationUUID, isTyping, isPrivateMessage) => wsClient?.sendTypingIndicator(conversationUUID, isTyping, isPrivateMessage)
export const closeWebSocket = () => wsClient?.close()
\ No newline at end of file
diff --git a/frontend/apps/widget/index.html b/frontend/apps/widget/index.html
index 7dec111c..10c1c7f8 100644
--- a/frontend/apps/widget/index.html
+++ b/frontend/apps/widget/index.html
@@ -7,7 +7,7 @@
libredesk Chat Widget
-
diff --git a/frontend/apps/widget/src/components/BaseBanner.vue b/frontend/apps/widget/src/components/BaseBanner.vue
new file mode 100644
index 00000000..2d08ad8d
--- /dev/null
+++ b/frontend/apps/widget/src/components/BaseBanner.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
diff --git a/frontend/apps/widget/src/components/ChatMessages.vue b/frontend/apps/widget/src/components/ChatMessages.vue
index b8b249b4..feb9a9dd 100644
--- a/frontend/apps/widget/src/components/ChatMessages.vue
+++ b/frontend/apps/widget/src/components/ChatMessages.vue
@@ -5,139 +5,143 @@
@@ -145,7 +149,7 @@
+
+
+
+
+
+
+
diff --git a/frontend/apps/widget/src/store/widget.js b/frontend/apps/widget/src/store/widget.js
index 77a87466..1a4603aa 100644
--- a/frontend/apps/widget/src/store/widget.js
+++ b/frontend/apps/widget/src/store/widget.js
@@ -10,6 +10,9 @@ export const useWidgetStore = defineStore('widget', () => {
const isMobileFullScreen = ref(false)
const isExpanded = ref(false)
const wasExpandedBeforeLeaving = ref(false)
+ const connectionFailed = ref(false)
+ const connecting = ref(false)
+ const connected = ref(false)
// Getters
@@ -105,6 +108,18 @@ export const useWidgetStore = defineStore('widget', () => {
isExpanded.value = expanded
}
+ const setConnectionFailed = (failed) => {
+ connectionFailed.value = failed
+ }
+
+ const setConnecting = (value) => {
+ connecting.value = value
+ }
+
+ const setConnected = (value) => {
+ connected.value = value
+ }
+
return {
// State
isOpen,
@@ -114,6 +129,9 @@ export const useWidgetStore = defineStore('widget', () => {
isMobileFullScreen,
isExpanded,
wasExpandedBeforeLeaving,
+ connectionFailed,
+ connecting,
+ connected,
// Getters
isChatView,
@@ -130,5 +148,8 @@ export const useWidgetStore = defineStore('widget', () => {
expandWidget,
collapseWidget,
setExpanded,
+ setConnectionFailed,
+ setConnecting,
+ setConnected,
}
})
diff --git a/frontend/apps/widget/src/websocket.js b/frontend/apps/widget/src/websocket.js
index c821b472..6f76daea 100644
--- a/frontend/apps/widget/src/websocket.js
+++ b/frontend/apps/widget/src/websocket.js
@@ -15,16 +15,19 @@ export const WS_EVENT = {
CONVERSATION_UPDATE: 'conversation_update',
}
+// sync retries ~5 min (100 × 3s); WS reconnects every 2s up to ~5 min (150 attempts).
+const SYNC_MAX_RETRIES = 100
+const SYNC_RETRY_DELAY_MS = 3000
+const RECONNECT_INTERVAL_MS = 2000
+
let widgetWSClient
let _syncOnFirstConnect = true
export class WidgetWebSocketClient {
constructor() {
this.socket = null
- this.reconnectInterval = 1000
- this.maxReconnectInterval = 30000
this.reconnectAttempts = 0
- this.maxReconnectAttempts = 50
+ this.maxReconnectAttempts = 150
this.isReconnecting = false
this.reconnectTimer = null
this.manualClose = false
@@ -34,6 +37,10 @@ export class WidgetWebSocketClient {
this.wsInitiated = false
this.token = null
this.inboxId = null
+ this.syncRetryTimer = null
+ this.networkListenersSetup = false
+ this.recovering = false
+ this.connectedBannerTimer = null
}
init (token, inboxId) {
@@ -60,10 +67,10 @@ export class WidgetWebSocketClient {
}
handleOpen () {
- this.reconnectInterval = 1000
this.reconnectAttempts = 0
this.isReconnecting = false
this.lastPong = Date.now()
+ useWidgetStore().setConnectionFailed(false)
this.setupPing()
// Auto-join inbox after connection if inbox_id is set.
@@ -77,6 +84,8 @@ export class WidgetWebSocketClient {
if (this.wsInitiated || _syncOnFirstConnect) {
this.syncMissedMessages()
}
+
+ this.finishRecovery()
this.wsInitiated = true
}
@@ -144,12 +153,21 @@ export class WidgetWebSocketClient {
handleClose () {
this.clearPing()
if (!this.manualClose) {
+ this.beginRecovery()
this.reconnect()
}
}
reconnect () {
- if (this.isReconnecting || this.reconnectAttempts >= this.maxReconnectAttempts) return
+ if (this.isReconnecting) return
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
+ const widgetStore = useWidgetStore()
+ widgetStore.setConnectionFailed(true)
+ widgetStore.setConnecting(false)
+ widgetStore.setConnected(false)
+ this.recovering = false
+ return
+ }
this.isReconnecting = true
this.reconnectAttempts++
@@ -158,28 +176,52 @@ export class WidgetWebSocketClient {
this.isReconnecting = false
this.reconnectTimer = null
this.connect()
- this.reconnectInterval = Math.min(this.reconnectInterval * 1.5, this.maxReconnectInterval)
- }, this.reconnectInterval)
+ }, RECONNECT_INTERVAL_MS)
+ }
+
+ beginRecovery () {
+ if (!this.wsInitiated || this.recovering) return
+ this.recovering = true
+ clearTimeout(this.connectedBannerTimer)
+ const widgetStore = useWidgetStore()
+ widgetStore.setConnected(false)
+ widgetStore.setConnecting(true)
+ }
+
+ finishRecovery () {
+ if (!this.recovering) return
+ this.recovering = false
+ const widgetStore = useWidgetStore()
+ widgetStore.setConnecting(false)
+ widgetStore.setConnected(true)
+ clearTimeout(this.connectedBannerTimer)
+ this.connectedBannerTimer = setTimeout(() => widgetStore.setConnected(false), 2000)
}
setupNetworkListeners () {
+ if (this.networkListenersSetup) return
+ this.networkListenersSetup = true
+
window.addEventListener('online', () => {
- // Cancel any pending backoff timer and reconnect immediately.
+ if (this.manualClose) return
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
this.reconnectAttempts = 0
- this.reconnectInterval = 1000
this.isReconnecting = false
+ useWidgetStore().setConnectionFailed(false)
if (this.socket) {
this.socket.close()
}
+ this.beginRecovery()
+ this.syncMissedMessages()
this.reconnect()
})
// On tab return, if WS is not connected, sync data immediately and reconnect in parallel.
document.addEventListener('visibilitychange', () => {
+ if (this.manualClose) return
if (document.visibilityState === 'visible' && this.socket?.readyState !== WebSocket.OPEN) {
this.syncMissedMessages()
this.reconnect()
@@ -189,14 +231,15 @@ export class WidgetWebSocketClient {
setupPing () {
this.clearPing()
+ // Backend read deadline is 20s; ping every 10s so a single missed ping doesn't trip it.
this.pingInterval = setInterval(() => {
if (this.socket?.readyState === WebSocket.OPEN) {
try {
this.socket.send(JSON.stringify({
type: 'ping',
}))
- if (Date.now() - this.lastPong > 60000) {
- console.warn('No pong received in 60 seconds, closing widget connection')
+ if (Date.now() - this.lastPong > 30000) {
+ console.warn('No pong received in 30 seconds, closing widget connection')
this.socket.close()
}
} catch (e) {
@@ -204,7 +247,7 @@ export class WidgetWebSocketClient {
this.reconnect()
}
}
- }, 5000)
+ }, 10000)
}
clearPing () {
@@ -231,17 +274,22 @@ export class WidgetWebSocketClient {
this.send(joinMessage)
}
- // Silently refresh conversation list and current conversation to catch messages missed while WS was disconnected.
- syncMissedMessages () {
+ async syncMissedMessages (attempt = 0) {
const now = Date.now()
- if (now - this.lastSyncAt < 2000) return
+ if (attempt === 0 && now - this.lastSyncAt < 2000) return
this.lastSyncAt = now
+ clearTimeout(this.syncRetryTimer)
const chatStore = useChatStore()
- chatStore.fetchConversations(true, true)
+ const conversationsOk = await chatStore.fetchConversations(true, true)
const currentConversationUUID = chatStore.currentConversation?.uuid
+ let conversationOk = true
if (currentConversationUUID) {
- chatStore.loadConversation(currentConversationUUID, true, true)
+ conversationOk = await chatStore.loadConversation(currentConversationUUID, true, true)
+ }
+
+ if ((!conversationsOk || !conversationOk) && attempt < SYNC_MAX_RETRIES) {
+ this.syncRetryTimer = setTimeout(() => this.syncMissedMessages(attempt + 1), SYNC_RETRY_DELAY_MS)
}
}
@@ -267,6 +315,20 @@ export class WidgetWebSocketClient {
close () {
this.manualClose = true
this.clearPing()
+ clearTimeout(this.syncRetryTimer)
+ this.syncRetryTimer = null
+ clearTimeout(this.connectedBannerTimer)
+ this.connectedBannerTimer = null
+ clearTimeout(this.reconnectTimer)
+ this.reconnectTimer = null
+ this.isReconnecting = false
+ this.reconnectAttempts = 0
+ this.recovering = false
+ this.lastSyncAt = 0
+ const widgetStore = useWidgetStore()
+ widgetStore.setConnectionFailed(false)
+ widgetStore.setConnecting(false)
+ widgetStore.setConnected(false)
if (this.socket) {
this.socket.close()
}
diff --git a/frontend/cypress/e2e/adminFlows.cy.js b/frontend/cypress/e2e/adminFlows.cy.js
new file mode 100644
index 00000000..d5a684c9
--- /dev/null
+++ b/frontend/cypress/e2e/adminFlows.cy.js
@@ -0,0 +1,152 @@
+// End-to-end journey against a real backend (no API mocking):
+// login -> create email inbox -> create team -> create agent ->
+// create an outgoing conversation -> reply (sent through MailHog).
+//
+// The steps run in order and share state, so the conversation reuses the
+// inbox created earlier in the same spec.
+
+describe('Admin setup and outgoing conversation', () => {
+ const stamp = Date.now()
+ const inboxName = `Cypress Inbox ${stamp}`
+ const fromAddress = `Cypress Support
`
+ const teamName = `Cypress Team ${stamp}`
+ const agentFirstName = 'Cypress'
+ const agentEmail = `cypress.agent.${stamp}@example.com`
+ const contactEmail = `cypress.customer.${stamp}@example.com`
+ const subject = `Cypress subject ${stamp}`
+ const replyBody = `Automated reply from Cypress ${stamp}`
+
+ // SMTP points at the MailHog sink running as a CI service; IMAP is dummy.
+ const smtpHost = Cypress.env('SMTP_HOST') || '127.0.0.1'
+ const smtpPort = Cypress.env('SMTP_PORT') || '1025'
+ const mailhogUrl = Cypress.env('MAILHOG_URL')
+
+ let conversationUuid
+
+ beforeEach(() => {
+ cy.viewport(1280, 800) // desktop layout so the inbox sidebar (New conversation) is visible
+ cy.login()
+ })
+
+ it('creates an email inbox that sends through MailHog', () => {
+ cy.intercept('POST', '**/api/v1/inboxes').as('createInbox')
+
+ cy.visit('/admin/inboxes/new')
+ cy.contains('Create an email inbox').click()
+ cy.contains('Configure IMAP and SMTP manually').click()
+
+ cy.get('input[name="name"]').type(inboxName)
+ cy.get('input[name="from"]').type(fromAddress)
+
+ cy.get('input[name="imap.username"]').type('cypress')
+ cy.get('input[name="imap.password"]').type('cypress')
+
+ // smtp.host/port also exist in the hidden OAuth section, so target the visible ones.
+ cy.get('input[name="smtp.host"]:visible').clear().type(smtpHost)
+ cy.get('input[name="smtp.port"]:visible').clear().type(smtpPort)
+ cy.get('input[name="smtp.username"]').type('cypress')
+ cy.get('input[name="smtp.password"]').type('cypress')
+ cy.selectOption('Login', 'None') // SMTP auth protocol -> None (MailHog needs none)
+
+ cy.contains('button', 'Create').click()
+ cy.wait('@createInbox').its('response.statusCode').should('eq', 200)
+ cy.location('pathname').should('eq', '/admin/inboxes')
+ })
+
+ it('creates a team', () => {
+ cy.intercept('POST', '**/api/v1/teams').as('createTeam')
+
+ cy.visit('/admin/teams/teams/new')
+
+ // Emoji is required. Pick it first so the picker overlay is closed (by the
+ // next click) before filling the rest of the form.
+ cy.get('input[name="emoji"]').click()
+ cy.get('.v3-emoji-picker .v3-emojis button').first().click()
+
+ cy.get('input[name="name"]').type(teamName)
+ cy.selectOption('Select an assignment type', 'Round robin')
+ cy.contains('button[role="combobox"]', 'Select timezone').click()
+ cy.get('[role="option"]').first().click()
+
+ cy.contains('button', 'Create').click()
+ cy.wait('@createTeam').its('response.statusCode').should('eq', 200)
+ cy.location('pathname').should('eq', '/admin/teams/teams')
+ })
+
+ it('creates an agent', () => {
+ cy.intercept('POST', '**/api/v1/agents').as('createAgent')
+
+ cy.visit('/admin/teams/agents/new')
+
+ cy.get('input[name="first_name"]').type(agentFirstName)
+ cy.get('input[name="email"]').type(agentEmail)
+
+ cy.get('input[placeholder="Select roles"]').click()
+ cy.get('[role="option"]').contains('Agent').click()
+
+ cy.contains('button', 'Create').click()
+ cy.wait('@createAgent').its('response.statusCode').should('eq', 200)
+ cy.location('pathname').should('eq', '/admin/teams/agents')
+ })
+
+ it('creates an outgoing conversation from the new inbox', () => {
+ cy.intercept('POST', '**/api/v1/conversations').as('createConversation')
+
+ cy.visit('/inboxes/assigned')
+ cy.contains('New conversation').click()
+
+ cy.get('[role="dialog"]').within(() => {
+ cy.get('input[type="email"]').type(contactEmail)
+ cy.get('input[name="first_name"]').type('Cypress')
+ cy.get('input[name="last_name"]').type('Customer')
+ cy.get('input[name="subject"]').type(subject)
+ cy.contains('button[role="combobox"]', 'Select inbox').click()
+ })
+ // Radix Select options render in a portal outside the dialog.
+ cy.get('[role="option"]').contains(inboxName).click()
+
+ cy.get('[role="dialog"]')
+ .find('.tiptap.ProseMirror')
+ .click()
+ .type('Hello, this is an automated outgoing conversation.')
+ cy.get('[role="dialog"]').contains('button', 'Submit').click()
+
+ cy.wait('@createConversation').then(({ response }) => {
+ expect(response.statusCode).to.eq(200)
+ conversationUuid = response.body.data.uuid
+ expect(conversationUuid, 'conversation uuid').to.be.a('string').and.not.be.empty
+ })
+ })
+
+ it('replies to the conversation and the email lands in MailHog', () => {
+ expect(conversationUuid, 'conversation from previous step').to.be.a('string')
+
+ cy.intercept('POST', `**/api/v1/conversations/${conversationUuid}/messages`).as('sendReply')
+
+ cy.visit(`/inboxes/all/conversation/${conversationUuid}`)
+ cy.get('.tiptap.ProseMirror').first().click().type(replyBody)
+ cy.contains('button', /^Send$/).click() // exact: avoid the adjacent "Send and set status" split button
+
+ cy.wait('@sendReply').its('response.statusCode').should('eq', 200)
+ cy.contains(replyBody).should('exist')
+
+ // Outgoing mail is dispatched asynchronously; poll MailHog until it shows up.
+ // Only runs in CI where MAILHOG_URL is set.
+ if (mailhogUrl) {
+ const findInMailHog = (attempt = 0) => {
+ cy.request(`${mailhogUrl}/api/v2/messages`).then((res) => {
+ const delivered = (res.body.items || []).some((m) =>
+ JSON.stringify(m).includes(contactEmail)
+ )
+ if (delivered) return
+ if (attempt >= 15) {
+ throw new Error(`Email to ${contactEmail} not found in MailHog`)
+ }
+ cy.wait(1000)
+ findInMailHog(attempt + 1)
+ })
+ }
+ findInMailHog()
+ }
+ })
+})
diff --git a/frontend/cypress/support/commands.js b/frontend/cypress/support/commands.js
index 119ab03f..bc949e8f 100644
--- a/frontend/cypress/support/commands.js
+++ b/frontend/cypress/support/commands.js
@@ -23,3 +23,32 @@
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
+
+// Real login as the System user. Caches the session (and the csrf_token cookie
+// the backend sets on login) across specs so subsequent writes don't 403.
+Cypress.Commands.add('login', () => {
+ const email = 'System'
+ const password = Cypress.env('SYSTEM_PASSWORD') || 'StrongPass!123'
+ cy.session(
+ 'system-agent',
+ () => {
+ cy.visit('/')
+ cy.get('#email').clear().type(email)
+ cy.get('#password').clear().type(password, { log: false })
+ cy.contains('button', 'Sign in').click()
+ cy.url().should('include', '/inboxes')
+ },
+ {
+ cacheAcrossSpecs: true,
+ validate() {
+ cy.request('/api/v1/agents/me').its('status').should('eq', 200)
+ }
+ }
+ )
+})
+
+// Pick an option from a shadcn/radix Select by the label currently on its trigger.
+Cypress.Commands.add('selectOption', (triggerLabel, optionText) => {
+ cy.contains('button[role="combobox"]', triggerLabel).click()
+ cy.get('[role="option"]').contains(optionText).click()
+})
diff --git a/frontend/package.json b/frontend/package.json
index 887e64c7..2385689e 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -45,7 +45,7 @@
"@unovis/vue": "^1.6.5",
"@vee-validate/zod": "^4.15.0",
"@vueuse/core": "^12.4.0",
- "axios": "^1.15.2",
+ "axios": "^1.16.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"codemirror": "^6.0.2",
@@ -87,7 +87,7 @@
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"vite": "^6.4.2",
- "vitest": "^3.2.2"
+ "vitest": "^4.1.0"
},
"packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a"
}
\ No newline at end of file
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 7714e8ad..9ea38e4b 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -84,8 +84,8 @@ importers:
specifier: ^12.4.0
version: 12.4.0(typescript@5.7.3)
axios:
- specifier: ^1.15.2
- version: 1.15.2(debug@4.4.0)
+ specifier: ^1.16.0
+ version: 1.16.0(debug@4.4.0)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -161,7 +161,7 @@ importers:
version: 1.10.5
'@vitejs/plugin-vue':
specifier: ^5.2.1
- version: 5.2.1(vite@6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.7.1))(vue@3.5.13(typescript@5.7.3))
+ version: 5.2.1(vite@6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.9.0))(vue@3.5.13(typescript@5.7.3))
'@vue/eslint-config-prettier':
specifier: ^8.0.0
version: 8.0.0(eslint@8.57.1)(prettier@3.4.2)
@@ -203,10 +203,10 @@ importers:
version: 1.0.7(tailwindcss@3.4.17)
vite:
specifier: ^6.4.2
- version: 6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.7.1)
+ version: 6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.9.0)
vitest:
- specifier: ^3.2.2
- version: 3.2.2(@types/node@25.8.0)(jiti@1.21.7)(jsdom@29.0.1)(sass@1.99.0)(stylus@0.57.0)(yaml@2.7.1)
+ specifier: ^4.1.0
+ version: 4.1.0(@types/node@25.8.0)(jsdom@29.0.1)(vite@6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.9.0))
packages:
@@ -669,9 +669,6 @@ packages:
resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
engines: {node: '>=6.0.0'}
- '@jridgewell/sourcemap-codec@1.5.0':
- resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
-
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
@@ -982,6 +979,9 @@ packages:
'@sideway/pinpoint@2.0.0':
resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==}
+ '@standard-schema/spec@1.1.0':
+ resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
+
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
@@ -1192,8 +1192,8 @@ packages:
'@tiptap/pm': ^2.7.0
vue: ^3.0.0
- '@types/chai@5.2.2':
- resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==}
+ '@types/chai@5.2.3':
+ resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
@@ -1309,6 +1309,9 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+ '@types/estree@1.0.9':
+ resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
+
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
@@ -1412,34 +1415,34 @@ packages:
vite: ^5.0.0 || ^6.0.0
vue: ^3.2.25
- '@vitest/expect@3.2.2':
- resolution: {integrity: sha512-ipHw0z669vEMjzz3xQE8nJX1s0rQIb7oEl4jjl35qWTwm/KIHERIg/p/zORrjAaZKXfsv7IybcNGHwhOOAPMwQ==}
+ '@vitest/expect@4.1.0':
+ resolution: {integrity: sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==}
- '@vitest/mocker@3.2.2':
- resolution: {integrity: sha512-jKojcaRyIYpDEf+s7/dD3LJt53c0dPfp5zCPXz9H/kcGrSlovU/t1yEaNzM9oFME3dcd4ULwRI/x0Po1Zf+LTw==}
+ '@vitest/mocker@4.1.0':
+ resolution: {integrity: sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==}
peerDependencies:
msw: ^2.4.9
- vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
- '@vitest/pretty-format@3.2.2':
- resolution: {integrity: sha512-FY4o4U1UDhO9KMd2Wee5vumwcaHw7Vg4V7yR4Oq6uK34nhEJOmdRYrk3ClburPRUA09lXD/oXWZ8y/Sdma0aUQ==}
+ '@vitest/pretty-format@4.1.0':
+ resolution: {integrity: sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==}
- '@vitest/runner@3.2.2':
- resolution: {integrity: sha512-GYcHcaS3ejGRZYed2GAkvsjBeXIEerDKdX3orQrBJqLRiea4NSS9qvn9Nxmuy1IwIB+EjFOaxXnX79l8HFaBwg==}
+ '@vitest/runner@4.1.0':
+ resolution: {integrity: sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==}
- '@vitest/snapshot@3.2.2':
- resolution: {integrity: sha512-aMEI2XFlR1aNECbBs5C5IZopfi5Lb8QJZGGpzS8ZUHML5La5wCbrbhLOVSME68qwpT05ROEEOAZPRXFpxZV2wA==}
+ '@vitest/snapshot@4.1.0':
+ resolution: {integrity: sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==}
- '@vitest/spy@3.2.2':
- resolution: {integrity: sha512-6Utxlx3o7pcTxvp0u8kUiXtRFScMrUg28KjB3R2hon7w4YqOFAEA9QwzPVVS1QNL3smo4xRNOpNZClRVfpMcYg==}
+ '@vitest/spy@4.1.0':
+ resolution: {integrity: sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==}
- '@vitest/utils@3.2.2':
- resolution: {integrity: sha512-qJYMllrWpF/OYfWHP32T31QCaLa3BAzT/n/8mNGhPdVcjY+JYazQFO1nsJvXU12Kp1xMpNY4AGuljPTNjQve6A==}
+ '@vitest/utils@4.1.0':
+ resolution: {integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==}
'@vue/compiler-core@3.5.13':
resolution: {integrity: sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==}
@@ -1623,8 +1626,8 @@ packages:
aws4@1.13.2:
resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==}
- axios@1.15.2:
- resolution: {integrity: sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==}
+ axios@1.16.0:
+ resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==}
babel-plugin-macros@3.1.0:
resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==}
@@ -1679,10 +1682,6 @@ packages:
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
- cac@6.7.14:
- resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
- engines: {node: '>=8'}
-
cachedir@2.4.0:
resolution: {integrity: sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==}
engines: {node: '>=6'}
@@ -1709,18 +1708,14 @@ packages:
caseless@0.12.0:
resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
- chai@5.2.0:
- resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==}
- engines: {node: '>=12'}
+ chai@6.2.2:
+ resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
+ engines: {node: '>=18'}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
- check-error@2.1.1:
- resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==}
- engines: {node: '>= 16'}
-
check-more-types@2.24.0:
resolution: {integrity: sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==}
engines: {node: '>= 0.8.0'}
@@ -1802,6 +1797,9 @@ packages:
convert-source-map@1.9.0:
resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==}
+ convert-source-map@2.0.0:
+ resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+
copy-anything@3.0.5:
resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==}
engines: {node: '>=12.13'}
@@ -2030,15 +2028,6 @@ packages:
supports-color:
optional: true
- debug@4.4.1:
- resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
- engines: {node: '>=6.0'}
- peerDependencies:
- supports-color: '*'
- peerDependenciesMeta:
- supports-color:
- optional: true
-
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -2055,10 +2044,6 @@ packages:
resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
engines: {node: '>=0.10'}
- deep-eql@5.0.2:
- resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
- engines: {node: '>=6'}
-
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@@ -2143,11 +2128,11 @@ packages:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
- es-module-lexer@1.7.0:
- resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
+ es-module-lexer@2.1.0:
+ resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
- es-object-atoms@1.1.1:
- resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
+ es-object-atoms@1.1.2:
+ resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
engines: {node: '>= 0.4'}
es-set-tostringtag@2.1.0:
@@ -2259,8 +2244,8 @@ packages:
resolution: {integrity: sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==}
engines: {node: '>=4'}
- expect-type@1.2.1:
- resolution: {integrity: sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==}
+ expect-type@1.3.0:
+ resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
extend@3.0.2:
@@ -2457,8 +2442,8 @@ packages:
resolution: {integrity: sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==}
engines: {node: '>=8'}
- hasown@2.0.3:
- resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==}
+ hasown@2.0.4:
+ resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
hookable@5.5.3:
@@ -2724,9 +2709,6 @@ packages:
resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==}
engines: {node: '>=18'}
- loupe@3.1.3:
- resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==}
-
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
@@ -2740,8 +2722,8 @@ packages:
peerDependencies:
vue: '>=3.0.1'
- magic-string@0.30.17:
- resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
+ magic-string@0.30.21:
+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
map-stream@0.1.0:
resolution: {integrity: sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==}
@@ -2867,6 +2849,9 @@ packages:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
+ obug@2.1.1:
+ resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
+
ohash@2.0.11:
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
@@ -2939,10 +2924,6 @@ packages:
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
- pathval@2.0.0:
- resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==}
- engines: {node: '>= 14.16'}
-
pause-stream@0.0.11:
resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==}
@@ -2966,10 +2947,6 @@ packages:
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
engines: {node: '>=8.6'}
- picomatch@4.0.2:
- resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==}
- engines: {node: '>=12'}
-
picomatch@4.0.4:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
@@ -3347,8 +3324,8 @@ packages:
engines: {node: '>=16'}
hasBin: true
- std-env@3.9.0:
- resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==}
+ std-env@4.1.0:
+ resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
stream-combiner@0.0.4:
resolution: {integrity: sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==}
@@ -3478,30 +3455,23 @@ packages:
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
- tinyexec@0.3.2:
- resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
-
- tinyglobby@0.2.14:
- resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==}
- engines: {node: '>=12.0.0'}
+ tinyexec@1.2.4:
+ resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==}
+ engines: {node: '>=18'}
tinyglobby@0.2.16:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'}
- tinypool@1.1.0:
- resolution: {integrity: sha512-7CotroY9a8DKsKprEy/a14aCCm8jYVmR7aFy4fpkZM8sdpNJbKkixuNjgM50yCmip2ezc8z4N7k3oe2+rfRJCQ==}
- engines: {node: ^18.0.0 || >=20.0.0}
+ tinyglobby@0.2.17:
+ resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
+ engines: {node: '>=12.0.0'}
tinyqueue@2.0.3:
resolution: {integrity: sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==}
- tinyrainbow@2.0.0:
- resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
- engines: {node: '>=14.0.0'}
-
- tinyspy@4.0.3:
- resolution: {integrity: sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==}
+ tinyrainbow@3.1.0:
+ resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
tippy.js@6.3.7:
@@ -3629,11 +3599,6 @@ packages:
resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==}
engines: {'0': node >=0.6.0}
- vite-node@3.2.2:
- resolution: {integrity: sha512-Xj/jovjZvDXOq2FgLXu8NsY4uHUMWtzVmMC2LkCu9HWdr9Qu1Is5sanX3Z4jOFKdohfaWDnEJWp9pRP0vVpAcA==}
- engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
- hasBin: true
-
vite@6.4.2:
resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@@ -3674,26 +3639,33 @@ packages:
yaml:
optional: true
- vitest@3.2.2:
- resolution: {integrity: sha512-fyNn/Rp016Bt5qvY0OQvIUCwW2vnaEBLxP42PmKbNIoasSYjML+8xyeADOPvBe+Xfl/ubIw4og7Lt9jflRsCNw==}
- engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+ vitest@4.1.0:
+ resolution: {integrity: sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==}
+ engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
- '@types/debug': ^4.1.12
- '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
- '@vitest/browser': 3.2.2
- '@vitest/ui': 3.2.2
+ '@opentelemetry/api': ^1.9.0
+ '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
+ '@vitest/browser-playwright': 4.1.0
+ '@vitest/browser-preview': 4.1.0
+ '@vitest/browser-webdriverio': 4.1.0
+ '@vitest/ui': 4.1.0
happy-dom: '*'
jsdom: '*'
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
- '@types/debug':
+ '@opentelemetry/api':
optional: true
'@types/node':
optional: true
- '@vitest/browser':
+ '@vitest/browser-playwright':
+ optional: true
+ '@vitest/browser-preview':
+ optional: true
+ '@vitest/browser-webdriverio':
optional: true
'@vitest/ui':
optional: true
@@ -3840,9 +3812,9 @@ packages:
resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==}
engines: {node: '>= 6'}
- yaml@2.7.1:
- resolution: {integrity: sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==}
- engines: {node: '>= 14'}
+ yaml@2.9.0:
+ resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
+ engines: {node: '>= 14.6'}
hasBin: true
yauzl@2.10.0:
@@ -4346,21 +4318,19 @@ snapshots:
'@jridgewell/gen-mapping@0.3.8':
dependencies:
'@jridgewell/set-array': 1.2.1
- '@jridgewell/sourcemap-codec': 1.5.0
+ '@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping': 0.3.25
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/set-array@1.2.1': {}
- '@jridgewell/sourcemap-codec@1.5.0': {}
-
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.25':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
- '@jridgewell/sourcemap-codec': 1.5.0
+ '@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping@0.3.31':
dependencies:
@@ -4591,6 +4561,8 @@ snapshots:
'@sideway/pinpoint@2.0.0': {}
+ '@standard-schema/spec@1.1.0': {}
+
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
@@ -4811,9 +4783,10 @@ snapshots:
'@tiptap/pm': 2.11.2
vue: 3.5.13(typescript@5.7.3)
- '@types/chai@5.2.2':
+ '@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
+ assertion-error: 2.0.1
'@types/d3-array@3.2.2': {}
@@ -4950,6 +4923,8 @@ snapshots:
'@types/estree@1.0.8': {}
+ '@types/estree@1.0.9': {}
+
'@types/geojson@7946.0.16': {}
'@types/leaflet@1.7.6':
@@ -5129,51 +5104,51 @@ snapshots:
transitivePeerDependencies:
- vue
- '@vitejs/plugin-vue@5.2.1(vite@6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.7.1))(vue@3.5.13(typescript@5.7.3))':
+ '@vitejs/plugin-vue@5.2.1(vite@6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.9.0))(vue@3.5.13(typescript@5.7.3))':
dependencies:
- vite: 6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.7.1)
+ vite: 6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.9.0)
vue: 3.5.13(typescript@5.7.3)
- '@vitest/expect@3.2.2':
+ '@vitest/expect@4.1.0':
dependencies:
- '@types/chai': 5.2.2
- '@vitest/spy': 3.2.2
- '@vitest/utils': 3.2.2
- chai: 5.2.0
- tinyrainbow: 2.0.0
+ '@standard-schema/spec': 1.1.0
+ '@types/chai': 5.2.3
+ '@vitest/spy': 4.1.0
+ '@vitest/utils': 4.1.0
+ chai: 6.2.2
+ tinyrainbow: 3.1.0
- '@vitest/mocker@3.2.2(vite@6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.7.1))':
+ '@vitest/mocker@4.1.0(vite@6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.9.0))':
dependencies:
- '@vitest/spy': 3.2.2
+ '@vitest/spy': 4.1.0
estree-walker: 3.0.3
- magic-string: 0.30.17
+ magic-string: 0.30.21
optionalDependencies:
- vite: 6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.7.1)
+ vite: 6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.9.0)
- '@vitest/pretty-format@3.2.2':
+ '@vitest/pretty-format@4.1.0':
dependencies:
- tinyrainbow: 2.0.0
+ tinyrainbow: 3.1.0
- '@vitest/runner@3.2.2':
+ '@vitest/runner@4.1.0':
dependencies:
- '@vitest/utils': 3.2.2
+ '@vitest/utils': 4.1.0
pathe: 2.0.3
- '@vitest/snapshot@3.2.2':
+ '@vitest/snapshot@4.1.0':
dependencies:
- '@vitest/pretty-format': 3.2.2
- magic-string: 0.30.17
+ '@vitest/pretty-format': 4.1.0
+ '@vitest/utils': 4.1.0
+ magic-string: 0.30.21
pathe: 2.0.3
- '@vitest/spy@3.2.2':
- dependencies:
- tinyspy: 4.0.3
+ '@vitest/spy@4.1.0': {}
- '@vitest/utils@3.2.2':
+ '@vitest/utils@4.1.0':
dependencies:
- '@vitest/pretty-format': 3.2.2
- loupe: 3.1.3
- tinyrainbow: 2.0.0
+ '@vitest/pretty-format': 4.1.0
+ convert-source-map: 2.0.0
+ tinyrainbow: 3.1.0
'@vue/compiler-core@3.5.13':
dependencies:
@@ -5196,7 +5171,7 @@ snapshots:
'@vue/compiler-ssr': 3.5.13
'@vue/shared': 3.5.13
estree-walker: 2.0.2
- magic-string: 0.30.17
+ magic-string: 0.30.21
postcss: 8.5.10
source-map-js: 1.2.1
@@ -5392,7 +5367,7 @@ snapshots:
aws4@1.13.2: {}
- axios@1.15.2(debug@4.4.0):
+ axios@1.16.0(debug@4.4.0):
dependencies:
follow-redirects: 1.16.0(debug@4.4.0)
form-data: 4.0.5
@@ -5455,8 +5430,6 @@ snapshots:
base64-js: 1.5.1
ieee754: 1.2.1
- cac@6.7.14: {}
-
cachedir@2.4.0: {}
call-bind-apply-helpers@1.0.2:
@@ -5477,21 +5450,13 @@ snapshots:
caseless@0.12.0: {}
- chai@5.2.0:
- dependencies:
- assertion-error: 2.0.1
- check-error: 2.1.1
- deep-eql: 5.0.2
- loupe: 3.1.3
- pathval: 2.0.0
+ chai@6.2.2: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
supports-color: 7.2.0
- check-error@2.1.1: {}
-
check-more-types@2.24.0: {}
chokidar@3.6.0:
@@ -5572,6 +5537,8 @@ snapshots:
convert-source-map@1.9.0: {}
+ convert-source-map@2.0.0: {}
+
copy-anything@3.0.5:
dependencies:
is-what: 4.1.16
@@ -5861,10 +5828,6 @@ snapshots:
dependencies:
ms: 2.1.3
- debug@4.4.1:
- dependencies:
- ms: 2.1.3
-
debug@4.4.3(supports-color@8.1.1):
dependencies:
ms: 2.1.3
@@ -5876,8 +5839,6 @@ snapshots:
decode-uri-component@0.2.2:
optional: true
- deep-eql@5.0.2: {}
-
deep-is@0.1.4: {}
defu@6.1.7: {}
@@ -5944,9 +5905,9 @@ snapshots:
es-errors@1.3.0: {}
- es-module-lexer@1.7.0: {}
+ es-module-lexer@2.1.0: {}
- es-object-atoms@1.1.1:
+ es-object-atoms@1.1.2:
dependencies:
es-errors: 1.3.0
@@ -5955,7 +5916,7 @@ snapshots:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
- hasown: 2.0.3
+ hasown: 2.0.4
esbuild@0.25.12:
optionalDependencies:
@@ -6092,7 +6053,7 @@ snapshots:
estree-walker@3.0.3:
dependencies:
- '@types/estree': 1.0.8
+ '@types/estree': 1.0.9
esutils@2.0.3: {}
@@ -6138,7 +6099,7 @@ snapshots:
dependencies:
pify: 2.3.0
- expect-type@1.2.1: {}
+ expect-type@1.3.0: {}
extend@3.0.2: {}
@@ -6221,7 +6182,7 @@ snapshots:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
- hasown: 2.0.3
+ hasown: 2.0.4
mime-types: 2.1.35
fraction.js@4.3.7: {}
@@ -6253,18 +6214,18 @@ snapshots:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
es-errors: 1.3.0
- es-object-atoms: 1.1.1
+ es-object-atoms: 1.1.2
function-bind: 1.1.2
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
- hasown: 2.0.3
+ hasown: 2.0.4
math-intrinsics: 1.1.0
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
- es-object-atoms: 1.1.1
+ es-object-atoms: 1.1.2
get-stream@5.2.0:
dependencies:
@@ -6337,7 +6298,7 @@ snapshots:
is-stream: 2.0.1
type-fest: 0.8.1
- hasown@2.0.3:
+ hasown@2.0.4:
dependencies:
function-bind: 1.1.2
@@ -6406,11 +6367,11 @@ snapshots:
is-core-module@2.16.1:
dependencies:
- hasown: 2.0.3
+ hasown: 2.0.4
is-core-module@2.16.2:
dependencies:
- hasown: 2.0.3
+ hasown: 2.0.4
is-extglob@2.1.1: {}
@@ -6589,8 +6550,6 @@ snapshots:
strip-ansi: 7.2.0
wrap-ansi: 9.0.2
- loupe@3.1.3: {}
-
lru-cache@10.4.3: {}
lru-cache@11.2.7: {}
@@ -6599,9 +6558,9 @@ snapshots:
dependencies:
vue: 3.5.13(typescript@5.7.3)
- magic-string@0.30.17:
+ magic-string@0.30.21:
dependencies:
- '@jridgewell/sourcemap-codec': 1.5.0
+ '@jridgewell/sourcemap-codec': 1.5.5
map-stream@0.1.0: {}
@@ -6721,6 +6680,8 @@ snapshots:
object-inspect@1.13.4: {}
+ obug@2.1.1: {}
+
ohash@2.0.11: {}
once@1.4.0:
@@ -6790,8 +6751,6 @@ snapshots:
pathe@2.0.3: {}
- pathval@2.0.0: {}
-
pause-stream@0.0.11:
dependencies:
through: 2.3.8
@@ -6811,8 +6770,6 @@ snapshots:
picomatch@2.3.2: {}
- picomatch@4.0.2: {}
-
picomatch@4.0.4: {}
pify@2.3.0: {}
@@ -6844,7 +6801,7 @@ snapshots:
postcss-load-config@4.0.2(postcss@8.5.10):
dependencies:
lilconfig: 3.1.3
- yaml: 2.7.1
+ yaml: 2.9.0
optionalDependencies:
postcss: 8.5.10
@@ -7267,7 +7224,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- std-env@3.9.0: {}
+ std-env@4.1.0: {}
stream-combiner@0.0.4:
dependencies:
@@ -7420,25 +7377,21 @@ snapshots:
tinybench@2.9.0: {}
- tinyexec@0.3.2: {}
-
- tinyglobby@0.2.14:
- dependencies:
- fdir: 6.5.0(picomatch@4.0.4)
- picomatch: 4.0.4
+ tinyexec@1.2.4: {}
tinyglobby@0.2.16:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
- tinypool@1.1.0: {}
+ tinyglobby@0.2.17:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
tinyqueue@2.0.3: {}
- tinyrainbow@2.0.0: {}
-
- tinyspy@4.0.3: {}
+ tinyrainbow@3.1.0: {}
tippy.js@6.3.7:
dependencies:
@@ -7542,28 +7495,7 @@ snapshots:
core-util-is: 1.0.2
extsprintf: 1.3.0
- vite-node@3.2.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.7.1):
- dependencies:
- cac: 6.7.14
- debug: 4.4.1
- es-module-lexer: 1.7.0
- pathe: 2.0.3
- vite: 6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.7.1)
- transitivePeerDependencies:
- - '@types/node'
- - jiti
- - less
- - lightningcss
- - sass
- - sass-embedded
- - stylus
- - sugarss
- - supports-color
- - terser
- - tsx
- - yaml
-
- vite@6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.7.1):
+ vite@6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.9.0):
dependencies:
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.4)
@@ -7577,49 +7509,35 @@ snapshots:
jiti: 1.21.7
sass: 1.99.0
stylus: 0.57.0
- yaml: 2.7.1
+ yaml: 2.9.0
- vitest@3.2.2(@types/node@25.8.0)(jiti@1.21.7)(jsdom@29.0.1)(sass@1.99.0)(stylus@0.57.0)(yaml@2.7.1):
+ vitest@4.1.0(@types/node@25.8.0)(jsdom@29.0.1)(vite@6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.9.0)):
dependencies:
- '@types/chai': 5.2.2
- '@vitest/expect': 3.2.2
- '@vitest/mocker': 3.2.2(vite@6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.7.1))
- '@vitest/pretty-format': 3.2.2
- '@vitest/runner': 3.2.2
- '@vitest/snapshot': 3.2.2
- '@vitest/spy': 3.2.2
- '@vitest/utils': 3.2.2
- chai: 5.2.0
- debug: 4.4.1
- expect-type: 1.2.1
- magic-string: 0.30.17
+ '@vitest/expect': 4.1.0
+ '@vitest/mocker': 4.1.0(vite@6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.9.0))
+ '@vitest/pretty-format': 4.1.0
+ '@vitest/runner': 4.1.0
+ '@vitest/snapshot': 4.1.0
+ '@vitest/spy': 4.1.0
+ '@vitest/utils': 4.1.0
+ es-module-lexer: 2.1.0
+ expect-type: 1.3.0
+ magic-string: 0.30.21
+ obug: 2.1.1
pathe: 2.0.3
- picomatch: 4.0.2
- std-env: 3.9.0
+ picomatch: 4.0.4
+ std-env: 4.1.0
tinybench: 2.9.0
- tinyexec: 0.3.2
- tinyglobby: 0.2.14
- tinypool: 1.1.0
- tinyrainbow: 2.0.0
- vite: 6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.7.1)
- vite-node: 3.2.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.7.1)
+ tinyexec: 1.2.4
+ tinyglobby: 0.2.17
+ tinyrainbow: 3.1.0
+ vite: 6.4.2(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.9.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 25.8.0
jsdom: 29.0.1
transitivePeerDependencies:
- - jiti
- - less
- - lightningcss
- msw
- - sass
- - sass-embedded
- - stylus
- - sugarss
- - supports-color
- - terser
- - tsx
- - yaml
vt-pbf@3.1.3:
dependencies:
@@ -7703,7 +7621,7 @@ snapshots:
wait-on@8.0.1(debug@4.4.0):
dependencies:
- axios: 1.15.2(debug@4.4.0)
+ axios: 1.16.0(debug@4.4.0)
joi: 17.13.3
lodash: 4.17.21
minimist: 1.2.8
@@ -7766,7 +7684,7 @@ snapshots:
yaml@1.10.3: {}
- yaml@2.7.1: {}
+ yaml@2.9.0: {}
yauzl@2.10.0:
dependencies:
diff --git a/frontend/shared-ui/assets/styles/main.scss b/frontend/shared-ui/assets/styles/main.scss
index e59622c9..34dbe749 100644
--- a/frontend/shared-ui/assets/styles/main.scss
+++ b/frontend/shared-ui/assets/styles/main.scss
@@ -6,10 +6,23 @@
font-size: 16px;
}
+::view-transition-old(root),
+::view-transition-new(root) {
+ animation-duration: 0.15s;
+ animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ ::view-transition-old(root),
+ ::view-transition-new(root) {
+ animation: none;
+ }
+}
+
@layer base {
html,
body {
- font-family: 'Plus Jakarta Sans', sans-serif;
+ font-family: 'Instrument Sans', sans-serif;
min-height: 100%;
overflow: hidden;
margin: 0;
@@ -310,7 +323,9 @@
[id$="_OLK_SRC_BODY_SECTION"],
[id$="_OLK_SRC_BODY_SECTION"] ~ *,
[class*="_OutlookMessageHeader"],
- [class*="_OutlookMessageHeader"] ~ * {
+ [class*="_OutlookMessageHeader"] ~ *,
+ [class*="_yahoo_quoted"],
+ [class*="_gmail_quote_container"] {
@apply hidden;
}
}
diff --git a/frontend/shared-ui/components/DaySeparator/DaySeparator.vue b/frontend/shared-ui/components/DaySeparator/DaySeparator.vue
new file mode 100644
index 00000000..c56edbe9
--- /dev/null
+++ b/frontend/shared-ui/components/DaySeparator/DaySeparator.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
diff --git a/frontend/shared-ui/components/DaySeparator/index.js b/frontend/shared-ui/components/DaySeparator/index.js
new file mode 100644
index 00000000..70bf7860
--- /dev/null
+++ b/frontend/shared-ui/components/DaySeparator/index.js
@@ -0,0 +1 @@
+export { default } from './DaySeparator.vue'
diff --git a/frontend/shared-ui/composables/index.js b/frontend/shared-ui/composables/index.js
index 5c9b4961..2d8ea83a 100644
--- a/frontend/shared-ui/composables/index.js
+++ b/frontend/shared-ui/composables/index.js
@@ -1 +1,2 @@
export { useTypingIndicator } from './useTypingIndicator.js'
+export { useStickyScroll } from './useStickyScroll.js'
diff --git a/frontend/shared-ui/composables/useNotificationSound.js b/frontend/shared-ui/composables/useNotificationSound.js
index 6738f621..01370d62 100644
--- a/frontend/shared-ui/composables/useNotificationSound.js
+++ b/frontend/shared-ui/composables/useNotificationSound.js
@@ -1,6 +1,8 @@
import notificationSound from '../assets/notification.mp3'
+const PLAY_THROTTLE_MS = 1500
let audio = null
+let lastPlayedAt = 0
export function initAudioContext() {
if (audio) return
@@ -11,6 +13,9 @@ export function initAudioContext() {
export function playNotificationSound() {
if (!audio) return
+ const now = Date.now()
+ if (now - lastPlayedAt < PLAY_THROTTLE_MS) return
+ lastPlayedAt = now
audio.currentTime = 0
audio.play().catch(() => {})
}
diff --git a/frontend/shared-ui/composables/useStickyScroll.js b/frontend/shared-ui/composables/useStickyScroll.js
new file mode 100644
index 00000000..def53733
--- /dev/null
+++ b/frontend/shared-ui/composables/useStickyScroll.js
@@ -0,0 +1,60 @@
+import { ref, onMounted, onUnmounted } from 'vue'
+
+// Sticks the scroll container to the bottom while the user hasn't scrolled away.
+// Programmatic scroll events are distinguished from user scrolls via a one-shot flag.
+// Apply `overflow-anchor: none` to the scroll container to prevent browser scroll anchoring
+// from firing spurious scroll events during content growth.
+export function useStickyScroll (scrollEl, contentEl, options = {}) {
+ const {
+ tolerance = 100,
+ skipAutoScroll = () => false,
+ onArriveBottom = () => { }
+ } = options
+
+ const hasUserScrolled = ref(false)
+ let isProgrammaticScroll = false
+ let resizeObserver = null
+
+ const scrollToBottom = () => {
+ const el = scrollEl.value
+ if (!el) return
+ isProgrammaticScroll = true
+ el.scrollTop = el.scrollHeight
+ requestAnimationFrame(() => { isProgrammaticScroll = false })
+ }
+
+ const scrollToOffset = (top) => {
+ const el = scrollEl.value
+ if (!el) return
+ isProgrammaticScroll = true
+ el.scrollTop = top
+ requestAnimationFrame(() => { isProgrammaticScroll = false })
+ }
+
+ const handleScroll = () => {
+ if (isProgrammaticScroll) return
+ const el = scrollEl.value
+ if (!el) return
+ const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= tolerance
+ hasUserScrolled.value = !atBottom
+ if (atBottom) onArriveBottom()
+ }
+
+ const onContentResize = () => {
+ if (skipAutoScroll() || hasUserScrolled.value) return
+ scrollToBottom()
+ }
+
+ onMounted(() => {
+ if (contentEl.value && typeof ResizeObserver !== 'undefined') {
+ resizeObserver = new ResizeObserver(onContentResize)
+ resizeObserver.observe(contentEl.value)
+ }
+ })
+
+ onUnmounted(() => {
+ if (resizeObserver) resizeObserver.disconnect()
+ })
+
+ return { hasUserScrolled, scrollToBottom, scrollToOffset, handleScroll }
+}
diff --git a/frontend/shared-ui/utils/csat.js b/frontend/shared-ui/utils/csat.js
new file mode 100644
index 00000000..97fe04f4
--- /dev/null
+++ b/frontend/shared-ui/utils/csat.js
@@ -0,0 +1,16 @@
+const RATING_EMOJI = { 1: '😢', 2: '😕', 3: '😊', 4: '😃', 5: '🤩' }
+const RATING_TEXT_KEY = {
+ 1: 'globals.terms.poor',
+ 2: 'globals.terms.fair',
+ 3: 'globals.terms.good',
+ 4: 'globals.terms.great',
+ 5: 'globals.terms.excellent'
+}
+
+export function csatRatingEmoji (rating) {
+ return RATING_EMOJI[rating] || ''
+}
+
+export function csatRatingTextKey (rating) {
+ return RATING_TEXT_KEY[rating] || ''
+}
diff --git a/frontend/shared-ui/utils/file.js b/frontend/shared-ui/utils/file.js
index f9610a5d..009f00f9 100644
--- a/frontend/shared-ui/utils/file.js
+++ b/frontend/shared-ui/utils/file.js
@@ -6,14 +6,20 @@ export function formatBytes (bytes) {
}
}
-export function getThumbFilepath (filepath) {
- const urlParts = filepath.split('/')
- const filename = urlParts.pop()
- return `/uploads/thumb_${filename}`
+const UUID_V4_RE = /[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/i
+
+export function getThumbFilepath (url) {
+ if (!url) return url
+ const match = url.match(UUID_V4_RE)
+ if (!match) return url
+ const qIdx = url.indexOf('?')
+ const query = qIdx >= 0 ? url.substring(qIdx) : ''
+ return `/uploads/thumb_${match[0]}${query}`
}
export function downloadUrl (url) {
if (!url) return url
- const separator = url.includes('?') ? '&' : '?'
- return `${url}${separator}download=1`
+ const match = url.match(UUID_V4_RE)
+ if (!match) return url
+ return `/uploads/${match[0]}?download=1`
}
diff --git a/frontend/shared-ui/utils/file.test.js b/frontend/shared-ui/utils/file.test.js
new file mode 100644
index 00000000..bedfd0f5
--- /dev/null
+++ b/frontend/shared-ui/utils/file.test.js
@@ -0,0 +1,97 @@
+import { describe, test, expect } from 'vitest'
+import { downloadUrl, getThumbFilepath } from './file'
+
+describe('downloadUrl', () => {
+ test('returns falsy input unchanged', () => {
+ expect(downloadUrl('')).toBe('')
+ expect(downloadUrl(null)).toBe(null)
+ expect(downloadUrl(undefined)).toBe(undefined)
+ })
+
+ test('rewrites relative fs upload path', () => {
+ expect(downloadUrl('/uploads/9a4f0a03-7b36-4e05-aacd-4eb04947a79d'))
+ .toBe('/uploads/9a4f0a03-7b36-4e05-aacd-4eb04947a79d?download=1')
+ })
+
+ test('strips signature params from signed fs url', () => {
+ const signed = 'https://example.com/uploads/9a4f0a03-7b36-4e05-aacd-4eb04947a79d?sig=abc&exp=1700000000'
+ expect(downloadUrl(signed))
+ .toBe('/uploads/9a4f0a03-7b36-4e05-aacd-4eb04947a79d?download=1')
+ })
+
+ test('extracts uuid from public s3 url', () => {
+ const s3 = 'https://s3.ap-south-1.amazonaws.com/example-bucket/9a4f0a03-7b36-4e05-aacd-4eb04947a79d'
+ expect(downloadUrl(s3))
+ .toBe('/uploads/9a4f0a03-7b36-4e05-aacd-4eb04947a79d?download=1')
+ })
+
+ test('extracts uuid from presigned s3 url with query params', () => {
+ const presigned = 'https://s3.ap-south-1.amazonaws.com/example-bucket/9a4f0a03-7b36-4e05-aacd-4eb04947a79d?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=300&response-content-disposition=inline%3B%20filename%3D%22photo.jpg%22&X-Amz-Signature=deadbeef'
+ expect(downloadUrl(presigned))
+ .toBe('/uploads/9a4f0a03-7b36-4e05-aacd-4eb04947a79d?download=1')
+ })
+
+ test('extracts uuid from virtual-hosted s3 url', () => {
+ const s3 = 'https://example-bucket.s3.ap-south-1.amazonaws.com/9a4f0a03-7b36-4e05-aacd-4eb04947a79d?X-Amz-Signature=deadbeef'
+ expect(downloadUrl(s3))
+ .toBe('/uploads/9a4f0a03-7b36-4e05-aacd-4eb04947a79d?download=1')
+ })
+
+ test('does not double-append download param when already present', () => {
+ const already = '/uploads/9a4f0a03-7b36-4e05-aacd-4eb04947a79d?download=1'
+ expect(downloadUrl(already))
+ .toBe('/uploads/9a4f0a03-7b36-4e05-aacd-4eb04947a79d?download=1')
+ })
+
+ test('handles unencoded slash inside query param', () => {
+ const url = 'https://s3.example.com/bucket/9a4f0a03-7b36-4e05-aacd-4eb04947a79d?response-content-disposition=inline;filename="a/b.jpg"'
+ expect(downloadUrl(url))
+ .toBe('/uploads/9a4f0a03-7b36-4e05-aacd-4eb04947a79d?download=1')
+ })
+
+ test('returns original url when no uuid found', () => {
+ expect(downloadUrl('https://example.com/some/path')).toBe('https://example.com/some/path')
+ })
+
+ test('returns first uuid when multiple are present', () => {
+ const url = 'https://s3.example.com/11111111-1111-4111-8111-111111111111/9a4f0a03-7b36-4e05-aacd-4eb04947a79d'
+ expect(downloadUrl(url))
+ .toBe('/uploads/11111111-1111-4111-8111-111111111111?download=1')
+ })
+
+ test('matches uppercase uuid', () => {
+ const url = 'https://s3.example.com/bucket/9A4F0A03-7B36-4E05-AACD-4EB04947A79D'
+ expect(downloadUrl(url))
+ .toBe('/uploads/9A4F0A03-7B36-4E05-AACD-4EB04947A79D?download=1')
+ })
+})
+
+describe('getThumbFilepath', () => {
+ test('returns falsy input unchanged', () => {
+ expect(getThumbFilepath('')).toBe('')
+ expect(getThumbFilepath(null)).toBe(null)
+ expect(getThumbFilepath(undefined)).toBe(undefined)
+ })
+
+ test('builds thumb path from relative fs upload path', () => {
+ expect(getThumbFilepath('/uploads/9a4f0a03-7b36-4e05-aacd-4eb04947a79d'))
+ .toBe('/uploads/thumb_9a4f0a03-7b36-4e05-aacd-4eb04947a79d')
+ })
+
+ test('preserves signature params from signed fs url', () => {
+ const signed = 'https://example.com/uploads/9a4f0a03-7b36-4e05-aacd-4eb04947a79d?sig=abc&exp=1700000000'
+ expect(getThumbFilepath(signed))
+ .toBe('/uploads/thumb_9a4f0a03-7b36-4e05-aacd-4eb04947a79d?sig=abc&exp=1700000000')
+ })
+
+ test('preserves query string from presigned s3 url', () => {
+ const presigned = 'https://s3.ap-south-1.amazonaws.com/example-bucket/9a4f0a03-7b36-4e05-aacd-4eb04947a79d?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Signature=deadbeef'
+ expect(getThumbFilepath(presigned))
+ .toBe('/uploads/thumb_9a4f0a03-7b36-4e05-aacd-4eb04947a79d?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Signature=deadbeef')
+ })
+
+ test('returns original url when no uuid found', () => {
+ expect(getThumbFilepath('https://example.com/some/path'))
+ .toBe('https://example.com/some/path')
+ })
+})
diff --git a/frontend/shared-ui/utils/http.js b/frontend/shared-ui/utils/http.js
index d4adbb59..e4e97c72 100644
--- a/frontend/shared-ui/utils/http.js
+++ b/frontend/shared-ui/utils/http.js
@@ -11,6 +11,11 @@ function handleHTTPError (error) {
data: null,
status_code: null
}
+ // Request was cancelled by an AbortController Return
+ // empty message so toast emitters that read .message show nothing.
+ if (error?.code === 'ERR_CANCELED' || error?.name === 'CanceledError') {
+ return { ...resp, message: '', canceled: true }
+ }
// Response received from the server.
if (error.response && error.response.data) {
if (error.response.data.error_type) {
diff --git a/frontend/shared-ui/utils/quotedContent.js b/frontend/shared-ui/utils/quotedContent.js
index 9ca51c93..df2f3291 100644
--- a/frontend/shared-ui/utils/quotedContent.js
+++ b/frontend/shared-ui/utils/quotedContent.js
@@ -3,14 +3,18 @@
// render time, so any CSS that needs to match the rendered DOM must use
// attribute-suffix selectors (see .hide-quoted-text in main.scss).
//
-// Scope: covers Gmail/Apple/Thunderbird. The id/class markers
-// below cover the Microsoft variants (Outlook desktop/web/mac, Hotmail).
+// Scope: covers Gmail/Apple/Thunderbird *replies*. Gmail
+// *forwards* wrap content in
+// instead. The id/class markers below cover the Microsoft variants
+// (Outlook desktop/web/mac, Hotmail) and Yahoo Mail.
export const QUOTE_MARKERS = [
'
{
diff --git a/frontend/shared-ui/utils/string.js b/frontend/shared-ui/utils/string.js
index 760de42d..786cfa55 100644
--- a/frontend/shared-ui/utils/string.js
+++ b/frontend/shared-ui/utils/string.js
@@ -66,3 +66,12 @@ export function renderTemplate(text, data) {
data[Object.keys(data).find(k => k.toLowerCase() === key.toLowerCase())] || fallback?.trim() || ''
)
}
+
+export function isValidTemplate (text, allowedVars = []) {
+ if (!text) return true
+ // Strip valid variable tokens; any leftover brace means bad syntax or an unknown variable.
+ const leftover = text.replace(/\{\{\s*(.*?)\s*\}\}/g, (m, v) =>
+ allowedVars.includes(v.trim()) ? '' : m
+ )
+ return !leftover.includes('{') && !leftover.includes('}')
+}
diff --git a/go.mod b/go.mod
index b069d637..1a21b46a 100644
--- a/go.mod
+++ b/go.mod
@@ -5,7 +5,6 @@ go 1.25.0
require (
github.com/abhinavxd/ssrfguard v0.1.0
github.com/alicebob/miniredis/v2 v2.32.1
- github.com/casbin/casbin/v2 v2.99.0
github.com/coreos/go-oidc/v3 v3.11.0
github.com/disintegration/imaging v1.6.2
github.com/emersion/go-imap/v2 v2.0.0-beta.3
@@ -15,9 +14,9 @@ require (
github.com/gabriel-vasile/mimetype v1.4.11
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0
+ github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056
github.com/jhillyerd/enmime v1.2.0
github.com/jmoiron/sqlx v1.4.0
- github.com/k3a/html2text v1.2.1
github.com/knadh/go-i18n v0.1.0
github.com/knadh/goyesql/v2 v2.2.0
github.com/knadh/koanf/parsers/json v0.1.0
@@ -52,8 +51,6 @@ require (
cloud.google.com/go/compute/metadata v0.3.0 // indirect
github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
- github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect
- github.com/casbin/govaluate v1.2.0 // indirect
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
@@ -64,7 +61,6 @@ require (
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
- github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
diff --git a/go.sum b/go.sum
index a1f1b853..502953f0 100644
--- a/go.sum
+++ b/go.sum
@@ -13,16 +13,10 @@ github.com/andybalholm/brotli v1.0.2/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
-github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I=
-github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
-github.com/casbin/casbin/v2 v2.99.0 h1:Y993vfRenh8Xtb4XVaK8KeYJTjD4Zn1XVewGszhzk1E=
-github.com/casbin/casbin/v2 v2.99.0/go.mod h1:LO7YPez4dX3LgoTCqSQAleQDo0S0BeZBDxYnPUl95Ng=
-github.com/casbin/govaluate v1.2.0 h1:wXCXFmqyY+1RwiKfYo3jMKyrtZmOL3kHwaqDyCPOYak=
-github.com/casbin/govaluate v1.2.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -69,8 +63,6 @@ github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7w
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
-github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
-github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
@@ -78,8 +70,6 @@ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
-github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA=
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
@@ -88,10 +78,6 @@ github.com/jhillyerd/enmime v1.2.0/go.mod h1:FRFuUPCLh8PByQv+8xRcLO9QHqaqTqreYho
github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
-github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
-github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
-github.com/k3a/html2text v1.2.1 h1:nvnKgBvBR/myqrwfLuiqecUtaK1lB9hGziIJKatNFVY=
-github.com/k3a/html2text v1.2.1/go.mod h1:ieEXykM67iT8lTvEWBh6fhpH4B23kB9OMKPdIBmgUqA=
github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
@@ -161,10 +147,6 @@ github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
github.com/savsgio/gotils v0.0.0-20211223103454-d0aaa54c5899/go.mod h1:oejLrk1Y/5zOF+c/aHtXqn3TFlzzbAgPWg8zBiAHDas=
github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 h1:KanIMPX0QdEdB4R3CiimCAbxFrhB3j7h0/OvpYGVQa8=
github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg=
-github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
-github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
-github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
-github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
@@ -212,7 +194,6 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
-golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -256,8 +237,6 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
diff --git a/i18n/da-DK.json b/i18n/da-DK.json
index 7128dd99..4b8b3ac7 100644
--- a/i18n/da-DK.json
+++ b/i18n/da-DK.json
@@ -19,7 +19,6 @@
"actions.removeLink": "Fjern link",
"actions.removeTags": "Fjern tags",
"actions.sendCsat": "Send CSAT-undersøgelse",
- "actions.sendPrivateNote": "Send privat note",
"actions.sendReply": "Send svar",
"actions.setPriority": "Angiv prioritet",
"actions.setSla": "Angiv SLA",
@@ -39,14 +38,6 @@
"activityLog.rolePermissionsAdded": "{actorEmail} ({actorId}) tilføjede tilladelse(r) {permissions} til rollen {roleName} ({roleId})",
"activityLog.rolePermissionsChanged": "{actorEmail} ({actorId}) fjernede tilladelse(r) {removed} og tilføjede tilladelse(r) {added} til rollen {roleName} ({roleId})",
"activityLog.rolePermissionsRemoved": "{actorEmail} ({actorId}) fjernede tilladelse(r) {permissions} fra rollen {roleName} ({roleId})",
- "activityLog.type": "Aktivitetslogtype",
- "activityLog.type.agentAway": "Agent væk",
- "activityLog.type.agentAwayReassigned": "Agent væk og gentildelt",
- "activityLog.type.agentLogin": "Agent-login",
- "activityLog.type.agentLogout": "Agent-logout",
- "activityLog.type.agentOnline": "Agent er online",
- "activityLog.type.agentPasswordSet": "Agent adgangskode indstillet",
- "activityLog.type.agentRolePermissionsChanged": "Agent rolletilladelser ændret",
"admin.agent.apiKey.description": "Generér API-nøgler til denne agent for at tilgå libredesk programmatisk.",
"admin.agent.apiKey.noKey": "Ingen API-nøgle genereret til denne agent.",
"admin.agent.apiKey.warningMessage": "Denne hemmelighed vises kun én gang. Sørg for at kopiere den nu.",
@@ -55,7 +46,6 @@
"admin.automation.all": "ALLE",
"admin.automation.and": "OG",
"admin.automation.any": "ENHVER",
- "admin.automation.below": "under",
"admin.automation.conversationUpdate": "Samtaleopdatering",
"admin.automation.conversationUpdate.description": "Regler, som eksekveres, når en samtale opdateres.",
"admin.automation.evaluateRuleOnTheseEvents": "Evaluér regel ved disse begivenheder.",
@@ -67,7 +57,6 @@
"admin.automation.executeFirstMatchingRule": "Eksekvér første matchende regel",
"admin.automation.help": "Automatiser handlinger, når samtaler oprettes, opdateres eller efter en timeplan.",
"admin.automation.invalid": "Sørg for at have mindst én handling og én regel, samt at deres værdifelter er ikke tomme.",
- "admin.automation.match": "Matchning",
"admin.automation.matchTheseRules": "Match disse regler",
"admin.automation.newConversation.description": "Regler, der køres, når en kontakt opretter en ny samtale. Samtaler oprettet af agenter udløser ikke disse regler. Træk og slip for at ændre rækkefølgen.",
"admin.automation.noRulesFound": "Ingen regler fundet",
@@ -188,7 +177,6 @@
"admin.inbox.livechat.installation.instructions.step2": "Indsæt det lige før det afsluttende `body`-tag på hver side, hvor livechat-widgetten skal vises",
"admin.inbox.livechat.installation.instructions.title": "Installationsinstruktioner",
"admin.inbox.livechat.installation.jsApi.description": "Styr widgetten programmatisk fra din applikation.",
- "admin.inbox.livechat.installation.jsApi.title": "JavaScript API",
"admin.inbox.livechat.introductionMessage": "Introduktionsbesked",
"admin.inbox.livechat.language.auto": "Automatisk registrering (browsersprog)",
"admin.inbox.livechat.launcher": "Startknap",
@@ -393,7 +381,6 @@
"auth.enterEmailForReset": "Angiv e-mailadresse for at modtage et adgangskodenulstillingslink.",
"auth.enterNewPasswordTwice": "Angiv den nye adgangskode to gange for at bekræfte.",
"auth.forgotPassword": "Glemt adgangskode?",
- "auth.hidePassword": "Hide password",
"auth.invalidOrExpiredSession": "Ugyldig/udløbet session",
"auth.invalidOrExpiredSessionClearCookie": "Ugyldig/udløbet session. Ryd cookies, og forsøg igen.",
"auth.invalidResetLink": "Ugyldigt nulstillingslink. Anmod venligst om et nyt link til nulstilling af adgangskode.",
@@ -407,7 +394,6 @@
"auth.sendResetLink": "Send nulstillingslink",
"auth.setNewPassword": "Indstil ny adgangskode",
"auth.settingPassword": "Indstiller adgangskode...",
- "auth.showPassword": "Show password",
"auth.signIn": "Log ind på din konto",
"auth.signInButton": "Log ind",
"automation.deletionConfirmation": "Denne handling kan ikke fortrydes. Dette vil permanent slette denne automatiseringsregel.",
@@ -527,7 +513,6 @@
"globals.messages.all": "Alle {name}",
"globals.messages.apply": "Anvend {name}",
"globals.messages.areYouAbsolutelySure": "Fuldstændig sikker?",
- "globals.messages.attachFile": "Attach file",
"globals.messages.back": "Tilbage",
"globals.messages.backOnDayAt": "den {day} kl. {time}",
"globals.messages.backTodayAt": "i dag kl. {time}",
@@ -561,10 +546,8 @@
"globals.messages.errorUploadingFile": "Fejl ved upload af fil",
"globals.messages.false": "false",
"globals.messages.fileTypeisNotAnImage": "Filtype er ikke et billede",
- "globals.messages.goBack": "Go back",
"globals.messages.golangDurationHoursMinutes": "Varighed i timer eller minutter. Eks.: 1t, 30m, 1t30m",
"globals.messages.gradientEnd": "Gradient slut",
- "globals.messages.gradientStart": "Gradient start",
"globals.messages.headerTextColor": "Overskriftstekstfarve",
"globals.messages.hoursSinceCreated": "Timer siden oprettelse",
"globals.messages.hoursSinceFirstReply": "Timer siden første svar",
@@ -586,7 +569,6 @@
"globals.messages.noResultsFound": "Ingen resultater fundet",
"globals.messages.notAllowed": "{name} ikke tilladt",
"globals.messages.notFound": "{name} ikke fundet",
- "globals.messages.options": "Options",
"globals.messages.pageNofTotal": "Side {page} af {total}",
"globals.messages.pageNotFound": "Siden ikke fundet",
"globals.messages.pageTooLarge": "Sidestørrelsen er for stor, må maks. udgøre {max}",
@@ -637,7 +619,6 @@
"globals.terms.activityLog": "Aktivitetslog | Aktivitetslogge",
"globals.terms.actor": "Aktør | Aktører",
"globals.terms.admin": "Administrator | Administratorer",
- "globals.terms.agent": "Agent | Agenter",
"globals.terms.alert": "Advarsel | Advarsler",
"globals.terms.announcement": "Meddelelse | Meddelelser",
"globals.terms.apiKey": "API-nøgle | API nøgler",
@@ -650,7 +631,6 @@
"globals.terms.away": "Væk | Væk",
"globals.terms.awayReassigning": "Væk og gentildeling",
"globals.terms.background": "Baggrund",
- "globals.terms.beta": "Beta",
"globals.terms.black": "Sort",
"globals.terms.body": "Brødtekst",
"globals.terms.brandName": "Brandnavn",
@@ -687,16 +667,13 @@
"globals.terms.expand": "Udvid",
"globals.terms.fair": "Hæderlig",
"globals.terms.features": "Funktioner",
- "globals.terms.feedback": "Feedback | Feedback",
"globals.terms.file": "Fil | Filer",
- "globals.terms.filter": "Filter | Filtre",
"globals.terms.firstName": "Fornavn | Fornavne",
"globals.terms.firstReplyAt": "Første svar kl.",
"globals.terms.fromEmailAddress": "Fra e-mailadresse | Fra e-mailadresser",
"globals.terms.general": "Generelt",
"globals.terms.good": "God",
"globals.terms.google": "Google-konto",
- "globals.terms.gradient": "Gradient",
"globals.terms.great": "Glimrende",
"globals.terms.home": "Hjem",
"globals.terms.homeScreen": "Startskærm",
@@ -704,11 +681,9 @@
"globals.terms.host": "Vært | Værter",
"globals.terms.hour": "Time | Timer",
"globals.terms.image": "Billede | Billeder",
- "globals.terms.import": "Import | Importer",
"globals.terms.inactive": "Inaktiv | Inaktive",
"globals.terms.inbox": "Indbakke | Indbakker",
"globals.terms.initiatedAt": "Påbegyndt kl.",
- "globals.terms.integration": "Integration | Integrationer",
"globals.terms.ipAddress": "IP-adresse | IP-adresser",
"globals.terms.isDefault": "Er standard",
"globals.terms.key": "Nøgle | Nøgler",
@@ -725,7 +700,6 @@
"globals.terms.liveChat": "Livechat",
"globals.terms.loadMore": "Indlæs mere",
"globals.terms.loading": "Indlæser...",
- "globals.terms.log": "Log | Logge",
"globals.terms.logoUrl": "Logo-URL",
"globals.terms.macro": "Makro | Makroer",
"globals.terms.mention": "Omtale | Omtaler",
@@ -734,7 +708,6 @@
"globals.terms.myInbox": "Min Indbakke | Mine Indbakker",
"globals.terms.name": "Navn | Navne",
"globals.terms.none": "Ingen",
- "globals.terms.note": "Note | Noter",
"globals.terms.notification": "Notifikation | Notifikationer",
"globals.terms.off": "FRA",
"globals.terms.offline": "Ikke tilgængelig | Ikke tilgængelig",
@@ -752,7 +725,6 @@
"globals.terms.pickDate": "Vælg en dato",
"globals.terms.placeholder": "Pladsholder | Pladsholdere",
"globals.terms.poor": "Ringe",
- "globals.terms.port": "Port | Porte",
"globals.terms.primaryColor": "Primær farve | Primære farver",
"globals.terms.priority": "Prioritet | Prioriteter",
"globals.terms.privateNote": "Privat note | Private noter",
@@ -762,7 +734,6 @@
"globals.terms.providerURL": "Udbyder-URL",
"globals.terms.reconnect": "Genopret forbindelse",
"globals.terms.referenceNumber": "Referencenummer",
- "globals.terms.regex": "Regex | Regex'er",
"globals.terms.regexHint": "Regex-tip",
"globals.terms.reply": "Svar | Svar",
"globals.terms.report": "Rapport | Rapporter",
@@ -779,7 +750,6 @@
"globals.terms.sendWelcomeEmail": "Send velkomstmail? | Send velkomstmails?",
"globals.terms.setPassword": "Indstil adgangskode | Indstil adgangskoder",
"globals.terms.sharedView": "Delt visning | Delte visninger",
- "globals.terms.sla": "SLA | SLA'er",
"globals.terms.slaMetric": "SLA-metrik | SLA-metrikker",
"globals.terms.slaPolicy": "SLA-politik | SLA-politikker",
"globals.terms.smtpHost": "SMTP-vært | SMTP-værter",
@@ -787,8 +757,6 @@
"globals.terms.snooze": "Slumre",
"globals.terms.solid": "Ensfarvet",
"globals.terms.someone": "Nogen",
- "globals.terms.sso": "SSO | SSO'er",
- "globals.terms.status": "Status | Statusser",
"globals.terms.subject": "Emne | Emner",
"globals.terms.tag": "Mærke | Mærker",
"globals.terms.team": "Hold | Hold",
@@ -801,25 +769,20 @@
"globals.terms.timestamp": "Tidsstempel | Tidsstempler",
"globals.terms.timezone": "Tidszone | Tidszoner",
"globals.terms.title": "Titel | Titler",
- "globals.terms.tls": "TLS | TLS'er",
"globals.terms.today": "I dag",
"globals.terms.tryAgain": "Forsøg igen",
- "globals.terms.type": "Type | Typer",
"globals.terms.typeMessage": "Skriv din besked...",
"globals.terms.unAuthorized": "Uautoriseret",
"globals.terms.unassigned": "Ikke-tildelt",
"globals.terms.updatedAt": "Opdateret kl.",
- "globals.terms.url": "URL | URL'er",
"globals.terms.usage": "Brug",
"globals.terms.username": "Brugernavn | Brugernavne",
"globals.terms.users": "Brugere",
"globals.terms.value": "Værdi | Værdier",
- "globals.terms.video": "Video | Videoer",
"globals.terms.view": "Visning | Visninger",
"globals.terms.visibility": "Synlighed | Synligheder",
"globals.terms.visitor": "Besøgende | Besøgende",
"globals.terms.warning": "Advarsel | Advarsler",
- "globals.terms.webhook": "WebHook | Webhooks",
"globals.terms.week": "Uge | Uger",
"globals.terms.white": "Hvid",
"globals.terms.workspace": "Arbejdsområde",
@@ -1045,6 +1008,5 @@
"webhook.sendTest": "Send testbesked",
"webhook.sentSuccessfully": "Webhook sendt",
"widget.conversationClosed": "Denne samtale er blevet lukket",
- "widget.ipBlocked": "Adgang nægtet",
- "widget.prechatForm.startChat": "Start chat"
-}
+ "widget.ipBlocked": "Adgang nægtet"
+}
diff --git a/i18n/de-DE.json b/i18n/de-DE.json
index c52c8a51..598628f2 100644
--- a/i18n/de-DE.json
+++ b/i18n/de-DE.json
@@ -19,7 +19,6 @@
"actions.removeLink": "Link entfernen",
"actions.removeTags": "Tags entfernen",
"actions.sendCsat": "CSAT senden",
- "actions.sendPrivateNote": "Private Notiz senden",
"actions.sendReply": "Antwort senden",
"actions.setPriority": "Priorität setzen",
"actions.setSla": "SLA setzen",
@@ -39,14 +38,6 @@
"activityLog.rolePermissionsAdded": "{actorEmail} ({actorId}) hat Berechtigung(en) {permissions} zur Rolle {roleName} ({roleId}) hinzugefügt",
"activityLog.rolePermissionsChanged": "{actorEmail} ({actorId}) hat Berechtigung(en) {removed} entfernt und Berechtigung(en) {added} zur Rolle {roleName} ({roleId}) hinzugefügt",
"activityLog.rolePermissionsRemoved": "{actorEmail} ({actorId}) hat Berechtigung(en) {permissions} von der Rolle {roleName} ({roleId}) entfernt",
- "activityLog.type": "Aktivitätsprotokoll-Typ",
- "activityLog.type.agentAway": "Mitarbeiter abwesend",
- "activityLog.type.agentAwayReassigned": "Mitarbeiter abwesend, neu zugewiesen",
- "activityLog.type.agentLogin": "Mitarbeiter angemeldet",
- "activityLog.type.agentLogout": "Mitarbeiter abgemeldet",
- "activityLog.type.agentOnline": "Mitarbeiter online",
- "activityLog.type.agentPasswordSet": "Mitarbeiterpasswort gesetzt",
- "activityLog.type.agentRolePermissionsChanged": "Mitarbeiterrollenberechtigungen geändert",
"admin.agent.apiKey.noKey": "Es wurde kein API-Schlüssel für diesen Mitarbeiter erstellt.",
"admin.agent.apiKey.warningMessage": "Dieses Geheimnis wird nur einmal angezeigt. Bitte kopieren Sie es jetzt.",
"admin.agent.deleteConfirmation": "Dies wird den Mitarbeiter dauerhaft löschen. Erwägen Sie stattdessen das Deaktivieren des Accounts.",
@@ -54,7 +45,6 @@
"admin.automation.all": "ALLE",
"admin.automation.and": "UND",
"admin.automation.any": "EINE",
- "admin.automation.below": "unten",
"admin.automation.conversationUpdate": "Updates einer Konversation",
"admin.automation.conversationUpdate.description": "Regeln, die ausgeführt werden, wenn eine Konversation aktualisiert wird.",
"admin.automation.evaluateRuleOnTheseEvents": "Regeln bei diesen Aktionen auswerten.",
@@ -66,7 +56,6 @@
"admin.automation.executeFirstMatchingRule": "Erste übereinstimmende Regel ausführen",
"admin.automation.help": "Automatisieren Sie Aktionen, wenn Konversationen erstellt, aktualisiert oder nach einem stundlichen Zeitplan ausgeführt werden.",
"admin.automation.invalid": "Stelle sicher, dass du mindestens eine Aktion und eine Regel hast und ihre Werte nicht leer sind.",
- "admin.automation.match": "Wenn",
"admin.automation.matchTheseRules": "Wende diese Regeln an",
"admin.automation.newConversation.description": "Regeln, die ausgeführt werden, wenn ein Kontakt eine neue Konversation erstellt. Von Mitarbeitern gestartete Konversationen lösen diese Regeln nicht aus. Per Drag-and-Drop neu anordnen.",
"admin.automation.noRulesFound": "Keine Regeln gefunden",
@@ -166,7 +155,6 @@
"admin.inbox.livechat.installation.instructions.step1": "Kopieren Sie das untenstehende Code-Snippet",
"admin.inbox.livechat.installation.instructions.title": "Installationsanweisungen",
"admin.inbox.livechat.installation.jsApi.description": "Steuern Sie das Widget programmatisch aus Ihrer Anwendung.",
- "admin.inbox.livechat.installation.jsApi.title": "JavaScript API",
"admin.inbox.livechat.introductionMessage": "Einleitungsnachricht",
"admin.inbox.livechat.launcher": "Chat-Starter",
"admin.inbox.livechat.launcher.color": "Launcher-Farbe",
@@ -501,7 +489,6 @@
"globals.messages.errorUploadingFile": "Fehler beim Hochladen der Datei",
"globals.messages.false": "Falsch",
"globals.messages.fileTypeisNotAnImage": "Dateityp ist kein Bild",
- "globals.messages.goBack": "Go back",
"globals.messages.gradientEnd": "Farbverlauf Ende",
"globals.messages.gradientStart": "Farbverlauf Start",
"globals.messages.headerTextColor": "Kopfzeilen-Textfarbe",
@@ -585,7 +572,6 @@
"globals.terms.away": "Abwesend | Abwesend",
"globals.terms.awayReassigning": "Abwesend und neu zuweisen",
"globals.terms.background": "Hintergrund",
- "globals.terms.beta": "Beta",
"globals.terms.black": "Schwarz",
"globals.terms.body": "Inhalt",
"globals.terms.brandName": "Markenname",
@@ -622,9 +608,7 @@
"globals.terms.expand": "Erweitern",
"globals.terms.fair": "Befriedigend",
"globals.terms.features": "Funktionen",
- "globals.terms.feedback": "Feedback | Feedback",
"globals.terms.file": "Datei | Dateien",
- "globals.terms.filter": "Filter | Filter",
"globals.terms.firstName": "Vorname | Vornamen",
"globals.terms.firstReplyAt": "Erste Antwort am",
"globals.terms.general": "Allgemein",
@@ -642,7 +626,6 @@
"globals.terms.inactive": "Inaktiv | Inaktiv",
"globals.terms.inbox": "Posteingang | Posteingänge",
"globals.terms.initiatedAt": "Erstellt am",
- "globals.terms.integration": "Integration | Integrationen",
"globals.terms.ipAddress": "IP-Adresse | IP-Adressen",
"globals.terms.isDefault": "Standardwert",
"globals.terms.key": "Schlüssel | Schlüssel",
@@ -666,7 +649,6 @@
"globals.terms.message": "Nachricht | Nachrichten",
"globals.terms.microsoft": "Microsoft-Konto",
"globals.terms.myInbox": "Mein Posteingang | Meine Posteingänge",
- "globals.terms.name": "Name | Namen",
"globals.terms.none": "Keine",
"globals.terms.note": "Notiz | Notizen",
"globals.terms.notification": "Benachrichtigung | Benachrichtigungen",
@@ -675,7 +657,6 @@
"globals.terms.online": "Erreichbar | Erreichbar",
"globals.terms.open": "Offen",
"globals.terms.openMenu": "Menü öffnen",
- "globals.terms.optional": "Optional | Optional",
"globals.terms.overdue": "Überfällig",
"globals.terms.overview": "Übersicht",
"globals.terms.page": "Seite | Seiten",
@@ -686,7 +667,6 @@
"globals.terms.pickDate": "Datum auswählen",
"globals.terms.placeholder": "Platzhalter | Platzhalter",
"globals.terms.poor": "Schlecht",
- "globals.terms.port": "Port | Anschlüsse",
"globals.terms.primaryColor": "Primärfarbe | Primärfarben",
"globals.terms.priority": "Priorität | Prioritäten",
"globals.terms.privateNote": "Private Notiz | Private Notizen",
@@ -712,16 +692,12 @@
"globals.terms.sendWelcomeEmail": "Willkommens-E-Mail senden? | Willkommens-E-Mails senden?",
"globals.terms.setPassword": "Passwort setzen | Passwörter setzen",
"globals.terms.sharedView": "Geteilte Ansicht | Geteilte Ansichten",
- "globals.terms.sla": "SLA | SLA",
"globals.terms.smtpHost": "SMTP-Host | SMTP-Hosts",
"globals.terms.smtpPort": "SMTP-Port | SMTP-Ports",
"globals.terms.snooze": "Später erinnern",
"globals.terms.solid": "Einfarbig",
"globals.terms.someone": "Jemand",
- "globals.terms.status": "Status | Status",
"globals.terms.subject": "Thema | Themen",
- "globals.terms.tag": "Tag | Tags",
- "globals.terms.team": "Team | Teams",
"globals.terms.teamInbox": "Team-Posteingang | Team-Posteingänge",
"globals.terms.teammate": "Teammitglied | Teammitglieder",
"globals.terms.tellUsMore": "Erzählen Sie uns mehr...",
@@ -731,7 +707,6 @@
"globals.terms.timestamp": "Zeitstempel | Zeitstempeln",
"globals.terms.timezone": "Zeitzonen | Zeitzonen",
"globals.terms.title": "Titel | Titel",
- "globals.terms.tls": "TLS | TLS",
"globals.terms.today": "Heute",
"globals.terms.tryAgain": "Erneut versuchen",
"globals.terms.type": "Typ | Typen",
@@ -739,17 +714,14 @@
"globals.terms.unAuthorized": "Nicht autorisiert",
"globals.terms.unassigned": "Nicht zugewiesen",
"globals.terms.updatedAt": "Aktualisiert am",
- "globals.terms.url": "URL | URL",
"globals.terms.usage": "Nutzung",
"globals.terms.username": "Benutzername | Benutzernamen",
"globals.terms.users": "Benutzer",
"globals.terms.value": "Wert | Werte",
- "globals.terms.video": "Video | Video",
"globals.terms.view": "Ansicht | Ansichten",
"globals.terms.visibility": "Sichtbarkeit | Sichtbarkeit",
"globals.terms.visitor": "Besucher | Besucher",
"globals.terms.warning": "Warnung | Warnungen",
- "globals.terms.webhook": "Webhook | Webhook",
"globals.terms.week": "Woche | Wochen",
"globals.terms.white": "Weiß",
"globals.terms.workspace": "Arbeitsbereich",
@@ -962,4 +934,4 @@
"widget.conversationClosed": "Diese Konversation wurde geschlossen",
"widget.ipBlocked": "Zugriff verweigert",
"widget.prechatForm.startChat": "Chat starten"
-}
+}
diff --git a/i18n/en-US.json b/i18n/en-US.json
index 47dca95f..74f380b8 100644
--- a/i18n/en-US.json
+++ b/i18n/en-US.json
@@ -270,6 +270,11 @@
"admin.inbox.replyToAddress": "Reply-To address (optional)",
"admin.inbox.replyToAddress.description": "Where customer replies go. Only needed when SMTP From and IMAP mailbox are on different domains.",
"admin.inbox.replyToAddress.placeholder": "support{'@'}example.com",
+ "admin.inbox.fromNameTemplate": "From display name template (optional)",
+ "admin.inbox.fromNameTemplate.description": "Display name for outgoing email replies. Leave blank to use the name from the From address.",
+ "admin.inbox.fromNameTemplate.variables": "Variables: {'{{ .Agent.FirstName }}'}, {'{{ .Agent.LastName }}'}, {'{{ .Agent.FullName }}'}, {'{{ .Inbox.Name }}'}.",
+ "admin.inbox.fromNameTemplate.placeholder": "{'{{ .Agent.FirstName }} at {{ .Inbox.Name }}'}",
+ "admin.inbox.fromNameTemplate.invalidTemplate": "Invalid template. Use only the supported variables and balanced braces.",
"admin.inbox.skipTLSVerification": "Skip TLS Verification",
"admin.inbox.skipTLSVerification.description": "Skip hostname check on the TLS certificate.",
"admin.inbox.smtpConfig": "SMTP Configuration",
@@ -532,14 +537,18 @@
"contextLink.urlTemplateHelp": "{'{{token}}'} is a base64-encoded AES-256-GCM encrypted blob containing all contact and agent fields (requires secret). Individual variables like {'{{email}}'}, {'{{phone}}'}, {'{{external_user_id}}'}, {'{{contact_id}}'}, {'{{first_name}}'}, {'{{last_name}}'}, {'{{conversation_uuid}}'} are passed as plain text.",
"conversation.agentAssigned": "Agent assigned",
"conversation.allLoaded": "All conversations loaded",
+ "conversation.assignSelfAction": "Assign to me",
+ "conversation.assignSelfPrompt": "This conversation isn't assigned to anyone.",
"conversation.bulkActions.clearSelection": "Clear selection",
"conversation.bulkActions.failedToast": "Some conversations couldn't be updated",
"conversation.bulkActions.selectAll": "Select all conversations",
"conversation.bulkActions.selectConversation": "Select conversation",
- "conversation.bulkActions.selected": "No conversations selected | {count} selected",
+ "conversation.bulkActions.selected": "No conversations selected | 1 selected | {count} selected",
"conversation.bulkActions.successToast": "Conversations updated",
"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",
@@ -611,6 +620,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",
@@ -634,7 +648,9 @@
"globals.messages.clear": "Clear",
"globals.messages.close": "Close",
"globals.messages.closeChat": "Close chat",
+ "globals.messages.connected": "Connected",
"globals.messages.connecting": "Connecting...",
+ "globals.messages.connectionFailedRefresh": "Connection failed. Please refresh.",
"globals.messages.continueConversation": "Continue your conversation",
"globals.messages.copied": "Copied to clipboard",
"globals.messages.correctEmailErrors": "Please correct the email errors",
@@ -666,6 +682,7 @@
"globals.messages.hoursSinceResolved": "Hours since resolved",
"globals.messages.import": "Import",
"globals.messages.invalid": "{name} is invalid",
+ "globals.messages.invalidFilters": "Invalid filters.",
"globals.messages.invite": "Invite",
"globals.messages.lastUsed": "Last used",
"globals.messages.linkUrl": "Link URL",
@@ -706,6 +723,8 @@
"globals.messages.sendUsMessage": "Send us a message",
"globals.messages.sending": "Sending...",
"globals.messages.setUp": "Set up",
+ "globals.messages.showLess": "Show less",
+ "globals.messages.showMore": "Show more",
"globals.messages.smallScreenDescription": "Libredesk is designed for larger screens. For the best experience, please use a desktop or tablet.",
"globals.messages.smallScreenTitle": "Switch to desktop for the best experience",
"globals.messages.somethingWentWrong": "Something went wrong",
@@ -739,6 +758,7 @@
"globals.terms.apiKey": "API key | API keys",
"globals.terms.appliesTo": "Applies to",
"globals.terms.ascending": "Ascending",
+ "globals.terms.attachment": "Attachment | Attachments",
"globals.terms.audio": "Audio",
"globals.terms.automation": "Automation | Automations",
"globals.terms.availabilityStatus": "Availability status",
@@ -761,6 +781,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",
@@ -769,6 +791,8 @@
"globals.terms.country": "Country",
"globals.terms.createdAt": "Created at",
"globals.terms.createdOn": "Created on",
+ "globals.terms.csatFeedback": "CSAT feedback",
+ "globals.terms.csatRating": "CSAT rating",
"globals.terms.custom": "Custom",
"globals.terms.customAttribute": "Custom attribute | Custom attributes",
"globals.terms.date": "Date",
@@ -778,6 +802,7 @@
"globals.terms.description": "Description | Descriptions",
"globals.terms.disabled": "Disabled",
"globals.terms.download": "Download",
+ "globals.terms.downloadedAt": "Downloaded at",
"globals.terms.draft": "Draft",
"globals.terms.email": "Email | Emails",
"globals.terms.enabled": "Enabled",
@@ -815,6 +840,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",
@@ -833,6 +860,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",
@@ -890,6 +918,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",
@@ -911,6 +940,7 @@
"globals.terms.tryAgain": "Try again",
"globals.terms.type": "Type | Types",
"globals.terms.typeMessage": "Type your message...",
+ "globals.terms.typing": "Typing...",
"globals.terms.unAuthorized": "Unauthorized",
"globals.terms.unassigned": "Unassigned",
"globals.terms.updatedAt": "Updated at",
@@ -924,12 +954,14 @@
"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",
"globals.terms.whatsapp": "WhatsApp",
"globals.terms.white": "White",
"globals.terms.workspace": "Workspace",
+ "globals.terms.yesterday": "Yesterday",
"globals.terms.you": "You",
"imageLightbox.next": "Next image",
"imageLightbox.previous": "Previous image",
@@ -1020,6 +1052,7 @@
"replyBox.missingTagsTitle": "No tags on this conversation",
"replyBox.removeBCC": "Remove BCC",
"replyBox.sendAnyway": "Send anyway",
+ "replyBox.sendAndSetAs": "Send and set as",
"replyBox.toRequired": "At least one recipient is required in the To field.",
"report.agentStatus": "Agent Status",
"report.chart.newConversations": "New conversations",
@@ -1149,11 +1182,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",
@@ -1161,4 +1193,4 @@
"widget.conversationClosed": "This conversation has been closed",
"widget.ipBlocked": "Access denied",
"widget.prechatForm.startChat": "Start chat"
-}
\ No newline at end of file
+}
diff --git a/i18n/es-ES.json b/i18n/es-ES.json
index c4c6ed7b..09804810 100644
--- a/i18n/es-ES.json
+++ b/i18n/es-ES.json
@@ -19,7 +19,6 @@
"actions.removeLink": "Eliminar enlace",
"actions.removeTags": "Eliminar etiquetas",
"actions.sendCsat": "Enviar CSAT",
- "actions.sendPrivateNote": "Enviar nota privada",
"actions.sendReply": "Enviar respuesta",
"actions.setPriority": "Establecer prioridad",
"actions.setSla": "Establecer SLA",
@@ -39,14 +38,6 @@
"activityLog.rolePermissionsAdded": "{actorEmail} ({actorId}) añadió permiso(s) {permissions} al rol {roleName} ({roleId})",
"activityLog.rolePermissionsChanged": "{actorEmail} ({actorId}) eliminó permiso(s) {removed} y añadió permiso(s) {added} al rol {roleName} ({roleId})",
"activityLog.rolePermissionsRemoved": "{actorEmail} ({actorId}) eliminó permiso(s) {permissions} del rol {roleName} ({roleId})",
- "activityLog.type": "Tipo de registro de actividad",
- "activityLog.type.agentAway": "Agente ausente",
- "activityLog.type.agentAwayReassigned": "Agente ausente reasignado",
- "activityLog.type.agentLogin": "Inicio de sesión de agente",
- "activityLog.type.agentLogout": "Cierre de sesión de agente",
- "activityLog.type.agentOnline": "Agente en línea",
- "activityLog.type.agentPasswordSet": "Contraseña de agente establecida",
- "activityLog.type.agentRolePermissionsChanged": "Permisos de rol de agente cambiados",
"admin.agent.apiKey.description": "Genera claves API para que este agente acceda a Libredesk programáticamente.",
"admin.agent.apiKey.noKey": "No se ha generado ninguna clave API para este agente.",
"admin.agent.apiKey.warningMessage": "Este secreto solo se mostrará una vez. Asegúrate de copiarlo ahora.",
@@ -55,7 +46,6 @@
"admin.automation.all": "TODOS",
"admin.automation.and": "Y",
"admin.automation.any": "CUALQUIERA",
- "admin.automation.below": "debajo",
"admin.automation.conversationUpdate": "Actualización de conversación",
"admin.automation.conversationUpdate.description": "Reglas que se ejecutan cuando se actualiza una conversación.",
"admin.automation.evaluateRuleOnTheseEvents": "Evaluar regla en estos eventos.",
@@ -67,7 +57,6 @@
"admin.automation.executeFirstMatchingRule": "Ejecutar primera regla coincidente",
"admin.automation.help": "Automatiza acciones cuando las conversaciones se crean, se actualizan o en un horario periódico.",
"admin.automation.invalid": "Asegúrate de tener al menos una acción y una regla y que sus valores no estén vacíos.",
- "admin.automation.match": "Coincidir",
"admin.automation.matchTheseRules": "Coincidir con estas reglas",
"admin.automation.newConversation.description": "Reglas que se ejecutan cuando un contacto crea una nueva conversación. Las conversaciones iniciadas por agentes no activan estas reglas. Arrastra y suelta para reordenar.",
"admin.automation.noRulesFound": "No se encontraron reglas",
@@ -118,7 +107,6 @@
"admin.inbox.authProtocol": "Protocolo de autenticación",
"admin.inbox.authProtocol.description": "Protocolo de autenticación a utilizar.",
"admin.inbox.authProtocol.login": "Nombre de usuario",
- "admin.inbox.authProtocol.plain": "Plain",
"admin.inbox.chooseChannel": "Elegir canal",
"admin.inbox.createEmailInbox": "Crea una bandeja de entrada de correo electrónico para soporte al cliente por email",
"admin.inbox.createLiveChatInbox": "Crea una bandeja de entrada de chat en vivo para soporte al cliente en tiempo real",
@@ -188,7 +176,6 @@
"admin.inbox.livechat.installation.instructions.step2": "Pégalo justo antes de la etiqueta de cierre `body` en cada página donde quieras que aparezca el widget de chat en vivo",
"admin.inbox.livechat.installation.instructions.title": "Instrucciones de instalación",
"admin.inbox.livechat.installation.jsApi.description": "Controla el widget programáticamente desde tu aplicación.",
- "admin.inbox.livechat.installation.jsApi.title": "JavaScript API",
"admin.inbox.livechat.introductionMessage": "Mensaje de introducción",
"admin.inbox.livechat.language.auto": "Detección automática (idioma del navegador)",
"admin.inbox.livechat.launcher": "Lanzador",
@@ -356,12 +343,9 @@
"admin.tags.deleteConfirmation": "¿Estás seguro de que quieres eliminar esta etiqueta? Esto también la eliminará de todas las conversaciones",
"admin.team.assignmentType": "Tipo de asignación automática",
"admin.team.assignmentType.description": "Round robin: Las conversaciones se asignan a los miembros del equipo de forma rotativa. Manual: Las conversaciones deben ser tomadas por los miembros del equipo.",
- "admin.team.assignmentType.manual": "Manual",
"admin.team.assignmentType.placeholder": "Selecciona un tipo de asignación",
- "admin.team.assignmentType.roundRobin": "Round robin",
"admin.team.businessHours.description": "Horario de atención predeterminado del equipo, se usará para calcular el SLA.",
"admin.team.couldNotFetchBusinessHours": "No se pudo obtener el horario de atención",
- "admin.team.emoji": "Emoji",
"admin.team.help.description": "Configura los ajustes del equipo incluyendo horario de trabajo y políticas SLA.",
"admin.team.help.detail": "Administra los límites de asignación automática de agentes y más.",
"admin.team.maxAutoAssigned": "Conversaciones máximas asignadas automáticamente",
@@ -407,7 +391,6 @@
"auth.sendResetLink": "Enviar enlace de restablecimiento",
"auth.setNewPassword": "Establecer nueva contraseña",
"auth.settingPassword": "Estableciendo contraseña...",
- "auth.showPassword": "Show password",
"auth.signIn": "Inicia sesión en tu cuenta",
"auth.signInButton": "Iniciar sesión",
"automation.deletionConfirmation": "Esta acción no se puede deshacer. Se eliminará permanentemente esta regla de automatización.",
@@ -635,7 +618,6 @@
"globals.terms.action": "Acción | Acciones",
"globals.terms.active": "Activo",
"globals.terms.activityLog": "Registro de actividad | Registros de actividad",
- "globals.terms.actor": "Actor | Actores",
"globals.terms.admin": "Administrador | Administradores",
"globals.terms.agent": "Agente | Agentes",
"globals.terms.alert": "Alerta | Alertas",
@@ -643,14 +625,12 @@
"globals.terms.apiKey": "Llave API | Llaves API",
"globals.terms.appliesTo": "Aplica a",
"globals.terms.ascending": "Ascendente",
- "globals.terms.audio": "Audio",
"globals.terms.automation": "Automatización | Automatizaciones",
"globals.terms.availabilityStatus": "Estado de disponibilidad",
"globals.terms.awaitingResponse": "Esperando respuesta",
"globals.terms.away": "Ausente | Ausente",
"globals.terms.awayReassigning": "Ausente y reasignando",
"globals.terms.background": "Fondo",
- "globals.terms.beta": "Beta",
"globals.terms.black": "Negro",
"globals.terms.body": "Cuerpo",
"globals.terms.brandName": "Nombre de marca",
@@ -681,7 +661,6 @@
"globals.terms.draft": "Borrador",
"globals.terms.email": "Correo Electrónico | Correos Electrónicos",
"globals.terms.enabled": "Habilitado",
- "globals.terms.error": "Error | Errores",
"globals.terms.event": "Evento | Eventos",
"globals.terms.excellent": "Excepcional",
"globals.terms.expand": "Expandir",
@@ -693,15 +672,12 @@
"globals.terms.firstName": "Nombre | Nombres",
"globals.terms.firstReplyAt": "Primera respuesta el",
"globals.terms.fromEmailAddress": "Dirección de correo electrónico de origen | Direcciones de correo electrónico de origen",
- "globals.terms.general": "General",
"globals.terms.good": "Bueno",
- "globals.terms.google": "Google",
"globals.terms.gradient": "Degradado",
"globals.terms.great": "Excelente",
"globals.terms.home": "Inicio",
"globals.terms.homeScreen": "Pantalla de inicio",
"globals.terms.homeScreenApp": "App de pantalla de inicio | Apps de pantalla de inicio",
- "globals.terms.host": "Host | Hosts",
"globals.terms.hour": "Hora | Horas",
"globals.terms.image": "Imagen | Imágenes",
"globals.terms.import": "Importar | Importaciones",
@@ -727,16 +703,13 @@
"globals.terms.loading": "Cargando...",
"globals.terms.log": "Registro | Registros",
"globals.terms.logoUrl": "URL del logo",
- "globals.terms.macro": "Macro | Macros",
"globals.terms.mention": "Mención | Menciones",
"globals.terms.message": "Mensaje | Mensajes",
- "globals.terms.microsoft": "Microsoft",
"globals.terms.myInbox": "Mi bandeja de entrada | Mis bandejas de entrada",
"globals.terms.name": "Nombre | Nombres",
"globals.terms.none": "Ninguno",
"globals.terms.note": "Nota | Notas",
"globals.terms.notification": "Notificación | Notificaciones",
- "globals.terms.off": "OFF",
"globals.terms.offline": "Desconectado | Desconectado",
"globals.terms.online": "En línea | En línea",
"globals.terms.open": "Abrir",
@@ -762,14 +735,12 @@
"globals.terms.providerURL": "URL del proveedor",
"globals.terms.reconnect": "Reconectar",
"globals.terms.referenceNumber": "Número de referencia",
- "globals.terms.regex": "Regex | Regexes",
"globals.terms.regexHint": "Sugerencia de regex",
"globals.terms.reply": "Respuesta | Respuestas",
"globals.terms.report": "Informe | Informes",
"globals.terms.required": "Requerido",
"globals.terms.resolve": "Resolver",
"globals.terms.resolvedAt": "Resuelto el",
- "globals.terms.role": "Rol | Roles",
"globals.terms.rootURL": "URL raíz",
"globals.terms.search": "Búsqueda",
"globals.terms.secondaryColor": "Color secundario | Colores secundarios",
@@ -779,7 +750,6 @@
"globals.terms.sendWelcomeEmail": "¿Enviar correo de bienvenida? | ¿Enviar correos de bienvenida?",
"globals.terms.setPassword": "Establecer contraseña | Establecer contraseñas",
"globals.terms.sharedView": "Vista compartida | Vistas compartidas",
- "globals.terms.sla": "SLA | SLAs",
"globals.terms.slaMetric": "Métrica SLA | Métricas SLA",
"globals.terms.slaPolicy": "Política SLA | Políticas SLA",
"globals.terms.smtpHost": "Host SMTP | Hosts SMTP",
@@ -787,7 +757,6 @@
"globals.terms.snooze": "Posponer",
"globals.terms.solid": "Sólido",
"globals.terms.someone": "Alguien",
- "globals.terms.sso": "SSO | SSO",
"globals.terms.status": "Estado | Estados",
"globals.terms.subject": "Asunto | Asuntos",
"globals.terms.tag": "Etiqueta | Etiquetas",
@@ -801,7 +770,6 @@
"globals.terms.timestamp": "Marca de tiempo | Marcas de tiempo",
"globals.terms.timezone": "Zona horaria | Zonas horarias",
"globals.terms.title": "Título | Títulos",
- "globals.terms.tls": "TLS | TLS",
"globals.terms.today": "Hoy",
"globals.terms.tryAgain": "Intenta de nuevo",
"globals.terms.type": "Tipo | Tipos",
@@ -809,7 +777,6 @@
"globals.terms.unAuthorized": "No autorizado",
"globals.terms.unassigned": "No asignado",
"globals.terms.updatedAt": "Actualizado el",
- "globals.terms.url": "URL | URLs",
"globals.terms.usage": "Uso",
"globals.terms.username": "Nombre de usuario | Nombres de usuario",
"globals.terms.users": "Usuarios",
@@ -899,7 +866,6 @@
"placeholders.selectValue": "Seleccionar valor",
"placeholders.startConversation": "Iniciar conversación",
"placeholders.tellUsAboutYourself": "Cuéntanos sobre ti",
- "replyBox.bcc": "BCC",
"replyBox.contactEmailMissing": "El correo del contacto no está en los destinatarios",
"replyBox.contactEmailMissingDescription": "El correo electrónico del contacto ({email}) no figura en los campos «Para», «Copia» ni «Copia oculta». El contacto no recibirá esta respuesta.",
"replyBox.emailAddresess": "Direcciones de correo electrónico separadas por coma",
@@ -919,7 +885,6 @@
"report.messages.incoming": "Entrantes",
"report.messages.outgoing": "Salientes",
"report.messages.perConversation": "Por conversación",
- "report.messages.total": "Total",
"report.noTagsFound": "No se encontraron etiquetas",
"report.openConversations": "Conversaciones abiertas",
"report.sla.avgFirstResp": "Promedio de tiempo de primera respuesta",
@@ -1047,4 +1012,4 @@
"widget.conversationClosed": "Esta conversación ha sido cerrada",
"widget.ipBlocked": "Acceso denegado",
"widget.prechatForm.startChat": "Iniciar chat"
-}
+}
diff --git a/i18n/fa-IR.json b/i18n/fa-IR.json
index ea3a4750..a04c8a48 100644
--- a/i18n/fa-IR.json
+++ b/i18n/fa-IR.json
@@ -19,7 +19,6 @@
"actions.removeLink": "حذف لینک",
"actions.removeTags": "حذف برچسبها",
"actions.sendCsat": "ارسال CSAT",
- "actions.sendPrivateNote": "ارسال یادداشت خصوصی",
"actions.sendReply": "ارسال پاسخ",
"actions.setPriority": "تنظیم اولویت",
"actions.setSla": "تنظیم SLA",
@@ -39,14 +38,6 @@
"activityLog.rolePermissionsAdded": "{actorEmail} ({actorId}) مجوز(های) {permissions} را به نقش {roleName} ({roleId}) اضافه کرد",
"activityLog.rolePermissionsChanged": "{actorEmail} ({actorId}) مجوز(های) {removed} را حذف و مجوز(های) {added} را به نقش {roleName} ({roleId}) اضافه کرد",
"activityLog.rolePermissionsRemoved": "{actorEmail} ({actorId}) مجوز(های) {permissions} را از نقش {roleName} ({roleId}) حذف کرد",
- "activityLog.type": "نوع لاگ فعالیت",
- "activityLog.type.agentAway": "غیبت نماینده",
- "activityLog.type.agentAwayReassigned": "تخصیص مجدد در زمان غیبت نماینده",
- "activityLog.type.agentLogin": "ورود نماینده",
- "activityLog.type.agentLogout": "خروج نماینده",
- "activityLog.type.agentOnline": "آنلاین شدن نماینده",
- "activityLog.type.agentPasswordSet": "تنظیم رمز عبور نماینده",
- "activityLog.type.agentRolePermissionsChanged": "تغییر مجوزهای نقش نماینده",
"admin.agent.apiKey.description": "کلیدهای API برای این نماینده تولید کنید تا به صورت برنامهنویسی به libredesk دسترسی داشته باشد.",
"admin.agent.apiKey.noKey": "هیچ کلید API برای این نماینده تولید نشده است.",
"admin.agent.apiKey.warningMessage": "این رمز فقط یک بار نمایش داده میشود. مطمئن شوید که الان آن را کپی کنید.",
@@ -55,7 +46,6 @@
"admin.automation.all": "همه",
"admin.automation.and": "و",
"admin.automation.any": "هر",
- "admin.automation.below": "پایین",
"admin.automation.conversationUpdate": "بهروزرسانی مکالمه",
"admin.automation.conversationUpdate.description": "قوانینی که هنگام بهروزرسانی یک مکالمه اجرا میشوند.",
"admin.automation.evaluateRuleOnTheseEvents": "ارزیابی قانون برای این رویدادها.",
@@ -67,7 +57,6 @@
"admin.automation.executeFirstMatchingRule": "اجرای اولین قانون منطبق",
"admin.automation.help": "اقدامات را هنگام ایجاد، بهروزرسانی مکالمات یا بر اساس زمانبندی ساعتی بهصورت خودکار انجام دهید.",
"admin.automation.invalid": "مطمئن شوید که حداقل یک اقدام و یک قانون دارید و مقادیر آنها خالی نیست.",
- "admin.automation.match": "تطابق",
"admin.automation.matchTheseRules": "تطابق با این قوانین",
"admin.automation.newConversation.description": "قوانینی که هنگام ایجاد مکالمه جدید توسط مخاطب اجرا میشوند. مکالمههایی که توسط کارشناسان ایجاد شوند این قوانین را فعال نمیکنند. برای تغییر ترتیب، بکشید و رها کنید.",
"admin.automation.noRulesFound": "قانونی یافت نشد",
@@ -188,7 +177,6 @@
"admin.inbox.livechat.installation.instructions.step2": "آن را درست قبل از تگ بسته شدن `body` در هر صفحهای که میخواهید ویجت چت زنده نمایش داده شود، قرار دهید",
"admin.inbox.livechat.installation.instructions.title": "دستورالعملهای نصب",
"admin.inbox.livechat.installation.jsApi.description": "ویجت را به صورت برنامهنویسی از اپلیکیشن خود کنترل کنید.",
- "admin.inbox.livechat.installation.jsApi.title": "JavaScript API",
"admin.inbox.livechat.introductionMessage": "پیام معرفی",
"admin.inbox.livechat.language.auto": "تشخیص خودکار (زبان مرورگر)",
"admin.inbox.livechat.launcher": "لانچر",
@@ -393,7 +381,6 @@
"auth.enterEmailForReset": "ایمیل خود را برای دریافت لینک بازنشانی رمز عبور وارد کنید.",
"auth.enterNewPasswordTwice": "رمز عبور جدید خود را دو بار وارد کنید تا تأیید شود.",
"auth.forgotPassword": "رمز عبور را فراموش کردهاید؟",
- "auth.hidePassword": "Hide password",
"auth.invalidOrExpiredSession": "جلسه نامعتبر یا منقضی شده",
"auth.invalidOrExpiredSessionClearCookie": "نشست نامعتبر یا منقضی شده. لطفاً کوکیهای خود را پاک کنید و دوباره امتحان کنید.",
"auth.invalidResetLink": "لینک بازنشانی نامعتبر است. لطفاً لینک بازنشانی رمز عبور جدیدی درخواست کنید.",
@@ -407,7 +394,6 @@
"auth.sendResetLink": "ارسال لینک بازنشانی",
"auth.setNewPassword": "تنظیم رمز عبور جدید",
"auth.settingPassword": "در حال تنظیم رمز عبور...",
- "auth.showPassword": "Show password",
"auth.signIn": "وارد حساب کاربری خود شوید",
"auth.signInButton": "ورود",
"automation.deletionConfirmation": "این عمل قابل بازگشت نیست. این قانون اتوماسیون به طور دائم حذف خواهد شد.",
@@ -455,7 +441,6 @@
"contact.unblockedSuccessfully": "مسدودیت مخاطب با موفقیت رفع شد",
"contact.updateContact": "بهروزرسانی مخاطب",
"contextLink.edit": "ویرایش لینک زمینهای",
- "contextLink.namePlaceholder": "My App",
"contextLink.new": "لینک زمینهای جدید",
"contextLink.secret": "رمز",
"contextLink.secretHelp": "یک کلید ۳۲ کاراکتری مشترک بین Libredesk و اپلیکیشن خارجی. برای رمزگذاری {'{{token}}'} استفاده میشود تا فقط اپلیکیشن خارجی بتواند اطلاعات مخاطب را بخواند. اگر فقط از متغیرهای ساده مانند {'{{email}}'} استفاده میکنید، نیازی به آن نیست.",
@@ -527,7 +512,6 @@
"globals.messages.all": "همه {name}",
"globals.messages.apply": "اعمال {name}",
"globals.messages.areYouAbsolutelySure": "آیا کاملاً مطمئن هستید؟",
- "globals.messages.attachFile": "Attach file",
"globals.messages.back": "بازگشت",
"globals.messages.backOnDayAt": "در {day} در ساعت {time}",
"globals.messages.backTodayAt": "امروز در ساعت {time}",
@@ -561,7 +545,6 @@
"globals.messages.errorUploadingFile": "خطا در بارگذاری فایل",
"globals.messages.false": "false",
"globals.messages.fileTypeisNotAnImage": "نوع فایل تصویر نیست",
- "globals.messages.goBack": "Go back",
"globals.messages.golangDurationHoursMinutes": "مدتزمان به ساعت یا دقیقه. مثال: 1h، 30m، 1h30m",
"globals.messages.gradientEnd": "پایان گرادیان",
"globals.messages.gradientStart": "شروع گرادیان",
@@ -586,7 +569,6 @@
"globals.messages.noResultsFound": "نتیجهای یافت نشد",
"globals.messages.notAllowed": "{name} مجاز نیست",
"globals.messages.notFound": "{name} یافت نشد",
- "globals.messages.options": "Options",
"globals.messages.pageNofTotal": "صفحه {page} از {total}",
"globals.messages.pageNotFound": "صفحه یافت نشد",
"globals.messages.pageTooLarge": "اندازه صفحه بیش از حد بزرگ است، باید حداکثر {max} باشد",
@@ -779,7 +761,6 @@
"globals.terms.sendWelcomeEmail": "ارسال ایمیل خوشآمدگویی؟ | ارسال ایمیلهای خوشآمدگویی؟",
"globals.terms.setPassword": "تنظیم رمز عبور | تنظیم رمزهای عبور",
"globals.terms.sharedView": "نمای مشترک | نماهای مشترک",
- "globals.terms.sla": "SLA | SLAs",
"globals.terms.slaMetric": "معیار SLA | معیارهای SLA",
"globals.terms.slaPolicy": "سیاست SLA | سیاستهای SLA",
"globals.terms.smtpHost": "میزبان SMTP | میزبانهای SMTP",
@@ -787,7 +768,6 @@
"globals.terms.snooze": "تعویق",
"globals.terms.solid": "تکرنگ",
"globals.terms.someone": "شخصی",
- "globals.terms.sso": "SSO | SSO",
"globals.terms.status": "وضعیت | وضعیتها",
"globals.terms.subject": "موضوع | موضوعات",
"globals.terms.tag": "برچسب | برچسبها",
@@ -801,7 +781,6 @@
"globals.terms.timestamp": "برچسب زمانی | برچسبهای زمانی",
"globals.terms.timezone": "منطقه زمانی | مناطق زمانی",
"globals.terms.title": "عنوان | عناوین",
- "globals.terms.tls": "TLS | TLSها",
"globals.terms.today": "امروز",
"globals.terms.tryAgain": "دوباره امتحان کنید",
"globals.terms.type": "نوع | انواع",
@@ -1047,4 +1026,4 @@
"widget.conversationClosed": "این مکالمه بسته شده است",
"widget.ipBlocked": "دسترسی رد شد",
"widget.prechatForm.startChat": "شروع چت"
-}
+}
diff --git a/i18n/fr-FR.json b/i18n/fr-FR.json
index 093d5cd2..7cca0943 100644
--- a/i18n/fr-FR.json
+++ b/i18n/fr-FR.json
@@ -12,6 +12,7 @@
"actions.addCondition": "Ajouter une condition",
"actions.addTags": "Ajouter des étiquettes",
"actions.addingPrivateNotes": "Ajout de notes privées",
+ "actions.addPrivateNote": "Ajouter une note privée",
"actions.applyMacro": "Appliquer la macro",
"actions.assignAgent": "Affecter un agent",
"actions.assignTeam": "Affecter une équipe",
@@ -19,7 +20,6 @@
"actions.removeLink": "Supprimer le lien",
"actions.removeTags": "Supprimer les étiquettes",
"actions.sendCsat": "Envoyer le questionnaire de satisfaction utilisateur (CSAT)",
- "actions.sendPrivateNote": "Envoyer une note privée",
"actions.sendReply": "Envoyer une réponse",
"actions.setPriority": "Définir la priorité",
"actions.setSla": "Définir le SLA",
@@ -36,17 +36,17 @@
"activityLog.agentOnline": "{actorEmail} ({actorId}) a changé le statut de {targetEmail} ({targetId}) vers \"En ligne\"",
"activityLog.agentOnlineSelf": "{actorEmail} ({actorId}) est en ligne",
"activityLog.agentPasswordSet": "{actorEmail} ({actorId}) a défini le mot de passe de {targetEmail} ({targetId})",
+ "activityLog.entryType": "Type d'entrée du journal",
+ "activityLog.entryType.agentAway": "Agent absent",
+ "activityLog.entryType.agentAwayReassigned": "Agent absent et réaffecté",
+ "activityLog.entryType.agentLogin": "Connexion de l'agent",
+ "activityLog.entryType.agentLogout": "Déconnexion de l'agent",
+ "activityLog.entryType.agentOnline": "Agent en ligne",
+ "activityLog.entryType.agentPasswordSet": "Mot de passe de l'agent défini",
+ "activityLog.entryType.agentRolePermissionsChanged": "Autorisations du rôle de l'agent modifiées",
"activityLog.rolePermissionsAdded": "{actorEmail} ({actorId}) a ajouté les permissions {permissions} au rôle {roleName} ({roleId})",
"activityLog.rolePermissionsChanged": "{actorEmail} ({actorId}) a supprimé les permissions {removed} et ajouté les permissions {added} au rôle {roleName} ({roleId})",
"activityLog.rolePermissionsRemoved": "{actorEmail} ({actorId}) a supprimé les permissions {permissions} du rôle {roleName} ({roleId})",
- "activityLog.type": "Type d'entrée du journal d'activité",
- "activityLog.type.agentAway": "Agent absent",
- "activityLog.type.agentAwayReassigned": "Agent absent et réaffecté",
- "activityLog.type.agentLogin": "Connexion de l'agent",
- "activityLog.type.agentLogout": "Déconnexion de l'agent",
- "activityLog.type.agentOnline": "Agent en ligne",
- "activityLog.type.agentPasswordSet": "Mot de passe de l'agent défini",
- "activityLog.type.agentRolePermissionsChanged": "Autorisations du rôle de l'agent modifiées",
"admin.agent.apiKey.description": "Générer des clés API pour que cet agent accède à Libredesk par des applications tierces.",
"admin.agent.apiKey.noKey": "Aucune clé API n'a été générée pour cet agent.",
"admin.agent.apiKey.warningMessage": "Cette clé ne sera affichée qu'une seule fois. Assurez-vous de la copier dès maintenant.",
@@ -55,7 +55,6 @@
"admin.automation.all": "TOUTES",
"admin.automation.and": "ET",
"admin.automation.any": "N'IMPORTE LAQUELLE",
- "admin.automation.below": "ci-dessous",
"admin.automation.conversationUpdate": "Mise à jour de la conversation",
"admin.automation.conversationUpdate.description": "Règles qui s'exécutent lorsqu'une conversation est mise à jour.",
"admin.automation.evaluateRuleOnTheseEvents": "Évaluer les règles relatives à ces événements.",
@@ -67,7 +66,7 @@
"admin.automation.executeFirstMatchingRule": "Exécuter la première règle correspondante",
"admin.automation.help": "Automatisez des actions lors de la création, la mise à jour des conversations ou selon une programmation horaire.",
"admin.automation.invalid": "Assurez-vous que vous avez au moins une action et une règle et que leurs valeurs ne sont pas vides.",
- "admin.automation.match": "Correspond",
+ "admin.automation.matchBelow": "Correspond à {any_or_all} ci-dessous.",
"admin.automation.matchTheseRules": "Correspondre à ces règles",
"admin.automation.newConversation.description": "Règles qui s'exécutent lorsqu'une nouvelle conversation est créée par un contact. Les conversations initiées par les agents ne déclenchent pas ces règles. Glisser-déposer pour réorganiser.",
"admin.automation.noRulesFound": "Aucune règle trouvée",
@@ -138,7 +137,7 @@
"admin.inbox.help.email": "Connectez votre compte e-mail Google ou Microsoft, ou configurez les paramètres IMAP et SMTP manuellement. Chaque boîte ajoutée crée un nouveau canal e-mail pour recevoir les e-mails des clients.",
"admin.inbox.help.livechat": "Créez des widgets de chat en direct qui peuvent être intégrés à votre site web pour un support client en temps réel.",
"admin.inbox.idleTimeout": "Délai d’inactivité",
- "admin.inbox.idleTimeout.description": "Le délai d'inactivité est la durée maximale d'inactivité d'une connexion avant de la fermer et de la retirer du pool.",
+ "admin.inbox.idleTimeout.description": "Durée maximale durant laquelle une connexion inactive doit être gardée en vie avant d'être fermée et retirée du pool.",
"admin.inbox.imap.tls.description": "Choisissez la méthode de cryptage pour IMAP.",
"admin.inbox.imapConfig": "Configuration IMAP",
"admin.inbox.imapScanInboxSince": "Scanner la boîte de réception depuis",
@@ -190,7 +189,7 @@
"admin.inbox.livechat.installation.instructions.step2": "Collez-le juste avant la balise de fermeture `body` sur chaque page où vous souhaitez que le widget de chat apparaisse",
"admin.inbox.livechat.installation.instructions.title": "Instructions d'installation",
"admin.inbox.livechat.installation.jsApi.description": "Contrôlez le widget programmatiquement depuis votre application.",
- "admin.inbox.livechat.installation.jsApi.title": "JavaScript API",
+ "admin.inbox.livechat.installation.jsApi.title": "API JavaScript",
"admin.inbox.livechat.introductionMessage": "Message d'introduction",
"admin.inbox.livechat.language.auto": "Détection automatique (langue du navigateur)",
"admin.inbox.livechat.launcher": "Lanceur",
@@ -265,7 +264,7 @@
"admin.inbox.oauth.step2AddCallback": "2. Ajoutez cette URL de retour :",
"admin.inbox.oauth.step3EnterCredentials": "3. Entrez vos identifiants ci-dessous :",
"admin.inbox.promptTagsOnReply": "Demander à étiqueter avant de répondre",
- "admin.inbox.promptTagsOnReply.description": "Avertir les agents lorsqu'ils s'apprêtent à répondre à une conversation qui ne comprend aucune étiquette. Les agents peuvent toujours répondre sans affecter une étiquette en outrepassant l'avertissement. ",
+ "admin.inbox.promptTagsOnReply.description": "Avertir les agents lorsqu'ils s'apprêtent à répondre à une conversation qui ne comprend aucune étiquette. Les agents peuvent toujours répondre sans affecter une étiquette en outrepassant l'avertissement.",
"admin.inbox.replyToAddress": "Adresse de réponse (optionnel)",
"admin.inbox.replyToAddress.description": "Adresse recevant la réponse du client. Nécessaire seulement lorsque l'en-tête SMTP « From » et la boîte de réception IMAP utilisent des domaines différents.",
"admin.inbox.replyToAddress.placeholder": "support{'@'}exemple.com",
@@ -274,7 +273,7 @@
"admin.inbox.smtpConfig": "Configuration SMTP",
"admin.inbox.tls.description": "Cryptage TLS/SSL, STARTTLS est couramment utilisé.",
"admin.inbox.waitTimeout": "Temps d'attente dépassé",
- "admin.inbox.waitTimeout.description": "Correspond au délai maximal d'attente pour obtenir une connexion à partir d'un serveur. Cela peut se produire lorsque toutes les connexions ouvertes sont occupées à envoyer des e-mails et ne sont pas libérées assez rapidement pour réintégrer le pool. Il s'agit également du délai d'expiration utilisé lors de la création de nouvelles connexions SMTP.",
+ "admin.inbox.waitTimeout.description": "Délai maximal d'attente pour obtenir une connexion à partir d'un serveur. Le délai d'attente peut être dépassé lorsque toutes les connexions ouvertes sont occupées à envoyer des e-mails et ne sont pas libérées assez rapidement pour réintégrer le pool. Il s'agit également du délai d'expiration utilisé lors de la création de nouvelles connexions SMTP.",
"admin.macro.actionInvalid": "Chaque action doit avoir un type et une valeur",
"admin.macro.help": "Combinez plusieurs actions de conversation en macros exécutables en un clic.",
"admin.macro.messageContent": "Réponse à envoyer lorsque la macro est utilisée (facultatif)",
@@ -478,7 +477,7 @@
"conversation.bulkActions.failedToast": "Certaines conversations n'ont pas pu être modifiées",
"conversation.bulkActions.selectAll": "Sélectionner toutes les conversations",
"conversation.bulkActions.selectConversation": "Sélectionner la conversation",
- "conversation.bulkActions.selected": "Aucune conversation sélectionnée | {count} sélectionnée(s)",
+ "conversation.bulkActions.selected": "Aucune conversation sélectionnée | 1 sélectionnée | {count} sélectionnées",
"conversation.bulkActions.successToast": "Conversations modifiées",
"conversation.bulkActions.toolbar": "Barre d'outils d'actions en masse",
"conversation.couldNotFetch": "Impossible d'obtenir des conversations",
@@ -698,7 +697,7 @@
"globals.terms.disabled": "Désactivé",
"globals.terms.download": "Télécharger",
"globals.terms.draft": "Brouillon",
- "globals.terms.email": "Email | E-mails",
+ "globals.terms.email": "E-mail | E-mails",
"globals.terms.enabled": "Activé",
"globals.terms.error": "Erreur | Erreurs",
"globals.terms.event": "Évènement | Évènements",
@@ -830,6 +829,7 @@
"globals.terms.tryAgain": "Réessayer",
"globals.terms.type": "Type | Types",
"globals.terms.typeMessage": "Tapez votre message...",
+ "globals.terms.typing": "Est en train d'écrire...",
"globals.terms.unAuthorized": "Non autorisé",
"globals.terms.unassigned": "Non assigné",
"globals.terms.updatedAt": "Mis à jour le",
@@ -889,7 +889,7 @@
"macro.partiallyApplied": "Macro partiellement appliquée",
"macro.permissionDenied": "Permission refusée pour certaines actions macro",
"media.fileEmpty": "Ce fichier fait 0 octets, il ne sera donc pas joint.",
- "media.fileSizeTooLarge": "Taille du fichier trop importante, veuillez télécharger un fichier inférieur à {size} ",
+ "media.fileSizeTooLarge": "Taille du fichier trop importante, veuillez télécharger un fichier inférieur à {size}",
"media.fileTypeNotAllowed": "Type de fichier non autorisé",
"media.invalidOrExpiredURL": "URL de média invalide ou expirée",
"navigation.away": "Absent",
@@ -935,7 +935,7 @@
"replyBox.emailAddresess": "Adresses électroniques séparées par une virgule",
"replyBox.invalidEmailsIn": "Emails non valides dans",
"replyBox.missingTagsDescription": "Cette conversation ne comprend aucune étiquette. L'étiquetage aide à l'établissement de statistiques et au tri des conversations. Vous gardez la possibilité d'envoyer votre réponse sans étiqueter la conversation.",
- "replyBox.missingTagsTitle": "Cette conversation ne comporte aucune étiquette.",
+ "replyBox.missingTagsTitle": "Cette conversation ne comporte aucune étiquette",
"replyBox.removeBCC": "Supprimer le BCC",
"replyBox.sendAnyway": "Envoyer quand même",
"replyBox.toRequired": "Au moins un destinataire est requis dans le champ À.",
@@ -1079,4 +1079,4 @@
"widget.conversationClosed": "Cette conversation a été fermée",
"widget.ipBlocked": "Accès refusé",
"widget.prechatForm.startChat": "Démarrer le chat"
-}
+}
diff --git a/i18n/it-IT.json b/i18n/it-IT.json
index 868081a6..3f324fe0 100644
--- a/i18n/it-IT.json
+++ b/i18n/it-IT.json
@@ -19,7 +19,6 @@
"actions.removeLink": "Rimuovi link",
"actions.removeTags": "Rimuovi tag",
"actions.sendCsat": "Invia CSAT",
- "actions.sendPrivateNote": "Invia nota privata",
"actions.sendReply": "Invia risposta",
"actions.setPriority": "Imposta priorità",
"actions.setSla": "Imposta SLA",
@@ -39,14 +38,6 @@
"activityLog.rolePermissionsAdded": "{actorEmail} ({actorId}) ha aggiunto il/i permesso/i {permissions} al ruolo {roleName} ({roleId})",
"activityLog.rolePermissionsChanged": "{actorEmail} ({actorId}) ha rimosso il/i permesso/i {removed} e aggiunto il/i permesso/i {added} al ruolo {roleName} ({roleId})",
"activityLog.rolePermissionsRemoved": "{actorEmail} ({actorId}) ha rimosso il/i permesso/i {permissions} dal ruolo {roleName} ({roleId})",
- "activityLog.type": "Tipo di log attività",
- "activityLog.type.agentAway": "Agente assente",
- "activityLog.type.agentAwayReassigned": "Agente assente riassegnato",
- "activityLog.type.agentLogin": "Accesso agente",
- "activityLog.type.agentLogout": "Uscita agente",
- "activityLog.type.agentOnline": "Agente online",
- "activityLog.type.agentPasswordSet": "Password agente impostata",
- "activityLog.type.agentRolePermissionsChanged": "Permessi del ruolo dell'agente modificati",
"admin.agent.apiKey.description": "Genera chiavi API per questo agente per accedere a libredesk programmaticamente.",
"admin.agent.apiKey.noKey": "Nessuna chiave API è stata generata per questo agente.",
"admin.agent.apiKey.warningMessage": "Questo segreto verrà mostrato solo una volta. Assicurati di copiarlo ora.",
@@ -55,7 +46,6 @@
"admin.automation.all": "TUTTO",
"admin.automation.and": "E",
"admin.automation.any": "QUALSIASI",
- "admin.automation.below": "sotto",
"admin.automation.conversationUpdate": "Aggiornamento Della Conversazione",
"admin.automation.conversationUpdate.description": "Regole che vengono eseguite quando una conversazione è aggiornata.",
"admin.automation.evaluateRuleOnTheseEvents": "Valuta la regola su questi eventi.",
@@ -67,7 +57,6 @@
"admin.automation.executeFirstMatchingRule": "Esegui la prima regola di corrispondenza",
"admin.automation.help": "Automatizza azioni alla creazione, all'aggiornamento delle conversazioni o con una pianificazione oraria.",
"admin.automation.invalid": "Assicurati di avere almeno un'azione e una regola e che i loro valori non siano vuoti.",
- "admin.automation.match": "Corrispondenza",
"admin.automation.matchTheseRules": "Corrisponde a queste regole",
"admin.automation.newConversation.description": "Regole che vengono eseguite quando un contatto crea una nuova conversazione. Le conversazioni avviate dagli agenti non attivano queste regole. Trascina e rilascia per riordinare.",
"admin.automation.noRulesFound": "Nessuna regola trovata",
@@ -188,7 +177,6 @@
"admin.inbox.livechat.installation.instructions.step2": "Incollalo subito prima del tag di chiusura `body` su ogni pagina dove vuoi che appaia il widget di live chat",
"admin.inbox.livechat.installation.instructions.title": "Istruzioni per l'installazione",
"admin.inbox.livechat.installation.jsApi.description": "Controlla il widget programmaticamente dalla tua applicazione.",
- "admin.inbox.livechat.installation.jsApi.title": "JavaScript API",
"admin.inbox.livechat.introductionMessage": "Messaggio di introduzione",
"admin.inbox.livechat.language.auto": "Rilevamento automatico (lingua del browser)",
"admin.inbox.livechat.launcher": "Pulsante di avvio",
@@ -393,7 +381,6 @@
"auth.enterEmailForReset": "Inserisci la tua email per ricevere un link per il reset della password.",
"auth.enterNewPasswordTwice": "Inserisci la tua nuova password due volte per confermare.",
"auth.forgotPassword": "Hai dimenticato la password?",
- "auth.hidePassword": "Hide password",
"auth.invalidOrExpiredSession": "Sessione scaduta o non valida",
"auth.invalidOrExpiredSessionClearCookie": "Sessione non valida o scaduta. Si prega di cancellare i cookie e riprovare.",
"auth.invalidResetLink": "Link di ripristino non valido. Richiedi un nuovo link di ripristino password.",
@@ -407,7 +394,6 @@
"auth.sendResetLink": "Invia link di ripristino",
"auth.setNewPassword": "Imposta una nuova password",
"auth.settingPassword": "Imposta password...",
- "auth.showPassword": "Show password",
"auth.signIn": "Accedi al tuo account",
"auth.signInButton": "Accedi",
"automation.deletionConfirmation": "Questa azione non può essere annullata. Questa regola di automazione verrà eliminata permanentemente.",
@@ -455,7 +441,6 @@
"contact.unblockedSuccessfully": "Contatto sbloccato con successo",
"contact.updateContact": "Aggiorna contatto",
"contextLink.edit": "Modifica link contestuale",
- "contextLink.namePlaceholder": "My App",
"contextLink.new": "Nuovo link contestuale",
"contextLink.secret": "Segreto",
"contextLink.secretHelp": "Una chiave di 32 caratteri condivisa tra Libredesk e l'app esterna. Usata per crittografare il {'{{token}}'} in modo che solo l'app esterna possa leggere i dettagli del contatto. Non necessaria se si usano solo variabili in chiaro come {'{{email}}'}.",
@@ -527,7 +512,6 @@
"globals.messages.all": "Tutti {name}",
"globals.messages.apply": "Applica {name}",
"globals.messages.areYouAbsolutelySure": "Sei assolutamente sicuro?",
- "globals.messages.attachFile": "Attach file",
"globals.messages.back": "Indietro",
"globals.messages.backOnDayAt": "il {day} alle {time}",
"globals.messages.backTodayAt": "oggi alle {time}",
@@ -561,7 +545,6 @@
"globals.messages.errorUploadingFile": "Errore nel caricamento del file",
"globals.messages.false": "false",
"globals.messages.fileTypeisNotAnImage": "Il tipo di file non è un'immagine",
- "globals.messages.goBack": "Go back",
"globals.messages.golangDurationHoursMinutes": "Durata in ore o minuti. Esempio: 1h, 30m, 1h30m",
"globals.messages.gradientEnd": "Fine gradiente",
"globals.messages.gradientStart": "Inizio gradiente",
@@ -586,7 +569,6 @@
"globals.messages.noResultsFound": "Nessun risultato trovato",
"globals.messages.notAllowed": "{name} non consentito",
"globals.messages.notFound": "{name} non trovato",
- "globals.messages.options": "Options",
"globals.messages.pageNofTotal": "Pagina {page} di {total}",
"globals.messages.pageNotFound": "Pagina non trovata",
"globals.messages.pageTooLarge": "La dimensione della pagina è troppo grande, dovrebbe essere al massimo {max}",
@@ -631,7 +613,6 @@
"globals.messages.welcomeToLibredesk": "Benvenuti su Libredesk",
"globals.messages.wellBeBack": "Torneremo {when}",
"globals.messages.yes": "Si {name}",
- "globals.terms.account": "Account | Account",
"globals.terms.action": "Azione | Azioni",
"globals.terms.active": "Attivo",
"globals.terms.activityLog": "Log attività | Log attività",
@@ -650,7 +631,6 @@
"globals.terms.away": "Non disponibile | Non disponibile",
"globals.terms.awayReassigning": "Non disponibile, riassegnazione",
"globals.terms.background": "Sfondo",
- "globals.terms.beta": "Beta",
"globals.terms.black": "Nero",
"globals.terms.body": "Corpo",
"globals.terms.brandName": "Nome del marchio",
@@ -679,7 +659,6 @@
"globals.terms.description": "Descrizione | Descrizioni",
"globals.terms.disabled": "Disattivato",
"globals.terms.draft": "Bozza",
- "globals.terms.email": "Email | Email",
"globals.terms.enabled": "Attivo",
"globals.terms.error": "Errore | Errori",
"globals.terms.event": "Evento | Eventi",
@@ -687,8 +666,6 @@
"globals.terms.expand": "Espandi",
"globals.terms.fair": "Discreto",
"globals.terms.features": "Funzionalità",
- "globals.terms.feedback": "Feedback | Feedback",
- "globals.terms.file": "File | File",
"globals.terms.filter": "Filtro | Filtri",
"globals.terms.firstName": "Nome | Nomi",
"globals.terms.firstReplyAt": "Prima risposta alle",
@@ -701,7 +678,6 @@
"globals.terms.home": "Pagina iniziale",
"globals.terms.homeScreen": "Schermata iniziale",
"globals.terms.homeScreenApp": "App schermata iniziale | App schermata iniziale",
- "globals.terms.host": "Host | Host",
"globals.terms.hour": "Ora | Ore",
"globals.terms.image": "Immagine | Immagini",
"globals.terms.import": "Importa | Importazioni",
@@ -727,7 +703,6 @@
"globals.terms.loading": "Caricamento...",
"globals.terms.log": "Uno | Registri",
"globals.terms.logoUrl": "URL del logo",
- "globals.terms.macro": "Macro | Macro",
"globals.terms.mention": "Menzione | Menzioni",
"globals.terms.message": "Messaggio | Messaggi",
"globals.terms.microsoft": "Microsoft ",
@@ -746,7 +721,6 @@
"globals.terms.overview": "Panoramica",
"globals.terms.page": "Pagina | Pagine",
"globals.terms.participant": "Partecipante | Partecipanti",
- "globals.terms.password": "Password | Password",
"globals.terms.pending": "In sospeso",
"globals.terms.phoneNumber": "Numero di telefono | Numeri di telefono",
"globals.terms.pickDate": "Seleziona una data",
@@ -762,7 +736,6 @@
"globals.terms.providerURL": "URL Fornitore",
"globals.terms.reconnect": "Riconnetti",
"globals.terms.referenceNumber": "Numero di riferimento",
- "globals.terms.regex": "Regex | Regex",
"globals.terms.regexHint": "Suggerimento Regex",
"globals.terms.reply": "Risposta | Risposte",
"globals.terms.report": "Relazione | Relazioni",
@@ -779,7 +752,6 @@
"globals.terms.sendWelcomeEmail": "Invia una e-mail di benvenuto | Invia e-mail di benvenuto",
"globals.terms.setPassword": "Imposta password | Imposta le password",
"globals.terms.sharedView": "Vista condivisa | Viste condivise",
- "globals.terms.sla": "SLA | Contratti di servizio",
"globals.terms.slaMetric": "Metriche di utilizzo | Metriche di utilizzo",
"globals.terms.slaPolicy": "Politica SLA | Politiche SLA",
"globals.terms.smtpHost": "Host SMTP | Hosts SMTP",
@@ -787,11 +759,8 @@
"globals.terms.snooze": "Posticipa",
"globals.terms.solid": "Tinta unita",
"globals.terms.someone": "Qualcuno",
- "globals.terms.sso": "SSO | SSO",
"globals.terms.status": "Stato | Stati",
"globals.terms.subject": "Oggetto | Oggetti",
- "globals.terms.tag": "Tag | Tag",
- "globals.terms.team": "Squadra | Teams",
"globals.terms.teamInbox": "Casella Team | Caselle Team",
"globals.terms.teammate": "Compagno di team | Compagni di team",
"globals.terms.tellUsMore": "Raccontaci di più...",
@@ -801,7 +770,6 @@
"globals.terms.timestamp": "Data e ora | Data e ora",
"globals.terms.timezone": "Fuso orario | Fusi orario",
"globals.terms.title": "Titolo | Titoli",
- "globals.terms.tls": "TLS | TLS",
"globals.terms.today": "Oggi",
"globals.terms.tryAgain": "Prova ancora",
"globals.terms.type": "Tipologia | Tipologie",
@@ -809,17 +777,14 @@
"globals.terms.unAuthorized": "Non autorizzato",
"globals.terms.unassigned": "Non assegnato",
"globals.terms.updatedAt": "Aggiornato Alle",
- "globals.terms.url": "URL | URL",
"globals.terms.usage": "Utilizzo",
"globals.terms.username": "Nome utente | Nomi utente",
"globals.terms.users": "Utenti",
"globals.terms.value": "Valore | Valori",
- "globals.terms.video": "Video | Video",
"globals.terms.view": "Vista | Viste",
"globals.terms.visibility": "Visibilità | Visibilità",
"globals.terms.visitor": "Visitatore | Visitatori",
"globals.terms.warning": "Attenzione | Avvisi",
- "globals.terms.webhook": "Webhook | Webhook",
"globals.terms.week": "Settimana | Settimane",
"globals.terms.white": "Bianco",
"globals.terms.workspace": "Area di lavoro",
@@ -1047,4 +1012,4 @@
"widget.conversationClosed": "Questa conversazione è stata chiusa",
"widget.ipBlocked": "Accesso negato",
"widget.prechatForm.startChat": "Avvia chat"
-}
+}
diff --git a/i18n/ja-JP.json b/i18n/ja-JP.json
index cc1f71c5..7917f461 100644
--- a/i18n/ja-JP.json
+++ b/i18n/ja-JP.json
@@ -19,7 +19,6 @@
"actions.removeLink": "リンクを削除",
"actions.removeTags": "タグを削除",
"actions.sendCsat": "CSATを送信",
- "actions.sendPrivateNote": "非公開ノートを送信",
"actions.sendReply": "返信を送信",
"actions.setPriority": "優先度を設定",
"actions.setSla": "SLAを設定",
@@ -39,14 +38,6 @@
"activityLog.rolePermissionsAdded": "{actorEmail} ({actorId}) がロール {roleName} ({roleId}) に権限 {permissions} を追加しました",
"activityLog.rolePermissionsChanged": "{actorEmail} ({actorId}) がロール {roleName} ({roleId}) の権限 {removed} を削除し、権限 {added} を追加しました",
"activityLog.rolePermissionsRemoved": "{actorEmail} ({actorId}) がロール {roleName} ({roleId}) から権限 {permissions} を削除しました",
- "activityLog.type": "アクティビティログの種類",
- "activityLog.type.agentAway": "担当者が離席しました",
- "activityLog.type.agentAwayReassigned": "担当者が離席し再割り当てをしました",
- "activityLog.type.agentLogin": "担当者がログインしました",
- "activityLog.type.agentLogout": "担当者がログアウトしました",
- "activityLog.type.agentOnline": "担当者がオンラインになりました",
- "activityLog.type.agentPasswordSet": "担当者がパスワードをセットしました",
- "activityLog.type.agentRolePermissionsChanged": "担当者のロール権限が変更されました",
"admin.agent.apiKey.description": "このエージェントが LibreDesk にプログラムからアクセスできるように API キーを生成します。",
"admin.agent.apiKey.noKey": "この担当者にはまだ API キーが生成されていません。",
"admin.agent.apiKey.warningMessage": "このシークレットは一度だけ表示されます。今すぐコピーしてください。",
@@ -55,7 +46,6 @@
"admin.automation.all": "すべて",
"admin.automation.and": "および",
"admin.automation.any": "いずれか",
- "admin.automation.below": "以下",
"admin.automation.conversationUpdate": "会話の更新",
"admin.automation.conversationUpdate.description": "会話が更新された際に実行されるルール。",
"admin.automation.evaluateRuleOnTheseEvents": "これらのイベントでルールを評価します。",
@@ -67,7 +57,6 @@
"admin.automation.executeFirstMatchingRule": "条件に合致した最初のルールのみを実行",
"admin.automation.help": "会話の作成時、更新時、または1時間ごとのスケジュールでアクションを自動実行します。",
"admin.automation.invalid": "少なくとも1つのアクションと1つのルールを設定し、それぞれの値が空でないことを確認してください。",
- "admin.automation.match": "一致",
"admin.automation.matchTheseRules": "これらのルールに一致",
"admin.automation.newConversation.description": "連絡先が新しい会話を作成した際に実行されるルールです。エージェントが開始した会話ではこれらのルールは実行されません。ドラッグ&ドロップで順序を変更できます。",
"admin.automation.noRulesFound": "ルールが見つかりません",
@@ -188,7 +177,6 @@
"admin.inbox.livechat.installation.instructions.step2": "ライブチャットウィジェットを表示したいすべてのページの `body` 閉じタグの直前に貼り付けます",
"admin.inbox.livechat.installation.instructions.title": "インストール手順",
"admin.inbox.livechat.installation.jsApi.description": "アプリケーションからウィジェットをプログラムで制御します。",
- "admin.inbox.livechat.installation.jsApi.title": "JavaScript API",
"admin.inbox.livechat.introductionMessage": "紹介メッセージ",
"admin.inbox.livechat.language.auto": "自動検出(ブラウザの言語)",
"admin.inbox.livechat.launcher": "ランチャー",
@@ -393,7 +381,6 @@
"auth.enterEmailForReset": "パスワードリセットリンクを受け取るには、メールアドレスを入力してください。",
"auth.enterNewPasswordTwice": "新しいパスワードを二度入力して確認してください。",
"auth.forgotPassword": "パスワードをお忘れですか?",
- "auth.hidePassword": "Hide password",
"auth.invalidOrExpiredSession": "セッションが無効、または期限切れです",
"auth.invalidOrExpiredSessionClearCookie": "無効または期限切れのセッションです。クッキーを消去して、もう一度やり直してください。",
"auth.invalidResetLink": "無効なリセットリンクです。新しいパスワードリセットリンクをリクエストしてください。",
@@ -407,7 +394,6 @@
"auth.sendResetLink": "リセット用リンクを送信",
"auth.setNewPassword": "新しいパスワードを設定します",
"auth.settingPassword": "パスワードを設定中…",
- "auth.showPassword": "Show password",
"auth.signIn": "アカウントにサインインしてください",
"auth.signInButton": "サインイン",
"automation.deletionConfirmation": "この操作は元に戻せません。このオートメーションルールは完全に削除されます。",
@@ -455,7 +441,6 @@
"contact.unblockedSuccessfully": "連絡先のブロックを解除しました",
"contact.updateContact": "連絡先を更新",
"contextLink.edit": "コンテキストリンクを編集",
- "contextLink.namePlaceholder": "My App",
"contextLink.new": "新しいコンテキストリンク",
"contextLink.secret": "シークレット",
"contextLink.secretHelp": "Libredeskと外部アプリ間で共有される32文字のキーです。{'{{token}}'} を暗号化して外部アプリだけがコンタクト情報を読み取れるようにします。{'{{email}}'} などのプレーン変数のみを使用する場合は不要です。",
@@ -504,7 +489,6 @@
"customAttribute.deletionConfirmation": "この操作は元に戻せません。このカスタム属性は完全に削除されます。",
"customAttribute.edit": "カスタム属性を編集",
"customAttribute.new": "新規カスタム属性",
- "dateFilter.lastNDays": "過去 {n} 日間",
"dateFilter.selectDays": "日数を選択",
"editor.addLinkUrl": "リンクURLを追加",
"editor.editLinkUrl": "リンクURLを編集",
@@ -527,7 +511,6 @@
"globals.messages.all": "すべての {name}",
"globals.messages.apply": "{name} を適用",
"globals.messages.areYouAbsolutelySure": "本当によろしいですか?",
- "globals.messages.attachFile": "Attach file",
"globals.messages.back": "戻る",
"globals.messages.backOnDayAt": "{day} {time}",
"globals.messages.backTodayAt": "本日 {time}",
@@ -561,7 +544,6 @@
"globals.messages.errorUploadingFile": "ファイルのアップロードエラー",
"globals.messages.false": "false",
"globals.messages.fileTypeisNotAnImage": "ファイルの種類が画像ではありません",
- "globals.messages.goBack": "Go back",
"globals.messages.golangDurationHoursMinutes": "時間または分で期間を指定します。例:1h、30m、1h30m",
"globals.messages.gradientEnd": "グラデーション終了",
"globals.messages.gradientStart": "グラデーション開始",
@@ -586,7 +568,6 @@
"globals.messages.noResultsFound": "結果が見つかりません",
"globals.messages.notAllowed": "{name} は許可されていません",
"globals.messages.notFound": "{name} が見つかりません",
- "globals.messages.options": "Options",
"globals.messages.pageNofTotal": "{total}ページ中{page}ページ",
"globals.messages.pageNotFound": "ページが見つかりません",
"globals.messages.pageTooLarge": "ページサイズが大きすぎます。 {max} 以下にしてください",
@@ -631,89 +612,88 @@
"globals.messages.welcomeToLibredesk": "Libredesk へようこそ",
"globals.messages.wellBeBack": "{when} に戻ります",
"globals.messages.yes": "はい、{name}",
- "globals.terms.account": "アカウント",
- "globals.terms.action": "アクション",
+ "globals.terms.account": "アカウント | アカウント",
+ "globals.terms.action": "アクション | アクション",
"globals.terms.active": "有効",
- "globals.terms.activityLog": "アクティビティログ",
- "globals.terms.actor": "操作担当者",
- "globals.terms.admin": "管理者",
- "globals.terms.agent": "担当者",
- "globals.terms.alert": "アラート",
- "globals.terms.apiKey": "API キー",
+ "globals.terms.activityLog": "アクティビティログ | アクティビティログ",
+ "globals.terms.actor": "操作担当者 | 操作担当者",
+ "globals.terms.admin": "管理者 | 管理者",
+ "globals.terms.agent": "担当者 | 担当者",
+ "globals.terms.alert": "アラート | アラート",
+ "globals.terms.apiKey": "API キー | API キー",
"globals.terms.appliesTo": "適用先",
"globals.terms.ascending": "昇順",
"globals.terms.audio": "オーディオ",
- "globals.terms.automation": "自動化",
+ "globals.terms.automation": "自動化 | 自動化",
"globals.terms.availabilityStatus": "対応状況",
"globals.terms.awaitingResponse": "回答待ち",
- "globals.terms.away": "離席中",
+ "globals.terms.away": "離席中 | 離席中",
"globals.terms.awayReassigning": "離席と再割り当て",
"globals.terms.background": "背景",
"globals.terms.beta": "ベータ",
"globals.terms.black": "黒",
"globals.terms.body": "本文",
"globals.terms.brandName": "ブランド名",
- "globals.terms.businessHour": "営業時間",
+ "globals.terms.businessHour": "営業時間 | 営業時間",
"globals.terms.callbackURL": "コールバック URL",
"globals.terms.channel": "チャンネル",
"globals.terms.clientID": "クライアントID",
"globals.terms.clientSecret": "クライアントシークレット",
"globals.terms.closedAt": "クローズ日時",
"globals.terms.collapse": "折りたたむ",
- "globals.terms.contact": "連絡先",
- "globals.terms.content": "コンテンツ",
- "globals.terms.contextLink": "コンテキストリンク",
+ "globals.terms.contact": "連絡先 | 連絡先",
+ "globals.terms.content": "コンテンツ | コンテンツ",
+ "globals.terms.contextLink": "コンテキストリンク | コンテキストリンク",
"globals.terms.continue": "続ける",
- "globals.terms.conversation": "会話",
+ "globals.terms.conversation": "会話 | 会話",
"globals.terms.copy": "コピー",
"globals.terms.country": "国",
"globals.terms.createdAt": "作成日時",
"globals.terms.createdOn": "作成日時",
"globals.terms.custom": "カスタム",
- "globals.terms.customAttribute": "カスタム属性",
+ "globals.terms.customAttribute": "カスタム属性 | カスタム属性",
"globals.terms.date": "日付",
- "globals.terms.day": "日",
+ "globals.terms.day": "日 | 日",
"globals.terms.default": "デフォルト",
"globals.terms.descending": "降順",
- "globals.terms.description": "説明",
+ "globals.terms.description": "説明 | 説明",
"globals.terms.disabled": "無効",
"globals.terms.draft": "下書き",
- "globals.terms.email": "Eメール",
+ "globals.terms.email": "Eメール | Eメール",
"globals.terms.enabled": "有効",
- "globals.terms.error": "エラー",
- "globals.terms.event": "イベント",
+ "globals.terms.error": "エラー | エラー",
+ "globals.terms.event": "イベント | イベント",
"globals.terms.excellent": "優秀",
"globals.terms.expand": "展開",
"globals.terms.fair": "普通",
"globals.terms.features": "機能",
- "globals.terms.file": "ファイル",
- "globals.terms.filter": "フィルター",
- "globals.terms.firstName": "姓",
+ "globals.terms.file": "ファイル | ファイル",
+ "globals.terms.filter": "フィルター | フィルター",
+ "globals.terms.firstName": "姓 | 姓",
"globals.terms.firstReplyAt": "最初の返信日時",
- "globals.terms.fromEmailAddress": "送信元メールアドレス",
+ "globals.terms.fromEmailAddress": "送信元メールアドレス | 送信元メールアドレス",
"globals.terms.general": "一般",
"globals.terms.good": "満足",
- "globals.terms.google": "Google",
"globals.terms.gradient": "グラデーション",
"globals.terms.great": "良い",
"globals.terms.home": "ホーム",
"globals.terms.homeScreen": "ホーム画面",
- "globals.terms.host": "ホスト",
- "globals.terms.hour": "時間",
- "globals.terms.image": "画像",
- "globals.terms.import": "インポート",
- "globals.terms.inactive": "非アクティブ",
- "globals.terms.inbox": "受信トレイ",
+ "globals.terms.host": "ホスト | ホスト",
+ "globals.terms.hour": "時間 | 時間",
+ "globals.terms.image": "画像 | 画像",
+ "globals.terms.import": "インポート | インポート",
+ "globals.terms.inactive": "非アクティブ | 非アクティブ",
+ "globals.terms.inbox": "受信トレイ | 受信トレイ",
"globals.terms.initiatedAt": "開始日時",
- "globals.terms.integration": "インテグレーション",
- "globals.terms.ipAddress": "IP アドレス",
+ "globals.terms.integration": "インテグレーション | インテグレーション",
+ "globals.terms.ipAddress": "IP アドレス | IP アドレス",
"globals.terms.isDefault": "デフォルトかどうか",
- "globals.terms.key": "キー",
- "globals.terms.language": "言語",
+ "globals.terms.key": "キー | キー",
+ "globals.terms.language": "言語 | 言語",
"globals.terms.lastActive": "最終活動日時",
"globals.terms.lastLogin": "最終ログイン",
"globals.terms.lastMessageAt": "最終メッセージ日時",
- "globals.terms.lastName": "名",
+ "globals.terms.lastName": "名 | 名",
"globals.terms.lastReplyAt": "最終返信日時",
"globals.terms.lastUpdated": "最終更新日時",
"globals.terms.learnMore": "詳細を見る",
@@ -721,96 +701,96 @@
"globals.terms.liveChat": "ライブチャット",
"globals.terms.loadMore": "さらに読み込む",
"globals.terms.loading": "読み込み中…",
- "globals.terms.log": "ログ",
+ "globals.terms.log": "ログ | ログ",
"globals.terms.logoUrl": "ロゴURL",
- "globals.terms.macro": "マクロ",
- "globals.terms.mention": "あなた宛て",
- "globals.terms.message": "メッセージ",
- "globals.terms.microsoft": "Microsoft",
- "globals.terms.myInbox": "受信トレイ",
- "globals.terms.name": "名前",
+ "globals.terms.macro": "マクロ | マクロ",
+ "globals.terms.mention": "あなた宛て | あなた宛て",
+ "globals.terms.message": "メッセージ | メッセージ",
+ "globals.terms.myInbox": "受信トレイ | 受信トレイ",
+ "globals.terms.name": "名前 | 名前",
"globals.terms.none": "なし",
- "globals.terms.note": "ノート",
- "globals.terms.notification": "通知",
+ "globals.terms.note": "ノート | ノート",
+ "globals.terms.notification": "通知 | 通知",
"globals.terms.off": "オフ",
- "globals.terms.offline": "オフライン",
- "globals.terms.online": "オンライン",
+ "globals.terms.offline": "オフライン | オフライン",
+ "globals.terms.online": "オンライン | オンライン",
"globals.terms.open": "オープン",
"globals.terms.openMenu": "メニューを開く",
- "globals.terms.optional": "オプション",
+ "globals.terms.optional": "オプション | オプション",
"globals.terms.overdue": "期限超過",
"globals.terms.overview": "概要",
- "globals.terms.page": "ページ",
- "globals.terms.participant": "参加者",
- "globals.terms.password": "パスワード",
+ "globals.terms.page": "ページ | ページ",
+ "globals.terms.participant": "参加者 | 参加者",
+ "globals.terms.password": "パスワード | パスワード",
"globals.terms.pending": "保留中",
- "globals.terms.phoneNumber": "電話番号",
+ "globals.terms.phoneNumber": "電話番号 | 電話番号",
"globals.terms.pickDate": "日付を選択",
"globals.terms.poor": "不満",
- "globals.terms.port": "ポート",
- "globals.terms.primaryColor": "プライマリカラー",
- "globals.terms.priority": "優先度",
- "globals.terms.privateNote": "非公開ノート",
+ "globals.terms.port": "ポート | ポート",
+ "globals.terms.primaryColor": "プライマリカラー | プライマリカラー",
+ "globals.terms.priority": "優先度 | 優先度",
+ "globals.terms.privateNote": "非公開ノート | 非公開ノート",
"globals.terms.productivity": "生産性",
- "globals.terms.profile": "プロファイル",
- "globals.terms.provider": "プロバイダー",
+ "globals.terms.profile": "プロファイル | プロファイル",
+ "globals.terms.provider": "プロバイダー | プロバイダー",
"globals.terms.providerURL": "プロバイダー URL",
"globals.terms.reconnect": "再接続",
"globals.terms.referenceNumber": "参照番号",
- "globals.terms.regex": "正規表現",
+ "globals.terms.regex": "正規表現 | 正規表現",
"globals.terms.regexHint": "正規表現のヒント",
- "globals.terms.reply": "返信",
- "globals.terms.report": "レポート",
+ "globals.terms.reply": "返信 | 返信",
+ "globals.terms.report": "レポート | レポート",
"globals.terms.required": "必須",
"globals.terms.resolve": "解決",
"globals.terms.resolvedAt": "解決日時",
- "globals.terms.role": "役割",
+ "globals.terms.role": "役割 | 役割",
"globals.terms.rootURL": "ルート URL",
"globals.terms.search": "検索",
- "globals.terms.secondaryColor": "セカンダリカラー",
- "globals.terms.secret": "シークレット",
+ "globals.terms.secondaryColor": "セカンダリカラー | セカンダリカラー",
+ "globals.terms.secret": "シークレット | シークレット",
"globals.terms.security": "セキュリティ",
"globals.terms.select": "選択",
- "globals.terms.sendWelcomeEmail": "ウェルカムメールを送信しますか?",
- "globals.terms.setPassword": "パスワードの設定",
- "globals.terms.sharedView": "共有ビュー",
- "globals.terms.sla": "SLA",
- "globals.terms.slaPolicy": "SLAポリシー",
- "globals.terms.smtpHost": "SMTP ホスト",
- "globals.terms.smtpPort": "SMTP ポート",
+ "globals.terms.sendWelcomeEmail": "ウェルカムメールを送信しますか? | ウェルカムメールを送信しますか?",
+ "globals.terms.setPassword": "パスワードの設定 | パスワードの設定",
+ "globals.terms.sharedView": "共有ビュー | 共有ビュー",
+ "globals.terms.sla": "SLA | SLA",
+ "globals.terms.slaMetric": "SLA指標 | SLA指標",
+ "globals.terms.slaPolicy": "SLAポリシー | SLAポリシー",
+ "globals.terms.smtpHost": "SMTP ホスト | SMTP ホスト",
+ "globals.terms.smtpPort": "SMTP ポート | SMTP ポート",
"globals.terms.snooze": "スヌーズ",
"globals.terms.solid": "単色",
"globals.terms.someone": "誰か",
- "globals.terms.sso": "SSO",
- "globals.terms.status": "ステータス",
- "globals.terms.subject": "件名",
- "globals.terms.tag": "タグ",
- "globals.terms.team": "チーム",
- "globals.terms.teamInbox": "チーム受信トレイ",
- "globals.terms.teammate": "チームメンバー",
+ "globals.terms.sso": "SSO | SSO",
+ "globals.terms.status": "ステータス | ステータス",
+ "globals.terms.subject": "件名 | 件名",
+ "globals.terms.tag": "タグ | タグ",
+ "globals.terms.team": "チーム | チーム",
+ "globals.terms.teamInbox": "チーム受信トレイ | チーム受信トレイ",
+ "globals.terms.teammate": "チームメンバー | チームメンバー",
"globals.terms.tellUsMore": "詳しく教えてください...",
- "globals.terms.template": "テンプレート",
+ "globals.terms.template": "テンプレート | テンプレート",
"globals.terms.tenantID": "テナントID",
"globals.terms.time": "時間",
- "globals.terms.timestamp": "タイムスタンプ",
- "globals.terms.timezone": "タイムゾーン",
- "globals.terms.tls": "TLS",
+ "globals.terms.timestamp": "タイムスタンプ | タイムスタンプ",
+ "globals.terms.timezone": "タイムゾーン | タイムゾーン",
+ "globals.terms.tls": "TLS | TLS",
"globals.terms.today": "今日",
"globals.terms.tryAgain": "もう一度試してください",
- "globals.terms.type": "タイプ",
+ "globals.terms.type": "タイプ | タイプ",
"globals.terms.typeMessage": "メッセージを入力してください...",
"globals.terms.unAuthorized": "権限がありません",
"globals.terms.unassigned": "未割り当て",
"globals.terms.updatedAt": "更新日時",
- "globals.terms.url": "URL",
+ "globals.terms.url": "URL | URL",
"globals.terms.usage": "利用状況",
- "globals.terms.username": "ユーザー名",
+ "globals.terms.username": "ユーザー名 | ユーザー名",
"globals.terms.users": "ユーザー",
- "globals.terms.value": "値",
- "globals.terms.view": "表示",
- "globals.terms.visibility": "公開範囲",
- "globals.terms.warning": "警告",
- "globals.terms.webhook": "Webhook",
+ "globals.terms.value": "値 | 値",
+ "globals.terms.view": "表示 | 表示",
+ "globals.terms.visibility": "公開範囲 | 公開範囲",
+ "globals.terms.warning": "警告 | 警告",
+ "globals.terms.webhook": "Webhook | Webhook",
"globals.terms.white": "白",
"globals.terms.workspace": "ワークスペース",
"globals.terms.you": "あなた",
@@ -889,7 +869,6 @@
"placeholders.selectValue": "値を選択",
"placeholders.startConversation": "会話を開始",
"placeholders.tellUsAboutYourself": "自己紹介をお願いします",
- "replyBox.bcc": "BCC",
"replyBox.contactEmailMissing": "連絡先のメールが宛先にありません",
"replyBox.contactEmailMissingDescription": "連絡先のメール ({email}) が宛先、CC、BCCに含まれていません。連絡先はこの返信を受け取れません。",
"replyBox.emailAddresess": "メールアドレスはカンマで区切って入力してください",
@@ -1037,4 +1016,4 @@
"widget.conversationClosed": "この会話はクローズされました",
"widget.ipBlocked": "アクセスが拒否されました",
"widget.prechatForm.startChat": "チャットを開始"
-}
+}
diff --git a/i18n/mr-IN.json b/i18n/mr-IN.json
index c0188639..0c8c08fb 100644
--- a/i18n/mr-IN.json
+++ b/i18n/mr-IN.json
@@ -19,7 +19,6 @@
"actions.removeLink": "लिंक काढा",
"actions.removeTags": "टॅग्ज काढा",
"actions.sendCsat": "CSAT पाठवा",
- "actions.sendPrivateNote": "खाजगी टीप पाठवा",
"actions.sendReply": "उत्तर पाठवा",
"actions.setPriority": "प्राधान्य सेट करा",
"actions.setSla": "SLA सेट करा",
@@ -39,14 +38,6 @@
"activityLog.rolePermissionsAdded": "{actorEmail} ({actorId}) ने भूमिका {roleName} ({roleId}) ला परवानगी(ा) {permissions} जोडल्या",
"activityLog.rolePermissionsChanged": "{actorEmail} ({actorId}) ने भूमिका {roleName} ({roleId}) मधून परवानगी(ा) {removed} काढल्या आणि परवानगी(ा) {added} जोडल्या",
"activityLog.rolePermissionsRemoved": "{actorEmail} ({actorId}) ने भूमिका {roleName} ({roleId}) मधून परवानगी(ा) {permissions} काढल्या",
- "activityLog.type": "अॅक्टिव्हिटी लॉग प्रकार",
- "activityLog.type.agentAway": "एजंट दूर",
- "activityLog.type.agentAwayReassigned": "एजंट दूर पुन्हा नियुक्त",
- "activityLog.type.agentLogin": "एजंट लॉगिन",
- "activityLog.type.agentLogout": "एजंट लॉगआउट",
- "activityLog.type.agentOnline": "एजंट ऑनलाइन",
- "activityLog.type.agentPasswordSet": "एजंट पासवर्ड सेट",
- "activityLog.type.agentRolePermissionsChanged": "एजंट भूमिका परवानग्या बदलल्या",
"admin.agent.apiKey.description": "या एजंटसाठी Libredesk प्रोग्रामॅटिकली प्रवेश करण्यासाठी API की निर्माण करा.",
"admin.agent.apiKey.noKey": "या एजंटसाठी कोणतीही API की निर्माण केलेली नाही.",
"admin.agent.apiKey.warningMessage": "हे गुप्त फक्त एकदाच दाखवले जाईल. आता ते कॉपी करण्याचे सुनिश्चित करा.",
@@ -55,7 +46,6 @@
"admin.automation.all": "सर्व",
"admin.automation.and": "आणि",
"admin.automation.any": "कोणतेही",
- "admin.automation.below": "खाली",
"admin.automation.conversationUpdate": "संभाषण अपडेट",
"admin.automation.conversationUpdate.description": "संभाषण अपडेट झाल्यावर चालणारे नियम.",
"admin.automation.evaluateRuleOnTheseEvents": "या इव्हेंटवर नियम मूल्यांकन करा.",
@@ -67,7 +57,6 @@
"admin.automation.executeFirstMatchingRule": "प्रथम जुळणारा नियम एक्झिक्यूट करा",
"admin.automation.help": "संभाषणे तयार झाल्यावर, अपडेट झाल्यावर किंवा तासाच्या वेळापत्रकानुसार क्रिया स्वयंचलित करा.",
"admin.automation.invalid": "किमान एक क्रिया आणि एक नियम असल्याचे सुनिश्चित करा.",
- "admin.automation.match": "जुळणी",
"admin.automation.matchTheseRules": "ह्या नियमांशी जुळवा",
"admin.automation.newConversation.description": "संपर्काने नवीन संभाषण तयार केल्यावर चालणारे नियम. एजंटने सुरू केलेल्या संभाषणांवर हे नियम लागू होत नाहीत. क्रम बदलण्यासाठी ड्रॅग आणि ड्रॉप करा.",
"admin.automation.noRulesFound": "कोणतेही नियम सापडले नाहीत",
@@ -188,7 +177,6 @@
"admin.inbox.livechat.installation.instructions.step2": "लाइव्ह चॅट विजेट दाखवायचे असलेल्या प्रत्येक पृष्ठावर `body` टॅग बंद होण्यापूर्वी पेस्ट करा",
"admin.inbox.livechat.installation.instructions.title": "इन्स्टॉलेशन सूचना",
"admin.inbox.livechat.installation.jsApi.description": "तुमच्या अॅप्लिकेशनमधून विजेट प्रोग्रामॅटिकली नियंत्रित करा.",
- "admin.inbox.livechat.installation.jsApi.title": "JavaScript API",
"admin.inbox.livechat.introductionMessage": "परिचय संदेश",
"admin.inbox.livechat.language.auto": "स्वयं-शोध (ब्राउझर भाषा)",
"admin.inbox.livechat.launcher": "लाँचर",
@@ -393,7 +381,6 @@
"auth.enterEmailForReset": "पासवर्ड रीसेट लिंक मिळविण्यासाठी ईमेल प्रविष्ट करा.",
"auth.enterNewPasswordTwice": "कृपया पुष्टीकरणासाठी नवीन पासवर्ड दोनदा प्रविष्ट करा.",
"auth.forgotPassword": "पासवर्ड विसरलात?",
- "auth.hidePassword": "Hide password",
"auth.invalidOrExpiredSession": "अवैध किंवा कालबाह्य सत्र",
"auth.invalidOrExpiredSessionClearCookie": "अवैध किंवा कालबाह्य सत्र. कृपया तुमच्या कुकीज साफ करा आणि पुन्हा प्रयत्न करा.",
"auth.invalidResetLink": "अवैध रीसेट लिंक. कृपया नवीन पासवर्ड रीसेट लिंकची विनंती करा.",
@@ -407,7 +394,6 @@
"auth.sendResetLink": "रीसेट लिंक पाठवा",
"auth.setNewPassword": "नवीन पासवर्ड सेट करा",
"auth.settingPassword": "पासवर्ड सेट करत आहे...",
- "auth.showPassword": "Show password",
"auth.signIn": "तुमच्या खात्यात प्रवेश करा",
"auth.signInButton": "साइन इन",
"automation.deletionConfirmation": "ही क्रिया पूर्ववत करता येणार नाही. हा स्वयंचलन नियम कायमचा हटवला जाईल.",
@@ -455,7 +441,6 @@
"contact.unblockedSuccessfully": "संपर्क यशस्वीरित्या अनब्लॉक केला",
"contact.updateContact": "संपर्क अपडेट करा",
"contextLink.edit": "संदर्भ दुवा संपादित करा",
- "contextLink.namePlaceholder": "My App",
"contextLink.new": "नवीन संदर्भ दुवा",
"contextLink.secret": "गुपित",
"contextLink.secretHelp": "Libredesk आणि बाह्य अॅप यांमध्ये सामायिक केलेली ३२ अक्षरांची की. {'{{token}}'} एन्क्रिप्ट करण्यासाठी वापरली जाते जेणेकरून फक्त बाह्य अॅपच संपर्क तपशील वाचू शकेल. तुम्ही फक्त {'{{email}}'} सारखे साधे व्हेरिएबल्स वापरत असाल तर आवश्यक नाही.",
@@ -527,7 +512,6 @@
"globals.messages.all": "सर्व {name}",
"globals.messages.apply": "{name} लागू करा",
"globals.messages.areYouAbsolutelySure": "तुम्हाला नक्की खात्री आहे का?",
- "globals.messages.attachFile": "Attach file",
"globals.messages.back": "मागे",
"globals.messages.backOnDayAt": "{day} रोजी {time} वाजता",
"globals.messages.backTodayAt": "आज {time} वाजता",
@@ -561,7 +545,6 @@
"globals.messages.errorUploadingFile": "फाइल अपलोड करताना त्रुटी",
"globals.messages.false": "असत्य",
"globals.messages.fileTypeisNotAnImage": "फाइल इमेज नाही",
- "globals.messages.goBack": "Go back",
"globals.messages.golangDurationHoursMinutes": "तास किंवा मिनिटांमध्ये कालावधी. उदाहरण: 1h, 30m, 1h30m",
"globals.messages.gradientEnd": "ग्रेडिएंट शेवट",
"globals.messages.gradientStart": "ग्रेडिएंट सुरुवात",
@@ -586,7 +569,6 @@
"globals.messages.noResultsFound": "कोणतेही निकाल सापडले नाहीत",
"globals.messages.notAllowed": "{name} परवानगी नाही",
"globals.messages.notFound": "{name} सापडले नाही",
- "globals.messages.options": "Options",
"globals.messages.pageNofTotal": "पृष्ठ {page} पैकी {total}",
"globals.messages.pageNotFound": "पृष्ठ सापडले नाही",
"globals.messages.pageTooLarge": "पृष्ठ आकार खूप मोठा आहे, जास्तीत जास्त {max} असावा",
@@ -1047,4 +1029,4 @@
"widget.conversationClosed": "हे संभाषण बंद करण्यात आले आहे",
"widget.ipBlocked": "प्रवेश नाकारला",
"widget.prechatForm.startChat": "चॅट सुरू करा"
-}
+}
diff --git a/i18n/pt-BR.json b/i18n/pt-BR.json
index 96735c62..3c2398e7 100644
--- a/i18n/pt-BR.json
+++ b/i18n/pt-BR.json
@@ -1,6 +1,6 @@
{
"_.code": "pt-BR",
- "_.name": "Português",
+ "_.name": "Português (Brasil)",
"account.avatarRemoved": "Avatar removido",
"account.changeAvatar": "Mude seu avatar aqui.",
"account.chooseAFile": "Escolha um arquivo...",
@@ -19,7 +19,6 @@
"actions.removeLink": "Remover link",
"actions.removeTags": "Remover etiquetas",
"actions.sendCsat": "Enviar CSAT",
- "actions.sendPrivateNote": "Enviar nota privada",
"actions.sendReply": "Enviar resposta",
"actions.setPriority": "Definir prioridade",
"actions.setSla": "Definir SLA",
@@ -39,14 +38,6 @@
"activityLog.rolePermissionsAdded": "{actorEmail} ({actorId}) adicionou permissão(ões) {permissions} ao cargo {roleName} ({roleId})",
"activityLog.rolePermissionsChanged": "{actorEmail} ({actorId}) removeu permissão(ões) {removed} e adicionou permissão(ões) {added} ao cargo {roleName} ({roleId})",
"activityLog.rolePermissionsRemoved": "{actorEmail} ({actorId}) removeu(s) permissão(ões) {permissions} do cargo {roleName} ({roleId})",
- "activityLog.type": "Tipo de log de atividade",
- "activityLog.type.agentAway": "Agente ausente",
- "activityLog.type.agentAwayReassigned": "Agente à distância reatribuído",
- "activityLog.type.agentLogin": "'Login' do agente",
- "activityLog.type.agentLogout": "'Logout' do agente",
- "activityLog.type.agentOnline": "Agente 'conectado'",
- "activityLog.type.agentPasswordSet": "Definir senha do agente",
- "activityLog.type.agentRolePermissionsChanged": "Permissões de papel de agente alteradas",
"admin.agent.apiKey.description": "Gerar chaves de API para este agente acessar libredesk programaticamente.",
"admin.agent.apiKey.noKey": "Nenhuma chave de API foi gerada para este agente.",
"admin.agent.apiKey.warningMessage": "Este segredo será mostrado apenas uma vez. Certifique-se de copiá-lo agora.",
@@ -55,7 +46,6 @@
"admin.automation.all": "Mensagem recebida",
"admin.automation.and": "Mensagem enviada",
"admin.automation.any": "Mudança de prioridade",
- "admin.automation.below": "abaixo",
"admin.automation.conversationUpdate": "Atualizar conversa",
"admin.automation.conversationUpdate.description": "Regras que são executadas quando uma conversa é atualizada.",
"admin.automation.evaluateRuleOnTheseEvents": "Avalie a regra sobre estes eventos.",
@@ -67,7 +57,6 @@
"admin.automation.executeFirstMatchingRule": "Executar primeira regra correspondente",
"admin.automation.help": "Automatize ações quando conversas são criadas, atualizadas ou a cada hora.",
"admin.automation.invalid": "Certifique-se de ter pelo menos uma ação e uma regra, e que seus valores não estejam vazios.",
- "admin.automation.match": "Corresponder",
"admin.automation.matchTheseRules": "Corresponder estas regras",
"admin.automation.newConversation.description": "Regras executadas quando uma nova conversa é criada por um contato. Conversas iniciadas por agentes não disparam essas regras. Arraste e solte para reordenar.",
"admin.automation.noRulesFound": "Nenhuma regra encontrada",
@@ -118,7 +107,6 @@
"admin.general.timezone.placeholder": "Selecione fuso horário",
"admin.inbox.authProtocol": "Protocolo de Autenticação",
"admin.inbox.authProtocol.description": "Protocolo de autenticação a usar.",
- "admin.inbox.authProtocol.login": "Login",
"admin.inbox.authProtocol.plain": "Simples",
"admin.inbox.chooseChannel": "Escolha o Canal",
"admin.inbox.createEmailInbox": "Criar uma caixa de entrada para suporte ao cliente baseado em e-mail",
@@ -193,7 +181,6 @@
"admin.inbox.livechat.installation.jsApi.title": "API JavaScript",
"admin.inbox.livechat.introductionMessage": "Mensagem de introdução",
"admin.inbox.livechat.language.auto": "Detecção automática (idioma do navegador)",
- "admin.inbox.livechat.launcher": "Launcher",
"admin.inbox.livechat.launcher.color": "Cor do launcher",
"admin.inbox.livechat.launcher.logo": "Logo do launcher",
"admin.inbox.livechat.launcher.position": "Posição",
@@ -268,7 +255,6 @@
"admin.inbox.promptTagsOnReply.description": "Avise agentes antes de enviarem uma resposta em uma conversa que não tem etiquetas. Os agentes ainda podem enviar sem etiquetar, confirmando o prompt.",
"admin.inbox.replyToAddress": "Endereço de resposta (opcional)",
"admin.inbox.replyToAddress.description": "Para onde as respostas do cliente vão. Necessário apenas quando o email de origem do SMTP e a caixa de correio do IMAP estão em domínios diferentes.",
- "admin.inbox.replyToAddress.placeholder": "support{'@'}example.com",
"admin.inbox.skipTLSVerification": "Ignorar verificação TLS",
"admin.inbox.skipTLSVerification.description": "Ignorar a verificação de nome do host no certificado TLS.",
"admin.inbox.smtpConfig": "Configuração SMTP",
@@ -363,12 +349,9 @@
"admin.tags.deleteConfirmation": "Tem certeza de que deseja excluir esta etiqueta? Isso também a removerá de todas as conversas",
"admin.team.assignmentType": "Tipo de atribuição automática",
"admin.team.assignmentType.description": "Round robin: as conversas são atribuídas aos membros da equipe em ordem circular. Manual: as conversas devem ser selecionadas pelos membros da equipe.",
- "admin.team.assignmentType.manual": "Manual",
"admin.team.assignmentType.placeholder": "Selecione um tipo de atribuição",
- "admin.team.assignmentType.roundRobin": "Round robin",
"admin.team.businessHours.description": "Horários comerciais padrão para a equipe, serão usados para calcular o SLA.",
"admin.team.couldNotFetchBusinessHours": "Não foi possível buscar horários comerciais",
- "admin.team.emoji": "Emoji",
"admin.team.help.description": "Configure as configurações da equipe, incluindo horários de trabalho e políticas de SLA.",
"admin.team.help.detail": "Gerencie os limites de atribuição automática de agentes e muito mais.",
"admin.team.maxAutoAssigned": "Máximo de conversas atribuídas automaticamente",
@@ -652,7 +635,6 @@
"globals.terms.away": "Ausente | Ausente",
"globals.terms.awayReassigning": "Fora e reatribuindo",
"globals.terms.background": "Fundo",
- "globals.terms.beta": "Beta",
"globals.terms.black": "Preto",
"globals.terms.body": "Corpo",
"globals.terms.brandName": "Nome da marca",
@@ -689,7 +671,6 @@
"globals.terms.expand": "Expandir",
"globals.terms.fair": "Justo",
"globals.terms.features": "Recursos",
- "globals.terms.feedback": "Feedback | Feedbacks",
"globals.terms.file": "Arquivo | Arquivos",
"globals.terms.filter": "Filtro | Filtros",
"globals.terms.firstName": "Primeiro nome | Primeiros nomes",
@@ -702,7 +683,6 @@
"globals.terms.home": "Inicio",
"globals.terms.homeScreen": "Tela inicial",
"globals.terms.homeScreenApp": "Aplicativo de tela inicial | Aplicativos de tela inicial",
- "globals.terms.host": "Host | Hosts",
"globals.terms.hour": "Hora | Horas",
"globals.terms.image": "Imagem | Imagens",
"globals.terms.import": "Importar | Importações",
@@ -724,11 +704,8 @@
"globals.terms.liveChat": "Chat ao vivo",
"globals.terms.loadMore": "Carregar mais",
"globals.terms.loading": "Carregando...",
- "globals.terms.log": "Log | Logs",
"globals.terms.logoUrl": "URL do logo",
- "globals.terms.macro": "Macro | Macros",
"globals.terms.mention": "Menção | Menções",
- "globals.terms.microsoft": "Microsoft",
"globals.terms.myInbox": "Minha Caixa de Entrada | Minhas Caixas de Entrada",
"globals.terms.name": "Nome | Nomes",
"globals.terms.none": "Nada",
@@ -758,7 +735,6 @@
"globals.terms.providerURL": "URL do provedor",
"globals.terms.reconnect": "Reconectar",
"globals.terms.referenceNumber": "Número de referência",
- "globals.terms.regex": "Regex | Regexes",
"globals.terms.regexHint": "Dica de regex",
"globals.terms.reply": "Resposta | Respostas",
"globals.terms.report": "Relatório | Relatórios",
@@ -775,7 +751,6 @@
"globals.terms.sendWelcomeEmail": "Enviar email de boas-vindas? | Enviar emails de boas-vindas?",
"globals.terms.setPassword": "Definir senha | Definir senhas",
"globals.terms.sharedView": "Visualização compartilhada | Visualizações compartilhadas",
- "globals.terms.sla": "SLA | SLAs",
"globals.terms.slaMetric": "Métrica de SLA | Métricas de SLA",
"globals.terms.slaPolicy": "Política de SLA | Políticas de SLA",
"globals.terms.smtpHost": "Host SMTP | Hosts SMTP",
@@ -784,7 +759,6 @@
"globals.terms.snoozed": "Pausado",
"globals.terms.solid": "Sólido",
"globals.terms.someone": "Alguém",
- "globals.terms.sso": "SSO | SSOs",
"globals.terms.status": "Estado | Estados",
"globals.terms.subject": "Assunto | Assuntos",
"globals.terms.tag": "Etiqueta | Etiquetas",
@@ -798,7 +772,6 @@
"globals.terms.timestamp": "Carimbo de data/hora | Carimbos de data/hora",
"globals.terms.timezone": "Fuso horário | Fusos horários",
"globals.terms.title": "Título | Títulos",
- "globals.terms.tls": "TLS | TLSs",
"globals.terms.today": "Hoje",
"globals.terms.tryAgain": "Tente novamente",
"globals.terms.type": "Tipo | Tipos",
@@ -806,7 +779,6 @@
"globals.terms.unAuthorized": "Não autorizado",
"globals.terms.unassigned": "Não atribuída",
"globals.terms.updatedAt": "Atualizado em",
- "globals.terms.url": "URL | URLs",
"globals.terms.usage": "Uso",
"globals.terms.username": "Nome de usuário | Nomes de usuário",
"globals.terms.users": "Usuários",
@@ -817,7 +789,6 @@
"globals.terms.visitor": "Visitante | Visitantes",
"globals.terms.waiting": "Aguardando",
"globals.terms.warning": "Aviso | Avisos",
- "globals.terms.webhook": "Webhook | Webhooks",
"globals.terms.week": "Semana | Semanas",
"globals.terms.white": "Branco",
"globals.terms.you": "Você",
@@ -915,7 +886,6 @@
"report.messages.incoming": "Entrada",
"report.messages.outgoing": "Saída",
"report.messages.perConversation": "Por Conversa",
- "report.messages.total": "Total",
"report.noTagsFound": "Nenhuma etiqueta encontrada",
"report.openConversations": "Conversas abertas",
"report.sla.avgFirstResp": "Tempo médio de primeira resposta",
@@ -1042,4 +1012,4 @@
"widget.conversationClosed": "Esta conversa foi encerrada",
"widget.ipBlocked": "Acesso negado",
"widget.prechatForm.startChat": "Iniciar chat"
-}
+}
diff --git a/i18n/zh-CN.json b/i18n/zh-CN.json
new file mode 100644
index 00000000..eb804542
--- /dev/null
+++ b/i18n/zh-CN.json
@@ -0,0 +1,1081 @@
+{
+ "_.code": "zh-CN",
+ "_.name": "简体中文",
+ "account.avatarRemoved": "已移除头像",
+ "account.changeAvatar": "在此更改您的头像。",
+ "account.chooseAFile": "选择文件...",
+ "account.cropAvatar": "裁剪头像",
+ "account.editProfile": "编辑个人资料",
+ "account.publicAvatar": "公共头像",
+ "account.removeAvatar": "移除头像",
+ "actions.addAction": "添加操作",
+ "actions.addCondition": "添加条件",
+ "actions.addTags": "添加标签",
+ "actions.addingPrivateNotes": "添加私密备注中",
+ "actions.addPrivateNote": "添加私密备注",
+ "actions.applyMacro": "应用宏",
+ "actions.assignAgent": "分配客服",
+ "actions.assignTeam": "分配团队",
+ "actions.noActions": "无操作",
+ "actions.removeLink": "移除链接",
+ "actions.removeTags": "移除标签",
+ "actions.sendCsat": "发送满意度调查",
+ "actions.sendReply": "发送回复",
+ "actions.setPriority": "设置优先级",
+ "actions.setSla": "设置SLA",
+ "actions.setStatus": "设置状态",
+ "actions.setTags": "设置标签",
+ "actions.setValue": "设置值",
+ "actions.startingConversation": "发起会话",
+ "activityLog.agentAway": "{actorEmail} ({actorId}) 将 {targetEmail} ({targetId}) 的状态更改为离开",
+ "activityLog.agentAwayReassign": "{actorEmail} ({actorId}) 将 {targetEmail} ({targetId}) 的状态更改为离开并重新分配",
+ "activityLog.agentAwayReassignSelf": "{actorEmail} ({actorId}) 离开并重新分配",
+ "activityLog.agentAwaySelf": "{actorEmail} ({actorId}) 离开",
+ "activityLog.agentLogin": "{email} ({userId}) 已登录",
+ "activityLog.agentLogout": "{email} ({userId}) 已登出",
+ "activityLog.agentOnline": "{actorEmail} ({actorId}) 将 {targetEmail} ({targetId}) 的状态更改为在线",
+ "activityLog.agentOnlineSelf": "{actorEmail} ({actorId}) 在线",
+ "activityLog.agentPasswordSet": "{actorEmail} ({actorId}) 为 {targetEmail} ({targetId}) 设置了密码",
+ "activityLog.entryType": "日志条目类型",
+ "activityLog.entryType.agentAway": "客服离开",
+ "activityLog.entryType.agentAwayReassigned": "客服离开重新分配",
+ "activityLog.entryType.agentLogin": "客服登录",
+ "activityLog.entryType.agentLogout": "客服登出",
+ "activityLog.entryType.agentOnline": "客服在线",
+ "activityLog.entryType.agentPasswordSet": "客服密码已设置",
+ "activityLog.entryType.agentRolePermissionsChanged": "客服角色权限已更改",
+ "activityLog.rolePermissionsAdded": "{actorEmail} ({actorId}) 向角色 {roleName} ({roleId}) 添加了权限 {permissions}",
+ "activityLog.rolePermissionsChanged": "{actorEmail} ({actorId}) 从角色 {roleName} ({roleId}) 移除了权限 {removed},并添加了权限 {added}",
+ "activityLog.rolePermissionsRemoved": "{actorEmail} ({actorId}) 从角色 {roleName} ({roleId}) 移除了权限 {permissions}",
+ "admin.agent.apiKey.description": "为此客服生成 API 密钥,以编程方式访问 Libredesk。",
+ "admin.agent.apiKey.noKey": "尚未为此客服生成 API 密钥。",
+ "admin.agent.apiKey.warningMessage": "此密钥仅显示一次。请确保立即复制。",
+ "admin.agent.deleteConfirmation": "这将永久删除该客服。请考虑改为禁用该帐户。",
+ "admin.agent.help": "管理支持客服、角色、权限和团队。",
+ "admin.automation.all": "全部",
+ "admin.automation.and": "AND",
+ "admin.automation.any": "任意",
+ "admin.automation.conversationUpdate": "会话更新",
+ "admin.automation.conversationUpdate.description": "会话更新时运行的规则。",
+ "admin.automation.evaluateRuleOnTheseEvents": "在这些事件上评估规则。",
+ "admin.automation.event.message.incoming": "收到的消息",
+ "admin.automation.event.message.outgoing": "发出的消息",
+ "admin.automation.event.priority.change": "优先级变更",
+ "admin.automation.event.status.change": "状态变更",
+ "admin.automation.executeAllMatchingRules": "执行所有匹配的规则",
+ "admin.automation.executeFirstMatchingRule": "执行第一个匹配的规则",
+ "admin.automation.help": "在会话创建、更新或每小时计划时自动执行操作。",
+ "admin.automation.invalid": "请确保至少有一个操作和一个规则,并且它们的值不为空。",
+ "admin.automation.matchBelow": "匹配以下 {any_or_all}。",
+ "admin.automation.matchTheseRules": "匹配这些规则",
+ "admin.automation.newConversation.description": "当联系人创建新会话时运行的规则。客服发起的会话不会触发这些规则。拖放以重新排序。",
+ "admin.automation.noRulesFound": "未找到规则",
+ "admin.automation.or": "OR",
+ "admin.automation.performTheseActions": "执行这些操作",
+ "admin.automation.timeTriggers": "定时任务",
+ "admin.automation.timeTriggers.description": "每小时运行一次的规则。",
+ "admin.automation.validation.addAction": "请至少添加一个操作。",
+ "admin.automation.validation.addCondition": "请至少添加一个条件。",
+ "admin.automation.validation.selectActionType": "请为所有操作选择类型。",
+ "admin.automation.validation.selectField": "请为所有条件选择字段。",
+ "admin.automation.validation.selectOperator": "请为所有条件选择运算符。",
+ "admin.automation.validation.setActionValue": "请为所有操作设置值。",
+ "admin.automation.validation.setConditionValue": "请为所有条件设置值。",
+ "admin.banner.restartMessage": "某些设置已更改,需要重启应用程序才能生效。",
+ "admin.businessHour.help.description": "工作时间允许您为整个帮助台或单个团队设置工作时间。",
+ "admin.businessHour.help.detail": "SLA 计算基于工作时间。如果团队设置了工作时间,SLA 将使用该团队的时间进行计算。否则,将回退到帮助台的工作时间。",
+ "admin.businessHours.alwaysOpen24x7": "全天候开放 (24/7)",
+ "admin.businessHours.customBusinessHours": "自定义工作时间",
+ "admin.businessHours.setBusinessHours": "设置工作时间",
+ "admin.contextLink.help.description": "侧边栏应用显示在会话侧边栏中,允许客服打开外部工具,如 CRM、计费系统或内部仪表板,并自动传递联系人详细信息。",
+ "admin.contextLink.help.detail": "使用 {'{{token}}'} 获取完全加密的有效负载,外部应用程序使用共享密钥解密。或者使用独立变量,如 {'{{email}}'}、{'{{phone}}'}、{'{{external_user_id}}'},用于指向受信任内部工具的纯 URL 链接。",
+ "admin.conversationStatus.category.placeholder": "选择类别",
+ "admin.conversationStatus.name.description": "设置状态名称。完成后点击保存。",
+ "admin.conversationTags.edit.description": "更改标签名称。完成后点击保存。",
+ "admin.conversationTags.name.valid": "标签名称至少需要 3 个字符",
+ "admin.conversationTags.new.description": "设置标签名称。完成后点击保存。",
+ "admin.customAttribute.help": "自定义属性帮助您设置关于联系人或会话的额外详细信息,例如订阅计划或首次购买日期。",
+ "admin.customAttributes.keyNotAllowed": "提供的键不允许,因为它与默认属性冲突。请使用不同的键。",
+ "admin.customAttributes.regex.description": "用于验证此自定义属性值的正则表达式。留空则跳过验证。",
+ "admin.customAttributes.regexHint.description": "正则表达式模式提示。",
+ "admin.empty": "从侧边栏选择一个部分",
+ "admin.general.allowedFileUploadExtensions": "允许的文件上传扩展名",
+ "admin.general.allowedFileUploadExtensions.description": "使用 `*` 允许所有文件类型。例如:`jpg, png, pdf`",
+ "admin.general.businessHours.placeholder": "选择工作时间",
+ "admin.general.faviconURL": "Favicon URL",
+ "admin.general.faviconURL.valid": "Favicon URL 应为有效 URL",
+ "admin.general.help": "这些是您整个帮助台的默认设置。团队级别的设置将在配置时覆盖这些设置。",
+ "admin.general.language.placeholder": "选择语言",
+ "admin.general.logoURL.valid": "Logo URL 应为有效 URL",
+ "admin.general.maxAllowedFileUploadSize": "最大允许文件上传大小",
+ "admin.general.maxAllowedFileUploadSize.description": "最大允许文件上传大小(MB)。",
+ "admin.general.maxAllowedFileUploadSize.valid": "最大允许文件上传大小应在 1 到 500 MB 之间",
+ "admin.general.rootURL.description": "应用程序的Root URL。(末尾无斜杠)。",
+ "admin.general.rootURL.valid": "Root URL 应为有效 URL",
+ "admin.general.siteName": "站点名称",
+ "admin.general.siteName.min": "站点名称至少需要 1 个字符",
+ "admin.general.timezone.placeholder": "选择时区",
+ "admin.inbox.authProtocol": "认证协议",
+ "admin.inbox.authProtocol.description": "要使用的认证协议。",
+ "admin.inbox.authProtocol.login": "Login",
+ "admin.inbox.authProtocol.plain": "Plain",
+ "admin.inbox.chooseChannel": "选择渠道",
+ "admin.inbox.createEmailInbox": "创建用于电子邮件客户支持的电子邮件收件箱",
+ "admin.inbox.createLiveChatInbox": "创建用于实时客户支持的在线聊天收件箱",
+ "admin.inbox.csatSurveys": "客户满意度调查",
+ "admin.inbox.csatSurveys.description_1": "当会话标记为已解决时发送客户满意度调查。",
+ "admin.inbox.csatSurveys.description_2": "为了更好地控制发送调查的时间,请禁用此选项并创建自动化规则来发送调查。",
+ "admin.inbox.csatSurveys.description_3": "客户满意度调查每次会话仅发送一次。",
+ "admin.inbox.enablePlusAddressing": "启用子地址会话识别",
+ "admin.inbox.enablePlusAddressing.description": "使用子地址发信改善会话线程,但需要提供商支持(例如 Gmail、Microsoft 365,发信地址类似support+conv123{'@'}example.com,QQ邮箱不支持这个需关闭)。",
+ "admin.inbox.enablePlusAddressing.requiredForMicrosoft": "Microsoft 收件箱需要此功能才能正确将回复分组为线程。",
+ "admin.inbox.enabled.description": "切换扫描收件箱和发送消息。",
+ "admin.inbox.fromEmailAddress.description": "收件箱的发件人电子邮件地址。例如:我的收件箱 ",
+ "admin.inbox.fromEmailAddress.placeholder": "我的收件箱 ",
+ "admin.inbox.heloHostname": "HELO 主机名",
+ "admin.inbox.heloHostname.description": "用于 HELO/EHLO 命令的主机名。如果未设置,默认为 localhost。",
+ "admin.inbox.help.email": "连接您的 Google 或 Microsoft 电子邮件帐户,或手动配置 IMAP 和 SMTP 设置。每个添加的收件箱都会创建一个新的电子邮件渠道,用于接收客户电子邮件。",
+ "admin.inbox.help.livechat": "创建可嵌入您网站的在线聊天小部件,用于实时客户支持。",
+ "admin.inbox.idleTimeout": "空闲超时",
+ "admin.inbox.idleTimeout.description": "在关闭并从连接池中移除不活动连接之前,保持其存活的最长时间。",
+ "admin.inbox.imap.tls.description": "选择 IMAP 的加密方法。",
+ "admin.inbox.imapConfig": "IMAP 配置",
+ "admin.inbox.imapScanInboxSince": "扫描收件箱自",
+ "admin.inbox.imapScanInboxSince.description": "为提高高电子邮件量大型帮助台的性能,此设置将扫描限制为自指定时长(例如 `2h`、`48h`)以来收到的电子邮件,通过从当前时间减去该时长计算。",
+ "admin.inbox.imapScanInterval": "扫描间隔",
+ "admin.inbox.imapScanInterval.description": "扫描收件箱新电子邮件的间隔。格式:120s、1m、1h",
+ "admin.inbox.livechat.allowStartConversation": "允许发起会话",
+ "admin.inbox.livechat.allowStartConversation.users.description": "允许用户发起新会话",
+ "admin.inbox.livechat.allowStartConversation.visitors.description": "允许访客发起新会话",
+ "admin.inbox.livechat.blockedIPs.description": "输入要拉黑的 IP 地址或 CIDR 范围,每行一个。被拉黑的 IP 将无法访问小部件。",
+ "admin.inbox.livechat.blockedIPs.list": "IP 拉黑列表",
+ "admin.inbox.livechat.chatIntroduction": "聊天介绍",
+ "admin.inbox.livechat.chatReplyExpectationMessage": "聊天回复预期消息",
+ "admin.inbox.livechat.chatReplyExpectationMessage.description": "在工作时间内显示的关于预期回复时间的消息。",
+ "admin.inbox.livechat.colors": "颜色",
+ "admin.inbox.livechat.continuity.maxMessagesPerEmail": "每封电子邮件最大消息数",
+ "admin.inbox.livechat.continuity.maxMessagesPerEmail.description": "单封连续性电子邮件中包含的最大消息数量。最大 100。",
+ "admin.inbox.livechat.continuity.minEmailInterval": "最小电子邮件间隔",
+ "admin.inbox.livechat.continuity.minEmailInterval.description": "向同一联系人发送连续性电子邮件的最短时间间隔。最短 1 分钟。",
+ "admin.inbox.livechat.continuity.offlineThreshold": "离线阈值",
+ "admin.inbox.livechat.continuity.offlineThreshold.description": "联系人离线后等待多长时间再发送电子邮件。最短 1 分钟。",
+ "admin.inbox.livechat.continuityEmailFooter": "直接回复此电子邮件以继续会话。",
+ "admin.inbox.livechat.continuityEmailFooterWithLink": "回复此电子邮件或{link}前往聊天{endlink}以继续会话。",
+ "admin.inbox.livechat.conversationContinuity": "会话连续性电子邮件收件箱",
+ "admin.inbox.livechat.conversationContinuity.description": "当联系人离线时,回复将通过此电子邮件收件箱发送。他们可以通过回复电子邮件或返回聊天小部件继续会话。",
+ "admin.inbox.livechat.darkMode": "深色模式",
+ "admin.inbox.livechat.darkMode.description": "为聊天小部件启用深色模式",
+ "admin.inbox.livechat.directToConversation": "直接启动到会话",
+ "admin.inbox.livechat.directToConversation.description": "跳过主屏幕,直接打开用户最近的会话,或如果不存在则打开新会话",
+ "admin.inbox.livechat.externalLinks": "外部链接",
+ "admin.inbox.livechat.externalLinks.add": "添加外部链接",
+ "admin.inbox.livechat.fallbackLanguage": "后备语言",
+ "admin.inbox.livechat.fallbackLanguage.description": "当访客的浏览器语言不可用时使用的语言。",
+ "admin.inbox.livechat.features.emoji": "表情符号支持",
+ "admin.inbox.livechat.features.emoji.description": "允许用户在聊天中使用表情符号",
+ "admin.inbox.livechat.features.fileUpload": "文件上传",
+ "admin.inbox.livechat.features.fileUpload.description": "允许用户在聊天中上传文件",
+ "admin.inbox.livechat.greetingMessage": "问候消息",
+ "admin.inbox.livechat.greetingMessage.variables": "可用变量:{'{{.FirstName}}'}、{'{{.LastName}}'}。使用 {'|'} 作为后备,例如 Hi {'{{.FirstName | there}}'}",
+ "admin.inbox.livechat.homeScreen.fadeBackground": "渐变背景",
+ "admin.inbox.livechat.homeScreen.fadeBackground.description": "将标题背景融合到下方内容区域",
+ "admin.inbox.livechat.homeScreen.headerTextColor.description": "主屏幕上问候语和介绍文本的颜色",
+ "admin.inbox.livechat.installation.identity.addJwt": "将已签名的 JWT 添加到 LibredeskSettings:",
+ "admin.inbox.livechat.installation.identity.description": "验证登录用户,以便会话链接到其真实帐户,并且用户不能相互冒充。",
+ "admin.inbox.livechat.installation.identity.howItWorks": "您的服务器生成包含用户详细信息的已签名 JWT。使用“安全”选项卡中的密钥以 HS256 算法对其签名。",
+ "admin.inbox.livechat.installation.identity.secretWarning": "切勿在客户端代码中暴露密钥。始终在您的服务器上生成 JWT。",
+ "admin.inbox.livechat.installation.identity.title": "身份验证",
+ "admin.inbox.livechat.installation.instructions.step1": "复制以下代码片段",
+ "admin.inbox.livechat.installation.instructions.step2": "将其粘贴到希望显示在线聊天小部件的每个页面的 `body` 结束标记之前",
+ "admin.inbox.livechat.installation.instructions.title": "安装说明",
+ "admin.inbox.livechat.installation.jsApi.description": "从您的应用程序以编程方式控制小部件。",
+ "admin.inbox.livechat.installation.jsApi.title": "JavaScript API",
+ "admin.inbox.livechat.introductionMessage": "介绍消息",
+ "admin.inbox.livechat.language.auto": "自动检测(浏览器语言)",
+ "admin.inbox.livechat.launcher": "启动器",
+ "admin.inbox.livechat.launcher.color": "启动器颜色",
+ "admin.inbox.livechat.launcher.logo": "启动器徽标",
+ "admin.inbox.livechat.launcher.position": "位置",
+ "admin.inbox.livechat.launcher.position.left": "左",
+ "admin.inbox.livechat.launcher.position.right": "右",
+ "admin.inbox.livechat.launcher.spacing.bottom": "底部间距",
+ "admin.inbox.livechat.launcher.spacing.bottom.description": "距屏幕底部的距离(像素)",
+ "admin.inbox.livechat.launcher.spacing.side": "侧边间距",
+ "admin.inbox.livechat.launcher.spacing.side.description": "距屏幕侧面的距离(像素)",
+ "admin.inbox.livechat.noticeBanner": "通知横幅",
+ "admin.inbox.livechat.noticeBanner.enabled": "启用通知横幅",
+ "admin.inbox.livechat.noticeBanner.text": "通知横幅文本",
+ "admin.inbox.livechat.officeHours": "办公时间",
+ "admin.inbox.livechat.prechatForm.addField": "添加字段",
+ "admin.inbox.livechat.prechatForm.availableFields": "可用的自定义属性",
+ "admin.inbox.livechat.prechatForm.enabled": "启用聊天前表单",
+ "admin.inbox.livechat.prechatForm.enabled.description": "在聊天开始前显示一个收集信息的表单",
+ "admin.inbox.livechat.prechatForm.fields": "表单字段",
+ "admin.inbox.livechat.prechatForm.noFields": "未配置字段。从自定义属性中添加字段。",
+ "admin.inbox.livechat.prechatForm.title": "表单标题",
+ "admin.inbox.livechat.preventMultipleConversations": "防止多个会话",
+ "admin.inbox.livechat.preventMultipleConversations.users.description": "防止用户同时发起多个会话",
+ "admin.inbox.livechat.preventMultipleConversations.visitors.description": "防止访客同时发起多个会话",
+ "admin.inbox.livechat.preventReplyToClosedConversation": "禁止回复已关闭的会话",
+ "admin.inbox.livechat.preventReplyToClosedConversation.description": "启用后,用户无法回复已关闭的会话",
+ "admin.inbox.livechat.secretKey": "密钥",
+ "admin.inbox.livechat.secretKey.description": "设置密钥以保护聊天小部件。",
+ "admin.inbox.livechat.sessionDuration.description": "已认证用户会话保持活动状态的时间。会话在活动时自动延长。最短 1 小时。示例:24h、168h、720h、2160h。",
+ "admin.inbox.livechat.sessionDuration.label": "已认证用户会话时长",
+ "admin.inbox.livechat.showOfficeHoursAfterAssignment": "在团队分配后显示办公时间",
+ "admin.inbox.livechat.showOfficeHoursAfterAssignment.description": "在会话分配给团队后显示办公时间",
+ "admin.inbox.livechat.showOfficeHoursInChat": "在聊天中显示办公时间",
+ "admin.inbox.livechat.showOfficeHoursInChat.description": "显示团队下次可用的时间",
+ "admin.inbox.livechat.showPoweredBy": "显示技术支持",
+ "admin.inbox.livechat.showPoweredBy.description": "在小部件中打开会话时显示“Powered by Libredesk”",
+ "admin.inbox.livechat.startConversationButtonText": "发起会话按钮文本",
+ "admin.inbox.livechat.tabs.appearance": "外观",
+ "admin.inbox.livechat.tabs.installation": "安装",
+ "admin.inbox.livechat.tabs.messages": "消息",
+ "admin.inbox.livechat.tabs.prechat": "聊天前表单",
+ "admin.inbox.livechat.trustedDomains.description": "每行一个域名。*.example.com 仅匹配子域名,需单独添加裸域 example.com。留空允许任何域名。",
+ "admin.inbox.livechat.trustedDomains.list": "域名列表",
+ "admin.inbox.livechat.userSettings.visitors": "访客",
+ "admin.inbox.livechat.websiteUrl": "网站 URL",
+ "admin.inbox.livechat.websiteUrl.description": "安装聊天小部件的 URL。用于连续性电子邮件中以链接回聊天。",
+ "admin.inbox.mailbox": "邮箱",
+ "admin.inbox.mailbox.description": "扫描传入电子邮件的邮箱(文件夹)。默认为 INBOX(通常无需更改)。",
+ "admin.inbox.maxConnections": "最大连接数",
+ "admin.inbox.maxConnections.description": "与服务器的最大并发连接数。",
+ "admin.inbox.maxRetries": "最大重试次数",
+ "admin.inbox.maxRetries.description": "消息失败时的重试次数。",
+ "admin.inbox.oauth.chooseSetupMethod": "选择设置方法",
+ "admin.inbox.oauth.clientIDSecretRequired": "请提供客户端 ID 和客户端密钥",
+ "admin.inbox.oauth.connectAccount": "连接 {provider} 帐户",
+ "admin.inbox.oauth.connectedVia": "已通过 OAuth 连接 - {provider}",
+ "admin.inbox.oauth.enterClientID": "输入您的 OAuth 客户端 ID",
+ "admin.inbox.oauth.enterClientSecret": "输入您的 OAuth 客户端密钥",
+ "admin.inbox.oauth.followSteps": "按照以下步骤连接您的电子邮件帐户",
+ "admin.inbox.oauth.googleCloudConsole": "Google Cloud Console",
+ "admin.inbox.oauth.googleDescription": "使用 Google Workspace 或 Gmail 连接",
+ "admin.inbox.oauth.microsoftAzurePortal": "Microsoft Azure Portal",
+ "admin.inbox.oauth.microsoftDescription": "使用 Microsoft 365 或 Outlook 连接",
+ "admin.inbox.oauth.otherProvider": "其他提供商",
+ "admin.inbox.oauth.otherProviderDescription": "手动配置 IMAP 和 SMTP",
+ "admin.inbox.oauth.reconnectAccount": "重新连接 {provider} 帐户",
+ "admin.inbox.oauth.reconnectDescription": "重新输入您的凭据以刷新 OAuth 连接",
+ "admin.inbox.oauth.selectConnectionMethod": "选择您想如何连接您的电子邮件帐户",
+ "admin.inbox.oauth.step1CreateApp": "1. 在以下位置创建 OAuth 应用",
+ "admin.inbox.oauth.step2AddCallback": "2. 添加此回调 URL:",
+ "admin.inbox.oauth.step3EnterCredentials": "3. 在下方输入您的凭据:",
+ "admin.inbox.promptTagsOnReply": "回复前提示添加标签",
+ "admin.inbox.promptTagsOnReply.description": "在客服对没有标签的会话发送回复前发出预警。客服仍可通过确认提示在不添加标签的情况下发送。",
+ "admin.inbox.replyToAddress": "回复地址(可选)",
+ "admin.inbox.replyToAddress.description": "客户回复发送到的地址。仅在 SMTP 发件人和 IMAP 邮箱位于不同域时需要。",
+ "admin.inbox.replyToAddress.placeholder": "support{'@'}example.com",
+ "admin.inbox.skipTLSVerification": "跳过 TLS 验证",
+ "admin.inbox.skipTLSVerification.description": "跳过 TLS 证书上的主机名检查。",
+ "admin.inbox.smtpConfig": "SMTP 配置",
+ "admin.inbox.tls.description": "TLS/SSL 加密,通常使用 STARTTLS。",
+ "admin.inbox.waitTimeout": "等待超时",
+ "admin.inbox.waitTimeout.description": "等待获取连接的最长时间,超时后将失败。当所有打开的连接都忙于发送电子邮件且未能足够快地返回连接池时,可能会发生超时。这也是创建新 SMTP 连接时使用的超时。",
+ "admin.macro.actionInvalid": "每个操作必须有一个类型和一个值",
+ "admin.macro.help": "将多个会话操作组合成单击宏。",
+ "admin.macro.messageContent": "使用宏时发送的回复(可选)",
+ "admin.macro.messageOrActionRequired": "需要消息内容或操作",
+ "admin.notification.help.description": "配置 SMTP 服务器设置,以便向团队成员发送电子邮件通知。",
+ "admin.notification.help.detail": "配置完成后,团队成员将收到关于会话分配、SLA 违规和其他重要事件的自动通知。",
+ "admin.notification.restartApp": "设置已成功更新,请重启应用程序以使更改生效。",
+ "admin.oidc.help": "配置单点登录,使用一个或多个 OpenID Connect 提供程序。",
+ "admin.role.activityLog.manage": "管理活动日志",
+ "admin.role.ai.manage": "管理 AI 功能",
+ "admin.role.automations.manage": "管理自动化",
+ "admin.role.businessHours.manage": "管理工作时间",
+ "admin.role.cannotModifyAdminRole": "无法修改管理员角色,请创建一个新角色。",
+ "admin.role.contactNotes.delete": "删除联系人备注",
+ "admin.role.contactNotes.read": "查看联系人备注",
+ "admin.role.contactNotes.write": "添加联系人备注",
+ "admin.role.contacts.block": "拉黑联系人",
+ "admin.role.contacts.read": "查看联系人详细信息",
+ "admin.role.contacts.readAll": "查看所有联系人",
+ "admin.role.contacts.write": "编辑联系人详细信息",
+ "admin.role.contextLinks.manage": "管理侧边栏应用",
+ "admin.role.conversations.read": "查看会话",
+ "admin.role.conversations.readAll": "查看所有会话",
+ "admin.role.conversations.readAssigned": "查看分配给我的会话",
+ "admin.role.conversations.readTeamAll": "查看您团队的会话",
+ "admin.role.conversations.readTeamInbox": "查看团队收件箱中的会话",
+ "admin.role.conversations.readUnassigned": "查看所有未分配的会话",
+ "admin.role.conversations.updatePriority": "更改会话优先级",
+ "admin.role.conversations.updateStatus": "更改会话状态",
+ "admin.role.conversations.updateTags": "添加或移除会话标签",
+ "admin.role.conversations.updateTeamAssignee": "将会话分配给团队",
+ "admin.role.conversations.updateUserAssignee": "将会话分配给用户",
+ "admin.role.conversations.write": "创建会话",
+ "admin.role.customAttributes.manage": "管理自定义属性",
+ "admin.role.generalSettings.manage": "管理常规设置",
+ "admin.role.help": "管理角色及其权限,以精细控制您的支持帮助台。",
+ "admin.role.inboxes.manage": "管理收件箱",
+ "admin.role.macros.manage": "管理宏",
+ "admin.role.messages.read": "查看会话消息",
+ "admin.role.messages.write": "在会话中发送消息",
+ "admin.role.messages.writeAsContact": "以联系人身份发送消息",
+ "admin.role.notificationSettings.manage": "管理通知设置",
+ "admin.role.oidc.manage": "管理 SSO 配置",
+ "admin.role.reports.manage": "管理报告",
+ "admin.role.roleForAllSupportAgents": "所有支持客服的角色",
+ "admin.role.roles.manage": "管理角色",
+ "admin.role.setPermissionsForThisRole": "为此角色设置权限",
+ "admin.role.sharedViews.manage": "管理共享视图",
+ "admin.role.sla.manage": "管理 SLA 策略",
+ "admin.role.status.manage": "管理会话状态",
+ "admin.role.tags.manage": "管理标签",
+ "admin.role.teams.manage": "管理团队",
+ "admin.role.templates.manage": "管理模板",
+ "admin.role.users.manage": "管理用户",
+ "admin.role.view.manage": "创建和管理会话视图",
+ "admin.role.webhooks.manage": "管理 Webhooks",
+ "admin.sharedView.help": "创建所有客服或特定团队可见的共享视图。",
+ "admin.sla.addBreachAlert": "添加违规通知",
+ "admin.sla.addWarningAlert": "添加预警通知",
+ "admin.sla.advanceWarning": "提前预警",
+ "admin.sla.afterSpecificDuration": "特定时长后",
+ "admin.sla.alertConfiguration": "通知配置",
+ "admin.sla.alertConfiguration.description": "设置通知触发器和收件人",
+ "admin.sla.alertRecipients": "通知收件人",
+ "admin.sla.assignedUser": "分配的用户",
+ "admin.sla.atleastOneSLATimeRequired": "至少需要首次响应时间、下次响应时间或解决时间中的一个。",
+ "admin.sla.breach": "违规",
+ "admin.sla.description.valid": "SLA 策略描述应在 1 到 255 个字符之间",
+ "admin.sla.firstResponseTime": "首次响应时间",
+ "admin.sla.followUpDelay": "跟进延迟",
+ "admin.sla.help.description": "配置 SLA 策略以设置响应、解决和下次响应时间目标。",
+ "admin.sla.help.detail": "SLA 有助于跟踪团队绩效,确保在预期时间范围内处理会话。违反 SLA 会触发向已配置团队成员发送通知。",
+ "admin.sla.immediatelyOnBreach": "违规时立即",
+ "admin.sla.name.valid": "SLA 策略名称应在 1 到 255 个字符之间",
+ "admin.sla.nextResponseTime": "下次响应时间",
+ "admin.sla.noAlertsConfigured": "未配置通知",
+ "admin.sla.postBreachAlert": "违规后通知",
+ "admin.sla.preBreachAlert": "违规前通知",
+ "admin.sla.resolutionTime": "解决时间",
+ "admin.sla.triggerTiming": "触发时机",
+ "admin.sla.warning": "预警",
+ "admin.sso.logoURLDescription": "在登录页面上显示的自定义 Logo URL。",
+ "admin.sso.setThisUrlForCallback": "将此 URI 设置为回调。",
+ "admin.status.help": "创建自定义会话状态以扩展默认工作流。",
+ "admin.tag.help": "标签可用于筛选会话,并作为自动化中的条件。",
+ "admin.tags.deleteConfirmation": "您确定要删除此标签吗?这将同时从所有会话中移除该标签",
+ "admin.team.assignmentType": "自动分配类型",
+ "admin.team.assignmentType.description": "轮询:会话以轮询方式分配给团队成员。手动:会话由团队成员自行领取。",
+ "admin.team.assignmentType.manual": "手动",
+ "admin.team.assignmentType.placeholder": "选择分配类型",
+ "admin.team.assignmentType.roundRobin": "轮询",
+ "admin.team.businessHours.description": "团队的默认工作时间,将用于计算 SLA。",
+ "admin.team.couldNotFetchBusinessHours": "无法获取工作时间",
+ "admin.team.emoji": "表情符号",
+ "admin.team.help.description": "配置团队设置,包括工作时间和 SLA 策略。",
+ "admin.team.help.detail": "管理客服自动分配限制等。",
+ "admin.team.maxAutoAssigned": "最大自动分配会话数",
+ "admin.team.maxAutoAssigned.description": "可自动分配给一个客服的最大会话数量。仅处于“打开”类别状态的会话计入此限制。设置为 0 表示无限制。",
+ "admin.team.noPermissionBusinessHours": "您无权查看工作时间。",
+ "admin.team.slaPolicy.description": "当会话分配给此团队时,自动应用于会话的 SLA 策略。",
+ "admin.team.slaPolicy.placeholder": "选择策略",
+ "admin.team.timezone.description": "团队的时区将用于计算 SLA。",
+ "admin.template.emailNotificationTemplates": "电子邮件通知模板",
+ "admin.template.help": "自定义发送给客户的电子邮件和发送给团队的通知电子邮件的外观和内容。",
+ "admin.template.makeSureTemplateHasContent": "确保模板只包含一次 {content}。",
+ "admin.template.onlyOneDefaultOutgoingTemplate": "您只能有一个默认的发送电子邮件模板。",
+ "admin.template.outgoingEmailTemplates": "发送电子邮件模板",
+ "admin.webhook.events.description": "选择您要订阅的事件。",
+ "admin.webhook.help": "配置 Webhooks,以便在您的帮助台中发生事件时接收实时通知。",
+ "admin.webhook.secret.description": "用于 Webhook 签名验证的可选密钥。",
+ "agent.apiKeyGenerated": "API 密钥生成成功",
+ "agent.apiKeyRevoked": "API 密钥已成功吊销",
+ "agent.edit": "编辑客服",
+ "agent.generateApiKey": "生成 API 密钥",
+ "agent.new": "新建客服",
+ "agent.selectAvailabilityStatus": "选择可用性状态",
+ "ai.apiKey.description": "{provider} API 密钥未设置或无效。请输入有效的 API 密钥以使用 AI 功能。",
+ "ai.apiKeyNotSet": "{provider} API 密钥未设置。请让您的管理员进行设置",
+ "ai.enterOpenAIAPIKey": "输入 OpenAI API 密钥",
+ "auth.backToLogin": "返回登录",
+ "auth.checkEmailForReset": "查看您的电子邮件中的密码重置链接。",
+ "auth.confirmPassword": "确认密码",
+ "auth.csrfTokenMismatch": "CSRF 令牌不匹配",
+ "auth.enterEmailForReset": "输入您的电子邮件以接收密码重置链接。",
+ "auth.enterNewPasswordTwice": "输入两次新密码以确认。",
+ "auth.forgotPassword": "忘记密码?",
+ "auth.hidePassword": "隐藏密码",
+ "auth.invalidOrExpiredSession": "会话无效或已过期",
+ "auth.invalidOrExpiredSessionClearCookie": "会话无效或已过期。请清除您的 Cookie 后重试。",
+ "auth.invalidResetLink": "重置链接无效。请请求新的密码重置链接。",
+ "auth.loggingIn": "正在登录...",
+ "auth.newPassword": "新密码",
+ "auth.orContinueWith": "或使用以下方式继续",
+ "auth.passwordRequired": "密码是必需的。",
+ "auth.passwordSetSuccess": "您现在可以使用新密码登录。",
+ "auth.passwordsDoNotMatch": "密码不匹配。",
+ "auth.resetPassword": "重置密码",
+ "auth.sendResetLink": "发送重置链接",
+ "auth.setNewPassword": "设置新密码",
+ "auth.settingPassword": "正在设置密码...",
+ "auth.showPassword": "显示密码",
+ "auth.signIn": "登录您的帐户",
+ "auth.signInButton": "登录",
+ "automation.deletionConfirmation": "此操作无法撤消。这将永久删除此自动化规则。",
+ "automation.editRule": "编辑规则",
+ "automation.newRule": "新建规则",
+ "businessHour.deletionConfirmation": "此操作无法撤消。这将永久删除此工作时间。",
+ "businessHour.edit": "编辑工作时间",
+ "businessHour.new": "新建工作时间",
+ "businessHour.newHoliday": "新建假期",
+ "command.navigate": "导航",
+ "command.noCommandAvailable": "无可用命令",
+ "command.pickSnoozeTime": "选择暂停时间",
+ "command.replyPreview": "回复预览",
+ "command.selectAMacro": "选择一个宏以查看详情",
+ "command.snoozeFor": "暂停",
+ "command.typeCmdOrSearch": "输入命令或搜索...",
+ "confirm.deleteContextLink": "此操作无法撤消。这将永久删除此侧边栏应用。",
+ "confirm.deleteInbox": "此操作无法撤消。这将永久删除此收件箱。",
+ "confirm.deleteMacro": "此操作无法撤消。这将永久删除此宏。",
+ "confirm.deleteSharedView": "此操作无法撤消。这将永久删除此共享视图。",
+ "confirm.deleteSso": "此操作无法撤消。这将永久删除此 SSO。",
+ "confirm.deleteTeam": "此操作无法撤消。这将永久删除此团队。",
+ "confirm.deleteView": "此操作无法撤消。这将永久删除此视图。",
+ "confirm.deleteWebhook": "此操作无法撤消。这将永久删除此 Webhook。",
+ "contact.addNote": "添加备注",
+ "contact.allContacts": "所有联系人",
+ "contact.alreadyExistsWithEmail": "已存在具有相同电子邮件的其他联系人",
+ "contact.blockConfirm": "您确定要拉黑此联系人吗?他们将无法聊天,并且来自其地址的传入电子邮件将被拒绝。这也将拉黑来自使用相同电子邮件的任何其他联系人的传入电子邮件。",
+ "contact.blockContact": "拉黑联系人",
+ "contact.blockedSuccessfully": "联系人已成功拉黑",
+ "contact.deleteNote": "删除备注",
+ "contact.editContact": "编辑联系人",
+ "contact.identityNotVerified": "身份未验证",
+ "contact.identityVerified": "身份已验证",
+ "contact.newNote": "新建备注",
+ "contact.noContactsFound": "未找到联系人",
+ "contact.notes.empty": "暂无备注",
+ "contact.notes.help": "为此联系人添加备注,以跟踪重要信息和会话。",
+ "contact.saveNote": "保存备注",
+ "contact.searchByEmail": "按电子邮件搜索",
+ "contact.type.contact": "联系人",
+ "contact.type.visitor": "访客",
+ "contact.unblockConfirm": "您确定要取消拉黑此联系人吗?他们将可以再次聊天,并且来自其地址的传入电子邮件将被接受。",
+ "contact.unblockContact": "取消拉黑联系人",
+ "contact.unblockedSuccessfully": "联系人已成功取消拉黑",
+ "contact.updateContact": "更新联系人",
+ "contextLink.edit": "编辑侧边栏应用",
+ "contextLink.namePlaceholder": "我的应用",
+ "contextLink.new": "新建侧边栏应用",
+ "contextLink.secret": "密钥",
+ "contextLink.secretHelp": "在 Libredesk 和外部应用之间共享的 32 字符密钥。用于加密 {'{{token}}'},以便只有外部应用可以读取联系人详细信息。如果仅使用纯变量,如 {'{{email}}'},则不需要。",
+ "contextLink.secretLengthError": "密钥必须正好为 32 个字符。",
+ "contextLink.tokenExpiry": "令牌过期时间(秒)",
+ "contextLink.tokenExpiryHelp": "外部应用应拒绝早于此时间的令牌。作为 'exp'(过期时间戳)包含在加密令牌中。",
+ "contextLink.urlTemplate": "URL 模板",
+ "contextLink.urlTemplateHelp": "{'{{token}}'} 是一个 base64 编码的 AES-256-GCM 加密 blob,包含所有联系人和客服字段(需要密钥)。独立变量如 {'{{email}}'}、{'{{phone}}'}、{'{{external_user_id}}'}、{'{{contact_id}}'}、{'{{first_name}}'}、{'{{last_name}}'}、{'{{conversation_uuid}}'} 以纯文本形式传递。",
+ "conversation.agentAssigned": "客服已分配",
+ "conversation.allLoaded": "所有会话已加载",
+ "conversation.bulkActions.clearSelection": "清除选择",
+ "conversation.bulkActions.failedToast": "某些会话无法更新",
+ "conversation.bulkActions.selectAll": "选择所有会话",
+ "conversation.bulkActions.selectConversation": "选择会话",
+ "conversation.bulkActions.successToast": "会话已更新",
+ "conversation.bulkActions.toolbar": "批量操作工具栏",
+ "conversation.couldNotFetch": "无法获取会话",
+ "conversation.hideQuotedText": "隐藏引用文本",
+ "conversation.mentions": "提及",
+ "conversation.myInbox": "我的收件箱",
+ "conversation.newConversation": "新建会话",
+ "conversation.noConversationsFound": "未找到会话",
+ "conversation.notMemberOfTeam": "您不是该团队的成员,请刷新页面后重试",
+ "conversation.placeholder": "从左侧面板选择一个会话。",
+ "conversation.search": "搜索会话",
+ "conversation.searchContact": "按电子邮件搜索联系人或输入新电子邮件",
+ "conversation.sentViaEmail": "通过电子邮件发送",
+ "conversation.showQuotedText": "显示引用文本",
+ "conversation.sidebar.contactAttributes": "联系人属性",
+ "conversation.sidebar.information": "信息",
+ "conversation.sidebar.lastVisitedPages": "最近访问的页面",
+ "conversation.sidebar.noPreviousConvo": "无历史会话",
+ "conversation.sidebar.notAvailable": "不可用",
+ "conversation.sidebar.previousConvo": "历史会话",
+ "conversation.sort.newestActivity": "最新活动",
+ "conversation.sort.nextSLATarget": "下一个 SLA 目标",
+ "conversation.sort.oldestActivity": "最早活动",
+ "conversation.sort.priorityFirst": "优先级优先",
+ "conversation.sort.startedFirst": "最早开始",
+ "conversation.sort.startedLast": "最晚开始",
+ "conversation.sort.waitingLongest": "等待最长",
+ "conversation.teamAssigned": "团队已分配",
+ "conversation.tryAdjustingFilters": "尝试调整筛选条件",
+ "conversation.viewPermissionDenied": "您无权访问此视图",
+ "conversationStatus.alreadyInUse": "无法删除状态,因为它正在使用中,请在删除前从所有会话中移除此状态",
+ "conversationStatus.cannotUpdateDefault": "无法更新默认会话状态",
+ "csat.alreadySubmitted": "客户满意度调查已提交",
+ "csat.pageTitle": "评价您与我们的互动",
+ "csat.pleaseFillRequired": "请提供评分或反馈。",
+ "csat.rateYourInteraction": "评价您最近的互动",
+ "csat.thankYouMessage": "感谢您抽出时间提交反馈。",
+ "customAttribute.deletionConfirmation": "此操作无法撤消。这将永久删除此自定义属性。",
+ "customAttribute.edit": "编辑自定义属性",
+ "customAttribute.new": "新建自定义属性",
+ "dateFilter.lastNDays": "最近 {n} 天 | 最近 {n} 天",
+ "dateFilter.selectDays": "选择天数",
+ "editor.addLinkUrl": "添加链接 URL",
+ "editor.editLinkUrl": "编辑链接 URL",
+ "editor.hint.full": "Shift + Enter 添加新行。Ctrl + Enter 发送。Ctrl + K 打开命令栏。",
+ "editor.hint.newLineCtrlK": "Shift + Enter 添加新行。Ctrl + K 打开命令栏。",
+ "editor.hint.newLineSend": "Shift + Enter 添加新行。Ctrl + Enter 发送。",
+ "editor.newLine": "Shift + Enter 添加新行。",
+ "errors.alreadyExistsCustomAttribute": "自定义属性已存在",
+ "errors.alreadyExistsRole": "角色已存在",
+ "errors.alreadyExistsTag": "标签已存在",
+ "errors.alreadyExistsTeam": "团队已存在",
+ "errors.canOnlyDeleteOwnNote": "您只能删除自己的备注",
+ "errors.parsingRequest": "解析请求出错",
+ "filter.add": "添加筛选器",
+ "globals.messages.add": "添加",
+ "globals.messages.addAnnouncement": "添加公告",
+ "globals.messages.addEmoji": "添加表情符号",
+ "globals.messages.addExternalLink": "添加外部链接",
+ "globals.messages.additionalFeedback": "额外反馈(可选)",
+ "globals.messages.all": "全部",
+ "globals.messages.apply": "应用",
+ "globals.messages.areYouAbsolutelySure": "您确定吗?",
+ "globals.messages.attachFile": "附加文件",
+ "globals.messages.back": "返回",
+ "globals.messages.backOnDayAt": "于 {day} {time}",
+ "globals.messages.backTodayAt": "今天 {time}",
+ "globals.messages.backTomorrowAt": "明天 {time}",
+ "globals.messages.backgroundColor": "背景颜色",
+ "globals.messages.backgroundImageUrl": "背景图片 URL",
+ "globals.messages.badRequest": "错误请求",
+ "globals.messages.bestFit": "最适合",
+ "globals.messages.block": "拉黑",
+ "globals.messages.cancel": "取消",
+ "globals.messages.caseSensitiveMatch": "区分大小写匹配",
+ "globals.messages.close": "关闭",
+ "globals.messages.closeChat": "关闭聊天",
+ "globals.messages.connecting": "正在连接...",
+ "globals.messages.continueConversation": "继续您的会话",
+ "globals.messages.copied": "已复制到剪贴板",
+ "globals.messages.correctEmailErrors": "请更正电子邮件错误",
+ "globals.messages.coverImageUrl": "封面图片 URL",
+ "globals.messages.create": "创建",
+ "globals.messages.currentlyOffline": "我们当前离线",
+ "globals.messages.delete": "删除",
+ "globals.messages.deleteAll": "删除全部",
+ "globals.messages.deletedSuccessfully": "删除成功",
+ "globals.messages.disable": "禁用",
+ "globals.messages.edit": "编辑",
+ "globals.messages.empty": "{name} 为空",
+ "globals.messages.enable": "启用",
+ "globals.messages.errorAlreadyExists": "已存在",
+ "globals.messages.errorSendingMessage": "发送消息出错",
+ "globals.messages.errorSendingPasswordResetEmail": "发送密码重置电子邮件出错",
+ "globals.messages.errorUpdatingConversation": "更新会话出错",
+ "globals.messages.errorUploadingFile": "上传文件出错",
+ "globals.messages.false": "否",
+ "globals.messages.fileTypeisNotAnImage": "文件类型不是图像",
+ "globals.messages.goBack": "返回",
+ "globals.messages.golangDurationHoursMinutes": "以小时或分钟为单位的时长。示例:1h, 30m, 1h30m",
+ "globals.messages.gradientEnd": "渐变结束",
+ "globals.messages.gradientStart": "渐变开始",
+ "globals.messages.headerTextColor": "标题文本颜色",
+ "globals.messages.hoursSinceCreated": "创建后的小时数",
+ "globals.messages.hoursSinceFirstReply": "首次回复后的小时数",
+ "globals.messages.hoursSinceLastReply": "最后回复后的小时数",
+ "globals.messages.hoursSinceResolved": "解决后的小时数",
+ "globals.messages.import": "导入",
+ "globals.messages.invite": "邀请",
+ "globals.messages.lastUsed": "上次使用",
+ "globals.messages.linkUrl": "链接 URL",
+ "globals.messages.markAllAsRead": "全部标记为已读",
+ "globals.messages.markAsRead": "标记为已读",
+ "globals.messages.markAsUnread": "标记为未读",
+ "globals.messages.maxLength": "最多 {max} 个字符",
+ "globals.messages.mustBeNumber": "必须为数字",
+ "globals.messages.new": "新建",
+ "globals.messages.no": "否",
+ "globals.messages.noInternetConnection": "无网络连接",
+ "globals.messages.noMessagesYet": "暂无消息",
+ "globals.messages.noResultsFound": "未找到结果",
+ "globals.messages.notAllowed": "不允许",
+ "globals.messages.notFound": "未找到",
+ "globals.messages.options": "选项",
+ "globals.messages.pageNofTotal": "第 {page} 页,共 {total} 页",
+ "globals.messages.pageNotFound": "页面未找到",
+ "globals.messages.pageTooLarge": "页面尺寸太大,最多为 {max}",
+ "globals.messages.pickDateAndTime": "选择日期和时间",
+ "globals.messages.pleaseRateConversation": "请评价此会话:",
+ "globals.messages.pleaseSelect": "提交前请选择 {name}",
+ "globals.messages.pressEnterToSelectAValue": "按回车键选择一个值",
+ "globals.messages.reassigning": "重新分配",
+ "globals.messages.regenerate": "重新生成",
+ "globals.messages.replying": "回复中",
+ "globals.messages.required": "{name} 是必填的",
+ "globals.messages.reset": "重置",
+ "globals.messages.revoke": "吊销",
+ "globals.messages.save": "保存",
+ "globals.messages.saveChanges": "保存更改",
+ "globals.messages.savedSuccessfully": "更改已保存",
+ "globals.messages.saving": "正在保存...",
+ "globals.messages.selectAFutureTime": "选择一个未来时间",
+ "globals.messages.selectTLS": "选择 TLS 类型",
+ "globals.messages.send": "发送",
+ "globals.messages.sendUsMessage": "给我们发送消息",
+ "globals.messages.sending": "正在发送...",
+ "globals.messages.setUp": "设置",
+ "globals.messages.smallScreenDescription": "Libredesk 适用于较大的屏幕。为获得最佳体验,请使用桌面电脑或平板电脑。",
+ "globals.messages.smallScreenTitle": "切换到桌面电脑以获得最佳体验",
+ "globals.messages.somethingWentWrong": "出了点问题",
+ "globals.messages.startNewConversation": "开始新会话",
+ "globals.messages.startTypingToSearch": "开始输入以搜索...",
+ "globals.messages.strongPassword": "密码长度必须在 {min} 到 {max} 个字符之间,且至少包含一个大写字母、一个小写字母、一个数字和一个特殊字符。",
+ "globals.messages.submit": "提交",
+ "globals.messages.submitFeedback": "提交反馈",
+ "globals.messages.submitting": "正在提交...",
+ "globals.messages.thankYou": "谢谢!",
+ "globals.messages.thankYouFeedback": "✅ 感谢您的反馈!",
+ "globals.messages.tooManyRequests": "请求过多。",
+ "globals.messages.true": "是",
+ "globals.messages.unblock": "取消拉黑",
+ "globals.messages.update": "更新",
+ "globals.messages.upload": "上传",
+ "globals.messages.uploading": "上传中",
+ "globals.messages.visibleWhen": "可见条件",
+ "globals.messages.welcomeToLibredesk": "欢迎使用 Libredesk",
+ "globals.messages.wellBeBack": "我们将于 {when} 回来",
+ "globals.messages.yes": "是",
+ "globals.terms.account": "帐户 | 帐户",
+ "globals.terms.action": "操作 | 操作",
+ "globals.terms.active": "活跃",
+ "globals.terms.activityLog": "活动日志 | 活动日志",
+ "globals.terms.actor": "操作者 | 操作者",
+ "globals.terms.admin": "管理员 | 管理员",
+ "globals.terms.agent": "客服 | 客服",
+ "globals.terms.alert": "通知 | 通知",
+ "globals.terms.announcement": "公告 | 公告",
+ "globals.terms.apiKey": "API 密钥 | API 密钥",
+ "globals.terms.appliesTo": "适用于",
+ "globals.terms.ascending": "升序",
+ "globals.terms.audio": "音频",
+ "globals.terms.automation": "自动化 | 自动化",
+ "globals.terms.availabilityStatus": "可用性状态",
+ "globals.terms.awaitingResponse": "等待回复",
+ "globals.terms.away": "离开 | 离开",
+ "globals.terms.awayReassigning": "离开并重新分配",
+ "globals.terms.background": "背景",
+ "globals.terms.beta": "测试版",
+ "globals.terms.black": "黑色",
+ "globals.terms.body": "正文",
+ "globals.terms.brandName": "品牌名称",
+ "globals.terms.businessHour": "工作时间 | 工作时间",
+ "globals.terms.callbackURL": "回调 URL",
+ "globals.terms.category": "类别",
+ "globals.terms.channel": "渠道",
+ "globals.terms.clientID": "客户端 ID (Client ID)",
+ "globals.terms.clientSecret": "客户端密钥 (Client secret)",
+ "globals.terms.closed": "已关闭",
+ "globals.terms.closedAt": "关闭于",
+ "globals.terms.collapse": "折叠",
+ "globals.terms.contact": "联系人 | 联系人",
+ "globals.terms.content": "内容 | 内容",
+ "globals.terms.contextLink": "侧边栏应用 | 侧边栏应用",
+ "globals.terms.continue": "继续",
+ "globals.terms.conversation": "会话 | 会话",
+ "globals.terms.copy": "复制",
+ "globals.terms.country": "国家/地区",
+ "globals.terms.createdAt": "创建于",
+ "globals.terms.createdOn": "创建日期",
+ "globals.terms.custom": "自定义",
+ "globals.terms.customAttribute": "自定义属性 | 自定义属性",
+ "globals.terms.date": "日期",
+ "globals.terms.day": "天 | 天",
+ "globals.terms.default": "默认",
+ "globals.terms.descending": "降序",
+ "globals.terms.description": "描述 | 描述",
+ "globals.terms.disabled": "已禁用",
+ "globals.terms.download": "下载",
+ "globals.terms.draft": "草稿",
+ "globals.terms.email": "电子邮件 | 电子邮件",
+ "globals.terms.enabled": "已启用",
+ "globals.terms.error": "错误 | 错误",
+ "globals.terms.event": "事件 | 事件",
+ "globals.terms.excellent": "非常好",
+ "globals.terms.expand": "展开",
+ "globals.terms.fair": "一般",
+ "globals.terms.features": "功能",
+ "globals.terms.feedback": "反馈 | 反馈",
+ "globals.terms.file": "文件 | 文件",
+ "globals.terms.filter": "筛选器 | 筛选器",
+ "globals.terms.firstName": "名 | 名",
+ "globals.terms.firstReplyAt": "首次回复于",
+ "globals.terms.fromEmailAddress": "发件人电子邮件地址 | 发件人电子邮件地址",
+ "globals.terms.general": "常规",
+ "globals.terms.good": "好",
+ "globals.terms.google": "Google",
+ "globals.terms.gradient": "渐变",
+ "globals.terms.great": "很好",
+ "globals.terms.home": "主页",
+ "globals.terms.homeScreen": "主屏幕",
+ "globals.terms.homeScreenApp": "主屏幕应用 | 主屏幕应用",
+ "globals.terms.host": "主机 | 主机",
+ "globals.terms.hour": "小时 | 小时",
+ "globals.terms.image": "图像 | 图像",
+ "globals.terms.import": "导入 | 导入",
+ "globals.terms.inactive": "不活跃 | 不活跃",
+ "globals.terms.inbox": "收件箱 | 收件箱",
+ "globals.terms.initiatedAt": "发起于",
+ "globals.terms.integration": "集成 | 集成",
+ "globals.terms.ipAddress": "IP 地址 | IP 地址",
+ "globals.terms.isDefault": "默认",
+ "globals.terms.key": "键 | 键",
+ "globals.terms.label": "标签 | 标签",
+ "globals.terms.language": "语言 | 语言",
+ "globals.terms.lastActive": "最后活跃",
+ "globals.terms.lastLogin": "最后登录",
+ "globals.terms.lastMessageAt": "最后消息于",
+ "globals.terms.lastName": "姓 | 姓",
+ "globals.terms.lastReplyAt": "最后回复于",
+ "globals.terms.lastUpdated": "最后更新",
+ "globals.terms.learnMore": "了解更多",
+ "globals.terms.listValues": "列表值",
+ "globals.terms.liveChat": "在线聊天",
+ "globals.terms.loadMore": "加载更多",
+ "globals.terms.loading": "正在加载...",
+ "globals.terms.log": "日志 | 日志",
+ "globals.terms.logoUrl": "Logo URL",
+ "globals.terms.macro": "宏 | 宏",
+ "globals.terms.mention": "提及 | 提及",
+ "globals.terms.message": "消息 | 消息",
+ "globals.terms.microsoft": "Microsoft",
+ "globals.terms.myInbox": "我的收件箱 | 我的收件箱",
+ "globals.terms.name": "名称 | 名称",
+ "globals.terms.none": "无",
+ "globals.terms.note": "备注 | 备注",
+ "globals.terms.notification": "通知 | 通知",
+ "globals.terms.off": "关闭",
+ "globals.terms.offline": "离线 | 离线",
+ "globals.terms.online": "在线 | 在线",
+ "globals.terms.open": "打开",
+ "globals.terms.openMenu": "打开菜单",
+ "globals.terms.optional": "可选 | 可选",
+ "globals.terms.original": "原始",
+ "globals.terms.overdue": "逾期",
+ "globals.terms.overview": "概览",
+ "globals.terms.page": "页 | 页",
+ "globals.terms.participant": "参与者 | 参与者",
+ "globals.terms.password": "密码 | 密码",
+ "globals.terms.pending": "待处理",
+ "globals.terms.phoneNumber": "电话号码 | 电话号码",
+ "globals.terms.pickDate": "选择一个日期",
+ "globals.terms.placeholder": "占位符 | 占位符",
+ "globals.terms.poor": "差",
+ "globals.terms.port": "端口 | 端口",
+ "globals.terms.primaryColor": "主色 | 主色",
+ "globals.terms.priority": "优先级 | 优先级",
+ "globals.terms.privateNote": "私密备注 | 私密备注",
+ "globals.terms.productivity": "生产力",
+ "globals.terms.profile": "个人资料 | 个人资料",
+ "globals.terms.provider": "提供商 | 提供商",
+ "globals.terms.providerURL": "提供商 URL",
+ "globals.terms.reconnect": "重新连接",
+ "globals.terms.referenceNumber": "参考编号",
+ "globals.terms.regex": "正则表达式 | 正则表达式",
+ "globals.terms.regexHint": "正则表达式提示",
+ "globals.terms.remove": "移除",
+ "globals.terms.reply": "回复 | 回复",
+ "globals.terms.report": "报告 | 报告",
+ "globals.terms.required": "必填",
+ "globals.terms.resolve": "解决",
+ "globals.terms.resolved": "已解决",
+ "globals.terms.resolvedAt": "解决于",
+ "globals.terms.role": "角色 | 角色",
+ "globals.terms.rootURL": "Root URL",
+ "globals.terms.search": "搜索",
+ "globals.terms.secondaryColor": "辅助色 | 辅助色",
+ "globals.terms.secret": "密钥 | 密钥",
+ "globals.terms.security": "安全",
+ "globals.terms.select": "选择",
+ "globals.terms.sendWelcomeEmail": "发送欢迎邮件? | 发送欢迎邮件?",
+ "globals.terms.setPassword": "设置密码 | 设置密码",
+ "globals.terms.sharedView": "共享视图 | 共享视图",
+ "globals.terms.sla": "SLA | SLA",
+ "globals.terms.slaMetric": "SLA 指标 | SLA 指标",
+ "globals.terms.slaPolicy": "SLA 策略 | SLA 策略",
+ "globals.terms.small": "小",
+ "globals.terms.smtpHost": "SMTP 主机 | SMTP 主机",
+ "globals.terms.smtpPort": "SMTP 端口 | SMTP 端口",
+ "globals.terms.snooze": "暂停",
+ "globals.terms.snoozed": "已暂停",
+ "globals.terms.solid": "纯色",
+ "globals.terms.someone": "某人",
+ "globals.terms.sso": "SSO | SSO",
+ "globals.terms.status": "状态 | 状态",
+ "globals.terms.subject": "主题 | 主题",
+ "globals.terms.tag": "标签 | 标签",
+ "globals.terms.team": "团队 | 团队",
+ "globals.terms.teamInbox": "团队收件箱 | 团队收件箱",
+ "globals.terms.teammate": "团队成员 | 团队成员",
+ "globals.terms.tellUsMore": "告诉我们更多...",
+ "globals.terms.template": "模板 | 模板",
+ "globals.terms.tenantID": "租户 ID (Tenant ID)",
+ "globals.terms.time": "时间",
+ "globals.terms.timestamp": "时间戳 | 时间戳",
+ "globals.terms.timezone": "时区 | 时区",
+ "globals.terms.title": "标题 | 标题",
+ "globals.terms.tls": "TLS | TLS",
+ "globals.terms.today": "今天",
+ "globals.terms.tryAgain": "重试",
+ "globals.terms.type": "类型 | 类型",
+ "globals.terms.typeMessage": "输入您的消息...",
+ "globals.terms.typing": "正在输入中……",
+ "globals.terms.unAuthorized": "未授权",
+ "globals.terms.unassigned": "未分配",
+ "globals.terms.updatedAt": "更新于",
+ "globals.terms.url": "URL | URL",
+ "globals.terms.usage": "使用情况",
+ "globals.terms.username": "用户名 | 用户名",
+ "globals.terms.users": "用户",
+ "globals.terms.value": "值 | 值",
+ "globals.terms.video": "视频 | 视频",
+ "globals.terms.view": "视图 | 视图",
+ "globals.terms.visibility": "可见性 | 可见性",
+ "globals.terms.visitor": "访客 | 访客",
+ "globals.terms.waiting": "等待中",
+ "globals.terms.warning": "预警 | 预警",
+ "globals.terms.webhook": "Webhook | Webhook",
+ "globals.terms.week": "周 | 周",
+ "globals.terms.white": "白色",
+ "globals.terms.workspace": "工作区",
+ "globals.terms.you": "您",
+ "imageLightbox.next": "下一张图片",
+ "imageLightbox.previous": "上一张图片",
+ "imageLightbox.resetZoom": "重置缩放",
+ "imageLightbox.zoomIn": "放大",
+ "imageLightbox.zoomOut": "缩小",
+ "importer.agentCaseSensitiveNote": "角色和团队必须完全匹配(区分大小写)",
+ "importer.createdAgent": "第 {row} 行:已创建客服 {name} ({email})",
+ "importer.createdTag": "第 {row} 行:已创建标签 \"{name}\"",
+ "importer.csvMustContainHeadersAndData": "CSV 必须包含标题行和至少一行数据",
+ "importer.emailExists": "第 {row} 行 ({email}):错误 - 电子邮件已存在",
+ "importer.errorAssigningTeams": "第 {row} 行 ({email}):错误 - 团队分配失败:{error}",
+ "importer.errorCreating": "第 {row} 行 ({email}):错误 - 创建客服失败:{error}",
+ "importer.errorCreatingTag": "第 {row} 行 ({name}):错误 - 创建标签失败:{error}",
+ "importer.importAlreadyInProgress": "导入已在进行中",
+ "importer.importComplete": "导入完成:共 {total} 项,成功 {success} 项,失败 {errors} 项",
+ "importer.invalidEmail": "第 {row} 行:错误 - 电子邮件格式无效:{email}",
+ "importer.invalidRoles": "第 {row} 行 ({email}):错误 - 无效的角色:{roles}",
+ "importer.invalidTeams": "第 {row} 行 ({email}):错误 - 无效的团队:{teams}",
+ "importer.missingFields": "第 {row} 行:错误 - 缺少必填字段:{fields}",
+ "importer.requiredCSVFormat": "所需的 CSV 格式",
+ "importer.roleRequired": "第 {row} 行 ({email}):错误 - 至少需要一个角色",
+ "importer.startingImport": "开始导入 {count} 个{type}",
+ "importer.tagExists": "第 {row} 行:错误 - 标签已存在:\"{name}\"",
+ "inbox.edit": "编辑收件箱",
+ "inbox.emptyIMAP": "IMAP 配置为空",
+ "inbox.emptySMTP": "SMTP 配置为空",
+ "inbox.newInbox": "新建收件箱",
+ "inbox.oauthAlreadyExists": "此电子邮件的收件箱已存在。请使用重新连接来更新凭据。",
+ "inbox.oauthEmailMismatch": "授权电子邮件与此收件箱不匹配。请使用正确的帐户授权。",
+ "inbox.oauthNotFound": "未找到具有此电子邮件可重新连接的收件箱。",
+ "macro.actionNotAllowed": "不允许 {name} 操作",
+ "macro.actionType": "操作类型",
+ "macro.applied": "宏已应用",
+ "macro.couldNotApply": "无法应用宏",
+ "macro.duplicateActionsNotAllowed": "不允许重复操作",
+ "macro.editMacro": "编辑宏",
+ "macro.new": "新建宏",
+ "macro.partiallyApplied": "宏部分应用",
+ "macro.permissionDenied": "某些宏操作的权限被拒绝",
+ "media.fileEmpty": "此文件为 0 字节,因此不会附加。",
+ "media.fileSizeTooLarge": "文件大小太大,请上传小于 {size} 的文件",
+ "media.fileTypeNotAllowed": "不允许的文件类型",
+ "media.invalidOrExpiredURL": "媒体 URL 无效或已过期",
+ "navigation.away": "离开",
+ "navigation.darkMode": "深色模式",
+ "navigation.logout": "登出",
+ "navigation.reassignReplies": "重新分配回复",
+ "notification.conversationAssigned": "会话已分配给您 #{referenceNumber}",
+ "notification.mentionedInConversation": "{author} 在 #{referenceNumber} 中提及了您",
+ "notification.slaAlert": "SLA {type}:{metric} - #{referenceNumber}",
+ "notification.slaDueIn": "在 {duration} 内到期",
+ "notification.slaOverdue": "逾期 {duration}",
+ "oidc.edit": "编辑 SSO",
+ "oidc.new": "新建 SSO",
+ "placeholders.chatIntroduction": "向我们询问任何事情,或分享您的反馈。",
+ "placeholders.enterUrl": "输入 URL",
+ "placeholders.fieldLabel": "字段标签",
+ "placeholders.fieldPlaceholder": "字段占位符",
+ "placeholders.greetingMessage": "欢迎!今天我们能为您做什么?",
+ "placeholders.introductionMessage": "我们随时为您提供帮助!",
+ "placeholders.linkText": "链接文本",
+ "placeholders.noticeBannerText": "我们的响应时间比平时慢。我们正在努力处理您的消息。",
+ "placeholders.selectAction": "选择操作",
+ "placeholders.selectAgent": "选择客服",
+ "placeholders.selectCsvFile": "选择 CSV 文件",
+ "placeholders.selectEvents": "选择事件",
+ "placeholders.selectField": "选择字段",
+ "placeholders.selectInbox": "选择收件箱",
+ "placeholders.selectOperator": "选择运算符",
+ "placeholders.selectPosition": "选择位置",
+ "placeholders.selectPriority": "选择优先级",
+ "placeholders.selectProtocol": "选择协议",
+ "placeholders.selectProvider": "选择提供商",
+ "placeholders.selectTags": "选择标签",
+ "placeholders.selectTeam": "选择团队",
+ "placeholders.selectTeams": "选择团队",
+ "placeholders.selectType": "选择类型",
+ "placeholders.selectValue": "选择值",
+ "placeholders.startConversation": "开始会话",
+ "placeholders.tellUsAboutYourself": "向我们介绍一下您自己",
+ "replyBox.bcc": "密送",
+ "replyBox.contactEmailMissing": "联系人电子邮件不在收件人中",
+ "replyBox.contactEmailMissingDescription": "联系人的电子邮件 ({email}) 未包含在收件人、抄送或密送中。联系人将不会收到此回复。",
+ "replyBox.emailAddresess": "电子邮件地址,用逗号分隔",
+ "replyBox.invalidEmailsIn": "存在无效的电子邮件",
+ "replyBox.missingTagsDescription": "此会话没有标签。添加标签有助于报告和分类。您仍可以在不加标签的情况下发送。",
+ "replyBox.missingTagsTitle": "此会话没有标签",
+ "replyBox.removeBCC": "移除密送",
+ "replyBox.sendAnyway": "仍然发送",
+ "replyBox.toRequired": "收件人字段中至少需要一个收件人。",
+ "report.agentStatus": "客服状态",
+ "report.chart.newConversations": "新会话",
+ "report.chart.resolvedConversations": "已解决会话",
+ "report.chart.title": "会话趋势",
+ "report.csat.avgRating": "平均评分",
+ "report.csat.cardTitle": "客户满意度(最近 {days} 天)",
+ "report.csat.responseRate": "回复率",
+ "report.csat.responses": "回复数",
+ "report.messages.cardTitle": "消息量(最近 {days} 天)",
+ "report.messages.incoming": "收到的",
+ "report.messages.outgoing": "发出的",
+ "report.messages.perConversation": "每会话",
+ "report.messages.total": "总计",
+ "report.noTagsFound": "未找到标签",
+ "report.openConversations": "打开的会话",
+ "report.sla.avgFirstResp": "平均首次响应时间",
+ "report.sla.avgNextResp": "平均下次响应时间",
+ "report.sla.avgResolution": "平均解决时间",
+ "report.sla.breached": "已违规",
+ "report.sla.cardTitle": "SLA 表现(最近 {days} 天)",
+ "report.sla.compliance": "合规率",
+ "report.sla.firstResponse": "首次响应",
+ "report.sla.met": "达标",
+ "report.sla.nextResponse": "下次响应",
+ "report.sla.resolution": "解决",
+ "report.tags.cardTitle": "标签分布(最近 {days} 天)",
+ "report.tags.tagged": "已标记",
+ "report.tags.topTags": "热门标签",
+ "role.deletionConfirmation": "此操作无法撤消。这将永久删除此角色。",
+ "role.edit": "编辑角色",
+ "role.new": "新建角色",
+ "role.select": "选择角色",
+ "search.adjustSearchTerms": "尝试调整您的搜索词或筛选条件。",
+ "search.minQueryLength": "请至少输入 {length} 个字符进行搜索。",
+ "search.noResultsForQuery": "未找到与查询 `{query}` 相关的结果。尝试其他搜索词。",
+ "search.searchBy": "按参考编号、联系人电子邮件地址或会话中的消息搜索。",
+ "setup.completeYourSetup": "完成您的设置",
+ "setup.createFirstInbox": "创建您的第一个收件箱",
+ "setup.inviteTeammates": "邀请团队成员",
+ "sharedView.allAgents": "所有客服",
+ "sharedView.editSharedView": "编辑共享视图",
+ "sharedView.new": "新建共享视图",
+ "sla.deletionConfirmation": "此操作无法撤消。这将永久删除此 SLA 策略。",
+ "sla.edit": "编辑 SLA 策略",
+ "sla.enterDuration": "输入时长",
+ "sla.firstResponseTimeAfterResolution": "首次响应时间不能晚于解决时间",
+ "sla.met": "SLA 达标",
+ "sla.minimumDurationOneMinute": "时长必须至少为 1 分钟。",
+ "sla.new": "新建 SLA 策略",
+ "sla.overdueBy": "逾期",
+ "sla.selectMetric": "选择 SLA 指标",
+ "status.deletionConfirmation": "此操作无法撤消。这将永久删除此状态。",
+ "status.deniedPermission": "权限被拒绝",
+ "status.disabledFileUpload": "文件上传已禁用",
+ "status.disabledInbox": "收件箱已禁用",
+ "status.edit": "编辑状态",
+ "status.new": "新建状态",
+ "tag.edit": "编辑标签",
+ "tag.new": "新建标签",
+ "team.edit": "编辑团队",
+ "team.new": "新建团队",
+ "template.cannotDeleteBuiltInTemplate": "无法删除内置模板",
+ "template.defaultTemplateAlreadyExists": "默认模板已存在",
+ "template.deletionConfirmation": "此操作无法撤消。这将永久删除此模板。",
+ "template.edit": "编辑模板",
+ "template.new": "新建模板",
+ "toast.apiKeyGenerated": "API 密钥已生成",
+ "toast.authorizationDenied": "授权被拒绝",
+ "toast.avatarUpdated": "头像更新成功",
+ "toast.errorConnectingInbox": "连接收件箱出错",
+ "toast.inboxConnected": "收件箱连接成功",
+ "toast.inboxReconnected": "收件箱重新连接成功",
+ "toast.noNotificationsFound": "未找到通知",
+ "update.newUpdateAvailable": "有新更新可用",
+ "user.accountDisabled": "您的帐户已禁用,请联系管理员",
+ "user.cannotDeleteSystemUser": "无法删除系统用户",
+ "user.invalidEmailPassword": "电子邮件或密码无效。",
+ "user.resetPasswordTokenExpired": "令牌无效或已过期,请重新请求密码重置链接后重试",
+ "user.sameEmailAlreadyExists": "使用相同电子邮件的用户已存在",
+ "user.userAlreadyLoggedIn": "用户已登录",
+ "user.userCannotDeleteSelf": "您无法删除自己",
+ "validation.invalid": "无效",
+ "validation.invalidColor": "无效颜色",
+ "validation.invalidCredential": "无效凭据",
+ "validation.invalidCsvFile": "无效的 CSV 文件",
+ "validation.invalidDomain": "无效域名:{domain}。仅输入域名(例如 example.com),不要带协议或路径。",
+ "validation.invalidDuration": "无效的时长格式。请使用有效格式(例如 30m、1h、48h)。",
+ "validation.invalidEmail": "无效的电子邮件地址",
+ "validation.invalidFromAddress": "发件人电子邮件地址格式无效,请确保它是格式为 `Name ` 的有效电子邮件地址",
+ "validation.invalidIPOrCIDR": "无效的 IP 地址或 CIDR 范围:{entry}",
+ "validation.invalidInbox": "无效收件箱",
+ "validation.invalidKey": "无效键",
+ "validation.invalidPermission": "无效权限",
+ "validation.invalidPortValue": "无效端口值",
+ "validation.invalidProvider": "无效提供商",
+ "validation.invalidSnoozeDuration": "无效的暂停时长",
+ "validation.invalidTimeFormat": "无效的时间格式(HH:mm)",
+ "validation.invalidUrl": "无效 URL",
+ "validation.invalidUser": "无效用户",
+ "validation.invalidValue": "无效值",
+ "validation.messageCannotBeEmpty": "消息不能为空",
+ "validation.minDuration": "{name} 必须至少为 {min}。",
+ "validation.minmax": "必须在 {min} 到 {max} 个字符之间",
+ "validation.minmaxNumber": "必须在 {min} 到 {max} 之间",
+ "validation.notFoundConversation": "会话未找到",
+ "validation.notFoundCsatSurvey": "客户满意度调查未找到",
+ "validation.notFoundCustomAttribute": "自定义属性未找到",
+ "validation.notFoundFile": "文件未找到",
+ "validation.notFoundImport": "导入未找到",
+ "validation.notFoundInbox": "收件箱未找到",
+ "validation.notFoundMacro": "宏未找到",
+ "validation.notFoundMedia": "媒体未找到",
+ "validation.notFoundOidcProvider": "未找到 OIDC 提供程序",
+ "validation.notFoundProvider": "提供商未找到",
+ "validation.notFoundRole": "角色未找到",
+ "validation.notFoundRule": "规则未找到",
+ "validation.notFoundSla": "SLA 未找到",
+ "validation.notFoundTeam": "团队未找到",
+ "validation.notFoundTemplate": "模板未找到",
+ "validation.notFoundUser": "用户未找到",
+ "validation.notFoundView": "视图未找到",
+ "validation.passwordCannotBeEmpty": "密码不能为空",
+ "validation.selectAtLeastOneEvent": "请至少选择一个事件",
+ "validation.selectAtLeastOneRecipient": "请至少选择一个收件人",
+ "validation.selectAtLeastOneRole": "请至少选择一个角色",
+ "validation.subjectCannotBeEmpty": "主题不能为空",
+ "validation.tooLongStatus": "状态太长,最多 {max} 个字符",
+ "view.form.description": "创建并保存自定义筛选视图,以便快速访问您的会话。",
+ "view.form.filter.partiallyFilled": "请确保您已正确填写筛选字段。",
+ "view.form.filter.selectAtLeastOne": "至少选择一个筛选条件。",
+ "view.form.filters.description": "设置一个或多个筛选条件以自定义视图。",
+ "view.form.name.description": "为您的视图输入唯一的名称。",
+ "view.form.name.length": "视图名称应在 2 到 30 个字符之间。",
+ "webhook.edit": "编辑 Webhook",
+ "webhook.new": "新建 Webhook",
+ "webhook.sendTest": "发送测试",
+ "webhook.sentSuccessfully": "Webhook 发送成功",
+ "widget.conversationClosed": "此会话已关闭",
+ "widget.ipBlocked": "访问被拒绝",
+ "widget.prechatForm.startChat": "开始聊天"
+}
diff --git a/internal/activity_log/activity_log.go b/internal/activity_log/activity_log.go
index afd5f4f9..f8a3274b 100644
--- a/internal/activity_log/activity_log.go
+++ b/internal/activity_log/activity_log.go
@@ -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)
}
diff --git a/internal/authz/authz.go b/internal/authz/authz.go
index 6b0ecedd..14a5c2b8 100644
--- a/internal/authz/authz.go
+++ b/internal/authz/authz.go
@@ -1,137 +1,29 @@
-// package authz provides Casbin-based authorization.
package authz
import (
- "fmt"
"slices"
- "strconv"
- "strings"
- "sync"
+ authzmodels "github.com/abhinavxd/libredesk/internal/authz/models"
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
"github.com/abhinavxd/libredesk/internal/envelope"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
- "github.com/casbin/casbin/v2"
- "github.com/casbin/casbin/v2/model"
"github.com/knadh/go-i18n"
+ "github.com/volatiletech/null/v9"
"github.com/zerodha/logf"
)
-// Enforcer is a wrapper around Casbin enforcer.
type Enforcer struct {
- enforcer *casbin.SyncedEnforcer
- // User permissions cache to avoid loading policies into Casbin every time.
- permsCache map[int][]string
- permsCacheMu sync.RWMutex
- lo *logf.Logger
- i18n *i18n.I18n
+ lo *logf.Logger
+ i18n *i18n.I18n
}
-const casbinModel = `
- [request_definition]
- r = sub, obj, act
-
- [policy_definition]
- p = sub, obj, act
-
- [policy_effect]
- e = some(where (p.eft == allow))
-
- [matchers]
- m = r.sub == p.sub && r.obj == p.obj && r.act == p.act
-`
-
-// NewEnforcer initializes a new Enforcer with the hardcoded model
func NewEnforcer(lo *logf.Logger, i18n *i18n.I18n) (*Enforcer, error) {
- m, err := model.NewModelFromString(casbinModel)
- if err != nil {
- return nil, fmt.Errorf("failed to create Casbin model: %v", err)
- }
- e, err := casbin.NewSyncedEnforcer(m)
- if err != nil {
- return nil, fmt.Errorf("failed to create Casbin enforcer: %v", err)
- }
-
- return &Enforcer{
- enforcer: e,
- permsCache: make(map[int][]string),
- lo: lo,
- i18n: i18n,
- }, nil
+ return &Enforcer{lo: lo, i18n: i18n}, nil
}
-// LoadPermissions syncs user permissions with Casbin enforcer by removing existing policies and adding current permissions as new policies.
-func (e *Enforcer) LoadPermissions(user umodels.User) error {
- e.permsCacheMu.RLock()
- cached, exists := e.permsCache[user.ID]
- e.permsCacheMu.RUnlock()
-
- if exists && slices.Equal(cached, user.Permissions) {
- return nil
- }
-
- e.lo.Debug("loading user permissions in enforcer cache", "user_id", user.ID, "permissions", user.Permissions)
-
- // Build all policies and add them to the enforcer.
- var policies [][]string
- userID := strconv.Itoa(user.ID)
- for _, perm := range user.Permissions {
- parts := strings.Split(perm, ":")
- if len(parts) != 2 {
- return fmt.Errorf("invalid permission format: %s", perm)
- }
- policies = append(policies, []string{userID, parts[0], parts[1]})
- }
-
- _, err := e.enforcer.RemoveFilteredPolicy(0, userID)
- if err != nil {
- return fmt.Errorf("failed to remove policies: %v", err)
- }
-
- if len(policies) > 0 {
- _, err = e.enforcer.AddPolicies(policies)
- if err != nil {
- return fmt.Errorf("failed to add policies: %v", err)
- }
- }
-
- // Update permsCache with the latest permissions
- e.permsCacheMu.Lock()
- e.permsCache[user.ID] = slices.Clone(user.Permissions)
- e.permsCacheMu.Unlock()
-
- return nil
-}
-
-// InvalidateUserCache removes user from permsCache to be called when user permissions change.
-func (e *Enforcer) InvalidateUserCache(userID int) {
- e.permsCacheMu.Lock()
- delete(e.permsCache, userID)
- e.permsCacheMu.Unlock()
-}
-
-// InvalidateAllCache clears the entire permissions cache.
-func (e *Enforcer) InvalidateAllCache() {
- e.permsCacheMu.Lock()
- defer e.permsCacheMu.Unlock()
- e.permsCache = make(map[int][]string)
-}
-
-// Enforce checks if a user has permission to perform an action on an object.
+// Enforce returns true if the user's permission list contains "obj:act".
func (e *Enforcer) Enforce(user umodels.User, obj, act string) (bool, error) {
- // Load permissions before enforcing as user perjissions might have changed.
- err := e.LoadPermissions(user)
- if err != nil {
- e.lo.Error("error loading permissions", "user_id", user.ID, "object", obj, "action", act, "error", err)
- return false, err
- }
- // Check if the user has the required permission
- allowed, err := e.enforcer.Enforce(strconv.Itoa(user.ID), obj, act)
- if err != nil {
- e.lo.Error("error checking permission", "user_id", user.ID, "object", obj, "action", act, "error", err)
- return false, fmt.Errorf("error checking permission: %v", err)
- }
- return allowed, nil
+ return slices.Contains(user.Permissions, obj+":"+act), nil
}
// EnforceConversationAccess determines if a user has access to a specific conversation based on their permissions.
@@ -142,73 +34,42 @@ func (e *Enforcer) Enforce(user umodels.User, obj, act string) (bool, error) {
// 4. User has the "read_unassigned" permission and the conversation is not assigned to any user or team.
// Returns true if access is granted, false otherwise. In case of an error while checking permissions returns false and the error.
func (e *Enforcer) EnforceConversationAccess(user umodels.User, conversation cmodels.Conversation) (bool, error) {
- checkPermission := func(action string) (bool, error) {
- allowed, err := e.Enforce(user, "conversations", action)
- if err != nil {
- e.lo.Error("error enforcing permission", "user_id", user.ID, "conversation_id", conversation.ID, "error", err)
- return false, envelope.NewError(envelope.GeneralError, e.i18n.T("globals.messages.somethingWentWrong"), nil)
- }
- if !allowed {
- e.lo.Debug("permission denied", "user_id", user.ID, "action", action, "conversation_id", conversation.ID)
- }
- return allowed, nil
- }
-
- // Check `read` permission
- if allowed, err := checkPermission("read"); err != nil || !allowed {
- return allowed, err
- }
-
- // Check `read_all` permission
- if allowed, err := checkPermission("read_all"); err != nil || allowed {
- return allowed, err
- }
-
- // Check `read_assigned` permission for user-assigned conversations
- if conversation.AssignedUserID.Int == user.ID {
- if allowed, err := checkPermission("read_assigned"); err != nil || allowed {
- return allowed, err
- }
- }
-
- // Check `read_team_all` permission for all team conversations (superset - includes assigned to teammates)
- if conversation.AssignedTeamID.Int > 0 && slices.Contains(user.Teams.IDs(), conversation.AssignedTeamID.Int) {
- if allowed, err := checkPermission("read_team_all"); err != nil || allowed {
- return allowed, err
- }
- }
-
- // Check `read_team_inbox` permission for team-assigned conversations (no user assigned)
- if conversation.AssignedTeamID.Int > 0 && slices.Contains(user.Teams.IDs(), conversation.AssignedTeamID.Int) && conversation.AssignedUserID.Int == 0 {
- if allowed, err := checkPermission("read_team_inbox"); err != nil || allowed {
- return allowed, err
- }
- }
-
- // Check `read_unassigned` permission for unassigned conversations
- if conversation.AssignedUserID.Int == 0 && conversation.AssignedTeamID.Int == 0 {
- if allowed, err := checkPermission("read_unassigned"); err != nil || allowed {
- return allowed, err
- }
- }
- return false, nil
+ return CanReadAssignment(user, conversation.AssignedUserID, conversation.AssignedTeamID), nil
}
-// EnforceMediaAccess checks for read access on linked model to media.
+func CanReadAssignment(user umodels.User, assignedUserID, assignedTeamID null.Int) bool {
+ if !slices.Contains(user.Permissions, authzmodels.PermConversationsRead) {
+ return false
+ }
+ if slices.Contains(user.Permissions, authzmodels.PermConversationsReadAll) {
+ return true
+ }
+ if assignedUserID.Valid && assignedUserID.Int == user.ID &&
+ slices.Contains(user.Permissions, authzmodels.PermConversationsReadAssigned) {
+ return true
+ }
+ if assignedTeamID.Valid && slices.Contains(user.Teams.IDs(), assignedTeamID.Int) {
+ if slices.Contains(user.Permissions, authzmodels.PermConversationsReadTeamAll) {
+ return true
+ }
+ if !assignedUserID.Valid && slices.Contains(user.Permissions, authzmodels.PermConversationsReadTeamInbox) {
+ return true
+ }
+ }
+ if !assignedUserID.Valid && !assignedTeamID.Valid &&
+ slices.Contains(user.Permissions, authzmodels.PermConversationsReadUnassigned) {
+ return true
+ }
+ return false
+}
+
+// EnforceMediaAccess checks read access on the model linked to a media item.
func (e *Enforcer) EnforceMediaAccess(user umodels.User, model string) (bool, error) {
- switch model {
- // TODO: Pick this table / model name from the package/models/models.go
- case "messages":
- allowed, err := e.Enforce(user, model, "read")
- if err != nil {
- e.lo.Error("error enforcing permission", "user_id", user.ID, "model", model, "error", err)
- return false, envelope.NewError(envelope.GeneralError, e.i18n.T("globals.messages.somethingWentWrong"), nil)
- }
- if !allowed {
- return false, envelope.NewError(envelope.UnauthorizedError, e.i18n.T("status.deniedPermission"), nil)
- }
- default:
+ if model != "messages" {
return true, nil
}
+ if !slices.Contains(user.Permissions, "messages:read") {
+ return false, envelope.NewError(envelope.UnauthorizedError, e.i18n.T("status.deniedPermission"), nil)
+ }
return true, nil
}
diff --git a/internal/authz/authz_test.go b/internal/authz/authz_test.go
new file mode 100644
index 00000000..41087adf
--- /dev/null
+++ b/internal/authz/authz_test.go
@@ -0,0 +1,379 @@
+package authz
+
+import (
+ "fmt"
+ "slices"
+ "testing"
+
+ cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
+ tmodels "github.com/abhinavxd/libredesk/internal/team/models"
+ umodels "github.com/abhinavxd/libredesk/internal/user/models"
+ "github.com/knadh/go-i18n"
+ "github.com/lib/pq"
+ "github.com/volatiletech/null/v9"
+ "github.com/zerodha/logf"
+)
+
+func newTestEnforcer(t *testing.T) *Enforcer {
+ t.Helper()
+ tr, err := i18n.New([]byte(`{"_.code":"en","_.name":"English","status.deniedPermission":"denied"}`))
+ if err != nil {
+ t.Fatalf("i18n init: %v", err)
+ }
+ lo := logf.New(logf.Opts{})
+ e, err := NewEnforcer(&lo, tr)
+ if err != nil {
+ t.Fatalf("enforcer init: %v", err)
+ }
+ return e
+}
+
+func user(id int, perms []string, teamIDs ...int) umodels.User {
+ teams := make(tmodels.TeamsCompact, 0, len(teamIDs))
+ for _, tid := range teamIDs {
+ teams = append(teams, tmodels.TeamCompact{ID: tid})
+ }
+ return umodels.User{
+ ID: id,
+ Permissions: pq.StringArray(perms),
+ Teams: teams,
+ }
+}
+
+func conv(assignedUser, assignedTeam int) cmodels.Conversation {
+ c := cmodels.Conversation{}
+ if assignedUser > 0 {
+ c.AssignedUserID = null.IntFrom(assignedUser)
+ }
+ if assignedTeam > 0 {
+ c.AssignedTeamID = null.IntFrom(assignedTeam)
+ }
+ return c
+}
+
+type convAccessCase struct {
+ name string
+ user umodels.User
+ conv cmodels.Conversation
+ want bool
+}
+
+type mediaAccessCase struct {
+ name string
+ user umodels.User
+ model string
+ wantAllow bool
+ wantErr bool
+}
+
+func convAccessCases() []convAccessCase {
+ const (
+ me = 1
+ other = 2
+ myTeam = 10
+ notMine = 20
+ )
+ return []convAccessCase{
+ {"no perms at all", user(me, nil), conv(0, 0), false},
+ {"read missing even with read_all", user(me, []string{"conversations:read_all"}), conv(0, 0), false},
+ {"read missing even with read_assigned + assignment", user(me, []string{"conversations:read_assigned"}), conv(me, 0), false},
+ {"read alone, no scope perm", user(me, []string{"conversations:read"}), conv(0, 0), false},
+ {"read alone, conversation assigned to user", user(me, []string{"conversations:read"}), conv(me, 0), false},
+
+ {"read_all allows any conversation: unassigned", user(me, []string{"conversations:read", "conversations:read_all"}), conv(0, 0), true},
+ {"read_all allows any conversation: assigned to other", user(me, []string{"conversations:read", "conversations:read_all"}), conv(other, notMine), true},
+ {"read_all allows any conversation: assigned to me", user(me, []string{"conversations:read", "conversations:read_all"}), conv(me, myTeam), true},
+
+ {"read_assigned: assigned to user", user(me, []string{"conversations:read", "conversations:read_assigned"}), conv(me, 0), true},
+ {"read_assigned: assigned to other", user(me, []string{"conversations:read", "conversations:read_assigned"}), conv(other, 0), false},
+ {"read_assigned: unassigned", user(me, []string{"conversations:read", "conversations:read_assigned"}), conv(0, 0), false},
+ {"read_assigned: assigned to me + team I'm not in", user(me, []string{"conversations:read", "conversations:read_assigned"}, myTeam), conv(me, notMine), true},
+
+ {"read_team_all: user in team, no user assigned", user(me, []string{"conversations:read", "conversations:read_team_all"}, myTeam), conv(0, myTeam), true},
+ {"read_team_all: user in team, user assigned", user(me, []string{"conversations:read", "conversations:read_team_all"}, myTeam), conv(other, myTeam), true},
+ {"read_team_all: user in team, self assigned", user(me, []string{"conversations:read", "conversations:read_team_all"}, myTeam), conv(me, myTeam), true},
+ {"read_team_all: user not in team", user(me, []string{"conversations:read", "conversations:read_team_all"}, myTeam), conv(0, notMine), false},
+ {"read_team_all: no team assigned", user(me, []string{"conversations:read", "conversations:read_team_all"}, myTeam), conv(0, 0), false},
+ {"read_team_all: user has no teams", user(me, []string{"conversations:read", "conversations:read_team_all"}), conv(0, myTeam), false},
+
+ {"read_team_inbox: user in team, no user assigned", user(me, []string{"conversations:read", "conversations:read_team_inbox"}, myTeam), conv(0, myTeam), true},
+ {"read_team_inbox: user in team but user already assigned", user(me, []string{"conversations:read", "conversations:read_team_inbox"}, myTeam), conv(other, myTeam), false},
+ {"read_team_inbox: user in team and self assigned", user(me, []string{"conversations:read", "conversations:read_team_inbox"}, myTeam), conv(me, myTeam), false},
+ {"read_team_inbox: user not in team", user(me, []string{"conversations:read", "conversations:read_team_inbox"}, myTeam), conv(0, notMine), false},
+ {"read_team_inbox: no team on conversation", user(me, []string{"conversations:read", "conversations:read_team_inbox"}, myTeam), conv(0, 0), false},
+
+ {"read_unassigned: no user no team", user(me, []string{"conversations:read", "conversations:read_unassigned"}), conv(0, 0), true},
+ {"read_unassigned: user assigned only", user(me, []string{"conversations:read", "conversations:read_unassigned"}), conv(other, 0), false},
+ {"read_unassigned: team assigned only", user(me, []string{"conversations:read", "conversations:read_unassigned"}), conv(0, myTeam), false},
+ {"read_unassigned: both assigned", user(me, []string{"conversations:read", "conversations:read_unassigned"}), conv(other, myTeam), false},
+
+ {"combined: read_assigned + read_team_inbox, matches assigned", user(me, []string{"conversations:read", "conversations:read_assigned", "conversations:read_team_inbox"}, myTeam), conv(me, myTeam), true},
+ {"combined: read_assigned + read_team_inbox, matches team_inbox", user(me, []string{"conversations:read", "conversations:read_assigned", "conversations:read_team_inbox"}, myTeam), conv(0, myTeam), true},
+ {"combined: read_assigned + read_team_inbox, neither matches", user(me, []string{"conversations:read", "conversations:read_assigned", "conversations:read_team_inbox"}, myTeam), conv(other, notMine), false},
+ {"combined: all scope perms, fully unassigned", user(me, []string{"conversations:read", "conversations:read_assigned", "conversations:read_team_all", "conversations:read_team_inbox", "conversations:read_unassigned"}, myTeam), conv(0, 0), true},
+
+ // Multi-team user
+ {"multi-team: user in 3 teams, conv on 1st team, read_team_all", user(me, []string{"conversations:read", "conversations:read_team_all"}, myTeam, 11, 12), conv(0, myTeam), true},
+ {"multi-team: user in 3 teams, conv on 2nd team, read_team_all", user(me, []string{"conversations:read", "conversations:read_team_all"}, myTeam, 11, 12), conv(0, 11), true},
+ {"multi-team: user in 3 teams, conv on 3rd team, read_team_all", user(me, []string{"conversations:read", "conversations:read_team_all"}, myTeam, 11, 12), conv(0, 12), true},
+ {"multi-team: user in 3 teams, conv on unrelated team, read_team_all", user(me, []string{"conversations:read", "conversations:read_team_all"}, myTeam, 11, 12), conv(0, notMine), false},
+ {"multi-team: user in 3 teams, conv on 2nd team, read_team_inbox no user", user(me, []string{"conversations:read", "conversations:read_team_inbox"}, myTeam, 11, 12), conv(0, 11), true},
+ {"multi-team: user in 3 teams, conv on 2nd team, read_team_inbox user assigned", user(me, []string{"conversations:read", "conversations:read_team_inbox"}, myTeam, 11, 12), conv(other, 11), false},
+
+ // read_all priority/short-circuit
+ {"read_all overrides denying scopes: only read_assigned would deny", user(me, []string{"conversations:read", "conversations:read_all", "conversations:read_assigned"}), conv(other, notMine), true},
+ {"read_all overrides denying scopes: nothing else matches", user(me, []string{"conversations:read", "conversations:read_all", "conversations:read_unassigned"}, myTeam), conv(other, notMine), true},
+ {"read_all with every other scope: still allow", user(me, []string{"conversations:read", "conversations:read_all", "conversations:read_assigned", "conversations:read_team_all", "conversations:read_team_inbox", "conversations:read_unassigned"}, myTeam), conv(other, notMine), true},
+
+ // OR fallthrough across 3+ scopes
+ {"read_assigned + read_team_all + read_unassigned: only unassigned matches", user(me, []string{"conversations:read", "conversations:read_assigned", "conversations:read_team_all", "conversations:read_unassigned"}, myTeam), conv(0, 0), true},
+ {"read_assigned + read_team_all + read_unassigned: only team_all matches", user(me, []string{"conversations:read", "conversations:read_assigned", "conversations:read_team_all", "conversations:read_unassigned"}, myTeam), conv(other, myTeam), true},
+ {"read_assigned + read_team_all + read_unassigned: only assigned matches", user(me, []string{"conversations:read", "conversations:read_assigned", "conversations:read_team_all", "conversations:read_unassigned"}, myTeam), conv(me, notMine), true},
+ {"read_assigned + read_team_all + read_unassigned: none match", user(me, []string{"conversations:read", "conversations:read_assigned", "conversations:read_team_all", "conversations:read_unassigned"}, myTeam), conv(other, notMine), false},
+ {"read_team_inbox + read_unassigned: team_inbox path", user(me, []string{"conversations:read", "conversations:read_team_inbox", "conversations:read_unassigned"}, myTeam), conv(0, myTeam), true},
+ {"read_team_inbox + read_unassigned: unassigned path", user(me, []string{"conversations:read", "conversations:read_team_inbox", "conversations:read_unassigned"}, myTeam), conv(0, 0), true},
+ {"read_team_inbox + read_unassigned: assigned-to-other deny", user(me, []string{"conversations:read", "conversations:read_team_inbox", "conversations:read_unassigned"}, myTeam), conv(other, 0), false},
+
+ // User + team both assigned, multiple paths
+ {"self assigned + in team: only read_team_all", user(me, []string{"conversations:read", "conversations:read_team_all"}, myTeam), conv(me, myTeam), true},
+ {"self assigned + in team: only read_assigned", user(me, []string{"conversations:read", "conversations:read_assigned"}, myTeam), conv(me, myTeam), true},
+ {"self assigned + in team: only read_team_inbox (user assigned so team_inbox path skipped)", user(me, []string{"conversations:read", "conversations:read_team_inbox"}, myTeam), conv(me, myTeam), false},
+ {"other assigned + team in user teams: read_team_all allows", user(me, []string{"conversations:read", "conversations:read_team_all"}, myTeam), conv(other, myTeam), true},
+ {"other assigned + team in user teams: read_assigned denies (not me)", user(me, []string{"conversations:read", "conversations:read_assigned"}, myTeam), conv(other, myTeam), false},
+ {"other assigned + team in user teams: read_team_inbox denies (user set)", user(me, []string{"conversations:read", "conversations:read_team_inbox"}, myTeam), conv(other, myTeam), false},
+
+ // Self assigned but team not in user's teams
+ {"self assigned + team not mine: read_team_all denies", user(me, []string{"conversations:read", "conversations:read_team_all"}, myTeam), conv(me, notMine), false},
+ {"self assigned + team not mine: read_assigned allows", user(me, []string{"conversations:read", "conversations:read_assigned"}, myTeam), conv(me, notMine), true},
+ {"self assigned + team not mine: read_team_inbox denies", user(me, []string{"conversations:read", "conversations:read_team_inbox"}, myTeam), conv(me, notMine), false},
+
+ // Boundary: user has zero teams
+ {"no teams + read_team_all + team-assigned conv: deny", user(me, []string{"conversations:read", "conversations:read_team_all"}), conv(0, myTeam), false},
+ {"no teams + read_team_inbox + team-assigned conv: deny", user(me, []string{"conversations:read", "conversations:read_team_inbox"}), conv(0, myTeam), false},
+ {"no teams + read_assigned + self-assigned conv: allow", user(me, []string{"conversations:read", "conversations:read_assigned"}), conv(me, 0), true},
+
+ // Duplicate / irrelevant perms in list (shouldn't change semantics)
+ {"duplicate read entries", user(me, []string{"conversations:read", "conversations:read", "conversations:read_all"}), conv(0, 0), true},
+ {"unrelated perms mixed in", user(me, []string{"messages:write", "tags:manage", "conversations:read", "conversations:read_assigned"}), conv(me, 0), true},
+ {"typo perm doesn't match", user(me, []string{"conversations:read", "conversations:readd_all"}), conv(other, 0), false},
+ {"obj typo doesn't match", user(me, []string{"conversations:read", "conversation:read_all"}), conv(other, 0), false},
+ }
+}
+
+func mediaAccessCases() []mediaAccessCase {
+ return []mediaAccessCase{
+ {"messages with read", user(1, []string{"messages:read"}), "messages", true, false},
+ {"messages without read", user(1, nil), "messages", false, true},
+ {"messages with unrelated perms", user(1, []string{"conversations:read"}), "messages", false, true},
+ {"messages with read among many", user(1, []string{"conversations:read_all", "messages:read", "tags:manage"}), "messages", true, false},
+ {"non-messages model with no perms", user(1, nil), "conversations", true, false},
+ {"non-messages model with perms", user(1, []string{"messages:read"}), "contacts", true, false},
+ {"empty model treated as non-messages", user(1, nil), "", true, false},
+ {"case-sensitive: Messages not equal to messages", user(1, nil), "Messages", true, false},
+ }
+}
+
+func TestEnforceConversationAccess(t *testing.T) {
+ e := newTestEnforcer(t)
+ for _, tt := range convAccessCases() {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := e.EnforceConversationAccess(tt.user, tt.conv)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got != tt.want {
+ t.Errorf("got %v want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestCanReadAssignment(t *testing.T) {
+ for _, tt := range convAccessCases() {
+ t.Run(tt.name, func(t *testing.T) {
+ got := CanReadAssignment(tt.user, tt.conv.AssignedUserID, tt.conv.AssignedTeamID)
+ if got != tt.want {
+ t.Errorf("got %v want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestCanReadAssignmentMatchesEnforceConversationAccess(t *testing.T) {
+ e := newTestEnforcer(t)
+ for _, tt := range convAccessCases() {
+ t.Run(tt.name, func(t *testing.T) {
+ helper := CanReadAssignment(tt.user, tt.conv.AssignedUserID, tt.conv.AssignedTeamID)
+ method, err := e.EnforceConversationAccess(tt.user, tt.conv)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if helper != method {
+ t.Errorf("helper=%v method=%v (must stay in sync)", helper, method)
+ }
+ })
+ }
+}
+
+func TestEnforceMediaAccess(t *testing.T) {
+ e := newTestEnforcer(t)
+ for _, tt := range mediaAccessCases() {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := e.EnforceMediaAccess(tt.user, tt.model)
+ if tt.wantErr && err == nil {
+ t.Errorf("expected error, got nil")
+ }
+ if !tt.wantErr && err != nil {
+ t.Errorf("unexpected error: %v", err)
+ }
+ if got != tt.wantAllow {
+ t.Errorf("got %v want %v", got, tt.wantAllow)
+ }
+ })
+ }
+}
+
+func referenceAllow(perms []string, userID, assignedUser, assignedTeam int, userTeams []int) bool {
+ has := func(p string) bool { return slices.Contains(perms, "conversations:"+p) }
+ inTeam := assignedTeam > 0 && slices.Contains(userTeams, assignedTeam)
+
+ if !has("read") {
+ return false
+ }
+ if has("read_all") {
+ return true
+ }
+ if has("read_assigned") && assignedUser == userID && userID > 0 {
+ return true
+ }
+ if inTeam && has("read_team_all") {
+ return true
+ }
+ if inTeam && assignedUser == 0 && has("read_team_inbox") {
+ return true
+ }
+ if assignedUser == 0 && assignedTeam == 0 && has("read_unassigned") {
+ return true
+ }
+ return false
+}
+
+func powerSet(items []string) [][]string {
+ n := len(items)
+ result := make([][]string, 0, 1< 10 {
+ t.Errorf("... and %d more failures", failures-10)
+ }
+ t.Logf("checked %d combinations, %d failures", checked, failures)
+}
+
+func TestExhaustiveMediaAccess(t *testing.T) {
+ e := newTestEnforcer(t)
+
+ allPerms := []string{
+ "messages:read", "messages:write",
+ "conversations:read", "conversations:read_all",
+ "tags:manage", "users:manage",
+ }
+ models := []string{"messages", "conversations", "tags", "users", "contacts", "", "MESSAGES", "Messages"}
+
+ checked := 0
+ for _, perms := range powerSet(allPerms) {
+ for _, m := range models {
+ u := user(1, perms)
+ got, err := e.EnforceMediaAccess(u, m)
+
+ want, wantErr := true, false
+ if m == "messages" && !slices.Contains(perms, "messages:read") {
+ want, wantErr = false, true
+ }
+ checked++
+ if got != want {
+ t.Errorf("perms=%v model=%q: got allow=%v want=%v", perms, m, got, want)
+ }
+ if wantErr && err == nil {
+ t.Errorf("perms=%v model=%q: expected err", perms, m)
+ }
+ if !wantErr && err != nil {
+ t.Errorf("perms=%v model=%q: unexpected err: %v", perms, m, err)
+ }
+ }
+ }
+ t.Logf("checked %d combinations", checked)
+}
+
+func TestPowerSet(t *testing.T) {
+ got := powerSet([]string{"a", "b", "c"})
+ if len(got) != 8 {
+ t.Fatalf("expected 8 subsets for 3 items, got %d", len(got))
+ }
+ seen := map[string]bool{}
+ for _, s := range got {
+ seen[fmt.Sprint(s)] = true
+ }
+ if len(seen) != 8 {
+ t.Errorf("duplicate subsets returned")
+ }
+}
diff --git a/internal/autoassigner/autoassigner.go b/internal/autoassigner/autoassigner.go
index 7f7561e1..cce34be9 100644
--- a/internal/autoassigner/autoassigner.go
+++ b/internal/autoassigner/autoassigner.go
@@ -10,6 +10,7 @@ import (
"sync"
"time"
+ "github.com/abhinavxd/libredesk/internal/conversation"
"github.com/abhinavxd/libredesk/internal/conversation/models"
tmodels "github.com/abhinavxd/libredesk/internal/team/models"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
@@ -28,7 +29,7 @@ const (
type conversationStore interface {
GetUnassignedConversations() ([]models.Conversation, error)
- UpdateConversationUserAssignee(conversationUUID string, userID int, user umodels.User) error
+ ClaimUnassignedConversation(conversationUUID string, userID, expectedTeamID int, user umodels.User) error
ActiveUserConversationsCount(userID int) (int, error)
}
@@ -204,8 +205,8 @@ func (e *Engine) assignConversations() error {
e.lo.Debug("found unassigned conversations", "count", len(unassignedConversations))
}
- for _, conversation := range unassignedConversations {
- teamID := conversation.AssignedTeamID.Int
+ for _, conv := range unassignedConversations {
+ teamID := conv.AssignedTeamID.Int
teamMax := e.teamMaxAutoAssignments[teamID]
poolSize := e.poolSize(teamID)
@@ -215,7 +216,7 @@ func (e *Engine) assignConversations() error {
if err != nil {
// Log other errors.
if err != ErrTeamNotFound && err != ErrNoUsersInPool {
- e.lo.Error("error fetching user from balancer pool", "conversation_uuid", conversation.UUID, "error", err)
+ e.lo.Error("error fetching user from balancer pool", "conversation_uuid", conv.UUID, "error", err)
}
break
}
@@ -238,8 +239,13 @@ func (e *Engine) assignConversations() error {
continue
}
- if err := e.conversationStore.UpdateConversationUserAssignee(conversation.UUID, userID, e.systemUser); err != nil {
- e.lo.Error("error assigning conversation", "conversation_uuid", conversation.UUID, "user_id", userID, "error", err)
+ if err := e.conversationStore.ClaimUnassignedConversation(conv.UUID, userID, teamID, e.systemUser); err != nil {
+ // Already assigned by someone else, stop trying.
+ if errors.Is(err, conversation.ErrConversationAlreadyAssigned) {
+ e.lo.Debug("conversation already assigned, skipping", "conversation_uuid", conv.UUID)
+ break
+ }
+ e.lo.Error("error assigning conversation", "conversation_uuid", conv.UUID, "user_id", userID, "error", err)
continue
}
break
diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go
index cbe72410..ff5d1433 100644
--- a/internal/conversation/conversation.go
+++ b/internal/conversation/conversation.go
@@ -15,6 +15,8 @@ import (
"sync"
"time"
+ "github.com/abhinavxd/libredesk/internal/authz"
+ authzmodels "github.com/abhinavxd/libredesk/internal/authz/models"
"github.com/abhinavxd/libredesk/internal/automation"
amodels "github.com/abhinavxd/libredesk/internal/automation/models"
"github.com/abhinavxd/libredesk/internal/conversation/models"
@@ -49,15 +51,30 @@ var (
//go:embed queries.sql
efs embed.FS
errConversationNotFound = errors.New("conversation not found")
- 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"}
+ 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", "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
@@ -129,6 +146,7 @@ type teamStore interface {
type userStore interface {
Get(int, string, []string) (umodels.User, error)
GetAgent(int, string) (umodels.User, error)
+ GetAgentCachedOrLoad(int) (umodels.User, error)
GetSystemUser() (umodels.User, error)
CreateContact(user *umodels.User) error
UpgradeVisitorToContact(visitorID int) error
@@ -144,7 +162,7 @@ type mediaStore interface {
SetContentID(id int, contentID string) error
GetByModel(id int, model string) ([]mmodels.Media, error)
GetByContentIDs(contentIDs []string, conversationUUID string) ([]mmodels.Media, error)
- ContentIDExists(contentID string) (bool, string, error)
+ ContentIDExists(contentID, conversationUUID string) (bool, string, error)
Upload(fileName, contentType string, content io.ReadSeeker) (string, string, error)
UploadAndInsert(fileName, contentType, contentID string, modelType null.String, modelID null.Int, content io.ReadSeeker, fileSize int, disposition null.String, meta []byte) (mmodels.Media, error)
}
@@ -273,6 +291,7 @@ type queries struct {
// Conversation queries.
GetConversationUUID *sqlx.Stmt `query:"get-conversation-uuid"`
GetConversation *sqlx.Stmt `query:"get-conversation"`
+ GetConversationListItem *sqlx.Stmt `query:"get-conversation-list-item"`
GetConversationsCreatedAfter *sqlx.Stmt `query:"get-conversations-created-after"`
GetUnassignedConversations *sqlx.Stmt `query:"get-unassigned-conversations"`
GetConversations string `query:"get-conversations"`
@@ -287,6 +306,7 @@ type queries struct {
UpsertUserLastSeen *sqlx.Stmt `query:"upsert-user-last-seen"`
MarkConversationUnread *sqlx.Stmt `query:"mark-conversation-unread"`
UpdateConversationAssignedUser *sqlx.Stmt `query:"update-conversation-assigned-user"`
+ ClaimUnassignedConversation *sqlx.Stmt `query:"claim-unassigned-conversation"`
UpdateConversationAssignedTeam *sqlx.Stmt `query:"update-conversation-assigned-team"`
UpdateConversationCustomAttributes *sqlx.Stmt `query:"update-conversation-custom-attributes"`
UpdateConversationPriority *sqlx.Stmt `query:"update-conversation-priority"`
@@ -344,6 +364,10 @@ type queries struct {
// Broadcast queries.
GetActiveLivechatConversationsByAgent *sqlx.Stmt `query:"get-active-livechat-conversations-by-agent"`
+
+ // WS list-subscribe authz.
+ FilterAuthorizedListUUIDs *sqlx.Stmt `query:"filter-authorized-list-uuids"`
+ GetConversationUUIDsByContact *sqlx.Stmt `query:"get-conversation-uuids-by-contact"`
}
// CreateConversation creates a new conversation. If maxConversations > 0, the insert is
@@ -385,6 +409,11 @@ func (c *Manager) CreateConversation(contactID, inboxID int, lastMessage string,
c.lo.Error("error inserting new conversation into the DB", "error", err)
return 0, "", err
}
+ if item, err := c.GetConversationListItem(uuid); err == nil {
+ c.BroadcastNewConversation(&item)
+ } else {
+ c.lo.Error("error fetching conversation list item for broadcast", "uuid", uuid, "error", err)
+ }
return id, uuid, nil
}
@@ -690,11 +719,36 @@ func (c *Manager) UpdateConversationWaitingSince(conversationUUID string, at *ti
// UpdateConversationUserAssignee sets the assignee of a conversation to a specifc user.
func (c *Manager) UpdateConversationUserAssignee(uuid string, assigneeID int, actor umodels.User) error {
- if err := c.UpdateAssignee(uuid, assigneeID, models.AssigneeTypeUser); err != nil {
+ if err := c.updateAssignee(uuid, assigneeID, models.AssigneeTypeUser); err != nil {
return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
}
+ return c.afterUserAssignedHooks(uuid, assigneeID, actor)
+}
- // Refetch the conversation to get the updated details.
+// ClaimUnassignedConversation atomically assigns a conversation only if still unassigned and still in expectedTeamID, else returns ErrConversationAlreadyAssigned.
+func (c *Manager) ClaimUnassignedConversation(uuid string, assigneeID, expectedTeamID int, actor umodels.User) error {
+ prev, prevErr := c.GetConversationListItem(uuid)
+
+ res, err := c.q.ClaimUnassignedConversation.Exec(uuid, assigneeID, expectedTeamID)
+ if err != nil {
+ c.lo.Error("error claiming unassigned conversation", "uuid", uuid, "error", err)
+ return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
+ }
+ rows, err := res.RowsAffected()
+ if err != nil {
+ c.lo.Error("error reading rows affected for conversation claim", "uuid", uuid, "error", err)
+ return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
+ }
+ if rows == 0 {
+ return ErrConversationAlreadyAssigned
+ }
+
+ c.broadcastReassignment(uuid, prev, prevErr)
+ return c.afterUserAssignedHooks(uuid, assigneeID, actor)
+}
+
+// afterUserAssignedHooks runs the side-effects shared by every user-assignment path.
+func (c *Manager) afterUserAssignedHooks(uuid string, assigneeID int, actor umodels.User) error {
conversation, err := c.GetConversation(0, uuid, "")
if err != nil {
return err
@@ -707,10 +761,8 @@ func (c *Manager) UpdateConversationUserAssignee(uuid string, assigneeID int, ac
"conversation": conversation,
})
- // Evaluate automation rules.
c.automation.EvaluateConversationUpdateRules(conversation, amodels.EventConversationUserAssigned)
- // Send notifications to assignee (skip if self-assigning).
if assigneeID != actor.ID {
if err := c.NotifyAssignment([]int{assigneeID}, conversation); err != nil {
c.lo.Error("error sending assignment notification", "error", err)
@@ -721,7 +773,6 @@ func (c *Manager) UpdateConversationUserAssignee(uuid string, assigneeID int, ac
return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
}
- // Broadcast conversation update to widget clients with assignee info.
agent, err := c.userStore.GetAgent(assigneeID, "")
if err == nil {
c.SignAvatarURL(&agent.AvatarURL)
@@ -748,7 +799,7 @@ func (c *Manager) UpdateConversationTeamAssignee(uuid string, teamID int, actor
}
previousAssignedTeamID := conversation.AssignedTeamID.Int
- if err := c.UpdateAssignee(uuid, teamID, models.AssigneeTypeTeam); err != nil {
+ if err := c.updateAssignee(uuid, teamID, models.AssigneeTypeTeam); err != nil {
return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
}
@@ -794,28 +845,18 @@ func (c *Manager) UpdateConversationTeamAssignee(uuid string, teamID int, actor
return nil
}
-// UpdateAssignee updates the assignee of a conversation.
-func (c *Manager) UpdateAssignee(uuid string, assigneeID int, assigneeType string) error {
- var prop string
- switch assigneeType {
- case models.AssigneeTypeUser:
- prop = "assigned_user_id"
- if _, err := c.q.UpdateConversationAssignedUser.Exec(uuid, assigneeID); err != nil {
- c.lo.Error("error updating conversation assignee", "error", err)
- return fmt.Errorf("updating assignee: %w", err)
- }
- case models.AssigneeTypeTeam:
- prop = "assigned_team_id"
- if _, err := c.q.UpdateConversationAssignedTeam.Exec(uuid, assigneeID); err != nil {
- c.lo.Error("error updating conversation assignee", "error", err)
- return fmt.Errorf("updating assignee: %w", err)
- }
- default:
- return fmt.Errorf("invalid assignee type: %s", assigneeType)
+// broadcastReassignment broadcasts a reassignment to agents, given the conversation's prior list item.
+func (c *Manager) broadcastReassignment(uuid string, prev models.ConversationListItem, prevErr error) {
+ next, nextErr := c.GetConversationListItem(uuid)
+ if nextErr != nil {
+ c.lo.Error("error fetching conversation list item for assignee broadcast", "uuid", uuid, "error", nextErr)
+ return
}
- // Broadcast update to all subscribers.
- c.BroadcastConversationUpdate(uuid, map[string]any{prop: assigneeID})
- return nil
+ var prevPtr *models.ConversationListItem
+ if prevErr == nil {
+ prevPtr = &prev
+ }
+ c.BroadcastConvReassignment(prevPtr, &next)
}
// UpdateConversationPriority updates the priority of a conversation.
@@ -917,7 +958,6 @@ func (c *Manager) UpdateConversationStatus(uuid string, statusID int, status, sn
return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
}
- // Broadcast updates using websocket.
agentData := map[string]any{"status": status}
if oldStatus != models.StatusResolved && status == models.StatusResolved {
resolvedAt := conversationBeforeChange.ResolvedAt.Time
@@ -926,6 +966,18 @@ func (c *Manager) UpdateConversationStatus(uuid string, statusID int, status, sn
}
agentData["resolved_at"] = resolvedAt.Format(time.RFC3339)
}
+ if status == models.StatusClosed {
+ closedAt := time.Now()
+ if conversation.ID != 0 && !conversation.ClosedAt.Time.IsZero() {
+ closedAt = conversation.ClosedAt.Time
+ }
+ agentData["closed_at"] = closedAt.Format(time.RFC3339)
+ }
+ if status == models.StatusSnoozed {
+ agentData["snoozed_until"] = snoozeUntil.Format(time.RFC3339)
+ } else if oldStatus == models.StatusSnoozed {
+ agentData["snoozed_until"] = nil
+ }
c.BroadcastConversationUpdate(uuid, agentData)
// Evaluate automation rules.
@@ -1022,6 +1074,8 @@ func (c *Manager) SetConversationTags(uuid string, action string, tagNames []str
}
}
+ c.BroadcastConversationUpdate(uuid, map[string]any{"tags": newTags})
+
return nil
}
@@ -1249,11 +1303,16 @@ func (m *Manager) ApplySLA(conversation models.Conversation, policyID int, actor
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
- // Record the SLA application as an activity.
- if err := m.RecordSLASet(conversation.UUID, policy.Name, actor); err != nil {
- return err
+ if updated, ferr := m.GetConversation(0, conversation.UUID, ""); ferr == nil {
+ m.BroadcastConversationUpdate(conversation.UUID, map[string]any{
+ "sla_policy_id": updated.SLAPolicyID.Int,
+ "applied_sla_id": updated.AppliedSLAID.Int,
+ "first_response_deadline_at": nullTimeOrNil(updated.FirstResponseDueAt),
+ "resolution_deadline_at": nullTimeOrNil(updated.ResolutionDueAt),
+ })
}
- return nil
+
+ return m.RecordSLASet(conversation.UUID, policy.Name, actor)
}
// ApplyAction applies an action to a conversation, this can be called from multiple packages across the app to perform actions on conversations.
@@ -1352,6 +1411,7 @@ func (m *Manager) ApplyAction(action amodels.RuleAction, conv models.Conversatio
// RemoveConversationAssignee removes assigned user from a conversation.
func (m *Manager) RemoveConversationAssignee(uuid, typ string, actor umodels.User) error {
+ prev, prevErr := m.GetConversationListItem(uuid)
if _, err := m.q.RemoveConversationAssignee.Exec(uuid, typ); err != nil {
m.lo.Error("error removing conversation assignee", "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.errorUpdatingConversation"), nil)
@@ -1377,13 +1437,16 @@ func (m *Manager) RemoveConversationAssignee(uuid, typ string, actor umodels.Use
}
}
- // Broadcast ws update.
- switch typ {
- case models.AssigneeTypeUser:
- m.BroadcastConversationUpdate(uuid, map[string]any{"assigned_user_id": nil})
- case models.AssigneeTypeTeam:
- m.BroadcastConversationUpdate(uuid, map[string]any{"assigned_team_id": nil})
+ next, nextErr := m.GetConversationListItem(uuid)
+ if nextErr != nil {
+ m.lo.Error("error fetching conversation list item for unassign broadcast", "uuid", uuid, "error", nextErr)
+ return nil
}
+ var prevPtr *models.ConversationListItem
+ if prevErr == nil {
+ prevPtr = &prev
+ }
+ m.BroadcastConvReassignment(prevPtr, &next)
return nil
}
@@ -1446,11 +1509,13 @@ func (m *Manager) SendCSATReply(actorUserID int, conversation models.Conversatio
// DeleteConversation deletes a conversation.
func (m *Manager) DeleteConversation(uuid string) error {
- m.lo.Info("deleting conversation", "uuid", uuid)
- if _, err := m.q.DeleteConversation.Exec(uuid); err != nil {
- m.lo.Error("error deleting conversation", "error", err)
+ res, err := m.q.DeleteConversation.Exec(uuid)
+ if err != nil {
+ m.lo.Error("error deleting conversation", "uuid", uuid, "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
+ rows, _ := res.RowsAffected()
+ m.lo.Info("deleted conversation", "uuid", uuid, "rows_affected", rows)
return nil
}
@@ -1541,35 +1606,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 {
@@ -1621,52 +1657,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{
@@ -1674,11 +1664,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.
@@ -1874,3 +1874,129 @@ func (m *Manager) calculateBusinessHoursInfo(conversation models.Conversation) (
func (c *Manager) formatRefMarker(ref string) string {
return strings.ReplaceAll(c.subjectRefFormat, "{ref}", ref)
}
+
+func (c *Manager) GetConversationListItem(uuid string) (models.ConversationListItem, error) {
+ var item models.ConversationListItem
+ if err := c.q.GetConversationListItem.Get(&item, uuid); err != nil {
+ return item, err
+ }
+ return item, nil
+}
+
+func (c *Manager) AuthorizedConnectedAgentIDs(assignedUserID, assignedTeamID null.Int) []int {
+ connected := c.wsHub.ConnectedUserIDs()
+ if len(connected) == 0 {
+ return nil
+ }
+ out := make([]int, 0, len(connected))
+ for _, id := range connected {
+ agent, err := c.userStore.GetAgentCachedOrLoad(id)
+ if err != nil {
+ continue
+ }
+ if !agent.Enabled {
+ continue
+ }
+ if authz.CanReadAssignment(agent, assignedUserID, assignedTeamID) {
+ out = append(out, id)
+ }
+ }
+ return out
+}
+
+// FilterAuthorizedListUUIDs returns the subset of UUIDs the agent can read, mirroring the conversation read-permission chain.
+func (c *Manager) FilterAuthorizedListUUIDs(agentID int, uuids []string) ([]string, error) {
+ if len(uuids) == 0 {
+ return nil, nil
+ }
+ user, err := c.userStore.GetAgentCachedOrLoad(agentID)
+ if err != nil {
+ return nil, err
+ }
+ if !user.Enabled {
+ return nil, nil
+ }
+ var authorized []string
+ err = c.q.FilterAuthorizedListUUIDs.Select(&authorized,
+ pq.Array(uuids),
+ user.ID,
+ pq.Array(user.Teams.IDs()),
+ slices.Contains(user.Permissions, authzmodels.PermConversationsRead),
+ slices.Contains(user.Permissions, authzmodels.PermConversationsReadAll),
+ slices.Contains(user.Permissions, authzmodels.PermConversationsReadAssigned),
+ slices.Contains(user.Permissions, authzmodels.PermConversationsReadTeamAll),
+ slices.Contains(user.Permissions, authzmodels.PermConversationsReadTeamInbox),
+ slices.Contains(user.Permissions, authzmodels.PermConversationsReadUnassigned),
+ )
+ if err != nil {
+ c.lo.Error("error filtering authorized list uuids", "agent_id", agentID, "error", err)
+ return nil, err
+ }
+ return authorized, nil
+}
+
+// updateAssignee updates the team / user assignee of a conversation and broadcasts the reassignment to connected clients.
+func (c *Manager) updateAssignee(uuid string, assigneeID int, assigneeType string) error {
+ prev, prevErr := c.GetConversationListItem(uuid)
+ switch assigneeType {
+ case models.AssigneeTypeUser:
+ if _, err := c.q.UpdateConversationAssignedUser.Exec(uuid, assigneeID); err != nil {
+ c.lo.Error("error updating conversation assignee", "error", err)
+ return fmt.Errorf("updating assignee: %w", err)
+ }
+ case models.AssigneeTypeTeam:
+ if _, err := c.q.UpdateConversationAssignedTeam.Exec(uuid, assigneeID); err != nil {
+ c.lo.Error("error updating conversation assignee", "error", err)
+ return fmt.Errorf("updating assignee: %w", err)
+ }
+ default:
+ return fmt.Errorf("invalid assignee type: %s", assigneeType)
+ }
+ c.broadcastReassignment(uuid, prev, prevErr)
+ 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)
+ }
+}
diff --git a/internal/conversation/drafts.go b/internal/conversation/drafts.go
index 5e132a34..a62be6d3 100644
--- a/internal/conversation/drafts.go
+++ b/internal/conversation/drafts.go
@@ -3,31 +3,35 @@ package conversation
import (
"context"
"encoding/json"
+ "strings"
"time"
"github.com/abhinavxd/libredesk/internal/conversation/models"
"github.com/abhinavxd/libredesk/internal/envelope"
)
-// UpsertConversationDraft saves or updates a draft for a conversation.
func (m *Manager) UpsertConversationDraft(conversationID, userID int, content string, meta json.RawMessage) (models.ConversationDraft, error) {
var draft models.ConversationDraft
+ content = rewriteInlineImagesToCID(content)
if err := m.q.UpsertConversationDraft.Get(&draft, conversationID, userID, content, meta); err != nil {
m.lo.Error("error upserting conversation draft", "conversation_id", conversationID, "user_id", userID, "error", err)
return draft, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
+ draft.Content = m.resolveDraftInlineCIDs(draft.Content)
return draft, nil
}
-// GetAllUserDrafts retrieves all drafts for a user.
func (m *Manager) GetAllUserDrafts(userID int) ([]models.ConversationDraft, error) {
var drafts = make([]models.ConversationDraft, 0)
if err := m.q.GetAllUserDrafts.Select(&drafts, userID); err != nil {
m.lo.Error("error fetching user drafts", "user_id", userID, "error", err)
return nil, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
+ for i := range drafts {
+ drafts[i].Content = m.resolveDraftInlineCIDs(drafts[i].Content)
+ }
return drafts, nil
}
@@ -62,3 +66,19 @@ func (m *Manager) DeleteStaleDrafts(ctx context.Context, retentionPeriod time.Du
return nil
}
+
+func (m *Manager) resolveDraftInlineCIDs(content string) string {
+ cids := extractInlineContentIDs(content)
+ for _, cid := range cids {
+ uuid := strings.TrimPrefix(cid, "ldsk-")
+ if uuid == "" {
+ continue
+ }
+ media, err := m.mediaStore.Get(0, uuid)
+ if err != nil {
+ continue
+ }
+ content = strings.ReplaceAll(content, "cid:"+cid, m.mediaStore.GetURL(media.UUID, media.ContentType, media.Filename))
+ }
+ return content
+}
diff --git a/internal/conversation/message.go b/internal/conversation/message.go
index 838db831..cc513366 100644
--- a/internal/conversation/message.go
+++ b/internal/conversation/message.go
@@ -7,10 +7,12 @@ import (
"encoding/json"
"errors"
"fmt"
+ "net/mail"
"path/filepath"
"regexp"
"slices"
"strings"
+ "text/template"
"time"
"github.com/abhinavxd/libredesk/internal/attachment"
@@ -38,6 +40,16 @@ const (
// Matches
and captures the URL for downstream parsing.
var imgSrcPattern = regexp.MustCompile(`(?i)
]*?\bsrc=["']([^"']*)["']`)
+// fromNameVars is the template context for an inbox's from-name template.
+type fromNameVars struct {
+ Agent fromNameAgent
+ Inbox fromNameInbox
+}
+
+type fromNameAgent struct{ FirstName, LastName, FullName string }
+
+type fromNameInbox struct{ Name string }
+
// Run starts a pool of worker goroutines to handle message dispatching via inbox's channel and processes incoming messages. It scans for
// pending outgoing messages at the specified read interval and pushes them to the outgoing queue to be sent.
func (m *Manager) Run(ctx context.Context, incomingQWorkers, outgoingQWorkers, scanInterval time.Duration) {
@@ -167,8 +179,7 @@ func (m *Manager) sendOutgoingMessage(message models.Message) {
outbound := message.ToOutbound()
if inb.Channel() == inbox.ChannelEmail {
- // Set from address of the inbox
- outbound.From = inb.FromAddress()
+ outbound.From = m.emailFromAddress(inb, message)
// Set "In-Reply-To" and "References" headers for email threading.
outbound.References, outbound.InReplyTo = m.BuildEmailThreadingHeaders(message.ConversationID, outbound.SourceID)
@@ -348,6 +359,23 @@ func (m *Manager) GetConversationMessages(conversationUUID string, page, pageSiz
return messages, pageSize, nil
}
+// GetAllConversationMessages returns all messages in a conversation in chronological order.
+func (m *Manager) GetAllConversationMessages(conversationUUID string, private *bool, msgTypes []string) ([]models.Message, error) {
+ var all []models.Message
+ for page := 1; ; page++ {
+ messages, _, err := m.GetConversationMessages(conversationUUID, page, maxMessagesPerPage, private, msgTypes)
+ if err != nil {
+ return nil, err
+ }
+ all = append(all, messages...)
+ if len(messages) == 0 || len(all) >= messages[0].Total {
+ break
+ }
+ }
+ slices.Reverse(all)
+ return all, nil
+}
+
// GetMessage retrieves a message by UUID.
func (m *Manager) GetMessage(uuid string) (models.Message, error) {
var message models.Message
@@ -674,8 +702,13 @@ func (m *Manager) InsertMessage(message *models.Message) error {
// Update conversation last message details (also conditionally updates last_interaction if not activity/private).
m.UpdateConversationLastMessage(message.ConversationID, message.ConversationUUID, lastMessage, message.SenderType, message.Type, message.Private, message.CreatedAt, message.SenderID)
- // Broadcast new message with computed preview.
- m.BroadcastNewMessage(message, lastMessage)
+ var convItem *models.ConversationListItem
+ if item, err := m.GetConversationListItem(message.ConversationUUID); err == nil {
+ convItem = &item
+ } else {
+ m.lo.Error("error fetching conversation list item for broadcast", "uuid", message.ConversationUUID, "error", err)
+ }
+ m.BroadcastNewMessage(message, convItem, lastMessage)
}
// Refetch the message to get all fields populated (e.g., author, media URLs).
@@ -836,6 +869,7 @@ func (m *Manager) ProcessIncomingMessage(in models.IncomingMessage) (models.Mess
Type: umodels.UserTypeContact,
}
if err := m.userStore.CreateContact(&user); err != nil {
+ m.lo.Error("error creating contact for incoming message", "message_source_id", in.SourceID.String, "error", err)
return models.Message{}, fmt.Errorf("creating contact: %w", err)
}
senderID = user.ID
@@ -847,6 +881,7 @@ func (m *Manager) ProcessIncomingMessage(in models.IncomingMessage) (models.Mess
if conversationID == 0 {
conversationID, conversationUUID, isNewConversation, err = m.findOrCreateConversation(in)
if err != nil {
+ m.lo.Error("error finding or creating conversation for incoming message", "message_source_id", in.SourceID.String, "error", err)
return models.Message{}, err
}
}
@@ -865,9 +900,9 @@ func (m *Manager) ProcessIncomingMessage(in models.IncomingMessage) (models.Mess
// Upload message attachments. On failure, delete the conversation if it was just created for this message.
if upErr := m.UploadMessageAttachments(&msg); upErr != nil {
- m.lo.Error("error uploading message attachments", "message_source_id", in.SourceID, "error", upErr)
+ m.lo.Error("error uploading message attachments", "message_source_id", in.SourceID.String, "error", upErr)
if isNewConversation && conversationUUID != "" {
- m.lo.Info("deleting conversation as message attachment upload failed", "conversation_uuid", conversationUUID, "message_source_id", in.SourceID)
+ m.lo.Info("deleting conversation as message attachment upload failed", "conversation_uuid", conversationUUID, "message_source_id", in.SourceID.String)
if err := m.DeleteConversation(conversationUUID); err != nil {
return models.Message{}, fmt.Errorf("deleting conversation after message attachment upload failure: %w", err)
}
@@ -875,9 +910,15 @@ func (m *Manager) ProcessIncomingMessage(in models.IncomingMessage) (models.Mess
return models.Message{}, fmt.Errorf("uploading message attachments: %w", upErr)
}
- // Insert message.
+ // Insert message. On failure, delete the conversation if it was just created for this message.
if err = m.InsertMessage(&msg); err != nil {
- return models.Message{}, err
+ m.lo.Error("error inserting incoming message", "message_source_id", in.SourceID.String, "conversation_uuid", conversationUUID, "is_new", isNewConversation, "error", err)
+ if isNewConversation && conversationUUID != "" {
+ if delErr := m.DeleteConversation(conversationUUID); delErr != nil {
+ return models.Message{}, fmt.Errorf("deleting conversation after message insert failure: %w", delErr)
+ }
+ }
+ return models.Message{}, fmt.Errorf("inserting message: %w", err)
}
// When a customer replies to a continuity emailsync the message to their live chat widget via WebSocket.
@@ -1207,9 +1248,13 @@ func (m *Manager) UploadMessageAttachments(message *models.Message) error {
contentID = storedCID
}
- // Sanitize filename.
attachment.Name = stringutil.SanitizeFilename(attachment.Name)
+ if len(attachment.Content) == 0 {
+ m.lo.Warn("skipping empty attachment", "name", attachment.Name, "content_id", contentID, "content_type", attachment.ContentType, "disposition", attachment.Disposition, "message_source_id", message.SourceID.String, "conversation_uuid", message.ConversationUUID)
+ continue
+ }
+
m.lo.Debug("uploading message attachment", "name", attachment.Name, "content_id", contentID, "size", attachment.Size, "content_type", attachment.ContentType, "disposition", attachment.Disposition)
// Upload and insert entry in media table.
@@ -1227,7 +1272,7 @@ func (m *Manager) UploadMessageAttachments(message *models.Message) error {
[]byte("{}"), /** meta **/
)
if err != nil {
- m.lo.Error("failed to upload attachment", "name", attachment.Name, "error", err)
+ m.lo.Error("failed to upload attachment", "name", attachment.Name, "content_type", attachment.ContentType, "size", attachment.Size, "content_id", contentID, "disposition", attachment.Disposition, "conversation_uuid", message.ConversationUUID, "message_source_id", message.SourceID.String, "error", err)
return fmt.Errorf("failed to upload media %s: %w", attachment.Name, err)
}
@@ -1531,9 +1576,65 @@ func (m *Manager) findExistingMedia(rawContentID, conversationUUID string) (stri
if !strings.HasPrefix(rawContentID, "ldsk-") {
storedCID = conversationUUID + "_" + rawContentID
}
- exists, mediaUUID, err := m.mediaStore.ContentIDExists(storedCID)
+ exists, mediaUUID, err := m.mediaStore.ContentIDExists(storedCID, conversationUUID)
if err != nil {
m.lo.Error("error checking media existence by content ID", "content_id", storedCID, "error", err)
}
return storedCID, exists, mediaUUID
}
+
+// 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()
+
+ tpl := inb.FromNameTemplate()
+ if tpl == "" || message.SenderType != models.SenderTypeAgent {
+ return from
+ }
+
+ agent, err := m.userStore.GetAgentCachedOrLoad(message.SenderID)
+ if err != nil {
+ m.lo.Error("error fetching agent for from name template", "error", err, "sender_id", message.SenderID)
+ return from
+ }
+ if agent.IsSystemUser() {
+ return from
+ }
+
+ addr, err := mail.ParseAddress(from)
+ if err != nil {
+ m.lo.Error("error parsing inbox from address for name template", "error", err, "from", from)
+ return from
+ }
+
+ firstName := strings.TrimSpace(agent.FirstName)
+ lastName := strings.TrimSpace(agent.LastName)
+ data := fromNameVars{
+ Agent: fromNameAgent{
+ FirstName: firstName,
+ LastName: lastName,
+ FullName: strings.TrimSpace(firstName + " " + lastName),
+ },
+ Inbox: fromNameInbox{Name: inb.Name()},
+ }
+
+ t, err := template.New("from").Parse(tpl)
+ if err != nil {
+ m.lo.Error("error parsing from name template", "error", err, "template", tpl)
+ return from
+ }
+
+ var buf bytes.Buffer
+ if err := t.Execute(&buf, data); err != nil {
+ m.lo.Error("error executing from name template", "error", err, "template", tpl)
+ return from
+ }
+
+ name := strings.TrimSpace(buf.String())
+ if name == "" {
+ return from
+ }
+ addr.Name = name
+ return addr.String()
+}
diff --git a/internal/conversation/models/models.go b/internal/conversation/models/models.go
index 600274ae..ebc4f27e 100644
--- a/internal/conversation/models/models.go
+++ b/internal/conversation/models/models.go
@@ -135,6 +135,8 @@ type ConversationListItem struct {
LastInteractionSender null.String `db:"last_interaction_sender" json:"last_interaction_sender"`
NextSLADeadlineAt null.Time `db:"next_sla_deadline_at" json:"next_sla_deadline_at"`
PriorityID null.Int `db:"priority_id" json:"priority_id"`
+ AssignedUserID null.Int `db:"assigned_user_id" json:"assigned_user_id"`
+ AssignedTeamID null.Int `db:"assigned_team_id" json:"assigned_team_id"`
UnreadMessageCount int `db:"unread_message_count" json:"unread_message_count"`
Status null.String `db:"status" json:"status"`
Priority null.String `db:"priority" json:"priority"`
@@ -201,6 +203,9 @@ type Conversation struct {
LastContinuityEmailSentAt null.Time `db:"last_continuity_email_sent_at" json:"-"`
LastInboundAt null.Time `db:"last_inbound_at" json:"last_inbound_at"`
ContactLastInboundAt null.Time `db:"contact_last_inbound_at" json:"contact_last_inbound_at"`
+ CSATRating null.Int `db:"csat_rating" json:"csat_rating"`
+ CSATFeedback null.String `db:"csat_feedback" json:"csat_feedback"`
+ CSATRespondedAt null.Time `db:"csat_responded_at" json:"csat_responded_at"`
PreviousConversations []PreviousConversation `db:"-" json:"previous_conversations"`
}
@@ -268,6 +273,13 @@ type MessageAuthor struct {
LastActiveAt null.Time `db:"last_active_at" json:"last_active_at"`
}
+func (a *MessageAuthor) FullName() string {
+ if a.LastName == "" {
+ return a.FirstName
+ }
+ return a.FirstName + " " + a.LastName
+}
+
type ConversationCounts struct {
TotalAssigned int `db:"total_assigned" json:"total_assigned"`
UnresolvedCount int `db:"unresolved_count" json:"unresolved_count"`
diff --git a/internal/conversation/queries.sql b/internal/conversation/queries.sql
index 1f699b76..e635cdcf 100644
--- a/internal/conversation/queries.sql
+++ b/internal/conversation/queries.sql
@@ -65,6 +65,8 @@ SELECT
conversations.last_interaction_sender,
conversations.next_sla_deadline_at,
conversations.priority_id,
+ conversations.assigned_user_id,
+ conversations.assigned_team_id,
(
SELECT CASE WHEN COUNT(*) > 9 THEN 10 ELSE COUNT(*) END
FROM (
@@ -119,6 +121,65 @@ SELECT
) nxt_resp_event ON true
WHERE 1=1 %s
+-- name: get-conversation-list-item
+SELECT
+ conversations.id,
+ conversations.created_at,
+ conversations.updated_at,
+ conversations.uuid,
+ conversations.reference_number,
+ conversations.waiting_since,
+ users.created_at as "contact.created_at",
+ users.updated_at as "contact.updated_at",
+ users.first_name as "contact.first_name",
+ users.last_name as "contact.last_name",
+ users.email as "contact.email",
+ users.avatar_url as "contact.avatar_url",
+ inboxes.channel as inbox_channel,
+ inboxes.name as inbox_name,
+ conversations.sla_policy_id,
+ conversations.first_reply_at,
+ conversations.last_reply_at,
+ conversations.resolved_at,
+ conversations.subject,
+ conversations.last_message,
+ conversations.last_message_at,
+ conversations.last_message_sender,
+ conversations.last_interaction,
+ conversations.last_interaction_at,
+ conversations.last_interaction_sender,
+ conversations.next_sla_deadline_at,
+ conversations.priority_id,
+ conversations.assigned_user_id,
+ conversations.assigned_team_id,
+ conversation_statuses.name as status,
+ conversation_priorities.name as priority,
+ as_latest.first_response_deadline_at,
+ as_latest.resolution_deadline_at,
+ as_latest.id as applied_sla_id,
+ nxt_resp_event.deadline_at AS next_response_deadline_at,
+ nxt_resp_event.met_at as next_response_met_at
+FROM conversations
+JOIN users ON contact_id = users.id
+JOIN inboxes ON inbox_id = inboxes.id
+LEFT JOIN conversation_statuses ON status_id = conversation_statuses.id
+LEFT JOIN conversation_priorities ON priority_id = conversation_priorities.id
+LEFT JOIN LATERAL (
+ SELECT id, first_response_deadline_at, resolution_deadline_at
+ FROM applied_slas
+ WHERE conversation_id = conversations.id
+ ORDER BY created_at DESC LIMIT 1
+) as_latest ON true
+LEFT JOIN LATERAL (
+ SELECT se.deadline_at, se.met_at
+ FROM sla_events se
+ WHERE se.applied_sla_id = as_latest.id
+ AND se.type = 'next_response'
+ ORDER BY se.created_at DESC
+ LIMIT 1
+) nxt_resp_event ON true
+WHERE conversations.uuid = $1::uuid;
+
-- name: get-conversation
SELECT
c.id,
@@ -188,10 +249,20 @@ SELECT
nxt_resp_event.met_at as next_response_met_at,
c.last_continuity_email_sent_at,
c.last_inbound_at,
- (SELECT MAX(c2.last_inbound_at) FROM conversations c2 WHERE c2.contact_id = c.contact_id AND c2.inbox_id = c.inbox_id) AS contact_last_inbound_at
+ (SELECT MAX(c2.last_inbound_at) FROM conversations c2 WHERE c2.contact_id = c.contact_id AND c2.inbox_id = c.inbox_id) AS contact_last_inbound_at,
+ csat.rating as csat_rating,
+ csat.feedback as csat_feedback,
+ csat.response_timestamp as csat_responded_at
FROM conversations c
JOIN users ct ON c.contact_id = ct.id
JOIN inboxes inb ON c.inbox_id = inb.id
+LEFT JOIN LATERAL (
+ SELECT rating, feedback, response_timestamp
+ FROM csat_responses
+ WHERE conversation_id = c.id
+ ORDER BY response_timestamp DESC NULLS LAST, created_at DESC
+ LIMIT 1
+) csat ON true
LEFT JOIN sla_policies sla ON c.sla_policy_id = sla.id
LEFT JOIN teams at ON at.id = c.assigned_team_id
LEFT JOIN conversation_statuses s ON c.status_id = s.id
@@ -210,10 +281,10 @@ LEFT JOIN LATERAL (
ORDER BY se.created_at DESC
LIMIT 1
) nxt_resp_event ON true
-WHERE
+WHERE
($1 > 0 AND c.id = $1)
- OR
- ($2::uuid IS NOT NULL AND c.uuid = $2::uuid)
+ OR
+ (NULLIF($2, '')::uuid IS NOT NULL AND c.uuid = NULLIF($2, '')::uuid)
OR
($3::TEXT != '' AND c.reference_number = $3::TEXT)
@@ -326,6 +397,12 @@ SET assigned_user_id = $2,
updated_at = NOW()
WHERE uuid = $1;
+-- name: claim-unassigned-conversation
+UPDATE conversations
+SET assigned_user_id = $2,
+updated_at = NOW()
+WHERE uuid = $1 AND assigned_user_id IS NULL AND assigned_team_id = $3;
+
-- name: update-conversation-contact-last-seen
UPDATE conversations
SET contact_last_seen_at = NOW(),
@@ -895,7 +972,7 @@ ORDER BY cd.updated_at DESC;
DELETE FROM conversation_drafts
WHERE conversation_id IN (
SELECT id FROM conversations
- WHERE ($1 > 0 AND id = $1) OR ($2::uuid IS NOT NULL AND uuid = $2::uuid)
+ WHERE ($1 > 0 AND id = $1) OR (NULLIF($2, '')::uuid IS NOT NULL AND uuid = NULLIF($2, '')::uuid)
) AND user_id = $3;
-- name: delete-stale-drafts
@@ -907,19 +984,21 @@ INSERT INTO conversation_mentions (conversation_id, message_id, mentioned_user_i
VALUES ($1, $2, $3, $4, $5);
-- name: mark-conversation-unread
-INSERT INTO conversation_last_seen (user_id, conversation_id, last_seen_at)
-VALUES (
- $1,
- (SELECT id FROM conversations WHERE uuid = $2),
- (SELECT created_at - INTERVAL '1 second' FROM conversation_messages
- WHERE conversation_id = (SELECT id FROM conversations WHERE uuid = $2)
- ORDER BY created_at DESC LIMIT 1)
+WITH target AS (
+ SELECT id FROM conversations WHERE uuid = $2
+),
+last_msg AS (
+ SELECT created_at - INTERVAL '1 second' AS ts
+ FROM conversation_messages
+ WHERE conversation_id = (SELECT id FROM target)
+ AND (meta IS NULL OR NOT COALESCE((meta->>'continuity_email')::boolean, false))
+ ORDER BY created_at DESC LIMIT 1
)
+INSERT INTO conversation_last_seen (user_id, conversation_id, last_seen_at)
+SELECT $1, (SELECT id FROM target), ts FROM last_msg
ON CONFLICT (conversation_id, user_id)
DO UPDATE SET
- last_seen_at = (SELECT created_at - INTERVAL '1 second' FROM conversation_messages
- WHERE conversation_id = (SELECT id FROM conversations WHERE uuid = $2)
- ORDER BY created_at DESC LIMIT 1),
+ last_seen_at = EXCLUDED.last_seen_at,
updated_at = NOW();
-- name: get-active-livechat-conversations-by-agent
@@ -934,3 +1013,32 @@ WHERE c.assigned_user_id = $1
AND u.availability_status = 'online'
ORDER BY c.last_interaction_at DESC
LIMIT 50;
+
+-- name: filter-authorized-list-uuids
+-- $1: uuids (uuid[])
+-- $2: user_id
+-- $3: team_ids (int[])
+-- $4: has 'conversations:read'
+-- $5: has 'conversations:read_all'
+-- $6: has 'conversations:read_assigned'
+-- $7: has 'conversations:read_team_all'
+-- $8: has 'conversations:read_team_inbox'
+-- $9: has 'conversations:read_unassigned'
+SELECT uuid::text
+FROM conversations
+WHERE uuid = ANY($1::uuid[])
+ AND $4
+ AND (
+ $5
+ OR ($6 AND assigned_user_id = $2)
+ OR ($7 AND assigned_team_id = ANY($3::int[]))
+ OR ($8 AND assigned_team_id = ANY($3::int[]) AND assigned_user_id IS NULL)
+ OR ($9 AND assigned_user_id IS NULL AND assigned_team_id IS NULL)
+ );
+
+-- name: get-conversation-uuids-by-contact
+SELECT uuid::text
+FROM conversations
+WHERE contact_id = $1
+ORDER BY last_message_at DESC NULLS LAST
+LIMIT 200;
diff --git a/internal/conversation/transcript.go b/internal/conversation/transcript.go
new file mode 100644
index 00000000..2a7a6618
--- /dev/null
+++ b/internal/conversation/transcript.go
@@ -0,0 +1,60 @@
+package conversation
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/abhinavxd/libredesk/internal/conversation/models"
+ "github.com/abhinavxd/libredesk/internal/stringutil"
+)
+
+const (
+ transcriptTimeFormat = "Jan 02, 2006 03:04 PM MST"
+ transcriptSeparator = "------------------------------------------------------------"
+)
+
+func (m *Manager) BuildTranscript(conversation models.Conversation, messages []models.Message, downloadedAt time.Time) []byte {
+ var b strings.Builder
+ fmt.Fprintf(&b, "%s #%s\n", m.i18n.T("globals.terms.conversation"), conversation.ReferenceNumber)
+ if subject := conversation.Subject.String; subject != "" {
+ fmt.Fprintf(&b, "%s: %s\n", m.i18n.T("globals.terms.subject"), subject)
+ }
+ if contact := conversation.Contact.FullName(); contact != "" {
+ fmt.Fprintf(&b, "%s: %s\n", m.i18n.T("globals.terms.contact"), contact)
+ }
+ fmt.Fprintf(&b, "%s: %s\n", m.i18n.T("globals.terms.createdAt"), conversation.CreatedAt.UTC().Format(transcriptTimeFormat))
+ fmt.Fprintf(&b, "%s: %s\n\n", m.i18n.T("globals.terms.downloadedAt"), downloadedAt.UTC().Format(transcriptTimeFormat))
+ b.WriteString(transcriptSeparator + "\n")
+
+ for _, message := range messages {
+ content := message.TextContent
+ if content == "" {
+ content = stringutil.HTML2Text(message.Content)
+ }
+ fmt.Fprintf(&b, "\n[%s] %s (%s):\n%s\n",
+ message.CreatedAt.UTC().Format(transcriptTimeFormat),
+ message.Author.FullName(),
+ m.senderTypeLabel(message.SenderType),
+ content,
+ )
+ if len(message.Attachments) > 0 {
+ names := make([]string, 0, len(message.Attachments))
+ for _, attachment := range message.Attachments {
+ names = append(names, attachment.Name)
+ }
+ fmt.Fprintf(&b, "%s: %s\n", m.i18n.Tc("globals.terms.attachment", len(message.Attachments)), strings.Join(names, ", "))
+ }
+ }
+ return []byte(b.String())
+}
+
+func (m *Manager) senderTypeLabel(senderType string) string {
+ switch senderType {
+ case models.SenderTypeAgent:
+ return m.i18n.T("globals.terms.agent")
+ case models.SenderTypeContact:
+ return m.i18n.T("globals.terms.contact")
+ }
+ return senderType
+}
diff --git a/internal/conversation/transcript_test.go b/internal/conversation/transcript_test.go
new file mode 100644
index 00000000..04665e33
--- /dev/null
+++ b/internal/conversation/transcript_test.go
@@ -0,0 +1,86 @@
+package conversation
+
+import (
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/abhinavxd/libredesk/internal/attachment"
+ "github.com/abhinavxd/libredesk/internal/conversation/models"
+ "github.com/knadh/go-i18n"
+ "github.com/volatiletech/null/v9"
+)
+
+func newTestI18n(t *testing.T) *i18n.I18n {
+ t.Helper()
+ b, err := os.ReadFile("../../i18n/en-US.json")
+ if err != nil {
+ t.Fatalf("reading i18n file: %v", err)
+ }
+ in, err := i18n.New(b)
+ if err != nil {
+ t.Fatalf("loading i18n: %v", err)
+ }
+ return in
+}
+
+func TestBuildTranscript(t *testing.T) {
+ m := &Manager{i18n: newTestI18n(t)}
+
+ created := time.Date(2026, time.May, 11, 10, 0, 0, 0, time.UTC)
+ downloaded := time.Date(2026, time.June, 2, 16, 10, 0, 0, time.UTC)
+
+ conversation := models.Conversation{
+ ReferenceNumber: "1234",
+ Subject: null.StringFrom("Refund request"),
+ CreatedAt: created,
+ Contact: models.ConversationContact{FirstName: "John", LastName: "Doe"},
+ }
+
+ messages := []models.Message{
+ {
+ Type: models.MessageIncoming,
+ SenderType: models.SenderTypeContact,
+ CreatedAt: created.Add(time.Minute),
+ TextContent: "Hi, I need a refund for order #555",
+ Author: models.MessageAuthor{FirstName: "John", LastName: "Doe"},
+ },
+ {
+ Type: models.MessageOutgoing,
+ SenderType: models.SenderTypeAgent,
+ CreatedAt: created.Add(5 * time.Minute),
+ TextContent: "Sure, processing it now.",
+ Author: models.MessageAuthor{FirstName: "Priya"},
+ Attachments: attachment.Attachments{{Name: "invoice.pdf"}},
+ },
+ {
+ Type: models.MessageOutgoing,
+ SenderType: models.SenderTypeAgent,
+ CreatedAt: created.Add(6 * time.Minute),
+ Content: "Falls back to HTML
",
+ ContentType: models.ContentTypeHTML,
+ Author: models.MessageAuthor{FirstName: "Priya"},
+ },
+ }
+
+ out := string(m.BuildTranscript(conversation, messages, downloaded))
+
+ wantContains := []string{
+ "Conversation #1234",
+ "Subject: Refund request",
+ "Contact: John Doe",
+ "Created at: May 11, 2026 10:00 AM UTC",
+ "Downloaded at: Jun 02, 2026 04:10 PM UTC",
+ "[May 11, 2026 10:01 AM UTC] John Doe (Contact):",
+ "Hi, I need a refund for order #555",
+ "[May 11, 2026 10:05 AM UTC] Priya (Agent):",
+ "Attachment: invoice.pdf",
+ "Falls back to HTML",
+ }
+ for _, want := range wantContains {
+ if !strings.Contains(out, want) {
+ t.Errorf("transcript missing %q\n---\n%s", want, out)
+ }
+ }
+}
diff --git a/internal/conversation/ws.go b/internal/conversation/ws.go
index aeaf6ff6..62dfcfbb 100644
--- a/internal/conversation/ws.go
+++ b/internal/conversation/ws.go
@@ -7,23 +7,33 @@ import (
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
"github.com/abhinavxd/libredesk/internal/inbox"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
+ "github.com/abhinavxd/libredesk/internal/ws"
wsmodels "github.com/abhinavxd/libredesk/internal/ws/models"
)
-// BroadcastNewMessage broadcasts a new message to all users.
-// lastMessage is the computed preview text (e.g., "Image" for media-only messages).
-func (m *Manager) BroadcastNewMessage(message *cmodels.Message, lastMessage string) {
+func (m *Manager) BroadcastNewConversation(conv *cmodels.ConversationListItem) {
+ m.broadcastConvToAuthorized(conv, nil)
+}
+
+// BroadcastConvReassignment notifies the union of agents authorized under old and new assignee state, so agents losing access receive the updated payload and their frontend can filter the conv out.
+func (m *Manager) BroadcastConvReassignment(oldConv, newConv *cmodels.ConversationListItem) {
+ m.broadcastConvToAuthorized(newConv, oldConv)
+}
+
+func (m *Manager) BroadcastNewMessage(message *cmodels.Message, conv *cmodels.ConversationListItem, preview string) {
+ if conv == nil {
+ return
+ }
data := map[string]any{
"conversation_uuid": message.ConversationUUID,
- "content": "",
- "created_at": message.CreatedAt.Format(time.RFC3339),
"uuid": message.UUID,
- "private": message.Private,
"type": message.Type,
+ "preview": preview,
+ "created_at": message.CreatedAt.Format(time.RFC3339),
"sender_type": message.SenderType,
+ "conversation": convToBroadcastMap(conv),
}
- // Include echo_id from meta so clients can match WS events to pending messages.
var meta map[string]any
if len(message.Meta) > 0 {
if err := json.Unmarshal(message.Meta, &meta); err == nil {
@@ -33,38 +43,67 @@ func (m *Manager) BroadcastNewMessage(message *cmodels.Message, lastMessage stri
}
}
- m.broadcastToUsers([]int{}, wsmodels.Message{
+ userIDs := m.AuthorizedConnectedAgentIDs(conv.AssignedUserID, conv.AssignedTeamID)
+ if len(userIDs) == 0 {
+ return
+ }
+ m.broadcastToUsers(userIDs, wsmodels.Message{
Type: wsmodels.MessageTypeNewMessage,
Data: data,
})
}
-// BroadcastMessageUpdate broadcasts a partial message update to all users.
+// BroadcastMessageUpdate broadcasts a partial message update to list subscribers.
func (m *Manager) BroadcastMessageUpdate(conversationUUID, messageUUID string, data map[string]any) {
data["conversation_uuid"] = conversationUUID
data["uuid"] = messageUUID
- m.broadcastToUsers([]int{}, wsmodels.Message{
+ m.broadcastToConversationListSubs(conversationUUID, wsmodels.Message{
Type: wsmodels.MessageTypeMessageUpdate,
Data: data,
})
}
-// BroadcastConversationUpdate broadcasts a partial conversation update to all agent clients.
+// BroadcastConversationUpdate broadcasts a partial conversation update to list subscribers.
func (m *Manager) BroadcastConversationUpdate(conversationUUID string, data map[string]any) {
data["uuid"] = conversationUUID
- m.broadcastToUsers([]int{}, wsmodels.Message{
+ m.broadcastToConversationListSubs(conversationUUID, wsmodels.Message{
Type: wsmodels.MessageTypeConversationUpdate,
Data: data,
})
}
-// BroadcastContactUpdate broadcasts a contact update to all agent clients.
func (m *Manager) BroadcastContactUpdate(contactID int, data map[string]any) {
data["contact_id"] = contactID
- m.broadcastToUsers([]int{}, wsmodels.Message{
+ var uuids []string
+ if err := m.q.GetConversationUUIDsByContact.Select(&uuids, contactID); err != nil {
+ m.lo.Error("error fetching contact's conversations for broadcast", "contact_id", contactID, "error", err)
+ return
+ }
+ if len(uuids) == 0 {
+ return
+ }
+ seen := map[*ws.Client]struct{}{}
+ for _, uuid := range uuids {
+ for _, c := range m.wsHub.ListSubscribers(uuid) {
+ seen[c] = struct{}{}
+ }
+ }
+ if len(seen) == 0 {
+ return
+ }
+ clients := make([]*ws.Client, 0, len(seen))
+ for c := range seen {
+ clients = append(clients, c)
+ }
+ messageBytes, err := json.Marshal(wsmodels.Message{
Type: "contact_update",
Data: data,
})
+ if err != nil {
+ m.lo.Error("error marshalling contact_update WS message", "error", err)
+ return
+ }
+ m.wsHub.PushToClients(clients, messageBytes)
}
// BroadcastTypingToConversation broadcasts typing status to all subscribers of a conversation.
@@ -98,6 +137,32 @@ func (m *Manager) BroadcastTypingToWidgetClientsOnly(conversationUUID string, is
m.broadcastTypingToWidgetClients(conversationUUID, isTyping)
}
+func (m *Manager) broadcastConvToAuthorized(conv, oldConv *cmodels.ConversationListItem) {
+ if conv == nil {
+ return
+ }
+ userIDs := m.AuthorizedConnectedAgentIDs(conv.AssignedUserID, conv.AssignedTeamID)
+ if oldConv != nil {
+ seen := make(map[int]struct{}, len(userIDs))
+ for _, id := range userIDs {
+ seen[id] = struct{}{}
+ }
+ for _, id := range m.AuthorizedConnectedAgentIDs(oldConv.AssignedUserID, oldConv.AssignedTeamID) {
+ if _, ok := seen[id]; !ok {
+ seen[id] = struct{}{}
+ userIDs = append(userIDs, id)
+ }
+ }
+ }
+ if len(userIDs) == 0 {
+ return
+ }
+ m.broadcastToUsers(userIDs, wsmodels.Message{
+ Type: wsmodels.MessageTypeNewConversation,
+ Data: convToBroadcastMap(conv),
+ })
+}
+
// broadcastToUsers broadcasts a message to a list of users, if the list is empty it broadcasts to all users.
func (m *Manager) broadcastToUsers(userIDs []int, message wsmodels.Message) {
messageBytes, err := json.Marshal(message)
@@ -111,6 +176,20 @@ func (m *Manager) broadcastToUsers(userIDs []int, message wsmodels.Message) {
})
}
+// broadcastToConversationListSubs pushes a message to the conversation's list and open subscribers.
+func (m *Manager) broadcastToConversationListSubs(conversationUUID string, message wsmodels.Message) {
+ clients := m.wsHub.ListSubscribers(conversationUUID)
+ if len(clients) == 0 {
+ return
+ }
+ messageBytes, err := json.Marshal(message)
+ if err != nil {
+ m.lo.Error("error marshalling WS message", "error", err)
+ return
+ }
+ m.wsHub.PushToClients(clients, messageBytes)
+}
+
// broadcastTypingToWidgetClients broadcasts typing status to widget clients (customers) for a conversation.
func (m *Manager) broadcastTypingToWidgetClients(conversationUUID string, isTyping bool) {
conversation, err := m.GetConversation(0, conversationUUID, "")
@@ -130,9 +209,16 @@ func (m *Manager) broadcastTypingToWidgetClients(conversationUUID string, isTypi
}
}
-// BroadcastAgentStatusToWidget sends a lightweight assignee availability update
-// to widget clients for all active livechat conversations assigned to the given agent.
-func (m *Manager) BroadcastAgentStatusToWidget(agentID int, status string) {
+func (m *Manager) BroadcastAgentAvailability(agentID int, status string) {
+ m.broadcastToUsers([]int{}, wsmodels.Message{
+ Type: wsmodels.MessageTypeAgentAvailability,
+ Data: map[string]any{
+ "agent_id": agentID,
+ "availability_status": status,
+ },
+ })
+
+ // Get all recent live chat conversations for this agent and broadcast the availability update to online widgets.
var conversations []struct {
UUID string `db:"uuid"`
ContactID int `db:"contact_id"`
@@ -165,3 +251,20 @@ func (m *Manager) BroadcastConversationToWidget(conversationUUID string, contact
liveChatInbox.BroadcastConversationToClients(conversationUUID, contactID, data)
}
}
+
+func convToBroadcastMap(conv *cmodels.ConversationListItem) map[string]any {
+ if conv == nil {
+ return nil
+ }
+ b, err := json.Marshal(conv)
+ if err != nil {
+ return nil
+ }
+ var out map[string]any
+ if err := json.Unmarshal(b, &out); err != nil {
+ return nil
+ }
+ delete(out, "unread_message_count")
+ delete(out, "mentioned_message_uuid")
+ return out
+}
diff --git a/internal/dbutil/builder.go b/internal/dbutil/builder.go
index c07e317f..72f04e28 100644
--- a/internal/dbutil/builder.go
+++ b/internal/dbutil/builder.go
@@ -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":[]},{"logic":"AND","rules":[]}]}
+ 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
}
diff --git a/internal/dbutil/builder_test.go b/internal/dbutil/builder_test.go
new file mode 100644
index 00000000..93f6d76d
--- /dev/null
+++ b/internal/dbutil/builder_test.go
@@ -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)
+ }
+ }
+}
diff --git a/internal/inbox/channel/email/email.go b/internal/inbox/channel/email/email.go
index 08ae8d7b..913e59bf 100644
--- a/internal/inbox/channel/email/email.go
+++ b/internal/inbox/channel/email/email.go
@@ -22,6 +22,7 @@ const (
// Email represents the email inbox with multiple SMTP servers and IMAP clients.
type Email struct {
id int
+ name string
smtpPools []*smtppool.Pool
smtpPoolsMu sync.RWMutex
smtpPoolsToken string
@@ -33,6 +34,7 @@ type Email struct {
headers map[string]string
lo *logf.Logger
from string
+ fromNameTemplate string
replyTo string
enablePlusAddressing bool
messageStore inbox.MessageStore
@@ -48,6 +50,7 @@ type TokenRefreshCallback func(inboxID int, updatedConfig models.Config) error
// Opts holds the options required for the email inbox.
type Opts struct {
ID int
+ Name string
Headers map[string]string
Config models.Config
Lo *logf.Logger
@@ -68,8 +71,10 @@ func New(store inbox.MessageStore, userStore inbox.UserStore, opts Opts) (*Email
e := &Email{
id: opts.ID,
+ name: opts.Name,
headers: opts.Headers,
from: opts.Config.From,
+ fromNameTemplate: opts.Config.FromNameTemplate,
replyTo: opts.Config.ReplyTo,
smtpCfg: opts.Config.SMTP,
imapCfg: opts.Config.IMAP,
@@ -111,11 +116,21 @@ func (e *Email) Close() error {
return e.closeSMTPPool()
}
+// Name returns the inbox name.
+func (e *Email) Name() string {
+ return e.name
+}
+
// FromAddress returns the from address for this inbox.
func (e *Email) FromAddress() string {
return e.from
}
+// FromNameTemplate returns the from display name template for this inbox, empty if unset.
+func (e *Email) FromNameTemplate() string {
+ return e.fromNameTemplate
+}
+
// ReplyToAddress returns the reply-to address for this inbox, empty if unset.
func (e *Email) ReplyToAddress() string {
return e.replyTo
@@ -136,6 +151,7 @@ func (e *Email) getCurrentConfig() models.Config {
SMTP: e.smtpCfg,
IMAP: e.imapCfg,
From: e.from,
+ FromNameTemplate: e.fromNameTemplate,
ReplyTo: e.replyTo,
OAuth: oauth,
AuthType: e.authType,
diff --git a/internal/inbox/channel/email/imap.go b/internal/inbox/channel/email/imap.go
index 43808ff9..3cfd725a 100644
--- a/internal/inbox/channel/email/imap.go
+++ b/internal/inbox/channel/email/imap.go
@@ -532,6 +532,11 @@ func (e *Email) processFullMessage(item imapclient.FetchItemDataBodySection, inc
})
}
+ incomingMsg.Content = stringutil.SanitizeUTF8(incomingMsg.Content)
+ incomingMsg.Subject = stringutil.SanitizeUTF8(incomingMsg.Subject)
+ incomingMsg.Contact.FirstName = stringutil.SanitizeUTF8(incomingMsg.Contact.FirstName)
+ incomingMsg.Contact.LastName = stringutil.SanitizeUTF8(incomingMsg.Contact.LastName)
+
e.lo.Debug("enqueuing incoming email message", "message_id", incomingMsg.SourceID.String,
"attachments", len(envelope.Attachments), "inline_attachments", len(envelope.Inlines))
diff --git a/internal/inbox/channel/livechat/livechat.go b/internal/inbox/channel/livechat/livechat.go
index 865d53ad..d7ecd1fb 100644
--- a/internal/inbox/channel/livechat/livechat.go
+++ b/internal/inbox/channel/livechat/livechat.go
@@ -143,6 +143,7 @@ func (c *Client) CloseChannel() {
// LiveChat represents the live chat inbox.
type LiveChat struct {
id int
+ name string
config Config
from string
lo *logf.Logger
@@ -156,6 +157,7 @@ type LiveChat struct {
// Opts holds the options required for the live chat inbox.
type Opts struct {
ID int
+ Name string
Config Config
From string
Lo *logf.Logger
@@ -166,6 +168,7 @@ type Opts struct {
func New(store inbox.MessageStore, userStore inbox.UserStore, opts Opts) (*LiveChat, error) {
lc := &LiveChat{
id: opts.ID,
+ name: opts.Name,
config: opts.Config,
from: opts.From,
lo: opts.Lo,
@@ -283,6 +286,16 @@ func (lc *LiveChat) ReplyToAddress() string {
return ""
}
+// Name returns the inbox name.
+func (lc *LiveChat) Name() string {
+ return lc.name
+}
+
+// FromNameTemplate is not applicable to livechat and always returns empty.
+func (lc *LiveChat) FromNameTemplate() string {
+ return ""
+}
+
// Channel returns the channel name for this inbox.
func (lc *LiveChat) Channel() string {
return ChannelLiveChat
@@ -301,7 +314,7 @@ func (lc *LiveChat) AddClient(userID string) (*Client, error) {
client := &Client{
ID: userID,
- Channel: make(chan []byte, 1000),
+ Channel: make(chan []byte, 128),
}
// Add the client to the clients map.
diff --git a/internal/inbox/channel/whatsapp/whatsapp.go b/internal/inbox/channel/whatsapp/whatsapp.go
index b1de8de6..11c4c864 100644
--- a/internal/inbox/channel/whatsapp/whatsapp.go
+++ b/internal/inbox/channel/whatsapp/whatsapp.go
@@ -73,6 +73,7 @@ type SourceIDUpdater interface {
// WhatsApp implements inbox.Inbox.
type WhatsApp struct {
id int
+ name string
config Config
from string
client *whatsapp.Client
@@ -84,6 +85,7 @@ type WhatsApp struct {
// Opts holds construction options.
type Opts struct {
ID int
+ Name string
Config Config
From string
Client *whatsapp.Client
@@ -104,6 +106,7 @@ func New(store inbox.MessageStore, opts Opts) (*WhatsApp, error) {
}
return &WhatsApp{
id: opts.ID,
+ name: opts.Name,
config: opts.Config,
from: opts.From,
client: opts.Client,
@@ -125,12 +128,18 @@ func (w *WhatsApp) Receive(ctx context.Context) error { return nil }
// Channel returns the channel name.
func (w *WhatsApp) Channel() string { return ChannelWhatsApp }
+// Name returns the configured inbox name.
+func (w *WhatsApp) Name() string { return w.name }
+
// FromAddress returns the configured display "from" string.
func (w *WhatsApp) FromAddress() string { return w.from }
// ReplyToAddress is not applicable to WhatsApp.
func (w *WhatsApp) ReplyToAddress() string { return "" }
+// FromNameTemplate is not applicable to WhatsApp and always returns empty.
+func (w *WhatsApp) FromNameTemplate() string { return "" }
+
// Close releases any resources. Currently a no-op.
func (w *WhatsApp) Close() error { return nil }
diff --git a/internal/inbox/inbox.go b/internal/inbox/inbox.go
index 189d8ebf..a22350ec 100644
--- a/internal/inbox/inbox.go
+++ b/internal/inbox/inbox.go
@@ -63,7 +63,9 @@ type Inbox interface {
Closer
Identifier
MessageHandler
+ Name() string
FromAddress() string
+ FromNameTemplate() string
ReplyToAddress() string
Channel() string
}
@@ -255,7 +257,7 @@ func (m *Manager) Create(inbox imodels.Inbox) (imodels.Inbox, error) {
}
var createdInbox imodels.Inbox
- if err := m.queries.InsertInbox.Get(&createdInbox, inbox.Channel, encryptedConfig, inbox.Name, inbox.From, inbox.Enabled, inbox.CSATEnabled, inbox.PromptTagsOnReply, inbox.Secret, inbox.LinkedEmailInboxID); err != nil {
+ if err := m.queries.InsertInbox.Get(&createdInbox, inbox.Channel, encryptedConfig, inbox.Name, inbox.From, inbox.Enabled, inbox.CSATEnabled, inbox.PromptTagsOnReply, inbox.Secret, inbox.LinkedEmailInboxID, inbox.FromNameTemplate); err != nil {
m.lo.Error("error creating inbox", "error", err)
return imodels.Inbox{}, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
@@ -458,7 +460,7 @@ func (m *Manager) Update(id int, inbox imodels.Inbox) (imodels.Inbox, error) {
// Update the inbox in the DB.
var updatedInbox imodels.Inbox
- if err := m.queries.Update.Get(&updatedInbox, id, inbox.Channel, encryptedConfig, inbox.Name, inbox.From, inbox.CSATEnabled, inbox.PromptTagsOnReply, inbox.Enabled, inbox.Secret, inbox.LinkedEmailInboxID); err != nil {
+ if err := m.queries.Update.Get(&updatedInbox, id, inbox.Channel, encryptedConfig, inbox.Name, inbox.From, inbox.CSATEnabled, inbox.PromptTagsOnReply, inbox.Enabled, inbox.Secret, inbox.LinkedEmailInboxID, inbox.FromNameTemplate); err != nil {
m.lo.Error("error updating inbox", "error", err)
return imodels.Inbox{}, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
diff --git a/internal/inbox/models/models.go b/internal/inbox/models/models.go
index eb3afc74..f147a2a9 100644
--- a/internal/inbox/models/models.go
+++ b/internal/inbox/models/models.go
@@ -29,6 +29,7 @@ type Inbox struct {
CSATEnabled bool `db:"csat_enabled" json:"csat_enabled"`
PromptTagsOnReply bool `db:"prompt_tags_on_reply" json:"prompt_tags_on_reply"`
From string `db:"from" json:"from"`
+ FromNameTemplate string `db:"from_name_template" json:"from_name_template"`
Config json.RawMessage `db:"config" json:"config"`
Secret null.String `db:"secret" json:"secret"`
LinkedEmailInboxID null.Int `db:"linked_email_inbox_id" json:"linked_email_inbox_id"`
@@ -46,6 +47,7 @@ type Config struct {
SMTP []SMTPConfig `json:"smtp"`
IMAP []IMAPConfig `json:"imap"`
From string `json:"from"`
+ FromNameTemplate string `json:"from_name_template"`
ReplyTo string `json:"reply_to"`
EnablePlusAddressing bool `json:"enable_plus_addressing"`
}
diff --git a/internal/inbox/queries.sql b/internal/inbox/queries.sql
index b8800562..77d8a984 100644
--- a/internal/inbox/queries.sql
+++ b/internal/inbox/queries.sql
@@ -1,24 +1,24 @@
-- name: get-active-inboxes
-SELECT id, uuid, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, prompt_tags_on_reply, config, "from", linked_email_inbox_id FROM inboxes where enabled is TRUE and deleted_at is NULL;
+SELECT id, uuid, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, prompt_tags_on_reply, config, "from", from_name_template, linked_email_inbox_id FROM inboxes where enabled is TRUE and deleted_at is NULL;
-- name: get-all-inboxes
-SELECT id, uuid, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, prompt_tags_on_reply, config, "from", linked_email_inbox_id FROM inboxes where deleted_at is NULL;
+SELECT id, uuid, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, prompt_tags_on_reply, config, "from", from_name_template, linked_email_inbox_id FROM inboxes where deleted_at is NULL;
-- name: insert-inbox
INSERT INTO inboxes
-(channel, config, "name", "from", enabled, csat_enabled, prompt_tags_on_reply, secret, linked_email_inbox_id)
-VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9)
+(channel, config, "name", "from", enabled, csat_enabled, prompt_tags_on_reply, secret, linked_email_inbox_id, from_name_template)
+VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *
-- name: get-inbox
-SELECT id, uuid, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, prompt_tags_on_reply, config, "from", secret, linked_email_inbox_id FROM inboxes where id = $1 and deleted_at is NULL;
+SELECT id, uuid, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, prompt_tags_on_reply, config, "from", from_name_template, secret, linked_email_inbox_id FROM inboxes where id = $1 and deleted_at is NULL;
-- name: get-inbox-by-uuid
-SELECT id, uuid, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, prompt_tags_on_reply, config, "from", secret, linked_email_inbox_id FROM inboxes where uuid = $1 and deleted_at is NULL;
+SELECT id, uuid, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, prompt_tags_on_reply, config, "from", from_name_template, secret, linked_email_inbox_id FROM inboxes where uuid = $1 and deleted_at is NULL;
-- name: update
UPDATE inboxes
-set channel = $2, config = $3, "name" = $4, "from" = $5, csat_enabled = $6, prompt_tags_on_reply = $7, enabled = $8, secret = $9, linked_email_inbox_id = $10, updated_at = now()
+set channel = $2, config = $3, "name" = $4, "from" = $5, csat_enabled = $6, prompt_tags_on_reply = $7, enabled = $8, secret = $9, linked_email_inbox_id = $10, from_name_template = $11, updated_at = now()
where id = $1 and deleted_at is NULL
RETURNING *;
diff --git a/internal/media/media.go b/internal/media/media.go
index baaf22e5..d46169af 100644
--- a/internal/media/media.go
+++ b/internal/media/media.go
@@ -122,13 +122,13 @@ func (m *Manager) Upload(fileName, contentType string, content io.ReadSeeker) (s
// Detect content type and override if needed.
contentType, err := m.detectContentType(contentType, content)
if err != nil {
- m.lo.Error("error detecting content type", "error", err)
+ m.lo.Error("error detecting content type", "error", err, "file_name", fileName, "content_type", contentType, "store", m.store.Name())
return "", "", err
}
fName, err := m.store.Put(fileName, contentType, content)
if err != nil {
- m.lo.Error("error uploading media", "error", err)
+ m.lo.Error("error uploading media to store", "error", err, "file_name", fileName, "content_type", contentType, "store", m.store.Name())
return "", "", envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.errorUploadingFile"), nil)
}
return fName, contentType, nil
@@ -138,7 +138,7 @@ func (m *Manager) Upload(fileName, contentType string, content io.ReadSeeker) (s
func (m *Manager) Insert(disposition null.String, fileName, contentType, contentID string, modelType null.String, uuid string, modelID null.Int, fileSize int, meta []byte) (models.Media, error) {
var id int
if err := m.queries.Insert.QueryRow(m.store.Name(), fileName, contentType, fileSize, meta, modelID, modelType, disposition, contentID, uuid).Scan(&id); err != nil {
- m.lo.Error("error inserting media", "error", err)
+ m.lo.Error("error inserting media", "error", err, "file_name", fileName, "content_type", contentType, "store", m.store.Name())
return models.Media{}, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
return m.Get(id, "")
@@ -180,10 +180,13 @@ func (m *Manager) SetContentID(id int, contentID string) error {
return nil
}
-// ContentIDExists checks if a content_id exists in the database and returns the UUID of the media file.
-func (m *Manager) ContentIDExists(contentID string) (bool, string, error) {
+// ContentIDExists reports whether a media row with the given content_id is linked to a message in the given conversation. Scoped this way so an orphan media row (e.g., from a partial failure) doesn't short-circuit a retry into skipping the upload.
+func (m *Manager) ContentIDExists(contentID, conversationUUID string) (bool, string, error) {
+ if contentID == "" || conversationUUID == "" {
+ return false, "", nil
+ }
var uuid string
- if err := m.queries.ContentIDExists.Get(&uuid, contentID); err != nil {
+ if err := m.queries.ContentIDExists.Get(&uuid, contentID, conversationUUID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false, "", nil
}
diff --git a/internal/media/queries.sql b/internal/media/queries.sql
index 78fdfbf8..bbebc1d8 100644
--- a/internal/media/queries.sql
+++ b/internal/media/queries.sql
@@ -17,10 +17,10 @@ RETURNING id;
-- name: get-media
SELECT id, created_at, updated_at, "uuid", store, filename, content_type, content_id, model_id, model_type, disposition, "size", meta
FROM media
-WHERE
+WHERE
($1 > 0 AND id = $1)
OR
- ($2 != '' AND uuid = $2::uuid)
+ ($2 != '' AND uuid = NULLIF($2, '')::uuid)
-- name: get-media-by-uuid
SELECT id, created_at, updated_at, "uuid", store, filename, content_type, content_id, model_id, model_type, disposition, "size", meta
@@ -51,7 +51,12 @@ WHERE model_type = 'messages'
AND created_at < NOW() - INTERVAL '7 days';
-- name: content-id-exists
-SELECT uuid FROM media WHERE content_id = $1;
+SELECT m.uuid
+FROM media m
+INNER JOIN conversation_messages cm ON cm.id = m.model_id
+WHERE m.model_type = 'messages'
+ AND m.content_id = $1
+ AND cm.conversation_id = (SELECT id FROM conversations WHERE uuid = $2::uuid LIMIT 1);
-- name: get-media-by-content-ids
SELECT m.id, m.created_at, m.updated_at, m."uuid", m.store, m.filename, m.content_type, m.content_id, m.model_id, m.model_type, m.disposition, m."size", m.meta
diff --git a/internal/media/stores/localfs/fs.go b/internal/media/stores/localfs/fs.go
index f95eaf28..42554976 100644
--- a/internal/media/stores/localfs/fs.go
+++ b/internal/media/stores/localfs/fs.go
@@ -42,13 +42,13 @@ func (c *Client) Put(filename string, cType string, src io.ReadSeeker) (string,
dir := getDir(c.opts.UploadPath)
o, err := os.OpenFile(filepath.Join(dir, filename), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0664)
if err != nil {
- return "", err
+ return "", fmt.Errorf("opening file for write %q: %w", filepath.Join(dir, filename), err)
}
out = o
defer out.Close()
- if _, err := io.Copy(out, src); err != nil {
- return "", err
+ if n, err := io.Copy(out, src); err != nil {
+ return "", fmt.Errorf("writing file %q after %d bytes: %w", filepath.Join(dir, filename), n, err)
}
return filename, nil
}
diff --git a/internal/media/stores/s3/s3.go b/internal/media/stores/s3/s3.go
index e19d5953..00579b19 100644
--- a/internal/media/stores/s3/s3.go
+++ b/internal/media/stores/s3/s3.go
@@ -80,7 +80,7 @@ func (c *Client) Put(name string, cType string, file io.ReadSeeker) (string, err
}
if _, err := c.s3.FilePut(p); err != nil {
- return "", err
+ return "", fmt.Errorf("s3 put bucket=%q key=%q content_type=%q: %w", c.opts.Bucket, p.ObjectKey, cType, err)
}
return name, nil
diff --git a/internal/migrations/v2.0.0.go b/internal/migrations/v2.0.0.go
index 5213002c..14a8c709 100644
--- a/internal/migrations/v2.0.0.go
+++ b/internal/migrations/v2.0.0.go
@@ -205,7 +205,7 @@ func V2_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
// Add built-in CSAT request email template.
_, err = db.Exec(`
INSERT INTO templates ("type", body, is_default, "name", subject, is_builtin)
- VALUES (
+ SELECT
'email_notification'::template_type,
'
@@ -255,7 +255,9 @@ func V2_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
'CSAT request',
'',
true
- ) ON CONFLICT DO NOTHING;
+ WHERE NOT EXISTS (
+ SELECT 1 FROM templates WHERE name = 'CSAT request' AND is_builtin = true
+ );
`)
if err != nil {
return err
diff --git a/internal/migrations/v2.3.0.go b/internal/migrations/v2.3.0.go
index 543eb0cf..e48d974f 100644
--- a/internal/migrations/v2.3.0.go
+++ b/internal/migrations/v2.3.0.go
@@ -20,14 +20,53 @@ func V2_3_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
}
for localeCode, localeRegion := range langMap {
- _, err := db.Exec(`
+ if _, err := db.Exec(`
UPDATE settings SET value = to_jsonb($1::text), updated_at = now()
WHERE key = 'app.lang' AND value = to_jsonb($2::text);
- `, localeRegion, localeCode)
- if err != nil {
+ `, localeRegion, localeCode); err != nil {
+ return err
+ }
+
+ if _, err := db.Exec(`
+ UPDATE inboxes
+ SET config = jsonb_set(config, '{language}', to_jsonb($1::text)), updated_at = now()
+ WHERE channel = 'livechat' AND config->>'language' = $2;
+ `, localeRegion, localeCode); err != nil {
+ return err
+ }
+
+ if _, err := db.Exec(`
+ UPDATE inboxes
+ SET config = jsonb_set(config, '{fallback_language}', to_jsonb($1::text)), updated_at = now()
+ WHERE channel = 'livechat' AND config->>'fallback_language' = $2;
+ `, localeRegion, localeCode); err != nil {
return err
}
}
+ // Dedupe legacy pending applied_slas per conversation before adding the partial unique index.
+ if _, err := db.Exec(`
+ DO $$
+ BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_indexes
+ WHERE indexname = 'index_applied_slas_unique_pending_per_conv'
+ AND tablename = 'applied_slas'
+ ) THEN
+ WITH ranked AS (
+ SELECT id, row_number() OVER (PARTITION BY conversation_id ORDER BY created_at DESC, id DESC) AS rn
+ FROM applied_slas
+ WHERE status = 'pending'
+ )
+ DELETE FROM applied_slas WHERE id IN (SELECT id FROM ranked WHERE rn > 1);
+
+ CREATE UNIQUE INDEX index_applied_slas_unique_pending_per_conv
+ ON applied_slas(conversation_id) WHERE status = 'pending';
+ END IF;
+ END $$;
+ `); err != nil {
+ return err
+ }
+
return nil
}
diff --git a/internal/migrations/v2.4.0.go b/internal/migrations/v2.4.0.go
new file mode 100644
index 00000000..6bd1c8b2
--- /dev/null
+++ b/internal/migrations/v2.4.0.go
@@ -0,0 +1,12 @@
+package migrations
+
+import (
+ "github.com/jmoiron/sqlx"
+ "github.com/knadh/koanf/v2"
+ "github.com/knadh/stuffbin"
+)
+
+func V2_4_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
+ _, err := db.Exec(`ALTER TABLE inboxes ADD COLUMN IF NOT EXISTS from_name_template TEXT NOT NULL DEFAULT '';`)
+ return err
+}
diff --git a/internal/search/queries.sql b/internal/search/queries.sql
index e71114ee..cb566906 100644
--- a/internal/search/queries.sql
+++ b/internal/search/queries.sql
@@ -28,7 +28,7 @@ SELECT
c.created_at as "conversation_created_at",
c.reference_number as "conversation_reference_number",
c.uuid as "conversation_uuid",
- m.text_content,
+ LEFT(m.text_content, 200) AS text_content,
cs.name as "conversation_status"
FROM conversation_messages m
JOIN conversations c ON m.conversation_id = c.id
diff --git a/internal/setting/setting.go b/internal/setting/setting.go
index 4ee39b5a..c062c1e0 100644
--- a/internal/setting/setting.go
+++ b/internal/setting/setting.go
@@ -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{}
diff --git a/internal/sla/queries.sql b/internal/sla/queries.sql
index 75268d8c..b488ee45 100644
--- a/internal/sla/queries.sql
+++ b/internal/sla/queries.sql
@@ -31,7 +31,10 @@ RETURNING *;
DELETE FROM sla_policies WHERE id = $1;
-- name: apply-sla
-WITH new_sla AS (
+WITH deleted AS (
+ DELETE FROM applied_slas WHERE conversation_id = $1 AND status = 'pending'
+),
+new_sla AS (
INSERT INTO applied_slas (
conversation_id,
sla_policy_id,
@@ -42,7 +45,7 @@ WITH new_sla AS (
)
-- update the conversation with the new SLA policy and next SLA deadline.
UPDATE conversations c
-SET
+SET
sla_policy_id = $2,
next_sla_deadline_at = LEAST($3, $4)
FROM new_sla ns
@@ -50,12 +53,20 @@ WHERE c.id = ns.conversation_id
RETURNING ns.id;
-- name: get-pending-applied-sla
--- Get all the applied SLAs (applied to a conversation) that are pending
+-- Returns only actionable pending SLAs: a metric is unresolved AND either its deadline has passed
+-- or the conversation has transitioned (first reply / resolve) since the last evaluation.
SELECT a.id, a.first_response_deadline_at, c.first_reply_at as conversation_first_response_at, a.sla_policy_id,
a.resolution_deadline_at, c.resolved_at as conversation_resolved_at, c.id as conversation_id, a.first_response_met_at, a.resolution_met_at, a.first_response_breached_at, a.resolution_breached_at
-FROM applied_slas a
+FROM applied_slas a
JOIN conversations c ON a.conversation_id = c.id and c.sla_policy_id = a.sla_policy_id
-WHERE a.status = 'pending'::applied_sla_status;
+WHERE a.status = 'pending'::applied_sla_status
+ AND (
+ (a.first_response_met_at IS NULL AND a.first_response_breached_at IS NULL
+ AND (a.first_response_deadline_at <= NOW() OR c.first_reply_at IS NOT NULL))
+ OR
+ (a.resolution_met_at IS NULL AND a.resolution_breached_at IS NULL
+ AND (a.resolution_deadline_at <= NOW() OR c.resolved_at IS NOT NULL))
+ );
-- name: update-applied-sla-breached-at
UPDATE applied_slas SET
@@ -198,6 +209,9 @@ FROM sla_events
WHERE id = $1;
-- name: get-pending-sla-events
-SELECT id
+-- Returns full event rows whose deadline has already passed (or that already have a met_at);
+SELECT id, created_at, updated_at, applied_sla_id, sla_policy_id, type, deadline_at, met_at, breached_at
FROM sla_events
-WHERE status = 'pending' AND deadline_at IS NOT NULL;
+WHERE status = 'pending'
+ AND deadline_at IS NOT NULL
+ AND (deadline_at <= NOW() OR met_at IS NOT NULL);
diff --git a/internal/sla/sla.go b/internal/sla/sla.go
index b2a7da63..52ced93f 100644
--- a/internal/sla/sla.go
+++ b/internal/sla/sla.go
@@ -271,7 +271,7 @@ func (m *Manager) ApplySLA(startTime time.Time, conversationID, assignedTeamID,
// Next response is not set at this point, next response are stored in SLA events as there can be multiple entries for next response.
deadlines.NextResponse = null.Time{}
- // Insert applied SLA entry.
+ // Insert applied SLA entry delete any previous pending applied SLA.
var appliedSLAID int
if err := m.q.ApplySLA.QueryRowx(
conversationID,
@@ -393,7 +393,6 @@ func (m *Manager) evaluatePendingSLAEvents(ctx context.Context) error {
m.lo.Info("found pending SLA events for evaluation", "count", len(slaEvents))
- // Cache for SLA policies.
var slaPolicyCache = make(map[int]models.SLAPolicy)
for _, event := range slaEvents {
select {
@@ -402,11 +401,6 @@ func (m *Manager) evaluatePendingSLAEvents(ctx context.Context) error {
default:
}
- if err := m.q.GetSLAEvent.GetContext(ctx, &event, event.ID); err != nil {
- m.lo.Error("error fetching SLA event", "error", err)
- continue
- }
-
if event.DeadlineAt.IsZero() {
m.lo.Warn("SLA event deadline is zero, skipping evaluation", "sla_event_id", event.ID)
continue
@@ -520,7 +514,6 @@ func (m *Manager) SendNotifications(ctx context.Context) error {
m.lo.Error("error sending notification", "error", err)
}
}
- m.lo.Info("sent SLA notifications", "count", len(notifications))
}
<-ticker.C
}
@@ -876,6 +869,7 @@ func (m *Manager) evaluatePendingSLAs(ctx context.Context) error {
// evaluateSLA evaluates an SLA policy on an applied SLA.
func (m *Manager) evaluateSLA(appliedSLA models.AppliedSLA) error {
m.lo.Debug("evaluating SLA", "conversation_id", appliedSLA.ConversationID, "applied_sla_id", appliedSLA.ID)
+ var changed bool
checkDeadline := func(deadline time.Time, metAt null.Time, metric string) error {
if deadline.IsZero() {
m.lo.Warn("deadline zero, skipping checking the deadline", "conversation_id", appliedSLA.ConversationID, "applied_sla_id", appliedSLA.ID, "metric", metric)
@@ -888,6 +882,7 @@ func (m *Manager) evaluateSLA(appliedSLA models.AppliedSLA) error {
if err := m.handleSLABreach(appliedSLA.ID, appliedSLA.SLAPolicyID, metric); err != nil {
return fmt.Errorf("updating SLA breach timestamp: %w", err)
}
+ changed = true
return nil
}
@@ -897,11 +892,13 @@ func (m *Manager) evaluateSLA(appliedSLA models.AppliedSLA) error {
if err := m.handleSLABreach(appliedSLA.ID, appliedSLA.SLAPolicyID, metric); err != nil {
return fmt.Errorf("updating SLA breach: %w", err)
}
+ changed = true
} else {
m.lo.Debug("SLA type met", "deadline", deadline, "met_at", metAt.Time, "metric", metric)
if _, err := m.q.UpdateAppliedSLAMetAt.Exec(appliedSLA.ID, metric); err != nil {
return fmt.Errorf("updating SLA met: %w", err)
}
+ changed = true
}
}
return nil
@@ -923,12 +920,15 @@ func (m *Manager) evaluateSLA(appliedSLA models.AppliedSLA) error {
}
}
- // Update the conversation next SLA deadline.
+ // Nothing transitioned; skip the recompute writes that would otherwise run every tick.
+ if !changed {
+ return nil
+ }
+
if _, err := m.q.UpdateConversationNextSLADeadline.Exec(appliedSLA.ConversationID, nil); err != nil {
return fmt.Errorf("setting conversation next SLA deadline: %w", err)
}
- // Update status of applied SLA.
if _, err := m.q.UpdateAppliedSLAStatus.Exec(appliedSLA.ID); err != nil {
return fmt.Errorf("updating applied SLA status: %w", err)
}
diff --git a/internal/stringutil/stringutil.go b/internal/stringutil/stringutil.go
index e2835235..33bd1d5d 100644
--- a/internal/stringutil/stringutil.go
+++ b/internal/stringutil/stringutil.go
@@ -10,7 +10,7 @@ import (
"strings"
"time"
- "github.com/k3a/html2text"
+ "github.com/jaytaylor/html2text"
)
const (
@@ -31,9 +31,22 @@ func NormalizeWhatsAppPhone(s string) string {
return waPhoneFormatting.Replace(s)
}
+// SanitizeUTF8 removes NUL bytes and replaces invalid UTF-8 byte sequences with the Unicode replacement character.
+func SanitizeUTF8(s string) string {
+ if s == "" {
+ return s
+ }
+ s = strings.ReplaceAll(s, "\x00", "")
+ return strings.ToValidUTF8(s, "�")
+}
+
// HTML2Text converts HTML to text.
func HTML2Text(html string) string {
- return strings.TrimSpace(html2text.HTML2Text(html))
+ out, err := html2text.FromString(html, html2text.Options{TextOnly: true})
+ if err != nil {
+ return ""
+ }
+ return strings.TrimSpace(out)
}
// SanitizeFilename sanitizes the provided filename.
diff --git a/internal/stringutil/stringutil_test.go b/internal/stringutil/stringutil_test.go
index dc539da7..8311c2f1 100644
--- a/internal/stringutil/stringutil_test.go
+++ b/internal/stringutil/stringutil_test.go
@@ -256,3 +256,41 @@ func TestSanitizeFilename(t *testing.T) {
})
}
}
+
+func TestSanitizeUTF8(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {"empty", "", ""},
+ {"plain ascii unchanged", "Hello, world!", "Hello, world!"},
+ {"valid copyright unchanged", "© 2026", "© 2026"},
+ {"orphan 0xa9 replaced", "\xa9 2026 Upstox", "� 2026 Upstox"},
+ {"nul stripped", "a\x00b", "ab"},
+ {"chinese unchanged", "你好世界", "你好世界"},
+ {"devanagari unchanged", "नमस्ते", "नमस्ते"},
+ {"arabic unchanged", "مرحبا", "مرحبا"},
+ {"emoji unchanged", "ok 😀👍", "ok 😀👍"},
+ {"accented latin unchanged", "café résumé", "café résumé"},
+ {"run of invalid bytes collapses to one replacement", "x\xa9\xa9y", "x�y"},
+ {"truncated 3-byte char replaced", "\xe4\xbd", "�"},
+ {"lead byte at end replaced", "abc\xc3", "abc�"},
+ {"overlong encoding replaced", "x\xc0\x80y", "x�y"},
+ {"cp1252 smart quotes replaced", "\x93hi\x94", "�hi�"},
+ {"multiple embedded nuls stripped", "a\x00\x00b\x00c", "abc"},
+ {"nul and invalid byte combined", "a\x00\xa9b", "a�b"},
+ {"valid multibyte preserved around invalid byte", "a你\xa9好b", "a你�好b"},
+ {"bom preserved", "\ufeffhi", "\ufeffhi"},
+ {"existing replacement char preserved", "a�b", "a�b"},
+ {"crlf and tab preserved", "l1\r\nl2\t", "l1\r\nl2\t"},
+ {"paired continuation kept, orphan replaced", "é\xa9", "é�"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := SanitizeUTF8(tt.input); got != tt.expected {
+ t.Errorf("SanitizeUTF8(%q) = %q, want %q", tt.input, got, tt.expected)
+ }
+ })
+ }
+}
diff --git a/internal/stringutil/timezone.go b/internal/stringutil/timezone.go
new file mode 100644
index 00000000..c1b9465c
--- /dev/null
+++ b/internal/stringutil/timezone.go
@@ -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"
+}
diff --git a/internal/stringutil/timezone_test.go b/internal/stringutil/timezone_test.go
new file mode 100644
index 00000000..64a115e9
--- /dev/null
+++ b/internal/stringutil/timezone_test.go
@@ -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)
+ }
+}
diff --git a/internal/team/team.go b/internal/team/team.go
index d619c1ce..9c5c842d 100644
--- a/internal/team/team.go
+++ b/internal/team/team.go
@@ -126,13 +126,21 @@ func (u *Manager) Update(id int, name, timezone, conversationAssignmentType stri
return team, nil
}
-// Delete deletes a team by ID also deletes all the team members and unassigns all the conversations belonging to the team.
-func (u *Manager) Delete(id int) error {
+// Delete deletes a team and returns the affected member IDs so callers can invalidate per-user state.
+func (u *Manager) Delete(id int) ([]int, error) {
+ members, err := u.GetMembers(id)
+ if err != nil {
+ return nil, err
+ }
if _, err := u.q.DeleteTeam.Exec(id); err != nil {
u.lo.Error("error deleting team", "error", err)
- return envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil)
+ return nil, envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil)
}
- return nil
+ ids := make([]int, 0, len(members))
+ for _, m := range members {
+ ids = append(ids, m.ID)
+ }
+ return ids, nil
}
// GetUserTeams retrieves teams of a user by user ID.
diff --git a/internal/user/agent.go b/internal/user/agent.go
index c0691dd5..0be6306d 100644
--- a/internal/user/agent.go
+++ b/internal/user/agent.go
@@ -13,9 +13,9 @@ import (
"golang.org/x/crypto/bcrypt"
)
-// MonitorUserAvailability continuously checks for user activity and sets them offline if inactive for more than 5 minutes.
+// MonitorUserAvailability sweeps inactive users to offline; cadence stays well below the 5-min threshold.
func (u *Manager) MonitorUserAvailability(ctx context.Context, onUsersOffline func([]models.OfflineUser)) {
- ticker := time.NewTicker(30 * time.Second)
+ ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
for {
select {
@@ -29,7 +29,7 @@ func (u *Manager) MonitorUserAvailability(ctx context.Context, onUsersOffline fu
}
}
-// GetAgent retrieves an agent by ID and also caches it for future requests.
+// GetAgent retrieves an agent by ID and caches it.
func (u *Manager) GetAgent(id int, email string) (models.User, error) {
agent, err := u.Get(id, email, []string{models.UserTypeAgent})
if err != nil {
@@ -37,24 +37,24 @@ func (u *Manager) GetAgent(id int, email string) (models.User, error) {
}
u.agentCacheMu.Lock()
- u.agentCache[id] = agent
+ u.agentCache[agent.ID] = cachedAgent{user: agent, expiresAt: time.Now().Add(agentCacheTTL)}
u.agentCacheMu.Unlock()
return agent, nil
}
-// GetAgentFromCache retrieves an agent from the cache by ID.
+// GetAgentFromCache returns a cached agent; expired entries are treated as misses.
func (u *Manager) GetAgentFromCache(id int) (models.User, bool) {
u.agentCacheMu.RLock()
defer u.agentCacheMu.RUnlock()
- agent, exists := u.agentCache[id]
- if !exists {
+ c, exists := u.agentCache[id]
+ if !exists || time.Now().After(c.expiresAt) {
return models.User{}, false
}
- return agent, true
+ return c.user, true
}
-// GetAgentCachedOrLoad retrieves an agent from cache, falling back to DB if not cached.
+// GetAgentCachedOrLoad returns the cached agent or loads from DB.
func (u *Manager) GetAgentCachedOrLoad(id int) (models.User, error) {
if agent, exists := u.GetAgentFromCache(id); exists {
return agent, nil
@@ -62,8 +62,9 @@ func (u *Manager) GetAgentCachedOrLoad(id int) (models.User, error) {
return u.GetAgent(id, "")
}
-// InvalidateAgentCache invalidates the agent cache for a specific agent ID.
+// InvalidateAgentCache drops a single agent from the cache.
func (u *Manager) InvalidateAgentCache(id int) {
+ u.lo.Debug("invalidating agent cache", "agent_id", id)
u.agentCacheMu.Lock()
defer u.agentCacheMu.Unlock()
delete(u.agentCache, id)
@@ -73,7 +74,7 @@ func (u *Manager) InvalidateAgentCache(id int) {
func (u *Manager) InvalidateAllAgentCache() {
u.agentCacheMu.Lock()
defer u.agentCacheMu.Unlock()
- u.agentCache = make(map[int]models.User)
+ u.agentCache = make(map[int]cachedAgent)
}
// GetAgentsCompact returns a compact list of agents with limited fields.
@@ -127,7 +128,7 @@ func (u *Manager) UpdateAgent(id int, firstName, lastName, email string, roles [
u.lo.Info("setting new password for user", "user_id", id)
}
- // Update user in the database and clear cache.
+ // Update user in the database.
if _, err := u.q.UpdateAgent.Exec(id, firstName, lastName, email, pq.Array(roles), null.String{}, hashedPassword, enabled, availabilityStatus); err != nil {
if dbutil.IsUniqueViolationError(err) {
return envelope.NewError(envelope.GeneralError, u.i18n.T("user.sameEmailAlreadyExists"), nil)
@@ -135,7 +136,6 @@ func (u *Manager) UpdateAgent(id int, firstName, lastName, email string, roles [
u.lo.Error("error updating user", "error", err)
return envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil)
}
- u.InvalidateAgentCache(id)
return nil
}
@@ -163,6 +163,9 @@ func (u *Manager) MarkInactiveUsersOffline() []models.OfflineUser {
u.lo.Error("error setting users offline", "error", err)
return nil
}
+ for _, user := range users {
+ u.InvalidateAgentCache(user.ID)
+ }
if len(users) > 0 {
u.lo.Info("set inactive users offline", "count", len(users))
}
@@ -172,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", "", "")
}
diff --git a/internal/user/contact.go b/internal/user/contact.go
index 19543ef1..5fb71994 100644
--- a/internal/user/contact.go
+++ b/internal/user/contact.go
@@ -94,7 +94,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
}
@@ -104,7 +104,7 @@ 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)
}
func (u *Manager) GetContactIDByChannelIdentity(channel, identifier string) (int, error) {
diff --git a/internal/user/models/models.go b/internal/user/models/models.go
index 6e5ad8a1..c6465ab7 100644
--- a/internal/user/models/models.go
+++ b/internal/user/models/models.go
@@ -33,16 +33,17 @@ const (
)
type UserCompact struct {
- ID int `db:"id" json:"id"`
- Type string `db:"type" json:"type"`
- FirstName string `db:"first_name" json:"first_name"`
- LastName string `db:"last_name" json:"last_name"`
- Email null.String `db:"email" json:"email"`
- Enabled bool `db:"enabled" json:"enabled"`
- AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
- ExternalUserID null.String `db:"external_user_id" json:"external_user_id"`
- CreatedAt time.Time `db:"created_at" json:"created_at"`
- UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
+ ID int `db:"id" json:"id"`
+ Type string `db:"type" json:"type"`
+ FirstName string `db:"first_name" json:"first_name"`
+ LastName string `db:"last_name" json:"last_name"`
+ Email null.String `db:"email" json:"email"`
+ Enabled bool `db:"enabled" json:"enabled"`
+ AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
+ ExternalUserID null.String `db:"external_user_id" json:"external_user_id"`
+ AvailabilityStatus string `db:"availability_status" json:"availability_status"`
+ CreatedAt time.Time `db:"created_at" json:"created_at"`
+ UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Total int `db:"total" json:"-"`
}
diff --git a/internal/user/queries.sql b/internal/user/queries.sql
index aefac419..3fe00d8c 100644
--- a/internal/user/queries.sql
+++ b/internal/user/queries.sql
@@ -1,5 +1,5 @@
-- name: get-users-compact
-SELECT COUNT(*) OVER() as total, users.id, users.avatar_url, users.type, users.created_at, users.updated_at, users.first_name, users.last_name, users.email, users.enabled, users.external_user_id
+SELECT COUNT(*) OVER() as total, users.id, users.avatar_url, users.type, users.created_at, users.updated_at, users.first_name, users.last_name, users.email, users.enabled, users.external_user_id, users.availability_status
FROM users
WHERE users.email != 'System' AND users.deleted_at IS NULL AND type = ANY($1)
@@ -120,10 +120,15 @@ SET availability_status = $2
WHERE id = $1;
-- name: update-last-active-at
+WITH prev AS (
+ SELECT availability_status AS old_status FROM users WHERE id = $1
+)
UPDATE users
SET last_active_at = now(),
availability_status = CASE WHEN availability_status = 'offline' THEN 'online' ELSE availability_status END
-WHERE id = $1;
+FROM prev
+WHERE users.id = $1
+RETURNING (prev.old_status = 'offline')::boolean AS was_offline;
-- name: update-inactive-offline
UPDATE users
@@ -145,7 +150,8 @@ WHERE id = $1 AND type = 'agent';
-- name: set-password
UPDATE users
SET password = $1, reset_password_token = NULL, reset_password_token_expiry = NULL
-WHERE reset_password_token = $2 AND reset_password_token_expiry > now();
+WHERE reset_password_token = $2 AND reset_password_token_expiry > now()
+RETURNING id;
-- name: insert-agent
WITH inserted_user AS (
@@ -445,3 +451,6 @@ SELECT
(SELECT COUNT(*) FROM transfer_conversations) as conversations_transferred,
(SELECT COUNT(*) FROM transfer_messages) as messages_transferred,
(SELECT COUNT(*) FROM delete_visitor) as visitor_deleted;
+
+-- name: get-user-ids-by-role
+SELECT user_id FROM user_roles WHERE role_id = $1;
diff --git a/internal/user/user.go b/internal/user/user.go
index df223d73..f9b844a8 100644
--- a/internal/user/user.go
+++ b/internal/user/user.go
@@ -12,6 +12,7 @@ import (
"regexp"
"strings"
"sync"
+ "time"
"log"
@@ -43,14 +44,27 @@ var (
PasswordHint = fmt.Sprintf("Password must be %d-%d characters long should contain at least one uppercase letter, one lowercase letter, one number, and one special character.", minPassword, maxPassword)
)
+const (
+ lastActiveFlushDebounce = 30 * time.Second
+ agentCacheTTL = 10 * time.Minute
+)
+
// Manager handles user-related operations.
type Manager struct {
lo *logf.Logger
i18n *i18n.I18n
q queries
db *sqlx.DB
- agentCache map[int]models.User
+ agentCache map[int]cachedAgent
agentCacheMu sync.RWMutex
+
+ lastActiveFlushAt map[int]time.Time
+ lastActiveFlushAtMu sync.Mutex
+}
+
+type cachedAgent struct {
+ user models.User
+ expiresAt time.Time
}
// Opts contains options for initializing the Manager.
@@ -64,6 +78,7 @@ type queries struct {
GetUser *sqlx.Stmt `query:"get-user"`
GetNotes *sqlx.Stmt `query:"get-notes"`
GetNote *sqlx.Stmt `query:"get-note"`
+ GetUserIDsByRole *sqlx.Stmt `query:"get-user-ids-by-role"`
GetUserByExternalID *sqlx.Stmt `query:"get-user-by-external-id"`
GetUsersCompact string `query:"get-users-compact"`
UpdateContact *sqlx.Stmt `query:"update-contact"`
@@ -117,11 +132,12 @@ func New(i18n *i18n.I18n, opts Opts) (*Manager, error) {
return nil, fmt.Errorf("error scanning SQL file: %w", err)
}
return &Manager{
- q: q,
- lo: opts.Lo,
- i18n: i18n,
- db: opts.DB,
- agentCache: make(map[int]models.User),
+ q: q,
+ lo: opts.Lo,
+ i18n: i18n,
+ db: opts.DB,
+ agentCache: make(map[int]cachedAgent),
+ lastActiveFlushAt: make(map[int]time.Time),
}, nil
}
@@ -142,8 +158,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)
@@ -191,7 +207,6 @@ func (u *Manager) GetContactOrVisitor(id int, email string) (models.User, error)
return u.Get(id, email, []string{models.UserTypeContact, models.UserTypeVisitor})
}
-// GetSystemUser retrieves the system user.
func (u *Manager) GetSystemUser() (models.User, error) {
return u.Get(0, models.SystemUserEmail, []string{models.UserTypeAgent})
}
@@ -296,7 +311,6 @@ func (u *Manager) UpdateLastLoginAt(id int) error {
// SetResetPasswordToken sets a reset password token for an user and returns the token.
func (u *Manager) SetResetPasswordToken(id int) (string, error) {
- // TODO: column `reset_password_token`, does not have a UNIQUE constraint. Add it in a future migration.
token, err := stringutil.RandomAlphanumeric(32)
if err != nil {
u.lo.Error("error generating reset password token", "error", err)
@@ -309,26 +323,25 @@ func (u *Manager) SetResetPasswordToken(id int) (string, error) {
return token, nil
}
-// ResetPassword sets a password for a given user's reset password token.
-func (u *Manager) ResetPassword(token, password string) error {
+// ResetPassword sets a password for a given user's reset password token and returns the user ID.
+func (u *Manager) ResetPassword(token, password string) (int, error) {
if !IsStrongPassword(password) {
- return envelope.NewError(envelope.InputError, "Password is not strong enough, "+PasswordHint, nil)
+ return 0, envelope.NewError(envelope.InputError, "Password is not strong enough, "+PasswordHint, nil)
}
- // Hash password.
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
u.lo.Error("error generating bcrypt password", "error", err)
- return envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil)
+ return 0, envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil)
}
- rows, err := u.q.SetPassword.Exec(passwordHash, token)
- if err != nil {
+ var id int
+ if err := u.q.SetPassword.Get(&id, passwordHash, token); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return 0, envelope.NewError(envelope.InputError, u.i18n.T("user.resetPasswordTokenExpired"), nil)
+ }
u.lo.Error("error setting new password", "error", err)
- return envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil)
+ return 0, envelope.NewError(envelope.GeneralError, u.i18n.T("globals.messages.somethingWentWrong"), nil)
}
- if count, _ := rows.RowsAffected(); count == 0 {
- return envelope.NewError(envelope.InputError, u.i18n.T("user.resetPasswordTokenExpired"), nil)
- }
- return nil
+ return id, nil
}
// UpdateAvailability updates the availability status of an user.
@@ -340,13 +353,25 @@ func (u *Manager) UpdateAvailability(id int, status string) error {
return nil
}
-// UpdateLastActive updates the last active timestamp of an user.
-func (u *Manager) UpdateLastActive(id int) error {
- if _, err := u.q.UpdateLastActiveAt.Exec(id); err != nil {
- u.lo.Error("error updating user last active at", "error", err)
- return fmt.Errorf("updating user last active at: %w", err)
+// UpdateLastActive updates last_active_at and returns true if the user flipped from offline to online.
+func (u *Manager) UpdateLastActive(id int) (wasOffline bool, err error) {
+ agent, cachedOK := u.GetAgentFromCache(id)
+ alreadyOnline := cachedOK && agent.AvailabilityStatus == models.Online
+
+ // Already online and within the debounce window - nothing to do.
+ if alreadyOnline && !u.reserveFlush(id) {
+ return false, nil
}
- return nil
+
+ if err := u.q.UpdateLastActiveAt.Get(&wasOffline, id); err != nil {
+ u.lo.Error("error updating user last active at", "error", err)
+ return false, fmt.Errorf("updating user last active at: %w", err)
+ }
+
+ if wasOffline {
+ u.InvalidateAgentCache(id)
+ }
+ return wasOffline, nil
}
// IsOffline returns true if the user's availability status is offline.
@@ -463,6 +488,15 @@ func (u *Manager) MergeVisitorToContact(visitorID, contactID int) error {
return nil
}
+func (u *Manager) GetUserIDsByRole(roleID int) ([]int, error) {
+ var ids []int
+ if err := u.q.GetUserIDsByRole.Select(&ids, roleID); err != nil {
+ u.lo.Error("error fetching user ids by role", "role_id", roleID, "error", err)
+ return nil, err
+ }
+ return ids, nil
+}
+
// ChangeSystemUserPassword updates the system user's password with a newly prompted one.
func ChangeSystemUserPassword(ctx context.Context, db *sqlx.DB) error {
// Prompt for password and get hashed password
@@ -566,7 +600,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{
@@ -574,9 +608,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.
@@ -598,3 +633,15 @@ func (u *Manager) generatePassword() ([]byte, error) {
}
return bytes, nil
}
+
+// reserveFlush atomically claims the flush slot, returning false if still inside the debounce window.
+func (u *Manager) reserveFlush(id int) bool {
+ u.lastActiveFlushAtMu.Lock()
+ defer u.lastActiveFlushAtMu.Unlock()
+ if last, ok := u.lastActiveFlushAt[id]; ok && time.Since(last) < lastActiveFlushDebounce {
+ return false
+ }
+ // Stamp timestamp.
+ u.lastActiveFlushAt[id] = time.Now()
+ return true
+}
diff --git a/internal/ws/client.go b/internal/ws/client.go
index 2acdee19..daf72b66 100644
--- a/internal/ws/client.go
+++ b/internal/ws/client.go
@@ -2,7 +2,6 @@ package ws
import (
"encoding/json"
- "log"
"sync"
"time"
@@ -91,9 +90,10 @@ func (c *Client) Listen() {
// processIncomingMessage processes incoming messages from the client.
func (c *Client) processIncomingMessage(data []byte) {
- // Handle ping messages, and update last active time for user.
if string(data) == "ping" {
- c.Hub.userStore.UpdateLastActive(c.ID)
+ if _, err := c.Hub.userStore.UpdateLastActive(c.ID); err != nil {
+ c.Hub.lo.Error("UpdateLastActive failed", "client_id", c.ID, "error", err)
+ }
c.SendMessage([]byte("pong"), websocket.TextMessage)
return
}
@@ -108,6 +108,8 @@ func (c *Client) processIncomingMessage(data []byte) {
switch msg.Type {
case models.MessageTypeConversationSubscribe:
c.handleConversationSubscribe(msg.Data)
+ case models.MessageTypeListSubscribeReplace:
+ c.handleListSubscribe(msg.Data)
case models.MessageTypeTyping:
c.handleTyping(msg.Data)
default:
@@ -115,15 +117,33 @@ func (c *Client) processIncomingMessage(data []byte) {
}
}
-// handleConversationSubscribe handles conversation subscription requests.
-//
-// No per-conversation authz check by design. All subscribers are authenticated
-// agents (this hub only serves /ws, which is gated by auth()), and the only
-// data that flows over a subscription is typing indicators - low value on its
-// own. Enforcing authz.EnforceConversationAccess here would add a DB lookup
-// per subscribe/typing frame for every widget visitor's keystroke.
+const maxListSubUUIDs = 500
+
+func (c *Client) handleListSubscribe(data interface{}) {
+ dataBytes, err := json.Marshal(data)
+ if err != nil {
+ c.SendError("invalid list_subscribe payload")
+ return
+ }
+ var payload struct {
+ UUIDs []string `json:"uuids"`
+ }
+ if err := json.Unmarshal(dataBytes, &payload); err != nil {
+ c.SendError("invalid list_subscribe payload")
+ return
+ }
+ if len(payload.UUIDs) > maxListSubUUIDs {
+ payload.UUIDs = payload.UUIDs[:maxListSubUUIDs]
+ }
+ authorized, err := c.Hub.conversationStore.FilterAuthorizedListUUIDs(c.ID, payload.UUIDs)
+ if err != nil {
+ return
+ }
+ c.Hub.SubscribeListReplace(c, authorized)
+}
+
+// handleConversationSubscribe registers the open-conversation sub; authz is enforced because content (not just typing) flows through it.
func (c *Client) handleConversationSubscribe(data interface{}) {
- // Convert the data to JSON and then unmarshal to ConversationSubscribe
dataBytes, err := json.Marshal(data)
if err != nil {
c.SendError("invalid subscription data")
@@ -141,19 +161,13 @@ func (c *Client) handleConversationSubscribe(data interface{}) {
return
}
- // Subscribe to the conversation using the Hub
- c.Hub.SubscribeToConversation(c, subscribeMsg.ConversationUUID)
-
- // Send confirmation back to client
- response := models.Message{
- Type: models.MessageTypeConversationSubscribed,
- Data: map[string]string{
- "conversation_uuid": subscribeMsg.ConversationUUID,
- },
+ // Authz: silently reject if the agent can't read this conversation.
+ authorized, err := c.Hub.conversationStore.FilterAuthorizedListUUIDs(c.ID, []string{subscribeMsg.ConversationUUID})
+ if err != nil || len(authorized) == 0 {
+ return
}
- responseBytes, _ := json.Marshal(response)
- c.SendMessage(responseBytes, websocket.TextMessage)
+ c.Hub.SubscribeOpenConv(c, subscribeMsg.ConversationUUID)
}
// handleTyping handles typing indicator messages.
@@ -201,7 +215,7 @@ func (c *Client) SendError(msg string) {
select {
case c.Send <- models.WSMessage{Data: b, MessageType: websocket.TextMessage}:
default:
- log.Println("Client send channel is full. Could not send error message.")
+ c.Hub.lo.Warn("client send channel full, could not send error message", "client_id", c.ID)
c.Hub.RemoveClient(c)
c.close()
}
@@ -210,7 +224,7 @@ func (c *Client) SendError(msg string) {
// SendMessage sends a message to client.
func (c *Client) SendMessage(b []byte, typ byte) {
if c.Closed.Get() {
- log.Println("Attempted to send message to closed client")
+ c.Hub.lo.Warn("attempted to send message to closed client", "client_id", c.ID)
return
}
select {
diff --git a/internal/ws/models/models.go b/internal/ws/models/models.go
index e980c361..49e8d743 100644
--- a/internal/ws/models/models.go
+++ b/internal/ws/models/models.go
@@ -9,8 +9,9 @@ const (
MessageTypeNewNotification = "new_notification"
MessageTypeError = "error"
MessageTypeConversationSubscribe = "conversation_subscribe"
- MessageTypeConversationSubscribed = "conversation_subscribed"
MessageTypeTyping = "typing"
+ MessageTypeListSubscribeReplace = "list_subscribe_replace"
+ MessageTypeAgentAvailability = "agent_availability_update"
)
// WSMessage represents a WS message.
diff --git a/internal/ws/ws.go b/internal/ws/ws.go
index de9ceccd..40be1f96 100644
--- a/internal/ws/ws.go
+++ b/internal/ws/ws.go
@@ -3,46 +3,149 @@ package ws
import (
"sync"
+ "time"
"github.com/abhinavxd/libredesk/internal/ws/models"
"github.com/fasthttp/websocket"
+ "github.com/zerodha/logf"
)
// Hub maintains the set of registered websockets clients.
type Hub struct {
- // Client ID to WS Client map, user can connect from multiple devices and each device will have a separate client.
+ lo *logf.Logger
+
clients map[int][]*Client
clientsMutex sync.RWMutex
- // Conversation UUID to clients map for faster conversation broadcasting
- conversationClients map[string][]*Client
- conversationClientsMutex sync.RWMutex
+ convSubsList map[string]map[*Client]struct{}
+ convSubsOpen map[string]map[*Client]struct{}
+ clientListSubs map[*Client]map[string]struct{}
+ clientOpenSub map[*Client]string
+ subsMu sync.RWMutex
userStore userStore
conversationStore conversationStore
}
type userStore interface {
- UpdateLastActive(userID int) error
+ UpdateLastActive(userID int) (bool, error)
}
type conversationStore interface {
BroadcastTypingToWidgetClientsOnly(conversationUUID string, isTyping bool)
+ FilterAuthorizedListUUIDs(agentID int, uuids []string) ([]string, error)
}
// NewHub creates a new websocket hub.
-func NewHub(userStore userStore) *Hub {
+func NewHub(lo *logf.Logger, userStore userStore) *Hub {
return &Hub{
- clients: make(map[int][]*Client, 10000),
- clientsMutex: sync.RWMutex{},
- conversationClients: make(map[string][]*Client),
- conversationClientsMutex: sync.RWMutex{},
- userStore: userStore,
- // To be set later via conversationStore.
+ lo: lo,
+ clients: make(map[int][]*Client, 64),
+ clientsMutex: sync.RWMutex{},
+ convSubsList: make(map[string]map[*Client]struct{}, 1024),
+ convSubsOpen: make(map[string]map[*Client]struct{}, 64),
+ clientListSubs: make(map[*Client]map[string]struct{}, 64),
+ clientOpenSub: make(map[*Client]string, 64),
+ userStore: userStore,
conversationStore: nil,
}
}
+func (h *Hub) KickUser(userID int) {
+ h.clientsMutex.RLock()
+ clients := append([]*Client(nil), h.clients[userID]...)
+ h.clientsMutex.RUnlock()
+ if len(clients) == 0 {
+ return
+ }
+ h.lo.Debug("kicking user ws connections", "user_id", userID, "connections", len(clients))
+ closeMsg := websocket.FormatCloseMessage(websocket.CloseNormalClosure, "kicked")
+ for _, c := range clients {
+ _ = c.Conn.WriteControl(websocket.CloseMessage, closeMsg, time.Now().Add(time.Second))
+ _ = c.Conn.Close()
+ }
+}
+
+// SubscribeListReplace replaces list-source subs; open-source subs are untouched so deep links survive list refreshes.
+func (h *Hub) SubscribeListReplace(client *Client, uuids []string) {
+ h.subsMu.Lock()
+ defer h.subsMu.Unlock()
+ for uuid := range h.clientListSubs[client] {
+ delete(h.convSubsList[uuid], client)
+ if len(h.convSubsList[uuid]) == 0 {
+ delete(h.convSubsList, uuid)
+ }
+ }
+ h.clientListSubs[client] = make(map[string]struct{}, len(uuids))
+ for _, uuid := range uuids {
+ h.clientListSubs[client][uuid] = struct{}{}
+ if h.convSubsList[uuid] == nil {
+ h.convSubsList[uuid] = make(map[*Client]struct{})
+ }
+ h.convSubsList[uuid][client] = struct{}{}
+ }
+}
+
+// SubscribeOpenConv sets the client's single open-conversation sub, replacing any previous one.
+func (h *Hub) SubscribeOpenConv(client *Client, uuid string) {
+ h.subsMu.Lock()
+ defer h.subsMu.Unlock()
+ if prev, ok := h.clientOpenSub[client]; ok && prev != uuid {
+ delete(h.convSubsOpen[prev], client)
+ if len(h.convSubsOpen[prev]) == 0 {
+ delete(h.convSubsOpen, prev)
+ }
+ }
+ h.clientOpenSub[client] = uuid
+ if h.convSubsOpen[uuid] == nil {
+ h.convSubsOpen[uuid] = make(map[*Client]struct{})
+ }
+ h.convSubsOpen[uuid][client] = struct{}{}
+}
+
+// ListSubscribers returns the union of list-source and open-source subscribers for a conversation.
+func (h *Hub) ListSubscribers(uuid string) []*Client {
+ h.subsMu.RLock()
+ defer h.subsMu.RUnlock()
+ listSet := h.convSubsList[uuid]
+ openSet := h.convSubsOpen[uuid]
+ if len(listSet) == 0 && len(openSet) == 0 {
+ return nil
+ }
+ union := make(map[*Client]struct{}, len(listSet)+len(openSet))
+ for c := range listSet {
+ union[c] = struct{}{}
+ }
+ for c := range openSet {
+ union[c] = struct{}{}
+ }
+ out := make([]*Client, 0, len(union))
+ for c := range union {
+ out = append(out, c)
+ }
+ return out
+}
+
+// ClearClientSubs drops all of a client's list and open subscriptions.
+func (h *Hub) ClearClientSubs(client *Client) {
+ h.subsMu.Lock()
+ defer h.subsMu.Unlock()
+ for uuid := range h.clientListSubs[client] {
+ delete(h.convSubsList[uuid], client)
+ if len(h.convSubsList[uuid]) == 0 {
+ delete(h.convSubsList, uuid)
+ }
+ }
+ delete(h.clientListSubs, client)
+ if prev, ok := h.clientOpenSub[client]; ok {
+ delete(h.convSubsOpen[prev], client)
+ if len(h.convSubsOpen[prev]) == 0 {
+ delete(h.convSubsOpen, prev)
+ }
+ delete(h.clientOpenSub, client)
+ }
+}
+
// SetConversationStore sets the conversation store for cross-broadcasting.
func (h *Hub) SetConversationStore(manager conversationStore) {
h.conversationStore = manager
@@ -60,11 +163,6 @@ func (h *Hub) RemoveClient(client *Client) {
h.clientsMutex.Lock()
defer h.clientsMutex.Unlock()
- // Remove from all conversation subscriptions
- h.conversationClientsMutex.Lock()
- h.removeClientFromAllConversations(client)
- h.conversationClientsMutex.Unlock()
-
if clients, ok := h.clients[client.ID]; ok {
for i, c := range clients {
if c == client {
@@ -73,6 +171,26 @@ func (h *Hub) RemoveClient(client *Client) {
}
}
}
+ h.ClearClientSubs(client)
+}
+
+func (h *Hub) ConnectedUserIDs() []int {
+ h.clientsMutex.RLock()
+ defer h.clientsMutex.RUnlock()
+ out := make([]int, 0, len(h.clients))
+ for id, clients := range h.clients {
+ if len(clients) > 0 {
+ out = append(out, id)
+ }
+ }
+ return out
+}
+
+// PushToClients sends a raw payload directly to the given client connections.
+func (h *Hub) PushToClients(clients []*Client, data []byte) {
+ for _, c := range clients {
+ c.SendMessage(data, websocket.TextMessage)
+ }
}
// BroadcastMessage broadcasts a message to the specified users.
@@ -99,48 +217,14 @@ func (h *Hub) BroadcastMessage(msg models.BroadcastMessage) {
}
}
-// SubscribeToConversation subscribes a client to a conversation.
-func (h *Hub) SubscribeToConversation(client *Client, conversationUUID string) {
- h.conversationClientsMutex.Lock()
- defer h.conversationClientsMutex.Unlock()
-
- // Unsubscribe from previous conversation if any
- h.removeClientFromAllConversations(client)
-
- // Subscribe to new conversation
- h.conversationClients[conversationUUID] = append(h.conversationClients[conversationUUID], client)
-}
-
-// removeClientFromAllConversations removes a client from all conversation subscriptions.
-// Must be called with conversationClientsMutex held.
-func (h *Hub) removeClientFromAllConversations(client *Client) {
- for conversationUUID, clients := range h.conversationClients {
- for i, c := range clients {
- if c == client {
- h.conversationClients[conversationUUID] = append(clients[:i], clients[i+1:]...)
- if len(h.conversationClients[conversationUUID]) == 0 {
- delete(h.conversationClients, conversationUUID)
- }
- break
- }
- }
- }
-}
-
-// BroadcastTypingToConversation relays an agent's typing status to the customer widget only.
-// Private-note typing is skipped.
func (h *Hub) BroadcastTypingToConversation(conversationUUID string, typingMsg models.TypingMessage) {
if h.conversationStore != nil && !typingMsg.IsPrivateMessage {
h.conversationStore.BroadcastTypingToWidgetClientsOnly(conversationUUID, typingMsg.IsTyping)
}
}
-// BroadcastTypingToAllConversationClients broadcasts typing status to all clients subscribed to a conversation.
func (h *Hub) BroadcastTypingToAllConversationClients(conversationUUID string, data []byte) {
- h.conversationClientsMutex.RLock()
- defer h.conversationClientsMutex.RUnlock()
-
- for _, client := range h.conversationClients[conversationUUID] {
- client.SendMessage(data, websocket.TextMessage)
+ for _, c := range h.ListSubscribers(conversationUUID) {
+ c.SendMessage(data, websocket.TextMessage)
}
}
diff --git a/schema.sql b/schema.sql
index 862d9282..58e47979 100644
--- a/schema.sql
+++ b/schema.sql
@@ -88,6 +88,7 @@ CREATE TABLE inboxes (
prompt_tags_on_reply bool DEFAULT false NOT NULL,
config jsonb DEFAULT '{}'::jsonb NOT NULL,
"from" TEXT NULL,
+ from_name_template TEXT NOT NULL DEFAULT '',
secret TEXT NULL,
linked_email_inbox_id INT REFERENCES inboxes(id) ON DELETE SET NULL,
CONSTRAINT constraint_inboxes_on_name CHECK (length("name") <= 140)
@@ -566,6 +567,7 @@ CREATE TABLE applied_slas (
);
CREATE INDEX index_applied_slas_on_conversation_id ON applied_slas(conversation_id);
CREATE INDEX index_applied_slas_on_status ON applied_slas(status);
+CREATE UNIQUE INDEX index_applied_slas_unique_pending_per_conv ON applied_slas(conversation_id) WHERE status = 'pending';
DROP TABLE IF EXISTS sla_events CASCADE;
CREATE TABLE sla_events (
diff --git a/static/email-templates/base.html b/static/email-templates/base.html
index becb6965..aa8dc933 100644
--- a/static/email-templates/base.html
+++ b/static/email-templates/base.html
@@ -8,12 +8,12 @@
-