Merge remote-tracking branch 'origin/main' into search-improvements

This commit is contained in:
Abhinav Raut
2026-09-17 02:25:17 +05:30
74 changed files with 4577 additions and 458 deletions
+1 -1
View File
@@ -283,7 +283,7 @@ func handleChatInit(r *fastglue.Request) error {
}
// Process post-message hooks for the new conversation and initial message.
if err := app.conversation.ProcessIncomingMessageHooks(conversationUUID, true); err != nil {
if err := app.conversation.ProcessIncomingMessageHooks(message, true); err != nil {
app.lo.Error("error processing incoming message hooks for initial message", "conversation_uuid", conversationUUID, "error", err)
}
+27
View File
@@ -353,6 +353,10 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.PUT("/api/v1/notifications/read-all", auth(handleMarkAllNotificationsAsRead))
g.DELETE("/api/v1/notifications/{id}", auth(handleDeleteNotification))
g.DELETE("/api/v1/notifications", auth(handleDeleteAllNotifications))
g.GET("/api/v1/notifications/preferences", auth(handleGetNotificationPreferences))
g.PUT("/api/v1/notifications/preferences", auth(handleUpdateNotificationPreferences))
g.POST("/api/v1/notifications/push-subscriptions", auth(handleCreatePushSubscription))
g.DELETE("/api/v1/notifications/push-subscriptions", auth(handleDeletePushSubscription))
// WebSocket.
g.GET("/ws", auth(func(r *fastglue.Request) error {
@@ -399,6 +403,8 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/assets/{all:*}", serveFrontendStaticFiles)
g.GET("/widget/assets/{all:*}", serveWidgetStaticFiles)
g.GET("/images/{all:*}", serveFrontendStaticFiles)
g.GET("/manifest.webmanifest", serveManifest)
g.GET("/sw.js", serveServiceWorker)
g.GET("/static/public/{all:*}", serveStaticFiles)
// Public pages.
@@ -518,6 +524,27 @@ func serveFrontendStaticFiles(r *fastglue.Request) error {
return nil
}
func serveManifest(r *fastglue.Request) error {
return serveMainFrontendFile(r, "manifest.webmanifest", "application/manifest+json", "no-cache")
}
func serveServiceWorker(r *fastglue.Request) error {
r.RequestCtx.Response.Header.Set("Service-Worker-Allowed", "/")
return serveMainFrontendFile(r, "sw.js", "application/javascript", "no-cache")
}
func serveMainFrontendFile(r *fastglue.Request, name, contentType, cacheControl string) error {
app := r.Context.(*App)
file, err := app.fs.Get(filepath.Join(frontendDir, name))
if err != nil {
return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.T("validation.notFoundFile"), nil, envelope.NotFoundError)
}
r.RequestCtx.Response.Header.Set("Content-Type", contentType)
r.RequestCtx.Response.Header.Set("Cache-Control", cacheControl)
r.RequestCtx.SetBody(file.ReadBytes())
return nil
}
// serveWidgetStaticFiles serves widget static assets from the embedded filesystem.
func serveWidgetStaticFiles(r *fastglue.Request) error {
app := r.Context.(*App)
+52 -6
View File
@@ -39,6 +39,7 @@ import (
fs "github.com/abhinavxd/libredesk/internal/media/stores/localfs"
"github.com/abhinavxd/libredesk/internal/media/stores/s3"
notifier "github.com/abhinavxd/libredesk/internal/notification"
notificationchannels "github.com/abhinavxd/libredesk/internal/notification/channels"
emailnotifier "github.com/abhinavxd/libredesk/internal/notification/providers/email"
"github.com/abhinavxd/libredesk/internal/oidc"
"github.com/abhinavxd/libredesk/internal/ratelimit"
@@ -1166,6 +1167,19 @@ func initUserNotification(db *sqlx.DB, i18n *i18n.I18n) *notifier.UserNotificati
return m
}
// initNotificationPreference inits the notification preference manager.
func initNotificationPreference(db *sqlx.DB, i18n *i18n.I18n) *notifier.PreferenceManager {
m, err := notifier.NewPreferenceManager(notifier.PreferenceManagerOpts{
DB: db,
Lo: initLogger("notification-preference"),
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing notification preference manager: %v", err)
}
return m
}
// initImporter inits the importer manager.
func initImporter(i18n *i18n.I18n) *importer.Importer {
return importer.New(importer.Opts{
@@ -1174,14 +1188,46 @@ func initImporter(i18n *i18n.I18n) *importer.Importer {
})
}
func initNotificationEmailQueue(db *sqlx.DB, outbound *notifier.Service) *notifier.EmailQueue {
q, err := notifier.NewEmailQueue(notifier.EmailQueueOpts{
DB: db,
Outbound: outbound,
Lo: initLogger("notification-email-queue"),
})
if err != nil {
log.Fatalf("error initializing notification email queue: %v", err)
}
return q
}
func initPushNotification(db *sqlx.DB, settings *setting.Manager, i18n *i18n.I18n) *notifier.PushManager {
m, err := notifier.NewPushManager(notifier.PushManagerOpts{
DB: db,
Settings: settings,
Lo: initLogger("push-notification"),
I18n: i18n,
RootURL: ko.String("app.root_url"),
Concurrency: ko.MustInt("notification.concurrency"),
QueueSize: ko.MustInt("notification.queue_size"),
})
if err != nil {
log.Fatalf("error initializing push notification manager: %v", err)
}
return m
}
// initNotifDispatcher initializes the notification dispatcher.
func initNotifDispatcher(userNotification *notifier.UserNotificationManager, outbound *notifier.Service, wsHub *ws.Hub, emailEnabled bool) *notifier.Dispatcher {
func initNotifDispatcher(userNotification *notifier.UserNotificationManager, prefs *notifier.PreferenceManager, push *notifier.PushManager, emailQueue *notifier.EmailQueue, wsHub *ws.Hub, emailEnabled bool) *notifier.Dispatcher {
providers := []notificationchannels.Provider{
notificationchannels.NewInApp(userNotification, wsHub, initLogger("notification-in-app")),
}
if emailEnabled {
providers = append(providers, notificationchannels.NewEmail(emailQueue))
}
providers = append(providers, notificationchannels.NewPush(push))
return notifier.NewDispatcher(notifier.DispatcherOpts{
InApp: userNotification,
Outbound: outbound,
WSHub: wsHub,
EmailEnabled: emailEnabled,
Lo: initLogger("notification-dispatcher"),
Pipeline: notificationchannels.NewPipeline(providers...),
Prefs: prefs,
})
}
+12 -1
View File
@@ -126,6 +126,8 @@ type App struct {
activityLog *activitylog.Manager
notifier *notifier.Service
userNotification *notifier.UserNotificationManager
notificationPref *notifier.PreferenceManager
pushNotification *notifier.PushManager
customAttribute *customAttribute.Manager
report *report.Manager
webhook *webhook.Manager
@@ -249,7 +251,10 @@ func main() {
wsHub = initWS(user)
notifier = initNotifier()
userNotification = initUserNotification(db, i18n)
notifDispatcher = initNotifDispatcher(userNotification, notifier, wsHub, ko.Bool("notification.email.enabled"))
notificationPreference = initNotificationPreference(db, i18n)
pushNotification = initPushNotification(db, settings, i18n)
notificationEmailQueue = initNotificationEmailQueue(db, notifier)
notifDispatcher = initNotifDispatcher(userNotification, notificationPreference, pushNotification, notificationEmailQueue, wsHub, ko.Bool("notification.email.enabled"))
automation = initAutomationEngine(db, i18n)
ai = initAI(ctx, db, i18n, ssrfControl)
sla = initSLA(db, team, settings, businessHours, template, user, i18n, notifDispatcher)
@@ -285,6 +290,10 @@ func main() {
go conversation.RunDraftCleaner(ctx, draftRetentionDuration)
go userNotification.RunNotificationCleaner(ctx)
go helpCenter.RunSearchLogCleaner(ctx)
if ko.Bool("notification.email.enabled") {
go notificationEmailQueue.Run(ctx)
}
go pushNotification.Run(ctx)
go aiAgent.Run(ctx, cmp.Or(ko.Int("ai_agent.worker_count"), 10))
go ai.Run(ctx)
@@ -329,6 +338,8 @@ func main() {
redis: rdb,
fc: initFastCache(rdb),
userNotification: userNotification,
notificationPref: notificationPreference,
pushNotification: pushNotification,
wsHub: wsHub,
}
app.consts.Store(constants)
+51
View File
@@ -0,0 +1,51 @@
package main
import (
"strings"
"testing"
"github.com/knadh/stuffbin"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
func TestServePWAFiles(t *testing.T) {
fs, err := stuffbin.NewLocalFS("/",
"../frontend/public/manifest.webmanifest:frontend/dist/main/manifest.webmanifest",
"../frontend/public/sw.js:frontend/dist/main/sw.js",
)
if err != nil {
t.Fatalf("creating test filesystem: %v", err)
}
tests := []struct {
name string
handler fastglue.FastRequestHandler
contentType string
wantServiceWorkerHdr bool
}{
{"manifest", serveManifest, "application/manifest+json", false},
{"service worker", serveServiceWorker, "application/javascript", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctx := &fasthttp.RequestCtx{}
req := &fastglue.Request{RequestCtx: ctx, Context: &App{fs: fs}}
if err := tc.handler(req); err != nil {
t.Fatalf("serving file: %v", err)
}
if got := string(ctx.Response.Header.ContentType()); got != tc.contentType {
t.Errorf("content type = %q, want %q", got, tc.contentType)
}
if got := string(ctx.Response.Header.Peek("Cache-Control")); got != "no-cache" {
t.Errorf("cache control = %q, want no-cache", got)
}
if tc.wantServiceWorkerHdr && string(ctx.Response.Header.Peek("Service-Worker-Allowed")) != "/" {
t.Error("service worker scope header is missing")
}
if strings.TrimSpace(string(ctx.Response.Body())) == "" {
t.Error("response body is empty")
}
})
}
}
+4
View File
@@ -6,6 +6,7 @@ import (
"strings"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/httputil"
"github.com/abhinavxd/libredesk/internal/setting/models"
"github.com/abhinavxd/libredesk/internal/stringutil"
"github.com/valyala/fasthttp"
@@ -57,6 +58,9 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error {
}
// Trim whitespace and trailing slash from root URL.
req.RootURL = strings.TrimRight(strings.TrimSpace(req.RootURL), "/")
if !httputil.IsValidHTTPURL(req.RootURL) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("admin.general.rootURL.valid"), nil, envelope.InputError)
}
// Get current language before update.
app.Lock()
+71
View File
@@ -5,6 +5,8 @@ import (
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/envelope"
notifier "github.com/abhinavxd/libredesk/internal/notification"
nmodels "github.com/abhinavxd/libredesk/internal/notification/models"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
@@ -97,3 +99,72 @@ func handleDeleteAllNotifications(r *fastglue.Request) error {
}
return r.SendEnvelope(true)
}
func handleGetNotificationPreferences(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
prefs, err := app.notificationPref.GetMatrix(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
pushEndpoints, err := app.pushNotification.Endpoints(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(map[string]any{
"preferences": prefs,
"email_enabled": ko.Bool("notification.email.enabled"),
"vapid_public_key": app.pushNotification.PublicKey(),
"push_endpoints": pushEndpoints,
})
}
func handleUpdateNotificationPreferences(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
prefs []nmodels.NotificationPreference
)
if err := r.Decode(&prefs, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
}
if err := app.notificationPref.Update(auser.ID, prefs); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
func handleCreatePushSubscription(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
subscription notifier.PushSubscriptionInput
)
if err := r.Decode(&subscription, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
}
if err := app.pushNotification.Upsert(auser.ID, subscription); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
func handleDeletePushSubscription(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
request struct {
Endpoint string `json:"endpoint"`
}
)
if err := r.Decode(&request, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
}
if err := app.pushNotification.Delete(auser.ID, request.Endpoint); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
+4 -1
View File
@@ -5,6 +5,9 @@
<meta charset="UTF-8" />
<meta name="robots" content="noindex, nofollow" />
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
<meta name="theme-color" content="hsl(152 39% 30%)" />
<link rel="manifest" href="/manifest.webmanifest" crossorigin="use-credentials" />
<link rel="apple-touch-icon" href="/images/pwa-icon-192.png" />
<link rel="preload" href="/static/public/static/fonts/Geist-Variable.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/static/public/static/fonts.css">
</head>
@@ -14,4 +17,4 @@
<script type="module" src="/src/main.js"></script>
</body>
</html>
</html>
+12 -1
View File
@@ -92,6 +92,7 @@ import { useTagStore } from './stores/tag'
import { useCustomAttributeStore } from './stores/customAttributes'
import { useIdleDetection } from './composables/useIdleDetection'
import { useNotificationStore } from './stores/notification'
import { useAiPromptStore } from '@main/stores/aiPrompt'
import { useViewStore } from './stores/view'
import { useKeyboardShortcutsDialog } from './composables/useKeyboardShortcutsDialog'
import KeyboardShortcutsDialog from './components/KeyboardShortcutsDialog.vue'
@@ -122,6 +123,7 @@ import NotificationBell from '@main/components/sidebar/NotificationBell.vue'
import PrimaryNavItems from '@main/components/sidebar/PrimaryNavItems.vue'
import { useIsMobile } from '@shared-ui/composables'
import api from '@main/api'
import { usePushNotifications } from '@/composables/usePushNotifications'
const route = useRoute()
const emitter = useEmitter()
@@ -165,6 +167,8 @@ const openCreateConversationDialog = ref(false)
const createConversationContact = ref(null)
const { t } = useI18n()
const notificationStore = useNotificationStore()
const aiPromptStore = useAiPromptStore()
const pushNotifications = usePushNotifications()
// Update browser tab title with unread notification count.
// Watch both unreadCount and route so the prefix is preserved after navigation.
@@ -214,10 +218,17 @@ const initStores = async () => {
inboxStore.fetchInboxes(),
slaStore.fetchSlas(),
tagStore.fetchTags(),
customAttributeStore.fetchCustomAttributes()
customAttributeStore.fetchCustomAttributes(),
aiPromptStore.fetchPrompts(),
refreshPushSubscription()
])
}
const refreshPushSubscription = async () => {
const { data } = await api.getNotificationPreferences()
await pushNotifications.refresh(data.data.vapid_public_key, data.data.push_endpoints)
}
const createView = () => {
view.value = {}
openCreateViewForm.value = true
+8
View File
@@ -623,6 +623,10 @@ const markNotificationAsRead = (id) => http.put(`/api/v1/notifications/${id}/rea
const markAllNotificationsAsRead = () => http.put('/api/v1/notifications/read-all')
const deleteNotification = (id) => http.delete(`/api/v1/notifications/${id}`)
const deleteAllNotifications = () => http.delete('/api/v1/notifications')
const getNotificationPreferences = () => http.get('/api/v1/notifications/preferences')
const updateNotificationPreferences = (data) => http.put('/api/v1/notifications/preferences', data)
const createPushSubscription = (data) => http.post('/api/v1/notifications/push-subscriptions', data)
const deletePushSubscription = (endpoint) => http.delete('/api/v1/notifications/push-subscriptions', { data: { endpoint } })
export default {
login,
@@ -861,5 +865,9 @@ export default {
markAllNotificationsAsRead,
deleteNotification,
deleteAllNotifications,
getNotificationPreferences,
updateNotificationPreferences,
createPushSubscription,
deletePushSubscription,
getContactPageVisits
}
@@ -12,9 +12,9 @@
>
<EditorToolbar
:editor="editor"
:ai-prompts="aiPrompts"
show-ai
@open-link="linkDialog?.open()"
@ai-prompt="emitPrompt"
@ai-generation-change="emit('aiGenerationChange', $event)"
/>
</BubbleMenu>
<EditorContent :editor="editor" class="native-html" />
@@ -41,7 +41,6 @@ const props = defineProps({
insertContent: String,
messageType: String,
autoFocus: { type: Boolean, default: true },
aiPrompts: { type: Array, default: () => [] },
disabled: { type: Boolean, default: false },
enableMentions: { type: Boolean, default: false },
getSuggestions: { type: Function, default: null },
@@ -53,7 +52,7 @@ const props = defineProps({
const emit = defineEmits([
'send',
'aiPromptSelected',
'aiGenerationChange',
'mentionsChanged',
'filesDropped',
'toggleMessageType'
@@ -61,8 +60,6 @@ const emit = defineEmits([
const linkDialog = ref(null)
const emitPrompt = (key) => emit('aiPromptSelected', key)
// Suppress the formatting bubble when an image node is selected so it
// doesn't fight with the image's own size/remove toolbar.
const shouldShowBubble = ({ editor: e, state }) => {
@@ -1,8 +1,14 @@
<template>
<div class="flex flex-wrap gap-1 items-center">
<DropdownMenu v-if="aiPrompts.length > 0">
<DropdownMenu v-if="showAi && aiPromptStore.prompts.length > 0">
<DropdownMenuTrigger as-child>
<Button type="button" size="sm" variant="ghost" class="flex items-center justify-center">
<Button
type="button"
size="sm"
variant="ghost"
class="flex items-center justify-center"
:disabled="isGenerating"
>
<span class="flex items-center">
<span class="text-medium">AI</span>
<Bot size="14" class="ml-1" />
@@ -12,9 +18,9 @@
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
v-for="prompt in aiPrompts"
v-for="prompt in aiPromptStore.prompts"
:key="prompt.key"
@select="emit('aiPrompt', prompt.key)"
@select="complete(prompt.key)"
>
{{ prompt.title }}
</DropdownMenuItem>
@@ -331,6 +337,7 @@
</template>
<script setup>
import { ref } from 'vue'
import {
ChevronDown,
Bold,
@@ -363,16 +370,20 @@ import {
DropdownMenuTrigger
} from '@shared-ui/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@shared-ui/components/ui/tooltip'
import { useAiPromptStore } from '@main/stores/aiPrompt'
import { codeBlockLanguages } from './codeLanguages'
const props = defineProps({
editor: { type: Object, default: null },
showArticleTools: { type: Boolean, default: false },
aiPrompts: { type: Array, default: () => [] },
showAi: { type: Boolean, default: false },
enableInlineImages: { type: Boolean, default: false }
})
const emit = defineEmits(['openLink', 'openYoutube', 'openImage', 'aiPrompt'])
const emit = defineEmits(['openLink', 'openYoutube', 'openImage', 'aiGenerationChange'])
const aiPromptStore = useAiPromptStore()
const isGenerating = ref(false)
const alignments = [
{ dir: 'left', icon: AlignLeft, label: 'editor.tooltip.alignLeft' },
@@ -398,4 +409,18 @@ const getCurrentHeadingText = () => {
}
return 'P'
}
const complete = async (promptKey) => {
if (!props.editor || isGenerating.value) return
isGenerating.value = true
emit('aiGenerationChange', true)
try {
const content = await aiPromptStore.complete(promptKey, props.editor.getHTML())
if (content !== null) props.editor.commands.setContent(content)
} finally {
isGenerating.value = false
emit('aiGenerationChange', false)
}
}
</script>
@@ -35,7 +35,7 @@ export function createSuggestionRenderer(ListComponent) {
if (popup) popup.style.display = 'none'
return true
}
return component.ref?.onKeyDown(props)
return component?.ref?.onKeyDown(props) ?? false
},
onExit: () => {
@@ -0,0 +1,12 @@
import { describe, expect, it, vi } from 'vitest'
import { createSuggestionRenderer } from './suggestionRenderer'
vi.mock('@tiptap/vue-3', () => ({ VueRenderer: vi.fn() }))
describe('createSuggestionRenderer', () => {
it('ignores key events before the suggestion list starts', () => {
const renderer = createSuggestionRenderer({})
expect(renderer.onKeyDown({ event: { key: 'Enter' } })).toBe(false)
})
})
@@ -62,9 +62,9 @@
<div class="flex gap-2.5">
<!-- Icon based on notification type -->
<component
:is="getNotificationIcon(notification.notification_type)"
:is="getNotificationStyle(notification.notification_type).icon"
class="flex-shrink-0 h-4 w-4 mt-0.5"
:class="getNotificationIconClass(notification.notification_type)"
:class="getNotificationStyle(notification.notification_type).class"
/>
<!-- Content -->
@@ -119,6 +119,17 @@
</Button>
</div>
</div>
<div class="flex justify-end border-t border-border p-1">
<router-link
:to="{ name: 'account-notifications' }"
:class="buttonVariants({ variant: 'ghost', size: 'sm' })"
@click="emit('close')"
>
<Settings aria-hidden="true" />
<span>{{ t('notification.settings') }}</span>
</router-link>
</div>
</div>
</template>
@@ -132,12 +143,15 @@ import {
CheckCheck,
X,
Trash2,
Settings,
AtSign,
UserPlus,
MessageSquare,
RotateCcw,
AlertTriangle,
AlertCircle
} from 'lucide-vue-next'
import { Button } from '@shared-ui/components/ui/button'
import { Button, buttonVariants } from '@shared-ui/components/ui/button'
import { Skeleton } from '@shared-ui/components/ui/skeleton'
import { useNotificationStore } from '@main/stores/notification'
import { getRelativeTime } from '@shared-ui/utils/datetime.js'
@@ -149,24 +163,22 @@ const { t } = useI18n()
const notificationStore = useNotificationStore()
const getNotificationIcon = (type) => {
const icons = {
mention: AtSign,
assignment: UserPlus,
sla_warning: AlertTriangle,
sla_breach: AlertCircle
}
return icons[type] || Bell
const notificationStyles = {
mention: { icon: AtSign, class: 'text-primary' },
assignment: { icon: UserPlus, class: 'text-accent-foreground' },
new_reply: { icon: MessageSquare, class: 'text-primary' },
new_reply_participating: { icon: MessageSquare, class: 'text-primary' },
conversation_reopened: { icon: RotateCcw, class: 'text-accent-foreground' }
}
const getNotificationIconClass = (type) => {
const classes = {
mention: 'text-primary',
assignment: 'text-accent-foreground',
sla_warning: 'text-destructive',
sla_breach: 'text-destructive'
const getNotificationStyle = (type) => {
if (type?.startsWith('sla_')) {
return {
icon: type.endsWith('_breach') ? AlertCircle : AlertTriangle,
class: 'text-destructive'
}
}
return classes[type] || 'text-muted-foreground'
return notificationStyles[type] || { icon: Bell, class: 'text-muted-foreground' }
}
const handleNotificationClick = async (notification) => {
@@ -130,6 +130,7 @@ import { useUserStore } from '@main/stores/user'
import { useIsMobile } from '@shared-ui/composables'
import { useRouter } from 'vue-router'
import { useKeyboardShortcutsDialog } from '@main/composables/useKeyboardShortcutsDialog'
import { useLogout } from '@main/composables/useLogout'
import { useColorMode } from '@vueuse/core'
@@ -139,8 +140,5 @@ const userStore = useUserStore()
const router = useRouter()
const { t } = useI18n()
const shortcutsDialog = useKeyboardShortcutsDialog()
const logout = () => {
window.location.href = '/logout'
}
const logout = useLogout()
</script>
@@ -0,0 +1,10 @@
import { usePushNotifications } from '@main/composables/usePushNotifications'
const defaultBrowser = typeof window === 'undefined' ? {} : window
export const createLogout = (pushNotifications, browser = defaultBrowser) => async () => {
await Promise.resolve(pushNotifications.disable()).catch(() => {})
browser.location.href = '/logout'
}
export const useLogout = () => createLogout(usePushNotifications())
@@ -0,0 +1,23 @@
import { describe, expect, it, vi } from 'vitest'
import { createLogout } from './useLogout'
describe('createLogout', () => {
it('clears the push association before logging out', async () => {
const pushNotifications = { disable: vi.fn().mockResolvedValue() }
const browser = { location: { href: '' } }
await createLogout(pushNotifications, browser)()
expect(pushNotifications.disable).toHaveBeenCalled()
expect(browser.location.href).toBe('/logout')
})
it('logs out when clearing the push association fails', async () => {
const pushNotifications = { disable: vi.fn().mockRejectedValue(new Error('offline')) }
const browser = { location: { href: '' } }
await createLogout(pushNotifications, browser)()
expect(browser.location.href).toBe('/logout')
})
})
@@ -0,0 +1,82 @@
import { ref } from 'vue'
import api from '@main/api'
const defaultBrowser = typeof window === 'undefined' ? {} : window
export const urlBase64ToUint8Array = (value) => {
const padding = '='.repeat((4 - value.length % 4) % 4)
const raw = atob((value + padding).replace(/-/g, '+').replace(/_/g, '/'))
return Uint8Array.from(raw, character => character.charCodeAt(0))
}
export const createPushNotifications = (apiClient = api, browser = defaultBrowser) => {
const supported = ref(Boolean(browser.Notification && browser.navigator?.serviceWorker && browser.PushManager))
const permission = ref(browser.Notification?.permission || 'unsupported')
const enabled = ref(false)
let registration = null
const registerWorker = async () => {
registration ??= await browser.navigator.serviceWorker.register('/sw.js')
return registration
}
const existingWorker = async () => {
registration ??= await browser.navigator.serviceWorker.getRegistration('/sw.js')
return registration
}
const save = async (subscription) => {
const value = subscription.toJSON()
await apiClient.createPushSubscription({
endpoint: value.endpoint,
p256dh: value.keys.p256dh,
auth: value.keys.auth
})
}
const refresh = async (vapidPublicKey, savedEndpoints = []) => {
if (!supported.value || !vapidPublicKey) return
const worker = await registerWorker()
const subscription = await worker.pushManager.getSubscription()
enabled.value = Boolean(subscription) && savedEndpoints.includes(subscription.endpoint)
if (enabled.value) await save(subscription)
}
const enable = async (vapidPublicKey) => {
if (!supported.value || !vapidPublicKey) return false
permission.value = await browser.Notification.requestPermission()
if (permission.value !== 'granted') return false
await registerWorker()
const worker = await browser.navigator.serviceWorker.ready
const existing = await worker.pushManager.getSubscription()
const subscription = existing || await worker.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
})
await save(subscription)
enabled.value = true
return true
}
const disable = async () => {
if (!supported.value) return
const worker = await existingWorker()
const subscription = await worker?.pushManager.getSubscription()
if (!subscription) {
enabled.value = false
return
}
try {
await apiClient.deletePushSubscription(subscription.endpoint)
} finally {
await subscription.unsubscribe()
enabled.value = false
}
}
return { supported, permission, enabled, refresh, enable, disable }
}
const pushNotifications = createPushNotifications()
export const usePushNotifications = () => pushNotifications
@@ -0,0 +1,151 @@
import { describe, expect, it, vi } from 'vitest'
import { createPushNotifications } from './usePushNotifications'
const makeBrowser = ({ permission = 'default', subscription = null } = {}) => {
const pushManager = {
getSubscription: vi.fn().mockResolvedValue(subscription),
subscribe: vi.fn()
}
const registration = { pushManager }
const browser = {
Notification: {
permission,
requestPermission: vi.fn().mockResolvedValue(permission)
},
navigator: {
serviceWorker: {
register: vi.fn().mockResolvedValue(registration),
getRegistration: vi.fn().mockResolvedValue(registration),
ready: Promise.resolve(registration)
}
},
PushManager: function PushManager () {}
}
return { browser, registration, pushManager }
}
const subscription = {
endpoint: 'https://push.example/subscription',
toJSON: () => ({
endpoint: 'https://push.example/subscription',
keys: { p256dh: 'public-key', auth: 'auth-secret' }
}),
unsubscribe: vi.fn().mockResolvedValue(true)
}
describe('createPushNotifications', () => {
it('reports unsupported browsers without registering a worker', async () => {
const api = { createPushSubscription: vi.fn() }
const state = createPushNotifications(api, {})
await state.refresh('vapid-key')
expect(state.supported.value).toBe(false)
expect(api.createPushSubscription).not.toHaveBeenCalled()
})
it('refreshes an existing subscription without requesting permission', async () => {
const { browser } = makeBrowser({ permission: 'granted', subscription })
const api = { createPushSubscription: vi.fn().mockResolvedValue({}) }
const state = createPushNotifications(api, browser)
await state.refresh('vapid-key', [subscription.endpoint])
expect(browser.Notification.requestPermission).not.toHaveBeenCalled()
expect(api.createPushSubscription).toHaveBeenCalledWith({
endpoint: subscription.endpoint,
p256dh: 'public-key',
auth: 'auth-secret'
})
expect(state.enabled.value).toBe(true)
})
it('does not claim a browser subscription the server does not hold for this user', async () => {
const { browser } = makeBrowser({ permission: 'granted', subscription })
const api = { createPushSubscription: vi.fn().mockResolvedValue({}) }
const state = createPushNotifications(api, browser)
await state.refresh('vapid-key', ['https://push.example/someone-else'])
expect(api.createPushSubscription).not.toHaveBeenCalled()
expect(state.enabled.value).toBe(false)
})
it('subscribes only after permission is granted', async () => {
const { browser, pushManager } = makeBrowser({ permission: 'default' })
browser.Notification.requestPermission.mockResolvedValue('granted')
pushManager.subscribe.mockResolvedValue(subscription)
const api = { createPushSubscription: vi.fn().mockResolvedValue({}) }
const state = createPushNotifications(api, browser)
await state.enable('AQID')
expect(pushManager.subscribe).toHaveBeenCalledWith({
userVisibleOnly: true,
applicationServerKey: new Uint8Array([1, 2, 3])
})
expect(state.enabled.value).toBe(true)
})
it('does not subscribe when permission is denied', async () => {
const { browser, pushManager } = makeBrowser({ permission: 'default' })
browser.Notification.requestPermission.mockResolvedValue('denied')
const state = createPushNotifications({}, browser)
await state.enable('AQID')
expect(pushManager.subscribe).not.toHaveBeenCalled()
expect(state.permission.value).toBe('denied')
})
it('waits for the first worker to become active before subscribing', async () => {
const { browser, registration, pushManager } = makeBrowser({ permission: 'granted' })
let resolveReady
browser.navigator.serviceWorker.ready = new Promise(resolve => {
resolveReady = resolve
})
pushManager.subscribe.mockResolvedValue(subscription)
const api = { createPushSubscription: vi.fn().mockResolvedValue({}) }
const state = createPushNotifications(api, browser)
const enabling = state.enable('AQID')
await vi.waitFor(() => expect(browser.navigator.serviceWorker.register).toHaveBeenCalledOnce())
expect(pushManager.subscribe).not.toHaveBeenCalled()
expect(api.createPushSubscription).not.toHaveBeenCalled()
expect(state.enabled.value).toBe(false)
resolveReady(registration)
await expect(enabling).resolves.toBe(true)
expect(pushManager.subscribe).toHaveBeenCalledOnce()
expect(api.createPushSubscription).toHaveBeenCalledOnce()
expect(state.enabled.value).toBe(true)
})
it('unsubscribes the current browser and deletes its endpoint', async () => {
const { browser } = makeBrowser({ permission: 'granted', subscription })
const api = {
createPushSubscription: vi.fn().mockResolvedValue({}),
deletePushSubscription: vi.fn().mockResolvedValue({})
}
const state = createPushNotifications(api, browser)
await state.refresh('vapid-key', [subscription.endpoint])
await state.disable()
expect(subscription.unsubscribe).toHaveBeenCalled()
expect(api.deletePushSubscription).toHaveBeenCalledWith(subscription.endpoint)
expect(state.enabled.value).toBe(false)
})
it('unsubscribes the browser when clearing the server association fails', async () => {
subscription.unsubscribe.mockClear()
const { browser } = makeBrowser({ permission: 'granted', subscription })
const api = { deletePushSubscription: vi.fn().mockRejectedValue(new Error('offline')) }
const state = createPushNotifications(api, browser)
await expect(state.disable()).rejects.toThrow('offline')
expect(subscription.unsubscribe).toHaveBeenCalledOnce()
})
})
@@ -1,5 +1,6 @@
import {
BarChart3,
Bell,
Bot,
BookOpen,
CircleDot,
@@ -33,6 +34,7 @@ import {
// Icon names referenced by string in navigation.js.
export const navIconMap = {
BarChart3,
Bell,
Bot,
BookOpen,
CircleDot,
@@ -265,6 +265,12 @@ export const accountNavItems = [
titleKey: 'globals.terms.profile',
href: '/account/profile',
icon: 'CircleUser'
},
{
titleKey: 'globals.terms.notification',
href: '/account/notifications',
isTitleKeyPlural: true,
icon: 'Bell'
}
]
@@ -54,7 +54,18 @@
</FormItem>
</FormField>
<Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
<div class="flex gap-2">
<Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
<Button
v-if="initialValues?.is_builtin && defaultBody"
type="button"
variant="outline"
:disabled="isLoading"
@click="resetToDefault"
>
{{ $t('globals.messages.resetToDefault') }}
</Button>
</div>
</form>
</template>
@@ -78,6 +89,7 @@ import CodeEditor from '@main/components/editor/CodeEditor.vue'
import { Checkbox } from '@shared-ui/components/ui/checkbox/index.js'
import { Label } from '@shared-ui/components/ui/label/index.js'
import { useI18n } from 'vue-i18n'
import { BUILT_IN_TEMPLATE_BODIES } from './defaults.js'
const props = defineProps({
initialValues: {
@@ -121,6 +133,14 @@ const hideSubject = computed(() => {
return isOutgoingTemplate.value || props.initialValues?.name === 'CSAT request'
})
const defaultBody = computed(() => {
return BUILT_IN_TEMPLATE_BODIES[props.initialValues?.name]
})
const resetToDefault = () => {
form.setFieldValue('body', defaultBody.value, false)
}
// Watch for changes in initialValues and update the form.
watch(
() => props.initialValues,
@@ -0,0 +1,164 @@
export const BUILT_IN_TEMPLATE_BODIES = {
'Conversation assigned': `<p>A new conversation has been assigned to you:</p>
<div>
Reference number: {{ .Conversation.ReferenceNumber }} <br>
Subject: {{ .Conversation.Subject }}
</div>
<p>
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
</p>
<div>
Best regards,<br>
Libredesk
</div>`,
'New reply from contact': `<p>{{ .Author.FullName }} replied to a conversation assigned to you:</p>
<div>
Reference number: {{ .Conversation.ReferenceNumber }} <br>
Subject: {{ .Conversation.Subject }}
</div>
<blockquote style="background-color: #f5f5f5; padding: 12px; margin: 16px 0; border-left: 4px solid #ddd;">
{{ .Message.Content }}
</blockquote>
<p>
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
</p>
<div>
Best regards,<br>
Libredesk
</div>`,
'New reply on participating conversation': `<p>{{ .Author.FullName }} replied to a conversation you are participating in:</p>
<div>
Reference number: {{ .Conversation.ReferenceNumber }} <br>
Subject: {{ .Conversation.Subject }}
</div>
<blockquote style="background-color: #f5f5f5; padding: 12px; margin: 16px 0; border-left: 4px solid #ddd;">
{{ .Message.Content }}
</blockquote>
<p>
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
</p>
<div>
Best regards,<br>
Libredesk
</div>`,
'Conversation reopened': `<p>{{ .Author.FullName }} replied and reopened a conversation assigned to you:</p>
<div>
Reference number: {{ .Conversation.ReferenceNumber }} <br>
Subject: {{ .Conversation.Subject }}
</div>
<blockquote style="background-color: #f5f5f5; padding: 12px; margin: 16px 0; border-left: 4px solid #ddd;">
{{ .Message.Content }}
</blockquote>
<p>
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
</p>
<div>
Best regards,<br>
Libredesk
</div>`,
'SLA breach warning': `<p>This is a notification that the SLA for conversation {{ .Conversation.ReferenceNumber }} is approaching the SLA deadline for {{ .SLA.Metric }}.</p>
<p>
Details:<br>
- Conversation reference number: {{ .Conversation.ReferenceNumber }}<br>
- Metric: {{ .SLA.Metric }}<br>
- Due in: {{ .SLA.DueIn }}
</p>
<p>
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
</p>
<p>
Best regards,<br>
Libredesk
</p>`,
'SLA breached': `<p>This is an urgent alert that the SLA for conversation {{ .Conversation.ReferenceNumber }} has been breached for {{ .SLA.Metric }}. Please take immediate action.</p>
<p>
Details:<br>
- Conversation reference number: {{ .Conversation.ReferenceNumber }}<br>
- Metric: {{ .SLA.Metric }}<br>
- Overdue by: {{ .SLA.OverdueBy }}
</p>
<p>
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
</p>
<p>
Best regards,<br>
Libredesk
</p>`,
'Mentioned in conversation': `<p>{{ .MentionedBy.FullName }} mentioned you in a private note on conversation #{{ .Conversation.ReferenceNumber }}.</p>
<blockquote style="background-color: #f5f5f5; padding: 12px; margin: 16px 0; border-left: 4px solid #ddd;">
{{ .Message.Content }}
</blockquote>
<p>
<a href="{{ RootURL }}/inboxes/mentioned/conversation/{{ .Conversation.UUID }}?scrollTo={{ .Message.UUID }}">View Conversation</a>
</p>
<p>
Best regards,<br>
libredesk
</p>`,
'CSAT request': `<p style="margin: 0 0 4px; font-size: 15px; color: #374151; text-align: center; line-height: 1.5;">
Your conversation <strong style="color: #111827;">#{{ .Conversation.ReferenceNumber }}</strong> has been resolved.
</p>
<p style="margin: 0 0 28px; font-size: 13px; color: #9ca3af; text-align: center;">
We would love to hear how it went.
</p>
<p style="margin: 0 0 20px; font-size: 14px; font-weight: 600; color: #374151; text-align: center;">
How would you rate your experience?
</p>
<!-- Variable CSATUUID is also available -->
<div style="text-align: center; margin: 0 auto; max-width: 400px; font-size: 0;">
<div style="display: inline-block; width: 72px; text-align: center; vertical-align: top; padding: 4px 0;">
<a href="{{ .CSATLink }}?rating=1" style="text-decoration: none; display: block;">
<span style="font-size: 34px; display: block; line-height: 1.4;">&#128546;</span>
<span style="font-size: 10px; display: block; font-weight: 600; color: #b0b5bd; text-transform: uppercase; letter-spacing: 0.05em; margin-top: 4px;">Poor</span>
</a>
</div>
<div style="display: inline-block; width: 72px; text-align: center; vertical-align: top; padding: 4px 0;">
<a href="{{ .CSATLink }}?rating=2" style="text-decoration: none; display: block;">
<span style="font-size: 34px; display: block; line-height: 1.4;">&#128533;</span>
<span style="font-size: 10px; display: block; font-weight: 600; color: #b0b5bd; text-transform: uppercase; letter-spacing: 0.05em; margin-top: 4px;">Fair</span>
</a>
</div>
<div style="display: inline-block; width: 72px; text-align: center; vertical-align: top; padding: 4px 0;">
<a href="{{ .CSATLink }}?rating=3" style="text-decoration: none; display: block;">
<span style="font-size: 34px; display: block; line-height: 1.4;">&#128522;</span>
<span style="font-size: 10px; display: block; font-weight: 600; color: #b0b5bd; text-transform: uppercase; letter-spacing: 0.05em; margin-top: 4px;">Good</span>
</a>
</div>
<div style="display: inline-block; width: 72px; text-align: center; vertical-align: top; padding: 4px 0;">
<a href="{{ .CSATLink }}?rating=4" style="text-decoration: none; display: block;">
<span style="font-size: 34px; display: block; line-height: 1.4;">&#128515;</span>
<span style="font-size: 10px; display: block; font-weight: 600; color: #b0b5bd; text-transform: uppercase; letter-spacing: 0.05em; margin-top: 4px;">Great</span>
</a>
</div>
<div style="display: inline-block; width: 72px; text-align: center; vertical-align: top; padding: 4px 0;">
<a href="{{ .CSATLink }}?rating=5" style="text-decoration: none; display: block;">
<span style="font-size: 34px; display: block; line-height: 1.4;">&#129321;</span>
<span style="font-size: 10px; display: block; font-weight: 600; color: #b0b5bd; text-transform: uppercase; letter-spacing: 0.05em; margin-top: 4px;">Excellent</span>
</a>
</div>
</div>`
}
@@ -12,7 +12,8 @@ import {
} from 'lucide-vue-next'
import { useUserStore } from '@main/stores/user'
import { useKeyboardShortcutsDialog } from '@main/composables/useKeyboardShortcutsDialog'
import { SECTIONS } from '../sections'
import { useLogout } from '@main/composables/useLogout'
import { SECTIONS } from '@main/features/command/sections'
const AWAY_STATUSES = ['away_manual', 'away_and_reassigning']
@@ -21,6 +22,7 @@ export function useAccountCommands() {
const mode = useColorMode()
const userStore = useUserStore()
const shortcutsDialog = useKeyboardShortcutsDialog()
const logout = useLogout()
return computed(() => {
const status = userStore.user.availability_status
@@ -70,9 +72,7 @@ export function useAccountCommands() {
keywords: ['sign out'],
section: SECTIONS.ACCOUNT,
icon: LogOut,
run: () => {
window.location.href = '/logout'
}
run: logout
}
]
})
@@ -49,10 +49,9 @@
<Button
v-if="userStore.can('contacts:write')"
size="sm"
class="flex items-center h-8 gap-1 ml-auto"
class="flex items-center h-8 ml-auto"
@click="showCreateDialog = true"
>
<UserPlus size="16" />
{{ $t('contact.new') }}
</Button>
</div>
@@ -140,7 +139,7 @@ import {
} from '@shared-ui/components/ui/select'
import { Input } from '@shared-ui/components/ui/input'
import { Button } from '@shared-ui/components/ui/button'
import { ArrowDownWideNarrow, IdCardIcon, UserPlus } from 'lucide-vue-next'
import { ArrowDownWideNarrow, IdCardIcon } from 'lucide-vue-next'
import { Popover, PopoverContent, PopoverTrigger } from '@shared-ui/components/ui/popover'
import { useDebounceFn } from '@vueuse/core'
import { EMITTER_EVENTS } from '@main/constants/emitterEvents.js'
@@ -55,7 +55,6 @@
v-if="isEditorFullscreen"
ref="fullscreenContentRef"
:isFullscreen="true"
:aiPrompts="aiPrompts"
:isSending="isSending"
:isDraftLoading="isDraftLoading"
:uploadingFiles="uploadingFiles"
@@ -75,7 +74,7 @@
@fileUpload="handleFileUpload"
@fileDelete="handleFileDelete"
@filesDropped="uploadFiles"
@aiPromptSelected="handleAiPromptSelected"
@aiGenerationChange="isGenerating = $event"
:isGenerating="isGenerating"
:canSendReply="canSendReply"
:canSendPrivateNote="canSendPrivateNote"
@@ -117,7 +116,6 @@
<ReplyBoxContent
ref="replyBoxContentRef"
:isFullscreen="false"
:aiPrompts="aiPrompts"
:isSending="isSending"
:isDraftLoading="isDraftLoading"
:uploadingFiles="uploadingFiles"
@@ -137,7 +135,7 @@
@fileUpload="handleFileUpload"
@fileDelete="handleFileDelete"
@filesDropped="uploadFiles"
@aiPromptSelected="handleAiPromptSelected"
@aiGenerationChange="isGenerating = $event"
:isGenerating="isGenerating"
:canSendReply="canSendReply"
:canSendPrivateNote="canSendPrivateNote"
@@ -158,7 +156,6 @@ import api from '@main/api'
import { useI18n } from 'vue-i18n'
import { useConversationStore } from '@main/stores/conversation'
import { useInboxStore } from '@main/stores/inbox'
import { useAiPromptStore } from '@main/stores/aiPrompt'
import { useNotificationStore } from '@main/stores/notification'
import {
AlertDialog,
@@ -254,8 +251,6 @@ const cc = ref('')
const bcc = ref('')
const showBcc = ref(false)
const emailErrors = ref([])
const aiPromptStore = useAiPromptStore()
const aiPrompts = computed(() => aiPromptStore.prompts)
const replyBoxContentRef = ref(null)
const fullscreenContentRef = ref(null)
const activeContentRef = () =>
@@ -265,8 +260,6 @@ const showMissingTagsWarning = ref(false)
const deferredStatus = ref(null)
const mentions = ref([])
aiPromptStore.fetchPrompts()
const runAiGeneration = async (requestFn) => {
if (isGenerating.value) return
const uuid = currentConversationUUID.value
@@ -286,9 +279,6 @@ const runAiGeneration = async (requestFn) => {
}
}
const handleAiPromptSelected = (key) =>
runAiGeneration(() => api.aiCompletion({ prompt_key: key, content: htmlContent.value }))
const handleGenerateReply = () =>
runAiGeneration((uuid) =>
api.aiGenerateReply({ conversation_uuid: uuid, instruction: textContent.value })
@@ -7,18 +7,18 @@
:class="{ 'mb-4': !isFullscreen, 'border-b border-border pb-4': isFullscreen }"
>
<Tabs v-model="messageType" class="rounded-lg">
<TabsList class="rounded-lg border bg-muted/50 p-0.5">
<TabsList>
<TabsTrigger
v-if="canSendReply"
value="reply"
:class="TAB_TRIGGER_CLASS"
class="max-md:py-2.5"
>
{{ $t('globals.terms.reply') }}
</TabsTrigger>
<TabsTrigger
v-if="canSendPrivateNote"
value="private_note"
:class="TAB_TRIGGER_CLASS"
class="max-md:py-2.5"
>
{{ $t('globals.terms.privateNote') }}
</TabsTrigger>
@@ -91,7 +91,6 @@
v-model:textContent="textContent"
:message-type="messageType"
:placeholder="isCramped ? t('globals.terms.typeMessage') : t('editor.hint.full')"
:aiPrompts="aiPrompts"
:insertContent="insertContent"
:autoFocus="true"
:disabled="isDraftLoading"
@@ -100,7 +99,7 @@
:enableInlineImages="conversationStore.current.inbox_channel === 'email'"
:getSuggestions="getSuggestions"
:getConversationSuggestions="getConversationSuggestions"
@aiPromptSelected="handleAiPromptSelected"
@aiGenerationChange="emit('aiGenerationChange', $event)"
@send="handleSend"
@mentionsChanged="handleMentionsChanged"
@filesDropped="handleFilesDropped"
@@ -146,9 +145,6 @@
const RECIPIENT_INPUT_CLASS =
'flex-grow border-input bg-card px-3 py-2 text-sm shadow-none focus-visible:ring-1 focus-visible:ring-ring'
const TAB_TRIGGER_CLASS =
'rounded-md px-3 py-1 text-sm transition-colors duration-150 max-md:py-2.5 data-[state=active]:bg-card data-[state=active]:shadow-sm'
import { ref, computed, nextTick, watch } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import { EMITTER_EVENTS } from '@main/constants/emitterEvents.js'
@@ -248,10 +244,6 @@ const props = defineProps({
type: Boolean,
default: false
},
aiPrompts: {
type: Array,
required: true
},
isSending: {
type: Boolean,
required: true
@@ -292,7 +284,7 @@ const emit = defineEmits([
'inlineImageUpload',
'fileDelete',
'filesDropped',
'aiPromptSelected',
'aiGenerationChange',
'generateReply'
])
@@ -400,10 +392,6 @@ const handleEmojiSelect = (emoji) => {
nextTick(() => (insertContent.value = emoji))
}
const handleAiPromptSelected = (key) => {
emit('aiPromptSelected', key)
}
// Watch and update macro view based on message type this filters our macros.
watch(
messageType,
+7
View File
@@ -173,6 +173,13 @@ const routes = [
name: 'profile',
component: () => import('@main/views/account/profile/ProfileEditView.vue'),
meta: { titleKey: 'account.editProfile' }
},
{
path: 'notifications',
name: 'account-notifications',
component: () =>
import('@main/views/account/notifications/NotificationPreferences.vue'),
meta: { titleKey: 'globals.terms.notification', titleCount: 2 }
}
]
},
+54 -27
View File
@@ -1,34 +1,61 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { handleHTTPError } from '@shared-ui/utils/http.js'
import { useEmitter } from '../composables/useEmitter'
import { EMITTER_EVENTS } from '../constants/emitterEvents'
import api from '../api'
import { useEmitter } from '@main/composables/useEmitter'
import { EMITTER_EVENTS } from '@main/constants/emitterEvents'
import api from '@main/api'
export const useAiPromptStore = defineStore('aiPrompt', () => {
const prompts = ref([])
const emitter = useEmitter()
let inflight = null
let hasFetched = false
const fetchPrompts = () => {
if (hasFetched) return Promise.resolve()
if (inflight) return inflight
inflight = api.getAiPrompts()
.then(response => {
prompts.value = response?.data?.data || []
hasFetched = true
})
.catch(error => {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
})
.finally(() => { inflight = null })
return inflight
}
return {
prompts,
fetchPrompts,
const prompts = ref([])
const emitter = useEmitter()
let inflight = null
let hasFetched = false
const showError = (error) => {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
const fetchPrompts = () => {
if (hasFetched) return Promise.resolve()
if (inflight) return inflight
inflight = api
.getAiPrompts()
.then((response) => {
prompts.value = response?.data?.data || []
hasFetched = true
})
.catch(showError)
.finally(() => {
inflight = null
})
return inflight
}
const refreshPrompts = async () => {
if (inflight) await inflight
hasFetched = false
return fetchPrompts()
}
const complete = async (promptKey, content) => {
try {
const response = await api.aiCompletion({ prompt_key: promptKey, content })
return response?.data?.data ?? ''
} catch (error) {
showError(error)
return null
}
}
return {
prompts,
fetchPrompts,
refreshPrompts,
complete
}
})
@@ -0,0 +1,185 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, h, nextTick, ref } from 'vue'
const { getPreferences, savePreferences, refresh, enable, disable, emit } = vi.hoisted(() => ({
getPreferences: vi.fn(),
savePreferences: vi.fn().mockResolvedValue({}),
refresh: vi.fn(),
enable: vi.fn().mockResolvedValue(true),
disable: vi.fn().mockResolvedValue(),
emit: vi.fn()
}))
vi.mock('@/api', () => ({
default: {
getNotificationPreferences: getPreferences,
updateNotificationPreferences: savePreferences
}
}))
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key) => key }) }))
vi.mock('@/composables/useEmitter', () => ({ useEmitter: () => ({ emit }) }))
vi.mock('@shared-ui/utils/http.js', () => ({ handleHTTPError: (error) => error }))
vi.mock('@/composables/usePushNotifications', () => ({
usePushNotifications: () => ({
supported: ref(true),
enabled: ref(false),
permission: ref('granted'),
refresh,
enable,
disable
})
}))
vi.mock('@shared-ui/components/ui/switch', () => ({
Switch: {
props: ['checked', 'disabled'],
emits: ['update:checked'],
setup:
(props, { emit }) =>
() =>
h('button', {
type: 'button',
disabled: props.disabled,
onClick: () => emit('update:checked', !props.checked)
})
}
}))
import NotificationPreferences from './NotificationPreferences.vue'
let app
let root
const settle = async () => {
for (let i = 0; i < 10; i++) await nextTick()
}
afterEach(() => {
app?.unmount()
root?.remove()
vi.clearAllMocks()
})
const mount = async (vapidPublicKey = 'test-key') => {
getPreferences.mockResolvedValue({
data: {
data: {
email_enabled: true,
vapid_public_key: vapidPublicKey,
push_endpoints: ['https://push.example/subscription'],
preferences: [
{ notification_type: 'new_reply', channel: 'email', enabled: true },
{ notification_type: 'new_reply', channel: 'in_app', enabled: false }
]
}
}
})
root = document.createElement('div')
document.body.append(root)
app = createApp(NotificationPreferences)
app.config.globalProperties.$t = (key) => key
app.mount(root)
await settle()
}
describe('notification preferences', () => {
it('shows a loader until preferences are loaded', async () => {
let finishLoad
getPreferences.mockReturnValueOnce(
new Promise((resolve) => {
finishLoad = resolve
})
)
root = document.createElement('div')
document.body.append(root)
app = createApp(NotificationPreferences)
app.config.globalProperties.$t = (key) => key
app.mount(root)
await nextTick()
expect(root.querySelector('[role="status"]')).not.toBeNull()
expect(root.querySelectorAll('button')).toHaveLength(0)
finishLoad({
data: {
data: {
email_enabled: true,
vapid_public_key: 'test-key',
push_endpoints: [],
preferences: []
}
}
})
await settle()
expect(root.querySelector('[role="status"]')).toBeNull()
expect(root.querySelectorAll('button')).toHaveLength(1)
})
it('saves a channel toggle', async () => {
await mount()
const email = root.querySelectorAll('button')[2]
expect(email).toBeDefined()
email.click()
await settle()
expect(savePreferences).toHaveBeenCalledWith([
{ notification_type: 'new_reply', channel: 'email', enabled: false }
])
})
it('prevents overlapping saves for the same preference', async () => {
let finishSave
savePreferences.mockImplementationOnce(
() =>
new Promise((resolve) => {
finishSave = resolve
})
)
await mount()
const switches = root.querySelectorAll('button')
const inApp = switches[1]
const email = switches[2]
email.click()
email.click()
await nextTick()
expect(savePreferences).toHaveBeenCalledTimes(1)
expect(email.disabled).toBe(true)
expect(inApp.disabled).toBe(false)
inApp.click()
await settle()
expect(savePreferences).toHaveBeenCalledTimes(2)
finishSave({})
await settle()
expect(email.disabled).toBe(false)
})
it('restores this browser push state from the saved endpoints', async () => {
await mount()
expect(refresh).toHaveBeenCalledWith('test-key', ['https://push.example/subscription'])
})
it('disables browser notifications when push is unavailable', async () => {
await mount('')
const browserPush = root.querySelector('button')
expect(browserPush.disabled).toBe(true)
expect(root.textContent).toContain('notification.browserPush.unavailable')
})
it('shows a loader while browser notifications are being enabled', async () => {
let finishEnable
enable.mockReturnValueOnce(
new Promise((resolve) => {
finishEnable = resolve
})
)
await mount()
root.querySelector('button').click()
await nextTick()
expect(root.querySelector('.animate-dot-flashing')).not.toBeNull()
finishEnable(true)
await settle()
expect(root.querySelector('.animate-dot-flashing')).toBeNull()
})
})
@@ -0,0 +1,205 @@
<template>
<div class="h-full relative">
<div class="flex flex-col space-y-5 max-w-2xl">
<div class="space-y-1">
<span class="sub-title">{{ $t('globals.terms.notification', 2) }}</span>
</div>
<Spinner v-if="isLoading" />
<template v-else>
<p v-if="!emailEnabled" class="text-muted-foreground text-xs">
{{ $t('notification.emailChannelDisabled') }}
</p>
<div
class="flex items-center justify-between gap-4 border border-border rounded-md px-4 py-3"
>
<div class="space-y-1">
<p class="text-sm text-foreground">
{{ $t('globals.terms.browserNotification', 2) }}
</p>
<p v-if="browserPushDescription" class="text-xs text-muted-foreground">
{{ browserPushDescription }}
</p>
</div>
<DotLoader
v-if="isUpdatingBrowserPush"
role="status"
:aria-label="$t('globals.terms.loading')"
/>
<Switch
v-else
:checked="pushNotifications.enabled.value"
:disabled="
!pushNotifications.supported.value ||
pushNotifications.permission.value === 'denied' ||
!vapidPublicKey
"
:aria-label="$t('globals.terms.browserNotification', 2)"
@update:checked="updateBrowserPush"
/>
</div>
<div class="border border-border rounded-md divide-y divide-border">
<div class="flex items-center px-4 py-2 text-xs font-medium text-muted-foreground">
<div class="flex-grow" />
<div v-for="channel in channels" :key="channel.key" class="w-20 text-center">
{{ $t(channel.labelKey) }}
</div>
</div>
<div v-for="row in rows" :key="row.type" class="flex items-center px-4 py-3">
<div class="flex-grow pr-4">
<p class="text-sm text-foreground">{{ typeLabel(row.type) }}</p>
</div>
<div v-for="channel in channels" :key="channel.key" class="w-20 flex justify-center">
<Switch
:checked="row[channel.key]"
:disabled="
(channel.key === 'email' && !emailEnabled) ||
pendingUpdates.has(preferenceKey(row.type, channel.key))
"
:aria-label="
$t('notification.channelToggleLabel', {
channel: $t(channel.labelKey),
type: typeLabel(row.type)
})
"
@update:checked="update(row, channel.key, $event)"
/>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
<script setup>
import { computed, ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { Switch } from '@shared-ui/components/ui/switch'
import { DotLoader } from '@shared-ui/components/ui/loader'
import { Spinner } from '@shared-ui/components/ui/spinner'
import { useEmitter } from '@/composables/useEmitter'
import { handleHTTPError } from '@shared-ui/utils/http.js'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import api from '@/api'
import { usePushNotifications } from '@/composables/usePushNotifications'
const { t } = useI18n()
const emitter = useEmitter()
const rows = ref([])
const isLoading = ref(true)
const emailEnabled = ref(true)
const vapidPublicKey = ref('')
const pendingUpdates = ref(new Set())
const isUpdatingBrowserPush = ref(false)
const pushNotifications = usePushNotifications()
const channels = [
{ key: 'in_app', labelKey: 'globals.terms.inApp' },
{ key: 'email', labelKey: 'globals.terms.email' },
{ key: 'push', labelKey: 'globals.terms.browser' }
]
const typeLabels = {
assignment: 'notification.type.assignment',
mention: 'notification.type.mention',
conversation_reopened: 'notification.type.conversationReopened',
new_reply: 'notification.type.newReply',
new_reply_participating: 'notification.type.newReplyParticipating',
sla_first_response_warning: 'notification.type.slaFirstResponseWarning',
sla_first_response_breach: 'notification.type.slaFirstResponseBreach',
sla_next_response_warning: 'notification.type.slaNextResponseWarning',
sla_next_response_breach: 'notification.type.slaNextResponseBreach',
sla_resolution_warning: 'notification.type.slaResolutionWarning',
sla_resolution_breach: 'notification.type.slaResolutionBreach'
}
const typeLabel = (type) => (typeLabels[type] ? t(typeLabels[type]) : type)
const preferenceKey = (type, channel) => `${type}:${channel}`
const fetchPreferences = async () => {
try {
const { data } = await api.getNotificationPreferences()
emailEnabled.value = data.data.email_enabled
vapidPublicKey.value = data.data.vapid_public_key
await pushNotifications.refresh(vapidPublicKey.value, data.data.push_endpoints)
const byType = {}
for (const pref of data.data.preferences) {
byType[pref.notification_type] ??= { type: pref.notification_type }
byType[pref.notification_type][pref.channel] = pref.enabled
}
rows.value = Object.values(byType)
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
isLoading.value = false
}
}
const browserPushDescription = computed(() => {
if (!pushNotifications.supported.value) return t('notification.browserPush.unsupported')
if (pushNotifications.permission.value === 'denied') return t('notification.browserPush.blocked')
if (!vapidPublicKey.value) return t('notification.browserPush.unavailable')
return ''
})
const updateBrowserPush = async (value) => {
if (isUpdatingBrowserPush.value) return
try {
isUpdatingBrowserPush.value = true
if (value) {
const granted = await pushNotifications.enable(vapidPublicKey.value)
if (!granted) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: t('notification.browserPush.blocked')
})
}
} else {
await pushNotifications.disable()
}
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
isUpdatingBrowserPush.value = false
}
}
const update = async (row, channel, value) => {
const key = preferenceKey(row.type, channel)
if (pendingUpdates.value.has(key)) return
const previous = row[channel]
row[channel] = value
pendingUpdates.value.add(key)
try {
await api.updateNotificationPreferences([
{ notification_type: row.type, channel, enabled: value }
])
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.savedSuccessfully')
})
} catch (error) {
row[channel] = previous
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
pendingUpdates.value.delete(key)
}
}
onMounted(fetchPreferences)
</script>
@@ -37,6 +37,7 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { storeToRefs } from 'pinia'
import AdminSplitLayout from '@/layouts/admin/AdminSplitLayout.vue'
import LoadingOverlay from '@main/components/layout/LoadingOverlay.vue'
import DataTable from '@main/components/datatable/DataTable.vue'
@@ -55,18 +56,20 @@ import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { handleHTTPError } from '@shared-ui/utils/http.js'
import { useI18n } from 'vue-i18n'
import api from '@/api'
import { useAiPromptStore } from '@main/stores/aiPrompt'
const { t } = useI18n()
const emitter = useEmitter()
const aiPromptStore = useAiPromptStore()
const { prompts } = storeToRefs(aiPromptStore)
const isLoading = ref(false)
const prompts = ref([])
const dialogOpen = ref(false)
const isEditing = ref(false)
const initialValues = ref({})
const editingId = ref(null)
const refreshHandler = (data) => {
if (data?.model === 'ai_editor_prompts') getPrompts()
if (data?.model === 'ai_editor_prompts') refreshPrompts()
}
const editHandler = (data) => {
@@ -74,7 +77,7 @@ const editHandler = (data) => {
}
onMounted(() => {
getPrompts()
loadPrompts()
emitter.on(EMITTER_EVENTS.REFRESH_LIST, refreshHandler)
emitter.on(EMITTER_EVENTS.EDIT_MODEL, editHandler)
})
@@ -84,19 +87,16 @@ onUnmounted(() => {
emitter.off(EMITTER_EVENTS.EDIT_MODEL, editHandler)
})
const getPrompts = async () => {
try {
isLoading.value = true
const resp = await api.getAiPrompts()
prompts.value = resp.data.data || []
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
isLoading.value = false
}
const loadPrompts = async () => {
isLoading.value = true
await aiPromptStore.fetchPrompts()
isLoading.value = false
}
const refreshPrompts = async () => {
isLoading.value = true
await aiPromptStore.refreshPrompts()
isLoading.value = false
}
const newPrompt = () => {
@@ -128,7 +128,7 @@ const submitPrompt = async (values) => {
await api.createAIPrompt(values)
}
dialogOpen.value = false
getPrompts()
await refreshPrompts()
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.savedSuccessfully')
})
@@ -0,0 +1,18 @@
describe('PWA notification preferences', () => {
it('serves installable assets', () => {
cy.request('/manifest.webmanifest').then(({ body, headers, status }) => {
expect(status).to.eq(200)
expect(headers['content-type']).to.include('application/manifest+json')
expect(body.name).to.eq('libredesk')
expect(body.display).to.eq('standalone')
expect(body.icons).to.have.length(2)
})
cy.request('/sw.js').then(({ body, status }) => {
expect(status).to.eq(200)
expect(body).to.include("addEventListener('push'")
expect(body).to.include("addEventListener('notificationclick'")
expect(body).not.to.include("addEventListener('fetch'")
})
})
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+22
View File
@@ -0,0 +1,22 @@
{
"name": "libredesk",
"short_name": "libredesk",
"description": "Customer support desk",
"start_url": "/inboxes/assigned",
"scope": "/",
"display": "standalone",
"background_color": "hsl(0 0% 99.2%)",
"theme_color": "hsl(152 39% 30%)",
"icons": [
{
"src": "/images/pwa-icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/images/pwa-icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+40
View File
@@ -0,0 +1,40 @@
const readPayload = (event) => {
try {
return event.data ? event.data.json() : null
} catch {
return null
}
}
self.addEventListener('install', () => self.skipWaiting())
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim())
})
self.addEventListener('push', (event) => {
const notification = readPayload(event)
if (!notification?.title) return
event.waitUntil(self.registration.showNotification(notification.title, {
body: notification.body,
tag: notification.tag,
icon: '/images/pwa-icon-192.png',
badge: '/images/pwa-icon-192.png',
data: { url: notification.url }
}))
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
const url = event.notification.data?.url
if (!url) return
const target = new URL(url, self.location.origin)
if (target.origin !== self.location.origin) return
event.waitUntil(self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(async (clients) => {
const client = clients[0]
if (!client) return self.clients.openWindow(target.href)
await client.focus()
return client.navigate(target.href).catch(() => self.clients.openWindow(target.href))
}))
})
+1
View File
@@ -3,6 +3,7 @@ module github.com/abhinavxd/libredesk
go 1.25.0
require (
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/abhinavxd/ssrfguard v0.1.0
github.com/alicebob/miniredis/v2 v2.33.0
github.com/coreos/go-oidc/v3 v3.11.0
+36
View File
@@ -8,6 +8,8 @@ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOEl
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
github.com/abhinavxd/ssrfguard v0.1.0 h1:Ns/llAQ63uGFehxSvhCd+WGDKmBEEmIH+E1AW1CGgWM=
github.com/abhinavxd/ssrfguard v0.1.0/go.mod h1:eNVubb+m/r3KrKWYdG6hxzeAfj+t2ZmZss4V/x7D6Ws=
github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 h1:uvdUDbHQHO85qeSydJtItA4T55Pw6BtAejd0APRJOCE=
@@ -103,10 +105,12 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -293,6 +297,10 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
@@ -300,6 +308,9 @@ golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
@@ -311,6 +322,10 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
@@ -318,6 +333,10 @@ golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -329,24 +348,41 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+25
View File
@@ -907,6 +907,7 @@
"globals.messages.replying": "Replying",
"globals.messages.required": "{name} Required",
"globals.messages.reset": "Reset",
"globals.messages.resetToDefault": "Reset to default",
"globals.messages.revoke": "Revoke",
"globals.messages.save": "Save",
"globals.messages.saveChanges": "Save changes",
@@ -971,6 +972,8 @@
"globals.terms.body": "Body",
"globals.terms.brandName": "Brand name",
"globals.terms.breadcrumb": "Breadcrumb",
"globals.terms.browser": "Browser",
"globals.terms.browserNotification": "Browser notification | Browser notifications",
"globals.terms.businessHour": "Business hour | Business hours",
"globals.terms.callbackURL": "Callback URL",
"globals.terms.category": "Category",
@@ -1042,6 +1045,7 @@
"globals.terms.hour": "Hour | Hours",
"globals.terms.icon": "Icon | Icons",
"globals.terms.image": "Image | Images",
"globals.terms.inApp": "In-app",
"globals.terms.import": "Import | Imports",
"globals.terms.inactive": "Inactive | Inactives",
"globals.terms.inbox": "Inbox | Inboxes",
@@ -1375,11 +1379,32 @@
"navigation.keyboardShortcuts": "Keyboard shortcuts",
"navigation.logout": "Logout",
"navigation.reassignReplies": "Reassign replies",
"notification.browserPush.blocked": "Browser notifications are blocked. Allow them in your browser settings to receive notifications on this device.",
"notification.browserPush.unavailable": "Browser notifications are unavailable because push is not configured. Ask an administrator to configure push notifications.",
"notification.browserPush.unsupported": "This browser does not support push notifications. Use a supported browser to receive notifications when libredesk is closed.",
"notification.channelToggleLabel": "{channel} notifications for {type}",
"notification.conversationAssigned": "Conversation assigned to you #{referenceNumber}",
"notification.conversationReopened": "Conversation reopened #{referenceNumber}",
"notification.emailChannelDisabled": "Email notifications are turned off for this workspace. Contact your administrator to enable them.",
"notification.invalidPreference": "Unknown notification type or channel. The preference was not saved.",
"notification.invalidPushSubscription": "This browser subscription is invalid. Disable and enable browser notifications, then try again.",
"notification.mentionedInConversation": "{author} mentioned you in #{referenceNumber}",
"notification.newReply": "New reply on #{referenceNumber}",
"notification.settings": "Notification settings",
"notification.slaAlert": "SLA {type}: {metric} for #{referenceNumber}",
"notification.slaDueIn": "Due in {duration}",
"notification.slaOverdue": "Overdue by {duration}",
"notification.type.assignment": "Conversation assigned to me",
"notification.type.conversationReopened": "Conversation assigned to me is reopened by a reply",
"notification.type.mention": "Mentioned in a conversation",
"notification.type.newReply": "New reply on a conversation assigned to me",
"notification.type.newReplyParticipating": "New reply on a conversation I'm participating in",
"notification.type.slaFirstResponseBreach": "First response SLA breached",
"notification.type.slaFirstResponseWarning": "First response SLA breach warning",
"notification.type.slaNextResponseBreach": "Next response SLA breached",
"notification.type.slaNextResponseWarning": "Next response SLA breach warning",
"notification.type.slaResolutionBreach": "Resolution SLA breached",
"notification.type.slaResolutionWarning": "Resolution SLA breach warning",
"oidc.edit": "Edit SSO",
"oidc.new": "New SSO",
"placeholders.chatIntroduction": "Ask us anything, or share your feedback.",
+238 -142
View File
@@ -2,12 +2,15 @@
package conversation
import (
"cmp"
"context"
"database/sql"
"embed"
"encoding/json"
"errors"
"fmt"
"html"
htmltemplate "html/template"
"io"
"slices"
"strconv"
@@ -29,7 +32,6 @@ import (
"github.com/abhinavxd/libredesk/internal/inbox"
imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
mmodels "github.com/abhinavxd/libredesk/internal/media/models"
notifier "github.com/abhinavxd/libredesk/internal/notification"
nmodels "github.com/abhinavxd/libredesk/internal/notification/models"
slaModels "github.com/abhinavxd/libredesk/internal/sla/models"
"github.com/abhinavxd/libredesk/internal/stringutil"
@@ -65,6 +67,7 @@ const (
<p>
<a href="{{ RootURL }}/inboxes/all/conversation/{{ .Conversation.UUID }}">#{{ .Conversation.ReferenceNumber }}</a>
</p>`
newReplyEmailDelay = 5 * time.Minute
)
var ListFilterRenderers = dbutil.FieldRenderers{
@@ -80,6 +83,10 @@ var ListFilterAllowedFields = dbutil.AllowedFields{
"inboxes": inboxesAllowedFields,
}
type notificationDispatcher interface {
Send(nmodels.Notification) ([]nmodels.DeliveryResult, error)
}
// Manager handles the operations related to conversations
type Manager struct {
q queries
@@ -93,7 +100,7 @@ type Manager struct {
settingsStore settingsStore
csatStore csatStore
webhookStore webhookStore
dispatcher *notifier.Dispatcher
dispatcher notificationDispatcher
lo *logf.Logger
db *sqlx.DB
i18n *i18n.I18n
@@ -111,6 +118,13 @@ type Manager struct {
aiAgent AIAgentEngine
}
type replyNotificationGroup struct {
nType nmodels.NotificationType
tmpl string
title string
recipients []umodels.User
}
// AIAgentEngine is notified when a conversation assigned to an AI assistant may need a response.
type AIAgentEngine interface {
HandleConversationEvent(conversationID, assigneeUserID int)
@@ -163,7 +177,6 @@ type teamStore interface {
type userStore interface {
Get(int, string, []string) (umodels.User, error)
GetAgent(int, string) (umodels.User, error)
GetAgentCachedOrLoad(int) (umodels.User, error)
GetSystemUser() (umodels.User, error)
ResolveContact(user *umodels.User, policy umodels.ContactPolicy) error
@@ -239,7 +252,7 @@ func New(
automation *automation.Engine,
template *template.Manager,
webhook webhookStore,
dispatcher *notifier.Dispatcher,
dispatcher notificationDispatcher,
opts Opts) (*Manager, error) {
var q queries
@@ -309,6 +322,7 @@ type queries struct {
GetContactConversationsForAI *sqlx.Stmt `query:"get-contact-conversations-for-ai"`
GetConversationsByContactEmailForAI *sqlx.Stmt `query:"get-conversations-by-contact-email-for-ai"`
GetConversationParticipants *sqlx.Stmt `query:"get-conversation-participants"`
GetConversationParticipantAgents *sqlx.Stmt `query:"get-conversation-participant-agents"`
GetUserActiveConversationsCount *sqlx.Stmt `query:"get-user-active-conversations-count"`
GetSidebarStandardCounts *sqlx.Stmt `query:"get-sidebar-standard-counts"`
GetConversationsCountBase string `query:"get-conversations-count-base"`
@@ -683,14 +697,14 @@ func (c *Manager) GetConversations(viewingUserID, userID int, teamIDs []int, lis
}
// ReOpenConversation reopens a conversation if it's snoozed, resolved or closed.
func (c *Manager) ReOpenConversation(conversationUUID string, actor umodels.User) error {
func (c *Manager) ReOpenConversation(conversationUUID string, actor umodels.User) (bool, error) {
var conversationID int
if err := c.q.ReOpenConversation.QueryRow(conversationUUID).Scan(&conversationID); err != nil {
if err == sql.ErrNoRows {
return nil
return false, nil
}
c.lo.Error("error reopening conversation", "uuid", conversationUUID, "error", err)
return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
return false, envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
}
// Reopening revives any first response or resolution deadline the resolved status had nulled.
@@ -701,9 +715,9 @@ func (c *Manager) ReOpenConversation(conversationUUID string, actor umodels.User
c.BroadcastConversationUpdate(conversationUUID, map[string]any{"status": models.StatusOpen})
if err := c.RecordStatusChange(models.StatusOpen, conversationUUID, actor); err != nil {
return err
return true, err
}
return nil
return true, nil
}
// ActiveUserConversationsCount returns the count of active conversations for a user. i.e. conversations not closed or resolved status.
@@ -811,7 +825,7 @@ func (c *Manager) afterUserAssignedHooks(uuid string, assigneeID int, actor umod
return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
}
agent, err := c.userStore.GetAgent(assigneeID, "")
agent, err := c.userStore.GetAgentCachedOrLoad(assigneeID)
if err == nil {
c.SignAvatarURL(&agent.AvatarURL)
// Always send expectation (empty for humans) so it clears when the AI hands off to an agent.
@@ -1211,64 +1225,157 @@ func (m *Manager) SendTransientEmail(inboxID, conversationID int, conversationUU
// NotifyAssignment sends notifications (in-app, WebSocket, email) for an assigned conversation.
func (m *Manager) NotifyAssignment(userIDs []int, conversation models.Conversation) error {
agent, err := m.userStore.GetAgent(userIDs[0], "")
agent, err := m.userStore.GetAgentCachedOrLoad(userIDs[0])
if err != nil {
m.lo.Error("error fetching agent", "user_id", userIDs[0], "error", err)
return fmt.Errorf("fetching agent: %w", err)
}
// Render email template.
// Automated messages do not have an author.
content, subject, err := m.template.RenderStoredEmailTemplate(template.TmplConversationAssigned,
map[string]any{
"Conversation": map[string]any{
"ReferenceNumber": conversation.ReferenceNumber,
"Subject": conversation.Subject.String,
"Priority": conversation.Priority.String,
"UUID": conversation.UUID,
},
"Contact": map[string]any{
"FirstName": conversation.Contact.FirstName,
"LastName": conversation.Contact.LastName,
"FullName": conversation.Contact.FullName(),
"Email": conversation.Contact.Email.String,
},
"Recipient": map[string]any{
"FirstName": agent.FirstName,
"LastName": agent.LastName,
"FullName": agent.FullName(),
"Email": agent.Email.String,
},
// Automated messages do not have an author.
"Author": map[string]any{
"FirstName": "",
"LastName": "",
"FullName": "",
"Email": "",
},
})
notificationTemplateData(conversation, agent, umodels.User{}))
if err != nil {
m.lo.Error("error rendering template", "template", template.TmplConversationAssigned, "conversation_uuid", conversation.UUID, "error", err)
return fmt.Errorf("rendering template: %w", err)
}
// Send notification.
m.dispatcher.Send(notifier.Notification{
Type: nmodels.NotificationTypeAssignment,
RecipientIDs: []int{agent.ID},
if _, err := m.dispatcher.Send(nmodels.Notification{
Type: nmodels.NotificationTypeAssignment,
Recipients: []nmodels.Recipient{{
UserID: agent.ID,
Email: &nmodels.EmailNotification{
Recipient: agent.Email.String,
Subject: subject,
Content: content,
},
}},
Title: m.i18n.Ts("notification.conversationAssigned", "referenceNumber", conversation.ReferenceNumber),
Body: conversation.Subject,
Body: null.StringFrom(cmp.Or(conversation.Subject.String, conversation.LastMessage.String)),
ConversationID: null.IntFrom(conversation.ID),
ConversationUUID: conversation.UUID,
// Parms required for email
Email: &notifier.EmailNotification{
Recipients: []string{agent.Email.String},
Subject: subject,
Content: content,
},
})
}); err != nil {
return fmt.Errorf("sending assignment notification: %w", err)
}
return nil
}
// NotifyNewReply notifies the assigned agent and the other participating agents of an incoming reply.
func (m *Manager) NotifyNewReply(conversation models.Conversation, message models.Message, reopened bool) {
assigneeType, assigneeTmpl, assigneeTitleKey := nmodels.NotificationTypeNewReply, template.TmplNewReply, "notification.newReply"
if reopened {
assigneeType, assigneeTmpl, assigneeTitleKey = nmodels.NotificationTypeConversationReopened, template.TmplConversationReopened, "notification.conversationReopened"
}
groups := []replyNotificationGroup{
{
nType: assigneeType,
tmpl: assigneeTmpl,
title: m.i18n.Ts(assigneeTitleKey, "referenceNumber", conversation.ReferenceNumber),
recipients: m.replyNotificationAssignee(conversation, message.SenderID),
},
{
nType: nmodels.NotificationTypeNewReplyParticipating,
tmpl: template.TmplNewReplyParticipating,
title: m.i18n.Ts("notification.newReply", "referenceNumber", conversation.ReferenceNumber),
recipients: m.replyNotificationParticipants(conversation, message.SenderID),
},
}
if !slices.ContainsFunc(groups, func(g replyNotificationGroup) bool { return len(g.recipients) > 0 }) {
return
}
author, err := m.userStore.Get(message.SenderID, "", []string{})
if err != nil {
m.lo.Error("error fetching sender for new reply notification", "user_id", message.SenderID, "error", err)
return
}
for _, group := range groups {
m.sendReplyNotification(conversation, message, group, author)
}
}
func (m *Manager) replyNotificationAssignee(conversation models.Conversation, senderID int) []umodels.User {
assigneeID := conversation.AssignedUserID.Int
if assigneeID == 0 || assigneeID == senderID {
return nil
}
agent, ok := m.notifiableAgent(assigneeID, conversation)
if !ok {
return nil
}
return []umodels.User{agent}
}
func (m *Manager) replyNotificationParticipants(conversation models.Conversation, senderID int) []umodels.User {
var participants []umodels.User
if err := m.q.GetConversationParticipantAgents.Select(&participants, conversation.UUID); err != nil {
m.lo.Error("error fetching participants for new reply notification", "conversation_uuid", conversation.UUID, "error", err)
return nil
}
agents := make([]umodels.User, 0, len(participants))
for _, participant := range participants {
if participant.ID == senderID || participant.ID == conversation.AssignedUserID.Int {
continue
}
if agent, ok := m.notifiableAgent(participant.ID, conversation); ok {
agents = append(agents, agent)
}
}
return agents
}
func (m *Manager) notifiableAgent(userID int, conversation models.Conversation) (umodels.User, bool) {
agent, err := m.userStore.GetAgentCachedOrLoad(userID)
if err != nil {
m.lo.Error("error fetching agent for new reply notification", "user_id", userID, "error", err)
return umodels.User{}, false
}
if !agent.Enabled || !authz.CanReadAssignment(agent, conversation.AssignedUserID, conversation.AssignedTeamID) {
return umodels.User{}, false
}
return agent, true
}
func (m *Manager) sendReplyNotification(conversation models.Conversation, message models.Message, group replyNotificationGroup, author umodels.User) {
if len(group.recipients) == 0 {
return
}
messageText := replyNotificationText(message)
candidateIDs := make([]int, len(group.recipients))
for i, recipient := range group.recipients {
candidateIDs[i] = recipient.ID
}
recipients := m.buildNotificationRecipients(candidateIDs, func(recipient umodels.User) (string, string, error) {
data := notificationTemplateData(conversation, recipient, author)
data["Message"] = map[string]any{
"UUID": message.UUID,
"Content": htmltemplate.HTML(strings.ReplaceAll(html.EscapeString(messageText), "\n", "<br>")),
}
content, subject, err := m.template.RenderStoredEmailTemplate(group.tmpl, data)
return subject, content, err
})
for i := range recipients {
if recipients[i].Email != nil {
recipients[i].Email.Delay = newReplyEmailDelay
}
}
if _, err := m.dispatcher.Send(nmodels.Notification{
Type: group.nType,
Recipients: recipients,
Title: group.title,
Body: null.StringFrom(cmp.Or(messageText, conversation.Subject.String)),
ConversationID: null.IntFrom(conversation.ID),
MessageID: null.IntFrom(message.ID),
ConversationUUID: conversation.UUID,
MessageUUID: message.UUID,
}); err != nil {
m.lo.Error("error sending reply notification", "type", group.nType, "error", err)
}
}
// NotifyMention sends notifications (in-app, WebSocket, email) for mentions.
// For team mentions, expands to all team members.
func (m *Manager) NotifyMention(conversationUUID string, message models.Message, mentions []models.MentionInput, mentionedByUserID int) {
@@ -1279,7 +1386,7 @@ func (m *Manager) NotifyMention(conversationUUID string, message models.Message,
}
// Get the user who made the mention.
author, err := m.userStore.GetAgent(mentionedByUserID, "")
author, err := m.userStore.GetAgentCachedOrLoad(mentionedByUserID)
if err != nil {
m.lo.Error("error fetching author for mention notification", "user_id", mentionedByUserID, "error", err)
return
@@ -1317,61 +1424,43 @@ func (m *Manager) NotifyMention(conversationUUID string, message models.Message,
if err != nil {
m.lo.Error("error fetching root URL for mention notification", "error", err)
}
messageContent := absolutizeConversationReferenceLinks(message.Content, rootURL)
messageContent := htmltemplate.HTML(absolutizeConversationReferenceLinks(message.Content, rootURL))
recipientIDs, emails := m.buildRecipientEmails(userIDs, func(recipient umodels.User) (string, string, error) {
content, subject, err := m.template.RenderStoredEmailTemplate(template.TmplMentioned,
map[string]any{
"Conversation": map[string]any{
"ReferenceNumber": conversation.ReferenceNumber,
"Subject": conversation.Subject.String,
"Priority": conversation.Priority.String,
"UUID": conversation.UUID,
},
"Recipient": map[string]any{
"FirstName": recipient.FirstName,
"LastName": recipient.LastName,
"FullName": recipient.FullName(),
"Email": recipient.Email.String,
},
"Message": map[string]any{
"UUID": message.UUID,
"Content": messageContent,
},
"MentionedBy": map[string]any{
"FirstName": author.FirstName,
"LastName": author.LastName,
"FullName": author.FullName(),
"Email": author.Email.String,
},
// Automated messages do not have an author.
"Author": map[string]any{
"FirstName": "",
"LastName": "",
"FullName": "",
"Email": "",
},
})
recipients := m.buildNotificationRecipients(userIDs, func(recipient umodels.User) (string, string, error) {
data := notificationTemplateData(conversation, recipient, umodels.User{})
data["Message"] = map[string]any{
"UUID": message.UUID,
"Content": messageContent,
}
data["MentionedBy"] = map[string]any{
"FirstName": author.FirstName,
"LastName": author.LastName,
"FullName": author.FullName(),
"Email": author.Email.String,
}
content, subject, err := m.template.RenderStoredEmailTemplate(template.TmplMentioned, data)
return subject, content, err
})
if len(recipientIDs) == 0 {
if len(recipients) == 0 {
return
}
// Send notification via dispatcher (handles in-app, WebSocket, and email).
m.dispatcher.SendWithEmails(notifier.Notification{
if _, err := m.dispatcher.Send(nmodels.Notification{
Type: nmodels.NotificationTypeMention,
RecipientIDs: recipientIDs,
Recipients: recipients,
Title: m.i18n.Ts("notification.mentionedInConversation", "author", author.FullName(), "referenceNumber", conversation.ReferenceNumber),
Body: null.StringFrom(message.TextContent),
ConversationID: null.IntFrom(conversation.ID),
MessageID: null.IntFrom(message.ID),
ActorID: null.IntFrom(mentionedByUserID),
ConversationUUID: conversation.UUID,
MessageUUID: message.UUID,
ActorFirstName: author.FirstName,
ActorLastName: author.LastName,
}, emails)
}); err != nil {
m.lo.Error("error sending mention notification", "error", err)
}
}
// UnassignOpen unassigns all open conversations belonging to a user.
@@ -1542,83 +1631,53 @@ func (m *Manager) notifyAutomation(subject, message string, entries []string, co
userIDs = userIDs[:amodels.MaxNotifyRecipients]
}
recipientIDs, emails := m.buildRecipientEmails(userIDs, func(recipient umodels.User) (string, string, error) {
content, err := m.template.RenderEmailWithTemplate(
map[string]any{
"Conversation": map[string]any{
"ReferenceNumber": conv.ReferenceNumber,
"Subject": conv.Subject.String,
"Priority": conv.Priority.String,
"UUID": conv.UUID,
},
"Recipient": map[string]any{
"FirstName": recipient.FirstName,
"LastName": recipient.LastName,
"FullName": recipient.FullName(),
"Email": recipient.Email.String,
},
"Contact": map[string]any{
"FirstName": conv.Contact.FirstName,
"LastName": conv.Contact.LastName,
"FullName": conv.Contact.FullName(),
"Email": conv.Contact.Email.String,
},
// Automated messages do not have an author.
"Author": map[string]any{
"FirstName": "",
"LastName": "",
"FullName": "",
"Email": "",
},
"Message": message,
},
automationNotifyEmailContent)
recipients := m.buildNotificationRecipients(userIDs, func(recipient umodels.User) (string, string, error) {
data := notificationTemplateData(conv, recipient, umodels.User{})
data["Message"] = message
content, err := m.template.RenderEmailWithTemplate(data, automationNotifyEmailContent)
return subject, content, err
})
m.dispatcher.SendWithEmails(notifier.Notification{
Type: nmodels.NotificationTypeMention,
RecipientIDs: recipientIDs,
if _, err := m.dispatcher.Send(nmodels.Notification{
Type: nmodels.NotificationTypeAutomation,
Recipients: recipients,
Title: subject,
Body: null.StringFrom(message),
ConversationID: null.IntFrom(conv.ID),
ConversationUUID: conv.UUID,
}, emails)
}); err != nil {
return fmt.Errorf("sending automation notification: %w", err)
}
return nil
}
// buildRecipientEmails fetches each recipient and renders their email, returning emails index-aligned with recipient IDs; on fetch or render failure the in-app notification still goes out with a zero-value email.
func (m *Manager) buildRecipientEmails(userIDs []int, render func(recipient umodels.User) (subject, content string, err error)) ([]int, []notifier.EmailNotification) {
var (
recipientIDs []int
emails []notifier.EmailNotification
)
func (m *Manager) buildNotificationRecipients(userIDs []int, render func(recipient umodels.User) (subject, content string, err error)) []nmodels.Recipient {
recipients := make([]nmodels.Recipient, 0, len(userIDs))
for _, id := range userIDs {
recipientIDs = append(recipientIDs, id)
delivery := nmodels.Recipient{UserID: id}
recipient, err := m.userStore.GetAgentCachedOrLoad(id)
if err != nil {
m.lo.Error("error fetching agent for email notification", "user_id", id, "error", err)
emails = append(emails, notifier.EmailNotification{})
recipients = append(recipients, delivery)
continue
}
var email notifier.EmailNotification
if recipient.Email.String != "" {
subject, content, err := render(recipient)
if err != nil {
m.lo.Error("error rendering email notification", "user_id", id, "error", err)
} else {
email = notifier.EmailNotification{
Recipients: []string{recipient.Email.String},
Subject: subject,
Content: content,
delivery.Email = &nmodels.EmailNotification{
Recipient: recipient.Email.String,
Subject: subject,
Content: content,
}
}
}
emails = append(emails, email)
recipients = append(recipients, delivery)
}
return recipientIDs, emails
return recipients
}
func (m *Manager) resolveNotifyRecipients(entries []string, conv models.Conversation) []int {
@@ -1956,7 +2015,7 @@ func (m *Manager) BuildWidgetConversationView(conversation models.Conversation)
// Fetch assignee details if assigned
if conversation.AssignedUserID.Int > 0 {
assignee, err := m.userStore.GetAgent(conversation.AssignedUserID.Int, "")
assignee, err := m.userStore.GetAgentCachedOrLoad(conversation.AssignedUserID.Int)
if err != nil {
m.lo.Error("error fetching conversation assignee for widget", "conversation_uuid", conversation.UUID, "error", err)
} else {
@@ -2253,6 +2312,35 @@ func renderTagFilter(operator, value string, paramIndex int) (string, []any, err
}
}
func notificationTemplateData(conversation models.Conversation, recipient, author umodels.User) map[string]any {
return map[string]any{
"Conversation": map[string]any{
"ReferenceNumber": conversation.ReferenceNumber,
"Subject": conversation.Subject.String,
"Priority": conversation.Priority.String,
"UUID": conversation.UUID,
},
"Contact": map[string]any{
"FirstName": conversation.Contact.FirstName,
"LastName": conversation.Contact.LastName,
"FullName": conversation.Contact.FullName(),
"Email": conversation.Contact.Email.String,
},
"Recipient": map[string]any{
"FirstName": recipient.FirstName,
"LastName": recipient.LastName,
"FullName": recipient.FullName(),
"Email": recipient.Email.String,
},
"Author": map[string]any{
"FirstName": author.FirstName,
"LastName": author.LastName,
"FullName": author.FullName(),
"Email": author.Email.String,
},
}
}
// appendListTypeConditions returns the SQL conditions for the list types, appending their bind parameters to args.
func appendListTypeConditions(listTypes []string, viewingUserID, userID int, teamIDs []int, args *[]any) ([]string, error) {
conditions := make([]string, 0, len(listTypes))
@@ -2308,3 +2396,11 @@ func listTypeWhereClause(conditions []string) string {
}
return "AND (" + strings.Join(conditions, " OR ") + ")"
}
func replyNotificationText(message models.Message) string {
full := strings.TrimSpace(message.TextContent)
if message.ContentType == models.ContentTypeHTML {
return cmp.Or(stringutil.HTML2TextNoQuotes(message.Content), full)
}
return cmp.Or(stringutil.TrimPlainTextQuotes(full), full)
}
@@ -1,6 +1,8 @@
package conversation
import "strings"
import (
"strings"
)
func absolutizeConversationReferenceLinks(content, rootURL string) string {
if rootURL == "" {
+11 -5
View File
@@ -496,7 +496,7 @@ func (m *Manager) CreateContactMessage(media []mmodels.Media, contactID int, con
}
// Process post-message hooks (reopen, waiting since, automation, SLA).
if err := m.ProcessIncomingMessageHooks(conversationUUID, isNewConversation); err != nil {
if err := m.ProcessIncomingMessageHooks(message, isNewConversation); err != nil {
m.lo.Error("error processing incoming message hooks", "conversation_uuid", conversationUUID, "error", err)
}
@@ -893,7 +893,7 @@ func (m *Manager) ProcessIncomingMessage(in models.IncomingMessage) (models.Mess
m.broadcastMessageToWidgetClients(&msg)
// Process post-message hooks (automation rules, webhooks, SLA, etc.).
if err := m.ProcessIncomingMessageHooks(msg.ConversationUUID, isNewConversation); err != nil {
if err := m.ProcessIncomingMessageHooks(msg, isNewConversation); err != nil {
m.lo.Error("error processing incoming message hooks", "conversation_uuid", msg.ConversationUUID, "error", err)
return models.Message{}, fmt.Errorf("processing incoming message hooks: %w", err)
}
@@ -1015,7 +1015,7 @@ func (m *Manager) ProcessIncomingLiveChatMessage(msg models.Message) (models.Mes
// Process post-message hooks (automation rules, webhooks, SLA, etc.).
// isNewConversation = false since conversation always exists for live chat.
if err := m.ProcessIncomingMessageHooks(msg.ConversationUUID, false); err != nil {
if err := m.ProcessIncomingMessageHooks(msg, false); err != nil {
m.lo.Error("error processing incoming message hooks", "conversation_uuid", msg.ConversationUUID, "error", err)
}
@@ -1386,7 +1386,9 @@ func (m *Manager) uploadThumbnailForMedia(media mmodels.Media, content []byte) e
// ProcessIncomingMessageHooks handles automation rules, webhooks, SLA events, and other post-processing
// for incoming messages. This allows other channels to insert messages first and then call this
// function to trigger the necessary hooks.
func (m *Manager) ProcessIncomingMessageHooks(conversationUUID string, isNewConversation bool) error {
func (m *Manager) ProcessIncomingMessageHooks(message models.Message, isNewConversation bool) error {
conversationUUID := message.ConversationUUID
// Start waiting since clock, cleared when agent replies.
m.StartConversationWaitingSince(conversationUUID, time.Now())
@@ -1407,11 +1409,13 @@ func (m *Manager) ProcessIncomingMessageHooks(conversationUUID string, isNewConv
}
// Reopen conversation if it's not Open.
var reopened bool
systemUser, err := m.userStore.GetSystemUser()
if err != nil {
m.lo.Error("error fetching system user", "error", err)
} else {
if err := m.ReOpenConversation(conversationUUID, systemUser); err != nil {
var err error
if reopened, err = m.ReOpenConversation(conversationUUID, systemUser); err != nil {
m.lo.Error("error reopening conversation", "error", err)
}
}
@@ -1425,6 +1429,8 @@ func (m *Manager) ProcessIncomingMessageHooks(conversationUUID string, isNewConv
// Trigger automations on incoming message event.
m.automation.EvaluateConversationUpdateRules(conversation, amodels.EventConversationMessageIncoming, previousValues, umodels.User{ID: conversation.ContactID})
go m.NotifyNewReply(conversation, message, reopened)
// If assigned to an AI assistant, let it respond to this inbound customer message.
if m.aiAgent != nil && conversation.AssignedUserID.Valid {
m.aiAgent.HandleConversationEvent(conversation.ID, conversation.AssignedUserID.Int)
+321
View File
@@ -0,0 +1,321 @@
package conversation
import (
"errors"
htmltemplate "html/template"
"slices"
"strings"
"sync"
"testing"
"time"
authzmodels "github.com/abhinavxd/libredesk/internal/authz/models"
"github.com/abhinavxd/libredesk/internal/conversation/models"
"github.com/abhinavxd/libredesk/internal/dbutil"
notifier "github.com/abhinavxd/libredesk/internal/notification"
nchannels "github.com/abhinavxd/libredesk/internal/notification/channels"
nmodels "github.com/abhinavxd/libredesk/internal/notification/models"
"github.com/abhinavxd/libredesk/internal/template"
"github.com/abhinavxd/libredesk/internal/testutil"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
"github.com/jmoiron/sqlx"
"github.com/volatiletech/null/v9"
"github.com/zerodha/logf"
)
type replyUserStore struct {
userStore
agent umodels.User
err error
}
type replyPreferences struct {
recipients []int
channels []nmodels.NotificationChannel
err error
mu sync.Mutex
}
type countingPush struct {
count int
}
func (p *countingPush) Send(int, nmodels.PushPayload) bool {
p.count++
return true
}
func (s replyUserStore) Get(int, string, []string) (umodels.User, error) {
return umodels.User{FirstName: "Contact"}, nil
}
func (s replyUserStore) GetAgentCachedOrLoad(int) (umodels.User, error) { return s.agent, s.err }
func (p *replyPreferences) EnabledChannels(ids []int, _ nmodels.NotificationType) (map[int][]nmodels.NotificationChannel, error) {
p.mu.Lock()
defer p.mu.Unlock()
p.recipients = append(p.recipients, ids...)
if p.err != nil {
return nil, p.err
}
enabled := make(map[int][]nmodels.NotificationChannel)
if len(p.channels) > 0 {
for _, id := range ids {
enabled[id] = slices.Clone(p.channels)
}
}
return enabled, nil
}
func TestNotifyNewReplyChecksParticipantAccess(t *testing.T) {
db := testutil.NewDB(t, "reply_access")
var agentID, inboxID int
if err := db.Get(&agentID, `INSERT INTO users (type, email, first_name, last_name) VALUES ('agent', 'participant@example.com', 'Agent', '') RETURNING id`); err != nil {
t.Fatal(err)
}
if err := db.Get(&inboxID, `INSERT INTO inboxes (name, channel) VALUES ('Test', 'email') RETURNING id`); err != nil {
t.Fatal(err)
}
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`, agentID, inboxID); err != nil {
t.Fatal(err)
}
db.MustExec(`INSERT INTO conversation_participants (conversation_id, user_id) VALUES ($1, $2)`, conv.ID, agentID)
var q struct {
Participants *sqlx.Stmt `query:"get-conversation-participant-agents"`
}
if err := dbutil.ScanSQLFile("queries.sql", &q, db, efs); err != nil {
t.Fatal(err)
}
lo := logf.New(logf.Opts{})
for _, tt := range []struct {
name string
permissions []string
enabled bool
err error
want bool
}{
{"former assignee", []string{authzmodels.PermConversationsRead, authzmodels.PermConversationsReadAssigned}, true, nil, false},
{"read all", []string{authzmodels.PermConversationsRead, authzmodels.PermConversationsReadAll}, true, nil, true},
{"missing base permission", []string{authzmodels.PermConversationsReadAll}, true, nil, false},
{"disabled", []string{authzmodels.PermConversationsRead, authzmodels.PermConversationsReadAll}, false, nil, false},
{"load failed", nil, true, errors.New("unavailable"), false},
} {
t.Run(tt.name, func(t *testing.T) {
m := &Manager{
lo: &lo,
userStore: replyUserStore{
agent: umodels.User{ID: agentID, Enabled: tt.enabled, Permissions: tt.permissions},
err: tt.err,
},
}
m.q.GetConversationParticipantAgents = q.Participants
conv.AssignedUserID = null.IntFrom(agentID + 1)
recipients := m.replyNotificationParticipants(conv, agentID+1)
if got := slices.ContainsFunc(recipients, func(recipient umodels.User) bool { return recipient.ID == agentID }); got != tt.want {
t.Fatalf("participant eligible = %v, want %v", got, tt.want)
}
})
}
}
func TestNotifyNewReplyChecksAssigneeAccess(t *testing.T) {
lo := logf.New(logf.Opts{})
for _, tt := range []struct {
name string
enabled bool
permissions []string
want bool
}{
{"assigned access", true, []string{authzmodels.PermConversationsRead, authzmodels.PermConversationsReadAssigned}, true},
{"disabled", false, []string{authzmodels.PermConversationsRead, authzmodels.PermConversationsReadAssigned}, false},
{"missing base permission", true, []string{authzmodels.PermConversationsReadAssigned}, false},
{"missing assignment access", true, []string{authzmodels.PermConversationsRead}, false},
} {
t.Run(tt.name, func(t *testing.T) {
m := &Manager{lo: &lo, userStore: replyUserStore{agent: umodels.User{ID: 42, Enabled: tt.enabled, Permissions: tt.permissions}}}
recipients := m.replyNotificationAssignee(models.Conversation{AssignedUserID: null.IntFrom(42)}, 7)
if got := slices.ContainsFunc(recipients, func(recipient umodels.User) bool { return recipient.ID == 42 }); got != tt.want {
t.Fatalf("assignee eligible = %v, want %v", got, tt.want)
}
})
}
}
func TestReplyNotificationsAlertForEveryMessage(t *testing.T) {
db := testutil.NewDB(t, "reply_notification_history")
var userID, inboxID int
if err := db.Get(&userID, `INSERT INTO users (type, email, first_name, last_name) VALUES ('agent', 'history@example.com', 'Agent', '') RETURNING id`); err != nil {
t.Fatal(err)
}
if err := db.Get(&inboxID, `INSERT INTO inboxes (name, channel) VALUES ('Test', 'email') RETURNING id`); err != nil {
t.Fatal(err)
}
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`, userID, inboxID); err != nil {
t.Fatal(err)
}
var messageID int
if err := db.Get(&messageID, `INSERT INTO conversation_messages (conversation_id, sender_id, sender_type, type, status, content, text_content, created_at) VALUES ($1, $2, 'contact', 'incoming', 'received', 'Old reply', 'Old reply', $3) RETURNING id`, conv.ID, userID, time.Now().Add(-time.Hour)); err != nil {
t.Fatal(err)
}
var q struct {
Participants *sqlx.Stmt `query:"get-conversation-participant-agents"`
}
if err := dbutil.ScanSQLFile("queries.sql", &q, db, efs); err != nil {
t.Fatal(err)
}
lo := logf.New(logf.Opts{})
i18n := testutil.NewI18n(t)
inApp, err := notifier.NewUserNotificationManager(notifier.UserNotificationOpts{DB: db, Lo: &lo, I18n: i18n})
if err != nil {
t.Fatal(err)
}
emailQueue, err := notifier.NewEmailQueue(notifier.EmailQueueOpts{DB: db, Lo: &lo})
if err != nil {
t.Fatal(err)
}
templates, err := template.New(&lo, db, nil, nil, htmltemplate.FuncMap{"RootURL": func() string { return "http://localhost" }}, i18n)
if err != nil {
t.Fatal(err)
}
conv.AssignedUserID = null.IntFrom(userID)
newManager := func(channels ...nmodels.NotificationChannel) *Manager {
db.MustExec(`DELETE FROM notification_email_queue`)
db.MustExec(`DELETE FROM user_notifications`)
m := &Manager{lo: &lo, i18n: i18n, template: templates,
userStore: replyUserStore{agent: umodels.User{ID: userID, Email: null.StringFrom("history@example.com"), Enabled: true, Permissions: []string{authzmodels.PermConversationsRead, authzmodels.PermConversationsReadAssigned}}},
dispatcher: notifier.NewDispatcher(notifier.DispatcherOpts{
Pipeline: nchannels.NewPipeline(nchannels.NewInApp(inApp, nil, &lo), nchannels.NewEmail(emailQueue)),
Prefs: &replyPreferences{channels: channels},
}),
}
m.q.GetConversationParticipantAgents = q.Participants
return m
}
notifications := func() int {
var count int
db.Get(&count, `SELECT count(*) FROM user_notifications`)
return count
}
message := models.Message{ID: messageID, SenderID: userID + 1, CreatedAt: time.Now().UTC().Truncate(time.Microsecond)}
t.Run("each reply creates an in-app alert", func(t *testing.T) {
m := newManager(nmodels.NotificationChannelInApp)
m.NotifyNewReply(conv, message, false)
if notifications() != 1 {
t.Fatalf("notifications after first reply = %d, want 1", notifications())
}
next := message
next.CreatedAt = message.CreatedAt.Add(time.Second)
m.NotifyNewReply(conv, next, false)
if notifications() != 2 {
t.Fatalf("notifications after second reply = %d, want 2", notifications())
}
m.NotifyNewReply(conv, next, true)
if notifications() != 3 {
t.Fatalf("notifications after reopened reply = %d, want 3", notifications())
}
})
t.Run("reply notifications remove quoted text", func(t *testing.T) {
m := newManager(nmodels.NotificationChannelInApp, nmodels.NotificationChannelEmail)
replyConversation := conv
replyConversation.Subject = null.StringFrom("Conversation subject")
reply := message
reply.ContentType = models.ContentTypeHTML
reply.Content = `<div>Live chat reply</div><div class="gmail_quote gmail_quote_container"><blockquote>Earlier message</blockquote></div>`
reply.TextContent = "Live chat reply\nEarlier message"
m.NotifyNewReply(replyConversation, reply, false)
var body string
db.Get(&body, `SELECT body FROM user_notifications LIMIT 1`)
if body != "Live chat reply" {
t.Fatalf("notification body = %q, want fresh reply", body)
}
var emailContent string
db.Get(&emailContent, `SELECT content FROM notification_email_queue LIMIT 1`)
if !strings.Contains(emailContent, "Live chat reply") || strings.Contains(emailContent, "Earlier message") {
t.Fatalf("queued email contains quoted history: %q", emailContent)
}
})
t.Run("assignment body falls back to last message", func(t *testing.T) {
m := newManager(nmodels.NotificationChannelInApp)
assigned := conv
assigned.LastMessage = null.StringFrom("Live chat opening message")
if err := m.NotifyAssignment([]int{userID}, assigned); err != nil {
t.Fatal(err)
}
var body string
db.Get(&body, `SELECT body FROM user_notifications LIMIT 1`)
if body != assigned.LastMessage.String {
t.Fatalf("notification body = %q, want %q", body, assigned.LastMessage.String)
}
})
t.Run("replies arriving together alert individually", func(t *testing.T) {
m := newManager(nmodels.NotificationChannelInApp)
var wg sync.WaitGroup
for range 8 {
wg.Go(func() { m.NotifyNewReply(conv, message, false) })
}
wg.Wait()
if notifications() != 8 {
t.Fatalf("notifications from concurrent replies = %d, want 8", notifications())
}
})
t.Run("push alerts for every reply", func(t *testing.T) {
m := newManager(nmodels.NotificationChannelPush)
push := &countingPush{}
m.dispatcher = notifier.NewDispatcher(notifier.DispatcherOpts{
Pipeline: nchannels.NewPipeline(nchannels.NewInApp(inApp, nil, &lo), nchannels.NewEmail(emailQueue), nchannels.NewPush(push)),
Prefs: &replyPreferences{channels: []nmodels.NotificationChannel{nmodels.NotificationChannelPush}},
})
m.NotifyNewReply(conv, message, false)
m.NotifyNewReply(conv, message, false)
if push.count != 2 {
t.Fatalf("push notifications = %d, want 2", push.count)
}
})
t.Run("agent with no channel enabled is not notified", func(t *testing.T) {
m := newManager()
m.NotifyNewReply(conv, message, false)
if notifications() != 0 {
t.Fatalf("notifications = %d, want 0", notifications())
}
})
t.Run("preference failure sends nothing", func(t *testing.T) {
m := newManager()
m.dispatcher = notifier.NewDispatcher(notifier.DispatcherOpts{Prefs: &replyPreferences{err: errors.New("lookup failed")}})
m.NotifyNewReply(conv, message, false)
if notifications() != 0 {
t.Fatalf("notifications = %d, want 0", notifications())
}
})
t.Run("email replies are clubbed in the queue", func(t *testing.T) {
m := newManager(nmodels.NotificationChannelEmail)
m.NotifyNewReply(conv, message, false)
next := message
next.CreatedAt = message.CreatedAt.Add(time.Minute)
m.NotifyNewReply(conv, next, false)
var queued int
db.Get(&queued, `SELECT count(*) FROM notification_email_queue`)
if queued != 1 {
t.Fatalf("queued emails = %d, want 1", queued)
}
})
}
+10
View File
@@ -535,6 +535,16 @@ WHERE conversation_id =
SELECT id FROM conversations WHERE uuid = $1
);
-- name: get-conversation-participant-agents
SELECT users.id, users.first_name, users.last_name, users.email
FROM conversation_participants
INNER JOIN users ON users.id = conversation_participants.user_id
WHERE conversation_participants.conversation_id = (SELECT id FROM conversations WHERE uuid = $1)
AND users.type = 'agent'
AND users.email != 'System'
AND users.enabled
AND users.deleted_at IS NULL;
-- name: insert-conversation-participant
INSERT INTO conversation_participants
(user_id, conversation_id)
+1 -1
View File
@@ -11,7 +11,7 @@ func IsValidHTTPURL(raw string) bool {
if err != nil {
return false
}
return u.Scheme == "http" || u.Scheme == "https"
return (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
}
// IsOriginTrusted checks if the given origin is trusted based on the trusted domains list
+28
View File
@@ -0,0 +1,28 @@
package httputil
import "testing"
func TestIsValidHTTPURL(t *testing.T) {
tests := []struct {
name string
url string
want bool
}{
{name: "HTTPS", url: "https://desk.example.com", want: true},
{name: "HTTP localhost", url: "http://localhost:9000", want: true},
{name: "path", url: "https://desk.example.com/app", want: true},
{name: "empty", url: "", want: false},
{name: "missing host", url: "https:", want: false},
{name: "relative", url: "/app", want: false},
{name: "unsupported scheme", url: "javascript:alert(1)", want: false},
{name: "attribute injection", url: `https://desk.example.com" onclick="alert(1)`, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsValidHTTPURL(tt.url); got != tt.want {
t.Fatalf("IsValidHTTPURL(%q) = %v, want %v", tt.url, got, tt.want)
}
})
}
}
+195 -1
View File
@@ -6,12 +6,206 @@ import (
"github.com/knadh/stuffbin"
)
var notificationTypesV2_9_0 = []string{
"new_reply",
"new_reply_participating",
"sla_first_response_warning",
"sla_first_response_breach",
"sla_next_response_warning",
"sla_next_response_breach",
"sla_resolution_warning",
"sla_resolution_breach",
"conversation_reopened",
"automation",
}
func V2_9_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
_, err := db.Exec(`
if _, err := db.Exec(`
UPDATE roles
SET permissions = array_append(permissions, 'messages:write_private')
WHERE 'messages:write' = ANY(permissions)
AND NOT ('messages:write_private' = ANY(permissions));
`); err != nil {
return err
}
for _, notificationType := range notificationTypesV2_9_0 {
if _, err := db.Exec(`ALTER TYPE user_notification_type ADD VALUE IF NOT EXISTS '` + notificationType + `';`); err != nil {
return err
}
}
if _, err := db.Exec(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'notification_channel') THEN
CREATE TYPE notification_channel AS ENUM ('in_app', 'email', 'push');
END IF;
END$$;
`); err != nil {
return err
}
if _, err := db.Exec(`ALTER TYPE notification_channel ADD VALUE IF NOT EXISTS 'push';`); err != nil {
return err
}
if _, err := db.Exec(`
CREATE TABLE IF NOT EXISTS user_notification_preferences (
id SERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
notification_type user_notification_type NOT NULL,
channel notification_channel NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
CONSTRAINT constraint_uniq_user_notification_preferences UNIQUE (user_id, notification_type, channel)
);
`); err != nil {
return err
}
if _, err := db.Exec(`
CREATE TABLE IF NOT EXISTS notification_push_subscriptions (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL
);
`); err != nil {
return err
}
if _, err := db.Exec(`
CREATE INDEX IF NOT EXISTS index_notification_push_subscriptions_on_user_id ON notification_push_subscriptions(user_id);
`); err != nil {
return err
}
if _, err := db.Exec(`
CREATE TABLE IF NOT EXISTS notification_email_queue (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
notification_id BIGINT REFERENCES user_notifications(id) ON DELETE CASCADE ON UPDATE CASCADE,
notification_type user_notification_type NOT NULL,
conversation_id BIGINT REFERENCES conversations(id) ON DELETE CASCADE ON UPDATE CASCADE,
recipient_email TEXT NOT NULL,
subject TEXT NOT NULL,
content TEXT NOT NULL,
queued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
send_at TIMESTAMPTZ NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
CONSTRAINT constraint_uniq_notification_email_queue UNIQUE (user_id, notification_type, conversation_id)
);
`); err != nil {
return err
}
if _, err := db.Exec(`
CREATE INDEX IF NOT EXISTS index_notification_email_queue_on_send_at ON notification_email_queue(send_at);
`); err != nil {
return err
}
if _, err := db.Exec(`
ALTER TABLE notification_email_queue ADD COLUMN IF NOT EXISTS attempts INTEGER NOT NULL DEFAULT 0;
`); err != nil {
return err
}
if _, err := db.Exec(`
INSERT INTO settings ("key", value) VALUES
('notification.push.vapid_public_key', '""'::jsonb),
('notification.push.vapid_private_key', '""'::jsonb)
ON CONFLICT ("key") DO NOTHING;
`); err != nil {
return err
}
if _, err := db.Exec(`
INSERT INTO templates ("type", body, is_default, "name", subject, is_builtin)
SELECT 'email_notification'::template_type, '
<p>{{ .Author.FullName }} replied to a conversation assigned to you:</p>
<div>
Reference number: {{ .Conversation.ReferenceNumber }} <br>
Subject: {{ .Conversation.Subject }}
</div>
<blockquote style="background-color: #f5f5f5; padding: 12px; margin: 16px 0; border-left: 4px solid #ddd;">
{{ .Message.Content }}
</blockquote>
<p>
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
</p>
<div>
Best regards,<br>
Libredesk
</div>
', false, 'New reply from contact', 'New reply on conversation #{{ .Conversation.ReferenceNumber }}', true
WHERE NOT EXISTS (SELECT 1 FROM templates WHERE "name" = 'New reply from contact');
`); err != nil {
return err
}
if _, err := db.Exec(`
INSERT INTO templates ("type", body, is_default, "name", subject, is_builtin)
SELECT 'email_notification'::template_type, '
<p>{{ .Author.FullName }} replied to a conversation you are participating in:</p>
<div>
Reference number: {{ .Conversation.ReferenceNumber }} <br>
Subject: {{ .Conversation.Subject }}
</div>
<blockquote style="background-color: #f5f5f5; padding: 12px; margin: 16px 0; border-left: 4px solid #ddd;">
{{ .Message.Content }}
</blockquote>
<p>
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
</p>
<div>
Best regards,<br>
Libredesk
</div>
', false, 'New reply on participating conversation', 'New reply on conversation #{{ .Conversation.ReferenceNumber }}', true
WHERE NOT EXISTS (SELECT 1 FROM templates WHERE "name" = 'New reply on participating conversation');
`); err != nil {
return err
}
_, err := db.Exec(`
INSERT INTO templates ("type", body, is_default, "name", subject, is_builtin)
SELECT 'email_notification'::template_type, '
<p>{{ .Author.FullName }} replied and reopened a conversation assigned to you:</p>
<div>
Reference number: {{ .Conversation.ReferenceNumber }} <br>
Subject: {{ .Conversation.Subject }}
</div>
<blockquote style="background-color: #f5f5f5; padding: 12px; margin: 16px 0; border-left: 4px solid #ddd;">
{{ .Message.Content }}
</blockquote>
<p>
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
</p>
<div>
Best regards,<br>
Libredesk
</div>
', false, 'Conversation reopened', 'Conversation #{{ .Conversation.ReferenceNumber }} reopened', true
WHERE NOT EXISTS (SELECT 1 FROM templates WHERE "name" = 'Conversation reopened');
`)
return err
}
+104
View File
@@ -52,3 +52,107 @@ func TestV2_9_0PrivateNotePermissionMigration(t *testing.T) {
}
}
}
func TestV2_9_0NotificationMigration(t *testing.T) {
db := testutil.NewDB(t, "migration_v2_9_0_notifications")
db.MustExec(`
DROP TABLE notification_email_queue;
DROP TABLE notification_push_subscriptions;
DROP TABLE user_notification_preferences;
DROP TYPE notification_channel;
DELETE FROM settings WHERE "key" IN (
'notification.push.vapid_public_key',
'notification.push.vapid_private_key'
);
DELETE FROM templates WHERE "name" IN (
'New reply from contact',
'New reply on participating conversation',
'Conversation reopened'
);
`)
for range 2 {
if err := V2_9_0(db, nil, nil); err != nil {
t.Fatalf("running migration: %v", err)
}
}
for _, table := range []string{
"user_notification_preferences",
"notification_push_subscriptions",
"notification_email_queue",
} {
var exists bool
if err := db.Get(&exists, `SELECT to_regclass($1) IS NOT NULL`, table); err != nil {
t.Fatalf("checking table %q: %v", table, err)
}
if !exists {
t.Errorf("table %q was not created", table)
}
}
var pushChannel bool
if err := db.Get(&pushChannel, `
SELECT EXISTS (
SELECT 1 FROM pg_enum e
JOIN pg_type t ON t.oid = e.enumtypid
WHERE t.typname = 'notification_channel' AND e.enumlabel = 'push'
)
`); err != nil {
t.Fatalf("checking push channel: %v", err)
}
if !pushChannel {
t.Error("push notification channel was not created")
}
var settingCount, templateCount int
if err := db.Get(&settingCount, `SELECT COUNT(*) FROM settings WHERE "key" LIKE 'notification.push.vapid_%'`); err != nil {
t.Fatalf("counting VAPID settings: %v", err)
}
if settingCount != 2 {
t.Errorf("VAPID setting count = %d, want 2", settingCount)
}
if err := db.Get(&templateCount, `
SELECT COUNT(*) FROM templates WHERE "name" IN (
'New reply from contact',
'New reply on participating conversation',
'Conversation reopened'
)
`); err != nil {
t.Fatalf("counting notification templates: %v", err)
}
if templateCount != 3 {
t.Errorf("notification template count = %d, want 3", templateCount)
}
}
func TestV2_9_0PreservesExistingQueuedEmails(t *testing.T) {
db := testutil.NewDB(t, "migration_v2_9_0_queue")
columnQuery := `SELECT data_type || ':' || is_nullable || ':' || COALESCE(column_default, '') FROM information_schema.columns WHERE table_name = 'notification_email_queue' AND column_name = $1`
var expectedAttemptsColumn string
if err := db.Get(&expectedAttemptsColumn, columnQuery, "attempts"); err != nil {
t.Fatal(err)
}
db.MustExec(`ALTER TABLE notification_email_queue DROP COLUMN attempts`)
db.MustExec(`INSERT INTO users (type, email, first_name, last_name) VALUES ('agent', 'queued@example.com', 'Agent', '')`)
db.MustExec(`INSERT INTO notification_email_queue (user_id, notification_type, recipient_email, subject, content, send_at) VALUES ((SELECT id FROM users LIMIT 1), 'new_reply', 'queued@example.com', 'Reply', 'Pending reply', now())`)
for range 2 {
if err := V2_9_0(db, nil, nil); err != nil {
t.Fatal(err)
}
}
var actualAttemptsColumn string
if err := db.Get(&actualAttemptsColumn, columnQuery, "attempts"); err != nil {
t.Fatal(err)
}
if actualAttemptsColumn != expectedAttemptsColumn {
t.Fatalf("attempts column = %q, schema column = %q", actualAttemptsColumn, expectedAttemptsColumn)
}
var preserved bool
if err := db.Get(&preserved, `SELECT content = 'Pending reply' AND attempts = 0 FROM notification_email_queue`); err != nil {
t.Fatal(err)
}
if !preserved {
t.Fatal("existing queued email changed")
}
}
+44
View File
@@ -0,0 +1,44 @@
package channels
import (
"time"
"github.com/abhinavxd/libredesk/internal/notification/models"
)
type EmailQueue interface {
Send(models.Email) bool
SendAfter(models.Email, time.Duration) bool
}
type email struct {
queue EmailQueue
}
func NewEmail(queue EmailQueue) Provider {
return &email{queue: queue}
}
func (p *email) Channel() models.NotificationChannel {
return models.NotificationChannelEmail
}
func (p *email) Send(delivery Delivery) Result {
notification := delivery.Recipient.Email
if notification == nil || notification.Recipient == "" {
return Result{}
}
message := models.Email{
UserID: delivery.Recipient.UserID,
NotificationID: delivery.NotificationID,
Type: delivery.Notification.Type,
ConversationID: delivery.Notification.ConversationID,
Recipient: notification.Recipient,
Subject: notification.Subject,
Content: notification.Content,
}
if notification.Delay > 0 {
return Result{Sent: p.queue.SendAfter(message, notification.Delay)}
}
return Result{Sent: p.queue.Send(message)}
}
@@ -0,0 +1,63 @@
package channels
import (
"testing"
"time"
"github.com/abhinavxd/libredesk/internal/notification/models"
)
type emailQueueStub struct {
sent []models.Email
delayed []models.Email
delay time.Duration
}
func (q *emailQueueStub) Send(email models.Email) bool {
q.sent = append(q.sent, email)
return true
}
func (q *emailQueueStub) SendAfter(email models.Email, delay time.Duration) bool {
q.delayed = append(q.delayed, email)
q.delay = delay
return true
}
func TestEmailProviderSkipsMissingRecipient(t *testing.T) {
for _, email := range []*models.EmailNotification{nil, {}} {
queue := &emailQueueStub{}
result := NewEmail(queue).Send(Delivery{Recipient: models.Recipient{UserID: 42, Email: email}})
if result.Sent || len(queue.sent) != 0 || len(queue.delayed) != 0 {
t.Fatalf("unexpected delivery: result=%#v queue=%#v", result, queue)
}
}
}
func TestEmailProviderSendsImmediately(t *testing.T) {
queue := &emailQueueStub{}
result := NewEmail(queue).Send(Delivery{
Recipient: models.Recipient{UserID: 42, Email: &models.EmailNotification{
Recipient: "agent@example.com",
Subject: "Assigned",
Content: "Conversation assigned",
}},
Notification: models.Notification{Type: models.NotificationTypeAssignment},
})
if !result.Sent || len(queue.sent) != 1 || queue.sent[0].Recipient != "agent@example.com" {
t.Fatalf("unexpected delivery: result=%#v queue=%#v", result, queue)
}
}
func TestEmailProviderSchedulesDelay(t *testing.T) {
queue := &emailQueueStub{}
result := NewEmail(queue).Send(Delivery{
Recipient: models.Recipient{UserID: 42, Email: &models.EmailNotification{
Recipient: "agent@example.com",
Delay: time.Minute,
}},
})
if !result.Sent || len(queue.delayed) != 1 || queue.delay != time.Minute {
t.Fatalf("unexpected delivery: result=%#v queue=%#v", result, queue)
}
}
+62
View File
@@ -0,0 +1,62 @@
package channels
import (
"encoding/json"
"github.com/abhinavxd/libredesk/internal/notification/models"
wsmodels "github.com/abhinavxd/libredesk/internal/ws/models"
"github.com/volatiletech/null/v9"
"github.com/zerodha/logf"
)
type InAppStore interface {
Create(int, models.NotificationType, string, null.String, null.Int, null.Int, null.Int, json.RawMessage) (models.UserNotification, error)
}
type WSHub interface {
BroadcastMessage(msg wsmodels.BroadcastMessage)
}
type inApp struct {
store InAppStore
wsHub WSHub
lo *logf.Logger
}
func NewInApp(store InAppStore, wsHub WSHub, lo *logf.Logger) Provider {
return &inApp{store: store, wsHub: wsHub, lo: lo}
}
func (p *inApp) Channel() models.NotificationChannel {
return models.NotificationChannelInApp
}
func (p *inApp) Send(delivery Delivery) Result {
n := delivery.Notification
notification, err := p.store.Create(delivery.Recipient.UserID, n.Type, n.Title, n.Body,
n.ConversationID, n.MessageID, n.ActorID, n.Meta)
if err != nil {
p.lo.Error("error creating in-app notification", "recipient_id", delivery.Recipient.UserID,
"type", n.Type, "error", err)
return Result{}
}
notification.ConversationUUID = null.StringFrom(n.ConversationUUID)
notification.MessageUUID = null.StringFrom(n.MessageUUID)
notification.ActorFirstName = null.StringFrom(n.ActorFirstName)
notification.ActorLastName = null.StringFrom(n.ActorLastName)
p.broadcast(delivery.Recipient.UserID, notification)
return Result{Sent: true, NotificationID: null.IntFrom(notification.ID)}
}
func (p *inApp) broadcast(userID int, notification any) {
if p.wsHub == nil {
return
}
message := wsmodels.Message{Type: wsmodels.MessageTypeNewNotification, Data: notification}
data, err := json.Marshal(message)
if err != nil {
p.lo.Error("error marshalling notification for Websocket", "error", err)
return
}
p.wsHub.BroadcastMessage(wsmodels.BroadcastMessage{Data: data, Users: []int{userID}})
}
+46
View File
@@ -0,0 +1,46 @@
package channels
import (
"fmt"
"github.com/abhinavxd/libredesk/internal/notification/models"
)
type PushSender interface {
Send(userID int, payload models.PushPayload) bool
}
type push struct {
sender PushSender
}
func NewPush(sender PushSender) Provider {
return &push{sender: sender}
}
func (p *push) Channel() models.NotificationChannel {
return models.NotificationChannelPush
}
func (p *push) Send(delivery Delivery) Result {
n := delivery.Notification
sent := p.sender.Send(delivery.Recipient.UserID, models.PushPayload{
Title: n.Title,
Body: n.Body.String,
Tag: fmt.Sprintf("%s_%s", n.Type, n.ConversationUUID),
URL: pushRoute(n.Type, n.ConversationUUID, n.MessageUUID),
})
return Result{Sent: sent}
}
func pushRoute(notificationType models.NotificationType, conversationUUID, messageUUID string) string {
list := "assigned"
if notificationType == models.NotificationTypeMention {
list = "mentioned"
}
route := fmt.Sprintf("/inboxes/%s/conversation/%s", list, conversationUUID)
if messageUUID != "" {
route += "?scrollTo=" + messageUUID
}
return route
}
+67
View File
@@ -0,0 +1,67 @@
package channels
import (
"cmp"
"slices"
"github.com/abhinavxd/libredesk/internal/notification/models"
"github.com/volatiletech/null/v9"
)
type Provider interface {
Channel() models.NotificationChannel
Send(Delivery) Result
}
type Delivery struct {
Recipient models.Recipient
Notification models.Notification
NotificationID null.Int
}
type Result struct {
Sent bool
NotificationID null.Int
}
type Pipeline struct {
providers []Provider
}
func NewPipeline(providers ...Provider) Pipeline {
providers = slices.Clone(providers)
slices.SortStableFunc(providers, func(a, b Provider) int {
return cmp.Compare(channelOrder(a.Channel()), channelOrder(b.Channel()))
})
return Pipeline{providers: providers}
}
func (p Pipeline) Send(delivery Delivery, enabled []models.NotificationChannel) []models.NotificationChannel {
var sent []models.NotificationChannel
for _, provider := range p.providers {
if !slices.Contains(enabled, provider.Channel()) {
continue
}
result := provider.Send(delivery)
if result.Sent {
sent = append(sent, provider.Channel())
}
if result.NotificationID.Valid {
delivery.NotificationID = result.NotificationID
}
}
return sent
}
func channelOrder(channel models.NotificationChannel) int {
switch channel {
case models.NotificationChannelInApp:
return 0
case models.NotificationChannelEmail:
return 1
case models.NotificationChannelPush:
return 2
default:
return 3
}
}
+25 -143
View File
@@ -1,169 +1,51 @@
package notifier
import (
"encoding/json"
"fmt"
"github.com/abhinavxd/libredesk/internal/notification/channels"
"github.com/abhinavxd/libredesk/internal/notification/models"
wsmodels "github.com/abhinavxd/libredesk/internal/ws/models"
"github.com/volatiletech/null/v9"
"github.com/zerodha/logf"
)
// WSHub defines the interface for the Websocket hub.
type WSHub interface {
BroadcastMessage(msg wsmodels.BroadcastMessage)
type NotificationPreferenceStore interface {
EnabledChannels(recipientIDs []int, nType models.NotificationType) (map[int][]models.NotificationChannel, error)
}
// Notification represents a notification to be sent through all channels.
type Notification struct {
// Core notification fields
Type models.NotificationType
RecipientIDs []int
Title string
Body null.String
ConversationID null.Int
MessageID null.Int
ActorID null.Int
Meta json.RawMessage
// For Websocket broadcast
ConversationUUID string
ActorFirstName string
ActorLastName string
// Email fields (optional - if empty, no email sent)
Email *EmailNotification
}
// EmailNotification holds email channel notification details.
type EmailNotification struct {
Recipients []string
Subject string
Content string
}
// Dispatcher coordinates sending notifications through multiple channels: WS, DB, email.
type Dispatcher struct {
inApp *UserNotificationManager
outbound *Service
wsHub WSHub
emailEnabled bool
lo *logf.Logger
pipeline channels.Pipeline
prefs NotificationPreferenceStore
}
// DispatcherOpts contains options for creating a new Dispatcher.
type DispatcherOpts struct {
InApp *UserNotificationManager
Outbound *Service
WSHub WSHub
EmailEnabled bool
Lo *logf.Logger
Pipeline channels.Pipeline
Prefs NotificationPreferenceStore
}
// NewDispatcher creates a new notification Dispatcher.
func NewDispatcher(opts DispatcherOpts) *Dispatcher {
return &Dispatcher{
inApp: opts.InApp,
outbound: opts.Outbound,
wsHub: opts.WSHub,
emailEnabled: opts.EmailEnabled,
lo: opts.Lo,
}
return &Dispatcher{pipeline: opts.Pipeline, prefs: opts.Prefs}
}
// Send sends a notification through all configured channels.
// For each recipient: creates in-app notification (DB), broadcasts via Websocket,
// and sends email if Email field is provided.
func (d *Dispatcher) Send(n Notification) {
for i, recipientID := range n.RecipientIDs {
d.sendToRecipient(recipientID, n)
if d.outbound != nil && n.Email != nil && d.emailEnabled {
var email string
if i < len(n.Email.Recipients) {
email = n.Email.Recipients[i]
} else if len(n.Email.Recipients) == 1 {
email = n.Email.Recipients[0] // Broadcast mode
}
if email != "" {
d.sendEmail(recipientID, email, n.Email.Subject, n.Email.Content, n.Type)
}
}
func (d *Dispatcher) Send(n models.Notification) ([]models.DeliveryResult, error) {
recipientIDs := make([]int, len(n.Recipients))
for i, recipient := range n.Recipients {
recipientIDs[i] = recipient.UserID
}
}
// SendWithEmails sends notifications where each recipient has their own email content.
// This is useful when email content is personalized per recipient.
func (d *Dispatcher) SendWithEmails(n Notification, emails []EmailNotification) {
for i, recipientID := range n.RecipientIDs {
d.sendToRecipient(recipientID, n)
if d.outbound != nil && i < len(emails) && len(emails[i].Recipients) > 0 && d.emailEnabled {
e := emails[i]
d.sendEmail(recipientID, e.Recipients[0], e.Subject, e.Content, n.Type)
}
}
}
// sendToRecipient creates in-app notification and broadcasts via Websocket.
// Returns the created notification or nil if creation failed.
func (d *Dispatcher) sendToRecipient(recipientID int, n Notification) *models.UserNotification {
notification, err := d.inApp.Create(
recipientID,
n.Type,
n.Title,
n.Body,
n.ConversationID,
n.MessageID,
n.ActorID,
n.Meta,
)
enabled, err := d.prefs.EnabledChannels(recipientIDs, n.Type)
if err != nil {
d.lo.Error("error creating in-app notification",
"recipient_id", recipientID,
"type", n.Type,
"error", err)
return nil
return nil, fmt.Errorf("fetching notification preferences: %w", err)
}
notification.ConversationUUID = null.StringFrom(n.ConversationUUID)
notification.ActorFirstName = null.StringFrom(n.ActorFirstName)
notification.ActorLastName = null.StringFrom(n.ActorLastName)
d.broadcastNotification([]int{recipientID}, notification)
return &notification
}
// sendEmail sends an email notification through the outbound service.
func (d *Dispatcher) sendEmail(recipientID int, email, subject, content string, nType models.NotificationType) {
if err := d.outbound.Send(Message{
RecipientEmails: []string{email},
Subject: subject,
Content: content,
Provider: ProviderEmail,
}); err != nil {
d.lo.Error("error sending email notification",
"recipient_id", recipientID,
"email", email,
"type", nType,
"error", err)
results := make([]models.DeliveryResult, 0, len(n.Recipients))
for _, recipient := range n.Recipients {
results = append(results, models.DeliveryResult{
RecipientID: recipient.UserID,
Channels: d.pipeline.Send(
channels.Delivery{Recipient: recipient, Notification: n},
enabled[recipient.UserID],
),
})
}
}
// broadcastNotification broadcasts a notification via Websocket to specified users.
func (d *Dispatcher) broadcastNotification(userIDs []int, notification any) {
if d.wsHub == nil {
return
}
message := wsmodels.Message{
Type: wsmodels.MessageTypeNewNotification,
Data: notification,
}
msgB, err := json.Marshal(message)
if err != nil {
d.lo.Error("error marshalling notification for Websocket", "error", err)
return
}
d.wsHub.BroadcastMessage(wsmodels.BroadcastMessage{
Data: msgB,
Users: userIDs,
})
return results, nil
}
+140
View File
@@ -0,0 +1,140 @@
package notifier
import (
"errors"
"slices"
"testing"
"github.com/abhinavxd/libredesk/internal/notification/channels"
"github.com/abhinavxd/libredesk/internal/notification/models"
"github.com/volatiletech/null/v9"
)
type fakePreferences struct {
channels map[int][]models.NotificationChannel
err error
}
func (p fakePreferences) EnabledChannels([]int, models.NotificationType) (map[int][]models.NotificationChannel, error) {
return p.channels, p.err
}
type fakePushSender struct {
userID int
payload models.PushPayload
}
type fakeChannelProvider struct {
channel models.NotificationChannel
result channels.Result
notificationID null.Int
}
func (p *fakePushSender) Send(userID int, payload models.PushPayload) bool {
p.userID = userID
p.payload = payload
return true
}
func (p *fakeChannelProvider) Channel() models.NotificationChannel {
return p.channel
}
func (p *fakeChannelProvider) Send(delivery channels.Delivery) channels.Result {
p.notificationID = delivery.NotificationID
return p.result
}
func TestDispatcherPassesResultsThroughProviders(t *testing.T) {
inApp := &fakeChannelProvider{
channel: models.NotificationChannelInApp,
result: channels.Result{Sent: true, NotificationID: null.IntFrom(7)},
}
email := &fakeChannelProvider{channel: models.NotificationChannelEmail}
d := NewDispatcher(DispatcherOpts{
Pipeline: channels.NewPipeline(email, inApp),
Prefs: fakePreferences{channels: map[int][]models.NotificationChannel{
42: {models.NotificationChannelInApp, models.NotificationChannelEmail},
}},
})
results, err := d.Send(models.Notification{Type: models.NotificationTypeMention, Recipients: []models.Recipient{{UserID: 42}}})
if err != nil {
t.Fatal(err)
}
if email.notificationID.Int != 7 {
t.Fatalf("email notification ID = %d, want 7", email.notificationID.Int)
}
if len(results) != 1 || !slices.Equal(results[0].Channels, []models.NotificationChannel{models.NotificationChannelInApp}) {
t.Fatalf("delivery results = %#v", results)
}
}
func TestDispatcherSendsPushUsingNotificationRoute(t *testing.T) {
push := &fakePushSender{}
d := NewDispatcher(DispatcherOpts{
Prefs: fakePreferences{channels: map[int][]models.NotificationChannel{
42: {models.NotificationChannelPush},
}},
Pipeline: channels.NewPipeline(channels.NewPush(push)),
})
d.Send(models.Notification{
Type: models.NotificationTypeMention,
Recipients: []models.Recipient{{UserID: 42}},
Title: "You were mentioned",
Body: null.StringFrom("A teammate mentioned you"),
ConversationUUID: "conversation-uuid",
MessageUUID: "message-uuid",
})
if push.userID != 42 {
t.Fatalf("sent to user %d, want 42", push.userID)
}
if push.payload.URL != "/inboxes/mentioned/conversation/conversation-uuid?scrollTo=message-uuid" {
t.Fatalf("push URL = %q", push.payload.URL)
}
if push.payload.Title != "You were mentioned" || push.payload.Body != "A teammate mentioned you" {
t.Fatalf("unexpected push payload: %#v", push.payload)
}
}
func TestDispatcherDoesNotSendPushWhenDisabled(t *testing.T) {
push := &fakePushSender{}
d := NewDispatcher(DispatcherOpts{
Prefs: fakePreferences{channels: map[int][]models.NotificationChannel{
42: nil,
}},
Pipeline: channels.NewPipeline(channels.NewPush(push)),
})
d.Send(models.Notification{
Type: models.NotificationTypeMention,
Recipients: []models.Recipient{{UserID: 42}},
Title: "You were mentioned",
})
if push.userID != 0 {
t.Fatalf("sent push to user %d when push is disabled", push.userID)
}
}
func TestDispatcherReturnsPreferenceLookupFailure(t *testing.T) {
lookupErr := errors.New("lookup failed")
push := &fakePushSender{}
d := NewDispatcher(DispatcherOpts{
Prefs: fakePreferences{err: lookupErr},
Pipeline: channels.NewPipeline(channels.NewPush(push)),
})
_, err := d.Send(models.Notification{
Type: models.NotificationTypeMention,
Recipients: []models.Recipient{{UserID: 42}},
Title: "You were mentioned",
})
if !errors.Is(err, lookupErr) {
t.Fatalf("error = %v, want %v", err, lookupErr)
}
if push.userID != 0 {
t.Fatalf("sent push to user %d after preference lookup failed", push.userID)
}
}
+174
View File
@@ -0,0 +1,174 @@
package notifier
import (
"context"
"time"
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/notification/models"
"github.com/jmoiron/sqlx"
"github.com/volatiletech/null/v9"
"github.com/zerodha/logf"
)
const (
emailQueueTick = 15 * time.Second
emailQueueBatch = 500
emailQueueClaimLease = 5 * time.Minute
emailQueueMaxAttempts = 3
emailQueueRetryDelay = time.Minute
)
type EmailSender interface {
Send(Message) error
SendSync(Message) error
}
type emailQueueQueries struct {
IsSeen *sqlx.Stmt `query:"is-notification-seen"`
Enqueue *sqlx.Stmt `query:"enqueue-notification-email"`
Claim *sqlx.Stmt `query:"claim-due-notification-emails"`
Delete *sqlx.Stmt `query:"delete-claimed-notification-email"`
Retry *sqlx.Stmt `query:"retry-claimed-notification-email"`
}
type queuedEmail struct {
ID int64 `db:"id"`
ClaimedAt time.Time `db:"updated_at"`
UserID int `db:"user_id"`
NotificationID null.Int `db:"notification_id"`
Type models.NotificationType `db:"notification_type"`
ConversationID null.Int `db:"conversation_id"`
Attempts int `db:"attempts"`
Recipient string `db:"recipient_email"`
Subject string `db:"subject"`
Content string `db:"content"`
QueuedAt time.Time `db:"queued_at"`
}
type EmailQueue struct {
q emailQueueQueries
outbound EmailSender
lo *logf.Logger
}
type EmailQueueOpts struct {
DB *sqlx.DB
Outbound EmailSender
Lo *logf.Logger
}
func NewEmailQueue(opts EmailQueueOpts) (*EmailQueue, error) {
var q emailQueueQueries
if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, queriesFS); err != nil {
return nil, err
}
return &EmailQueue{
q: q,
outbound: opts.Outbound,
lo: opts.Lo,
}, nil
}
func (q *EmailQueue) Send(e models.Email) bool {
if err := q.outbound.Send(Message{
RecipientEmails: []string{e.Recipient},
Subject: e.Subject,
Content: e.Content,
Provider: ProviderEmail,
}); err != nil {
q.lo.Error("error sending notification email", "user_id", e.UserID, "type", e.Type, "error", err)
return false
}
return true
}
func (q *EmailQueue) SendAfter(e models.Email, delay time.Duration) bool {
if _, err := q.q.Enqueue.Exec(e.UserID, e.NotificationID, e.Type, e.ConversationID, e.Recipient,
e.Subject, e.Content, time.Now().Add(delay)); err != nil {
q.lo.Error("error queueing notification email", "user_id", e.UserID, "type", e.Type, "error", err)
return false
}
return true
}
func (q *EmailQueue) Run(ctx context.Context) {
ticker := time.NewTicker(emailQueueTick)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
for _, e := range q.due() {
seen, err := q.seen(e)
if err != nil {
continue
}
if seen {
q.delete(e)
continue
}
q.deliver(e)
}
}
}
}
func (q *EmailQueue) due() []queuedEmail {
var due []queuedEmail
if err := q.q.Claim.Select(&due, emailQueueBatch, time.Now().Add(emailQueueClaimLease)); err != nil {
q.lo.Error("error claiming due notification emails", "error", err)
return nil
}
return due
}
func (q *EmailQueue) seen(e queuedEmail) (bool, error) {
var seen bool
if err := q.q.IsSeen.Get(&seen, e.UserID, e.NotificationID, e.ConversationID, e.QueuedAt); err != nil {
q.lo.Error("error checking notification seen state", "user_id", e.UserID, "type", e.Type, "error", err)
return false, err
}
return seen, nil
}
func (q *EmailQueue) deliver(e queuedEmail) bool {
if err := q.outbound.SendSync(Message{
RecipientEmails: []string{e.Recipient},
Subject: e.Subject,
Content: e.Content,
Provider: ProviderEmail,
}); err != nil {
q.lo.Error("error delivering notification email", "user_id", e.UserID, "type", e.Type, "error", err)
if e.Attempts+1 >= emailQueueMaxAttempts {
q.delete(e)
} else {
q.retry(e)
}
return false
}
q.delete(e)
return true
}
func (q *EmailQueue) delete(e queuedEmail) bool {
result, err := q.q.Delete.Exec(e.ID, e.ClaimedAt)
if err != nil {
q.lo.Error("error deleting delivered notification email", "user_id", e.UserID, "type", e.Type, "error", err)
return false
}
deleted, err := result.RowsAffected()
if err != nil {
q.lo.Error("error counting deleted notification emails", "user_id", e.UserID, "type", e.Type, "error", err)
return false
}
return deleted > 0
}
func (q *EmailQueue) retry(e queuedEmail) {
if _, err := q.q.Retry.Exec(e.ID, time.Now().Add(emailQueueRetryDelay), e.ClaimedAt); err != nil {
q.lo.Error("error scheduling notification email retry", "user_id", e.UserID, "type", e.Type, "error", err)
}
}
+286
View File
@@ -0,0 +1,286 @@
package notifier
import (
"errors"
"testing"
"time"
"github.com/abhinavxd/libredesk/internal/notification/channels"
"github.com/abhinavxd/libredesk/internal/notification/models"
"github.com/abhinavxd/libredesk/internal/testutil"
"github.com/volatiletech/null/v9"
"github.com/zerodha/logf"
)
type emailDeliveryProvider struct {
err error
}
func (p *emailDeliveryProvider) Send(Message) error {
return p.err
}
func (p *emailDeliveryProvider) Name() string {
return ProviderEmail
}
func TestDelayedReplyEmailUsesQueueTime(t *testing.T) {
db := testutil.NewDB(t, "reply_email_time")
var userID, inboxID, convID int
if err := db.Get(&userID, `INSERT INTO users (type, email, first_name, last_name) VALUES ('agent', 'read@example.com', 'Agent', '') RETURNING id`); err != nil {
t.Fatal(err)
}
if err := db.Get(&inboxID, `INSERT INTO inboxes (name, channel) VALUES ('Test', 'email') RETURNING id`); err != nil {
t.Fatal(err)
}
if err := db.Get(&convID, `INSERT INTO conversations (contact_id, inbox_id, status_id) VALUES ($1, $2, (SELECT id FROM conversation_statuses LIMIT 1)) RETURNING id`, userID, inboxID); err != nil {
t.Fatal(err)
}
lo := logf.New(logf.Opts{})
provider := &emailDeliveryProvider{}
outbound := NewService(map[string]Notifier{ProviderEmail: provider}, 1, 1, &lo)
queue, err := NewEmailQueue(EmailQueueOpts{DB: db, Outbound: outbound, Lo: &lo})
if err != nil {
t.Fatal(err)
}
d := NewDispatcher(DispatcherOpts{
Pipeline: channels.NewPipeline(channels.NewEmail(queue)),
Prefs: fakePreferences{channels: map[int][]models.NotificationChannel{userID: {models.NotificationChannelEmail}}},
})
n := models.Notification{
Type: models.NotificationTypeNewReply,
Recipients: []models.Recipient{{
UserID: userID,
Email: &models.EmailNotification{
Recipient: "read@example.com",
Subject: "Reply",
Content: "First reply",
Delay: time.Minute,
},
}},
ConversationID: null.IntFrom(convID),
}
db.MustExec(`INSERT INTO conversation_last_seen (user_id, conversation_id, last_seen_at) VALUES ($1, $2, now() - interval '1 hour')`, userID, convID)
for _, tt := range []struct {
name string
readOffset time.Duration
want bool
}{
{"read before queue", -time.Second, false},
{"read at queue", 0, true},
{"read after queue", time.Second, true},
} {
t.Run(tt.name, func(t *testing.T) {
d.Send(n)
var queuedAt time.Time
if err := db.Get(&queuedAt, `SELECT queued_at FROM notification_email_queue`); err != nil {
t.Fatal(err)
}
db.MustExec(`UPDATE conversation_last_seen SET last_seen_at = $1`, queuedAt.Add(tt.readOffset))
db.MustExec(`UPDATE notification_email_queue SET send_at = now() - interval '1 second'`)
due := queue.due()
if len(due) != 1 {
t.Fatalf("queued emails = %d, want 1", len(due))
}
got, err := queue.seen(due[0])
if err != nil {
t.Fatal(err)
}
if got != tt.want {
t.Fatalf("seen = %v, want %v", got, tt.want)
}
})
}
t.Run("coalesced reply uses latest message", func(t *testing.T) {
db.MustExec(`UPDATE conversation_last_seen SET last_seen_at = now() - interval '1 hour'`)
d.Send(n)
var firstSendAt time.Time
if err := db.Get(&firstSendAt, `SELECT send_at FROM notification_email_queue`); err != nil {
t.Fatal(err)
}
n.Recipients[0].Email.Content = "Second reply"
n.Recipients[0].Email.Delay = 2 * time.Minute
d.Send(n)
var second struct {
SendAt time.Time `db:"send_at"`
QueuedAt time.Time `db:"queued_at"`
}
if err := db.Get(&second, `SELECT send_at, queued_at FROM notification_email_queue`); err != nil {
t.Fatal(err)
}
if !second.SendAt.After(firstSendAt) {
t.Fatalf("coalesced email send time = %v, want after %v", second.SendAt, firstSendAt)
}
db.MustExec(`UPDATE notification_email_queue SET send_at = now() - interval '1 second'`)
due := queue.due()
if len(due) != 1 || due[0].Content != "Second reply" {
t.Fatalf("unexpected coalesced emails: %#v", due)
}
db.MustExec(`UPDATE conversation_last_seen SET last_seen_at = $1`, second.QueuedAt.Add(-time.Second))
seen, err := queue.seen(due[0])
if err != nil {
t.Fatal(err)
}
if seen {
t.Fatal("unread second reply suppressed")
}
db.MustExec(`UPDATE conversation_last_seen SET last_seen_at = $1`, second.QueuedAt)
seen, err = queue.seen(due[0])
if err != nil {
t.Fatal(err)
}
if !seen {
t.Fatal("read second reply was not suppressed")
}
})
}
func TestDelayedEmailSeenCheckReturnsDatabaseError(t *testing.T) {
db := testutil.NewDB(t, "notification_email_seen_error")
lo := logf.New(logf.Opts{})
queue, err := NewEmailQueue(EmailQueueOpts{DB: db, Lo: &lo})
if err != nil {
t.Fatal(err)
}
db.MustExec(`DROP TABLE conversation_last_seen`)
if _, err := queue.seen(queuedEmail{}); err == nil {
t.Fatal("seen-state database error was ignored")
}
}
func TestDelayedEmailRetriesThenGivesUp(t *testing.T) {
db := testutil.NewDB(t, "reply_email_recipients")
var userID, inboxID, convID int
if err := db.Get(&userID, `INSERT INTO users (type, email, first_name, last_name) VALUES ('agent', 'queued@example.com', 'Agent', '') RETURNING id`); err != nil {
t.Fatal(err)
}
if err := db.Get(&inboxID, `INSERT INTO inboxes (name, channel) VALUES ('Test', 'email') RETURNING id`); err != nil {
t.Fatal(err)
}
if err := db.Get(&convID, `INSERT INTO conversations (contact_id, inbox_id, status_id) VALUES ($1, $2, (SELECT id FROM conversation_statuses LIMIT 1)) RETURNING id`, userID, inboxID); err != nil {
t.Fatal(err)
}
lo := logf.New(logf.Opts{})
provider := &emailDeliveryProvider{}
outbound := NewService(map[string]Notifier{ProviderEmail: provider}, 1, 1, &lo)
queue, err := NewEmailQueue(EmailQueueOpts{DB: db, Outbound: outbound, Lo: &lo})
if err != nil {
t.Fatal(err)
}
d := NewDispatcher(DispatcherOpts{
Pipeline: channels.NewPipeline(channels.NewEmail(queue)),
Prefs: fakePreferences{channels: map[int][]models.NotificationChannel{userID: {models.NotificationChannelEmail}}},
})
n := models.Notification{Type: models.NotificationTypeNewReply, Recipients: []models.Recipient{{UserID: userID}}, ConversationID: null.IntFrom(convID)}
email := &models.EmailNotification{Recipient: "queued@example.com", Subject: "Reply", Content: "Reply", Delay: time.Minute}
send := func(email *models.EmailNotification) {
n.Recipients[0].Email = email
d.Send(n)
}
queued := func() int {
var count int
if err := db.Get(&count, `SELECT COUNT(*) FROM notification_email_queue`); err != nil {
t.Fatal(err)
}
return count
}
claimOne := func() queuedEmail {
t.Helper()
db.MustExec(`UPDATE notification_email_queue SET send_at = now() - interval '1 second'`)
due := queue.due()
if len(due) != 1 {
t.Fatalf("queued emails = %d, want 1", len(due))
}
return due[0]
}
send(nil)
if queued() != 0 {
t.Fatal("a notification without an email body was queued")
}
send(email)
if queued() != 1 {
t.Fatal("email was not queued")
}
if !queue.deliver(claimOne()) {
t.Fatal("email was not delivered")
}
if queued() != 0 {
t.Fatalf("queued emails after delivery = %d, want 0", queued())
}
provider.err = errors.New("SMTP unavailable")
send(email)
if queue.deliver(claimOne()) {
t.Fatal("failed send reported as delivered")
}
if queued() != 1 {
t.Fatalf("queued emails after failure = %d, want 1", queued())
}
for attempt := 2; attempt <= emailQueueMaxAttempts; attempt++ {
queue.deliver(claimOne())
}
if queued() != 0 {
t.Fatalf("queued emails after final failure = %d, want 0", queued())
}
send(email)
db.MustExec(`UPDATE notification_email_queue SET attempts = $1`, emailQueueMaxAttempts-1)
send(email)
var attempts int
if err := db.Get(&attempts, `SELECT attempts FROM notification_email_queue`); err != nil {
t.Fatal(err)
}
if attempts != 0 {
t.Fatalf("attempts after replacement = %d, want 0", attempts)
}
}
func TestClaimedEmailRemainsRecoverable(t *testing.T) {
db := testutil.NewDB(t, "notification_email_recovery")
var userID int
if err := db.Get(&userID, `INSERT INTO users (type, email, first_name, last_name) VALUES ('agent', 'recover@example.com', 'Agent', '') RETURNING id`); err != nil {
t.Fatal(err)
}
lo := logf.New(logf.Opts{})
provider := &emailDeliveryProvider{}
queue, err := NewEmailQueue(EmailQueueOpts{
DB: db,
Outbound: NewService(map[string]Notifier{ProviderEmail: provider}, 1, 1, &lo),
Lo: &lo,
})
if err != nil {
t.Fatal(err)
}
email := models.Email{
UserID: userID,
Type: models.NotificationTypeNewReply,
Recipient: "recover@example.com",
Subject: "Reply",
Content: "Pending reply",
}
if !queue.SendAfter(email, -time.Second) {
t.Fatal("queueing email failed")
}
claimed := queue.due()
if len(claimed) != 1 {
t.Fatalf("claimed emails = %d, want 1", len(claimed))
}
var queued int
if err := db.Get(&queued, `SELECT COUNT(*) FROM notification_email_queue`); err != nil {
t.Fatal(err)
}
if queued != 1 {
t.Fatalf("queued emails after claim = %d, want 1", queued)
}
if due := queue.due(); len(due) != 0 {
t.Fatalf("emails reclaimed before lease expiry = %d, want 0", len(due))
}
db.MustExec(`UPDATE notification_email_queue SET send_at = now() - interval '1 second'`)
if due := queue.due(); len(due) != 1 {
t.Fatalf("emails after lease expiry = %d, want 1", len(due))
}
}
+110 -2
View File
@@ -2,6 +2,7 @@ package models
import (
"encoding/json"
"slices"
"time"
"github.com/volatiletech/null/v9"
@@ -10,13 +11,120 @@ import (
// NotificationType represents the type of user notification.
type NotificationType string
// NotificationChannel represents the delivery channel of a notification.
type NotificationChannel string
const (
NotificationTypeMention NotificationType = "mention"
NotificationTypeAssignment NotificationType = "assignment"
NotificationTypeMention NotificationType = "mention"
NotificationTypeAssignment NotificationType = "assignment"
NotificationTypeNewReply NotificationType = "new_reply"
NotificationTypeNewReplyParticipating NotificationType = "new_reply_participating"
NotificationTypeConversationReopened NotificationType = "conversation_reopened"
NotificationTypeAutomation NotificationType = "automation"
NotificationTypeSLAFirstResponseWarn NotificationType = "sla_first_response_warning"
NotificationTypeSLAFirstResponseBreach NotificationType = "sla_first_response_breach"
NotificationTypeSLANextResponseWarn NotificationType = "sla_next_response_warning"
NotificationTypeSLANextResponseBreach NotificationType = "sla_next_response_breach"
NotificationTypeSLAResolutionWarn NotificationType = "sla_resolution_warning"
NotificationTypeSLAResolutionBreach NotificationType = "sla_resolution_breach"
NotificationTypeSLAWarning NotificationType = "sla_warning"
NotificationTypeSLABreach NotificationType = "sla_breach"
NotificationChannelInApp NotificationChannel = "in_app"
NotificationChannelEmail NotificationChannel = "email"
NotificationChannelPush NotificationChannel = "push"
)
// AgentNotificationTypes are the notification types agents can set preferences for.
var AgentNotificationTypes = []NotificationType{
NotificationTypeAssignment,
NotificationTypeMention,
NotificationTypeConversationReopened,
NotificationTypeNewReply,
NotificationTypeNewReplyParticipating,
NotificationTypeSLAFirstResponseWarn,
NotificationTypeSLAFirstResponseBreach,
NotificationTypeSLANextResponseWarn,
NotificationTypeSLANextResponseBreach,
NotificationTypeSLAResolutionWarn,
NotificationTypeSLAResolutionBreach,
}
// NotificationChannels are the delivery channels a preference can target.
var NotificationChannels = []NotificationChannel{
NotificationChannelInApp,
NotificationChannelEmail,
NotificationChannelPush,
}
// defaultDisabledTypes are off until an agent opts in. Every other type defaults to on.
var defaultDisabledTypes = []NotificationType{
NotificationTypeNewReply,
NotificationTypeNewReplyParticipating,
}
// NotificationPreference is a per-channel toggle for a notification type.
type NotificationPreference struct {
NotificationType NotificationType `db:"notification_type" json:"notification_type"`
Channel NotificationChannel `db:"channel" json:"channel"`
Enabled bool `db:"enabled" json:"enabled"`
}
type Recipient struct {
UserID int
Email *EmailNotification
}
type Notification struct {
Type NotificationType
Recipients []Recipient
Title string
Body null.String
ConversationID null.Int
MessageID null.Int
ActorID null.Int
Meta json.RawMessage
ConversationUUID string
MessageUUID string
ActorFirstName string
ActorLastName string
}
type EmailNotification struct {
Recipient string
Subject string
Content string
Delay time.Duration
}
type Email struct {
UserID int
NotificationID null.Int
Type NotificationType
ConversationID null.Int
Recipient string
Subject string
Content string
}
type PushPayload struct {
Title string `json:"title"`
Body string `json:"body,omitempty"`
Tag string `json:"tag,omitempty"`
URL string `json:"url"`
}
type DeliveryResult struct {
RecipientID int
Channels []NotificationChannel
}
// DefaultEnabled reports the preference for a type when the agent has no stored row for it.
func DefaultEnabled(nType NotificationType) bool {
return !slices.Contains(defaultDisabledTypes, nType)
}
// UserNotification represents an in-app notification for a user.
type UserNotification struct {
ID int `db:"id" json:"id"`
@@ -0,0 +1,24 @@
package models
import "testing"
func TestDefaultEnabled(t *testing.T) {
tests := []struct {
name string
nType NotificationType
want bool
}{
{"assignment", NotificationTypeAssignment, true},
{"mention", NotificationTypeMention, true},
{"new reply", NotificationTypeNewReply, false},
{"new reply participating", NotificationTypeNewReplyParticipating, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := DefaultEnabled(tt.nType); got != tt.want {
t.Fatalf("DefaultEnabled(%q) = %v, want %v", tt.nType, got, tt.want)
}
})
}
}
+136
View File
@@ -0,0 +1,136 @@
package notifier
import (
"slices"
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/notification/models"
"github.com/jmoiron/sqlx"
"github.com/knadh/go-i18n"
"github.com/lib/pq"
"github.com/zerodha/logf"
)
type prefQueries struct {
GetPreferences *sqlx.Stmt `query:"get-notification-preferences"`
GetPreferencesType *sqlx.Stmt `query:"get-notification-preferences-for-type"`
UpsertPreference *sqlx.Stmt `query:"upsert-notification-preference"`
}
type userChannelPreference struct {
UserID int `db:"user_id"`
Channel models.NotificationChannel `db:"channel"`
Enabled bool `db:"enabled"`
}
type PreferenceManager struct {
db *sqlx.DB
lo *logf.Logger
i18n *i18n.I18n
q prefQueries
}
type PreferenceManagerOpts struct {
DB *sqlx.DB
Lo *logf.Logger
I18n *i18n.I18n
}
// NewPreferenceManager creates a new PreferenceManager.
func NewPreferenceManager(opts PreferenceManagerOpts) (*PreferenceManager, error) {
var q prefQueries
if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, queriesFS); err != nil {
return nil, err
}
return &PreferenceManager{
db: opts.DB,
q: q,
lo: opts.Lo,
i18n: opts.I18n,
}, nil
}
func (m *PreferenceManager) EnabledChannels(userIDs []int, nType models.NotificationType) (map[int][]models.NotificationChannel, error) {
var rows []userChannelPreference
if err := m.q.GetPreferencesType.Select(&rows, pq.Array(userIDs), nType); err != nil {
return nil, err
}
stored := make(map[[2]any]bool, len(rows))
for _, r := range rows {
stored[[2]any{r.UserID, r.Channel}] = r.Enabled
}
enabled := make(map[int][]models.NotificationChannel, len(userIDs))
for _, userID := range userIDs {
for _, channel := range models.NotificationChannels {
on, ok := stored[[2]any{userID, channel}]
if !ok {
on = models.DefaultEnabled(nType)
}
if on {
enabled[userID] = append(enabled[userID], channel)
}
}
}
return enabled, nil
}
// GetMatrix returns the effective preference for every agent notification type and channel.
func (m *PreferenceManager) GetMatrix(userID int) ([]models.NotificationPreference, error) {
var rows []models.NotificationPreference
if err := m.q.GetPreferences.Select(&rows, userID); err != nil {
m.lo.Error("error fetching notification preferences", "user_id", userID, "error", err)
return nil, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
stored := make(map[[2]any]bool, len(rows))
for _, r := range rows {
stored[[2]any{r.NotificationType, r.Channel}] = r.Enabled
}
matrix := make([]models.NotificationPreference, 0, len(models.AgentNotificationTypes)*len(models.NotificationChannels))
for _, nType := range models.AgentNotificationTypes {
for _, channel := range models.NotificationChannels {
on, ok := stored[[2]any{nType, channel}]
if !ok {
on = models.DefaultEnabled(nType)
}
matrix = append(matrix, models.NotificationPreference{
NotificationType: nType,
Channel: channel,
Enabled: on,
})
}
}
return matrix, nil
}
// Update upserts the given preferences for the user. Unknown types or channels are rejected.
func (m *PreferenceManager) Update(userID int, prefs []models.NotificationPreference) error {
for _, p := range prefs {
if !slices.Contains(models.AgentNotificationTypes, p.NotificationType) ||
!slices.Contains(models.NotificationChannels, p.Channel) {
return envelope.NewError(envelope.InputError, m.i18n.T("notification.invalidPreference"), nil)
}
}
tx, err := m.db.Beginx()
if err != nil {
m.lo.Error("error starting notification preference update", "user_id", userID, "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
defer tx.Rollback()
for _, p := range prefs {
if _, err := tx.Stmtx(m.q.UpsertPreference).Exec(userID, p.NotificationType, p.Channel, p.Enabled); err != nil {
m.lo.Error("error updating notification preference", "user_id", userID, "type", p.NotificationType, "channel", p.Channel, "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
}
if err := tx.Commit(); err != nil {
m.lo.Error("error committing notification preference update", "user_id", userID, "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
return nil
}
+97
View File
@@ -0,0 +1,97 @@
package notifier
import (
"testing"
"github.com/abhinavxd/libredesk/internal/notification/models"
"github.com/abhinavxd/libredesk/internal/testutil"
"github.com/zerodha/logf"
)
func TestEnabledChannelsReturnsLookupFailure(t *testing.T) {
db := testutil.NewDB(t, "notification_preferences_read_failure")
lo := logf.New(logf.Opts{})
manager, err := NewPreferenceManager(PreferenceManagerOpts{
DB: db,
Lo: &lo,
I18n: testutil.NewI18n(t),
})
if err != nil {
t.Fatal(err)
}
if err := manager.q.GetPreferencesType.Close(); err != nil {
t.Fatal(err)
}
channels, err := manager.EnabledChannels([]int{1}, models.NotificationTypeAssignment)
if err == nil {
t.Fatal("preference lookup failure was not returned")
}
if channels != nil {
t.Fatalf("enabled channels = %v, want nil", channels)
}
}
func TestPreferenceUpdateIsAtomic(t *testing.T) {
db := testutil.NewDB(t, "notification_preferences_atomic")
var userID int
if err := db.Get(&userID, `INSERT INTO users (type, email, first_name, last_name) VALUES ('agent', 'prefs@example.com', 'Agent', '') RETURNING id`); err != nil {
t.Fatal(err)
}
lo := logf.New(logf.Opts{})
manager, err := NewPreferenceManager(PreferenceManagerOpts{
DB: db,
Lo: &lo,
I18n: testutil.NewI18n(t),
})
if err != nil {
t.Fatal(err)
}
t.Run("invalid preference", func(t *testing.T) {
err := manager.Update(userID, []models.NotificationPreference{
{NotificationType: models.NotificationTypeAssignment, Channel: models.NotificationChannelEmail, Enabled: false},
{NotificationType: "unknown", Channel: models.NotificationChannelPush, Enabled: true},
})
if err == nil {
t.Fatal("invalid update succeeded")
}
var count int
if err := db.Get(&count, `SELECT COUNT(*) FROM user_notification_preferences WHERE user_id = $1`, userID); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("stored preferences = %d, want 0", count)
}
})
t.Run("database failure", func(t *testing.T) {
db.MustExec(`
CREATE FUNCTION reject_push_preference() RETURNS trigger AS $$
BEGIN
IF NEW.channel = 'push' THEN
RAISE EXCEPTION 'push rejected';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER reject_push_preference
BEFORE INSERT OR UPDATE ON user_notification_preferences
FOR EACH ROW EXECUTE FUNCTION reject_push_preference();
`)
err := manager.Update(userID, []models.NotificationPreference{
{NotificationType: models.NotificationTypeAssignment, Channel: models.NotificationChannelEmail, Enabled: false},
{NotificationType: models.NotificationTypeAssignment, Channel: models.NotificationChannelPush, Enabled: true},
})
if err == nil {
t.Fatal("update succeeded despite database failure")
}
var count int
if err := db.Get(&count, `SELECT COUNT(*) FROM user_notification_preferences WHERE user_id = $1`, userID); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("stored preferences = %d, want 0", count)
}
})
}
+284
View File
@@ -0,0 +1,284 @@
package notifier
import (
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
webpush "github.com/SherClockHolmes/webpush-go"
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/envelope"
nmodels "github.com/abhinavxd/libredesk/internal/notification/models"
"github.com/abhinavxd/libredesk/internal/ssrf"
"github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx/types"
"github.com/knadh/go-i18n"
"github.com/zerodha/logf"
)
const (
pushPublicKeySetting = "notification.push.vapid_public_key"
pushPrivateKeySetting = "notification.push.vapid_private_key"
pushTTL = 86400
pushTitleRunes = 100
pushBodyRunes = 400
)
type pushSender func(context.Context, []byte, PushSubscription, string, string, string) (*http.Response, error)
type pushDelivery struct {
UserID int
Payload nmodels.PushPayload
}
type pushSettingStore interface {
Get(key string) (types.JSONText, error)
Update(settings any) error
}
type PushManager struct {
store pushSubscriptionStore
lo *logf.Logger
i18n *i18n.I18n
publicKey string
privateKey string
subject string
queue chan pushDelivery
concurrency int
sender pushSender
}
type PushManagerOpts struct {
DB *sqlx.DB
Settings pushSettingStore
Lo *logf.Logger
I18n *i18n.I18n
RootURL string
Concurrency int
QueueSize int
}
func NewPushManager(opts PushManagerOpts) (*PushManager, error) {
var q pushQueries
if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, queriesFS); err != nil {
return nil, err
}
privateKey, publicKey, err := loadVAPIDKeys(opts.Settings)
if err != nil {
opts.Lo.Error("error loading VAPID keys, push notifications are off", "error", err)
}
m := &PushManager{
store: &sqlPushStore{q: q},
lo: opts.Lo,
i18n: opts.I18n,
publicKey: publicKey,
privateKey: privateKey,
subject: vapidSubject(opts.RootURL),
queue: make(chan pushDelivery, opts.QueueSize),
concurrency: opts.Concurrency,
}
pushHTTPClient := newPushHTTPClient(opts.Lo)
m.sender = func(ctx context.Context, payload []byte, subscription PushSubscription, subject, publicKey, privateKey string) (*http.Response, error) {
return sendWebPush(ctx, payload, subscription, subject, publicKey, privateKey, pushHTTPClient)
}
return m, nil
}
func (m *PushManager) PublicKey() string {
return m.publicKey
}
func (m *PushManager) Endpoints(userID int) ([]string, error) {
subscriptions, err := m.store.List(userID)
if err != nil {
m.lo.Error("error fetching push subscriptions", "user_id", userID, "error", err)
return nil, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
endpoints := make([]string, 0, len(subscriptions))
for _, subscription := range subscriptions {
endpoints = append(endpoints, subscription.Endpoint)
}
return endpoints, nil
}
func (m *PushManager) Upsert(userID int, subscription PushSubscriptionInput) error {
if !validPushSubscription(subscription) {
return envelope.NewError(envelope.InputError, m.i18n.T("notification.invalidPushSubscription"), nil)
}
if err := m.store.Upsert(userID, subscription); err != nil {
m.lo.Error("error saving push subscription", "user_id", userID, "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
return nil
}
func (m *PushManager) Delete(userID int, endpoint string) error {
if endpoint == "" {
return envelope.NewError(envelope.InputError, m.i18n.T("notification.invalidPushSubscription"), nil)
}
if err := m.store.Delete(userID, endpoint); err != nil {
m.lo.Error("error deleting push subscription", "user_id", userID, "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
return nil
}
func (m *PushManager) Send(userID int, payload nmodels.PushPayload) bool {
if m.publicKey == "" || m.privateKey == "" {
return false
}
select {
case m.queue <- pushDelivery{UserID: userID, Payload: payload}:
return true
default:
m.lo.Error("push notification queue is full", "user_id", userID)
return false
}
}
func (m *PushManager) Run(ctx context.Context) {
var wg sync.WaitGroup
for range m.concurrency {
wg.Go(func() { m.worker(ctx) })
}
wg.Wait()
}
func (m *PushManager) worker(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case delivery := <-m.queue:
if err := m.deliver(ctx, delivery); err != nil {
m.lo.Error("error sending push notification", "user_id", delivery.UserID, "error", err)
}
}
}
}
func (m *PushManager) deliver(ctx context.Context, delivery pushDelivery) error {
subscriptions, err := m.store.List(delivery.UserID)
if err != nil {
return err
}
delivery.Payload.Title = pushPreview(delivery.Payload.Title, pushTitleRunes)
delivery.Payload.Body = pushPreview(delivery.Payload.Body, pushBodyRunes)
payload, err := json.Marshal(delivery.Payload)
if err != nil {
return err
}
for _, subscription := range subscriptions {
resp, err := m.sender(ctx, payload, subscription, m.subject, m.publicKey, m.privateKey)
if err != nil {
m.lo.Error("push endpoint rejected notification", "subscription_id", subscription.ID, "error", err)
continue
}
if resp == nil {
continue
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone {
if err := m.store.DeleteByID(subscription.ID); err != nil {
m.lo.Error("error deleting expired push subscription", "subscription_id", subscription.ID, "error", err)
}
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
m.lo.Error("push endpoint returned error", "subscription_id", subscription.ID, "status", resp.StatusCode)
}
}
return nil
}
func loadVAPIDKeys(settings pushSettingStore) (string, string, error) {
privateKey, err := readStringSetting(settings, pushPrivateKeySetting)
if err != nil {
return "", "", err
}
publicKey, err := readStringSetting(settings, pushPublicKeySetting)
if err != nil {
return "", "", err
}
if privateKey != "" && publicKey != "" {
return privateKey, publicKey, nil
}
privateKey, publicKey, err = webpush.GenerateVAPIDKeys()
if err != nil {
return "", "", err
}
if err := settings.Update(map[string]string{
pushPrivateKeySetting: privateKey,
pushPublicKeySetting: publicKey,
}); err != nil {
return "", "", err
}
return privateKey, publicKey, nil
}
func readStringSetting(settings pushSettingStore, key string) (string, error) {
b, err := settings.Get(key)
if err != nil {
return "", err
}
var value string
if err := json.Unmarshal(b, &value); err != nil {
return "", err
}
return value, nil
}
func sendWebPush(ctx context.Context, payload []byte, subscription PushSubscription, subject, publicKey, privateKey string, client *http.Client) (*http.Response, error) {
return webpush.SendNotificationWithContext(ctx, payload, &webpush.Subscription{
Endpoint: subscription.Endpoint,
Keys: webpush.Keys{
P256dh: subscription.P256DH,
Auth: subscription.Auth,
},
}, &webpush.Options{
HTTPClient: client,
Subscriber: subject,
VAPIDPublicKey: publicKey,
VAPIDPrivateKey: privateKey,
TTL: pushTTL,
})
}
func validPushSubscription(subscription PushSubscriptionInput) bool {
endpoint, err := url.Parse(subscription.Endpoint)
return err == nil && endpoint.Scheme == "https" && endpoint.Host != "" && subscription.P256DH != "" && subscription.Auth != ""
}
func vapidSubject(rootURL string) string {
rootURL = strings.TrimRight(rootURL, "/")
if strings.HasPrefix(rootURL, "https://") {
return rootURL
}
return "support@libredesk.io"
}
func newPushHTTPClient(lo *logf.Logger) *http.Client {
control := ssrf.NewControl(true, nil, lo)
return &http.Client{
Transport: ssrf.NewTransport(control, 10*time.Second),
Timeout: 10 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
}
func pushPreview(text string, limit int) string {
count := 0
for i := range text {
if count == limit {
return text[:i] + "…"
}
count++
}
return text
}
+57
View File
@@ -0,0 +1,57 @@
package notifier
import "github.com/jmoiron/sqlx"
type pushQueries struct {
List *sqlx.Stmt `query:"get-push-subscriptions"`
Upsert *sqlx.Stmt `query:"upsert-push-subscription"`
Delete *sqlx.Stmt `query:"delete-push-subscription"`
DeleteByID *sqlx.Stmt `query:"delete-push-subscription-by-id"`
}
type PushSubscription struct {
ID int `db:"id" json:"id"`
Endpoint string `db:"endpoint" json:"endpoint"`
P256DH string `db:"p256dh" json:"p256dh"`
Auth string `db:"auth" json:"auth"`
}
type PushSubscriptionInput struct {
Endpoint string `json:"endpoint"`
P256DH string `json:"p256dh"`
Auth string `json:"auth"`
}
type pushSubscriptionStore interface {
List(userID int) ([]PushSubscription, error)
Upsert(userID int, subscription PushSubscriptionInput) error
Delete(userID int, endpoint string) error
DeleteByID(id int) error
}
type sqlPushStore struct {
q pushQueries
}
func (s *sqlPushStore) List(userID int) ([]PushSubscription, error) {
var subscriptions []PushSubscription
if err := s.q.List.Select(&subscriptions, userID); err != nil {
return nil, err
}
return subscriptions, nil
}
func (s *sqlPushStore) Upsert(userID int, subscription PushSubscriptionInput) error {
_, err := s.q.Upsert.Exec(userID, subscription.Endpoint, subscription.P256DH, subscription.Auth)
return err
}
func (s *sqlPushStore) Delete(userID int, endpoint string) error {
_, err := s.q.Delete.Exec(userID, endpoint)
return err
}
func (s *sqlPushStore) DeleteByID(id int) error {
_, err := s.q.DeleteByID.Exec(id)
return err
}
+79
View File
@@ -0,0 +1,79 @@
package notifier
import (
"testing"
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/testutil"
)
func newTestPushStore(t *testing.T) (*sqlPushStore, int, int) {
t.Helper()
db := testutil.NewDB(t, "notification_push_store")
var q pushQueries
if err := dbutil.ScanSQLFile("queries.sql", &q, db, queriesFS); err != nil {
t.Fatal(err)
}
var firstUserID, secondUserID int
if err := db.QueryRow(`INSERT INTO users (type, email, first_name) VALUES ('agent', 'push-one@example.com', 'One') RETURNING id`).Scan(&firstUserID); err != nil {
t.Fatal(err)
}
if err := db.QueryRow(`INSERT INTO users (type, email, first_name) VALUES ('agent', 'push-two@example.com', 'Two') RETURNING id`).Scan(&secondUserID); err != nil {
t.Fatal(err)
}
return &sqlPushStore{q: q}, firstUserID, secondUserID
}
func TestPushStoreMovesEndpointToLatestUser(t *testing.T) {
store, firstUserID, secondUserID := newTestPushStore(t)
endpoint := "https://push.example/shared"
if err := store.Upsert(firstUserID, PushSubscriptionInput{Endpoint: endpoint, P256DH: "first-key", Auth: "first-auth"}); err != nil {
t.Fatal(err)
}
if err := store.Upsert(secondUserID, PushSubscriptionInput{Endpoint: endpoint, P256DH: "second-key", Auth: "second-auth"}); err != nil {
t.Fatal(err)
}
firstSubscriptions, err := store.List(firstUserID)
if err != nil {
t.Fatal(err)
}
secondSubscriptions, err := store.List(secondUserID)
if err != nil {
t.Fatal(err)
}
if len(firstSubscriptions) != 0 {
t.Fatalf("first user retained %d subscriptions", len(firstSubscriptions))
}
if len(secondSubscriptions) != 1 || secondSubscriptions[0].P256DH != "second-key" || secondSubscriptions[0].Auth != "second-auth" {
t.Fatalf("second user subscriptions = %#v", secondSubscriptions)
}
}
func TestPushStoreDeleteIsUserScoped(t *testing.T) {
store, firstUserID, secondUserID := newTestPushStore(t)
endpoint := "https://push.example/scoped"
if err := store.Upsert(secondUserID, PushSubscriptionInput{Endpoint: endpoint, P256DH: "key", Auth: "auth"}); err != nil {
t.Fatal(err)
}
if err := store.Delete(firstUserID, endpoint); err != nil {
t.Fatal(err)
}
subscriptions, err := store.List(secondUserID)
if err != nil {
t.Fatal(err)
}
if len(subscriptions) != 1 {
t.Fatalf("other user delete left %d subscriptions, want 1", len(subscriptions))
}
if err := store.Delete(secondUserID, endpoint); err != nil {
t.Fatal(err)
}
subscriptions, err = store.List(secondUserID)
if err != nil {
t.Fatal(err)
}
if len(subscriptions) != 0 {
t.Fatalf("owner delete left %d subscriptions", len(subscriptions))
}
}
+232
View File
@@ -0,0 +1,232 @@
package notifier
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"unicode/utf8"
nmodels "github.com/abhinavxd/libredesk/internal/notification/models"
"github.com/jmoiron/sqlx/types"
"github.com/zerodha/logf"
)
type fakePushSettings struct {
values map[string]string
updates int
}
func (s *fakePushSettings) Get(key string) (types.JSONText, error) {
b, _ := json.Marshal(s.values[key])
return b, nil
}
func (s *fakePushSettings) Update(value any) error {
s.updates++
for key, setting := range value.(map[string]string) {
s.values[key] = setting
}
return nil
}
type fakePushStore struct {
subscriptions []PushSubscription
deletedID int
}
func (s *fakePushStore) List(int) ([]PushSubscription, error) {
return s.subscriptions, nil
}
func (s *fakePushStore) Upsert(int, PushSubscriptionInput) error { return nil }
func (s *fakePushStore) Delete(int, string) error { return nil }
func (s *fakePushStore) DeleteByID(id int) error {
s.deletedID = id
return nil
}
func TestPushManagerDeliverSendsEverySubscription(t *testing.T) {
store := &fakePushStore{subscriptions: []PushSubscription{
{ID: 11, Endpoint: "https://push.example/one", P256DH: "key-one", Auth: "auth-one"},
{ID: 12, Endpoint: "https://push.example/two", P256DH: "key-two", Auth: "auth-two"},
}}
var endpoints []string
m := &PushManager{
store: store,
publicKey: "public",
privateKey: "private",
subject: "https://desk.example",
sender: func(_ context.Context, payload []byte, subscription PushSubscription, _, _, _ string) (*http.Response, error) {
if !strings.Contains(string(payload), `"url":"/inboxes/assigned/conversation/conv-1?scrollTo=msg-1"`) {
t.Fatalf("unexpected payload: %s", payload)
}
endpoints = append(endpoints, subscription.Endpoint)
return &http.Response{StatusCode: http.StatusCreated, Body: io.NopCloser(strings.NewReader(""))}, nil
},
}
if err := m.deliver(t.Context(), pushDelivery{UserID: 7, Payload: nmodels.PushPayload{
Title: "Assigned conversation",
Body: "A conversation was assigned to you",
Tag: "assignment_conv-1",
URL: "/inboxes/assigned/conversation/conv-1?scrollTo=msg-1",
}}); err != nil {
t.Fatal(err)
}
if len(endpoints) != 2 {
t.Fatalf("sent to %d endpoints, want 2", len(endpoints))
}
}
func TestPushManagerDeliverBoundsPreview(t *testing.T) {
for _, content := range []string{"", "Short note", strings.Repeat("a", 100), strings.Repeat("a", 101), strings.Repeat("a", 400), strings.Repeat("a", 401), strings.Repeat("a", 10000), strings.Repeat("世界🙂", 2000), strings.Repeat("<&>\x00\"\\", 2000), strings.Repeat("\x00", 2000)} {
t.Run(fmt.Sprintf("bytes_%d", len(content)), func(t *testing.T) {
original := nmodels.PushPayload{Title: content, Body: content, Tag: "mention_" + strings.Repeat("a", 36), URL: "/inboxes/mentioned/conversation/123?scrollTo=456"}
called := false
m := &PushManager{
store: &fakePushStore{subscriptions: []PushSubscription{{ID: 1}}},
sender: func(_ context.Context, payload []byte, _ PushSubscription, _, _, _ string) (*http.Response, error) {
called = true
if len(payload) > 3993 {
t.Fatalf("payload has %d bytes, exceeds Web Push capacity", len(payload))
}
var got nmodels.PushPayload
if err := json.Unmarshal(payload, &got); err != nil {
t.Fatal(err)
}
if got.URL != original.URL || got.Tag != original.Tag {
t.Fatal("routing fields changed")
}
for _, preview := range []string{got.Title, got.Body} {
if !utf8.ValidString(preview) || !strings.HasPrefix(content, strings.TrimSuffix(preview, "…")) {
t.Fatal("invalid preview")
}
if len(content) < 100 && preview != content {
t.Fatal("short content changed")
}
}
return nil, nil
},
}
if err := m.deliver(t.Context(), pushDelivery{UserID: 1, Payload: original}); err != nil {
t.Fatal(err)
}
if !called {
t.Fatal("push was not delivered")
}
})
}
}
func TestPushManagerDeliverDeletesExpiredSubscription(t *testing.T) {
store := &fakePushStore{subscriptions: []PushSubscription{{ID: 23, Endpoint: "https://push.example/expired"}}}
m := &PushManager{
store: store,
sender: func(context.Context, []byte, PushSubscription, string, string, string) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusGone, Body: io.NopCloser(strings.NewReader(""))}, nil
},
}
if err := m.deliver(t.Context(), pushDelivery{UserID: 7, Payload: nmodels.PushPayload{Title: "Mention"}}); err != nil {
t.Fatal(err)
}
if store.deletedID != 23 {
t.Fatalf("deleted subscription %d, want 23", store.deletedID)
}
}
func TestValidPushSubscription(t *testing.T) {
tests := []struct {
name string
input PushSubscriptionInput
want bool
}{
{"valid", PushSubscriptionInput{Endpoint: "https://push.example/id", P256DH: "key", Auth: "secret"}, true},
{"empty endpoint", PushSubscriptionInput{P256DH: "key", Auth: "secret"}, false},
{"non HTTPS endpoint", PushSubscriptionInput{Endpoint: "http://push.example/id", P256DH: "key", Auth: "secret"}, false},
{"missing public key", PushSubscriptionInput{Endpoint: "https://push.example/id", Auth: "secret"}, false},
{"missing auth", PushSubscriptionInput{Endpoint: "https://push.example/id", P256DH: "key"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := validPushSubscription(tt.input); got != tt.want {
t.Fatalf("validPushSubscription(%#v) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
func TestPushHTTPClientBlocksPrivateEndpoints(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("private endpoint received push request")
}))
defer server.Close()
lo := logf.New(logf.Opts{})
request, err := http.NewRequestWithContext(t.Context(), http.MethodPost, server.URL, nil)
if err != nil {
t.Fatal(err)
}
if _, err := newPushHTTPClient(&lo).Do(request); err == nil {
t.Fatal("private endpoint request succeeded")
}
}
func TestPushHTTPClientRejectsRedirects(t *testing.T) {
lo := logf.New(logf.Opts{})
client := newPushHTTPClient(&lo)
if err := client.CheckRedirect(&http.Request{}, nil); !errors.Is(err, http.ErrUseLastResponse) {
t.Fatalf("redirect error = %v, want %v", err, http.ErrUseLastResponse)
}
}
func TestVAPIDSubject(t *testing.T) {
if got := vapidSubject("https://desk.example/"); got != "https://desk.example" {
t.Fatalf("HTTPS subject = %q", got)
}
if got := vapidSubject("http://localhost:9000"); got != "support@libredesk.io" {
t.Fatalf("local subject = %q", got)
}
}
func TestLoadVAPIDKeysReusesStoredPair(t *testing.T) {
settings := &fakePushSettings{values: map[string]string{
pushPrivateKeySetting: "stored-private",
pushPublicKeySetting: "stored-public",
}}
privateKey, publicKey, err := loadVAPIDKeys(settings)
if err != nil {
t.Fatal(err)
}
if privateKey != "stored-private" || publicKey != "stored-public" {
t.Fatalf("got %q, %q", privateKey, publicKey)
}
if settings.updates != 0 {
t.Fatalf("updated stored VAPID keys %d times", settings.updates)
}
}
func TestLoadVAPIDKeysCreatesMissingPair(t *testing.T) {
settings := &fakePushSettings{values: map[string]string{}}
privateKey, publicKey, err := loadVAPIDKeys(settings)
if err != nil {
t.Fatal(err)
}
if privateKey == "" || publicKey == "" {
t.Fatal("generated VAPID keys are empty")
}
if settings.updates != 1 {
t.Fatalf("updated VAPID keys %d times, want 1", settings.updates)
}
}
+81
View File
@@ -46,3 +46,84 @@ DELETE FROM user_notifications WHERE user_id = $1;
-- name: delete-old-notifications
DELETE FROM user_notifications WHERE created_at < NOW() - INTERVAL '30 days';
-- name: get-notification-preferences
SELECT notification_type, channel, enabled
FROM user_notification_preferences
WHERE user_id = $1;
-- name: get-notification-preferences-for-type
SELECT user_id, channel, enabled
FROM user_notification_preferences
WHERE user_id = ANY($1::bigint[]) AND notification_type = $2;
-- name: upsert-notification-preference
INSERT INTO user_notification_preferences (user_id, notification_type, channel, enabled)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, notification_type, channel)
DO UPDATE SET enabled = EXCLUDED.enabled, updated_at = now();
-- name: get-push-subscriptions
SELECT id, endpoint, p256dh, auth
FROM notification_push_subscriptions
WHERE user_id = $1;
-- name: upsert-push-subscription
INSERT INTO notification_push_subscriptions (user_id, endpoint, p256dh, auth)
VALUES ($1, $2, $3, $4)
ON CONFLICT (endpoint) DO UPDATE SET
user_id = EXCLUDED.user_id,
p256dh = EXCLUDED.p256dh,
auth = EXCLUDED.auth,
updated_at = now();
-- name: delete-push-subscription
DELETE FROM notification_push_subscriptions WHERE user_id = $1 AND endpoint = $2;
-- name: delete-push-subscription-by-id
DELETE FROM notification_push_subscriptions WHERE id = $1;
-- name: is-notification-seen
SELECT COALESCE((SELECT is_read FROM user_notifications WHERE id = $2 AND user_id = $1), false)
OR EXISTS (
SELECT 1 FROM conversation_last_seen
WHERE conversation_id = $3 AND user_id = $1 AND last_seen_at >= $4
);
-- name: enqueue-notification-email
INSERT INTO notification_email_queue
(user_id, notification_id, notification_type, conversation_id, recipient_email, subject, content, send_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (user_id, notification_type, conversation_id) DO UPDATE SET
notification_id = EXCLUDED.notification_id,
recipient_email = EXCLUDED.recipient_email,
subject = EXCLUDED.subject,
content = EXCLUDED.content,
send_at = EXCLUDED.send_at,
attempts = 0,
queued_at = now(),
updated_at = now();
-- name: claim-due-notification-emails
WITH due AS (
SELECT id
FROM notification_email_queue
WHERE send_at <= now()
ORDER BY send_at
LIMIT $1
FOR UPDATE SKIP LOCKED
)
UPDATE notification_email_queue AS q
SET send_at = $2, updated_at = now()
FROM due
WHERE q.id = due.id
RETURNING q.id, q.updated_at, q.user_id, q.notification_id, q.notification_type, q.conversation_id, q.attempts,
q.recipient_email, q.subject, q.content, q.queued_at;
-- name: delete-claimed-notification-email
DELETE FROM notification_email_queue WHERE id = $1 AND updated_at = $2;
-- name: retry-claimed-notification-email
UPDATE notification_email_queue
SET send_at = $2, attempts = attempts + 1, updated_at = now()
WHERE id = $1 AND updated_at = $3;
+2 -1
View File
@@ -53,7 +53,8 @@ func New(opts Opts) (*Manager, error) {
// Fields that need encryption.
encryptedFields := map[string]bool{
"notification.email.password": true,
"notification.email.password": true,
"notification.push.vapid_private_key": true,
}
return &Manager{
+6
View File
@@ -264,6 +264,12 @@ SET processed_at = NOW(),
updated_at = NOW()
WHERE id = $1;
-- name: update-notification-recipients
UPDATE scheduled_sla_notifications
SET recipients = $2,
updated_at = NOW()
WHERE id = $1;
-- name: insert-next-response-sla-event
INSERT INTO sla_events (applied_sla_id, sla_policy_id, type, deadline_at)
SELECT $1, $2, 'next_response', $3
+35 -29
View File
@@ -16,7 +16,6 @@ import (
cstatusmodels "github.com/abhinavxd/libredesk/internal/conversation/status/models"
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/envelope"
notifier "github.com/abhinavxd/libredesk/internal/notification"
nmodels "github.com/abhinavxd/libredesk/internal/notification/models"
"github.com/abhinavxd/libredesk/internal/sla/models"
"github.com/abhinavxd/libredesk/internal/stringutil"
@@ -57,6 +56,16 @@ var metricLabels = map[string]string{
MetricNextResponse: "Next response",
}
var metricNotificationTypes = map[string]struct{ warning, breach nmodels.NotificationType }{
MetricFirstResponse: {nmodels.NotificationTypeSLAFirstResponseWarn, nmodels.NotificationTypeSLAFirstResponseBreach},
MetricNextResponse: {nmodels.NotificationTypeSLANextResponseWarn, nmodels.NotificationTypeSLANextResponseBreach},
MetricResolution: {nmodels.NotificationTypeSLAResolutionWarn, nmodels.NotificationTypeSLAResolutionBreach},
}
type notificationDispatcher interface {
Send(nmodels.Notification) ([]nmodels.DeliveryResult, error)
}
type Manager struct {
q queries
lo *logf.Logger
@@ -66,7 +75,7 @@ type Manager struct {
appSettingsStore appSettingsStore
businessHrsStore businessHrsStore
template *template.Manager
dispatcher *notifier.Dispatcher
dispatcher notificationDispatcher
wg sync.WaitGroup
opts Opts
}
@@ -128,6 +137,7 @@ type queries struct {
LockConversations *sqlx.Stmt `query:"lock-conversations"`
CloseSettledAppliedSLAs *sqlx.Stmt `query:"close-settled-applied-slas"`
UpdateSLANotificationProcessed *sqlx.Stmt `query:"update-notification-processed"`
UpdateSLANotificationRecipients *sqlx.Stmt `query:"update-notification-recipients"`
UpdateSLAEventAsBreached *sqlx.Stmt `query:"update-sla-event-as-breached"`
UpdateSLAEventAsMet *sqlx.Stmt `query:"update-sla-event-as-met"`
SetLatestSLAEventMetAt *sqlx.Stmt `query:"set-latest-sla-event-met-at"`
@@ -143,7 +153,7 @@ func New(
businessHrsStore businessHrsStore,
template *template.Manager,
userStore userStore,
dispatcher *notifier.Dispatcher,
dispatcher notificationDispatcher,
) (*Manager, error) {
var q queries
if err := dbutil.ScanSQLFile(
@@ -615,7 +625,7 @@ func (m *Manager) SendNotification(scheduledNotification models.ScheduledSLANoti
}
// Send to all recipients (agents).
for _, recipientS := range scheduledNotification.Recipients {
for i, recipientS := range scheduledNotification.Recipients {
// Check if SLA is already met, if met mark notification as processed and return.
switch scheduledNotification.Metric {
case MetricFirstResponse:
@@ -662,18 +672,12 @@ func (m *Manager) SendNotification(scheduledNotification models.ScheduledSLANoti
// Recipient not found?
if recipientID == 0 {
if _, err := m.q.UpdateSLANotificationProcessed.Exec(scheduledNotification.ID); err != nil {
m.lo.Error("error marking notification as processed", "error", err)
}
continue
}
agent, err := m.userStore.GetAgent(recipientID, "")
if err != nil {
m.lo.Error("error fetching agent for SLA notification", "recipient_id", recipientID, "error", err)
if _, err := m.q.UpdateSLANotificationProcessed.Exec(scheduledNotification.ID); err != nil {
m.lo.Error("error marking notification as processed", "error", err)
}
continue
}
@@ -757,12 +761,9 @@ func (m *Manager) SendNotification(scheduledNotification models.ScheduledSLANoti
continue
}
// Determine notification type for in-app notification.
var notifType nmodels.NotificationType
notifType := metricNotificationTypes[scheduledNotification.Metric].warning
if scheduledNotification.NotificationType == NotificationTypeBreach {
notifType = nmodels.NotificationTypeSLABreach
} else {
notifType = nmodels.NotificationTypeSLAWarning
notifType = metricNotificationTypes[scheduledNotification.Metric].breach
}
notificationTitle := m.i18n.Ts("notification.slaAlert",
@@ -777,26 +778,31 @@ func (m *Manager) SendNotification(scheduledNotification models.ScheduledSLANoti
notificationBody = m.i18n.Ts("notification.slaDueIn", "duration", dueIn)
}
// Send notification via dispatcher (handles in-app, WebSocket, and email).
m.dispatcher.Send(notifier.Notification{
Type: notifType,
RecipientIDs: []int{recipientID},
if _, err := m.dispatcher.Send(nmodels.Notification{
Type: notifType,
Recipients: []nmodels.Recipient{{
UserID: recipientID,
Email: &nmodels.EmailNotification{
Recipient: agent.Email.String,
Subject: subject,
Content: content,
},
}},
Title: notificationTitle,
Body: null.StringFrom(notificationBody),
ConversationID: null.IntFrom(appliedSLA.ConversationID),
ConversationUUID: appliedSLA.ConversationUUID,
Email: &notifier.EmailNotification{
Recipients: []string{agent.Email.String},
Subject: subject,
Content: content,
},
})
// Mark the notification as processed.
if _, err := m.q.UpdateSLANotificationProcessed.Exec(scheduledNotification.ID); err != nil {
m.lo.Error("error marking notification as processed", "error", err)
}); err != nil {
// The retry must not re-notify recipients that already got this alert.
if _, uerr := m.q.UpdateSLANotificationRecipients.Exec(scheduledNotification.ID, scheduledNotification.Recipients[i:]); uerr != nil {
m.lo.Error("error updating pending notification recipients", "scheduled_notification_id", scheduledNotification.ID, "error", uerr)
}
return fmt.Errorf("sending SLA notification: %w", err)
}
}
if _, err := m.q.UpdateSLANotificationProcessed.Exec(scheduledNotification.ID); err != nil {
m.lo.Error("error marking notification as processed", "error", err)
}
return nil
}
+82
View File
@@ -3,15 +3,19 @@ package sla
import (
"context"
"database/sql"
"errors"
"fmt"
htmltemplate "html/template"
"sync"
"sync/atomic"
"testing"
"time"
bmodels "github.com/abhinavxd/libredesk/internal/business_hours/models"
nmodels "github.com/abhinavxd/libredesk/internal/notification/models"
"github.com/abhinavxd/libredesk/internal/sla/models"
tmodels "github.com/abhinavxd/libredesk/internal/team/models"
"github.com/abhinavxd/libredesk/internal/template"
"github.com/abhinavxd/libredesk/internal/testutil"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
"github.com/jmoiron/sqlx"
@@ -29,6 +33,12 @@ type stubAppSettingsStore struct{}
type stubBusinessHrsStore struct{ bh bmodels.BusinessHours }
type stubNotificationDispatcher struct {
err error
succeed int
sentTo []int
}
type appliedRow struct {
ID int `db:"id"`
Status string `db:"status"`
@@ -52,6 +62,16 @@ func (s stubBusinessHrsStore) Get(id int) (bmodels.BusinessHours, error) {
return s.bh, nil
}
func (s *stubNotificationDispatcher) Send(n nmodels.Notification) ([]nmodels.DeliveryResult, error) {
if len(s.sentTo) >= s.succeed {
return nil, s.err
}
for _, recipient := range n.Recipients {
s.sentTo = append(s.sentTo, recipient.UserID)
}
return nil, nil
}
func TestApplySLASetsDeadlinesAndConversation(t *testing.T) {
m, db := newTestManager(t)
policy := insertPolicy(t, db, "p1", "1h", "2h", "")
@@ -474,6 +494,68 @@ func TestSendNotificationSkipsMetMetric(t *testing.T) {
}
}
func TestSendNotificationKeepsPendingWhenDispatchFails(t *testing.T) {
m, db := newTestManager(t)
policy := insertPolicy(t, db, "p1", "1h", "2h", "")
conv := insertConversation(t, db, "c1")
applySLA(t, m, conv, policy)
appliedID := fetchApplied(t, db, conv)[0].ID
db.MustExec(`INSERT INTO scheduled_sla_notifications (applied_sla_id, metric, notification_type, recipients, send_at) VALUES ($1, 'first_response', 'warning', '{1}', NOW() - INTERVAL '1 min')`, appliedID)
var pending []models.ScheduledSLANotification
if err := m.q.GetScheduledSLANotifications.Select(&pending); err != nil {
t.Fatal(err)
}
if len(pending) != 1 {
t.Fatalf("pending notifications = %d, want 1", len(pending))
}
renderer, err := template.New(m.lo, db, nil, nil, htmltemplate.FuncMap{"RootURL": func() string { return "http://localhost" }}, m.i18n)
if err != nil {
t.Fatal(err)
}
dispatchErr := errors.New("preference lookup failed")
m.template = renderer
m.dispatcher = &stubNotificationDispatcher{err: dispatchErr}
if err := m.SendNotification(pending[0]); !errors.Is(err, dispatchErr) {
t.Fatalf("error = %v, want %v", err, dispatchErr)
}
if queryInt(t, db, `SELECT COUNT(*) FROM scheduled_sla_notifications WHERE id = $1 AND processed_at IS NOT NULL`, pending[0].ID) != 0 {
t.Fatal("failed notification was marked processed")
}
}
func TestSendNotificationRetriesOnlyUndeliveredRecipients(t *testing.T) {
m, db := newTestManager(t)
policy := insertPolicy(t, db, "p1", "1h", "2h", "")
conv := insertConversation(t, db, "c1")
applySLA(t, m, conv, policy)
appliedID := fetchApplied(t, db, conv)[0].ID
db.MustExec(`INSERT INTO scheduled_sla_notifications (applied_sla_id, metric, notification_type, recipients, send_at) VALUES ($1, 'first_response', 'warning', '{1,2,3}', NOW() - INTERVAL '1 min')`, appliedID)
var pending []models.ScheduledSLANotification
if err := m.q.GetScheduledSLANotifications.Select(&pending); err != nil {
t.Fatal(err)
}
renderer, err := template.New(m.lo, db, nil, nil, htmltemplate.FuncMap{"RootURL": func() string { return "http://localhost" }}, m.i18n)
if err != nil {
t.Fatal(err)
}
dispatchErr := errors.New("preference lookup failed")
m.template = renderer
m.dispatcher = &stubNotificationDispatcher{err: dispatchErr, succeed: 1}
if err := m.SendNotification(pending[0]); !errors.Is(err, dispatchErr) {
t.Fatalf("error = %v, want %v", err, dispatchErr)
}
if got := queryStr(t, db, `SELECT array_to_string(recipients, ',') FROM scheduled_sla_notifications WHERE id = $1`, pending[0].ID); got != "2,3" {
t.Fatalf("recipients = %q, want \"2,3\"", got)
}
if queryInt(t, db, `SELECT COUNT(*) FROM scheduled_sla_notifications WHERE id = $1 AND processed_at IS NOT NULL`, pending[0].ID) != 0 {
t.Fatal("partially delivered notification was marked processed")
}
}
func TestPolicyMetricCombinations(t *testing.T) {
cases := []struct {
name string
+8 -5
View File
@@ -11,11 +11,14 @@ import (
const (
// Built-in templates names stored in the database.
TmplConversationAssigned = "Conversation assigned"
TmplSLABreachWarning = "SLA breach warning"
TmplSLABreached = "SLA breached"
TmplMentioned = "Mentioned in conversation"
TmplCSATRequest = "CSAT request"
TmplConversationAssigned = "Conversation assigned"
TmplSLABreachWarning = "SLA breach warning"
TmplSLABreached = "SLA breached"
TmplMentioned = "Mentioned in conversation"
TmplCSATRequest = "CSAT request"
TmplNewReply = "New reply from contact"
TmplNewReplyParticipating = "New reply on participating conversation"
TmplConversationReopened = "Conversation reopened"
// Built-in templates fetched from memory stored in `static` directory.
TmplResetPassword = "reset-password"
+123 -2
View File
@@ -22,7 +22,8 @@ DROP TYPE IF EXISTS "sla_metric" CASCADE; CREATE TYPE "sla_metric" AS ENUM ('fir
DROP TYPE IF EXISTS "sla_notification_type" CASCADE; CREATE TYPE "sla_notification_type" AS ENUM ('warning', 'breach');
DROP TYPE IF EXISTS "activity_log_type" CASCADE; CREATE TYPE "activity_log_type" AS ENUM ('agent_login', 'agent_logout', 'agent_away', 'agent_away_reassigned', 'agent_online', 'agent_password_set', 'agent_role_permissions_changed', 'contact_deleted', 'contact_data_exported');
DROP TYPE IF EXISTS "macro_visible_when" CASCADE; CREATE TYPE "macro_visible_when" AS ENUM ('replying', 'starting_conversation', 'adding_private_note');
DROP TYPE IF EXISTS "user_notification_type" CASCADE; CREATE TYPE "user_notification_type" AS ENUM ('mention', 'assignment', 'sla_warning', 'sla_breach');
DROP TYPE IF EXISTS "user_notification_type" CASCADE; CREATE TYPE "user_notification_type" AS ENUM ('mention', 'assignment', 'sla_warning', 'sla_breach', 'new_reply', 'new_reply_participating', 'sla_first_response_warning', 'sla_first_response_breach', 'sla_next_response_warning', 'sla_next_response_breach', 'sla_resolution_warning', 'sla_resolution_breach', 'conversation_reopened', 'automation');
DROP TYPE IF EXISTS "notification_channel" CASCADE; CREATE TYPE "notification_channel" AS ENUM ('in_app', 'email', 'push');
DROP TYPE IF EXISTS "conversation_status_category" CASCADE; CREATE TYPE "conversation_status_category" AS ENUM ('open', 'waiting', 'resolved');
DROP TYPE IF EXISTS "ai_knowledge_type" CASCADE; CREATE TYPE "ai_knowledge_type" AS ENUM ('snippet');
DROP TYPE IF EXISTS "webhook_event" CASCADE; CREATE TYPE webhook_event AS ENUM (
@@ -953,6 +954,49 @@ CREATE INDEX index_user_notifications_on_user_id_is_read ON user_notifications(u
CREATE INDEX index_user_notifications_on_created_at ON user_notifications(created_at);
CREATE INDEX index_user_notifications_on_conversation_id ON user_notifications(conversation_id);
DROP TABLE IF EXISTS user_notification_preferences CASCADE;
CREATE TABLE user_notification_preferences (
id SERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
notification_type user_notification_type NOT NULL,
channel notification_channel NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
CONSTRAINT constraint_uniq_user_notification_preferences UNIQUE (user_id, notification_type, channel)
);
DROP TABLE IF EXISTS notification_push_subscriptions CASCADE;
CREATE TABLE notification_push_subscriptions (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL
);
CREATE INDEX index_notification_push_subscriptions_on_user_id ON notification_push_subscriptions(user_id);
DROP TABLE IF EXISTS notification_email_queue CASCADE;
CREATE TABLE notification_email_queue (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
notification_id BIGINT REFERENCES user_notifications(id) ON DELETE CASCADE ON UPDATE CASCADE,
notification_type user_notification_type NOT NULL,
conversation_id BIGINT REFERENCES conversations(id) ON DELETE CASCADE ON UPDATE CASCADE,
recipient_email TEXT NOT NULL,
subject TEXT NOT NULL,
content TEXT NOT NULL,
queued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
send_at TIMESTAMPTZ NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
CONSTRAINT constraint_uniq_notification_email_queue UNIQUE (user_id, notification_type, conversation_id)
);
CREATE INDEX index_notification_email_queue_on_send_at ON notification_email_queue(send_at);
INSERT INTO ai_providers
("name", provider, type, config, is_default)
VALUES
@@ -996,7 +1040,9 @@ VALUES
('notification.email.hello_hostname', '""'::jsonb),
('notification.email.email_address', '"admin@yourcompany.com"'::jsonb),
('notification.email.max_msg_retries', '3'::jsonb),
('notification.email.enabled', 'false'::jsonb);
('notification.email.enabled', 'false'::jsonb),
('notification.push.vapid_public_key', '""'::jsonb),
('notification.push.vapid_private_key', '""'::jsonb);
-- Default conversation priorities
INSERT INTO conversation_priorities (name) VALUES
@@ -1053,6 +1099,81 @@ VALUES('email_notification'::template_type, '
', false, 'Conversation assigned', 'New conversation assigned to you', true);
INSERT INTO templates
("type", body, is_default, "name", subject, is_builtin)
VALUES('email_notification'::template_type, '
<p>{{ .Author.FullName }} replied to a conversation assigned to you:</p>
<div>
Reference number: {{ .Conversation.ReferenceNumber }} <br>
Subject: {{ .Conversation.Subject }}
</div>
<blockquote style="background-color: #f5f5f5; padding: 12px; margin: 16px 0; border-left: 4px solid #ddd;">
{{ .Message.Content }}
</blockquote>
<p>
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
</p>
<div>
Best regards,<br>
Libredesk
</div>
', false, 'New reply from contact', 'New reply on conversation #{{ .Conversation.ReferenceNumber }}', true);
INSERT INTO templates
("type", body, is_default, "name", subject, is_builtin)
VALUES('email_notification'::template_type, '
<p>{{ .Author.FullName }} replied to a conversation you are participating in:</p>
<div>
Reference number: {{ .Conversation.ReferenceNumber }} <br>
Subject: {{ .Conversation.Subject }}
</div>
<blockquote style="background-color: #f5f5f5; padding: 12px; margin: 16px 0; border-left: 4px solid #ddd;">
{{ .Message.Content }}
</blockquote>
<p>
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
</p>
<div>
Best regards,<br>
Libredesk
</div>
', false, 'New reply on participating conversation', 'New reply on conversation #{{ .Conversation.ReferenceNumber }}', true);
INSERT INTO templates
("type", body, is_default, "name", subject, is_builtin)
VALUES('email_notification'::template_type, '
<p>{{ .Author.FullName }} replied and reopened a conversation assigned to you:</p>
<div>
Reference number: {{ .Conversation.ReferenceNumber }} <br>
Subject: {{ .Conversation.Subject }}
</div>
<blockquote style="background-color: #f5f5f5; padding: 12px; margin: 16px 0; border-left: 4px solid #ddd;">
{{ .Message.Content }}
</blockquote>
<p>
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
</p>
<div>
Best regards,<br>
Libredesk
</div>
', false, 'Conversation reopened', 'Conversation #{{ .Conversation.ReferenceNumber }} reopened', true);
INSERT INTO templates
("type", body, is_default, "name", subject, is_builtin)
VALUES (