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:
Abhinav Raut
2026-10-20 00:58:33 +05:30
parent 754991ad07
commit 7fee393cce
26 changed files with 798 additions and 99 deletions
+2 -3
View File
@@ -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
View File
@@ -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.
+17
View File
@@ -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
View File
@@ -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",
+19
View File
@@ -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{})
+7
View File
@@ -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,
+62
View File
@@ -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{})
+6 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
+118
View File
@@ -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)
}
})
}
}
+53 -1
View File
@@ -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
}
+23 -1
View File
@@ -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
}
+122
View File
@@ -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
View File
@@ -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';