mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-10-25 22:09:36 +00:00
fix issues found during release testing
- Save recipient edits and sender choices with reply drafts. - Keep CC and BCC open when an address is cleared. - Allow SMTP without credentials when authentication is off. - Use the workspace timezone for reports and campaign dates. - Show CSAT responses without reloading the conversation. - Block access to attachments from deleted messages. - Accept conditional email templates and catch missing or repeated replies. - Explain why choosing another default email template failed. - Reload stale routes and keep widget image fallbacks working. - Keep the launcher usable by keyboard and reset host button styles.
This commit is contained in:
+2
-3
@@ -86,7 +86,7 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
|
||||
})
|
||||
}
|
||||
|
||||
if err := app.csat.UpdateResponse(uuid, rating, feedback, metaJSON); err != nil {
|
||||
if err := app.conversation.UpdateCSATResponse(uuid, rating, feedback, metaJSON); err != nil {
|
||||
// Surface only known, user-safe messages; never leak raw driver errors
|
||||
// (e.g. a malformed UUID triggers a Postgres syntax error string).
|
||||
msg := app.i18n.T("globals.messages.somethingWentWrong")
|
||||
@@ -161,8 +161,7 @@ func handleSubmitCSATResponse(r *fastglue.Request) error {
|
||||
|
||||
req.Feedback = truncateFeedback(req.Feedback)
|
||||
|
||||
// Update CSAT response
|
||||
if err := app.csat.UpdateResponse(uuid, req.Rating, req.Feedback, nil); err != nil {
|
||||
if err := app.conversation.UpdateCSATResponse(uuid, req.Rating, req.Feedback, nil /** meta **/); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -24,7 +24,7 @@ func handleOverviewCharts(r *fastglue.Request) error {
|
||||
app = r.Context.(*App)
|
||||
days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days")))
|
||||
)
|
||||
charts, err := app.report.GetOverviewChart(days)
|
||||
charts, err := app.report.GetOverviewChart(days, app.setting.GetAppTimezone())
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -37,7 +37,7 @@ func handleOverviewSLA(r *fastglue.Request) error {
|
||||
app = r.Context.(*App)
|
||||
days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days")))
|
||||
)
|
||||
sla, err := app.report.GetOverviewSLA(days)
|
||||
sla, err := app.report.GetOverviewSLA(days, app.setting.GetAppTimezone())
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -50,7 +50,7 @@ func handleOverviewCSAT(r *fastglue.Request) error {
|
||||
app = r.Context.(*App)
|
||||
days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days")))
|
||||
)
|
||||
csat, err := app.report.GetOverviewCSAT(days)
|
||||
csat, err := app.report.GetOverviewCSAT(days, app.setting.GetAppTimezone())
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -63,7 +63,7 @@ func handleOverviewMessageVolume(r *fastglue.Request) error {
|
||||
app = r.Context.(*App)
|
||||
days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days")))
|
||||
)
|
||||
volume, err := app.report.GetOverviewMessageVolume(days)
|
||||
volume, err := app.report.GetOverviewMessageVolume(days, app.setting.GetAppTimezone())
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -76,7 +76,7 @@ func handleOverviewTagDistribution(r *fastglue.Request) error {
|
||||
app = r.Context.(*App)
|
||||
days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days")))
|
||||
)
|
||||
tags, err := app.report.GetOverviewTagDistribution(days)
|
||||
tags, err := app.report.GetOverviewTagDistribution(days, app.setting.GetAppTimezone())
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
@@ -23,13 +23,21 @@ const validateAttachments = (attachments) => {
|
||||
)
|
||||
}
|
||||
|
||||
const validateRecipients = (recipients) =>
|
||||
Object.fromEntries(
|
||||
['to', 'cc', 'bcc', 'send_from']
|
||||
.filter(key => typeof recipients?.[key] === 'string')
|
||||
.map(key => [key, recipients[key]])
|
||||
)
|
||||
|
||||
const isDraftEmpty = (draft) => {
|
||||
const content = draft?.content || ''
|
||||
const hasText = getTextFromHTML(content).length > 0
|
||||
const hasInlineImage = /<img\b/i.test(content)
|
||||
const hasAttachments = draft?.meta?.attachments?.length > 0
|
||||
const hasMacroActions = draft?.meta?.macro_actions?.length > 0
|
||||
return !hasText && !hasInlineImage && !hasAttachments && !hasMacroActions
|
||||
const hasRecipients = Object.keys(validateRecipients(draft?.meta?.recipients)).length > 0
|
||||
return !hasText && !hasInlineImage && !hasAttachments && !hasMacroActions && !hasRecipients
|
||||
}
|
||||
|
||||
const draftKey = (uuid, type) => `${uuid}::${type}`
|
||||
@@ -38,6 +46,7 @@ const metaSignature = (meta) =>
|
||||
JSON.stringify({
|
||||
macro_id: meta?.macro_id || 0,
|
||||
macro_actions: meta?.macro_actions || [],
|
||||
recipients: validateRecipients(meta?.recipients),
|
||||
attachments: (meta?.attachments || []).map(a => a.uuid)
|
||||
})
|
||||
|
||||
@@ -54,13 +63,17 @@ export function useDraftManager (conversationUUID, messageType, uploadedFiles =
|
||||
const loadedAttachments = ref([])
|
||||
const loadedMacroActions = ref([])
|
||||
const loadedMacroID = ref(0)
|
||||
const recipients = ref({})
|
||||
|
||||
// Live-key guard: the editor is transiently empty during open/switch and must not clobber a stored draft.
|
||||
const loadedKey = ref(null)
|
||||
const currentKey = () => draftKey(conversationUUID.value, messageType.value)
|
||||
|
||||
const buildDraft = () => {
|
||||
const buildDraft = (type) => {
|
||||
const meta = {}
|
||||
if (type === 'reply' && Object.keys(recipients.value).length > 0) {
|
||||
meta.recipients = { ...recipients.value }
|
||||
}
|
||||
const macro = conversationStore.getMacro(MACRO_CONTEXT.REPLY)
|
||||
const macroActions = macro?.actions || []
|
||||
if (macro?.id > 0) meta.macro_id = macro.id
|
||||
@@ -85,6 +98,7 @@ export function useDraftManager (conversationUUID, messageType, uploadedFiles =
|
||||
loadedAttachments.value = validateAttachments(draft?.meta?.attachments)
|
||||
loadedMacroActions.value = validateMacroActions(draft?.meta?.macro_actions)
|
||||
loadedMacroID.value = Number(draft?.meta?.macro_id) > 0 ? Number(draft.meta.macro_id) : 0
|
||||
recipients.value = validateRecipients(draft?.meta?.recipients)
|
||||
}
|
||||
|
||||
const load = async (uuid, type) => {
|
||||
@@ -109,7 +123,7 @@ export function useDraftManager (conversationUUID, messageType, uploadedFiles =
|
||||
|
||||
// Update the store synchronously; server sync runs in the background, never awaited.
|
||||
const save = (uuid, type) => {
|
||||
const draft = buildDraft()
|
||||
const draft = buildDraft(type)
|
||||
if (sameDraft(draft, conversationStore.getDraft(uuid, type))) return
|
||||
if (isDraftEmpty(draft)) {
|
||||
conversationStore.removeDraft(uuid, type)
|
||||
@@ -134,6 +148,7 @@ export function useDraftManager (conversationUUID, messageType, uploadedFiles =
|
||||
const watchSources = [
|
||||
htmlContent,
|
||||
textContent,
|
||||
recipients,
|
||||
() => conversationStore.macros[MACRO_CONTEXT.REPLY]
|
||||
]
|
||||
if (uploadedFiles) watchSources.push(uploadedFiles)
|
||||
@@ -174,6 +189,7 @@ export function useDraftManager (conversationUUID, messageType, uploadedFiles =
|
||||
clearDraft,
|
||||
loadedAttachments,
|
||||
loadedMacroActions,
|
||||
loadedMacroID
|
||||
loadedMacroID,
|
||||
recipients
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { effectScope, nextTick, ref } from 'vue'
|
||||
import { useDraftManager } from './useDraftManager'
|
||||
|
||||
const { drafts, api, store } = vi.hoisted(() => {
|
||||
const drafts = new Map()
|
||||
return {
|
||||
drafts,
|
||||
api: { saveDraft: vi.fn().mockResolvedValue({}), deleteDraft: vi.fn().mockResolvedValue({}) },
|
||||
store: {
|
||||
macros: {},
|
||||
draftsReady: Promise.resolve(),
|
||||
getMacro: () => null,
|
||||
getDraft: (uuid, type) => drafts.get(`${uuid}:${type}`),
|
||||
setDraft: (uuid, type, draft) => drafts.set(`${uuid}:${type}`, draft),
|
||||
removeDraft: (uuid, type) => drafts.delete(`${uuid}:${type}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
vi.mock('@main/stores/conversation', () => ({ useConversationStore: () => store }))
|
||||
vi.mock('@main/api', () => ({ default: api }))
|
||||
|
||||
let scopes
|
||||
const settle = async () => {
|
||||
await nextTick()
|
||||
await vi.advanceTimersByTimeAsync(600)
|
||||
}
|
||||
const openDraft = () => {
|
||||
const scope = effectScope()
|
||||
scopes.push(scope)
|
||||
const uuid = ref('conversation-a')
|
||||
const type = ref('reply')
|
||||
const draft = scope.run(() => useDraftManager(uuid, type))
|
||||
return { ...draft, uuid, type }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
drafts.clear()
|
||||
scopes = []
|
||||
})
|
||||
afterEach(() => {
|
||||
scopes.forEach(scope => scope.stop())
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('reply draft recipients', () => {
|
||||
it('restores recipient edits with the body after remount', async () => {
|
||||
const draft = openDraft()
|
||||
await settle()
|
||||
draft.htmlContent.value = '<p>Keep this reply.</p>'
|
||||
draft.recipients.value = { to: 'other@example.com', cc: '', bcc: 'audit@example.com', send_from: 'alias@example.com' }
|
||||
await settle()
|
||||
const restored = openDraft()
|
||||
await settle()
|
||||
expect(restored.htmlContent.value).toBe('<p>Keep this reply.</p>')
|
||||
expect(restored.recipients.value).toEqual(draft.recipients.value)
|
||||
expect(api.saveDraft).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('preserves an explicitly removed recipient without message text', async () => {
|
||||
const draft = openDraft()
|
||||
await settle()
|
||||
draft.recipients.value = { cc: '' }
|
||||
await settle()
|
||||
expect(drafts.get('conversation-a:reply').meta.recipients).toEqual({ cc: '' })
|
||||
expect(api.deleteDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('isolates recipients across conversations and private notes', async () => {
|
||||
const draft = openDraft()
|
||||
await settle()
|
||||
draft.recipients.value = { bcc: 'audit@example.com' }
|
||||
draft.type.value = 'private_note'
|
||||
await settle()
|
||||
expect(draft.recipients.value).toEqual({})
|
||||
draft.htmlContent.value = '<p>Private note</p>'
|
||||
await settle()
|
||||
expect(drafts.get('conversation-a:private_note').meta).not.toHaveProperty('recipients')
|
||||
draft.type.value = 'reply'
|
||||
await settle()
|
||||
expect(draft.recipients.value).toEqual({ bcc: 'audit@example.com' })
|
||||
draft.uuid.value = 'conversation-b'
|
||||
await settle()
|
||||
expect(draft.recipients.value).toEqual({})
|
||||
draft.recipients.value = { to: 'second@example.com' }
|
||||
draft.uuid.value = 'conversation-a'
|
||||
await settle()
|
||||
expect(draft.recipients.value).toEqual({ bcc: 'audit@example.com' })
|
||||
expect(drafts.get('conversation-b:reply').meta.recipients).toEqual({ to: 'second@example.com' })
|
||||
})
|
||||
|
||||
it('clears recipients after a successful send without resurrecting the draft', async () => {
|
||||
const draft = openDraft()
|
||||
await settle()
|
||||
draft.recipients.value = { to: '', cc: 'copy@example.com' }
|
||||
await settle()
|
||||
draft.clearDraft()
|
||||
await settle()
|
||||
expect(draft.recipients.value).toEqual({})
|
||||
expect(drafts.size).toBe(0)
|
||||
expect(api.deleteDraft).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('loads older drafts and ignores malformed recipient metadata', async () => {
|
||||
drafts.set('conversation-a:reply', { content: '<p>Older draft</p>', meta: {} })
|
||||
const draft = openDraft()
|
||||
await settle()
|
||||
expect(draft.recipients.value).toEqual({})
|
||||
drafts.set('conversation-b:reply', { content: '<p>Reply</p>', meta: { recipients: { to: [], cc: '', bcc: null, extra: 'ignored' } } })
|
||||
draft.uuid.value = 'conversation-b'
|
||||
await settle()
|
||||
expect(draft.recipients.value).toEqual({ cc: '' })
|
||||
})
|
||||
})
|
||||
@@ -102,8 +102,8 @@ const businessHours = ref([])
|
||||
const error = ref('')
|
||||
const pendingDelete = ref(null)
|
||||
const stats = ref([])
|
||||
const from = ref(new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10))
|
||||
const to = ref(new Date().toISOString().slice(0, 10))
|
||||
const from = ref('')
|
||||
const to = ref('')
|
||||
const brandSenderName = computed(() => props.brandName.trim() || t('widget.brandBot'))
|
||||
const campaignEventExample = computed(
|
||||
() => `window.Libredesk.trackEvent(${JSON.stringify(campaign.value?.event || 'pricing_viewed')})`
|
||||
@@ -286,13 +286,24 @@ const refreshStats = async () => {
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await api.getAllBusinessHours()
|
||||
const [response, settingsResponse] = await Promise.all([
|
||||
api.getAllBusinessHours(),
|
||||
api.getSettings('general')
|
||||
])
|
||||
const data = response.data.data
|
||||
businessHours.value = data.results || data
|
||||
const dateFormat = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: settingsResponse.data.data['app.timezone'] || 'UTC',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
})
|
||||
from.value = dateFormat.format(new Date(Date.now() - 30 * 86400000))
|
||||
to.value = dateFormat.format(new Date())
|
||||
await refreshStats()
|
||||
} catch (err) {
|
||||
error.value = handleHTTPError(err).message
|
||||
}
|
||||
await refreshStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -56,8 +56,8 @@ export const createFormSchema = (t) => z.object({
|
||||
smtp: z.object({
|
||||
host: z.string().min(1, t('globals.messages.required')),
|
||||
port: z.number().min(1).max(65535),
|
||||
username: z.string().min(1, t('globals.messages.required')),
|
||||
password: z.string().min(1, t('globals.messages.required')),
|
||||
username: z.string().default(''),
|
||||
password: z.string().default(''),
|
||||
max_conns: z.number().min(1),
|
||||
max_msg_retries: z.number().min(0).max(100),
|
||||
idle_timeout: z.string().min(1, t('globals.messages.required')).refine(isGoDuration, {
|
||||
@@ -70,6 +70,13 @@ export const createFormSchema = (t) => z.object({
|
||||
tls_skip_verify: z.boolean().optional(),
|
||||
hello_hostname: z.string().optional(),
|
||||
auth_protocol: z.enum(['login', 'cram', 'plain', 'none'])
|
||||
}).superRefine((smtp, ctx) => {
|
||||
if (smtp.auth_protocol === 'none') return
|
||||
for (const field of ['username', 'password']) {
|
||||
if (!smtp[field]) {
|
||||
ctx.addIssue({ code: 'custom', path: [field], message: t('globals.messages.required') })
|
||||
}
|
||||
}
|
||||
})
|
||||
}).superRefine((values, ctx) => {
|
||||
const primary = extractEmailAddress(values.from).toLowerCase()
|
||||
|
||||
@@ -248,6 +248,28 @@ describe('Email Inbox Form Schema', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('unauthenticated SMTP accepts empty and omitted credentials', () => {
|
||||
for (const credential of ['', undefined]) {
|
||||
expect(() => schema.parse(withSmtp({
|
||||
auth_protocol: 'none', username: credential, password: credential
|
||||
}))).not.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
test('authenticated SMTP requires both credentials', () => {
|
||||
for (const auth_protocol of ['login', 'cram', 'plain']) {
|
||||
for (const field of ['username', 'password']) {
|
||||
for (const credential of ['', undefined]) {
|
||||
const result = schema.safeParse(withSmtp({ auth_protocol, [field]: credential }))
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error.issues).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ path: ['smtp', field] })
|
||||
]))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('smtp hello_hostname optional', () => {
|
||||
expect(() => schema.parse(withSmtp({ hello_hostname: 'desk.example.com' }))).not.toThrow()
|
||||
})
|
||||
|
||||
@@ -3,9 +3,7 @@ import { isGoDuration } from '@shared-ui/utils/string';
|
||||
|
||||
export const createFormSchema = (t) => z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
username: z.string().nonempty({
|
||||
message: t('globals.messages.required')
|
||||
}),
|
||||
username: z.string().default(''),
|
||||
host: z.string().nonempty({
|
||||
message: t('globals.messages.required')
|
||||
}),
|
||||
@@ -17,9 +15,7 @@ export const createFormSchema = (t) => z.object({
|
||||
.min(1, { message: t('validation.minmaxNumber', { min: 1, max: 65535 }) })
|
||||
.max(65535, { message: t('validation.minmaxNumber', { min: 1, max: 65535 }) })
|
||||
.default(587),
|
||||
password: z.string().nonempty({
|
||||
message: t('globals.messages.required')
|
||||
}),
|
||||
password: z.string().default(''),
|
||||
max_conns: z
|
||||
.number({
|
||||
invalid_type_error: t('globals.messages.mustBeNumber'),
|
||||
@@ -54,4 +50,11 @@ export const createFormSchema = (t) => z.object({
|
||||
hello_hostname: z.string().optional(),
|
||||
tls_type: z.enum(['none', 'starttls', 'tls']),
|
||||
tls_skip_verify: z.boolean().optional(),
|
||||
}).superRefine((smtp, ctx) => {
|
||||
if (smtp.auth_protocol === 'none') return
|
||||
for (const field of ['username', 'password']) {
|
||||
if (!smtp[field]) {
|
||||
ctx.addIssue({ code: 'custom', path: [field], message: t('globals.messages.required') })
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -161,6 +161,28 @@ describe('Notification Form Schema', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('unauthenticated SMTP accepts empty and omitted credentials', () => {
|
||||
for (const credential of ['', undefined]) {
|
||||
expect(() => schema.parse({
|
||||
...validForm, auth_protocol: 'none', username: credential, password: credential
|
||||
})).not.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
test('authenticated SMTP requires both credentials', () => {
|
||||
for (const auth_protocol of ['login', 'cram', 'plain']) {
|
||||
for (const field of ['username', 'password']) {
|
||||
for (const credential of ['', undefined]) {
|
||||
const result = schema.safeParse({ ...validForm, auth_protocol, [field]: credential })
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error.issues).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ path: [field] })
|
||||
]))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('tls_type missing', () => {
|
||||
const { tls_type, ...form } = validForm
|
||||
expect(() => schema.parse(form)).toThrow()
|
||||
|
||||
@@ -332,7 +332,8 @@ const {
|
||||
clearDraft,
|
||||
loadedAttachments,
|
||||
loadedMacroActions,
|
||||
loadedMacroID
|
||||
loadedMacroID,
|
||||
recipients
|
||||
} = useDraftManager(currentConversationUUID, messageType, mediaFiles)
|
||||
|
||||
// Rest of existing state
|
||||
@@ -340,11 +341,19 @@ const isEditorFullscreen = ref(false)
|
||||
const isMinimized = ref(false)
|
||||
const isSending = ref(false)
|
||||
const isGenerating = ref(false)
|
||||
const to = ref('')
|
||||
const cc = ref('')
|
||||
const bcc = ref('')
|
||||
const sendFrom = ref('')
|
||||
const senderPickedFor = ref(null)
|
||||
const recipientField = (field, defaultValue) => computed({
|
||||
get: () => recipients.value[field] ?? defaultValue(),
|
||||
set: (value) => { recipients.value = { ...recipients.value, [field]: value } }
|
||||
})
|
||||
const to = recipientField('to', () => conversationStore.currentTo?.join(', ') || '')
|
||||
const cc = recipientField('cc', () => conversationStore.currentCC?.join(', ') || '')
|
||||
const bcc = recipientField('bcc', () => conversationStore.currentBCC?.join(', ') || '')
|
||||
const sendFrom = computed({
|
||||
get: () => conversationStore.currentFromOptions.includes(recipients.value.send_from)
|
||||
? recipients.value.send_from
|
||||
: conversationStore.currentFrom || '',
|
||||
set: (value) => { recipients.value = { ...recipients.value, send_from: value } }
|
||||
})
|
||||
const showCc = ref(false)
|
||||
const showBcc = ref(false)
|
||||
const emailErrors = ref([])
|
||||
@@ -720,22 +729,8 @@ watch(
|
||||
|
||||
const pickSender = (value) => {
|
||||
sendFrom.value = value
|
||||
senderPickedFor.value = currentConversationUUID.value
|
||||
}
|
||||
|
||||
// Keep the agent's own sender pick while it is still allowed, since currentFrom changes whenever a new message arrives.
|
||||
watch(
|
||||
[currentConversationUUID, () => conversationStore.currentFrom],
|
||||
([uuid, value], [previousUUID] = []) => {
|
||||
if (uuid !== previousUUID) senderPickedFor.value = null
|
||||
const picked =
|
||||
senderPickedFor.value === uuid &&
|
||||
conversationStore.currentFromOptions.includes(sendFrom.value)
|
||||
if (!picked) sendFrom.value = value || ''
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Reset first so a loaded draft never inherits the previous conversation's macro (drafts store no message_content).
|
||||
watch(
|
||||
[loadedMacroID, loadedMacroActions],
|
||||
@@ -760,31 +755,22 @@ watch(
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// Initialize to, cc, and bcc fields with the current conversation's values.
|
||||
watch(
|
||||
() => conversationStore.currentCC,
|
||||
(newVal) => {
|
||||
cc.value = newVal?.join(', ') || ''
|
||||
showCc.value = cc.value.length > 0
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => conversationStore.currentTo,
|
||||
(newVal) => {
|
||||
to.value = newVal?.join(', ') || ''
|
||||
[isDraftLoading, cc],
|
||||
([loading, value], [wasLoading] = []) => {
|
||||
if (loading) return
|
||||
if (wasLoading || value.length > 0) showCc.value = value.length > 0
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => conversationStore.currentBCC,
|
||||
(newVal) => {
|
||||
bcc.value = newVal?.join(', ') || ''
|
||||
showBcc.value = bcc.value.length > 0
|
||||
[isDraftLoading, bcc],
|
||||
([loading, value], [wasLoading] = []) => {
|
||||
if (loading) return
|
||||
if (wasLoading || value.length > 0) showBcc.value = value.length > 0
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Media files and macro state are restored per draft by the draft manager; resetting here would race ahead of the save and drop them.
|
||||
|
||||
@@ -696,6 +696,23 @@ const router = createRouter({
|
||||
routes: routes
|
||||
})
|
||||
|
||||
const preloadErrors = new WeakSet()
|
||||
const reloadKey = 'libredesk:preload-reload'
|
||||
|
||||
window.addEventListener('vite:preloadError', (event) => {
|
||||
preloadErrors.add(event.payload)
|
||||
})
|
||||
|
||||
router.onError((error, to) => {
|
||||
if (!preloadErrors.has(error) || sessionStorage.getItem(reloadKey) === to.fullPath) return
|
||||
sessionStorage.setItem(reloadKey, to.fullPath)
|
||||
window.location.assign(to.fullPath)
|
||||
})
|
||||
|
||||
router.afterEach((to, from, failure) => {
|
||||
if (!failure) sessionStorage.removeItem(reloadKey)
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
// Cancel in-flight requests.
|
||||
if (to.fullPath !== from.fullPath) {
|
||||
|
||||
@@ -5,18 +5,16 @@
|
||||
:key="attachment.uuid"
|
||||
class="flex items-center cursor-pointer"
|
||||
>
|
||||
<!-- Image preview -->
|
||||
<div v-if="isImage(attachment)" class="relative">
|
||||
<img
|
||||
:src="getThumbnailUrl(attachment)"
|
||||
:alt="attachment.name"
|
||||
class="max-w-48 max-h-32 rounded-md object-cover"
|
||||
@error="fallbackToOriginal($event, attachment.url)"
|
||||
@error="failedThumbnails.add(attachment.uuid)"
|
||||
@click="openImage(attachment.url)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- File attachment -->
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center gap-2 p-2 bg-muted rounded-md border border-border hover:bg-muted/80 transition-colors"
|
||||
@@ -35,6 +33,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { File } from 'lucide-vue-next';
|
||||
import { formatBytes, getThumbFilepath } from '@shared-ui/utils/file';
|
||||
defineProps({
|
||||
@@ -44,21 +43,17 @@ defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const failedThumbnails = ref(new Set())
|
||||
|
||||
const isImage = (attachment) => {
|
||||
return attachment.content_type && attachment.content_type.startsWith('image/')
|
||||
}
|
||||
|
||||
const getThumbnailUrl = (attachment) => {
|
||||
if (!isImage(attachment)) return attachment.url
|
||||
if (!isImage(attachment) || failedThumbnails.value.has(attachment.uuid)) return attachment.url
|
||||
return attachment.thumbnail_url || getThumbFilepath(attachment.url)
|
||||
}
|
||||
|
||||
const fallbackToOriginal = (event, originalUrl) => {
|
||||
if (event.target.dataset.originalFallback) return
|
||||
event.target.dataset.originalFallback = 'true'
|
||||
event.target.src = originalUrl
|
||||
}
|
||||
|
||||
const openImage = (url) => {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
@@ -56,6 +56,8 @@ export function useReplyPreviews() {
|
||||
identity: String(user.userID || 'visitor'),
|
||||
previews,
|
||||
labels: {
|
||||
openChat: t('globals.messages.openChat'),
|
||||
closeChat: t('globals.messages.closeChat'),
|
||||
dismiss: t('globals.terms.dismiss'),
|
||||
dismissAll: t('widget.dismissPreviews'),
|
||||
open: t('globals.messages.openConversation')
|
||||
|
||||
+2
-1
@@ -1711,9 +1711,10 @@
|
||||
"team.edit": "Edit team",
|
||||
"team.new": "New team",
|
||||
"template.cannotDeleteBuiltInTemplate": "Cannot delete built-in template",
|
||||
"template.defaultTemplateAlreadyExists": "Default template already exists",
|
||||
"template.defaultTemplateAlreadyExists": "Another outgoing email template is already the default. Clear its default setting before choosing this template.",
|
||||
"template.deletionConfirmation": "This action cannot be undone. This will permanently delete this template.",
|
||||
"template.edit": "Edit template",
|
||||
"template.invalidOutgoingTemplate": "This email template cannot deliver replies. Check the syntax and include {content} exactly once.",
|
||||
"toast.apiKeyGenerated": "API key generated",
|
||||
"toast.authorizationDenied": "Authorization denied",
|
||||
"toast.avatarUpdated": "Avatar updated successfully",
|
||||
|
||||
@@ -226,6 +226,7 @@ type settingsStore interface {
|
||||
type csatStore interface {
|
||||
Create(conversationID int) (csatModels.CSATResponse, error)
|
||||
Get(uuid string) (csatModels.CSATResponse, error)
|
||||
UpdateResponse(uuid string, score int, feedback string, meta json.RawMessage) error
|
||||
MakePublicURL(appBaseURL, uuid string) string
|
||||
}
|
||||
|
||||
@@ -386,6 +387,7 @@ type queries struct {
|
||||
|
||||
// Message queries.
|
||||
GetMessage *sqlx.Stmt `query:"get-message"`
|
||||
GetCSATMessages *sqlx.Stmt `query:"get-csat-messages"`
|
||||
GetMessages string `query:"get-messages"`
|
||||
GetContactUnreadPreviewMessages *sqlx.Stmt `query:"get-contact-unread-preview-messages"`
|
||||
GetOutgoingPendingMessages *sqlx.Stmt `query:"get-outgoing-pending-messages"`
|
||||
@@ -1956,6 +1958,23 @@ func (m *Manager) SendCSATReply(actorUserID int, conversation models.Conversatio
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) UpdateCSATResponse(uuid string, rating int, feedback string, meta json.RawMessage) error {
|
||||
if err := m.csatStore.UpdateResponse(uuid, rating, feedback, meta); err != nil {
|
||||
return err
|
||||
}
|
||||
var messages []models.Message
|
||||
if err := m.q.GetCSATMessages.Select(&messages, uuid); err != nil {
|
||||
m.lo.Error("error fetching CSAT messages for update", "csat_uuid", uuid, "error", err)
|
||||
return nil
|
||||
}
|
||||
for _, message := range messages {
|
||||
message.CensorCSATContentWithStatus(true /** csatSubmitted **/, uuid, rating, feedback)
|
||||
message.StripCSATUUID()
|
||||
m.BroadcastMessageUpdate(message.ConversationUUID, message.UUID, map[string]any{"meta": message.Meta})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteConversation deletes a conversation.
|
||||
func (m *Manager) DeleteConversation(uuid string) error {
|
||||
res, err := m.q.DeleteConversation.Exec(uuid)
|
||||
|
||||
@@ -1,17 +1,100 @@
|
||||
package conversation
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
am "github.com/abhinavxd/libredesk/internal/authz/models"
|
||||
"github.com/abhinavxd/libredesk/internal/conversation/models"
|
||||
"github.com/abhinavxd/libredesk/internal/csat"
|
||||
"github.com/abhinavxd/libredesk/internal/dbutil"
|
||||
tmodels "github.com/abhinavxd/libredesk/internal/team/models"
|
||||
"github.com/abhinavxd/libredesk/internal/testutil"
|
||||
umodels "github.com/abhinavxd/libredesk/internal/user/models"
|
||||
"github.com/abhinavxd/libredesk/internal/ws"
|
||||
wsmodels "github.com/abhinavxd/libredesk/internal/ws/models"
|
||||
"github.com/zerodha/logf"
|
||||
)
|
||||
|
||||
func TestCSATSubmissionBroadcastsSavedFeedback(t *testing.T) {
|
||||
db := testutil.NewDB(t, "csat_live_update")
|
||||
lo := logf.New(logf.Opts{})
|
||||
lang := testutil.NewI18n(t)
|
||||
csats, err := csat.New(csat.Opts{DB: db, Lo: &lo, I18n: lang})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hub := ws.NewHub(&lo, nil /** userStore **/)
|
||||
m := &Manager{lo: &lo, i18n: lang, csatStore: csats, wsHub: hub}
|
||||
if err := dbutil.ScanSQLFile("queries.sql", &m.q, db, efs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var contactID, inboxID int
|
||||
if err := db.Get(&contactID, `INSERT INTO users (type, first_name, last_name) VALUES ('contact', 'Customer', '') RETURNING id`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Get(&inboxID, `INSERT INTO inboxes (name, channel) VALUES ('Chat', 'livechat') RETURNING id`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, rating := range []int{0, 5} {
|
||||
t.Run(fmt.Sprint(rating), func(t *testing.T) {
|
||||
var conv models.Conversation
|
||||
if err := db.Get(&conv, `INSERT INTO conversations (contact_id, inbox_id, status_id)
|
||||
VALUES ($1, $2, (SELECT id FROM conversation_statuses LIMIT 1)) RETURNING id, uuid`, contactID, inboxID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := csats.Create(conv.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var messageUUID string
|
||||
if err := db.Get(&messageUUID, `INSERT INTO conversation_messages (conversation_id, type, status, sender_id, sender_type, meta)
|
||||
VALUES ($1, 'outgoing', 'sent', $2, 'agent', jsonb_build_object('is_csat', true, 'csat_uuid', $3::text)) RETURNING uuid`, conv.ID, contactID, response.UUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := &ws.Client{Send: make(chan wsmodels.WSMessage, 2)}
|
||||
hub.SubscribeOpenConv(client, conv.UUID)
|
||||
feedback := "Helpful support. Café 日本語."
|
||||
if err := m.UpdateCSATResponse(response.UUID, rating, feedback, nil /** meta **/); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case update := <-client.Send:
|
||||
var payload struct {
|
||||
Type string `json:"type"`
|
||||
Data struct {
|
||||
UUID string `json:"uuid"`
|
||||
ConversationUUID string `json:"conversation_uuid"`
|
||||
Meta map[string]any `json:"meta"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(update.Data, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload.Type != wsmodels.MessageTypeMessageUpdate || payload.Data.UUID != messageUUID || payload.Data.ConversationUUID != conv.UUID {
|
||||
t.Fatalf("wrong message update: %+v", payload)
|
||||
}
|
||||
meta := payload.Data.Meta
|
||||
if meta["csat_submitted"] != true || meta["submitted_feedback"] != feedback || meta["csat_uuid"] != nil {
|
||||
t.Fatalf("incorrect or exposed CSAT metadata: %+v", meta)
|
||||
}
|
||||
if rating > 0 && meta["submitted_rating"] != float64(rating) {
|
||||
t.Fatalf("wrong rating: %+v", meta)
|
||||
}
|
||||
default:
|
||||
t.Fatal("no live CSAT update")
|
||||
}
|
||||
if err := m.UpdateCSATResponse(response.UUID, 1 /** rating **/, "duplicate", nil /** meta **/); err == nil {
|
||||
t.Fatal("duplicate response accepted")
|
||||
}
|
||||
if len(client.Send) != 0 {
|
||||
t.Fatal("rejected response broadcast an update")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviousConversationsRespectAccessBeforeLimit(t *testing.T) {
|
||||
db := testutil.NewDB(t, "previous_access")
|
||||
lo := logf.New(logf.Opts{})
|
||||
|
||||
@@ -915,6 +915,13 @@ AND NOT EXISTS (
|
||||
)
|
||||
ORDER BY m.id
|
||||
|
||||
-- name: get-csat-messages
|
||||
SELECT m.uuid, m.meta, c.uuid AS conversation_uuid
|
||||
FROM csat_responses r
|
||||
JOIN conversations c ON c.id = r.conversation_id
|
||||
JOIN conversation_messages m ON m.conversation_id = c.id
|
||||
WHERE r.uuid = $1 AND m.meta->>'csat_uuid' = r.uuid::text;
|
||||
|
||||
-- name: get-message
|
||||
SELECT
|
||||
m.id,
|
||||
|
||||
@@ -1,15 +1,77 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/dbutil"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/abhinavxd/libredesk/internal/media/models"
|
||||
"github.com/abhinavxd/libredesk/internal/testutil"
|
||||
"github.com/volatiletech/null/v9"
|
||||
"github.com/zerodha/logf"
|
||||
)
|
||||
|
||||
func TestDeletedMessageMediaUnavailable(t *testing.T) {
|
||||
db := testutil.NewDB(t, "deleted_message_media")
|
||||
lo := logf.New(logf.Opts{})
|
||||
m := &Manager{lo: &lo, i18n: testutil.NewI18n(t), rootURL: func() string { return "https://support.example.com" }}
|
||||
if err := dbutil.ScanSQLFile("queries.sql", &m.queries, db, efs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var contactID, inboxID, conversationID, messageID int
|
||||
if err := db.Get(&contactID, `INSERT INTO users (type, first_name, last_name) VALUES ('contact', 'Customer', '') RETURNING id`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Get(&inboxID, `INSERT INTO inboxes (name, channel) VALUES ('Chat', 'livechat') RETURNING id`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Get(&conversationID, `INSERT INTO conversations (contact_id, inbox_id, status_id)
|
||||
VALUES ($1, $2, (SELECT id FROM conversation_statuses LIMIT 1)) RETURNING id`, contactID, inboxID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Get(&messageID, `INSERT INTO conversation_messages (conversation_id, type, status, sender_id, sender_type)
|
||||
VALUES ($1, 'incoming', 'sent', $2, 'contact') RETURNING id`, conversationID, contactID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var attachments []models.Media
|
||||
for _, modelID := range []null.Int{{}, null.IntFrom(0), null.IntFrom(messageID)} {
|
||||
var med models.Media
|
||||
if err := db.Get(&med, `INSERT INTO media (store, filename, content_type, content_id, size, model_type, model_id, private)
|
||||
VALUES ('fs', 'receipt.png', 'image/png', '', 1, 'messages', $1, false) RETURNING id, uuid`, modelID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
attachments = append(attachments, med)
|
||||
if _, err := m.Get(med.ID, "" /** uuid **/); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := m.Get(0 /** id **/, med.UUID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := db.Exec(`DELETE FROM users WHERE id = $1`, contactID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, med := range attachments {
|
||||
for _, lookup := range []func() (models.Media, error){
|
||||
func() (models.Media, error) { return m.Get(med.ID, "" /** uuid **/) },
|
||||
func() (models.Media, error) { return m.Get(0 /** id **/, med.UUID) },
|
||||
} {
|
||||
_, err := lookup()
|
||||
if i < 2 {
|
||||
if err != nil {
|
||||
t.Fatalf("unattached draft became unavailable: %v", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
var missing envelope.Error
|
||||
if !errors.As(err, &missing) || missing.ErrorType != envelope.NotFoundError {
|
||||
t.Fatalf("deleted contact attachment remained available: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingMediaOwnership(t *testing.T) {
|
||||
db := testutil.NewDB(t, "media_ownership")
|
||||
lo := logf.New(logf.Opts{})
|
||||
|
||||
@@ -20,9 +20,12 @@ RETURNING id;
|
||||
SELECT id, created_at, updated_at, "uuid", store, filename, content_type, content_id, model_id, model_type, disposition, "size", meta, private, uploaded_by
|
||||
FROM media
|
||||
WHERE
|
||||
($1 > 0 AND id = $1)
|
||||
OR
|
||||
($2 != '' AND uuid = NULLIF($2, '')::uuid)
|
||||
(($1 > 0 AND id = $1) OR ($2 != '' AND uuid = NULLIF($2, '')::uuid))
|
||||
AND (
|
||||
model_type IS DISTINCT FROM 'messages'
|
||||
OR COALESCE(model_id, 0) = 0
|
||||
OR EXISTS (SELECT 1 FROM conversation_messages cm WHERE cm.id = media.model_id)
|
||||
);
|
||||
|
||||
-- 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, private, uploaded_by
|
||||
|
||||
+10
-10
@@ -67,7 +67,7 @@ WITH latest_applied AS (
|
||||
resolution_met_at, resolution_breached_at
|
||||
FROM applied_slas
|
||||
WHERE created_at >= CASE
|
||||
WHEN %d = 0 THEN CURRENT_DATE
|
||||
WHEN %d = 0 THEN DATE_TRUNC('day', NOW() AT TIME ZONE $1) AT TIME ZONE $1
|
||||
ELSE NOW() - INTERVAL '%d days'
|
||||
END
|
||||
ORDER BY conversation_id, created_at DESC, id DESC
|
||||
@@ -148,7 +148,7 @@ next_response AS (
|
||||
sla_events
|
||||
WHERE
|
||||
created_at >= CASE
|
||||
WHEN %d = 0 THEN CURRENT_DATE
|
||||
WHEN %d = 0 THEN DATE_TRUNC('day', NOW() AT TIME ZONE $1) AT TIME ZONE $1
|
||||
ELSE NOW() - INTERVAL '%d days'
|
||||
END
|
||||
AND type = 'next_response'
|
||||
@@ -189,13 +189,13 @@ WITH new_conversations AS (
|
||||
FROM
|
||||
(
|
||||
SELECT
|
||||
TO_CHAR(created_at :: date, 'YYYY-MM-DD') AS date,
|
||||
TO_CHAR(created_at AT TIME ZONE $1, 'YYYY-MM-DD') AS date,
|
||||
COUNT(*) AS count
|
||||
FROM
|
||||
conversations c
|
||||
WHERE
|
||||
c.created_at >= CASE
|
||||
WHEN %d = 0 THEN CURRENT_DATE
|
||||
WHEN %d = 0 THEN DATE_TRUNC('day', NOW() AT TIME ZONE $1) AT TIME ZONE $1
|
||||
ELSE NOW() - INTERVAL '%d days'
|
||||
END
|
||||
GROUP BY
|
||||
@@ -210,13 +210,13 @@ resolved_conversations AS (
|
||||
FROM
|
||||
(
|
||||
SELECT
|
||||
TO_CHAR(resolved_at :: date, 'YYYY-MM-DD') AS date,
|
||||
TO_CHAR(resolved_at AT TIME ZONE $1, 'YYYY-MM-DD') AS date,
|
||||
COUNT(*) AS count
|
||||
FROM
|
||||
conversations c
|
||||
WHERE
|
||||
c.resolved_at >= CASE
|
||||
WHEN %d = 0 THEN CURRENT_DATE
|
||||
WHEN %d = 0 THEN DATE_TRUNC('day', NOW() AT TIME ZONE $1) AT TIME ZONE $1
|
||||
ELSE NOW() - INTERVAL '%d days'
|
||||
END
|
||||
GROUP BY
|
||||
@@ -263,7 +263,7 @@ FROM
|
||||
csat_responses
|
||||
WHERE
|
||||
created_at >= CASE
|
||||
WHEN %d = 0 THEN CURRENT_DATE
|
||||
WHEN %d = 0 THEN DATE_TRUNC('day', NOW() AT TIME ZONE $1) AT TIME ZONE $1
|
||||
ELSE NOW() - INTERVAL '%d days'
|
||||
END;
|
||||
|
||||
@@ -281,7 +281,7 @@ WITH per_conversation AS (
|
||||
AND private = false
|
||||
AND (type = 'incoming' OR status = 'sent')
|
||||
AND created_at >= CASE
|
||||
WHEN %d = 0 THEN CURRENT_DATE
|
||||
WHEN %d = 0 THEN DATE_TRUNC('day', NOW() AT TIME ZONE $1) AT TIME ZONE $1
|
||||
ELSE NOW() - INTERVAL '%d days'
|
||||
END
|
||||
GROUP BY
|
||||
@@ -321,7 +321,7 @@ WITH tag_counts AS (
|
||||
LEFT JOIN conversation_tags ct ON t.id = ct.tag_id
|
||||
LEFT JOIN conversations c ON ct.conversation_id = c.id
|
||||
AND c.created_at >= CASE
|
||||
WHEN %d = 0 THEN CURRENT_DATE
|
||||
WHEN %d = 0 THEN DATE_TRUNC('day', NOW() AT TIME ZONE $1) AT TIME ZONE $1
|
||||
ELSE NOW() - INTERVAL '%d days'
|
||||
END
|
||||
GROUP BY
|
||||
@@ -343,7 +343,7 @@ tagging AS (
|
||||
conversations c
|
||||
WHERE
|
||||
c.created_at >= CASE
|
||||
WHEN %d = 0 THEN CURRENT_DATE
|
||||
WHEN %d = 0 THEN DATE_TRUNC('day', NOW() AT TIME ZONE $1) AT TIME ZONE $1
|
||||
ELSE NOW() - INTERVAL '%d days'
|
||||
END
|
||||
)
|
||||
|
||||
+11
-10
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/abhinavxd/libredesk/internal/dbutil"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/abhinavxd/libredesk/internal/report/models"
|
||||
"github.com/abhinavxd/libredesk/internal/stringutil"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/go-i18n"
|
||||
"github.com/zerodha/logf"
|
||||
@@ -90,7 +91,7 @@ func (m *Manager) GetOverViewCounts() (json.RawMessage, error) {
|
||||
}
|
||||
|
||||
// GetOverviewSLA returns overview SLA data
|
||||
func (m *Manager) GetOverviewSLA(days int) (json.RawMessage, error) {
|
||||
func (m *Manager) GetOverviewSLA(days int, timezone string) (json.RawMessage, error) {
|
||||
days = clampDays(days)
|
||||
tx, err := m.db.BeginTxx(context.Background(), &sql.TxOptions{
|
||||
ReadOnly: true,
|
||||
@@ -104,7 +105,7 @@ func (m *Manager) GetOverviewSLA(days int) (json.RawMessage, error) {
|
||||
var result models.OverviewSLA
|
||||
// Format query with days parameter for both CTEs
|
||||
query := fmt.Sprintf(m.q.GetOverviewSLA, days, days, days, days)
|
||||
if err := tx.Get(&result, query); err != nil {
|
||||
if err := tx.Get(&result, query, stringutil.NormalizeTimezone(timezone)); err != nil {
|
||||
m.lo.Error("error fetching overview SLA data", "error", err)
|
||||
return nil, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
||||
}
|
||||
@@ -124,7 +125,7 @@ func (m *Manager) GetOverviewSLA(days int) (json.RawMessage, error) {
|
||||
}
|
||||
|
||||
// GetOverviewChart returns overview chart data
|
||||
func (m *Manager) GetOverviewChart(days int) (json.RawMessage, error) {
|
||||
func (m *Manager) GetOverviewChart(days int, timezone string) (json.RawMessage, error) {
|
||||
days = clampDays(days)
|
||||
var stats = json.RawMessage{}
|
||||
tx, err := m.db.BeginTxx(context.Background(), &sql.TxOptions{
|
||||
@@ -137,7 +138,7 @@ func (m *Manager) GetOverviewChart(days int) (json.RawMessage, error) {
|
||||
defer tx.Rollback()
|
||||
|
||||
query := fmt.Sprintf(m.q.GetOverviewCharts, days, days, days, days)
|
||||
if err := tx.Get(&stats, query); err != nil {
|
||||
if err := tx.Get(&stats, query, stringutil.NormalizeTimezone(timezone)); err != nil {
|
||||
m.lo.Error("error fetching overview charts", "error", err)
|
||||
return nil, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
||||
}
|
||||
@@ -145,7 +146,7 @@ func (m *Manager) GetOverviewChart(days int) (json.RawMessage, error) {
|
||||
}
|
||||
|
||||
// GetOverviewCSAT returns CSAT metrics for the overview dashboard
|
||||
func (m *Manager) GetOverviewCSAT(days int) (json.RawMessage, error) {
|
||||
func (m *Manager) GetOverviewCSAT(days int, timezone string) (json.RawMessage, error) {
|
||||
days = clampDays(days)
|
||||
var stats = json.RawMessage{}
|
||||
tx, err := m.db.BeginTxx(context.Background(), &sql.TxOptions{
|
||||
@@ -158,7 +159,7 @@ func (m *Manager) GetOverviewCSAT(days int) (json.RawMessage, error) {
|
||||
defer tx.Rollback()
|
||||
|
||||
query := fmt.Sprintf(m.q.GetOverviewCSAT, days, days)
|
||||
if err := tx.Get(&stats, query); err != nil {
|
||||
if err := tx.Get(&stats, query, stringutil.NormalizeTimezone(timezone)); err != nil {
|
||||
m.lo.Error("error fetching overview CSAT", "error", err)
|
||||
return nil, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
||||
}
|
||||
@@ -166,7 +167,7 @@ func (m *Manager) GetOverviewCSAT(days int) (json.RawMessage, error) {
|
||||
}
|
||||
|
||||
// GetOverviewMessageVolume returns message volume metrics for the overview dashboard
|
||||
func (m *Manager) GetOverviewMessageVolume(days int) (json.RawMessage, error) {
|
||||
func (m *Manager) GetOverviewMessageVolume(days int, timezone string) (json.RawMessage, error) {
|
||||
days = clampDays(days)
|
||||
var stats = json.RawMessage{}
|
||||
tx, err := m.db.BeginTxx(context.Background(), &sql.TxOptions{
|
||||
@@ -179,7 +180,7 @@ func (m *Manager) GetOverviewMessageVolume(days int) (json.RawMessage, error) {
|
||||
defer tx.Rollback()
|
||||
|
||||
query := fmt.Sprintf(m.q.GetOverviewMessageVolume, days, days)
|
||||
if err := tx.Get(&stats, query); err != nil {
|
||||
if err := tx.Get(&stats, query, stringutil.NormalizeTimezone(timezone)); err != nil {
|
||||
m.lo.Error("error fetching overview message volume", "error", err)
|
||||
return nil, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
||||
}
|
||||
@@ -187,7 +188,7 @@ func (m *Manager) GetOverviewMessageVolume(days int) (json.RawMessage, error) {
|
||||
}
|
||||
|
||||
// GetOverviewTagDistribution returns tag distribution metrics for the overview dashboard
|
||||
func (m *Manager) GetOverviewTagDistribution(days int) (json.RawMessage, error) {
|
||||
func (m *Manager) GetOverviewTagDistribution(days int, timezone string) (json.RawMessage, error) {
|
||||
days = clampDays(days)
|
||||
var stats = json.RawMessage{}
|
||||
tx, err := m.db.BeginTxx(context.Background(), &sql.TxOptions{
|
||||
@@ -200,7 +201,7 @@ func (m *Manager) GetOverviewTagDistribution(days int) (json.RawMessage, error)
|
||||
defer tx.Rollback()
|
||||
|
||||
query := fmt.Sprintf(m.q.GetOverviewTagDistribution, days, days, days, days)
|
||||
if err := tx.Get(&stats, query); err != nil {
|
||||
if err := tx.Get(&stats, query, stringutil.NormalizeTimezone(timezone)); err != nil {
|
||||
m.lo.Error("error fetching overview tag distribution", "error", err)
|
||||
return nil, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/testutil"
|
||||
"github.com/zerodha/logf"
|
||||
)
|
||||
|
||||
func TestOverviewDatesUseWorkspaceTimezone(t *testing.T) {
|
||||
db := testutil.NewDB(t, "report_timezone")
|
||||
lo := logf.New(logf.Opts{})
|
||||
m, err := New(Opts{DB: db, Lo: &lo, I18n: testutil.NewI18n(t)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var contactID, inboxID int
|
||||
if err := db.Get(&contactID, `INSERT INTO users (type, first_name, last_name) VALUES ('contact', 'Report', '') RETURNING id`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Get(&inboxID, `INSERT INTO inboxes (name, channel) VALUES ('Report', 'livechat') RETURNING id`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := time.Now().UTC().Truncate(24 * time.Hour).Add(-24 * time.Hour)
|
||||
instants := []time.Time{base.Add(30 * time.Minute), base.Add(23*time.Hour + 30*time.Minute)}
|
||||
for _, instant := range instants {
|
||||
if _, err := db.Exec(`INSERT INTO conversations (contact_id, inbox_id, status_id, created_at, resolved_at)
|
||||
VALUES ($1, $2, (SELECT id FROM conversation_statuses LIMIT 1), $3, $3)`, contactID, inboxID, instant); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, timezone := range []string{"UTC", "Asia/Kathmandu", "America/Los_Angeles", "", "invalid/zone", "Local"} {
|
||||
t.Run(timezone, func(t *testing.T) {
|
||||
loc := time.UTC
|
||||
if timezone == "Asia/Kathmandu" || timezone == "America/Los_Angeles" {
|
||||
loc, err = time.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
want := map[string]int{}
|
||||
for _, instant := range instants {
|
||||
want[instant.In(loc).Format(time.DateOnly)]++
|
||||
}
|
||||
raw, err := m.GetOverviewChart(7 /** days **/, timezone)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var charts map[string][]struct {
|
||||
Date string `json:"date"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &charts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"new_conversations", "resolved_conversations"} {
|
||||
if len(charts[name]) != len(want) {
|
||||
t.Fatalf("%s dates = %v, want %v", name, charts[name], want)
|
||||
}
|
||||
for _, row := range charts[name] {
|
||||
if row.Count != want[row.Date] {
|
||||
t.Fatalf("%s row = %+v, want %v", name, row, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`DELETE FROM conversations`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
timezone := "Asia/Kathmandu"
|
||||
var midnight time.Time
|
||||
if err := db.Get(&midnight, `SELECT DATE_TRUNC('day', NOW() AT TIME ZONE $1) AT TIME ZONE $1`, timezone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, instant := range []time.Time{midnight.Add(-time.Minute), midnight.Add(time.Minute)} {
|
||||
var conversationID int
|
||||
if err := db.Get(&conversationID, `INSERT INTO conversations (contact_id, inbox_id, status_id, created_at)
|
||||
VALUES ($1, $2, (SELECT id FROM conversation_statuses LIMIT 1), $3) RETURNING id`, contactID, inboxID, instant); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO csat_responses (conversation_id, rating, created_at) VALUES ($1, 5, $2)`, conversationID, instant); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO conversation_messages (conversation_id, type, status, sender_id, sender_type, created_at)
|
||||
VALUES ($1, 'incoming', 'sent', $2, 'contact', $3)`, conversationID, contactID, instant); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
get func(int, string) (json.RawMessage, error)
|
||||
key string
|
||||
want float64
|
||||
}{
|
||||
{"csat", m.GetOverviewCSAT, "total_responses", 1},
|
||||
{"messages", m.GetOverviewMessageVolume, "total_messages", 1},
|
||||
{"tags", m.GetOverviewTagDistribution, "untagged_conversations", 1},
|
||||
{"sla", m.GetOverviewSLA, "first_response_met_count", 0},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
raw, err := tc.get(0 /** days **/, timezone)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(raw, &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result[tc.key] != tc.want {
|
||||
t.Fatalf("%s = %v, want %v", tc.key, result[tc.key], tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
"text/template/parse"
|
||||
|
||||
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
|
||||
umodels "github.com/abhinavxd/libredesk/internal/user/models"
|
||||
@@ -74,7 +75,15 @@ func (m *Manager) RenderEmailWithTemplate(data any, content string) (string, err
|
||||
return "", fmt.Errorf("parsing base template: %w", err)
|
||||
}
|
||||
|
||||
contentTemplate, err := template.New(TmplContent).Funcs(m.funcMap).Parse(content)
|
||||
contentCount := 0
|
||||
trackContent := template.FuncMap{
|
||||
"_libredeskTrackContent": func() string {
|
||||
contentCount++
|
||||
return ""
|
||||
},
|
||||
}
|
||||
baseTemplate.Funcs(trackContent)
|
||||
contentTemplate, err := template.New(TmplContent).Funcs(m.funcMap).Funcs(trackContent).Parse(`{{_libredeskTrackContent}}` + content)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parsing content template: %w", err)
|
||||
}
|
||||
@@ -89,6 +98,9 @@ func (m *Manager) RenderEmailWithTemplate(data any, content string) (string, err
|
||||
return "", fmt.Errorf("executing base template: %w", err)
|
||||
}
|
||||
|
||||
if contentCount != 1 {
|
||||
return "", fmt.Errorf("outgoing template rendered the reply %d times, expected once", contentCount)
|
||||
}
|
||||
return rendered.String(), nil
|
||||
}
|
||||
|
||||
@@ -215,3 +227,43 @@ func personData(person umodels.User) map[string]any {
|
||||
"Email": person.Email.String,
|
||||
}
|
||||
}
|
||||
|
||||
func parseOutgoingTemplate(body string, funcs template.FuncMap) (*template.Template, error) {
|
||||
tmpl, err := template.New(TmplBase).Funcs(funcs).Parse(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
count := 0
|
||||
for _, part := range tmpl.Templates() {
|
||||
count += countContentTemplates(part.Tree.Root)
|
||||
}
|
||||
if count == 0 {
|
||||
return nil, fmt.Errorf("outgoing template contains no reply template")
|
||||
}
|
||||
return tmpl, nil
|
||||
}
|
||||
|
||||
func countContentTemplates(node parse.Node) int {
|
||||
switch node := node.(type) {
|
||||
case *parse.TemplateNode:
|
||||
if node.Name == TmplContent {
|
||||
return 1
|
||||
}
|
||||
case *parse.ListNode:
|
||||
if node == nil {
|
||||
return 0
|
||||
}
|
||||
count := 0
|
||||
for _, child := range node.Nodes {
|
||||
count += countContentTemplates(child)
|
||||
}
|
||||
return count
|
||||
case *parse.IfNode:
|
||||
return countContentTemplates(node.List) + countContentTemplates(node.ElseList)
|
||||
case *parse.RangeNode:
|
||||
return countContentTemplates(node.List) + countContentTemplates(node.ElseList)
|
||||
case *parse.WithNode:
|
||||
return countContentTemplates(node.List) + countContentTemplates(node.ElseList)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"sync"
|
||||
texttemplate "text/template"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/dbutil"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
@@ -64,8 +65,14 @@ func New(lo *logf.Logger, db *sqlx.DB, webTpls *template.Template, tpls *templat
|
||||
|
||||
// Update updates a new template with the given name, and body.
|
||||
func (m *Manager) Update(id int, t models.Template) (models.Template, error) {
|
||||
if err := m.validateOutgoingTemplate(t); err != nil {
|
||||
return models.Template{}, err
|
||||
}
|
||||
var result models.Template
|
||||
if err := m.q.UpdateTemplate.Get(&result, id, t.Name, t.Body, t.IsDefault, t.Subject, t.Type); err != nil {
|
||||
if dbutil.IsUniqueViolationError(err) && t.IsDefault {
|
||||
return models.Template{}, envelope.NewError(envelope.ConflictError, m.i18n.T("template.defaultTemplateAlreadyExists"), nil /** data **/)
|
||||
}
|
||||
m.lo.Error("error updating template", "error", err)
|
||||
return models.Template{}, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
||||
}
|
||||
@@ -77,10 +84,13 @@ func (m *Manager) Create(t models.Template) (models.Template, error) {
|
||||
if t.IsDefault {
|
||||
t.Type = TypeEmailOutgoing
|
||||
}
|
||||
if err := m.validateOutgoingTemplate(t); err != nil {
|
||||
return models.Template{}, err
|
||||
}
|
||||
var result models.Template
|
||||
if err := m.q.InsertTemplate.Get(&result, t.Name, t.Body, t.IsDefault, t.Subject, t.Type); err != nil {
|
||||
if dbutil.IsUniqueViolationError(err) && t.IsDefault {
|
||||
return models.Template{}, envelope.NewError(envelope.GeneralError, m.i18n.T("template.defaultTemplateAlreadyExists"), nil)
|
||||
return models.Template{}, envelope.NewError(envelope.ConflictError, m.i18n.T("template.defaultTemplateAlreadyExists"), nil /** data **/)
|
||||
}
|
||||
m.lo.Error("error inserting template", "error", err)
|
||||
return models.Template{}, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
||||
@@ -182,3 +192,15 @@ func (m *Manager) Reload(webTpls, tpls *template.Template, funcMap template.Func
|
||||
m.funcMap = funcMap
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) validateOutgoingTemplate(t models.Template) error {
|
||||
if t.Type != TypeEmailOutgoing && !t.IsDefault {
|
||||
return nil
|
||||
}
|
||||
m.mutex.RLock()
|
||||
defer m.mutex.RUnlock()
|
||||
if _, err := parseOutgoingTemplate(t.Body, texttemplate.FuncMap(m.funcMap)); err != nil {
|
||||
return envelope.NewError(envelope.InputError, m.i18n.Ts("template.invalidOutgoingTemplate", "content", `{{ template "content" . }}`), nil /** data **/)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"os"
|
||||
@@ -9,12 +10,133 @@ import (
|
||||
"testing"
|
||||
|
||||
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/abhinavxd/libredesk/internal/template/models"
|
||||
"github.com/abhinavxd/libredesk/internal/testutil"
|
||||
umodels "github.com/abhinavxd/libredesk/internal/user/models"
|
||||
"github.com/volatiletech/null/v9"
|
||||
"github.com/zerodha/logf"
|
||||
)
|
||||
|
||||
var notificationSeedPattern = regexp.MustCompile(`(?s)VALUES\s*\(\s*'email_notification'::template_type,\s*'((?:''|[^'])*)'\s*,\s*false,\s*'([^']+)'`)
|
||||
|
||||
func TestDefaultTemplateConflict(t *testing.T) {
|
||||
db := testutil.NewDB(t, "default_template_conflict")
|
||||
lo := logf.New(logf.Opts{})
|
||||
m, err := New(&lo, db, nil /** webTpls **/, nil /** tpls **/, nil /** funcMap **/, testutil.NewI18n(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := m.Create(models.Template{Name: "First", Type: TypeEmailOutgoing, IsDefault: true, Body: `{{ template "content" . }}`})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := m.Create(models.Template{Name: "Second", Type: TypeEmailOutgoing, Body: `{{ template "content" . }}`})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second.IsDefault = true
|
||||
for _, save := range []func() (models.Template, error){
|
||||
func() (models.Template, error) { return m.Create(second) },
|
||||
func() (models.Template, error) { return m.Update(second.ID, second) },
|
||||
} {
|
||||
_, err := save()
|
||||
var conflict envelope.Error
|
||||
if !errors.As(err, &conflict) || conflict.ErrorType != envelope.ConflictError {
|
||||
t.Fatalf("expected an actionable conflict, got %v", err)
|
||||
}
|
||||
current, err := m.getDefaultOutgoingEmailTemplate()
|
||||
if err != nil || current.ID != first.ID {
|
||||
t.Fatalf("default changed after rejected save: %+v, %v", current, err)
|
||||
}
|
||||
}
|
||||
first.IsDefault = false
|
||||
if _, err := m.Update(first.ID, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := m.Update(second.ID, second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
current, err := m.getDefaultOutgoingEmailTemplate()
|
||||
if err != nil || current.ID != second.ID {
|
||||
t.Fatalf("default did not switch: %+v, %v", current, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutgoingTemplatePreservesReply(t *testing.T) {
|
||||
db := testutil.NewDB(t, "outgoing_template_content")
|
||||
lo := logf.New(logf.Opts{})
|
||||
m, err := New(&lo, db, nil /** webTpls **/, nil /** tpls **/, nil /** funcMap **/, testutil.NewI18n(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
valid := models.Template{Name: "Customer layout", Type: TypeEmailOutgoing, IsDefault: true, Body: `<header>Support</header>{{ template "content" . }}<footer>Thank you</footer>`}
|
||||
saved, err := m.Create(valid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, body := range []string{
|
||||
"",
|
||||
"<p>Support header</p>",
|
||||
`{{/* template "content" . */}}`,
|
||||
`{{ template "content" .`,
|
||||
} {
|
||||
t.Run("reject "+body, func(t *testing.T) {
|
||||
candidate := valid
|
||||
candidate.Body = body
|
||||
candidate.IsDefault = false
|
||||
if _, err := m.Create(candidate); err == nil {
|
||||
t.Fatal("created an invalid outgoing template")
|
||||
}
|
||||
if _, err := m.Update(saved.ID, candidate); err == nil {
|
||||
t.Fatal("updated to an invalid outgoing template")
|
||||
}
|
||||
})
|
||||
}
|
||||
stored, err := m.Get(saved.ID)
|
||||
if err != nil || stored.Body != valid.Body {
|
||||
t.Fatalf("rejected edit changed template: %+v, %v", stored, err)
|
||||
}
|
||||
const conditional = `{{ if .Show }}<p>Hello</p>{{ template "content" . }}{{ else }}{{ template "content" . }}{{ end }}`
|
||||
if _, err := m.Update(saved.ID, models.Template{Name: valid.Name, Type: valid.Type, IsDefault: true, Body: conditional}); err != nil {
|
||||
t.Fatalf("rejected a conditional layout that renders the reply once: %v", err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
body string
|
||||
data any
|
||||
ok bool
|
||||
}{
|
||||
{valid.Body, nil, true},
|
||||
{`{{template "content" .}}`, nil, true},
|
||||
{`{{- template "content" . -}}`, nil, true},
|
||||
{`{{ define "wrapper" }}{{ template "content" . }}{{ end }}{{ template "wrapper" . }}`, nil, true},
|
||||
{`{{ if .Show }}{{ template "content" . }}{{ end }}`, map[string]any{"Show": true}, true},
|
||||
{`{{ if .Show }}{{ template "content" . }}{{ end }}`, map[string]any{"Show": false}, false},
|
||||
{conditional, map[string]any{"Show": true}, true},
|
||||
{conditional, map[string]any{"Show": false}, true},
|
||||
{`{{ template "content" . }}{{ template "content" . }}`, nil, false},
|
||||
{`{{ range . }}{{ template "content" . }}{{ end }}`, []string{}, false},
|
||||
{`{{ range . }}{{ template "content" . }}{{ end }}`, []string{"first", "second"}, false},
|
||||
{`{{ define "wrapper" }}{{ template "content" . }}{{ end }}{{ template "wrapper" . }}{{ template "wrapper" . }}`, nil, false},
|
||||
{`<p>Previously saved layout without a reply</p>`, nil, false},
|
||||
} {
|
||||
t.Run("render "+tc.body+fmt.Sprint(tc.data), func(t *testing.T) {
|
||||
if _, err := db.Exec(`UPDATE templates SET body = $1 WHERE id = $2`, tc.body, saved.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const reply = "<p>Your receipt is ready. Café 日本語.</p>"
|
||||
got, err := m.RenderEmailWithTemplate(tc.data, reply)
|
||||
if tc.ok {
|
||||
if err != nil || strings.Count(got, reply) != 1 {
|
||||
t.Fatalf("reply was lost or repeated: %q, %v", got, err)
|
||||
}
|
||||
} else if err == nil || got != "" {
|
||||
t.Fatalf("unsafe outgoing content accepted: %q, %v", got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderNotificationEmailUsesSystemLayout(t *testing.T) {
|
||||
m := emailTemplateManager(t)
|
||||
var header strings.Builder
|
||||
|
||||
+13
-1
@@ -226,8 +226,12 @@
|
||||
createElements () {
|
||||
const branding = this.branding();
|
||||
|
||||
this.toggleButton = document.createElement('div');
|
||||
this.toggleButton = document.createElement('button');
|
||||
this.toggleButton.type = 'button';
|
||||
this.toggleButton.style.cssText = `
|
||||
all: revert;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
position: fixed;
|
||||
cursor: pointer;
|
||||
z-index: 9999;
|
||||
@@ -243,6 +247,7 @@
|
||||
`;
|
||||
|
||||
this.iconContainer = document.createElement('div');
|
||||
this.iconContainer.setAttribute('aria-hidden', 'true');
|
||||
this.iconContainer.style.cssText = `
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -255,6 +260,7 @@
|
||||
const iconTransition = `opacity ${this.OPEN_MS}ms ${this.EASE_OUT}, transform ${this.OPEN_MS}ms ${this.EASE_OUT}`;
|
||||
|
||||
this.defaultIcon = document.createElement('img');
|
||||
this.defaultIcon.alt = '';
|
||||
this.defaultIcon.src = branding.launcher?.logo_url || (this.config.baseURL + DEFAULT_LAUNCHER_LOGO_PATH);
|
||||
this.defaultIcon.style.transition = iconTransition;
|
||||
this.styleLauncherIcon();
|
||||
@@ -320,6 +326,8 @@
|
||||
this.widgetButtonWrapper = widgetButtonWrapper;
|
||||
|
||||
this.iframe = document.createElement('iframe');
|
||||
this.iframe.id = `libredesk-widget-${this.config.inboxID}`;
|
||||
this.toggleButton.setAttribute('aria-controls', this.iframe.id);
|
||||
this.iframe.src = `${this.config.baseURL}/widget?inbox_id=${encodeURIComponent(this.config.inboxID)}&parent_origin=${encodeURIComponent(window.location.origin)}` +
|
||||
(this.language ? `&lang=${encodeURIComponent(this.language)}` : '');
|
||||
this.iframeLanguage = this.language;
|
||||
@@ -498,6 +506,7 @@
|
||||
case 'REPLY_PREVIEWS':
|
||||
if (event.data.previews?.length && this.campaignData) this.dropCampaign(this.campaignData.id);
|
||||
this.previewData = event.data;
|
||||
this.setLauncherIcon(this.isChatVisible);
|
||||
this.renderPreviews();
|
||||
break;
|
||||
case 'VUE_APP_READY':
|
||||
@@ -668,6 +677,7 @@
|
||||
if (this.unreadCount > 0) this.showUnreadBadge(this.unreadCount);
|
||||
|
||||
this.postToIframe({ type: 'WIDGET_CLOSED' });
|
||||
if (document.activeElement === iframe) this.toggleButton.focus();
|
||||
|
||||
if (this._onHideCallback) this._onHideCallback();
|
||||
}
|
||||
@@ -680,6 +690,8 @@
|
||||
}
|
||||
|
||||
setLauncherIcon (open) {
|
||||
this.toggleButton.setAttribute('aria-expanded', String(open));
|
||||
this.toggleButton.setAttribute('aria-label', this.previewData?.labels?.[open ? 'closeChat' : 'openChat'] || 'libredesk');
|
||||
const hidden = (deg) => this.reducedMotion ? 'none' : `rotate(${deg}deg) scale(0.9)`;
|
||||
if (this.defaultIcon) {
|
||||
this.defaultIcon.style.opacity = open ? '0' : '1';
|
||||
|
||||
Reference in New Issue
Block a user