From ced1a8bee519615819a18d202984b223df4020e6 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Tue, 15 Sep 2026 12:31:27 +0530 Subject: [PATCH 01/10] add per-agent notification preferences and browser push --- cmd/chat.go | 2 +- cmd/handlers.go | 27 + cmd/init.go | 60 ++- cmd/main.go | 16 +- cmd/pwa_test.go | 51 ++ cmd/settings.go | 4 + cmd/user_notifications.go | 66 +++ frontend/apps/main/index.html | 5 +- frontend/apps/main/src/App.vue | 10 +- frontend/apps/main/src/api/index.js | 8 + .../components/editor/suggestionRenderer.js | 2 +- .../editor/suggestionRenderer.test.js | 12 + .../components/sidebar/NotificationPanel.vue | 34 +- .../src/components/sidebar/SidebarNavUser.vue | 6 +- .../apps/main/src/composables/useLogout.js | 10 + .../main/src/composables/useLogout.test.js | 23 + .../src/composables/usePushNotifications.js | 82 +++ .../composables/usePushNotifications.test.js | 140 +++++ frontend/apps/main/src/constants/navIcons.js | 2 + .../apps/main/src/constants/navigation.js | 6 + .../features/admin/templates/TemplateForm.vue | 22 +- .../src/features/admin/templates/defaults.js | 158 ++++++ .../command/providers/useAccountCommands.js | 8 +- frontend/apps/main/src/router/index.js | 7 + .../NotificationPreferences.test.js | 72 +++ .../notifications/NotificationPreferences.vue | 168 ++++++ .../cypress/e2e/ui/pwaNotifications.cy.js | 18 + frontend/public/images/pwa-icon-192.png | Bin 0 -> 5475 bytes frontend/public/images/pwa-icon-512.png | Bin 0 -> 23465 bytes frontend/public/manifest.webmanifest | 22 + frontend/public/sw.js | 34 ++ go.mod | 1 + go.sum | 36 ++ i18n/en-US.json | 25 + internal/conversation/conversation.go | 499 ++++++++++++++---- .../conversation/conversation_reference.go | 14 +- .../conversation_reference_test.go | 21 +- internal/conversation/message.go | 16 +- internal/conversation/notification_test.go | 428 +++++++++++++++ internal/conversation/queries.sql | 17 + internal/httputil/httputil.go | 2 +- internal/httputil/httputil_test.go | 28 + internal/migrations/v2.9.0.go | 195 ++++++- internal/migrations/v2.9.0_test.go | 113 ++++ internal/notification/channels/email.go | 45 ++ internal/notification/channels/email_test.go | 63 +++ internal/notification/channels/in_app.go | 62 +++ internal/notification/channels/push.go | 46 ++ internal/notification/channels/types.go | 67 +++ internal/notification/dispatcher.go | 168 +----- internal/notification/dispatcher_test.go | 140 +++++ internal/notification/email_queue.go | 193 +++++++ internal/notification/email_queue_test.go | 392 ++++++++++++++ internal/notification/models/models.go | 114 +++- internal/notification/models/models_test.go | 24 + internal/notification/preferences.go | 136 +++++ internal/notification/preferences_test.go | 97 ++++ internal/notification/push.go | 325 ++++++++++++ internal/notification/push_store_test.go | 79 +++ internal/notification/push_test.go | 232 ++++++++ internal/notification/queries.sql | 82 +++ internal/setting/setting.go | 3 +- internal/sla/queries.sql | 6 + internal/sla/sla.go | 64 ++- internal/sla/sla_test.go | 82 +++ internal/template/render.go | 28 +- internal/template/render_test.go | 68 +++ schema.sql | 120 ++++- 68 files changed, 4754 insertions(+), 352 deletions(-) create mode 100644 cmd/pwa_test.go create mode 100644 frontend/apps/main/src/components/editor/suggestionRenderer.test.js create mode 100644 frontend/apps/main/src/composables/useLogout.js create mode 100644 frontend/apps/main/src/composables/useLogout.test.js create mode 100644 frontend/apps/main/src/composables/usePushNotifications.js create mode 100644 frontend/apps/main/src/composables/usePushNotifications.test.js create mode 100644 frontend/apps/main/src/features/admin/templates/defaults.js create mode 100644 frontend/apps/main/src/views/account/notifications/NotificationPreferences.test.js create mode 100644 frontend/apps/main/src/views/account/notifications/NotificationPreferences.vue create mode 100644 frontend/cypress/e2e/ui/pwaNotifications.cy.js create mode 100644 frontend/public/images/pwa-icon-192.png create mode 100644 frontend/public/images/pwa-icon-512.png create mode 100644 frontend/public/manifest.webmanifest create mode 100644 frontend/public/sw.js create mode 100644 internal/conversation/notification_test.go create mode 100644 internal/httputil/httputil_test.go create mode 100644 internal/notification/channels/email.go create mode 100644 internal/notification/channels/email_test.go create mode 100644 internal/notification/channels/in_app.go create mode 100644 internal/notification/channels/push.go create mode 100644 internal/notification/channels/types.go create mode 100644 internal/notification/dispatcher_test.go create mode 100644 internal/notification/email_queue.go create mode 100644 internal/notification/email_queue_test.go create mode 100644 internal/notification/models/models_test.go create mode 100644 internal/notification/preferences.go create mode 100644 internal/notification/preferences_test.go create mode 100644 internal/notification/push.go create mode 100644 internal/notification/push_store_test.go create mode 100644 internal/notification/push_test.go create mode 100644 internal/template/render_test.go diff --git a/cmd/chat.go b/cmd/chat.go index 113a0dd0..91a0eb79 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -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) } diff --git a/cmd/handlers.go b/cmd/handlers.go index 4a31cf9a..bd2fdc1e 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -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) diff --git a/cmd/init.go b/cmd/init.go index 9a91ce96..612f0a6e 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -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" @@ -301,6 +302,7 @@ func initConversations( template *tmpl.Manager, webhook *webhook.Manager, dispatcher *notifier.Dispatcher, + rdb *redis.Client, ) *conversation.Manager { continuityConfig := &conversation.ContinuityConfig{} if ko.Exists("conversation.continuity_scan_interval") { @@ -309,6 +311,7 @@ func initConversations( c, err := conversation.New(hub, i18n, sla, status, priority, inboxStore, userStore, teamStore, mediaStore, settings, csat, automationEngine, template, webhook, dispatcher, conversation.Opts{ DB: db, + Redis: rdb, Lo: initLogger("conversation_manager"), OutgoingMessageQueueSize: ko.MustInt("message.outgoing_queue_size"), IncomingMessageQueueSize: ko.MustInt("message.incoming_queue_size"), @@ -1163,6 +1166,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{ @@ -1171,14 +1187,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, }) } diff --git a/cmd/main.go b/cmd/main.go index 41db7ef6..f76ae697 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -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,11 +251,14 @@ 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) - conversation = initConversations(i18n, sla, status, priority, wsHub, db, inbox, user, team, media, settings, csat, automation, template, webhook, notifDispatcher) + conversation = initConversations(i18n, sla, status, priority, wsHub, db, inbox, user, team, media, settings, csat, automation, template, webhook, notifDispatcher, rdb) aiAgent = initAIAgent(db, i18n, ai, conversation, media, settings, user, notifier, rdb) helpCenter = initHelpCenter(db, i18n, ai) autoassigner = initAutoAssigner(team, user, conversation) @@ -261,6 +266,7 @@ func main() { ) wsHub.SetConversationStore(conversation) + notificationEmailQueue.SetConversationStore(conversation) automation.SetConversationStore(conversation) systemUser, err := user.GetSystemUser() if err != nil { @@ -285,6 +291,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 +339,8 @@ func main() { redis: rdb, fc: initFastCache(rdb), userNotification: userNotification, + notificationPref: notificationPreference, + pushNotification: pushNotification, wsHub: wsHub, } app.consts.Store(constants) diff --git a/cmd/pwa_test.go b/cmd/pwa_test.go new file mode 100644 index 00000000..65c1c7f9 --- /dev/null +++ b/cmd/pwa_test.go @@ -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") + } + }) + } +} diff --git a/cmd/settings.go b/cmd/settings.go index 6cb48c8f..4f09c9ab 100644 --- a/cmd/settings.go +++ b/cmd/settings.go @@ -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() diff --git a/cmd/user_notifications.go b/cmd/user_notifications.go index caadb242..f086d85f 100644 --- a/cmd/user_notifications.go +++ b/cmd/user_notifications.go @@ -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,67 @@ 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) + } + return r.SendEnvelope(map[string]any{ + "preferences": prefs, + "email_enabled": ko.Bool("notification.email.enabled"), + "vapid_public_key": app.pushNotification.PublicKey(), + }) +} + +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) +} diff --git a/frontend/apps/main/index.html b/frontend/apps/main/index.html index 781f17db..f4f773c6 100644 --- a/frontend/apps/main/index.html +++ b/frontend/apps/main/index.html @@ -5,6 +5,9 @@ + + + @@ -14,4 +17,4 @@ - \ No newline at end of file + diff --git a/frontend/apps/main/src/App.vue b/frontend/apps/main/src/App.vue index e5d80238..c306a44f 100644 --- a/frontend/apps/main/src/App.vue +++ b/frontend/apps/main/src/App.vue @@ -122,6 +122,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 +166,7 @@ const openCreateConversationDialog = ref(false) const createConversationContact = ref(null) const { t } = useI18n() const notificationStore = useNotificationStore() +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 +216,16 @@ const initStores = async () => { inboxStore.fetchInboxes(), slaStore.fetchSlas(), tagStore.fetchTags(), - customAttributeStore.fetchCustomAttributes() + customAttributeStore.fetchCustomAttributes(), + refreshPushSubscription() ]) } +const refreshPushSubscription = async () => { + const { data } = await api.getNotificationPreferences() + await pushNotifications.refresh(data.data.vapid_public_key) +} + const createView = () => { view.value = {} openCreateViewForm.value = true diff --git a/frontend/apps/main/src/api/index.js b/frontend/apps/main/src/api/index.js index 01e44525..bab5473b 100644 --- a/frontend/apps/main/src/api/index.js +++ b/frontend/apps/main/src/api/index.js @@ -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 } diff --git a/frontend/apps/main/src/components/editor/suggestionRenderer.js b/frontend/apps/main/src/components/editor/suggestionRenderer.js index e0db90dd..2c9647ae 100644 --- a/frontend/apps/main/src/components/editor/suggestionRenderer.js +++ b/frontend/apps/main/src/components/editor/suggestionRenderer.js @@ -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: () => { diff --git a/frontend/apps/main/src/components/editor/suggestionRenderer.test.js b/frontend/apps/main/src/components/editor/suggestionRenderer.test.js new file mode 100644 index 00000000..3ba1649b --- /dev/null +++ b/frontend/apps/main/src/components/editor/suggestionRenderer.test.js @@ -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) + }) +}) diff --git a/frontend/apps/main/src/components/sidebar/NotificationPanel.vue b/frontend/apps/main/src/components/sidebar/NotificationPanel.vue index 8ea422dd..7087d6a2 100644 --- a/frontend/apps/main/src/components/sidebar/NotificationPanel.vue +++ b/frontend/apps/main/src/components/sidebar/NotificationPanel.vue @@ -62,9 +62,9 @@
@@ -134,6 +134,8 @@ import { Trash2, AtSign, UserPlus, + MessageSquare, + RotateCcw, AlertTriangle, AlertCircle } from 'lucide-vue-next' @@ -149,24 +151,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) => { diff --git a/frontend/apps/main/src/components/sidebar/SidebarNavUser.vue b/frontend/apps/main/src/components/sidebar/SidebarNavUser.vue index 8f18dc99..717ab1a7 100644 --- a/frontend/apps/main/src/components/sidebar/SidebarNavUser.vue +++ b/frontend/apps/main/src/components/sidebar/SidebarNavUser.vue @@ -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() diff --git a/frontend/apps/main/src/composables/useLogout.js b/frontend/apps/main/src/composables/useLogout.js new file mode 100644 index 00000000..61f7de9a --- /dev/null +++ b/frontend/apps/main/src/composables/useLogout.js @@ -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()) diff --git a/frontend/apps/main/src/composables/useLogout.test.js b/frontend/apps/main/src/composables/useLogout.test.js new file mode 100644 index 00000000..c229baf9 --- /dev/null +++ b/frontend/apps/main/src/composables/useLogout.test.js @@ -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') + }) +}) diff --git a/frontend/apps/main/src/composables/usePushNotifications.js b/frontend/apps/main/src/composables/usePushNotifications.js new file mode 100644 index 00000000..a0aff321 --- /dev/null +++ b/frontend/apps/main/src/composables/usePushNotifications.js @@ -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) => { + if (!supported.value || !vapidPublicKey) return + const worker = await registerWorker() + const subscription = await worker.pushManager.getSubscription() + enabled.value = Boolean(subscription) + if (subscription) 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 diff --git a/frontend/apps/main/src/composables/usePushNotifications.test.js b/frontend/apps/main/src/composables/usePushNotifications.test.js new file mode 100644 index 00000000..221cfca7 --- /dev/null +++ b/frontend/apps/main/src/composables/usePushNotifications.test.js @@ -0,0 +1,140 @@ +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') + + 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('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') + + 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() + }) +}) diff --git a/frontend/apps/main/src/constants/navIcons.js b/frontend/apps/main/src/constants/navIcons.js index 7535e953..b85cb6fe 100644 --- a/frontend/apps/main/src/constants/navIcons.js +++ b/frontend/apps/main/src/constants/navIcons.js @@ -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, diff --git a/frontend/apps/main/src/constants/navigation.js b/frontend/apps/main/src/constants/navigation.js index d91fce40..28a5b581 100644 --- a/frontend/apps/main/src/constants/navigation.js +++ b/frontend/apps/main/src/constants/navigation.js @@ -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' } ] diff --git a/frontend/apps/main/src/features/admin/templates/TemplateForm.vue b/frontend/apps/main/src/features/admin/templates/TemplateForm.vue index e5e4cc8c..1a615f58 100644 --- a/frontend/apps/main/src/features/admin/templates/TemplateForm.vue +++ b/frontend/apps/main/src/features/admin/templates/TemplateForm.vue @@ -54,7 +54,18 @@ - +
+ + +
@@ -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, diff --git a/frontend/apps/main/src/features/admin/templates/defaults.js b/frontend/apps/main/src/features/admin/templates/defaults.js new file mode 100644 index 00000000..362331bb --- /dev/null +++ b/frontend/apps/main/src/features/admin/templates/defaults.js @@ -0,0 +1,158 @@ +export const BUILT_IN_TEMPLATE_BODIES = { + 'Conversation assigned': `

A new conversation has been assigned to you:

+ +
+ Reference number: {{ .Conversation.ReferenceNumber }}
+ Subject: {{ .Conversation.Subject }} +
+ +

+ View Conversation +

+ +
+ Best regards,
+ Libredesk +
`, + 'New reply from contact': `

{{ .Author.FullName }} replied to a conversation assigned to you:

+ +
+ Reference number: {{ .Conversation.ReferenceNumber }}
+ Subject: {{ .Conversation.Subject }} +
+ +
{{ .Message.Content }}
+ +

+ View Conversation +

+ +
+ Best regards,
+ Libredesk +
`, + 'New reply on participating conversation': `

{{ .Author.FullName }} replied to a conversation you are participating in:

+ +
+ Reference number: {{ .Conversation.ReferenceNumber }}
+ Subject: {{ .Conversation.Subject }} +
+ +
{{ .Message.Content }}
+ +

+ View Conversation +

+ +
+ Best regards,
+ Libredesk +
`, + 'Conversation reopened': `

{{ .Author.FullName }} replied and reopened a conversation assigned to you:

+ +
+ Reference number: {{ .Conversation.ReferenceNumber }}
+ Subject: {{ .Conversation.Subject }} +
+ +
{{ .Message.Content }}
+ +

+ View Conversation +

+ +
+ Best regards,
+ Libredesk +
`, + 'SLA breach warning': `

This is a notification that the SLA for conversation {{ .Conversation.ReferenceNumber }} is approaching the SLA deadline for {{ .SLA.Metric }}.

+ +

+ Details:
+ - Conversation reference number: {{ .Conversation.ReferenceNumber }}
+ - Metric: {{ .SLA.Metric }}
+ - Due in: {{ .SLA.DueIn }} +

+ +

+ View Conversation +

+ +

+ Best regards,
+ Libredesk +

`, + 'SLA breached': `

This is an urgent alert that the SLA for conversation {{ .Conversation.ReferenceNumber }} has been breached for {{ .SLA.Metric }}. Please take immediate action.

+ +

+ Details:
+ - Conversation reference number: {{ .Conversation.ReferenceNumber }}
+ - Metric: {{ .SLA.Metric }}
+ - Overdue by: {{ .SLA.OverdueBy }} +

+ +

+ View Conversation +

+ +

+ Best regards,
+ Libredesk +

`, + 'Mentioned in conversation': `

{{ .MentionedBy.FullName }} mentioned you in a private note on conversation #{{ .Conversation.ReferenceNumber }}.

+ +
+{{ .Message.Content }} +
+ +

+View Conversation +

+ +

+Best regards,
+libredesk +

`, + 'CSAT request': `

+ Your conversation #{{ .Conversation.ReferenceNumber }} has been resolved. +

+

+ We would love to hear how it went. +

+

+ How would you rate your experience? +

+ +
+
+ + 😢 + Poor + +
+
+ + 😕 + Fair + +
+
+ + 😊 + Good + +
+
+ + 😃 + Great + +
+
+ + 🤩 + Excellent + +
+
` +} diff --git a/frontend/apps/main/src/features/command/providers/useAccountCommands.js b/frontend/apps/main/src/features/command/providers/useAccountCommands.js index 02b00e60..572aaf0b 100644 --- a/frontend/apps/main/src/features/command/providers/useAccountCommands.js +++ b/frontend/apps/main/src/features/command/providers/useAccountCommands.js @@ -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 } ] }) diff --git a/frontend/apps/main/src/router/index.js b/frontend/apps/main/src/router/index.js index 34eb0ba3..7ea886b3 100644 --- a/frontend/apps/main/src/router/index.js +++ b/frontend/apps/main/src/router/index.js @@ -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 } } ] }, diff --git a/frontend/apps/main/src/views/account/notifications/NotificationPreferences.test.js b/frontend/apps/main/src/views/account/notifications/NotificationPreferences.test.js new file mode 100644 index 00000000..88df5723 --- /dev/null +++ b/frontend/apps/main/src/views/account/notifications/NotificationPreferences.test.js @@ -0,0 +1,72 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createApp, h, nextTick, ref } from 'vue' + +const { getPreferences, savePreferences, refresh, emit } = vi.hoisted(() => ({ + getPreferences: vi.fn(), + savePreferences: vi.fn().mockResolvedValue({}), + refresh: vi.fn(), + 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 }) +})) +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, 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('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('leaves the push subscription to the app shell', async () => { + await mount() + expect(root.textContent).toContain('notification.type.newReply') + expect(refresh).not.toHaveBeenCalled() + }) + + 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') + }) +}) diff --git a/frontend/apps/main/src/views/account/notifications/NotificationPreferences.vue b/frontend/apps/main/src/views/account/notifications/NotificationPreferences.vue new file mode 100644 index 00000000..b1ea9d86 --- /dev/null +++ b/frontend/apps/main/src/views/account/notifications/NotificationPreferences.vue @@ -0,0 +1,168 @@ + + + diff --git a/frontend/cypress/e2e/ui/pwaNotifications.cy.js b/frontend/cypress/e2e/ui/pwaNotifications.cy.js new file mode 100644 index 00000000..8945986f --- /dev/null +++ b/frontend/cypress/e2e/ui/pwaNotifications.cy.js @@ -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'") + }) + }) +}) diff --git a/frontend/public/images/pwa-icon-192.png b/frontend/public/images/pwa-icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..79beea19e3d04e4beae65e8adba8b59004278f92 GIT binary patch literal 5475 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 literal 0 HcmV?d00001 diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest new file mode 100644 index 00000000..dbcab3dd --- /dev/null +++ b/frontend/public/manifest.webmanifest @@ -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" + } + ] +} diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 00000000..d42c00e6 --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,34 @@ +const readPayload = (event) => { + try { + return event.data ? event.data.json() : null + } catch { + return null + } +} + +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) + if ('navigate' in client) await client.navigate(target.href) + return client.focus() + })) +}) diff --git a/go.mod b/go.mod index 1855f052..95f01f6e 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index ad4cc84a..cb8f7445 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/i18n/en-US.json b/i18n/en-US.json index 9428f883..726bf0b1 100644 --- a/i18n/en-US.json +++ b/i18n/en-US.json @@ -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,7 @@ "globals.terms.body": "Body", "globals.terms.brandName": "Brand name", "globals.terms.breadcrumb": "Breadcrumb", + "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 +1044,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", @@ -1109,6 +1112,7 @@ "globals.terms.provider": "Provider | Providers", "globals.terms.providerURL": "Provider URL", "globals.terms.published": "Published", + "globals.terms.push": "Push", "globals.terms.read": "Read", "globals.terms.reconnect": "Reconnect", "globals.terms.referenceNumber": "Reference number", @@ -1374,11 +1378,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.description": "Receive notifications on this device when libredesk is closed.", + "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.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.", diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index 657c4210..42fbd58c 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -8,6 +8,8 @@ import ( "encoding/json" "errors" "fmt" + "html" + htmltemplate "html/template" "io" "slices" "strconv" @@ -29,7 +31,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" @@ -42,6 +43,7 @@ import ( "github.com/jmoiron/sqlx/types" "github.com/knadh/go-i18n" "github.com/lib/pq" + "github.com/redis/go-redis/v9" "github.com/volatiletech/null/v9" "github.com/zerodha/logf" ) @@ -65,6 +67,9 @@ const (

#{{ .Conversation.ReferenceNumber }}

` + newReplyEmailDelay = 2 * time.Minute + replyNotifiedKeyPrefix = "conversation:reply-notified:" + replyNotifiedTTL = 7 * 24 * time.Hour ) var conversationFilterRenderers = dbutil.FieldRenderers{ @@ -80,6 +85,10 @@ var conversationListAllowedFields = 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 +102,7 @@ type Manager struct { settingsStore settingsStore csatStore csatStore webhookStore webhookStore - dispatcher *notifier.Dispatcher + dispatcher notificationDispatcher lo *logf.Logger db *sqlx.DB i18n *i18n.I18n @@ -105,12 +114,21 @@ type Manager struct { outgoingProcessingMessages sync.Map closed bool closedMu sync.RWMutex + replyNotificationMu sync.Mutex + rdb *redis.Client wg sync.WaitGroup continuityConfig ContinuityConfig subjectRefFormat string 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 +181,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 @@ -216,6 +233,7 @@ type ContinuityConfig struct { // Opts holds the options for creating a new Manager. type Opts struct { DB *sqlx.DB + Redis *redis.Client Lo *logf.Logger OutgoingMessageQueueSize int IncomingMessageQueueSize int @@ -239,7 +257,7 @@ func New( automation *automation.Engine, template *template.Manager, webhook webhookStore, - dispatcher *notifier.Dispatcher, + dispatcher notificationDispatcher, opts Opts) (*Manager, error) { var q queries @@ -284,6 +302,7 @@ func New( automation: automation, template: template, db: opts.DB, + rdb: opts.Redis, lo: opts.Lo, incomingMessageQueue: make(chan models.IncomingMessage, opts.IncomingMessageQueueSize), outgoingMessageQueue: make(chan models.Message, opts.OutgoingMessageQueueSize), @@ -297,44 +316,46 @@ func New( type queries struct { // Conversation queries. - GetConversationUUID *sqlx.Stmt `query:"get-conversation-uuid"` - GetConversation *sqlx.Stmt `query:"get-conversation"` - GetConversationListItem *sqlx.Stmt `query:"get-conversation-list-item"` - GetConversationsCreatedAfter *sqlx.Stmt `query:"get-conversations-created-after"` - GetUnassignedConversations *sqlx.Stmt `query:"get-unassigned-conversations"` - GetConversations string `query:"get-conversations"` - GetContactChatConversations *sqlx.Stmt `query:"get-contact-chat-conversations"` - GetChatConversation *sqlx.Stmt `query:"get-chat-conversation"` - GetContactPreviousConversations *sqlx.Stmt `query:"get-contact-previous-conversations"` - 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"` - GetUserActiveConversationsCount *sqlx.Stmt `query:"get-user-active-conversations-count"` - GetSidebarStandardCounts *sqlx.Stmt `query:"get-sidebar-standard-counts"` - GetConversationsCountBase string `query:"get-conversations-count-base"` - StartConversationWaitingSince *sqlx.Stmt `query:"start-conversation-waiting-since"` - UpdateConversationReplyTimestamps *sqlx.Stmt `query:"update-conversation-reply-timestamps"` - UpdateConversationContactLastSeen *sqlx.Stmt `query:"update-conversation-contact-last-seen"` - UpsertUserLastSeen *sqlx.Stmt `query:"upsert-user-last-seen"` - MarkConversationUnread *sqlx.Stmt `query:"mark-conversation-unread"` - UpdateConversationAssignedUser *sqlx.Stmt `query:"update-conversation-assigned-user"` - ClaimUnassignedConversation *sqlx.Stmt `query:"claim-unassigned-conversation"` - UpdateConversationAssignedTeam *sqlx.Stmt `query:"update-conversation-assigned-team"` - UpdateConversationCustomAttributes *sqlx.Stmt `query:"update-conversation-custom-attributes"` - UpdateConversationPriority *sqlx.Stmt `query:"update-conversation-priority"` - UpdateConversationStatus *sqlx.Stmt `query:"update-conversation-status"` - UpdateConversationLastMessage *sqlx.Stmt `query:"update-conversation-last-message"` - InsertConversationParticipant *sqlx.Stmt `query:"insert-conversation-participant"` - InsertConversation *sqlx.Stmt `query:"insert-conversation"` - AddConversationTags *sqlx.Stmt `query:"add-conversation-tags"` - SetConversationTags *sqlx.Stmt `query:"set-conversation-tags"` - RemoveConversationTags *sqlx.Stmt `query:"remove-conversation-tags"` - GetConversationTags *sqlx.Stmt `query:"get-conversation-tags"` - UnassignOpenConversations *sqlx.Stmt `query:"unassign-open-conversations"` - ReOpenConversation *sqlx.Stmt `query:"re-open-conversation"` - UnsnoozeAll *sqlx.Stmt `query:"unsnooze-all"` - DeleteConversation *sqlx.Stmt `query:"delete-conversation"` - RemoveConversationAssignee *sqlx.Stmt `query:"remove-conversation-assignee"` + GetConversationUUID *sqlx.Stmt `query:"get-conversation-uuid"` + GetConversation *sqlx.Stmt `query:"get-conversation"` + GetConversationListItem *sqlx.Stmt `query:"get-conversation-list-item"` + GetConversationsCreatedAfter *sqlx.Stmt `query:"get-conversations-created-after"` + GetUnassignedConversations *sqlx.Stmt `query:"get-unassigned-conversations"` + GetConversations string `query:"get-conversations"` + GetContactChatConversations *sqlx.Stmt `query:"get-contact-chat-conversations"` + GetChatConversation *sqlx.Stmt `query:"get-chat-conversation"` + GetContactPreviousConversations *sqlx.Stmt `query:"get-contact-previous-conversations"` + 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"` + StartConversationWaitingSince *sqlx.Stmt `query:"start-conversation-waiting-since"` + UpdateConversationReplyTimestamps *sqlx.Stmt `query:"update-conversation-reply-timestamps"` + UpdateConversationContactLastSeen *sqlx.Stmt `query:"update-conversation-contact-last-seen"` + UpsertUserLastSeen *sqlx.Stmt `query:"upsert-user-last-seen"` + GetUsersWithUnreadConversationMessage *sqlx.Stmt `query:"get-users-with-unread-conversation-message"` + MarkConversationUnread *sqlx.Stmt `query:"mark-conversation-unread"` + UpdateConversationAssignedUser *sqlx.Stmt `query:"update-conversation-assigned-user"` + ClaimUnassignedConversation *sqlx.Stmt `query:"claim-unassigned-conversation"` + UpdateConversationAssignedTeam *sqlx.Stmt `query:"update-conversation-assigned-team"` + UpdateConversationCustomAttributes *sqlx.Stmt `query:"update-conversation-custom-attributes"` + UpdateConversationPriority *sqlx.Stmt `query:"update-conversation-priority"` + UpdateConversationStatus *sqlx.Stmt `query:"update-conversation-status"` + UpdateConversationLastMessage *sqlx.Stmt `query:"update-conversation-last-message"` + InsertConversationParticipant *sqlx.Stmt `query:"insert-conversation-participant"` + InsertConversation *sqlx.Stmt `query:"insert-conversation"` + AddConversationTags *sqlx.Stmt `query:"add-conversation-tags"` + SetConversationTags *sqlx.Stmt `query:"set-conversation-tags"` + RemoveConversationTags *sqlx.Stmt `query:"remove-conversation-tags"` + GetConversationTags *sqlx.Stmt `query:"get-conversation-tags"` + UnassignOpenConversations *sqlx.Stmt `query:"unassign-open-conversations"` + ReOpenConversation *sqlx.Stmt `query:"re-open-conversation"` + UnsnoozeAll *sqlx.Stmt `query:"unsnooze-all"` + DeleteConversation *sqlx.Stmt `query:"delete-conversation"` + RemoveConversationAssignee *sqlx.Stmt `query:"remove-conversation-assignee"` // Draft queries. UpsertConversationDraft *sqlx.Stmt `query:"upsert-conversation-draft"` @@ -537,22 +558,56 @@ func (c *Manager) GetConversationsCreatedAfter(after time.Time, afterID, limit i // UpdateUserLastSeen updates the last seen timestamp for a specific user on a conversation. func (c *Manager) UpdateUserLastSeen(uuid string, userID int) error { + c.replyNotificationMu.Lock() + defer c.replyNotificationMu.Unlock() + if _, err := c.q.UpsertUserLastSeen.Exec(userID, uuid); err != nil { c.lo.Error("error upserting user last seen", "user_id", userID, "conversation_uuid", uuid, "error", err) return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil) } + c.clearReplyNotified(uuid, userID) return nil } // MarkAsUnread marks a conversation as unread for a specific user by setting last_seen to before the last message. func (c *Manager) MarkAsUnread(uuid string, userID int) error { + c.replyNotificationMu.Lock() + defer c.replyNotificationMu.Unlock() + if _, err := c.q.MarkConversationUnread.Exec(userID, uuid); err != nil { c.lo.Error("error marking conversation as unread", "user_id", userID, "conversation_uuid", uuid, "error", err) return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil) } + c.clearReplyNotified(uuid, userID) return nil } +// ReleaseReplyNotification clears reply suppression for an abandoned email, preserving newer claims. +func (m *Manager) ReleaseReplyNotification(conversationID, userID int, messageCreatedAt time.Time) { + if m.rdb == nil { + return + } + uuid, err := m.GetConversationUUID(conversationID) + if err != nil { + return + } + + m.replyNotificationMu.Lock() + defer m.replyNotificationMu.Unlock() + + claim, err := m.rdb.Get(context.Background(), replyNotifiedKey(uuid, userID)).Int64() + if errors.Is(err, redis.Nil) { + return + } + if err != nil { + m.lo.Error("error fetching reply notification claim", "conversation_uuid", uuid, "user_id", userID, "error", err) + return + } + if claim == 1 || claim == messageCreatedAt.UnixMicro() { + m.clearReplyNotified(uuid, userID) + } +} + // UpdateContactLastSeen updates the last seen timestamp of the contact in the conversation. func (c *Manager) UpdateConversationContactLastSeen(uuid string) error { var lastSeenAt time.Time @@ -683,14 +738,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 +756,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 +866,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 +1266,244 @@ 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, ConversationID: null.IntFrom(conversation.ID), ConversationUUID: conversation.UUID, - // Parms required for email - Email: ¬ifier.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 + } + candidateIDs := make([]int, len(group.recipients)) + for i, recipient := range group.recipients { + candidateIDs[i] = recipient.ID + } + + claimedIDs, err := m.claimUnreadRecipients(conversation, candidateIDs, message.CreatedAt) + if err != nil { + return + } + + var recipients []nmodels.Recipient + for _, recipient := range group.recipients { + if !slices.Contains(claimedIDs, recipient.ID) { + continue + } + email := m.renderNotificationEmail(group.tmpl, conversation, message, recipient, author) + if email != nil { + email.Delay = newReplyEmailDelay + } + recipients = append(recipients, nmodels.Recipient{UserID: recipient.ID, Email: email}) + } + if len(recipients) == 0 { + return + } + + results, err := m.dispatcher.Send(nmodels.Notification{ + Type: group.nType, + Recipients: recipients, + Title: group.title, + Body: conversation.Subject, + ConversationID: null.IntFrom(conversation.ID), + MessageID: null.IntFrom(message.ID), + ConversationUUID: conversation.UUID, + MessageUUID: message.UUID, + MessageCreatedAt: null.TimeFrom(message.CreatedAt), + }) + if err != nil { + m.lo.Error("error sending reply notification", "type", group.nType, "error", err) + m.clearReplyNotified(conversation.UUID, claimedIDs...) + return + } + var untracked []int + for _, result := range results { + if !slices.Contains(result.Channels, nmodels.NotificationChannelInApp) && + !slices.Contains(result.Channels, nmodels.NotificationChannelEmail) { + untracked = append(untracked, result.RecipientID) + } + } + m.clearReplyNotified(conversation.UUID, untracked...) +} + +// claimUnreadRecipients holds the reply notification lock across the unread check and the claim, returning the claimed recipients. +func (m *Manager) claimUnreadRecipients(conversation models.Conversation, userIDs []int, messageCreatedAt time.Time) ([]int, error) { + m.replyNotificationMu.Lock() + defer m.replyNotificationMu.Unlock() + + var unreadIDs []int + if err := m.q.GetUsersWithUnreadConversationMessage.Select(&unreadIDs, conversation.ID, pq.Array(userIDs), messageCreatedAt); err != nil { + m.lo.Error("error checking conversation unread state", "conversation_uuid", conversation.UUID, "error", err) + return nil, err + } + return m.claimReplyNotified(conversation.UUID, unreadIDs, messageCreatedAt), nil +} + +func (m *Manager) claimReplyNotified(conversationUUID string, userIDs []int, messageCreatedAt time.Time) []int { + if len(userIDs) == 0 || m.rdb == nil { + return userIDs + } + ctx := context.Background() + pipe := m.rdb.Pipeline() + claims := make([]*redis.BoolCmd, len(userIDs)) + for i, userID := range userIDs { + claims[i] = pipe.SetNX(ctx, replyNotifiedKey(conversationUUID, userID), messageCreatedAt.UnixMicro(), replyNotifiedTTL) + } + if _, err := pipe.Exec(ctx); err != nil { + m.lo.Error("error claiming reply notifications", "conversation_uuid", conversationUUID, "error", err) + return userIDs + } + claimed := make([]int, 0, len(userIDs)) + for i, userID := range userIDs { + if claims[i].Val() { + claimed = append(claimed, userID) + } + } + return claimed +} + +func (m *Manager) clearReplyNotified(conversationUUID string, userIDs ...int) { + if len(userIDs) == 0 || m.rdb == nil { + return + } + keys := make([]string, len(userIDs)) + for i, userID := range userIDs { + keys[i] = replyNotifiedKey(conversationUUID, userID) + } + if err := m.rdb.Del(context.Background(), keys...).Err(); err != nil { + m.lo.Error("error clearing reply notification", "user_ids", userIDs, "conversation_uuid", conversationUUID, "error", err) + } +} + +// renderNotificationEmail returns nil when the recipient has no email or the template fails to render. +func (m *Manager) renderNotificationEmail(tmplName string, conversation models.Conversation, message models.Message, recipient, author umodels.User) *nmodels.EmailNotification { + if recipient.Email.String == "" { + return nil + } + data := notificationTemplateData(conversation, recipient, author) + data["Message"] = map[string]any{ + "UUID": message.UUID, + "Content": htmltemplate.HTML(strings.ReplaceAll(html.EscapeString(message.TextContent), "\n", "
")), + } + content, subject, err := m.template.RenderStoredEmailTemplate(tmplName, data) + if err != nil { + m.lo.Error("error rendering template", "template", tmplName, "conversation_uuid", conversation.UUID, "error", err) + return nil + } + return &nmodels.EmailNotification{ + Recipient: recipient.Email.String, + Subject: subject, + Content: content, + } +} + // 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 +1514,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,9 +1552,9 @@ 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 := sanitizeNotificationHTML(message.Content, rootURL) - recipientIDs, emails := m.buildRecipientEmails(userIDs, func(recipient umodels.User) (string, string, error) { + recipients := m.buildNotificationRecipients(userIDs, func(recipient umodels.User) (string, string, error) { content, subject, err := m.template.RenderStoredEmailTemplate(template.TmplMentioned, map[string]any{ "Conversation": map[string]any{ @@ -1355,23 +1590,25 @@ func (m *Manager) NotifyMention(conversationUUID string, message models.Message, 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,7 +1779,7 @@ 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) { + recipients := m.buildNotificationRecipients(userIDs, func(recipient umodels.User) (string, string, error) { content, err := m.template.RenderEmailWithTemplate( map[string]any{ "Conversation": map[string]any{ @@ -1576,49 +1813,46 @@ func (m *Manager) notifyAutomation(subject, message string, entries []string, co return subject, content, err }) - m.dispatcher.SendWithEmails(notifier.Notification{ + if _, err := m.dispatcher.Send(nmodels.Notification{ Type: nmodels.NotificationTypeMention, - RecipientIDs: recipientIDs, + 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 +2190,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 +2487,39 @@ func renderTagFilter(operator, value string, paramIndex int) (string, []any, err } } +func replyNotifiedKey(conversationUUID string, userID int) string { + return replyNotifiedKeyPrefix + conversationUUID + ":" + strconv.Itoa(userID) +} + +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)) diff --git a/internal/conversation/conversation_reference.go b/internal/conversation/conversation_reference.go index b600267a..b76f58fe 100644 --- a/internal/conversation/conversation_reference.go +++ b/internal/conversation/conversation_reference.go @@ -1,6 +1,13 @@ package conversation -import "strings" +import ( + htmltemplate "html/template" + "strings" + + "github.com/microcosm-cc/bluemonday" +) + +var notificationHTMLPolicy = bluemonday.UGCPolicy() func absolutizeConversationReferenceLinks(content, rootURL string) string { if rootURL == "" { @@ -12,3 +19,8 @@ func absolutizeConversationReferenceLinks(content, rootURL string) string { `href="`+strings.TrimRight(rootURL, "/")+`/inboxes/all/conversation/`, ) } + +func sanitizeNotificationHTML(content, rootURL string) htmltemplate.HTML { + content = notificationHTMLPolicy.Sanitize(content) + return htmltemplate.HTML(absolutizeConversationReferenceLinks(content, rootURL)) +} diff --git a/internal/conversation/conversation_reference_test.go b/internal/conversation/conversation_reference_test.go index 954b6c91..24a390f3 100644 --- a/internal/conversation/conversation_reference_test.go +++ b/internal/conversation/conversation_reference_test.go @@ -1,6 +1,9 @@ package conversation -import "testing" +import ( + "strings" + "testing" +) func TestAbsolutizeConversationReferenceLinks(t *testing.T) { content := `

#108 Help #109

` @@ -10,3 +13,19 @@ func TestAbsolutizeConversationReferenceLinks(t *testing.T) { t.Fatalf("got %q, want %q", got, want) } } + +func TestSanitizeNotificationHTML(t *testing.T) { + content := `

Allowed#108

` + got := string(sanitizeNotificationHTML(content, "https://desk.example.com")) + + for _, unsafe := range []string{"onerror", "Allowed", `href="https://desk.example.com/inboxes/all/conversation/abc"`} { + if !strings.Contains(got, safe) { + t.Fatalf("safe content %q missing from %q", safe, got) + } + } +} diff --git a/internal/conversation/message.go b/internal/conversation/message.go index 29cea742..b31e3a8c 100644 --- a/internal/conversation/message.go +++ b/internal/conversation/message.go @@ -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) diff --git a/internal/conversation/notification_test.go b/internal/conversation/notification_test.go new file mode 100644 index 00000000..1329f4bc --- /dev/null +++ b/internal/conversation/notification_test.go @@ -0,0 +1,428 @@ +package conversation + +import ( + "errors" + "fmt" + htmltemplate "html/template" + "slices" + "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/alicebob/miniredis/v2" + "github.com/alicebob/miniredis/v2/server" + "github.com/jmoiron/sqlx" + "github.com/redis/go-redis/v9" + "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 TestReplyNotificationsAlertOncePerUnreadConversation(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"` + LastSeen *sqlx.Stmt `query:"upsert-user-last-seen"` + MarkUnread *sqlx.Stmt `query:"mark-conversation-unread"` + Unread *sqlx.Stmt `query:"get-users-with-unread-conversation-message"` + UUID *sqlx.Stmt `query:"get-conversation-uuid"` + } + 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) + + var replyRedis *miniredis.Miniredis + newManager := func(channels ...nmodels.NotificationChannel) *Manager { + db.MustExec(`DELETE FROM conversation_last_seen`) + db.MustExec(`DELETE FROM notification_email_queue`) + db.MustExec(`DELETE FROM user_notifications`) + mr := miniredis.RunT(t) + replyRedis = mr + m := &Manager{lo: &lo, i18n: i18n, template: templates, rdb: redis.NewClient(&redis.Options{Addr: mr.Addr()}), + 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 + m.q.UpsertUserLastSeen = q.LastSeen + m.q.MarkConversationUnread = q.MarkUnread + m.q.GetUsersWithUnreadConversationMessage = q.Unread + m.q.GetConversationUUID = q.UUID + return m + } + message := models.Message{ID: messageID, SenderID: userID + 1, CreatedAt: time.Now().UTC().Truncate(time.Microsecond)} + + t.Run("one alert per unread conversation", func(t *testing.T) { + m := newManager(nmodels.NotificationChannelInApp) + notifications := func() int { + var count int + db.Get(&count, `SELECT count(*) FROM user_notifications`) + return count + } + + 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() != 1 { + t.Fatalf("notifications after a second unread reply = %d, want 1", notifications()) + } + + m.NotifyNewReply(conv, next, true) + if notifications() != 1 { + t.Fatalf("notifications after a reopen on an unread conversation = %d, want 1", notifications()) + } + + if err := m.UpdateUserLastSeen(conv.UUID, userID); err != nil { + t.Fatal(err) + } + later := next + later.CreatedAt = time.Now().UTC().Add(time.Minute) + m.NotifyNewReply(conv, later, false) + if notifications() != 2 { + t.Fatalf("notifications after reading and replying again = %d, want 2", notifications()) + } + + if err := m.MarkAsUnread(conv.UUID, userID); err != nil { + t.Fatal(err) + } + m.NotifyNewReply(conv, later, false) + if notifications() != 3 { + t.Fatalf("notifications after marking unread and replying again = %d, want 3", notifications()) + } + + if err := m.UpdateUserLastSeen(conv.UUID, userID); err != nil { + t.Fatal(err) + } + read := later + read.CreatedAt = time.Now().Add(-time.Minute) + m.NotifyNewReply(conv, read, false) + if notifications() != 3 { + t.Fatalf("a reply the agent had already read was notified, notifications = %d", notifications()) + } + }) + + t.Run("replies arriving together alert once", 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() + + var count int + db.Get(&count, `SELECT count(*) FROM user_notifications`) + if count != 1 { + t.Fatalf("notifications from concurrent replies = %d, want 1", count) + } + }) + + for _, markUnread := range []bool{false, true} { + t.Run(fmt.Sprintf("read during claim/markUnread=%v", markUnread), func(t *testing.T) { + m := newManager(nmodels.NotificationChannelInApp) + readDone := make(chan error, 1) + var once sync.Once + replyRedis.Server().SetPreHook(func(_ *server.Peer, cmd string, _ ...string) bool { + if cmd == "SET" { + once.Do(func() { + go func() { + if markUnread { + readDone <- m.MarkAsUnread(conv.UUID, userID) + } else { + readDone <- m.UpdateUserLastSeen(conv.UUID, userID) + } + }() + select { + case err := <-readDone: + readDone <- err + case <-time.After(100 * time.Millisecond): + } + }) + } + return false + }) + + m.NotifyNewReply(conv, message, false) + if err := <-readDone; err != nil { + t.Fatal(err) + } + if replyRedis.Exists(replyNotifiedKey(conv.UUID, userID)) { + t.Fatal("completed read left a reply suppression claim") + } + + next := message + next.CreatedAt = time.Now().UTC().Add(time.Minute) + m.NotifyNewReply(conv, next, false) + var count int + if err := db.Get(&count, `SELECT count(*) FROM user_notifications`); err != nil { + t.Fatal(err) + } + if count != 2 { + t.Fatalf("notifications after a concurrent read and a new reply = %d, want 2", count) + } + }) + } + + for _, state := range []string{"current", "legacy", "newer", "read"} { + t.Run("abandoned email releases only its claim/"+state, func(t *testing.T) { + m := newManager(nmodels.NotificationChannelEmail) + m.NotifyNewReply(conv, message, false) + key := replyNotifiedKey(conv.UUID, userID) + if state == "legacy" { + if err := replyRedis.Set(key, "1"); err != nil { + t.Fatal(err) + } + } + if state == "newer" || state == "read" { + if err := m.UpdateUserLastSeen(conv.UUID, userID); err != nil { + t.Fatal(err) + } + } + next := message + next.CreatedAt = time.Now().UTC().Add(time.Minute) + if state == "newer" { + m.NotifyNewReply(conv, next, false) + } + + m.ReleaseReplyNotification(conv.ID, userID, message.CreatedAt) + if got, want := replyRedis.Exists(key), state == "newer"; got != want { + t.Fatalf("claim after abandonment = %v, want %v", got, want) + } + m.NotifyNewReply(conv, next, false) + if !replyRedis.Exists(key) { + t.Fatal("next unread reply was not claimed") + } + }) + } + + t.Run("push only agent keeps no claim", 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.dispatcher = notifier.NewDispatcher(notifier.DispatcherOpts{ + Pipeline: nchannels.NewPipeline(nchannels.NewInApp(inApp, nil, &lo), nchannels.NewEmail(emailQueue)), + Prefs: &replyPreferences{channels: []nmodels.NotificationChannel{nmodels.NotificationChannelInApp}}, + }) + m.NotifyNewReply(conv, message, false) + + var count int + db.Get(&count, `SELECT count(*) FROM user_notifications`) + if count != 1 { + t.Fatalf("notifications after a push that stored nothing = %d, want 1", count) + } + }) + + t.Run("agent with no channel enabled is not notified", func(t *testing.T) { + m := newManager() + m.NotifyNewReply(conv, message, false) + var count int + db.Get(&count, `SELECT count(*) FROM user_notifications`) + if count != 0 { + t.Fatalf("notifications = %d, want 0", count) + } + }) + + t.Run("preference failure releases claim", 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 replyRedis.Exists(replyNotifiedKey(conv.UUID, userID)) { + t.Fatal("preference failure left a reply suppression claim") + } + }) + + t.Run("email only agent is alerted once until they read the conversation", 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) + } + + db.MustExec(`DELETE FROM notification_email_queue`) + if err := m.UpdateUserLastSeen(conv.UUID, userID); err != nil { + t.Fatal(err) + } + later := next + later.CreatedAt = time.Now().UTC().Add(time.Minute) + m.NotifyNewReply(conv, later, false) + db.Get(&queued, `SELECT count(*) FROM notification_email_queue`) + if queued != 1 { + t.Fatalf("queued emails after reading and replying again = %d, want 1", queued) + } + }) +} diff --git a/internal/conversation/queries.sql b/internal/conversation/queries.sql index dd75f6d1..47e1ca48 100644 --- a/internal/conversation/queries.sql +++ b/internal/conversation/queries.sql @@ -509,6 +509,13 @@ VALUES ($1, (SELECT id FROM conversations WHERE uuid = $2), NOW()) ON CONFLICT (conversation_id, user_id) DO UPDATE SET last_seen_at = NOW(), updated_at = NOW(); +-- name: get-users-with-unread-conversation-message +SELECT candidate.user_id +FROM unnest($2::BIGINT[]) AS candidate(user_id) +LEFT JOIN conversation_last_seen seen + ON seen.conversation_id = $1 AND seen.user_id = candidate.user_id +WHERE seen.last_seen_at IS NULL OR seen.last_seen_at < $3; + -- name: update-conversation-last-message -- $1=id, $2=uuid, $3=content, $4=sender_type, $5=timestamp, $6=message_type, $7=private, $8=sender_id UPDATE conversations SET @@ -535,6 +542,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) diff --git a/internal/httputil/httputil.go b/internal/httputil/httputil.go index 361c467e..7038a381 100644 --- a/internal/httputil/httputil.go +++ b/internal/httputil/httputil.go @@ -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 diff --git a/internal/httputil/httputil_test.go b/internal/httputil/httputil_test.go new file mode 100644 index 00000000..429049a7 --- /dev/null +++ b/internal/httputil/httputil_test.go @@ -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) + } + }) + } +} diff --git a/internal/migrations/v2.9.0.go b/internal/migrations/v2.9.0.go index 212e0bcc..bd4180b0 100644 --- a/internal/migrations/v2.9.0.go +++ b/internal/migrations/v2.9.0.go @@ -6,12 +6,205 @@ 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", +} + 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(), + message_created_at TIMESTAMPTZ, + 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 message_created_at TIMESTAMPTZ; + `); 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, ' +

{{ .Author.FullName }} replied to a conversation assigned to you:

+ +
+ Reference number: {{ .Conversation.ReferenceNumber }}
+ Subject: {{ .Conversation.Subject }} +
+ +
{{ .Message.Content }}
+ +

+ View Conversation +

+ +
+ Best regards,
+ Libredesk +
+ +', 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, ' +

{{ .Author.FullName }} replied to a conversation you are participating in:

+ +
+ Reference number: {{ .Conversation.ReferenceNumber }}
+ Subject: {{ .Conversation.Subject }} +
+ +
{{ .Message.Content }}
+ +

+ View Conversation +

+ +
+ Best regards,
+ Libredesk +
+ +', 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, ' +

{{ .Author.FullName }} replied and reopened a conversation assigned to you:

+ +
+ Reference number: {{ .Conversation.ReferenceNumber }}
+ Subject: {{ .Conversation.Subject }} +
+ +
{{ .Message.Content }}
+ +

+ View Conversation +

+ +
+ Best regards,
+ Libredesk +
+ +', false, 'Conversation reopened', 'Conversation #{{ .Conversation.ReferenceNumber }} reopened', true + WHERE NOT EXISTS (SELECT 1 FROM templates WHERE "name" = 'Conversation reopened'); `) return err } diff --git a/internal/migrations/v2.9.0_test.go b/internal/migrations/v2.9.0_test.go index 9654c7cf..d8e6f0d7 100644 --- a/internal/migrations/v2.9.0_test.go +++ b/internal/migrations/v2.9.0_test.go @@ -52,3 +52,116 @@ 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 expectedMessageTimeColumn, expectedAttemptsColumn string + if err := db.Get(&expectedMessageTimeColumn, columnQuery, "message_created_at"); err != nil { + t.Fatal(err) + } + if err := db.Get(&expectedAttemptsColumn, columnQuery, "attempts"); err != nil { + t.Fatal(err) + } + db.MustExec(`ALTER TABLE notification_email_queue DROP COLUMN message_created_at, 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 actualMessageTimeColumn, actualAttemptsColumn string + if err := db.Get(&actualMessageTimeColumn, columnQuery, "message_created_at"); err != nil { + t.Fatal(err) + } + if err := db.Get(&actualAttemptsColumn, columnQuery, "attempts"); err != nil { + t.Fatal(err) + } + if actualMessageTimeColumn != expectedMessageTimeColumn { + t.Fatalf("message time column = %q, schema column = %q", actualMessageTimeColumn, expectedMessageTimeColumn) + } + 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 message_created_at IS NULL AND attempts = 0 FROM notification_email_queue`); err != nil { + t.Fatal(err) + } + if !preserved { + t.Fatal("existing queued email changed") + } +} diff --git a/internal/notification/channels/email.go b/internal/notification/channels/email.go new file mode 100644 index 00000000..9d2b71d7 --- /dev/null +++ b/internal/notification/channels/email.go @@ -0,0 +1,45 @@ +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, + MessageCreatedAt: delivery.Notification.MessageCreatedAt, + 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)} +} diff --git a/internal/notification/channels/email_test.go b/internal/notification/channels/email_test.go new file mode 100644 index 00000000..ce05b49a --- /dev/null +++ b/internal/notification/channels/email_test.go @@ -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) + } +} diff --git a/internal/notification/channels/in_app.go b/internal/notification/channels/in_app.go new file mode 100644 index 00000000..caef1d5e --- /dev/null +++ b/internal/notification/channels/in_app.go @@ -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}}) +} diff --git a/internal/notification/channels/push.go b/internal/notification/channels/push.go new file mode 100644 index 00000000..5261fcf8 --- /dev/null +++ b/internal/notification/channels/push.go @@ -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(string(n.Type), n.ConversationUUID, n.MessageUUID), + }) + return Result{Sent: sent} +} + +func pushRoute(notificationType, conversationUUID, messageUUID string) string { + list := "assigned" + if notificationType == "mention" { + list = "mentioned" + } + route := fmt.Sprintf("/inboxes/%s/conversation/%s", list, conversationUUID) + if messageUUID != "" { + route += "?scrollTo=" + messageUUID + } + return route +} diff --git a/internal/notification/channels/types.go b/internal/notification/channels/types.go new file mode 100644 index 00000000..9b4ab2ac --- /dev/null +++ b/internal/notification/channels/types.go @@ -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 + } +} diff --git a/internal/notification/dispatcher.go b/internal/notification/dispatcher.go index 74b15b89..82208714 100644 --- a/internal/notification/dispatcher.go +++ b/internal/notification/dispatcher.go @@ -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 ¬ification -} -// 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 } diff --git a/internal/notification/dispatcher_test.go b/internal/notification/dispatcher_test.go new file mode 100644 index 00000000..1e471db8 --- /dev/null +++ b/internal/notification/dispatcher_test.go @@ -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) + } +} diff --git a/internal/notification/email_queue.go b/internal/notification/email_queue.go new file mode 100644 index 00000000..db9dfecd --- /dev/null +++ b/internal/notification/email_queue.go @@ -0,0 +1,193 @@ +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 replyNotificationStore interface { + ReleaseReplyNotification(conversationID, userID int, messageCreatedAt time.Time) +} + +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"` + MessageCreatedAt null.Time `db:"message_created_at"` +} + +type EmailQueue struct { + q emailQueueQueries + outbound EmailSender + conversationStore replyNotificationStore + 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) SetConversationStore(store replyNotificationStore) { + q.conversationStore = store +} + +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), e.MessageCreatedAt); 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.MessageCreatedAt); 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.abandon(e) + } else { + q.retry(e) + } + return false + } + q.delete(e) + return true +} + +func (q *EmailQueue) abandon(e queuedEmail) { + if !q.delete(e) || e.NotificationID.Valid || !e.ConversationID.Valid || q.conversationStore == nil { + return + } + switch e.Type { + case models.NotificationTypeNewReply, models.NotificationTypeNewReplyParticipating, models.NotificationTypeConversationReopened: + q.conversationStore.ReleaseReplyNotification(e.ConversationID.Int, e.UserID, e.MessageCreatedAt.Time) + } +} + +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) + } +} diff --git a/internal/notification/email_queue_test.go b/internal/notification/email_queue_test.go new file mode 100644 index 00000000..1d976800 --- /dev/null +++ b/internal/notification/email_queue_test.go @@ -0,0 +1,392 @@ +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 +} + +type replyNotificationRecorder struct { + release func(int, int, time.Time) +} + +func (p *emailDeliveryProvider) Send(Message) error { + return p.err +} + +func (p *emailDeliveryProvider) Name() string { + return ProviderEmail +} + +func (s *replyNotificationRecorder) ReleaseReplyNotification(conversationID, userID int, messageCreatedAt time.Time) { + s.release(conversationID, userID, messageCreatedAt) +} + +func TestDelayedReplyEmailUsesMessageTime(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}}}, + }) + messageTime := time.Now().UTC().Add(-time.Minute).Truncate(time.Microsecond) + 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), + MessageCreatedAt: null.TimeFrom(messageTime), + } + db.MustExec(`INSERT INTO conversation_last_seen (user_id, conversation_id, last_seen_at) VALUES ($1, $2, $3)`, userID, convID, messageTime.Add(-time.Second)) + for _, tt := range []struct { + name string + readTime time.Time + want bool + }{ + {"read before reply", messageTime.Add(-time.Second), false}, + {"read at reply", messageTime, true}, + {"read before enqueue", messageTime.Add(time.Second), true}, + } { + t.Run(tt.name, func(t *testing.T) { + db.MustExec(`UPDATE conversation_last_seen SET last_seen_at = $1`, tt.readTime) + d.Send(n) + 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 = $1`, messageTime.Add(time.Second)) + d.Send(n) + n.MessageCreatedAt = null.TimeFrom(messageTime.Add(2 * time.Second)) + n.Recipients[0].Email.Content = "Second reply" + d.Send(n) + 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) + } + 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`, messageTime.Add(3*time.Second)) + seen, err = queue.seen(due[0]) + if err != nil { + t.Fatal(err) + } + if !seen { + t.Fatal("read second reply was not suppressed") + } + }) + + t.Run("legacy email without message time", func(t *testing.T) { + n.MessageCreatedAt = null.Time{} + d.Send(n) + 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)) + } + seen, err := queue.seen(due[0]) + if err != nil { + t.Fatal(err) + } + if seen { + t.Fatal("legacy unread email suppressed") + } + db.MustExec(`UPDATE conversation_last_seen SET last_seen_at = now()`) + seen, err = queue.seen(due[0]) + if err != nil { + t.Fatal(err) + } + if !seen { + t.Fatal("legacy read email 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)) + } +} + +func TestAbandonedReplyEmailReleasesSuppression(t *testing.T) { + db := testutil.NewDB(t, "notification_email_abandonment") + var userID, inboxID, convID, notificationID int + if err := db.Get(&userID, `INSERT INTO users (type, email, first_name, last_name) VALUES ('agent', 'abandon@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) + } + if err := db.Get(¬ificationID, `INSERT INTO user_notifications (user_id, notification_type, title, conversation_id) VALUES ($1, 'new_reply', 'Reply', $2) RETURNING id`, userID, convID); 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) + } + for _, tt := range []struct { + name string + nType models.NotificationType + inApp bool + replaced bool + delivered bool + attempts int + wantReleased bool + wantQueued int + }{ + {name: "email only", nType: models.NotificationTypeNewReply, attempts: 2, wantReleased: true}, + {name: "participant", nType: models.NotificationTypeNewReplyParticipating, attempts: 2, wantReleased: true}, + {name: "reopened", nType: models.NotificationTypeConversationReopened, attempts: 2, wantReleased: true}, + {name: "in app remains", nType: models.NotificationTypeNewReply, inApp: true, attempts: 2}, + {name: "retry remains", nType: models.NotificationTypeNewReply, attempts: 1, wantQueued: 1}, + {name: "newer email remains", nType: models.NotificationTypeNewReply, replaced: true, attempts: 2, wantQueued: 1}, + {name: "successful delivery", nType: models.NotificationTypeNewReply, delivered: true, attempts: 2}, + {name: "unrelated notification", nType: models.NotificationTypeAssignment, attempts: 2}, + } { + t.Run(tt.name, func(t *testing.T) { + db.MustExec(`DELETE FROM notification_email_queue`) + messageTime := time.Now().UTC().Truncate(time.Microsecond) + email := models.Email{UserID: userID, Type: tt.nType, ConversationID: null.IntFrom(convID), + MessageCreatedAt: null.TimeFrom(messageTime), Recipient: "abandon@example.com", Subject: "Reply", Content: "Reply"} + if tt.inApp { + email.NotificationID = null.IntFrom(notificationID) + } + released := false + queue.SetConversationStore(&replyNotificationRecorder{release: func(gotConv, gotUser int, gotTime time.Time) { + released = true + if gotConv != convID || gotUser != userID || !gotTime.Equal(messageTime) { + t.Fatalf("released wrong reply: conversation=%d, user=%d, time=%v", gotConv, gotUser, gotTime) + } + }}) + if !queue.SendAfter(email, -time.Second) { + t.Fatal("queueing email failed") + } + db.MustExec(`UPDATE notification_email_queue SET attempts = $1`, tt.attempts) + due := queue.due() + if len(due) != 1 { + t.Fatalf("due emails = %d, want 1", len(due)) + } + if tt.replaced { + email.MessageCreatedAt = null.TimeFrom(messageTime.Add(time.Minute)) + if !queue.SendAfter(email, time.Minute) { + t.Fatal("replacing queued email failed") + } + } + provider.err = errors.New("SMTP unavailable") + if tt.delivered { + provider.err = nil + } + if delivered := queue.deliver(due[0]); delivered != tt.delivered { + t.Fatalf("delivered = %v, want %v", delivered, tt.delivered) + } + if released != tt.wantReleased { + t.Fatalf("released = %v, want %v", released, tt.wantReleased) + } + var count int + if err := db.Get(&count, `SELECT count(*) FROM notification_email_queue`); err != nil { + t.Fatal(err) + } + if count != tt.wantQueued { + t.Fatalf("queued emails = %d, want %d", count, tt.wantQueued) + } + }) + } +} diff --git a/internal/notification/models/models.go b/internal/notification/models/models.go index 8ea45bd3..3319056e 100644 --- a/internal/notification/models/models.go +++ b/internal/notification/models/models.go @@ -2,6 +2,7 @@ package models import ( "encoding/json" + "slices" "time" "github.com/volatiletech/null/v9" @@ -10,13 +11,122 @@ 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" + 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" + + // Superseded by the per-metric types above, retained so existing notification rows still render. 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 + MessageCreatedAt null.Time + 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 + MessageCreatedAt null.Time +} + +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"` diff --git a/internal/notification/models/models_test.go b/internal/notification/models/models_test.go new file mode 100644 index 00000000..bcf86f59 --- /dev/null +++ b/internal/notification/models/models_test.go @@ -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) + } + }) + } +} diff --git a/internal/notification/preferences.go b/internal/notification/preferences.go new file mode 100644 index 00000000..c203dfb9 --- /dev/null +++ b/internal/notification/preferences.go @@ -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 +} diff --git a/internal/notification/preferences_test.go b/internal/notification/preferences_test.go new file mode 100644 index 00000000..75ec8c79 --- /dev/null +++ b/internal/notification/preferences_test.go @@ -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) + } + }) +} diff --git a/internal/notification/push.go b/internal/notification/push.go new file mode 100644 index 00000000..a5f1feba --- /dev/null +++ b/internal/notification/push.go @@ -0,0 +1,325 @@ +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 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 pushDelivery struct { + UserID int + Payload nmodels.PushPayload +} + +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 pushSettingStore interface { + Get(key string) (types.JSONText, error) + Update(settings any) error +} + +type sqlPushStore struct { + q pushQueries +} + +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) 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 (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 +} + +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 +} diff --git a/internal/notification/push_store_test.go b/internal/notification/push_store_test.go new file mode 100644 index 00000000..bc9f2cf1 --- /dev/null +++ b/internal/notification/push_store_test.go @@ -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)) + } +} diff --git a/internal/notification/push_test.go b/internal/notification/push_test.go new file mode 100644 index 00000000..a9c5fc68 --- /dev/null +++ b/internal/notification/push_test.go @@ -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) + } +} diff --git a/internal/notification/queries.sql b/internal/notification/queries.sql index 96257938..bfd5ab44 100644 --- a/internal/notification/queries.sql +++ b/internal/notification/queries.sql @@ -46,3 +46,85 @@ 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, message_created_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +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, + message_created_at = EXCLUDED.message_created_at, + send_at = LEAST(notification_email_queue.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, COALESCE(q.message_created_at, q.queued_at) AS message_created_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; diff --git a/internal/setting/setting.go b/internal/setting/setting.go index c062c1e0..97aac98a 100644 --- a/internal/setting/setting.go +++ b/internal/setting/setting.go @@ -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{ diff --git a/internal/sla/queries.sql b/internal/sla/queries.sql index c360c4a6..fabff9d7 100644 --- a/internal/sla/queries.sql +++ b/internal/sla/queries.sql @@ -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 diff --git a/internal/sla/sla.go b/internal/sla/sla.go index a4fbea75..63427e0b 100644 --- a/internal/sla/sla.go +++ b/internal/sla/sla.go @@ -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: ¬ifier.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 } diff --git a/internal/sla/sla_test.go b/internal/sla/sla_test.go index eaaed6d2..ca959887 100644 --- a/internal/sla/sla_test.go +++ b/internal/sla/sla_test.go @@ -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 diff --git a/internal/template/render.go b/internal/template/render.go index 25b56af7..9c72bd99 100644 --- a/internal/template/render.go +++ b/internal/template/render.go @@ -3,19 +3,23 @@ package template import ( "bytes" "fmt" + htmltemplate "html/template" "strings" - "text/template" + texttemplate "text/template" "github.com/valyala/fasthttp" ) 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" @@ -29,7 +33,7 @@ const ( // RenderString renders Go template variables in the given content string // without wrapping it in the base email template. Returns original content on any error. func (m *Manager) RenderString(data any, content string) string { - t, err := template.New("content").Funcs(m.funcMap).Parse(content) + t, err := htmltemplate.New("content").Funcs(m.funcMap).Parse(content) if err != nil { return content } @@ -64,12 +68,12 @@ func (m *Manager) RenderEmailWithTemplate(data any, content string) (string, err defaultTmpl.Body = `{{ template "content" . }}` } - baseTemplate, err := template.New(TmplBase).Funcs(m.funcMap).Parse(defaultTmpl.Body) + baseTemplate, err := htmltemplate.New(TmplBase).Funcs(m.funcMap).Parse(defaultTmpl.Body) if err != nil { return "", fmt.Errorf("parsing base template: %w", err) } - contentTemplate, err := template.New(TmplContent).Funcs(m.funcMap).Parse(content) + contentTemplate, err := htmltemplate.New(TmplContent).Funcs(m.funcMap).Parse(content) if err != nil { return "", fmt.Errorf("parsing content template: %w", err) } @@ -99,7 +103,7 @@ func (m *Manager) RenderStoredEmailTemplate(name string, data any) (string, stri executeSubjectTemplate := func(subject string) (string, error) { var sb strings.Builder - subjectTmpl, err := template.New("subject").Funcs(m.funcMap).Parse(subject) + subjectTmpl, err := texttemplate.New("subject").Funcs(m.funcMap).Parse(subject) if err != nil { return "", fmt.Errorf("parsing subject template: %w", err) } @@ -118,12 +122,12 @@ func (m *Manager) RenderStoredEmailTemplate(name string, data any) (string, stri defaultTmpl.Body = `{{ template "content" . }}` } - baseTemplate, err := template.New(TmplBase).Funcs(m.funcMap).Parse(defaultTmpl.Body) + baseTemplate, err := htmltemplate.New(TmplBase).Funcs(m.funcMap).Parse(defaultTmpl.Body) if err != nil { return "", "", fmt.Errorf("parsing base template: %w", err) } - contentTemplate, err := template.New(TmplContent).Funcs(m.funcMap).Parse(tmpl.Body) + contentTemplate, err := htmltemplate.New(TmplContent).Funcs(m.funcMap).Parse(tmpl.Body) if err != nil { return "", "", fmt.Errorf("parsing content template: %w", err) } diff --git a/internal/template/render_test.go b/internal/template/render_test.go new file mode 100644 index 00000000..bbf18730 --- /dev/null +++ b/internal/template/render_test.go @@ -0,0 +1,68 @@ +package template + +import ( + htmltemplate "html/template" + "strings" + "testing" + + "github.com/abhinavxd/libredesk/internal/testutil" + "github.com/zerodha/logf" +) + +func TestRenderStringEscapesDataAndPreservesTemplateMarkup(t *testing.T) { + m := &Manager{funcMap: htmltemplate.FuncMap{}} + content := m.RenderString(map[string]any{ + "Contact": map[string]any{"FullName": "attacker"}, + }, `

Hi {{ .Contact.FullName }}

`) + + if !strings.Contains(content, "

Hi <b>attacker</b>

") { + t.Fatalf("unexpected content: %s", content) + } +} + +func TestRenderEmailWithTemplateEscapesDataAndPreservesTemplateMarkup(t *testing.T) { + db := testutil.NewDB(t, "outgoing_email_template_escape") + lo := logf.New(logf.Opts{}) + m, err := New(&lo, db, nil, nil, htmltemplate.FuncMap{}, testutil.NewI18n(t)) + if err != nil { + t.Fatal(err) + } + + content, err := m.RenderEmailWithTemplate(map[string]any{ + "Contact": map[string]any{"FullName": "attacker"}, + }, `

Hi {{ .Contact.FullName }}

`) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(content, "

Hi <b>attacker</b>

") { + t.Fatalf("unexpected content: %s", content) + } +} + +func TestRenderStoredEmailTemplateEscapesUntrustedHTML(t *testing.T) { + db := testutil.NewDB(t, "stored_email_template_escape") + lo := logf.New(logf.Opts{}) + m, err := New(&lo, db, nil, nil, htmltemplate.FuncMap{"RootURL": func() string { return "http://localhost" }}, testutil.NewI18n(t)) + if err != nil { + t.Fatal(err) + } + + content, _, err := m.RenderStoredEmailTemplate(TmplNewReply, map[string]any{ + "Author": map[string]any{"FullName": "attacker"}, + "Conversation": map[string]any{ + "ReferenceNumber": 42, + "Subject": "subject", + "UUID": "conversation-uuid", + }, + "Message": map[string]any{"Content": htmltemplate.HTML("safe message")}, + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(content, "<b>attacker</b>") || !strings.Contains(content, "<i>subject</i>") { + t.Fatalf("untrusted HTML was not escaped: %s", content) + } + if !strings.Contains(content, "safe message") { + t.Fatalf("trusted message HTML was escaped: %s", content) + } +} diff --git a/schema.sql b/schema.sql index f52ee88a..dea80277 100644 --- a/schema.sql +++ b/schema.sql @@ -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'); +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,50 @@ 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(), + message_created_at TIMESTAMPTZ, + 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 +1041,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 +1100,75 @@ 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, ' +

{{ .Author.FullName }} replied to a conversation assigned to you:

+ +
+ Reference number: {{ .Conversation.ReferenceNumber }}
+ Subject: {{ .Conversation.Subject }} +
+ +
{{ .Message.Content }}
+ +

+ View Conversation +

+ +
+ Best regards,
+ Libredesk +
+ +', 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, ' +

{{ .Author.FullName }} replied to a conversation you are participating in:

+ +
+ Reference number: {{ .Conversation.ReferenceNumber }}
+ Subject: {{ .Conversation.Subject }} +
+ +
{{ .Message.Content }}
+ +

+ View Conversation +

+ +
+ Best regards,
+ Libredesk +
+ +', 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, ' +

{{ .Author.FullName }} replied and reopened a conversation assigned to you:

+ +
+ Reference number: {{ .Conversation.ReferenceNumber }}
+ Subject: {{ .Conversation.Subject }} +
+ +
{{ .Message.Content }}
+ +

+ View Conversation +

+ +
+ Best regards,
+ Libredesk +
+ +', false, 'Conversation reopened', 'Conversation #{{ .Conversation.ReferenceNumber }} reopened', true); + INSERT INTO templates ("type", body, is_default, "name", subject, is_builtin) VALUES ( From f07ccd79708b40aa80971b85125aa943037270ed Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Tue, 15 Sep 2026 17:54:02 +0530 Subject: [PATCH 02/10] prevent overlapping notification preference saves --- .../NotificationPreferences.test.js | 30 +++++++++++++++++++ .../notifications/NotificationPreferences.vue | 13 +++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/frontend/apps/main/src/views/account/notifications/NotificationPreferences.test.js b/frontend/apps/main/src/views/account/notifications/NotificationPreferences.test.js index 88df5723..092fd6de 100644 --- a/frontend/apps/main/src/views/account/notifications/NotificationPreferences.test.js +++ b/frontend/apps/main/src/views/account/notifications/NotificationPreferences.test.js @@ -57,6 +57,36 @@ describe('notification preferences', () => { 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('leaves the push subscription to the app shell', async () => { await mount() expect(root.textContent).toContain('notification.type.newReply') diff --git a/frontend/apps/main/src/views/account/notifications/NotificationPreferences.vue b/frontend/apps/main/src/views/account/notifications/NotificationPreferences.vue index b1ea9d86..e6287a3d 100644 --- a/frontend/apps/main/src/views/account/notifications/NotificationPreferences.vue +++ b/frontend/apps/main/src/views/account/notifications/NotificationPreferences.vue @@ -43,7 +43,10 @@
{ const refreshPushSubscription = async () => { const { data } = await api.getNotificationPreferences() - await pushNotifications.refresh(data.data.vapid_public_key) + await pushNotifications.refresh(data.data.vapid_public_key, data.data.push_endpoints) } const createView = () => { diff --git a/frontend/apps/main/src/composables/usePushNotifications.js b/frontend/apps/main/src/composables/usePushNotifications.js index a0aff321..642c9035 100644 --- a/frontend/apps/main/src/composables/usePushNotifications.js +++ b/frontend/apps/main/src/composables/usePushNotifications.js @@ -34,12 +34,12 @@ export const createPushNotifications = (apiClient = api, browser = defaultBrowse }) } - const refresh = async (vapidPublicKey) => { + const refresh = async (vapidPublicKey, savedEndpoints = []) => { if (!supported.value || !vapidPublicKey) return const worker = await registerWorker() const subscription = await worker.pushManager.getSubscription() - enabled.value = Boolean(subscription) - if (subscription) await save(subscription) + enabled.value = Boolean(subscription) && savedEndpoints.includes(subscription.endpoint) + if (enabled.value) await save(subscription) } const enable = async (vapidPublicKey) => { diff --git a/frontend/apps/main/src/composables/usePushNotifications.test.js b/frontend/apps/main/src/composables/usePushNotifications.test.js index 221cfca7..827b4761 100644 --- a/frontend/apps/main/src/composables/usePushNotifications.test.js +++ b/frontend/apps/main/src/composables/usePushNotifications.test.js @@ -49,7 +49,7 @@ describe('createPushNotifications', () => { const api = { createPushSubscription: vi.fn().mockResolvedValue({}) } const state = createPushNotifications(api, browser) - await state.refresh('vapid-key') + await state.refresh('vapid-key', [subscription.endpoint]) expect(browser.Notification.requestPermission).not.toHaveBeenCalled() expect(api.createPushSubscription).toHaveBeenCalledWith({ @@ -60,6 +60,17 @@ describe('createPushNotifications', () => { 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') @@ -118,7 +129,7 @@ describe('createPushNotifications', () => { deletePushSubscription: vi.fn().mockResolvedValue({}) } const state = createPushNotifications(api, browser) - await state.refresh('vapid-key') + await state.refresh('vapid-key', [subscription.endpoint]) await state.disable() diff --git a/frontend/public/sw.js b/frontend/public/sw.js index d42c00e6..1a52d7a2 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -6,6 +6,12 @@ const readPayload = (event) => { } } +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 @@ -28,7 +34,7 @@ self.addEventListener('notificationclick', (event) => { event.waitUntil(self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(async (clients) => { const client = clients[0] if (!client) return self.clients.openWindow(target.href) - if ('navigate' in client) await client.navigate(target.href) - return client.focus() + await client.focus() + return client.navigate(target.href).catch(() => self.clients.openWindow(target.href)) })) }) diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index 42fbd58c..e2d53aba 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -43,7 +43,6 @@ import ( "github.com/jmoiron/sqlx/types" "github.com/knadh/go-i18n" "github.com/lib/pq" - "github.com/redis/go-redis/v9" "github.com/volatiletech/null/v9" "github.com/zerodha/logf" ) @@ -67,9 +66,7 @@ const (

#{{ .Conversation.ReferenceNumber }}

` - newReplyEmailDelay = 2 * time.Minute - replyNotifiedKeyPrefix = "conversation:reply-notified:" - replyNotifiedTTL = 7 * 24 * time.Hour + newReplyEmailDelay = 5 * time.Minute ) var conversationFilterRenderers = dbutil.FieldRenderers{ @@ -114,8 +111,6 @@ type Manager struct { outgoingProcessingMessages sync.Map closed bool closedMu sync.RWMutex - replyNotificationMu sync.Mutex - rdb *redis.Client wg sync.WaitGroup continuityConfig ContinuityConfig subjectRefFormat string @@ -233,7 +228,6 @@ type ContinuityConfig struct { // Opts holds the options for creating a new Manager. type Opts struct { DB *sqlx.DB - Redis *redis.Client Lo *logf.Logger OutgoingMessageQueueSize int IncomingMessageQueueSize int @@ -302,7 +296,6 @@ func New( automation: automation, template: template, db: opts.DB, - rdb: opts.Redis, lo: opts.Lo, incomingMessageQueue: make(chan models.IncomingMessage, opts.IncomingMessageQueueSize), outgoingMessageQueue: make(chan models.Message, opts.OutgoingMessageQueueSize), @@ -316,46 +309,45 @@ func New( type queries struct { // Conversation queries. - GetConversationUUID *sqlx.Stmt `query:"get-conversation-uuid"` - GetConversation *sqlx.Stmt `query:"get-conversation"` - GetConversationListItem *sqlx.Stmt `query:"get-conversation-list-item"` - GetConversationsCreatedAfter *sqlx.Stmt `query:"get-conversations-created-after"` - GetUnassignedConversations *sqlx.Stmt `query:"get-unassigned-conversations"` - GetConversations string `query:"get-conversations"` - GetContactChatConversations *sqlx.Stmt `query:"get-contact-chat-conversations"` - GetChatConversation *sqlx.Stmt `query:"get-chat-conversation"` - GetContactPreviousConversations *sqlx.Stmt `query:"get-contact-previous-conversations"` - 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"` - StartConversationWaitingSince *sqlx.Stmt `query:"start-conversation-waiting-since"` - UpdateConversationReplyTimestamps *sqlx.Stmt `query:"update-conversation-reply-timestamps"` - UpdateConversationContactLastSeen *sqlx.Stmt `query:"update-conversation-contact-last-seen"` - UpsertUserLastSeen *sqlx.Stmt `query:"upsert-user-last-seen"` - GetUsersWithUnreadConversationMessage *sqlx.Stmt `query:"get-users-with-unread-conversation-message"` - MarkConversationUnread *sqlx.Stmt `query:"mark-conversation-unread"` - UpdateConversationAssignedUser *sqlx.Stmt `query:"update-conversation-assigned-user"` - ClaimUnassignedConversation *sqlx.Stmt `query:"claim-unassigned-conversation"` - UpdateConversationAssignedTeam *sqlx.Stmt `query:"update-conversation-assigned-team"` - UpdateConversationCustomAttributes *sqlx.Stmt `query:"update-conversation-custom-attributes"` - UpdateConversationPriority *sqlx.Stmt `query:"update-conversation-priority"` - UpdateConversationStatus *sqlx.Stmt `query:"update-conversation-status"` - UpdateConversationLastMessage *sqlx.Stmt `query:"update-conversation-last-message"` - InsertConversationParticipant *sqlx.Stmt `query:"insert-conversation-participant"` - InsertConversation *sqlx.Stmt `query:"insert-conversation"` - AddConversationTags *sqlx.Stmt `query:"add-conversation-tags"` - SetConversationTags *sqlx.Stmt `query:"set-conversation-tags"` - RemoveConversationTags *sqlx.Stmt `query:"remove-conversation-tags"` - GetConversationTags *sqlx.Stmt `query:"get-conversation-tags"` - UnassignOpenConversations *sqlx.Stmt `query:"unassign-open-conversations"` - ReOpenConversation *sqlx.Stmt `query:"re-open-conversation"` - UnsnoozeAll *sqlx.Stmt `query:"unsnooze-all"` - DeleteConversation *sqlx.Stmt `query:"delete-conversation"` - RemoveConversationAssignee *sqlx.Stmt `query:"remove-conversation-assignee"` + GetConversationUUID *sqlx.Stmt `query:"get-conversation-uuid"` + GetConversation *sqlx.Stmt `query:"get-conversation"` + GetConversationListItem *sqlx.Stmt `query:"get-conversation-list-item"` + GetConversationsCreatedAfter *sqlx.Stmt `query:"get-conversations-created-after"` + GetUnassignedConversations *sqlx.Stmt `query:"get-unassigned-conversations"` + GetConversations string `query:"get-conversations"` + GetContactChatConversations *sqlx.Stmt `query:"get-contact-chat-conversations"` + GetChatConversation *sqlx.Stmt `query:"get-chat-conversation"` + GetContactPreviousConversations *sqlx.Stmt `query:"get-contact-previous-conversations"` + 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"` + StartConversationWaitingSince *sqlx.Stmt `query:"start-conversation-waiting-since"` + UpdateConversationReplyTimestamps *sqlx.Stmt `query:"update-conversation-reply-timestamps"` + UpdateConversationContactLastSeen *sqlx.Stmt `query:"update-conversation-contact-last-seen"` + UpsertUserLastSeen *sqlx.Stmt `query:"upsert-user-last-seen"` + MarkConversationUnread *sqlx.Stmt `query:"mark-conversation-unread"` + UpdateConversationAssignedUser *sqlx.Stmt `query:"update-conversation-assigned-user"` + ClaimUnassignedConversation *sqlx.Stmt `query:"claim-unassigned-conversation"` + UpdateConversationAssignedTeam *sqlx.Stmt `query:"update-conversation-assigned-team"` + UpdateConversationCustomAttributes *sqlx.Stmt `query:"update-conversation-custom-attributes"` + UpdateConversationPriority *sqlx.Stmt `query:"update-conversation-priority"` + UpdateConversationStatus *sqlx.Stmt `query:"update-conversation-status"` + UpdateConversationLastMessage *sqlx.Stmt `query:"update-conversation-last-message"` + InsertConversationParticipant *sqlx.Stmt `query:"insert-conversation-participant"` + InsertConversation *sqlx.Stmt `query:"insert-conversation"` + AddConversationTags *sqlx.Stmt `query:"add-conversation-tags"` + SetConversationTags *sqlx.Stmt `query:"set-conversation-tags"` + RemoveConversationTags *sqlx.Stmt `query:"remove-conversation-tags"` + GetConversationTags *sqlx.Stmt `query:"get-conversation-tags"` + UnassignOpenConversations *sqlx.Stmt `query:"unassign-open-conversations"` + ReOpenConversation *sqlx.Stmt `query:"re-open-conversation"` + UnsnoozeAll *sqlx.Stmt `query:"unsnooze-all"` + DeleteConversation *sqlx.Stmt `query:"delete-conversation"` + RemoveConversationAssignee *sqlx.Stmt `query:"remove-conversation-assignee"` // Draft queries. UpsertConversationDraft *sqlx.Stmt `query:"upsert-conversation-draft"` @@ -558,56 +550,22 @@ func (c *Manager) GetConversationsCreatedAfter(after time.Time, afterID, limit i // UpdateUserLastSeen updates the last seen timestamp for a specific user on a conversation. func (c *Manager) UpdateUserLastSeen(uuid string, userID int) error { - c.replyNotificationMu.Lock() - defer c.replyNotificationMu.Unlock() - if _, err := c.q.UpsertUserLastSeen.Exec(userID, uuid); err != nil { c.lo.Error("error upserting user last seen", "user_id", userID, "conversation_uuid", uuid, "error", err) return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil) } - c.clearReplyNotified(uuid, userID) return nil } // MarkAsUnread marks a conversation as unread for a specific user by setting last_seen to before the last message. func (c *Manager) MarkAsUnread(uuid string, userID int) error { - c.replyNotificationMu.Lock() - defer c.replyNotificationMu.Unlock() - if _, err := c.q.MarkConversationUnread.Exec(userID, uuid); err != nil { c.lo.Error("error marking conversation as unread", "user_id", userID, "conversation_uuid", uuid, "error", err) return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil) } - c.clearReplyNotified(uuid, userID) return nil } -// ReleaseReplyNotification clears reply suppression for an abandoned email, preserving newer claims. -func (m *Manager) ReleaseReplyNotification(conversationID, userID int, messageCreatedAt time.Time) { - if m.rdb == nil { - return - } - uuid, err := m.GetConversationUUID(conversationID) - if err != nil { - return - } - - m.replyNotificationMu.Lock() - defer m.replyNotificationMu.Unlock() - - claim, err := m.rdb.Get(context.Background(), replyNotifiedKey(uuid, userID)).Int64() - if errors.Is(err, redis.Nil) { - return - } - if err != nil { - m.lo.Error("error fetching reply notification claim", "conversation_uuid", uuid, "user_id", userID, "error", err) - return - } - if claim == 1 || claim == messageCreatedAt.UnixMicro() { - m.clearReplyNotified(uuid, userID) - } -} - // UpdateContactLastSeen updates the last seen timestamp of the contact in the conversation. func (c *Manager) UpdateConversationContactLastSeen(uuid string) error { var lastSeenAt time.Time @@ -1387,27 +1345,22 @@ func (m *Manager) sendReplyNotification(conversation models.Conversation, messag candidateIDs[i] = recipient.ID } - claimedIDs, err := m.claimUnreadRecipients(conversation, candidateIDs, message.CreatedAt) - if err != nil { - return + 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(message.TextContent), "\n", "
")), + } + 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 + } } - var recipients []nmodels.Recipient - for _, recipient := range group.recipients { - if !slices.Contains(claimedIDs, recipient.ID) { - continue - } - email := m.renderNotificationEmail(group.tmpl, conversation, message, recipient, author) - if email != nil { - email.Delay = newReplyEmailDelay - } - recipients = append(recipients, nmodels.Recipient{UserID: recipient.ID, Email: email}) - } - if len(recipients) == 0 { - return - } - - results, err := m.dispatcher.Send(nmodels.Notification{ + if _, err := m.dispatcher.Send(nmodels.Notification{ Type: group.nType, Recipients: recipients, Title: group.title, @@ -1417,90 +1370,8 @@ func (m *Manager) sendReplyNotification(conversation models.Conversation, messag ConversationUUID: conversation.UUID, MessageUUID: message.UUID, MessageCreatedAt: null.TimeFrom(message.CreatedAt), - }) - if err != nil { + }); err != nil { m.lo.Error("error sending reply notification", "type", group.nType, "error", err) - m.clearReplyNotified(conversation.UUID, claimedIDs...) - return - } - var untracked []int - for _, result := range results { - if !slices.Contains(result.Channels, nmodels.NotificationChannelInApp) && - !slices.Contains(result.Channels, nmodels.NotificationChannelEmail) { - untracked = append(untracked, result.RecipientID) - } - } - m.clearReplyNotified(conversation.UUID, untracked...) -} - -// claimUnreadRecipients holds the reply notification lock across the unread check and the claim, returning the claimed recipients. -func (m *Manager) claimUnreadRecipients(conversation models.Conversation, userIDs []int, messageCreatedAt time.Time) ([]int, error) { - m.replyNotificationMu.Lock() - defer m.replyNotificationMu.Unlock() - - var unreadIDs []int - if err := m.q.GetUsersWithUnreadConversationMessage.Select(&unreadIDs, conversation.ID, pq.Array(userIDs), messageCreatedAt); err != nil { - m.lo.Error("error checking conversation unread state", "conversation_uuid", conversation.UUID, "error", err) - return nil, err - } - return m.claimReplyNotified(conversation.UUID, unreadIDs, messageCreatedAt), nil -} - -func (m *Manager) claimReplyNotified(conversationUUID string, userIDs []int, messageCreatedAt time.Time) []int { - if len(userIDs) == 0 || m.rdb == nil { - return userIDs - } - ctx := context.Background() - pipe := m.rdb.Pipeline() - claims := make([]*redis.BoolCmd, len(userIDs)) - for i, userID := range userIDs { - claims[i] = pipe.SetNX(ctx, replyNotifiedKey(conversationUUID, userID), messageCreatedAt.UnixMicro(), replyNotifiedTTL) - } - if _, err := pipe.Exec(ctx); err != nil { - m.lo.Error("error claiming reply notifications", "conversation_uuid", conversationUUID, "error", err) - return userIDs - } - claimed := make([]int, 0, len(userIDs)) - for i, userID := range userIDs { - if claims[i].Val() { - claimed = append(claimed, userID) - } - } - return claimed -} - -func (m *Manager) clearReplyNotified(conversationUUID string, userIDs ...int) { - if len(userIDs) == 0 || m.rdb == nil { - return - } - keys := make([]string, len(userIDs)) - for i, userID := range userIDs { - keys[i] = replyNotifiedKey(conversationUUID, userID) - } - if err := m.rdb.Del(context.Background(), keys...).Err(); err != nil { - m.lo.Error("error clearing reply notification", "user_ids", userIDs, "conversation_uuid", conversationUUID, "error", err) - } -} - -// renderNotificationEmail returns nil when the recipient has no email or the template fails to render. -func (m *Manager) renderNotificationEmail(tmplName string, conversation models.Conversation, message models.Message, recipient, author umodels.User) *nmodels.EmailNotification { - if recipient.Email.String == "" { - return nil - } - data := notificationTemplateData(conversation, recipient, author) - data["Message"] = map[string]any{ - "UUID": message.UUID, - "Content": htmltemplate.HTML(strings.ReplaceAll(html.EscapeString(message.TextContent), "\n", "
")), - } - content, subject, err := m.template.RenderStoredEmailTemplate(tmplName, data) - if err != nil { - m.lo.Error("error rendering template", "template", tmplName, "conversation_uuid", conversation.UUID, "error", err) - return nil - } - return &nmodels.EmailNotification{ - Recipient: recipient.Email.String, - Subject: subject, - Content: content, } } @@ -1555,38 +1426,18 @@ func (m *Manager) NotifyMention(conversationUUID string, message models.Message, messageContent := sanitizeNotificationHTML(message.Content, rootURL) recipients := m.buildNotificationRecipients(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": "", - }, - }) + 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 }) @@ -1780,41 +1631,14 @@ func (m *Manager) notifyAutomation(subject, message string, entries []string, co } recipients := m.buildNotificationRecipients(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) + data := notificationTemplateData(conv, recipient, umodels.User{}) + data["Message"] = message + content, err := m.template.RenderEmailWithTemplate(data, automationNotifyEmailContent) return subject, content, err }) if _, err := m.dispatcher.Send(nmodels.Notification{ - Type: nmodels.NotificationTypeMention, + Type: nmodels.NotificationTypeAutomation, Recipients: recipients, Title: subject, Body: null.StringFrom(message), @@ -2487,10 +2311,6 @@ func renderTagFilter(operator, value string, paramIndex int) (string, []any, err } } -func replyNotifiedKey(conversationUUID string, userID int) string { - return replyNotifiedKeyPrefix + conversationUUID + ":" + strconv.Itoa(userID) -} - func notificationTemplateData(conversation models.Conversation, recipient, author umodels.User) map[string]any { return map[string]any{ "Conversation": map[string]any{ diff --git a/internal/conversation/notification_test.go b/internal/conversation/notification_test.go index 1329f4bc..76f0252e 100644 --- a/internal/conversation/notification_test.go +++ b/internal/conversation/notification_test.go @@ -2,7 +2,6 @@ package conversation import ( "errors" - "fmt" htmltemplate "html/template" "slices" "sync" @@ -18,10 +17,7 @@ import ( "github.com/abhinavxd/libredesk/internal/template" "github.com/abhinavxd/libredesk/internal/testutil" umodels "github.com/abhinavxd/libredesk/internal/user/models" - "github.com/alicebob/miniredis/v2" - "github.com/alicebob/miniredis/v2/server" "github.com/jmoiron/sqlx" - "github.com/redis/go-redis/v9" "github.com/volatiletech/null/v9" "github.com/zerodha/logf" ) @@ -145,7 +141,7 @@ func TestNotifyNewReplyChecksAssigneeAccess(t *testing.T) { } } -func TestReplyNotificationsAlertOncePerUnreadConversation(t *testing.T) { +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 { @@ -165,10 +161,6 @@ func TestReplyNotificationsAlertOncePerUnreadConversation(t *testing.T) { var q struct { Participants *sqlx.Stmt `query:"get-conversation-participant-agents"` - LastSeen *sqlx.Stmt `query:"upsert-user-last-seen"` - MarkUnread *sqlx.Stmt `query:"mark-conversation-unread"` - Unread *sqlx.Stmt `query:"get-users-with-unread-conversation-message"` - UUID *sqlx.Stmt `query:"get-conversation-uuid"` } if err := dbutil.ScanSQLFile("queries.sql", &q, db, efs); err != nil { t.Fatal(err) @@ -189,14 +181,10 @@ func TestReplyNotificationsAlertOncePerUnreadConversation(t *testing.T) { } conv.AssignedUserID = null.IntFrom(userID) - var replyRedis *miniredis.Miniredis newManager := func(channels ...nmodels.NotificationChannel) *Manager { - db.MustExec(`DELETE FROM conversation_last_seen`) db.MustExec(`DELETE FROM notification_email_queue`) db.MustExec(`DELETE FROM user_notifications`) - mr := miniredis.RunT(t) - replyRedis = mr - m := &Manager{lo: &lo, i18n: i18n, template: templates, rdb: redis.NewClient(&redis.Options{Addr: mr.Addr()}), + 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)), @@ -204,21 +192,17 @@ func TestReplyNotificationsAlertOncePerUnreadConversation(t *testing.T) { }), } m.q.GetConversationParticipantAgents = q.Participants - m.q.UpsertUserLastSeen = q.LastSeen - m.q.MarkConversationUnread = q.MarkUnread - m.q.GetUsersWithUnreadConversationMessage = q.Unread - m.q.GetConversationUUID = q.UUID 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("one alert per unread conversation", func(t *testing.T) { + t.Run("each reply creates an in-app alert", func(t *testing.T) { m := newManager(nmodels.NotificationChannelInApp) - notifications := func() int { - var count int - db.Get(&count, `SELECT count(*) FROM user_notifications`) - return count - } m.NotifyNewReply(conv, message, false) if notifications() != 1 { @@ -228,45 +212,17 @@ func TestReplyNotificationsAlertOncePerUnreadConversation(t *testing.T) { next := message next.CreatedAt = message.CreatedAt.Add(time.Second) m.NotifyNewReply(conv, next, false) - if notifications() != 1 { - t.Fatalf("notifications after a second unread reply = %d, want 1", notifications()) + if notifications() != 2 { + t.Fatalf("notifications after second reply = %d, want 2", notifications()) } m.NotifyNewReply(conv, next, true) - if notifications() != 1 { - t.Fatalf("notifications after a reopen on an unread conversation = %d, want 1", notifications()) - } - - if err := m.UpdateUserLastSeen(conv.UUID, userID); err != nil { - t.Fatal(err) - } - later := next - later.CreatedAt = time.Now().UTC().Add(time.Minute) - m.NotifyNewReply(conv, later, false) - if notifications() != 2 { - t.Fatalf("notifications after reading and replying again = %d, want 2", notifications()) - } - - if err := m.MarkAsUnread(conv.UUID, userID); err != nil { - t.Fatal(err) - } - m.NotifyNewReply(conv, later, false) if notifications() != 3 { - t.Fatalf("notifications after marking unread and replying again = %d, want 3", notifications()) - } - - if err := m.UpdateUserLastSeen(conv.UUID, userID); err != nil { - t.Fatal(err) - } - read := later - read.CreatedAt = time.Now().Add(-time.Minute) - m.NotifyNewReply(conv, read, false) - if notifications() != 3 { - t.Fatalf("a reply the agent had already read was notified, notifications = %d", notifications()) + t.Fatalf("notifications after reopened reply = %d, want 3", notifications()) } }) - t.Run("replies arriving together alert once", func(t *testing.T) { + t.Run("replies arriving together alert individually", func(t *testing.T) { m := newManager(nmodels.NotificationChannelInApp) var wg sync.WaitGroup for range 8 { @@ -274,92 +230,12 @@ func TestReplyNotificationsAlertOncePerUnreadConversation(t *testing.T) { } wg.Wait() - var count int - db.Get(&count, `SELECT count(*) FROM user_notifications`) - if count != 1 { - t.Fatalf("notifications from concurrent replies = %d, want 1", count) + if notifications() != 8 { + t.Fatalf("notifications from concurrent replies = %d, want 8", notifications()) } }) - for _, markUnread := range []bool{false, true} { - t.Run(fmt.Sprintf("read during claim/markUnread=%v", markUnread), func(t *testing.T) { - m := newManager(nmodels.NotificationChannelInApp) - readDone := make(chan error, 1) - var once sync.Once - replyRedis.Server().SetPreHook(func(_ *server.Peer, cmd string, _ ...string) bool { - if cmd == "SET" { - once.Do(func() { - go func() { - if markUnread { - readDone <- m.MarkAsUnread(conv.UUID, userID) - } else { - readDone <- m.UpdateUserLastSeen(conv.UUID, userID) - } - }() - select { - case err := <-readDone: - readDone <- err - case <-time.After(100 * time.Millisecond): - } - }) - } - return false - }) - - m.NotifyNewReply(conv, message, false) - if err := <-readDone; err != nil { - t.Fatal(err) - } - if replyRedis.Exists(replyNotifiedKey(conv.UUID, userID)) { - t.Fatal("completed read left a reply suppression claim") - } - - next := message - next.CreatedAt = time.Now().UTC().Add(time.Minute) - m.NotifyNewReply(conv, next, false) - var count int - if err := db.Get(&count, `SELECT count(*) FROM user_notifications`); err != nil { - t.Fatal(err) - } - if count != 2 { - t.Fatalf("notifications after a concurrent read and a new reply = %d, want 2", count) - } - }) - } - - for _, state := range []string{"current", "legacy", "newer", "read"} { - t.Run("abandoned email releases only its claim/"+state, func(t *testing.T) { - m := newManager(nmodels.NotificationChannelEmail) - m.NotifyNewReply(conv, message, false) - key := replyNotifiedKey(conv.UUID, userID) - if state == "legacy" { - if err := replyRedis.Set(key, "1"); err != nil { - t.Fatal(err) - } - } - if state == "newer" || state == "read" { - if err := m.UpdateUserLastSeen(conv.UUID, userID); err != nil { - t.Fatal(err) - } - } - next := message - next.CreatedAt = time.Now().UTC().Add(time.Minute) - if state == "newer" { - m.NotifyNewReply(conv, next, false) - } - - m.ReleaseReplyNotification(conv.ID, userID, message.CreatedAt) - if got, want := replyRedis.Exists(key), state == "newer"; got != want { - t.Fatalf("claim after abandonment = %v, want %v", got, want) - } - m.NotifyNewReply(conv, next, false) - if !replyRedis.Exists(key) { - t.Fatal("next unread reply was not claimed") - } - }) - } - - t.Run("push only agent keeps no claim", func(t *testing.T) { + t.Run("push alerts for every reply", func(t *testing.T) { m := newManager(nmodels.NotificationChannelPush) push := &countingPush{} m.dispatcher = notifier.NewDispatcher(notifier.DispatcherOpts{ @@ -367,40 +243,30 @@ func TestReplyNotificationsAlertOncePerUnreadConversation(t *testing.T) { Prefs: &replyPreferences{channels: []nmodels.NotificationChannel{nmodels.NotificationChannelPush}}, }) m.NotifyNewReply(conv, message, false) - - m.dispatcher = notifier.NewDispatcher(notifier.DispatcherOpts{ - Pipeline: nchannels.NewPipeline(nchannels.NewInApp(inApp, nil, &lo), nchannels.NewEmail(emailQueue)), - Prefs: &replyPreferences{channels: []nmodels.NotificationChannel{nmodels.NotificationChannelInApp}}, - }) m.NotifyNewReply(conv, message, false) - - var count int - db.Get(&count, `SELECT count(*) FROM user_notifications`) - if count != 1 { - t.Fatalf("notifications after a push that stored nothing = %d, want 1", count) + 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) - var count int - db.Get(&count, `SELECT count(*) FROM user_notifications`) - if count != 0 { - t.Fatalf("notifications = %d, want 0", count) + if notifications() != 0 { + t.Fatalf("notifications = %d, want 0", notifications()) } }) - t.Run("preference failure releases claim", func(t *testing.T) { + 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 replyRedis.Exists(replyNotifiedKey(conv.UUID, userID)) { - t.Fatal("preference failure left a reply suppression claim") + if notifications() != 0 { + t.Fatalf("notifications = %d, want 0", notifications()) } }) - t.Run("email only agent is alerted once until they read the conversation", func(t *testing.T) { + t.Run("email replies are clubbed in the queue", func(t *testing.T) { m := newManager(nmodels.NotificationChannelEmail) m.NotifyNewReply(conv, message, false) next := message @@ -412,17 +278,5 @@ func TestReplyNotificationsAlertOncePerUnreadConversation(t *testing.T) { if queued != 1 { t.Fatalf("queued emails = %d, want 1", queued) } - - db.MustExec(`DELETE FROM notification_email_queue`) - if err := m.UpdateUserLastSeen(conv.UUID, userID); err != nil { - t.Fatal(err) - } - later := next - later.CreatedAt = time.Now().UTC().Add(time.Minute) - m.NotifyNewReply(conv, later, false) - db.Get(&queued, `SELECT count(*) FROM notification_email_queue`) - if queued != 1 { - t.Fatalf("queued emails after reading and replying again = %d, want 1", queued) - } }) } diff --git a/internal/conversation/queries.sql b/internal/conversation/queries.sql index 47e1ca48..962d418e 100644 --- a/internal/conversation/queries.sql +++ b/internal/conversation/queries.sql @@ -509,13 +509,6 @@ VALUES ($1, (SELECT id FROM conversations WHERE uuid = $2), NOW()) ON CONFLICT (conversation_id, user_id) DO UPDATE SET last_seen_at = NOW(), updated_at = NOW(); --- name: get-users-with-unread-conversation-message -SELECT candidate.user_id -FROM unnest($2::BIGINT[]) AS candidate(user_id) -LEFT JOIN conversation_last_seen seen - ON seen.conversation_id = $1 AND seen.user_id = candidate.user_id -WHERE seen.last_seen_at IS NULL OR seen.last_seen_at < $3; - -- name: update-conversation-last-message -- $1=id, $2=uuid, $3=content, $4=sender_type, $5=timestamp, $6=message_type, $7=private, $8=sender_id UPDATE conversations SET diff --git a/internal/migrations/v2.9.0.go b/internal/migrations/v2.9.0.go index bd4180b0..96f9d959 100644 --- a/internal/migrations/v2.9.0.go +++ b/internal/migrations/v2.9.0.go @@ -16,6 +16,7 @@ var notificationTypesV2_9_0 = []string{ "sla_resolution_warning", "sla_resolution_breach", "conversation_reopened", + "automation", } func V2_9_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { diff --git a/internal/notification/channels/push.go b/internal/notification/channels/push.go index 5261fcf8..c020cb90 100644 --- a/internal/notification/channels/push.go +++ b/internal/notification/channels/push.go @@ -28,14 +28,14 @@ func (p *push) Send(delivery Delivery) Result { Title: n.Title, Body: n.Body.String, Tag: fmt.Sprintf("%s_%s", n.Type, n.ConversationUUID), - URL: pushRoute(string(n.Type), n.ConversationUUID, n.MessageUUID), + URL: pushRoute(n.Type, n.ConversationUUID, n.MessageUUID), }) return Result{Sent: sent} } -func pushRoute(notificationType, conversationUUID, messageUUID string) string { +func pushRoute(notificationType models.NotificationType, conversationUUID, messageUUID string) string { list := "assigned" - if notificationType == "mention" { + if notificationType == models.NotificationTypeMention { list = "mentioned" } route := fmt.Sprintf("/inboxes/%s/conversation/%s", list, conversationUUID) diff --git a/internal/notification/email_queue.go b/internal/notification/email_queue.go index db9dfecd..220c01c5 100644 --- a/internal/notification/email_queue.go +++ b/internal/notification/email_queue.go @@ -19,10 +19,6 @@ const ( emailQueueRetryDelay = time.Minute ) -type replyNotificationStore interface { - ReleaseReplyNotification(conversationID, userID int, messageCreatedAt time.Time) -} - type EmailSender interface { Send(Message) error SendSync(Message) error @@ -51,10 +47,9 @@ type queuedEmail struct { } type EmailQueue struct { - q emailQueueQueries - outbound EmailSender - conversationStore replyNotificationStore - lo *logf.Logger + q emailQueueQueries + outbound EmailSender + lo *logf.Logger } type EmailQueueOpts struct { @@ -75,10 +70,6 @@ func NewEmailQueue(opts EmailQueueOpts) (*EmailQueue, error) { }, nil } -func (q *EmailQueue) SetConversationStore(store replyNotificationStore) { - q.conversationStore = store -} - func (q *EmailQueue) Send(e models.Email) bool { if err := q.outbound.Send(Message{ RecipientEmails: []string{e.Recipient}, @@ -152,7 +143,7 @@ func (q *EmailQueue) deliver(e queuedEmail) bool { }); err != nil { q.lo.Error("error delivering notification email", "user_id", e.UserID, "type", e.Type, "error", err) if e.Attempts+1 >= emailQueueMaxAttempts { - q.abandon(e) + q.delete(e) } else { q.retry(e) } @@ -162,16 +153,6 @@ func (q *EmailQueue) deliver(e queuedEmail) bool { return true } -func (q *EmailQueue) abandon(e queuedEmail) { - if !q.delete(e) || e.NotificationID.Valid || !e.ConversationID.Valid || q.conversationStore == nil { - return - } - switch e.Type { - case models.NotificationTypeNewReply, models.NotificationTypeNewReplyParticipating, models.NotificationTypeConversationReopened: - q.conversationStore.ReleaseReplyNotification(e.ConversationID.Int, e.UserID, e.MessageCreatedAt.Time) - } -} - func (q *EmailQueue) delete(e queuedEmail) bool { result, err := q.q.Delete.Exec(e.ID, e.ClaimedAt) if err != nil { diff --git a/internal/notification/email_queue_test.go b/internal/notification/email_queue_test.go index 1d976800..348c207d 100644 --- a/internal/notification/email_queue_test.go +++ b/internal/notification/email_queue_test.go @@ -16,10 +16,6 @@ type emailDeliveryProvider struct { err error } -type replyNotificationRecorder struct { - release func(int, int, time.Time) -} - func (p *emailDeliveryProvider) Send(Message) error { return p.err } @@ -28,10 +24,6 @@ func (p *emailDeliveryProvider) Name() string { return ProviderEmail } -func (s *replyNotificationRecorder) ReleaseReplyNotification(conversationID, userID int, messageCreatedAt time.Time) { - s.release(conversationID, userID, messageCreatedAt) -} - func TestDelayedReplyEmailUsesMessageTime(t *testing.T) { db := testutil.NewDB(t, "reply_email_time") var userID, inboxID, convID int @@ -101,9 +93,21 @@ func TestDelayedReplyEmailUsesMessageTime(t *testing.T) { t.Run("coalesced reply uses latest message", func(t *testing.T) { db.MustExec(`UPDATE conversation_last_seen SET last_seen_at = $1`, messageTime.Add(time.Second)) 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.MessageCreatedAt = null.TimeFrom(messageTime.Add(2 * time.Second)) n.Recipients[0].Email.Content = "Second reply" + n.Recipients[0].Email.Delay = 2 * time.Minute d.Send(n) + var secondSendAt time.Time + if err := db.Get(&secondSendAt, `SELECT send_at FROM notification_email_queue`); err != nil { + t.Fatal(err) + } + if !secondSendAt.After(firstSendAt) { + t.Fatalf("coalesced email send time = %v, want after %v", secondSendAt, 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" { @@ -300,93 +304,3 @@ func TestClaimedEmailRemainsRecoverable(t *testing.T) { t.Fatalf("emails after lease expiry = %d, want 1", len(due)) } } - -func TestAbandonedReplyEmailReleasesSuppression(t *testing.T) { - db := testutil.NewDB(t, "notification_email_abandonment") - var userID, inboxID, convID, notificationID int - if err := db.Get(&userID, `INSERT INTO users (type, email, first_name, last_name) VALUES ('agent', 'abandon@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) - } - if err := db.Get(¬ificationID, `INSERT INTO user_notifications (user_id, notification_type, title, conversation_id) VALUES ($1, 'new_reply', 'Reply', $2) RETURNING id`, userID, convID); 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) - } - for _, tt := range []struct { - name string - nType models.NotificationType - inApp bool - replaced bool - delivered bool - attempts int - wantReleased bool - wantQueued int - }{ - {name: "email only", nType: models.NotificationTypeNewReply, attempts: 2, wantReleased: true}, - {name: "participant", nType: models.NotificationTypeNewReplyParticipating, attempts: 2, wantReleased: true}, - {name: "reopened", nType: models.NotificationTypeConversationReopened, attempts: 2, wantReleased: true}, - {name: "in app remains", nType: models.NotificationTypeNewReply, inApp: true, attempts: 2}, - {name: "retry remains", nType: models.NotificationTypeNewReply, attempts: 1, wantQueued: 1}, - {name: "newer email remains", nType: models.NotificationTypeNewReply, replaced: true, attempts: 2, wantQueued: 1}, - {name: "successful delivery", nType: models.NotificationTypeNewReply, delivered: true, attempts: 2}, - {name: "unrelated notification", nType: models.NotificationTypeAssignment, attempts: 2}, - } { - t.Run(tt.name, func(t *testing.T) { - db.MustExec(`DELETE FROM notification_email_queue`) - messageTime := time.Now().UTC().Truncate(time.Microsecond) - email := models.Email{UserID: userID, Type: tt.nType, ConversationID: null.IntFrom(convID), - MessageCreatedAt: null.TimeFrom(messageTime), Recipient: "abandon@example.com", Subject: "Reply", Content: "Reply"} - if tt.inApp { - email.NotificationID = null.IntFrom(notificationID) - } - released := false - queue.SetConversationStore(&replyNotificationRecorder{release: func(gotConv, gotUser int, gotTime time.Time) { - released = true - if gotConv != convID || gotUser != userID || !gotTime.Equal(messageTime) { - t.Fatalf("released wrong reply: conversation=%d, user=%d, time=%v", gotConv, gotUser, gotTime) - } - }}) - if !queue.SendAfter(email, -time.Second) { - t.Fatal("queueing email failed") - } - db.MustExec(`UPDATE notification_email_queue SET attempts = $1`, tt.attempts) - due := queue.due() - if len(due) != 1 { - t.Fatalf("due emails = %d, want 1", len(due)) - } - if tt.replaced { - email.MessageCreatedAt = null.TimeFrom(messageTime.Add(time.Minute)) - if !queue.SendAfter(email, time.Minute) { - t.Fatal("replacing queued email failed") - } - } - provider.err = errors.New("SMTP unavailable") - if tt.delivered { - provider.err = nil - } - if delivered := queue.deliver(due[0]); delivered != tt.delivered { - t.Fatalf("delivered = %v, want %v", delivered, tt.delivered) - } - if released != tt.wantReleased { - t.Fatalf("released = %v, want %v", released, tt.wantReleased) - } - var count int - if err := db.Get(&count, `SELECT count(*) FROM notification_email_queue`); err != nil { - t.Fatal(err) - } - if count != tt.wantQueued { - t.Fatalf("queued emails = %d, want %d", count, tt.wantQueued) - } - }) - } -} diff --git a/internal/notification/models/models.go b/internal/notification/models/models.go index 3319056e..7622b71d 100644 --- a/internal/notification/models/models.go +++ b/internal/notification/models/models.go @@ -20,6 +20,7 @@ const ( 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" @@ -27,7 +28,6 @@ const ( NotificationTypeSLAResolutionWarn NotificationType = "sla_resolution_warning" NotificationTypeSLAResolutionBreach NotificationType = "sla_resolution_breach" - // Superseded by the per-metric types above, retained so existing notification rows still render. NotificationTypeSLAWarning NotificationType = "sla_warning" NotificationTypeSLABreach NotificationType = "sla_breach" diff --git a/internal/notification/push.go b/internal/notification/push.go index a5f1feba..7b3c6899 100644 --- a/internal/notification/push.go +++ b/internal/notification/push.go @@ -31,47 +31,16 @@ const ( type pushSender func(context.Context, []byte, PushSubscription, string, string, string) (*http.Response, error) -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 pushDelivery struct { UserID int Payload nmodels.PushPayload } -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 pushSettingStore interface { Get(key string) (types.JSONText, error) Update(settings any) error } -type sqlPushStore struct { - q pushQueries -} - type PushManager struct { store pushSubscriptionStore lo *logf.Logger @@ -124,6 +93,19 @@ 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) @@ -215,29 +197,6 @@ func (m *PushManager) deliver(ctx context.Context, delivery pushDelivery) error return nil } -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 -} - func loadVAPIDKeys(settings pushSettingStore) (string, string, error) { privateKey, err := readStringSetting(settings, pushPrivateKeySetting) if err != nil { diff --git a/internal/notification/push_store.go b/internal/notification/push_store.go new file mode 100644 index 00000000..295b13f0 --- /dev/null +++ b/internal/notification/push_store.go @@ -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 +} diff --git a/internal/notification/queries.sql b/internal/notification/queries.sql index bfd5ab44..02f3f485 100644 --- a/internal/notification/queries.sql +++ b/internal/notification/queries.sql @@ -100,7 +100,7 @@ ON CONFLICT (user_id, notification_type, conversation_id) DO UPDATE SET subject = EXCLUDED.subject, content = EXCLUDED.content, message_created_at = EXCLUDED.message_created_at, - send_at = LEAST(notification_email_queue.send_at, EXCLUDED.send_at), + send_at = EXCLUDED.send_at, attempts = 0, queued_at = now(), updated_at = now(); diff --git a/schema.sql b/schema.sql index dea80277..f74a5150 100644 --- a/schema.sql +++ b/schema.sql @@ -22,7 +22,7 @@ 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', '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'); +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'); From 6fd04dee1bea873b80fd537d3516296599617790 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Wed, 16 Sep 2026 00:31:04 +0530 Subject: [PATCH 04/10] remove message time from email queue --- internal/conversation/conversation.go | 1 - internal/migrations/v2.9.0.go | 6 -- internal/migrations/v2.9.0_test.go | 17 ++---- internal/notification/channels/email.go | 15 +++-- internal/notification/email_queue.go | 26 ++++----- internal/notification/email_queue_test.go | 68 ++++++++--------------- internal/notification/models/models.go | 16 +++--- internal/notification/queries.sql | 7 +-- schema.sql | 1 - 9 files changed, 58 insertions(+), 99 deletions(-) diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index e2d53aba..a4122b51 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -1369,7 +1369,6 @@ func (m *Manager) sendReplyNotification(conversation models.Conversation, messag MessageID: null.IntFrom(message.ID), ConversationUUID: conversation.UUID, MessageUUID: message.UUID, - MessageCreatedAt: null.TimeFrom(message.CreatedAt), }); err != nil { m.lo.Error("error sending reply notification", "type", group.nType, "error", err) } diff --git a/internal/migrations/v2.9.0.go b/internal/migrations/v2.9.0.go index 96f9d959..5dcabb68 100644 --- a/internal/migrations/v2.9.0.go +++ b/internal/migrations/v2.9.0.go @@ -96,7 +96,6 @@ func V2_9_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { subject TEXT NOT NULL, content TEXT NOT NULL, queued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - message_created_at TIMESTAMPTZ, send_at TIMESTAMPTZ NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, CONSTRAINT constraint_uniq_notification_email_queue UNIQUE (user_id, notification_type, conversation_id) @@ -109,11 +108,6 @@ func V2_9_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { `); err != nil { return err } - if _, err := db.Exec(` - ALTER TABLE notification_email_queue ADD COLUMN IF NOT EXISTS message_created_at TIMESTAMPTZ; - `); 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 { diff --git a/internal/migrations/v2.9.0_test.go b/internal/migrations/v2.9.0_test.go index d8e6f0d7..d68b4bf6 100644 --- a/internal/migrations/v2.9.0_test.go +++ b/internal/migrations/v2.9.0_test.go @@ -129,14 +129,11 @@ func TestV2_9_0NotificationMigration(t *testing.T) { 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 expectedMessageTimeColumn, expectedAttemptsColumn string - if err := db.Get(&expectedMessageTimeColumn, columnQuery, "message_created_at"); err != nil { - t.Fatal(err) - } + var expectedAttemptsColumn string if err := db.Get(&expectedAttemptsColumn, columnQuery, "attempts"); err != nil { t.Fatal(err) } - db.MustExec(`ALTER TABLE notification_email_queue DROP COLUMN message_created_at, DROP COLUMN attempts`) + 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 { @@ -144,21 +141,15 @@ func TestV2_9_0PreservesExistingQueuedEmails(t *testing.T) { t.Fatal(err) } } - var actualMessageTimeColumn, actualAttemptsColumn string - if err := db.Get(&actualMessageTimeColumn, columnQuery, "message_created_at"); err != nil { - t.Fatal(err) - } + var actualAttemptsColumn string if err := db.Get(&actualAttemptsColumn, columnQuery, "attempts"); err != nil { t.Fatal(err) } - if actualMessageTimeColumn != expectedMessageTimeColumn { - t.Fatalf("message time column = %q, schema column = %q", actualMessageTimeColumn, expectedMessageTimeColumn) - } 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 message_created_at IS NULL AND attempts = 0 FROM notification_email_queue`); err != nil { + if err := db.Get(&preserved, `SELECT content = 'Pending reply' AND attempts = 0 FROM notification_email_queue`); err != nil { t.Fatal(err) } if !preserved { diff --git a/internal/notification/channels/email.go b/internal/notification/channels/email.go index 9d2b71d7..44cf367a 100644 --- a/internal/notification/channels/email.go +++ b/internal/notification/channels/email.go @@ -29,14 +29,13 @@ func (p *email) Send(delivery Delivery) Result { return Result{} } message := models.Email{ - UserID: delivery.Recipient.UserID, - NotificationID: delivery.NotificationID, - Type: delivery.Notification.Type, - ConversationID: delivery.Notification.ConversationID, - MessageCreatedAt: delivery.Notification.MessageCreatedAt, - Recipient: notification.Recipient, - Subject: notification.Subject, - Content: notification.Content, + 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)} diff --git a/internal/notification/email_queue.go b/internal/notification/email_queue.go index 220c01c5..9dbfd6ef 100644 --- a/internal/notification/email_queue.go +++ b/internal/notification/email_queue.go @@ -33,17 +33,17 @@ type emailQueueQueries struct { } 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"` - MessageCreatedAt null.Time `db:"message_created_at"` + 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 { @@ -85,7 +85,7 @@ func (q *EmailQueue) Send(e models.Email) bool { 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), e.MessageCreatedAt); err != nil { + 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 } @@ -127,7 +127,7 @@ func (q *EmailQueue) due() []queuedEmail { 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.MessageCreatedAt); err != nil { + 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 } diff --git a/internal/notification/email_queue_test.go b/internal/notification/email_queue_test.go index 348c207d..4e346c82 100644 --- a/internal/notification/email_queue_test.go +++ b/internal/notification/email_queue_test.go @@ -24,7 +24,7 @@ func (p *emailDeliveryProvider) Name() string { return ProviderEmail } -func TestDelayedReplyEmailUsesMessageTime(t *testing.T) { +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 { @@ -47,7 +47,6 @@ func TestDelayedReplyEmailUsesMessageTime(t *testing.T) { Pipeline: channels.NewPipeline(channels.NewEmail(queue)), Prefs: fakePreferences{channels: map[int][]models.NotificationChannel{userID: {models.NotificationChannelEmail}}}, }) - messageTime := time.Now().UTC().Add(-time.Minute).Truncate(time.Microsecond) n := models.Notification{ Type: models.NotificationTypeNewReply, Recipients: []models.Recipient{{ @@ -59,22 +58,25 @@ func TestDelayedReplyEmailUsesMessageTime(t *testing.T) { Delay: time.Minute, }, }}, - ConversationID: null.IntFrom(convID), - MessageCreatedAt: null.TimeFrom(messageTime), + ConversationID: null.IntFrom(convID), } - db.MustExec(`INSERT INTO conversation_last_seen (user_id, conversation_id, last_seen_at) VALUES ($1, $2, $3)`, userID, convID, messageTime.Add(-time.Second)) + 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 - readTime time.Time - want bool + name string + readOffset time.Duration + want bool }{ - {"read before reply", messageTime.Add(-time.Second), false}, - {"read at reply", messageTime, true}, - {"read before enqueue", messageTime.Add(time.Second), true}, + {"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) { - db.MustExec(`UPDATE conversation_last_seen SET last_seen_at = $1`, tt.readTime) 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 { @@ -91,28 +93,31 @@ func TestDelayedReplyEmailUsesMessageTime(t *testing.T) { } t.Run("coalesced reply uses latest message", func(t *testing.T) { - db.MustExec(`UPDATE conversation_last_seen SET last_seen_at = $1`, messageTime.Add(time.Second)) + 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.MessageCreatedAt = null.TimeFrom(messageTime.Add(2 * time.Second)) n.Recipients[0].Email.Content = "Second reply" n.Recipients[0].Email.Delay = 2 * time.Minute d.Send(n) - var secondSendAt time.Time - if err := db.Get(&secondSendAt, `SELECT send_at FROM notification_email_queue`); err != nil { + 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 !secondSendAt.After(firstSendAt) { - t.Fatalf("coalesced email send time = %v, want after %v", secondSendAt, firstSendAt) + 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) @@ -120,7 +125,7 @@ func TestDelayedReplyEmailUsesMessageTime(t *testing.T) { if seen { t.Fatal("unread second reply suppressed") } - db.MustExec(`UPDATE conversation_last_seen SET last_seen_at = $1`, messageTime.Add(3*time.Second)) + 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) @@ -129,31 +134,6 @@ func TestDelayedReplyEmailUsesMessageTime(t *testing.T) { t.Fatal("read second reply was not suppressed") } }) - - t.Run("legacy email without message time", func(t *testing.T) { - n.MessageCreatedAt = null.Time{} - d.Send(n) - 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)) - } - seen, err := queue.seen(due[0]) - if err != nil { - t.Fatal(err) - } - if seen { - t.Fatal("legacy unread email suppressed") - } - db.MustExec(`UPDATE conversation_last_seen SET last_seen_at = now()`) - seen, err = queue.seen(due[0]) - if err != nil { - t.Fatal(err) - } - if !seen { - t.Fatal("legacy read email was not suppressed") - } - }) } func TestDelayedEmailSeenCheckReturnsDatabaseError(t *testing.T) { diff --git a/internal/notification/models/models.go b/internal/notification/models/models.go index 7622b71d..5574cbd5 100644 --- a/internal/notification/models/models.go +++ b/internal/notification/models/models.go @@ -83,7 +83,6 @@ type Notification struct { Body null.String ConversationID null.Int MessageID null.Int - MessageCreatedAt null.Time ActorID null.Int Meta json.RawMessage ConversationUUID string @@ -100,14 +99,13 @@ type EmailNotification struct { } type Email struct { - UserID int - NotificationID null.Int - Type NotificationType - ConversationID null.Int - Recipient string - Subject string - Content string - MessageCreatedAt null.Time + UserID int + NotificationID null.Int + Type NotificationType + ConversationID null.Int + Recipient string + Subject string + Content string } type PushPayload struct { diff --git a/internal/notification/queries.sql b/internal/notification/queries.sql index 02f3f485..c73e48a0 100644 --- a/internal/notification/queries.sql +++ b/internal/notification/queries.sql @@ -92,14 +92,13 @@ SELECT COALESCE((SELECT is_read FROM user_notifications WHERE id = $2 AND user_i -- name: enqueue-notification-email INSERT INTO notification_email_queue - (user_id, notification_id, notification_type, conversation_id, recipient_email, subject, content, send_at, message_created_at) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + (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, - message_created_at = EXCLUDED.message_created_at, send_at = EXCLUDED.send_at, attempts = 0, queued_at = now(), @@ -119,7 +118,7 @@ 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, COALESCE(q.message_created_at, q.queued_at) AS message_created_at; + 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; diff --git a/schema.sql b/schema.sql index f74a5150..5e090136 100644 --- a/schema.sql +++ b/schema.sql @@ -991,7 +991,6 @@ CREATE TABLE notification_email_queue ( subject TEXT NOT NULL, content TEXT NOT NULL, queued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - message_created_at TIMESTAMPTZ, send_at TIMESTAMPTZ NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, CONSTRAINT constraint_uniq_notification_email_queue UNIQUE (user_id, notification_type, conversation_id) From ef53a4810617313a59bb7156fd69db195308f0a7 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Wed, 16 Sep 2026 01:43:39 +0530 Subject: [PATCH 05/10] revert chagnes to render.go --- internal/template/render.go | 15 ++++--- internal/template/render_test.go | 68 -------------------------------- 2 files changed, 7 insertions(+), 76 deletions(-) delete mode 100644 internal/template/render_test.go diff --git a/internal/template/render.go b/internal/template/render.go index 9c72bd99..479ddb61 100644 --- a/internal/template/render.go +++ b/internal/template/render.go @@ -3,9 +3,8 @@ package template import ( "bytes" "fmt" - htmltemplate "html/template" "strings" - texttemplate "text/template" + "text/template" "github.com/valyala/fasthttp" ) @@ -33,7 +32,7 @@ const ( // RenderString renders Go template variables in the given content string // without wrapping it in the base email template. Returns original content on any error. func (m *Manager) RenderString(data any, content string) string { - t, err := htmltemplate.New("content").Funcs(m.funcMap).Parse(content) + t, err := template.New("content").Funcs(m.funcMap).Parse(content) if err != nil { return content } @@ -68,12 +67,12 @@ func (m *Manager) RenderEmailWithTemplate(data any, content string) (string, err defaultTmpl.Body = `{{ template "content" . }}` } - baseTemplate, err := htmltemplate.New(TmplBase).Funcs(m.funcMap).Parse(defaultTmpl.Body) + baseTemplate, err := template.New(TmplBase).Funcs(m.funcMap).Parse(defaultTmpl.Body) if err != nil { return "", fmt.Errorf("parsing base template: %w", err) } - contentTemplate, err := htmltemplate.New(TmplContent).Funcs(m.funcMap).Parse(content) + contentTemplate, err := template.New(TmplContent).Funcs(m.funcMap).Parse(content) if err != nil { return "", fmt.Errorf("parsing content template: %w", err) } @@ -103,7 +102,7 @@ func (m *Manager) RenderStoredEmailTemplate(name string, data any) (string, stri executeSubjectTemplate := func(subject string) (string, error) { var sb strings.Builder - subjectTmpl, err := texttemplate.New("subject").Funcs(m.funcMap).Parse(subject) + subjectTmpl, err := template.New("subject").Funcs(m.funcMap).Parse(subject) if err != nil { return "", fmt.Errorf("parsing subject template: %w", err) } @@ -122,12 +121,12 @@ func (m *Manager) RenderStoredEmailTemplate(name string, data any) (string, stri defaultTmpl.Body = `{{ template "content" . }}` } - baseTemplate, err := htmltemplate.New(TmplBase).Funcs(m.funcMap).Parse(defaultTmpl.Body) + baseTemplate, err := template.New(TmplBase).Funcs(m.funcMap).Parse(defaultTmpl.Body) if err != nil { return "", "", fmt.Errorf("parsing base template: %w", err) } - contentTemplate, err := htmltemplate.New(TmplContent).Funcs(m.funcMap).Parse(tmpl.Body) + contentTemplate, err := template.New(TmplContent).Funcs(m.funcMap).Parse(tmpl.Body) if err != nil { return "", "", fmt.Errorf("parsing content template: %w", err) } diff --git a/internal/template/render_test.go b/internal/template/render_test.go deleted file mode 100644 index bbf18730..00000000 --- a/internal/template/render_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package template - -import ( - htmltemplate "html/template" - "strings" - "testing" - - "github.com/abhinavxd/libredesk/internal/testutil" - "github.com/zerodha/logf" -) - -func TestRenderStringEscapesDataAndPreservesTemplateMarkup(t *testing.T) { - m := &Manager{funcMap: htmltemplate.FuncMap{}} - content := m.RenderString(map[string]any{ - "Contact": map[string]any{"FullName": "attacker"}, - }, `

Hi {{ .Contact.FullName }}

`) - - if !strings.Contains(content, "

Hi <b>attacker</b>

") { - t.Fatalf("unexpected content: %s", content) - } -} - -func TestRenderEmailWithTemplateEscapesDataAndPreservesTemplateMarkup(t *testing.T) { - db := testutil.NewDB(t, "outgoing_email_template_escape") - lo := logf.New(logf.Opts{}) - m, err := New(&lo, db, nil, nil, htmltemplate.FuncMap{}, testutil.NewI18n(t)) - if err != nil { - t.Fatal(err) - } - - content, err := m.RenderEmailWithTemplate(map[string]any{ - "Contact": map[string]any{"FullName": "attacker"}, - }, `

Hi {{ .Contact.FullName }}

`) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(content, "

Hi <b>attacker</b>

") { - t.Fatalf("unexpected content: %s", content) - } -} - -func TestRenderStoredEmailTemplateEscapesUntrustedHTML(t *testing.T) { - db := testutil.NewDB(t, "stored_email_template_escape") - lo := logf.New(logf.Opts{}) - m, err := New(&lo, db, nil, nil, htmltemplate.FuncMap{"RootURL": func() string { return "http://localhost" }}, testutil.NewI18n(t)) - if err != nil { - t.Fatal(err) - } - - content, _, err := m.RenderStoredEmailTemplate(TmplNewReply, map[string]any{ - "Author": map[string]any{"FullName": "attacker"}, - "Conversation": map[string]any{ - "ReferenceNumber": 42, - "Subject": "subject", - "UUID": "conversation-uuid", - }, - "Message": map[string]any{"Content": htmltemplate.HTML("safe message")}, - }) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(content, "<b>attacker</b>") || !strings.Contains(content, "<i>subject</i>") { - t.Fatalf("untrusted HTML was not escaped: %s", content) - } - if !strings.Contains(content, "safe message") { - t.Fatalf("trusted message HTML was escaped: %s", content) - } -} From 43c007ef9e0a90a2f75ccc18db74608dfd5ea17d Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Wed, 16 Sep 2026 01:54:35 +0530 Subject: [PATCH 06/10] improve notification previews and editor AI Show the latest reply in alerts without quoted email history. Share AI prompts across conversation editors and clarify browser notification settings. --- frontend/apps/main/src/App.vue | 3 + .../components/editor/ConversationEditor.vue | 9 +- .../src/components/editor/EditorToolbar.vue | 37 +++++++-- .../src/features/admin/templates/defaults.js | 12 ++- .../src/features/conversation/ReplyBox.vue | 14 +--- .../features/conversation/ReplyBoxContent.vue | 22 ++--- frontend/apps/main/src/stores/aiPrompt.js | 81 ++++++++++++------ .../NotificationPreferences.test.js | 82 +++++++++++++++---- .../notifications/NotificationPreferences.vue | 25 +++++- .../src/views/admin/ai/AIEditorPrompts.vue | 34 ++++---- i18n/en-US.json | 3 +- internal/conversation/conversation.go | 18 +++- .../conversation/conversation_reference.go | 10 --- .../conversation_reference_test.go | 21 +---- internal/conversation/notification_test.go | 39 +++++++++ internal/migrations/v2.9.0.go | 12 ++- schema.sql | 12 ++- 17 files changed, 284 insertions(+), 150 deletions(-) diff --git a/frontend/apps/main/src/App.vue b/frontend/apps/main/src/App.vue index 5012311b..b9dfe330 100644 --- a/frontend/apps/main/src/App.vue +++ b/frontend/apps/main/src/App.vue @@ -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' @@ -166,6 +167,7 @@ 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. @@ -217,6 +219,7 @@ const initStores = async () => { slaStore.fetchSlas(), tagStore.fetchTags(), customAttributeStore.fetchCustomAttributes(), + aiPromptStore.fetchPrompts(), refreshPushSubscription() ]) } diff --git a/frontend/apps/main/src/components/editor/ConversationEditor.vue b/frontend/apps/main/src/components/editor/ConversationEditor.vue index 9b7c7070..a0ae2cf1 100644 --- a/frontend/apps/main/src/components/editor/ConversationEditor.vue +++ b/frontend/apps/main/src/components/editor/ConversationEditor.vue @@ -12,9 +12,9 @@ > @@ -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 }) => { diff --git a/frontend/apps/main/src/components/editor/EditorToolbar.vue b/frontend/apps/main/src/components/editor/EditorToolbar.vue index 79fab042..08085f95 100644 --- a/frontend/apps/main/src/components/editor/EditorToolbar.vue +++ b/frontend/apps/main/src/components/editor/EditorToolbar.vue @@ -1,8 +1,14 @@ diff --git a/frontend/apps/main/src/features/admin/templates/defaults.js b/frontend/apps/main/src/features/admin/templates/defaults.js index 362331bb..4df4b92d 100644 --- a/frontend/apps/main/src/features/admin/templates/defaults.js +++ b/frontend/apps/main/src/features/admin/templates/defaults.js @@ -21,7 +21,9 @@ export const BUILT_IN_TEMPLATE_BODIES = { Subject: {{ .Conversation.Subject }}
-
{{ .Message.Content }}
+
+{{ .Message.Content }} +

View Conversation @@ -38,7 +40,9 @@ export const BUILT_IN_TEMPLATE_BODIES = { Subject: {{ .Conversation.Subject }}

-
{{ .Message.Content }}
+
+{{ .Message.Content }} +

View Conversation @@ -55,7 +59,9 @@ export const BUILT_IN_TEMPLATE_BODIES = { Subject: {{ .Conversation.Subject }} -

{{ .Message.Content }}
+
+{{ .Message.Content }} +

View Conversation diff --git a/frontend/apps/main/src/features/conversation/ReplyBox.vue b/frontend/apps/main/src/features/conversation/ReplyBox.vue index edee9956..5c7acda6 100644 --- a/frontend/apps/main/src/features/conversation/ReplyBox.vue +++ b/frontend/apps/main/src/features/conversation/ReplyBox.vue @@ -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 @@ 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 }) diff --git a/frontend/apps/main/src/features/conversation/ReplyBoxContent.vue b/frontend/apps/main/src/features/conversation/ReplyBoxContent.vue index b97e132c..f207d2f2 100644 --- a/frontend/apps/main/src/features/conversation/ReplyBoxContent.vue +++ b/frontend/apps/main/src/features/conversation/ReplyBoxContent.vue @@ -7,18 +7,18 @@ :class="{ 'mb-4': !isFullscreen, 'border-b border-border pb-4': isFullscreen }" > - + {{ $t('globals.terms.reply') }} {{ $t('globals.terms.privateNote') }} @@ -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, diff --git a/frontend/apps/main/src/stores/aiPrompt.js b/frontend/apps/main/src/stores/aiPrompt.js index b706deee..e6050035 100644 --- a/frontend/apps/main/src/stores/aiPrompt.js +++ b/frontend/apps/main/src/stores/aiPrompt.js @@ -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 + } }) diff --git a/frontend/apps/main/src/views/account/notifications/NotificationPreferences.test.js b/frontend/apps/main/src/views/account/notifications/NotificationPreferences.test.js index 092fd6de..80a42143 100644 --- a/frontend/apps/main/src/views/account/notifications/NotificationPreferences.test.js +++ b/frontend/apps/main/src/views/account/notifications/NotificationPreferences.test.js @@ -2,43 +2,73 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { createApp, h, nextTick, ref } from 'vue' -const { getPreferences, savePreferences, refresh, emit } = vi.hoisted(() => ({ +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('@/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 }) + 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) - }) + 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 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, preferences: [ - { notification_type: 'new_reply', channel: 'email', enabled: true }, - { notification_type: 'new_reply', channel: 'in_app', enabled: false } - ] - } } }) + getPreferences.mockResolvedValue({ + data: { + data: { + email_enabled: true, + vapid_public_key: vapidPublicKey, + 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) @@ -54,7 +84,9 @@ describe('notification preferences', () => { expect(email).toBeDefined() email.click() await settle() - expect(savePreferences).toHaveBeenCalledWith([{ notification_type: 'new_reply', channel: 'email', enabled: false }]) + expect(savePreferences).toHaveBeenCalledWith([ + { notification_type: 'new_reply', channel: 'email', enabled: false } + ]) }) it('prevents overlapping saves for the same preference', async () => { @@ -99,4 +131,22 @@ describe('notification preferences', () => { 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() + }) }) diff --git a/frontend/apps/main/src/views/account/notifications/NotificationPreferences.vue b/frontend/apps/main/src/views/account/notifications/NotificationPreferences.vue index e6287a3d..057a2f5c 100644 --- a/frontend/apps/main/src/views/account/notifications/NotificationPreferences.vue +++ b/frontend/apps/main/src/views/account/notifications/NotificationPreferences.vue @@ -9,14 +9,24 @@ {{ $t('notification.emailChannelDisabled') }}

-
+

{{ $t('globals.terms.browserNotification', 2) }}

-

{{ browserPushDescription }}

+

+ {{ browserPushDescription }} +

+ ")), + "Content": htmltemplate.HTML(strings.ReplaceAll(html.EscapeString(messageText), "\n", "
")), } content, subject, err := m.template.RenderStoredEmailTemplate(group.tmpl, data) return subject, content, err @@ -1364,7 +1366,7 @@ func (m *Manager) sendReplyNotification(conversation models.Conversation, messag Type: group.nType, Recipients: recipients, Title: group.title, - Body: conversation.Subject, + Body: null.StringFrom(cmp.Or(messageText, conversation.Subject.String)), ConversationID: null.IntFrom(conversation.ID), MessageID: null.IntFrom(message.ID), ConversationUUID: conversation.UUID, @@ -1422,7 +1424,7 @@ 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 := sanitizeNotificationHTML(message.Content, rootURL) + messageContent := htmltemplate.HTML(absolutizeConversationReferenceLinks(message.Content, rootURL)) recipients := m.buildNotificationRecipients(userIDs, func(recipient umodels.User) (string, string, error) { data := notificationTemplateData(conversation, recipient, umodels.User{}) @@ -2394,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) +} diff --git a/internal/conversation/conversation_reference.go b/internal/conversation/conversation_reference.go index b76f58fe..b6f540ff 100644 --- a/internal/conversation/conversation_reference.go +++ b/internal/conversation/conversation_reference.go @@ -1,14 +1,9 @@ package conversation import ( - htmltemplate "html/template" "strings" - - "github.com/microcosm-cc/bluemonday" ) -var notificationHTMLPolicy = bluemonday.UGCPolicy() - func absolutizeConversationReferenceLinks(content, rootURL string) string { if rootURL == "" { return content @@ -19,8 +14,3 @@ func absolutizeConversationReferenceLinks(content, rootURL string) string { `href="`+strings.TrimRight(rootURL, "/")+`/inboxes/all/conversation/`, ) } - -func sanitizeNotificationHTML(content, rootURL string) htmltemplate.HTML { - content = notificationHTMLPolicy.Sanitize(content) - return htmltemplate.HTML(absolutizeConversationReferenceLinks(content, rootURL)) -} diff --git a/internal/conversation/conversation_reference_test.go b/internal/conversation/conversation_reference_test.go index 24a390f3..954b6c91 100644 --- a/internal/conversation/conversation_reference_test.go +++ b/internal/conversation/conversation_reference_test.go @@ -1,9 +1,6 @@ package conversation -import ( - "strings" - "testing" -) +import "testing" func TestAbsolutizeConversationReferenceLinks(t *testing.T) { content := `

#108 Help #109

` @@ -13,19 +10,3 @@ func TestAbsolutizeConversationReferenceLinks(t *testing.T) { t.Fatalf("got %q, want %q", got, want) } } - -func TestSanitizeNotificationHTML(t *testing.T) { - content := `

Allowed#108

` - got := string(sanitizeNotificationHTML(content, "https://desk.example.com")) - - for _, unsafe := range []string{"onerror", "Allowed", `href="https://desk.example.com/inboxes/all/conversation/abc"`} { - if !strings.Contains(got, safe) { - t.Fatalf("safe content %q missing from %q", safe, got) - } - } -} diff --git a/internal/conversation/notification_test.go b/internal/conversation/notification_test.go index 76f0252e..f389b3a5 100644 --- a/internal/conversation/notification_test.go +++ b/internal/conversation/notification_test.go @@ -4,6 +4,7 @@ import ( "errors" htmltemplate "html/template" "slices" + "strings" "sync" "testing" "time" @@ -222,6 +223,44 @@ func TestReplyNotificationsAlertForEveryMessage(t *testing.T) { } }) + 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 = `
Live chat reply
Earlier message
` + 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 diff --git a/internal/migrations/v2.9.0.go b/internal/migrations/v2.9.0.go index 5dcabb68..406df15b 100644 --- a/internal/migrations/v2.9.0.go +++ b/internal/migrations/v2.9.0.go @@ -133,7 +133,9 @@ func V2_9_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { Subject: {{ .Conversation.Subject }}
-
{{ .Message.Content }}
+
+{{ .Message.Content }} +

View Conversation @@ -160,7 +162,9 @@ func V2_9_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { Subject: {{ .Conversation.Subject }}

-
{{ .Message.Content }}
+
+{{ .Message.Content }} +

View Conversation @@ -187,7 +191,9 @@ func V2_9_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { Subject: {{ .Conversation.Subject }} -

{{ .Message.Content }}
+
+{{ .Message.Content }} +

View Conversation diff --git a/schema.sql b/schema.sql index 5e090136..fc510c63 100644 --- a/schema.sql +++ b/schema.sql @@ -1109,7 +1109,9 @@ VALUES('email_notification'::template_type, ' Subject: {{ .Conversation.Subject }} -

{{ .Message.Content }}
+
+{{ .Message.Content }} +

View Conversation @@ -1132,7 +1134,9 @@ VALUES('email_notification'::template_type, ' Subject: {{ .Conversation.Subject }} -

{{ .Message.Content }}
+
+{{ .Message.Content }} +

View Conversation @@ -1155,7 +1159,9 @@ VALUES('email_notification'::template_type, ' Subject: {{ .Conversation.Subject }} -

{{ .Message.Content }}
+
+{{ .Message.Content }} +

View Conversation From 635ce63b78cf97144fab89aef29aed0fc0bba70e Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Wed, 16 Sep 2026 12:39:05 +0530 Subject: [PATCH 07/10] fix browser notification persistence --- frontend/apps/main/index.html | 2 +- .../account/notifications/NotificationPreferences.test.js | 6 +++--- .../views/account/notifications/NotificationPreferences.vue | 1 + 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/apps/main/index.html b/frontend/apps/main/index.html index f4f773c6..63340480 100644 --- a/frontend/apps/main/index.html +++ b/frontend/apps/main/index.html @@ -6,7 +6,7 @@ - + diff --git a/frontend/apps/main/src/views/account/notifications/NotificationPreferences.test.js b/frontend/apps/main/src/views/account/notifications/NotificationPreferences.test.js index 80a42143..993c69d8 100644 --- a/frontend/apps/main/src/views/account/notifications/NotificationPreferences.test.js +++ b/frontend/apps/main/src/views/account/notifications/NotificationPreferences.test.js @@ -62,6 +62,7 @@ const mount = async (vapidPublicKey = 'test-key') => { 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 } @@ -119,10 +120,9 @@ describe('notification preferences', () => { expect(email.disabled).toBe(false) }) - it('leaves the push subscription to the app shell', async () => { + it('restores this browser push state from the saved endpoints', async () => { await mount() - expect(root.textContent).toContain('notification.type.newReply') - expect(refresh).not.toHaveBeenCalled() + expect(refresh).toHaveBeenCalledWith('test-key', ['https://push.example/subscription']) }) it('disables browser notifications when push is unavailable', async () => { diff --git a/frontend/apps/main/src/views/account/notifications/NotificationPreferences.vue b/frontend/apps/main/src/views/account/notifications/NotificationPreferences.vue index 057a2f5c..313e293d 100644 --- a/frontend/apps/main/src/views/account/notifications/NotificationPreferences.vue +++ b/frontend/apps/main/src/views/account/notifications/NotificationPreferences.vue @@ -120,6 +120,7 @@ const fetchPreferences = async () => { 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 } From 75ab05cc5fefe4fdaba7bfd249e5357c37a1747e Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Wed, 16 Sep 2026 23:07:37 +0530 Subject: [PATCH 08/10] show a loader while notification settings load --- .../NotificationPreferences.test.js | 33 +++++ .../notifications/NotificationPreferences.vue | 120 ++++++++++-------- 2 files changed, 97 insertions(+), 56 deletions(-) diff --git a/frontend/apps/main/src/views/account/notifications/NotificationPreferences.test.js b/frontend/apps/main/src/views/account/notifications/NotificationPreferences.test.js index 993c69d8..781df0a5 100644 --- a/frontend/apps/main/src/views/account/notifications/NotificationPreferences.test.js +++ b/frontend/apps/main/src/views/account/notifications/NotificationPreferences.test.js @@ -79,6 +79,39 @@ const mount = async (vapidPublicKey = 'test-key') => { } 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] diff --git a/frontend/apps/main/src/views/account/notifications/NotificationPreferences.vue b/frontend/apps/main/src/views/account/notifications/NotificationPreferences.vue index 313e293d..3e21f9aa 100644 --- a/frontend/apps/main/src/views/account/notifications/NotificationPreferences.vue +++ b/frontend/apps/main/src/views/account/notifications/NotificationPreferences.vue @@ -1,73 +1,77 @@ @@ -77,6 +81,7 @@ 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' @@ -86,6 +91,7 @@ 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()) @@ -132,6 +138,8 @@ const fetchPreferences = async () => { variant: 'destructive', description: handleHTTPError(error).message }) + } finally { + isLoading.value = false } } From ba349158bcd8a8b833f581c26c7cb14818f74696 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Thu, 17 Sep 2026 00:31:44 +0530 Subject: [PATCH 09/10] add notification settings link --- .../src/components/sidebar/NotificationPanel.vue | 14 +++++++++++++- i18n/en-US.json | 1 + 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/frontend/apps/main/src/components/sidebar/NotificationPanel.vue b/frontend/apps/main/src/components/sidebar/NotificationPanel.vue index 7087d6a2..cbc13e14 100644 --- a/frontend/apps/main/src/components/sidebar/NotificationPanel.vue +++ b/frontend/apps/main/src/components/sidebar/NotificationPanel.vue @@ -119,6 +119,17 @@ + +

+ + +
@@ -132,6 +143,7 @@ import { CheckCheck, X, Trash2, + Settings, AtSign, UserPlus, MessageSquare, @@ -139,7 +151,7 @@ import { 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' diff --git a/i18n/en-US.json b/i18n/en-US.json index 64e39dcc..d8904399 100644 --- a/i18n/en-US.json +++ b/i18n/en-US.json @@ -1389,6 +1389,7 @@ "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}", From cd4610a27aba2ff8ad1c2613b5204d943c6e08bc Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Thu, 17 Sep 2026 00:40:29 +0530 Subject: [PATCH 10/10] remove icon from new contact button --- frontend/apps/main/src/features/contact/ContactsList.vue | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/apps/main/src/features/contact/ContactsList.vue b/frontend/apps/main/src/features/contact/ContactsList.vue index 71dba87c..aa300190 100644 --- a/frontend/apps/main/src/features/contact/ContactsList.vue +++ b/frontend/apps/main/src/features/contact/ContactsList.vue @@ -49,10 +49,9 @@ @@ -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'