mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-10-25 22:09:36 +00:00
Merge remote-tracking branch 'origin/main' into search-improvements
This commit is contained in:
+1
-1
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -353,6 +353,10 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
||||
g.PUT("/api/v1/notifications/read-all", auth(handleMarkAllNotificationsAsRead))
|
||||
g.DELETE("/api/v1/notifications/{id}", auth(handleDeleteNotification))
|
||||
g.DELETE("/api/v1/notifications", auth(handleDeleteAllNotifications))
|
||||
g.GET("/api/v1/notifications/preferences", auth(handleGetNotificationPreferences))
|
||||
g.PUT("/api/v1/notifications/preferences", auth(handleUpdateNotificationPreferences))
|
||||
g.POST("/api/v1/notifications/push-subscriptions", auth(handleCreatePushSubscription))
|
||||
g.DELETE("/api/v1/notifications/push-subscriptions", auth(handleDeletePushSubscription))
|
||||
|
||||
// WebSocket.
|
||||
g.GET("/ws", auth(func(r *fastglue.Request) error {
|
||||
@@ -399,6 +403,8 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
||||
g.GET("/assets/{all:*}", serveFrontendStaticFiles)
|
||||
g.GET("/widget/assets/{all:*}", serveWidgetStaticFiles)
|
||||
g.GET("/images/{all:*}", serveFrontendStaticFiles)
|
||||
g.GET("/manifest.webmanifest", serveManifest)
|
||||
g.GET("/sw.js", serveServiceWorker)
|
||||
g.GET("/static/public/{all:*}", serveStaticFiles)
|
||||
|
||||
// Public pages.
|
||||
@@ -518,6 +524,27 @@ func serveFrontendStaticFiles(r *fastglue.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func serveManifest(r *fastglue.Request) error {
|
||||
return serveMainFrontendFile(r, "manifest.webmanifest", "application/manifest+json", "no-cache")
|
||||
}
|
||||
|
||||
func serveServiceWorker(r *fastglue.Request) error {
|
||||
r.RequestCtx.Response.Header.Set("Service-Worker-Allowed", "/")
|
||||
return serveMainFrontendFile(r, "sw.js", "application/javascript", "no-cache")
|
||||
}
|
||||
|
||||
func serveMainFrontendFile(r *fastglue.Request, name, contentType, cacheControl string) error {
|
||||
app := r.Context.(*App)
|
||||
file, err := app.fs.Get(filepath.Join(frontendDir, name))
|
||||
if err != nil {
|
||||
return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.T("validation.notFoundFile"), nil, envelope.NotFoundError)
|
||||
}
|
||||
r.RequestCtx.Response.Header.Set("Content-Type", contentType)
|
||||
r.RequestCtx.Response.Header.Set("Cache-Control", cacheControl)
|
||||
r.RequestCtx.SetBody(file.ReadBytes())
|
||||
return nil
|
||||
}
|
||||
|
||||
// serveWidgetStaticFiles serves widget static assets from the embedded filesystem.
|
||||
func serveWidgetStaticFiles(r *fastglue.Request) error {
|
||||
app := r.Context.(*App)
|
||||
|
||||
+52
-6
@@ -39,6 +39,7 @@ import (
|
||||
fs "github.com/abhinavxd/libredesk/internal/media/stores/localfs"
|
||||
"github.com/abhinavxd/libredesk/internal/media/stores/s3"
|
||||
notifier "github.com/abhinavxd/libredesk/internal/notification"
|
||||
notificationchannels "github.com/abhinavxd/libredesk/internal/notification/channels"
|
||||
emailnotifier "github.com/abhinavxd/libredesk/internal/notification/providers/email"
|
||||
"github.com/abhinavxd/libredesk/internal/oidc"
|
||||
"github.com/abhinavxd/libredesk/internal/ratelimit"
|
||||
@@ -1166,6 +1167,19 @@ func initUserNotification(db *sqlx.DB, i18n *i18n.I18n) *notifier.UserNotificati
|
||||
return m
|
||||
}
|
||||
|
||||
// initNotificationPreference inits the notification preference manager.
|
||||
func initNotificationPreference(db *sqlx.DB, i18n *i18n.I18n) *notifier.PreferenceManager {
|
||||
m, err := notifier.NewPreferenceManager(notifier.PreferenceManagerOpts{
|
||||
DB: db,
|
||||
Lo: initLogger("notification-preference"),
|
||||
I18n: i18n,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("error initializing notification preference manager: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// initImporter inits the importer manager.
|
||||
func initImporter(i18n *i18n.I18n) *importer.Importer {
|
||||
return importer.New(importer.Opts{
|
||||
@@ -1174,14 +1188,46 @@ func initImporter(i18n *i18n.I18n) *importer.Importer {
|
||||
})
|
||||
}
|
||||
|
||||
func initNotificationEmailQueue(db *sqlx.DB, outbound *notifier.Service) *notifier.EmailQueue {
|
||||
q, err := notifier.NewEmailQueue(notifier.EmailQueueOpts{
|
||||
DB: db,
|
||||
Outbound: outbound,
|
||||
Lo: initLogger("notification-email-queue"),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("error initializing notification email queue: %v", err)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
func initPushNotification(db *sqlx.DB, settings *setting.Manager, i18n *i18n.I18n) *notifier.PushManager {
|
||||
m, err := notifier.NewPushManager(notifier.PushManagerOpts{
|
||||
DB: db,
|
||||
Settings: settings,
|
||||
Lo: initLogger("push-notification"),
|
||||
I18n: i18n,
|
||||
RootURL: ko.String("app.root_url"),
|
||||
Concurrency: ko.MustInt("notification.concurrency"),
|
||||
QueueSize: ko.MustInt("notification.queue_size"),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("error initializing push notification manager: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// initNotifDispatcher initializes the notification dispatcher.
|
||||
func initNotifDispatcher(userNotification *notifier.UserNotificationManager, outbound *notifier.Service, wsHub *ws.Hub, emailEnabled bool) *notifier.Dispatcher {
|
||||
func initNotifDispatcher(userNotification *notifier.UserNotificationManager, prefs *notifier.PreferenceManager, push *notifier.PushManager, emailQueue *notifier.EmailQueue, wsHub *ws.Hub, emailEnabled bool) *notifier.Dispatcher {
|
||||
providers := []notificationchannels.Provider{
|
||||
notificationchannels.NewInApp(userNotification, wsHub, initLogger("notification-in-app")),
|
||||
}
|
||||
if emailEnabled {
|
||||
providers = append(providers, notificationchannels.NewEmail(emailQueue))
|
||||
}
|
||||
providers = append(providers, notificationchannels.NewPush(push))
|
||||
return notifier.NewDispatcher(notifier.DispatcherOpts{
|
||||
InApp: userNotification,
|
||||
Outbound: outbound,
|
||||
WSHub: wsHub,
|
||||
EmailEnabled: emailEnabled,
|
||||
Lo: initLogger("notification-dispatcher"),
|
||||
Pipeline: notificationchannels.NewPipeline(providers...),
|
||||
Prefs: prefs,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+12
-1
@@ -126,6 +126,8 @@ type App struct {
|
||||
activityLog *activitylog.Manager
|
||||
notifier *notifier.Service
|
||||
userNotification *notifier.UserNotificationManager
|
||||
notificationPref *notifier.PreferenceManager
|
||||
pushNotification *notifier.PushManager
|
||||
customAttribute *customAttribute.Manager
|
||||
report *report.Manager
|
||||
webhook *webhook.Manager
|
||||
@@ -249,7 +251,10 @@ func main() {
|
||||
wsHub = initWS(user)
|
||||
notifier = initNotifier()
|
||||
userNotification = initUserNotification(db, i18n)
|
||||
notifDispatcher = initNotifDispatcher(userNotification, notifier, wsHub, ko.Bool("notification.email.enabled"))
|
||||
notificationPreference = initNotificationPreference(db, i18n)
|
||||
pushNotification = initPushNotification(db, settings, i18n)
|
||||
notificationEmailQueue = initNotificationEmailQueue(db, notifier)
|
||||
notifDispatcher = initNotifDispatcher(userNotification, notificationPreference, pushNotification, notificationEmailQueue, wsHub, ko.Bool("notification.email.enabled"))
|
||||
automation = initAutomationEngine(db, i18n)
|
||||
ai = initAI(ctx, db, i18n, ssrfControl)
|
||||
sla = initSLA(db, team, settings, businessHours, template, user, i18n, notifDispatcher)
|
||||
@@ -285,6 +290,10 @@ func main() {
|
||||
go conversation.RunDraftCleaner(ctx, draftRetentionDuration)
|
||||
go userNotification.RunNotificationCleaner(ctx)
|
||||
go helpCenter.RunSearchLogCleaner(ctx)
|
||||
if ko.Bool("notification.email.enabled") {
|
||||
go notificationEmailQueue.Run(ctx)
|
||||
}
|
||||
go pushNotification.Run(ctx)
|
||||
go aiAgent.Run(ctx, cmp.Or(ko.Int("ai_agent.worker_count"), 10))
|
||||
go ai.Run(ctx)
|
||||
|
||||
@@ -329,6 +338,8 @@ func main() {
|
||||
redis: rdb,
|
||||
fc: initFastCache(rdb),
|
||||
userNotification: userNotification,
|
||||
notificationPref: notificationPreference,
|
||||
pushNotification: pushNotification,
|
||||
wsHub: wsHub,
|
||||
}
|
||||
app.consts.Store(constants)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
|
||||
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
notifier "github.com/abhinavxd/libredesk/internal/notification"
|
||||
nmodels "github.com/abhinavxd/libredesk/internal/notification/models"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
@@ -97,3 +99,72 @@ func handleDeleteAllNotifications(r *fastglue.Request) error {
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
func handleGetNotificationPreferences(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
prefs, err := app.notificationPref.GetMatrix(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
pushEndpoints, err := app.pushNotification.Endpoints(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(map[string]any{
|
||||
"preferences": prefs,
|
||||
"email_enabled": ko.Bool("notification.email.enabled"),
|
||||
"vapid_public_key": app.pushNotification.PublicKey(),
|
||||
"push_endpoints": pushEndpoints,
|
||||
})
|
||||
}
|
||||
|
||||
func handleUpdateNotificationPreferences(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
prefs []nmodels.NotificationPreference
|
||||
)
|
||||
if err := r.Decode(&prefs, "json"); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
|
||||
app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
|
||||
}
|
||||
if err := app.notificationPref.Update(auser.ID, prefs); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
func handleCreatePushSubscription(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
subscription notifier.PushSubscriptionInput
|
||||
)
|
||||
if err := r.Decode(&subscription, "json"); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
|
||||
}
|
||||
if err := app.pushNotification.Upsert(auser.ID, subscription); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
func handleDeletePushSubscription(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
request struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
}
|
||||
)
|
||||
if err := r.Decode(&request, "json"); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
|
||||
}
|
||||
if err := app.pushNotification.Delete(auser.ID, request.Endpoint); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
|
||||
<meta name="theme-color" content="hsl(152 39% 30%)" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" crossorigin="use-credentials" />
|
||||
<link rel="apple-touch-icon" href="/images/pwa-icon-192.png" />
|
||||
<link rel="preload" href="/static/public/static/fonts/Geist-Variable.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="stylesheet" href="/static/public/static/fonts.css">
|
||||
</head>
|
||||
@@ -14,4 +17,4 @@
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -92,6 +92,7 @@ import { useTagStore } from './stores/tag'
|
||||
import { useCustomAttributeStore } from './stores/customAttributes'
|
||||
import { useIdleDetection } from './composables/useIdleDetection'
|
||||
import { useNotificationStore } from './stores/notification'
|
||||
import { useAiPromptStore } from '@main/stores/aiPrompt'
|
||||
import { useViewStore } from './stores/view'
|
||||
import { useKeyboardShortcutsDialog } from './composables/useKeyboardShortcutsDialog'
|
||||
import KeyboardShortcutsDialog from './components/KeyboardShortcutsDialog.vue'
|
||||
@@ -122,6 +123,7 @@ import NotificationBell from '@main/components/sidebar/NotificationBell.vue'
|
||||
import PrimaryNavItems from '@main/components/sidebar/PrimaryNavItems.vue'
|
||||
import { useIsMobile } from '@shared-ui/composables'
|
||||
import api from '@main/api'
|
||||
import { usePushNotifications } from '@/composables/usePushNotifications'
|
||||
|
||||
const route = useRoute()
|
||||
const emitter = useEmitter()
|
||||
@@ -165,6 +167,8 @@ const openCreateConversationDialog = ref(false)
|
||||
const createConversationContact = ref(null)
|
||||
const { t } = useI18n()
|
||||
const notificationStore = useNotificationStore()
|
||||
const aiPromptStore = useAiPromptStore()
|
||||
const pushNotifications = usePushNotifications()
|
||||
|
||||
// Update browser tab title with unread notification count.
|
||||
// Watch both unreadCount and route so the prefix is preserved after navigation.
|
||||
@@ -214,10 +218,17 @@ const initStores = async () => {
|
||||
inboxStore.fetchInboxes(),
|
||||
slaStore.fetchSlas(),
|
||||
tagStore.fetchTags(),
|
||||
customAttributeStore.fetchCustomAttributes()
|
||||
customAttributeStore.fetchCustomAttributes(),
|
||||
aiPromptStore.fetchPrompts(),
|
||||
refreshPushSubscription()
|
||||
])
|
||||
}
|
||||
|
||||
const refreshPushSubscription = async () => {
|
||||
const { data } = await api.getNotificationPreferences()
|
||||
await pushNotifications.refresh(data.data.vapid_public_key, data.data.push_endpoints)
|
||||
}
|
||||
|
||||
const createView = () => {
|
||||
view.value = {}
|
||||
openCreateViewForm.value = true
|
||||
|
||||
@@ -623,6 +623,10 @@ const markNotificationAsRead = (id) => http.put(`/api/v1/notifications/${id}/rea
|
||||
const markAllNotificationsAsRead = () => http.put('/api/v1/notifications/read-all')
|
||||
const deleteNotification = (id) => http.delete(`/api/v1/notifications/${id}`)
|
||||
const deleteAllNotifications = () => http.delete('/api/v1/notifications')
|
||||
const getNotificationPreferences = () => http.get('/api/v1/notifications/preferences')
|
||||
const updateNotificationPreferences = (data) => http.put('/api/v1/notifications/preferences', data)
|
||||
const createPushSubscription = (data) => http.post('/api/v1/notifications/push-subscriptions', data)
|
||||
const deletePushSubscription = (endpoint) => http.delete('/api/v1/notifications/push-subscriptions', { data: { endpoint } })
|
||||
|
||||
export default {
|
||||
login,
|
||||
@@ -861,5 +865,9 @@ export default {
|
||||
markAllNotificationsAsRead,
|
||||
deleteNotification,
|
||||
deleteAllNotifications,
|
||||
getNotificationPreferences,
|
||||
updateNotificationPreferences,
|
||||
createPushSubscription,
|
||||
deletePushSubscription,
|
||||
getContactPageVisits
|
||||
}
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
>
|
||||
<EditorToolbar
|
||||
:editor="editor"
|
||||
:ai-prompts="aiPrompts"
|
||||
show-ai
|
||||
@open-link="linkDialog?.open()"
|
||||
@ai-prompt="emitPrompt"
|
||||
@ai-generation-change="emit('aiGenerationChange', $event)"
|
||||
/>
|
||||
</BubbleMenu>
|
||||
<EditorContent :editor="editor" class="native-html" />
|
||||
@@ -41,7 +41,6 @@ const props = defineProps({
|
||||
insertContent: String,
|
||||
messageType: String,
|
||||
autoFocus: { type: Boolean, default: true },
|
||||
aiPrompts: { type: Array, default: () => [] },
|
||||
disabled: { type: Boolean, default: false },
|
||||
enableMentions: { type: Boolean, default: false },
|
||||
getSuggestions: { type: Function, default: null },
|
||||
@@ -53,7 +52,7 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits([
|
||||
'send',
|
||||
'aiPromptSelected',
|
||||
'aiGenerationChange',
|
||||
'mentionsChanged',
|
||||
'filesDropped',
|
||||
'toggleMessageType'
|
||||
@@ -61,8 +60,6 @@ const emit = defineEmits([
|
||||
|
||||
const linkDialog = ref(null)
|
||||
|
||||
const emitPrompt = (key) => emit('aiPromptSelected', key)
|
||||
|
||||
// Suppress the formatting bubble when an image node is selected so it
|
||||
// doesn't fight with the image's own size/remove toolbar.
|
||||
const shouldShowBubble = ({ editor: e, state }) => {
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap gap-1 items-center">
|
||||
<DropdownMenu v-if="aiPrompts.length > 0">
|
||||
<DropdownMenu v-if="showAi && aiPromptStore.prompts.length > 0">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button type="button" size="sm" variant="ghost" class="flex items-center justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="flex items-center justify-center"
|
||||
:disabled="isGenerating"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<span class="text-medium">AI</span>
|
||||
<Bot size="14" class="ml-1" />
|
||||
@@ -12,9 +18,9 @@
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
v-for="prompt in aiPrompts"
|
||||
v-for="prompt in aiPromptStore.prompts"
|
||||
:key="prompt.key"
|
||||
@select="emit('aiPrompt', prompt.key)"
|
||||
@select="complete(prompt.key)"
|
||||
>
|
||||
{{ prompt.title }}
|
||||
</DropdownMenuItem>
|
||||
@@ -331,6 +337,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
ChevronDown,
|
||||
Bold,
|
||||
@@ -363,16 +370,20 @@ import {
|
||||
DropdownMenuTrigger
|
||||
} from '@shared-ui/components/ui/dropdown-menu'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@shared-ui/components/ui/tooltip'
|
||||
import { useAiPromptStore } from '@main/stores/aiPrompt'
|
||||
import { codeBlockLanguages } from './codeLanguages'
|
||||
|
||||
const props = defineProps({
|
||||
editor: { type: Object, default: null },
|
||||
showArticleTools: { type: Boolean, default: false },
|
||||
aiPrompts: { type: Array, default: () => [] },
|
||||
showAi: { type: Boolean, default: false },
|
||||
enableInlineImages: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['openLink', 'openYoutube', 'openImage', 'aiPrompt'])
|
||||
const emit = defineEmits(['openLink', 'openYoutube', 'openImage', 'aiGenerationChange'])
|
||||
|
||||
const aiPromptStore = useAiPromptStore()
|
||||
const isGenerating = ref(false)
|
||||
|
||||
const alignments = [
|
||||
{ dir: 'left', icon: AlignLeft, label: 'editor.tooltip.alignLeft' },
|
||||
@@ -398,4 +409,18 @@ const getCurrentHeadingText = () => {
|
||||
}
|
||||
return 'P'
|
||||
}
|
||||
|
||||
const complete = async (promptKey) => {
|
||||
if (!props.editor || isGenerating.value) return
|
||||
|
||||
isGenerating.value = true
|
||||
emit('aiGenerationChange', true)
|
||||
try {
|
||||
const content = await aiPromptStore.complete(promptKey, props.editor.getHTML())
|
||||
if (content !== null) props.editor.commands.setContent(content)
|
||||
} finally {
|
||||
isGenerating.value = false
|
||||
emit('aiGenerationChange', false)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -35,7 +35,7 @@ export function createSuggestionRenderer(ListComponent) {
|
||||
if (popup) popup.style.display = 'none'
|
||||
return true
|
||||
}
|
||||
return component.ref?.onKeyDown(props)
|
||||
return component?.ref?.onKeyDown(props) ?? false
|
||||
},
|
||||
|
||||
onExit: () => {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createSuggestionRenderer } from './suggestionRenderer'
|
||||
|
||||
vi.mock('@tiptap/vue-3', () => ({ VueRenderer: vi.fn() }))
|
||||
|
||||
describe('createSuggestionRenderer', () => {
|
||||
it('ignores key events before the suggestion list starts', () => {
|
||||
const renderer = createSuggestionRenderer({})
|
||||
|
||||
expect(renderer.onKeyDown({ event: { key: 'Enter' } })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -62,9 +62,9 @@
|
||||
<div class="flex gap-2.5">
|
||||
<!-- Icon based on notification type -->
|
||||
<component
|
||||
:is="getNotificationIcon(notification.notification_type)"
|
||||
:is="getNotificationStyle(notification.notification_type).icon"
|
||||
class="flex-shrink-0 h-4 w-4 mt-0.5"
|
||||
:class="getNotificationIconClass(notification.notification_type)"
|
||||
:class="getNotificationStyle(notification.notification_type).class"
|
||||
/>
|
||||
|
||||
<!-- Content -->
|
||||
@@ -119,6 +119,17 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end border-t border-border p-1">
|
||||
<router-link
|
||||
:to="{ name: 'account-notifications' }"
|
||||
:class="buttonVariants({ variant: 'ghost', size: 'sm' })"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<Settings aria-hidden="true" />
|
||||
<span>{{ t('notification.settings') }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -132,12 +143,15 @@ import {
|
||||
CheckCheck,
|
||||
X,
|
||||
Trash2,
|
||||
Settings,
|
||||
AtSign,
|
||||
UserPlus,
|
||||
MessageSquare,
|
||||
RotateCcw,
|
||||
AlertTriangle,
|
||||
AlertCircle
|
||||
} from 'lucide-vue-next'
|
||||
import { Button } from '@shared-ui/components/ui/button'
|
||||
import { Button, buttonVariants } from '@shared-ui/components/ui/button'
|
||||
import { Skeleton } from '@shared-ui/components/ui/skeleton'
|
||||
import { useNotificationStore } from '@main/stores/notification'
|
||||
import { getRelativeTime } from '@shared-ui/utils/datetime.js'
|
||||
@@ -149,24 +163,22 @@ const { t } = useI18n()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
|
||||
const getNotificationIcon = (type) => {
|
||||
const icons = {
|
||||
mention: AtSign,
|
||||
assignment: UserPlus,
|
||||
sla_warning: AlertTriangle,
|
||||
sla_breach: AlertCircle
|
||||
}
|
||||
return icons[type] || Bell
|
||||
const notificationStyles = {
|
||||
mention: { icon: AtSign, class: 'text-primary' },
|
||||
assignment: { icon: UserPlus, class: 'text-accent-foreground' },
|
||||
new_reply: { icon: MessageSquare, class: 'text-primary' },
|
||||
new_reply_participating: { icon: MessageSquare, class: 'text-primary' },
|
||||
conversation_reopened: { icon: RotateCcw, class: 'text-accent-foreground' }
|
||||
}
|
||||
|
||||
const getNotificationIconClass = (type) => {
|
||||
const classes = {
|
||||
mention: 'text-primary',
|
||||
assignment: 'text-accent-foreground',
|
||||
sla_warning: 'text-destructive',
|
||||
sla_breach: 'text-destructive'
|
||||
const getNotificationStyle = (type) => {
|
||||
if (type?.startsWith('sla_')) {
|
||||
return {
|
||||
icon: type.endsWith('_breach') ? AlertCircle : AlertTriangle,
|
||||
class: 'text-destructive'
|
||||
}
|
||||
}
|
||||
return classes[type] || 'text-muted-foreground'
|
||||
return notificationStyles[type] || { icon: Bell, class: 'text-muted-foreground' }
|
||||
}
|
||||
|
||||
const handleNotificationClick = async (notification) => {
|
||||
|
||||
@@ -130,6 +130,7 @@ import { useUserStore } from '@main/stores/user'
|
||||
import { useIsMobile } from '@shared-ui/composables'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useKeyboardShortcutsDialog } from '@main/composables/useKeyboardShortcutsDialog'
|
||||
import { useLogout } from '@main/composables/useLogout'
|
||||
|
||||
import { useColorMode } from '@vueuse/core'
|
||||
|
||||
@@ -139,8 +140,5 @@ const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const shortcutsDialog = useKeyboardShortcutsDialog()
|
||||
|
||||
const logout = () => {
|
||||
window.location.href = '/logout'
|
||||
}
|
||||
const logout = useLogout()
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { usePushNotifications } from '@main/composables/usePushNotifications'
|
||||
|
||||
const defaultBrowser = typeof window === 'undefined' ? {} : window
|
||||
|
||||
export const createLogout = (pushNotifications, browser = defaultBrowser) => async () => {
|
||||
await Promise.resolve(pushNotifications.disable()).catch(() => {})
|
||||
browser.location.href = '/logout'
|
||||
}
|
||||
|
||||
export const useLogout = () => createLogout(usePushNotifications())
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createLogout } from './useLogout'
|
||||
|
||||
describe('createLogout', () => {
|
||||
it('clears the push association before logging out', async () => {
|
||||
const pushNotifications = { disable: vi.fn().mockResolvedValue() }
|
||||
const browser = { location: { href: '' } }
|
||||
|
||||
await createLogout(pushNotifications, browser)()
|
||||
|
||||
expect(pushNotifications.disable).toHaveBeenCalled()
|
||||
expect(browser.location.href).toBe('/logout')
|
||||
})
|
||||
|
||||
it('logs out when clearing the push association fails', async () => {
|
||||
const pushNotifications = { disable: vi.fn().mockRejectedValue(new Error('offline')) }
|
||||
const browser = { location: { href: '' } }
|
||||
|
||||
await createLogout(pushNotifications, browser)()
|
||||
|
||||
expect(browser.location.href).toBe('/logout')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { ref } from 'vue'
|
||||
import api from '@main/api'
|
||||
|
||||
const defaultBrowser = typeof window === 'undefined' ? {} : window
|
||||
|
||||
export const urlBase64ToUint8Array = (value) => {
|
||||
const padding = '='.repeat((4 - value.length % 4) % 4)
|
||||
const raw = atob((value + padding).replace(/-/g, '+').replace(/_/g, '/'))
|
||||
return Uint8Array.from(raw, character => character.charCodeAt(0))
|
||||
}
|
||||
|
||||
export const createPushNotifications = (apiClient = api, browser = defaultBrowser) => {
|
||||
const supported = ref(Boolean(browser.Notification && browser.navigator?.serviceWorker && browser.PushManager))
|
||||
const permission = ref(browser.Notification?.permission || 'unsupported')
|
||||
const enabled = ref(false)
|
||||
let registration = null
|
||||
|
||||
const registerWorker = async () => {
|
||||
registration ??= await browser.navigator.serviceWorker.register('/sw.js')
|
||||
return registration
|
||||
}
|
||||
|
||||
const existingWorker = async () => {
|
||||
registration ??= await browser.navigator.serviceWorker.getRegistration('/sw.js')
|
||||
return registration
|
||||
}
|
||||
|
||||
const save = async (subscription) => {
|
||||
const value = subscription.toJSON()
|
||||
await apiClient.createPushSubscription({
|
||||
endpoint: value.endpoint,
|
||||
p256dh: value.keys.p256dh,
|
||||
auth: value.keys.auth
|
||||
})
|
||||
}
|
||||
|
||||
const refresh = async (vapidPublicKey, savedEndpoints = []) => {
|
||||
if (!supported.value || !vapidPublicKey) return
|
||||
const worker = await registerWorker()
|
||||
const subscription = await worker.pushManager.getSubscription()
|
||||
enabled.value = Boolean(subscription) && savedEndpoints.includes(subscription.endpoint)
|
||||
if (enabled.value) await save(subscription)
|
||||
}
|
||||
|
||||
const enable = async (vapidPublicKey) => {
|
||||
if (!supported.value || !vapidPublicKey) return false
|
||||
permission.value = await browser.Notification.requestPermission()
|
||||
if (permission.value !== 'granted') return false
|
||||
await registerWorker()
|
||||
const worker = await browser.navigator.serviceWorker.ready
|
||||
const existing = await worker.pushManager.getSubscription()
|
||||
const subscription = existing || await worker.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
|
||||
})
|
||||
await save(subscription)
|
||||
enabled.value = true
|
||||
return true
|
||||
}
|
||||
|
||||
const disable = async () => {
|
||||
if (!supported.value) return
|
||||
const worker = await existingWorker()
|
||||
const subscription = await worker?.pushManager.getSubscription()
|
||||
if (!subscription) {
|
||||
enabled.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
await apiClient.deletePushSubscription(subscription.endpoint)
|
||||
} finally {
|
||||
await subscription.unsubscribe()
|
||||
enabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { supported, permission, enabled, refresh, enable, disable }
|
||||
}
|
||||
|
||||
const pushNotifications = createPushNotifications()
|
||||
|
||||
export const usePushNotifications = () => pushNotifications
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createPushNotifications } from './usePushNotifications'
|
||||
|
||||
const makeBrowser = ({ permission = 'default', subscription = null } = {}) => {
|
||||
const pushManager = {
|
||||
getSubscription: vi.fn().mockResolvedValue(subscription),
|
||||
subscribe: vi.fn()
|
||||
}
|
||||
const registration = { pushManager }
|
||||
const browser = {
|
||||
Notification: {
|
||||
permission,
|
||||
requestPermission: vi.fn().mockResolvedValue(permission)
|
||||
},
|
||||
navigator: {
|
||||
serviceWorker: {
|
||||
register: vi.fn().mockResolvedValue(registration),
|
||||
getRegistration: vi.fn().mockResolvedValue(registration),
|
||||
ready: Promise.resolve(registration)
|
||||
}
|
||||
},
|
||||
PushManager: function PushManager () {}
|
||||
}
|
||||
return { browser, registration, pushManager }
|
||||
}
|
||||
|
||||
const subscription = {
|
||||
endpoint: 'https://push.example/subscription',
|
||||
toJSON: () => ({
|
||||
endpoint: 'https://push.example/subscription',
|
||||
keys: { p256dh: 'public-key', auth: 'auth-secret' }
|
||||
}),
|
||||
unsubscribe: vi.fn().mockResolvedValue(true)
|
||||
}
|
||||
|
||||
describe('createPushNotifications', () => {
|
||||
it('reports unsupported browsers without registering a worker', async () => {
|
||||
const api = { createPushSubscription: vi.fn() }
|
||||
const state = createPushNotifications(api, {})
|
||||
|
||||
await state.refresh('vapid-key')
|
||||
|
||||
expect(state.supported.value).toBe(false)
|
||||
expect(api.createPushSubscription).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refreshes an existing subscription without requesting permission', async () => {
|
||||
const { browser } = makeBrowser({ permission: 'granted', subscription })
|
||||
const api = { createPushSubscription: vi.fn().mockResolvedValue({}) }
|
||||
const state = createPushNotifications(api, browser)
|
||||
|
||||
await state.refresh('vapid-key', [subscription.endpoint])
|
||||
|
||||
expect(browser.Notification.requestPermission).not.toHaveBeenCalled()
|
||||
expect(api.createPushSubscription).toHaveBeenCalledWith({
|
||||
endpoint: subscription.endpoint,
|
||||
p256dh: 'public-key',
|
||||
auth: 'auth-secret'
|
||||
})
|
||||
expect(state.enabled.value).toBe(true)
|
||||
})
|
||||
|
||||
it('does not claim a browser subscription the server does not hold for this user', async () => {
|
||||
const { browser } = makeBrowser({ permission: 'granted', subscription })
|
||||
const api = { createPushSubscription: vi.fn().mockResolvedValue({}) }
|
||||
const state = createPushNotifications(api, browser)
|
||||
|
||||
await state.refresh('vapid-key', ['https://push.example/someone-else'])
|
||||
|
||||
expect(api.createPushSubscription).not.toHaveBeenCalled()
|
||||
expect(state.enabled.value).toBe(false)
|
||||
})
|
||||
|
||||
it('subscribes only after permission is granted', async () => {
|
||||
const { browser, pushManager } = makeBrowser({ permission: 'default' })
|
||||
browser.Notification.requestPermission.mockResolvedValue('granted')
|
||||
pushManager.subscribe.mockResolvedValue(subscription)
|
||||
const api = { createPushSubscription: vi.fn().mockResolvedValue({}) }
|
||||
const state = createPushNotifications(api, browser)
|
||||
|
||||
await state.enable('AQID')
|
||||
|
||||
expect(pushManager.subscribe).toHaveBeenCalledWith({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: new Uint8Array([1, 2, 3])
|
||||
})
|
||||
expect(state.enabled.value).toBe(true)
|
||||
})
|
||||
|
||||
it('does not subscribe when permission is denied', async () => {
|
||||
const { browser, pushManager } = makeBrowser({ permission: 'default' })
|
||||
browser.Notification.requestPermission.mockResolvedValue('denied')
|
||||
const state = createPushNotifications({}, browser)
|
||||
|
||||
await state.enable('AQID')
|
||||
|
||||
expect(pushManager.subscribe).not.toHaveBeenCalled()
|
||||
expect(state.permission.value).toBe('denied')
|
||||
})
|
||||
|
||||
it('waits for the first worker to become active before subscribing', async () => {
|
||||
const { browser, registration, pushManager } = makeBrowser({ permission: 'granted' })
|
||||
let resolveReady
|
||||
browser.navigator.serviceWorker.ready = new Promise(resolve => {
|
||||
resolveReady = resolve
|
||||
})
|
||||
pushManager.subscribe.mockResolvedValue(subscription)
|
||||
const api = { createPushSubscription: vi.fn().mockResolvedValue({}) }
|
||||
const state = createPushNotifications(api, browser)
|
||||
|
||||
const enabling = state.enable('AQID')
|
||||
await vi.waitFor(() => expect(browser.navigator.serviceWorker.register).toHaveBeenCalledOnce())
|
||||
|
||||
expect(pushManager.subscribe).not.toHaveBeenCalled()
|
||||
expect(api.createPushSubscription).not.toHaveBeenCalled()
|
||||
expect(state.enabled.value).toBe(false)
|
||||
|
||||
resolveReady(registration)
|
||||
await expect(enabling).resolves.toBe(true)
|
||||
expect(pushManager.subscribe).toHaveBeenCalledOnce()
|
||||
expect(api.createPushSubscription).toHaveBeenCalledOnce()
|
||||
expect(state.enabled.value).toBe(true)
|
||||
})
|
||||
|
||||
it('unsubscribes the current browser and deletes its endpoint', async () => {
|
||||
const { browser } = makeBrowser({ permission: 'granted', subscription })
|
||||
const api = {
|
||||
createPushSubscription: vi.fn().mockResolvedValue({}),
|
||||
deletePushSubscription: vi.fn().mockResolvedValue({})
|
||||
}
|
||||
const state = createPushNotifications(api, browser)
|
||||
await state.refresh('vapid-key', [subscription.endpoint])
|
||||
|
||||
await state.disable()
|
||||
|
||||
expect(subscription.unsubscribe).toHaveBeenCalled()
|
||||
expect(api.deletePushSubscription).toHaveBeenCalledWith(subscription.endpoint)
|
||||
expect(state.enabled.value).toBe(false)
|
||||
})
|
||||
|
||||
it('unsubscribes the browser when clearing the server association fails', async () => {
|
||||
subscription.unsubscribe.mockClear()
|
||||
const { browser } = makeBrowser({ permission: 'granted', subscription })
|
||||
const api = { deletePushSubscription: vi.fn().mockRejectedValue(new Error('offline')) }
|
||||
const state = createPushNotifications(api, browser)
|
||||
|
||||
await expect(state.disable()).rejects.toThrow('offline')
|
||||
|
||||
expect(subscription.unsubscribe).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BarChart3,
|
||||
Bell,
|
||||
Bot,
|
||||
BookOpen,
|
||||
CircleDot,
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
// Icon names referenced by string in navigation.js.
|
||||
export const navIconMap = {
|
||||
BarChart3,
|
||||
Bell,
|
||||
Bot,
|
||||
BookOpen,
|
||||
CircleDot,
|
||||
|
||||
@@ -265,6 +265,12 @@ export const accountNavItems = [
|
||||
titleKey: 'globals.terms.profile',
|
||||
href: '/account/profile',
|
||||
icon: 'CircleUser'
|
||||
},
|
||||
{
|
||||
titleKey: 'globals.terms.notification',
|
||||
href: '/account/notifications',
|
||||
isTitleKeyPlural: true,
|
||||
icon: 'Bell'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -54,7 +54,18 @@
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
|
||||
<div class="flex gap-2">
|
||||
<Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
|
||||
<Button
|
||||
v-if="initialValues?.is_builtin && defaultBody"
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="isLoading"
|
||||
@click="resetToDefault"
|
||||
>
|
||||
{{ $t('globals.messages.resetToDefault') }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
@@ -78,6 +89,7 @@ import CodeEditor from '@main/components/editor/CodeEditor.vue'
|
||||
import { Checkbox } from '@shared-ui/components/ui/checkbox/index.js'
|
||||
import { Label } from '@shared-ui/components/ui/label/index.js'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { BUILT_IN_TEMPLATE_BODIES } from './defaults.js'
|
||||
|
||||
const props = defineProps({
|
||||
initialValues: {
|
||||
@@ -121,6 +133,14 @@ const hideSubject = computed(() => {
|
||||
return isOutgoingTemplate.value || props.initialValues?.name === 'CSAT request'
|
||||
})
|
||||
|
||||
const defaultBody = computed(() => {
|
||||
return BUILT_IN_TEMPLATE_BODIES[props.initialValues?.name]
|
||||
})
|
||||
|
||||
const resetToDefault = () => {
|
||||
form.setFieldValue('body', defaultBody.value, false)
|
||||
}
|
||||
|
||||
// Watch for changes in initialValues and update the form.
|
||||
watch(
|
||||
() => props.initialValues,
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
export const BUILT_IN_TEMPLATE_BODIES = {
|
||||
'Conversation assigned': `<p>A new conversation has been assigned to you:</p>
|
||||
|
||||
<div>
|
||||
Reference number: {{ .Conversation.ReferenceNumber }} <br>
|
||||
Subject: {{ .Conversation.Subject }}
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
|
||||
</p>
|
||||
|
||||
<div>
|
||||
Best regards,<br>
|
||||
Libredesk
|
||||
</div>`,
|
||||
'New reply from contact': `<p>{{ .Author.FullName }} replied to a conversation assigned to you:</p>
|
||||
|
||||
<div>
|
||||
Reference number: {{ .Conversation.ReferenceNumber }} <br>
|
||||
Subject: {{ .Conversation.Subject }}
|
||||
</div>
|
||||
|
||||
<blockquote style="background-color: #f5f5f5; padding: 12px; margin: 16px 0; border-left: 4px solid #ddd;">
|
||||
{{ .Message.Content }}
|
||||
</blockquote>
|
||||
|
||||
<p>
|
||||
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
|
||||
</p>
|
||||
|
||||
<div>
|
||||
Best regards,<br>
|
||||
Libredesk
|
||||
</div>`,
|
||||
'New reply on participating conversation': `<p>{{ .Author.FullName }} replied to a conversation you are participating in:</p>
|
||||
|
||||
<div>
|
||||
Reference number: {{ .Conversation.ReferenceNumber }} <br>
|
||||
Subject: {{ .Conversation.Subject }}
|
||||
</div>
|
||||
|
||||
<blockquote style="background-color: #f5f5f5; padding: 12px; margin: 16px 0; border-left: 4px solid #ddd;">
|
||||
{{ .Message.Content }}
|
||||
</blockquote>
|
||||
|
||||
<p>
|
||||
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
|
||||
</p>
|
||||
|
||||
<div>
|
||||
Best regards,<br>
|
||||
Libredesk
|
||||
</div>`,
|
||||
'Conversation reopened': `<p>{{ .Author.FullName }} replied and reopened a conversation assigned to you:</p>
|
||||
|
||||
<div>
|
||||
Reference number: {{ .Conversation.ReferenceNumber }} <br>
|
||||
Subject: {{ .Conversation.Subject }}
|
||||
</div>
|
||||
|
||||
<blockquote style="background-color: #f5f5f5; padding: 12px; margin: 16px 0; border-left: 4px solid #ddd;">
|
||||
{{ .Message.Content }}
|
||||
</blockquote>
|
||||
|
||||
<p>
|
||||
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
|
||||
</p>
|
||||
|
||||
<div>
|
||||
Best regards,<br>
|
||||
Libredesk
|
||||
</div>`,
|
||||
'SLA breach warning': `<p>This is a notification that the SLA for conversation {{ .Conversation.ReferenceNumber }} is approaching the SLA deadline for {{ .SLA.Metric }}.</p>
|
||||
|
||||
<p>
|
||||
Details:<br>
|
||||
- Conversation reference number: {{ .Conversation.ReferenceNumber }}<br>
|
||||
- Metric: {{ .SLA.Metric }}<br>
|
||||
- Due in: {{ .SLA.DueIn }}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Best regards,<br>
|
||||
Libredesk
|
||||
</p>`,
|
||||
'SLA breached': `<p>This is an urgent alert that the SLA for conversation {{ .Conversation.ReferenceNumber }} has been breached for {{ .SLA.Metric }}. Please take immediate action.</p>
|
||||
|
||||
<p>
|
||||
Details:<br>
|
||||
- Conversation reference number: {{ .Conversation.ReferenceNumber }}<br>
|
||||
- Metric: {{ .SLA.Metric }}<br>
|
||||
- Overdue by: {{ .SLA.OverdueBy }}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Best regards,<br>
|
||||
Libredesk
|
||||
</p>`,
|
||||
'Mentioned in conversation': `<p>{{ .MentionedBy.FullName }} mentioned you in a private note on conversation #{{ .Conversation.ReferenceNumber }}.</p>
|
||||
|
||||
<blockquote style="background-color: #f5f5f5; padding: 12px; margin: 16px 0; border-left: 4px solid #ddd;">
|
||||
{{ .Message.Content }}
|
||||
</blockquote>
|
||||
|
||||
<p>
|
||||
<a href="{{ RootURL }}/inboxes/mentioned/conversation/{{ .Conversation.UUID }}?scrollTo={{ .Message.UUID }}">View Conversation</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Best regards,<br>
|
||||
libredesk
|
||||
</p>`,
|
||||
'CSAT request': `<p style="margin: 0 0 4px; font-size: 15px; color: #374151; text-align: center; line-height: 1.5;">
|
||||
Your conversation <strong style="color: #111827;">#{{ .Conversation.ReferenceNumber }}</strong> has been resolved.
|
||||
</p>
|
||||
<p style="margin: 0 0 28px; font-size: 13px; color: #9ca3af; text-align: center;">
|
||||
We would love to hear how it went.
|
||||
</p>
|
||||
<p style="margin: 0 0 20px; font-size: 14px; font-weight: 600; color: #374151; text-align: center;">
|
||||
How would you rate your experience?
|
||||
</p>
|
||||
<!-- Variable CSATUUID is also available -->
|
||||
<div style="text-align: center; margin: 0 auto; max-width: 400px; font-size: 0;">
|
||||
<div style="display: inline-block; width: 72px; text-align: center; vertical-align: top; padding: 4px 0;">
|
||||
<a href="{{ .CSATLink }}?rating=1" style="text-decoration: none; display: block;">
|
||||
<span style="font-size: 34px; display: block; line-height: 1.4;">😢</span>
|
||||
<span style="font-size: 10px; display: block; font-weight: 600; color: #b0b5bd; text-transform: uppercase; letter-spacing: 0.05em; margin-top: 4px;">Poor</span>
|
||||
</a>
|
||||
</div>
|
||||
<div style="display: inline-block; width: 72px; text-align: center; vertical-align: top; padding: 4px 0;">
|
||||
<a href="{{ .CSATLink }}?rating=2" style="text-decoration: none; display: block;">
|
||||
<span style="font-size: 34px; display: block; line-height: 1.4;">😕</span>
|
||||
<span style="font-size: 10px; display: block; font-weight: 600; color: #b0b5bd; text-transform: uppercase; letter-spacing: 0.05em; margin-top: 4px;">Fair</span>
|
||||
</a>
|
||||
</div>
|
||||
<div style="display: inline-block; width: 72px; text-align: center; vertical-align: top; padding: 4px 0;">
|
||||
<a href="{{ .CSATLink }}?rating=3" style="text-decoration: none; display: block;">
|
||||
<span style="font-size: 34px; display: block; line-height: 1.4;">😊</span>
|
||||
<span style="font-size: 10px; display: block; font-weight: 600; color: #b0b5bd; text-transform: uppercase; letter-spacing: 0.05em; margin-top: 4px;">Good</span>
|
||||
</a>
|
||||
</div>
|
||||
<div style="display: inline-block; width: 72px; text-align: center; vertical-align: top; padding: 4px 0;">
|
||||
<a href="{{ .CSATLink }}?rating=4" style="text-decoration: none; display: block;">
|
||||
<span style="font-size: 34px; display: block; line-height: 1.4;">😃</span>
|
||||
<span style="font-size: 10px; display: block; font-weight: 600; color: #b0b5bd; text-transform: uppercase; letter-spacing: 0.05em; margin-top: 4px;">Great</span>
|
||||
</a>
|
||||
</div>
|
||||
<div style="display: inline-block; width: 72px; text-align: center; vertical-align: top; padding: 4px 0;">
|
||||
<a href="{{ .CSATLink }}?rating=5" style="text-decoration: none; display: block;">
|
||||
<span style="font-size: 34px; display: block; line-height: 1.4;">🤩</span>
|
||||
<span style="font-size: 10px; display: block; font-weight: 600; color: #b0b5bd; text-transform: uppercase; letter-spacing: 0.05em; margin-top: 4px;">Excellent</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
} from 'lucide-vue-next'
|
||||
import { useUserStore } from '@main/stores/user'
|
||||
import { useKeyboardShortcutsDialog } from '@main/composables/useKeyboardShortcutsDialog'
|
||||
import { SECTIONS } from '../sections'
|
||||
import { useLogout } from '@main/composables/useLogout'
|
||||
import { SECTIONS } from '@main/features/command/sections'
|
||||
|
||||
const AWAY_STATUSES = ['away_manual', 'away_and_reassigning']
|
||||
|
||||
@@ -21,6 +22,7 @@ export function useAccountCommands() {
|
||||
const mode = useColorMode()
|
||||
const userStore = useUserStore()
|
||||
const shortcutsDialog = useKeyboardShortcutsDialog()
|
||||
const logout = useLogout()
|
||||
|
||||
return computed(() => {
|
||||
const status = userStore.user.availability_status
|
||||
@@ -70,9 +72,7 @@ export function useAccountCommands() {
|
||||
keywords: ['sign out'],
|
||||
section: SECTIONS.ACCOUNT,
|
||||
icon: LogOut,
|
||||
run: () => {
|
||||
window.location.href = '/logout'
|
||||
}
|
||||
run: logout
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
@@ -49,10 +49,9 @@
|
||||
<Button
|
||||
v-if="userStore.can('contacts:write')"
|
||||
size="sm"
|
||||
class="flex items-center h-8 gap-1 ml-auto"
|
||||
class="flex items-center h-8 ml-auto"
|
||||
@click="showCreateDialog = true"
|
||||
>
|
||||
<UserPlus size="16" />
|
||||
{{ $t('contact.new') }}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -140,7 +139,7 @@ import {
|
||||
} from '@shared-ui/components/ui/select'
|
||||
import { Input } from '@shared-ui/components/ui/input'
|
||||
import { Button } from '@shared-ui/components/ui/button'
|
||||
import { ArrowDownWideNarrow, IdCardIcon, UserPlus } from 'lucide-vue-next'
|
||||
import { ArrowDownWideNarrow, IdCardIcon } from 'lucide-vue-next'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@shared-ui/components/ui/popover'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { EMITTER_EVENTS } from '@main/constants/emitterEvents.js'
|
||||
|
||||
@@ -55,7 +55,6 @@
|
||||
v-if="isEditorFullscreen"
|
||||
ref="fullscreenContentRef"
|
||||
:isFullscreen="true"
|
||||
:aiPrompts="aiPrompts"
|
||||
:isSending="isSending"
|
||||
:isDraftLoading="isDraftLoading"
|
||||
:uploadingFiles="uploadingFiles"
|
||||
@@ -75,7 +74,7 @@
|
||||
@fileUpload="handleFileUpload"
|
||||
@fileDelete="handleFileDelete"
|
||||
@filesDropped="uploadFiles"
|
||||
@aiPromptSelected="handleAiPromptSelected"
|
||||
@aiGenerationChange="isGenerating = $event"
|
||||
:isGenerating="isGenerating"
|
||||
:canSendReply="canSendReply"
|
||||
:canSendPrivateNote="canSendPrivateNote"
|
||||
@@ -117,7 +116,6 @@
|
||||
<ReplyBoxContent
|
||||
ref="replyBoxContentRef"
|
||||
:isFullscreen="false"
|
||||
:aiPrompts="aiPrompts"
|
||||
:isSending="isSending"
|
||||
:isDraftLoading="isDraftLoading"
|
||||
:uploadingFiles="uploadingFiles"
|
||||
@@ -137,7 +135,7 @@
|
||||
@fileUpload="handleFileUpload"
|
||||
@fileDelete="handleFileDelete"
|
||||
@filesDropped="uploadFiles"
|
||||
@aiPromptSelected="handleAiPromptSelected"
|
||||
@aiGenerationChange="isGenerating = $event"
|
||||
:isGenerating="isGenerating"
|
||||
:canSendReply="canSendReply"
|
||||
:canSendPrivateNote="canSendPrivateNote"
|
||||
@@ -158,7 +156,6 @@ import api from '@main/api'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useConversationStore } from '@main/stores/conversation'
|
||||
import { useInboxStore } from '@main/stores/inbox'
|
||||
import { useAiPromptStore } from '@main/stores/aiPrompt'
|
||||
import { useNotificationStore } from '@main/stores/notification'
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -254,8 +251,6 @@ const cc = ref('')
|
||||
const bcc = ref('')
|
||||
const showBcc = ref(false)
|
||||
const emailErrors = ref([])
|
||||
const aiPromptStore = useAiPromptStore()
|
||||
const aiPrompts = computed(() => aiPromptStore.prompts)
|
||||
const replyBoxContentRef = ref(null)
|
||||
const fullscreenContentRef = ref(null)
|
||||
const activeContentRef = () =>
|
||||
@@ -265,8 +260,6 @@ const showMissingTagsWarning = ref(false)
|
||||
const deferredStatus = ref(null)
|
||||
const mentions = ref([])
|
||||
|
||||
aiPromptStore.fetchPrompts()
|
||||
|
||||
const runAiGeneration = async (requestFn) => {
|
||||
if (isGenerating.value) return
|
||||
const uuid = currentConversationUUID.value
|
||||
@@ -286,9 +279,6 @@ const runAiGeneration = async (requestFn) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAiPromptSelected = (key) =>
|
||||
runAiGeneration(() => api.aiCompletion({ prompt_key: key, content: htmlContent.value }))
|
||||
|
||||
const handleGenerateReply = () =>
|
||||
runAiGeneration((uuid) =>
|
||||
api.aiGenerateReply({ conversation_uuid: uuid, instruction: textContent.value })
|
||||
|
||||
@@ -7,18 +7,18 @@
|
||||
:class="{ 'mb-4': !isFullscreen, 'border-b border-border pb-4': isFullscreen }"
|
||||
>
|
||||
<Tabs v-model="messageType" class="rounded-lg">
|
||||
<TabsList class="rounded-lg border bg-muted/50 p-0.5">
|
||||
<TabsList>
|
||||
<TabsTrigger
|
||||
v-if="canSendReply"
|
||||
value="reply"
|
||||
:class="TAB_TRIGGER_CLASS"
|
||||
class="max-md:py-2.5"
|
||||
>
|
||||
{{ $t('globals.terms.reply') }}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
v-if="canSendPrivateNote"
|
||||
value="private_note"
|
||||
:class="TAB_TRIGGER_CLASS"
|
||||
class="max-md:py-2.5"
|
||||
>
|
||||
{{ $t('globals.terms.privateNote') }}
|
||||
</TabsTrigger>
|
||||
@@ -91,7 +91,6 @@
|
||||
v-model:textContent="textContent"
|
||||
:message-type="messageType"
|
||||
:placeholder="isCramped ? t('globals.terms.typeMessage') : t('editor.hint.full')"
|
||||
:aiPrompts="aiPrompts"
|
||||
:insertContent="insertContent"
|
||||
:autoFocus="true"
|
||||
:disabled="isDraftLoading"
|
||||
@@ -100,7 +99,7 @@
|
||||
:enableInlineImages="conversationStore.current.inbox_channel === 'email'"
|
||||
:getSuggestions="getSuggestions"
|
||||
:getConversationSuggestions="getConversationSuggestions"
|
||||
@aiPromptSelected="handleAiPromptSelected"
|
||||
@aiGenerationChange="emit('aiGenerationChange', $event)"
|
||||
@send="handleSend"
|
||||
@mentionsChanged="handleMentionsChanged"
|
||||
@filesDropped="handleFilesDropped"
|
||||
@@ -146,9 +145,6 @@
|
||||
const RECIPIENT_INPUT_CLASS =
|
||||
'flex-grow border-input bg-card px-3 py-2 text-sm shadow-none focus-visible:ring-1 focus-visible:ring-ring'
|
||||
|
||||
const TAB_TRIGGER_CLASS =
|
||||
'rounded-md px-3 py-1 text-sm transition-colors duration-150 max-md:py-2.5 data-[state=active]:bg-card data-[state=active]:shadow-sm'
|
||||
|
||||
import { ref, computed, nextTick, watch } from 'vue'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { EMITTER_EVENTS } from '@main/constants/emitterEvents.js'
|
||||
@@ -248,10 +244,6 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
aiPrompts: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
isSending: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
@@ -292,7 +284,7 @@ const emit = defineEmits([
|
||||
'inlineImageUpload',
|
||||
'fileDelete',
|
||||
'filesDropped',
|
||||
'aiPromptSelected',
|
||||
'aiGenerationChange',
|
||||
'generateReply'
|
||||
])
|
||||
|
||||
@@ -400,10 +392,6 @@ const handleEmojiSelect = (emoji) => {
|
||||
nextTick(() => (insertContent.value = emoji))
|
||||
}
|
||||
|
||||
const handleAiPromptSelected = (key) => {
|
||||
emit('aiPromptSelected', key)
|
||||
}
|
||||
|
||||
// Watch and update macro view based on message type this filters our macros.
|
||||
watch(
|
||||
messageType,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,34 +1,61 @@
|
||||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { handleHTTPError } from '@shared-ui/utils/http.js'
|
||||
import { useEmitter } from '../composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '../constants/emitterEvents'
|
||||
import api from '../api'
|
||||
import { useEmitter } from '@main/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@main/constants/emitterEvents'
|
||||
import api from '@main/api'
|
||||
|
||||
export const useAiPromptStore = defineStore('aiPrompt', () => {
|
||||
const prompts = ref([])
|
||||
const emitter = useEmitter()
|
||||
let inflight = null
|
||||
let hasFetched = false
|
||||
const fetchPrompts = () => {
|
||||
if (hasFetched) return Promise.resolve()
|
||||
if (inflight) return inflight
|
||||
inflight = api.getAiPrompts()
|
||||
.then(response => {
|
||||
prompts.value = response?.data?.data || []
|
||||
hasFetched = true
|
||||
})
|
||||
.catch(error => {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
})
|
||||
.finally(() => { inflight = null })
|
||||
return inflight
|
||||
}
|
||||
return {
|
||||
prompts,
|
||||
fetchPrompts,
|
||||
const prompts = ref([])
|
||||
const emitter = useEmitter()
|
||||
let inflight = null
|
||||
let hasFetched = false
|
||||
|
||||
const showError = (error) => {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
}
|
||||
|
||||
const fetchPrompts = () => {
|
||||
if (hasFetched) return Promise.resolve()
|
||||
if (inflight) return inflight
|
||||
|
||||
inflight = api
|
||||
.getAiPrompts()
|
||||
.then((response) => {
|
||||
prompts.value = response?.data?.data || []
|
||||
hasFetched = true
|
||||
})
|
||||
.catch(showError)
|
||||
.finally(() => {
|
||||
inflight = null
|
||||
})
|
||||
|
||||
return inflight
|
||||
}
|
||||
|
||||
const refreshPrompts = async () => {
|
||||
if (inflight) await inflight
|
||||
hasFetched = false
|
||||
return fetchPrompts()
|
||||
}
|
||||
|
||||
const complete = async (promptKey, content) => {
|
||||
try {
|
||||
const response = await api.aiCompletion({ prompt_key: promptKey, content })
|
||||
return response?.data?.data ?? ''
|
||||
} catch (error) {
|
||||
showError(error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
prompts,
|
||||
fetchPrompts,
|
||||
refreshPrompts,
|
||||
complete
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, h, nextTick, ref } from 'vue'
|
||||
|
||||
const { getPreferences, savePreferences, refresh, enable, disable, emit } = vi.hoisted(() => ({
|
||||
getPreferences: vi.fn(),
|
||||
savePreferences: vi.fn().mockResolvedValue({}),
|
||||
refresh: vi.fn(),
|
||||
enable: vi.fn().mockResolvedValue(true),
|
||||
disable: vi.fn().mockResolvedValue(),
|
||||
emit: vi.fn()
|
||||
}))
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
getNotificationPreferences: getPreferences,
|
||||
updateNotificationPreferences: savePreferences
|
||||
}
|
||||
}))
|
||||
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key) => key }) }))
|
||||
vi.mock('@/composables/useEmitter', () => ({ useEmitter: () => ({ emit }) }))
|
||||
vi.mock('@shared-ui/utils/http.js', () => ({ handleHTTPError: (error) => error }))
|
||||
vi.mock('@/composables/usePushNotifications', () => ({
|
||||
usePushNotifications: () => ({
|
||||
supported: ref(true),
|
||||
enabled: ref(false),
|
||||
permission: ref('granted'),
|
||||
refresh,
|
||||
enable,
|
||||
disable
|
||||
})
|
||||
}))
|
||||
vi.mock('@shared-ui/components/ui/switch', () => ({
|
||||
Switch: {
|
||||
props: ['checked', 'disabled'],
|
||||
emits: ['update:checked'],
|
||||
setup:
|
||||
(props, { emit }) =>
|
||||
() =>
|
||||
h('button', {
|
||||
type: 'button',
|
||||
disabled: props.disabled,
|
||||
onClick: () => emit('update:checked', !props.checked)
|
||||
})
|
||||
}
|
||||
}))
|
||||
import NotificationPreferences from './NotificationPreferences.vue'
|
||||
|
||||
let app
|
||||
let root
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 10; i++) await nextTick()
|
||||
}
|
||||
afterEach(() => {
|
||||
app?.unmount()
|
||||
root?.remove()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const mount = async (vapidPublicKey = 'test-key') => {
|
||||
getPreferences.mockResolvedValue({
|
||||
data: {
|
||||
data: {
|
||||
email_enabled: true,
|
||||
vapid_public_key: vapidPublicKey,
|
||||
push_endpoints: ['https://push.example/subscription'],
|
||||
preferences: [
|
||||
{ notification_type: 'new_reply', channel: 'email', enabled: true },
|
||||
{ notification_type: 'new_reply', channel: 'in_app', enabled: false }
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
root = document.createElement('div')
|
||||
document.body.append(root)
|
||||
app = createApp(NotificationPreferences)
|
||||
app.config.globalProperties.$t = (key) => key
|
||||
app.mount(root)
|
||||
await settle()
|
||||
}
|
||||
|
||||
describe('notification preferences', () => {
|
||||
it('shows a loader until preferences are loaded', async () => {
|
||||
let finishLoad
|
||||
getPreferences.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
finishLoad = resolve
|
||||
})
|
||||
)
|
||||
root = document.createElement('div')
|
||||
document.body.append(root)
|
||||
app = createApp(NotificationPreferences)
|
||||
app.config.globalProperties.$t = (key) => key
|
||||
app.mount(root)
|
||||
await nextTick()
|
||||
|
||||
expect(root.querySelector('[role="status"]')).not.toBeNull()
|
||||
expect(root.querySelectorAll('button')).toHaveLength(0)
|
||||
|
||||
finishLoad({
|
||||
data: {
|
||||
data: {
|
||||
email_enabled: true,
|
||||
vapid_public_key: 'test-key',
|
||||
push_endpoints: [],
|
||||
preferences: []
|
||||
}
|
||||
}
|
||||
})
|
||||
await settle()
|
||||
|
||||
expect(root.querySelector('[role="status"]')).toBeNull()
|
||||
expect(root.querySelectorAll('button')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('saves a channel toggle', async () => {
|
||||
await mount()
|
||||
const email = root.querySelectorAll('button')[2]
|
||||
expect(email).toBeDefined()
|
||||
email.click()
|
||||
await settle()
|
||||
expect(savePreferences).toHaveBeenCalledWith([
|
||||
{ notification_type: 'new_reply', channel: 'email', enabled: false }
|
||||
])
|
||||
})
|
||||
|
||||
it('prevents overlapping saves for the same preference', async () => {
|
||||
let finishSave
|
||||
savePreferences.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishSave = resolve
|
||||
})
|
||||
)
|
||||
await mount()
|
||||
const switches = root.querySelectorAll('button')
|
||||
const inApp = switches[1]
|
||||
const email = switches[2]
|
||||
|
||||
email.click()
|
||||
email.click()
|
||||
await nextTick()
|
||||
|
||||
expect(savePreferences).toHaveBeenCalledTimes(1)
|
||||
expect(email.disabled).toBe(true)
|
||||
expect(inApp.disabled).toBe(false)
|
||||
|
||||
inApp.click()
|
||||
await settle()
|
||||
expect(savePreferences).toHaveBeenCalledTimes(2)
|
||||
|
||||
finishSave({})
|
||||
await settle()
|
||||
expect(email.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('restores this browser push state from the saved endpoints', async () => {
|
||||
await mount()
|
||||
expect(refresh).toHaveBeenCalledWith('test-key', ['https://push.example/subscription'])
|
||||
})
|
||||
|
||||
it('disables browser notifications when push is unavailable', async () => {
|
||||
await mount('')
|
||||
const browserPush = root.querySelector('button')
|
||||
expect(browserPush.disabled).toBe(true)
|
||||
expect(root.textContent).toContain('notification.browserPush.unavailable')
|
||||
})
|
||||
|
||||
it('shows a loader while browser notifications are being enabled', async () => {
|
||||
let finishEnable
|
||||
enable.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
finishEnable = resolve
|
||||
})
|
||||
)
|
||||
await mount()
|
||||
|
||||
root.querySelector('button').click()
|
||||
await nextTick()
|
||||
|
||||
expect(root.querySelector('.animate-dot-flashing')).not.toBeNull()
|
||||
finishEnable(true)
|
||||
await settle()
|
||||
expect(root.querySelector('.animate-dot-flashing')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,205 @@
|
||||
<template>
|
||||
<div class="h-full relative">
|
||||
<div class="flex flex-col space-y-5 max-w-2xl">
|
||||
<div class="space-y-1">
|
||||
<span class="sub-title">{{ $t('globals.terms.notification', 2) }}</span>
|
||||
</div>
|
||||
|
||||
<Spinner v-if="isLoading" />
|
||||
|
||||
<template v-else>
|
||||
<p v-if="!emailEnabled" class="text-muted-foreground text-xs">
|
||||
{{ $t('notification.emailChannelDisabled') }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 border border-border rounded-md px-4 py-3"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm text-foreground">
|
||||
{{ $t('globals.terms.browserNotification', 2) }}
|
||||
</p>
|
||||
<p v-if="browserPushDescription" class="text-xs text-muted-foreground">
|
||||
{{ browserPushDescription }}
|
||||
</p>
|
||||
</div>
|
||||
<DotLoader
|
||||
v-if="isUpdatingBrowserPush"
|
||||
role="status"
|
||||
:aria-label="$t('globals.terms.loading')"
|
||||
/>
|
||||
<Switch
|
||||
v-else
|
||||
:checked="pushNotifications.enabled.value"
|
||||
:disabled="
|
||||
!pushNotifications.supported.value ||
|
||||
pushNotifications.permission.value === 'denied' ||
|
||||
!vapidPublicKey
|
||||
"
|
||||
:aria-label="$t('globals.terms.browserNotification', 2)"
|
||||
@update:checked="updateBrowserPush"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="border border-border rounded-md divide-y divide-border">
|
||||
<div class="flex items-center px-4 py-2 text-xs font-medium text-muted-foreground">
|
||||
<div class="flex-grow" />
|
||||
<div v-for="channel in channels" :key="channel.key" class="w-20 text-center">
|
||||
{{ $t(channel.labelKey) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="row in rows" :key="row.type" class="flex items-center px-4 py-3">
|
||||
<div class="flex-grow pr-4">
|
||||
<p class="text-sm text-foreground">{{ typeLabel(row.type) }}</p>
|
||||
</div>
|
||||
<div v-for="channel in channels" :key="channel.key" class="w-20 flex justify-center">
|
||||
<Switch
|
||||
:checked="row[channel.key]"
|
||||
:disabled="
|
||||
(channel.key === 'email' && !emailEnabled) ||
|
||||
pendingUpdates.has(preferenceKey(row.type, channel.key))
|
||||
"
|
||||
:aria-label="
|
||||
$t('notification.channelToggleLabel', {
|
||||
channel: $t(channel.labelKey),
|
||||
type: typeLabel(row.type)
|
||||
})
|
||||
"
|
||||
@update:checked="update(row, channel.key, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Switch } from '@shared-ui/components/ui/switch'
|
||||
import { DotLoader } from '@shared-ui/components/ui/loader'
|
||||
import { Spinner } from '@shared-ui/components/ui/spinner'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { handleHTTPError } from '@shared-ui/utils/http.js'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import api from '@/api'
|
||||
import { usePushNotifications } from '@/composables/usePushNotifications'
|
||||
|
||||
const { t } = useI18n()
|
||||
const emitter = useEmitter()
|
||||
const rows = ref([])
|
||||
const isLoading = ref(true)
|
||||
const emailEnabled = ref(true)
|
||||
const vapidPublicKey = ref('')
|
||||
const pendingUpdates = ref(new Set())
|
||||
const isUpdatingBrowserPush = ref(false)
|
||||
const pushNotifications = usePushNotifications()
|
||||
|
||||
const channels = [
|
||||
{ key: 'in_app', labelKey: 'globals.terms.inApp' },
|
||||
{ key: 'email', labelKey: 'globals.terms.email' },
|
||||
{ key: 'push', labelKey: 'globals.terms.browser' }
|
||||
]
|
||||
|
||||
const typeLabels = {
|
||||
assignment: 'notification.type.assignment',
|
||||
mention: 'notification.type.mention',
|
||||
conversation_reopened: 'notification.type.conversationReopened',
|
||||
new_reply: 'notification.type.newReply',
|
||||
new_reply_participating: 'notification.type.newReplyParticipating',
|
||||
sla_first_response_warning: 'notification.type.slaFirstResponseWarning',
|
||||
sla_first_response_breach: 'notification.type.slaFirstResponseBreach',
|
||||
sla_next_response_warning: 'notification.type.slaNextResponseWarning',
|
||||
sla_next_response_breach: 'notification.type.slaNextResponseBreach',
|
||||
sla_resolution_warning: 'notification.type.slaResolutionWarning',
|
||||
sla_resolution_breach: 'notification.type.slaResolutionBreach'
|
||||
}
|
||||
|
||||
const typeLabel = (type) => (typeLabels[type] ? t(typeLabels[type]) : type)
|
||||
const preferenceKey = (type, channel) => `${type}:${channel}`
|
||||
|
||||
const fetchPreferences = async () => {
|
||||
try {
|
||||
const { data } = await api.getNotificationPreferences()
|
||||
emailEnabled.value = data.data.email_enabled
|
||||
vapidPublicKey.value = data.data.vapid_public_key
|
||||
await pushNotifications.refresh(vapidPublicKey.value, data.data.push_endpoints)
|
||||
const byType = {}
|
||||
for (const pref of data.data.preferences) {
|
||||
byType[pref.notification_type] ??= { type: pref.notification_type }
|
||||
byType[pref.notification_type][pref.channel] = pref.enabled
|
||||
}
|
||||
rows.value = Object.values(byType)
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const browserPushDescription = computed(() => {
|
||||
if (!pushNotifications.supported.value) return t('notification.browserPush.unsupported')
|
||||
if (pushNotifications.permission.value === 'denied') return t('notification.browserPush.blocked')
|
||||
if (!vapidPublicKey.value) return t('notification.browserPush.unavailable')
|
||||
return ''
|
||||
})
|
||||
|
||||
const updateBrowserPush = async (value) => {
|
||||
if (isUpdatingBrowserPush.value) return
|
||||
|
||||
try {
|
||||
isUpdatingBrowserPush.value = true
|
||||
if (value) {
|
||||
const granted = await pushNotifications.enable(vapidPublicKey.value)
|
||||
if (!granted) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
description: t('notification.browserPush.blocked')
|
||||
})
|
||||
}
|
||||
} else {
|
||||
await pushNotifications.disable()
|
||||
}
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
} finally {
|
||||
isUpdatingBrowserPush.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const update = async (row, channel, value) => {
|
||||
const key = preferenceKey(row.type, channel)
|
||||
if (pendingUpdates.value.has(key)) return
|
||||
|
||||
const previous = row[channel]
|
||||
row[channel] = value
|
||||
pendingUpdates.value.add(key)
|
||||
try {
|
||||
await api.updateNotificationPreferences([
|
||||
{ notification_type: row.type, channel, enabled: value }
|
||||
])
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
description: t('globals.messages.savedSuccessfully')
|
||||
})
|
||||
} catch (error) {
|
||||
row[channel] = previous
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
} finally {
|
||||
pendingUpdates.value.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchPreferences)
|
||||
</script>
|
||||
@@ -37,6 +37,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import AdminSplitLayout from '@/layouts/admin/AdminSplitLayout.vue'
|
||||
import LoadingOverlay from '@main/components/layout/LoadingOverlay.vue'
|
||||
import DataTable from '@main/components/datatable/DataTable.vue'
|
||||
@@ -55,18 +56,20 @@ import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { handleHTTPError } from '@shared-ui/utils/http.js'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '@/api'
|
||||
import { useAiPromptStore } from '@main/stores/aiPrompt'
|
||||
|
||||
const { t } = useI18n()
|
||||
const emitter = useEmitter()
|
||||
const aiPromptStore = useAiPromptStore()
|
||||
const { prompts } = storeToRefs(aiPromptStore)
|
||||
const isLoading = ref(false)
|
||||
const prompts = ref([])
|
||||
const dialogOpen = ref(false)
|
||||
const isEditing = ref(false)
|
||||
const initialValues = ref({})
|
||||
const editingId = ref(null)
|
||||
|
||||
const refreshHandler = (data) => {
|
||||
if (data?.model === 'ai_editor_prompts') getPrompts()
|
||||
if (data?.model === 'ai_editor_prompts') refreshPrompts()
|
||||
}
|
||||
|
||||
const editHandler = (data) => {
|
||||
@@ -74,7 +77,7 @@ const editHandler = (data) => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getPrompts()
|
||||
loadPrompts()
|
||||
emitter.on(EMITTER_EVENTS.REFRESH_LIST, refreshHandler)
|
||||
emitter.on(EMITTER_EVENTS.EDIT_MODEL, editHandler)
|
||||
})
|
||||
@@ -84,19 +87,16 @@ onUnmounted(() => {
|
||||
emitter.off(EMITTER_EVENTS.EDIT_MODEL, editHandler)
|
||||
})
|
||||
|
||||
const getPrompts = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const resp = await api.getAiPrompts()
|
||||
prompts.value = resp.data.data || []
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
const loadPrompts = async () => {
|
||||
isLoading.value = true
|
||||
await aiPromptStore.fetchPrompts()
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
const refreshPrompts = async () => {
|
||||
isLoading.value = true
|
||||
await aiPromptStore.refreshPrompts()
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
const newPrompt = () => {
|
||||
@@ -128,7 +128,7 @@ const submitPrompt = async (values) => {
|
||||
await api.createAIPrompt(values)
|
||||
}
|
||||
dialogOpen.value = false
|
||||
getPrompts()
|
||||
await refreshPrompts()
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
description: t('globals.messages.savedSuccessfully')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
describe('PWA notification preferences', () => {
|
||||
it('serves installable assets', () => {
|
||||
cy.request('/manifest.webmanifest').then(({ body, headers, status }) => {
|
||||
expect(status).to.eq(200)
|
||||
expect(headers['content-type']).to.include('application/manifest+json')
|
||||
expect(body.name).to.eq('libredesk')
|
||||
expect(body.display).to.eq('standalone')
|
||||
expect(body.icons).to.have.length(2)
|
||||
})
|
||||
|
||||
cy.request('/sw.js').then(({ body, status }) => {
|
||||
expect(status).to.eq(200)
|
||||
expect(body).to.include("addEventListener('push'")
|
||||
expect(body).to.include("addEventListener('notificationclick'")
|
||||
expect(body).not.to.include("addEventListener('fetch'")
|
||||
})
|
||||
})
|
||||
})
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
const readPayload = (event) => {
|
||||
try {
|
||||
return event.data ? event.data.json() : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
self.addEventListener('install', () => self.skipWaiting())
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
const notification = readPayload(event)
|
||||
if (!notification?.title) return
|
||||
event.waitUntil(self.registration.showNotification(notification.title, {
|
||||
body: notification.body,
|
||||
tag: notification.tag,
|
||||
icon: '/images/pwa-icon-192.png',
|
||||
badge: '/images/pwa-icon-192.png',
|
||||
data: { url: notification.url }
|
||||
}))
|
||||
})
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close()
|
||||
const url = event.notification.data?.url
|
||||
if (!url) return
|
||||
const target = new URL(url, self.location.origin)
|
||||
if (target.origin !== self.location.origin) return
|
||||
|
||||
event.waitUntil(self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(async (clients) => {
|
||||
const client = clients[0]
|
||||
if (!client) return self.clients.openWindow(target.href)
|
||||
await client.focus()
|
||||
return client.navigate(target.href).catch(() => self.clients.openWindow(target.href))
|
||||
}))
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -907,6 +907,7 @@
|
||||
"globals.messages.replying": "Replying",
|
||||
"globals.messages.required": "{name} Required",
|
||||
"globals.messages.reset": "Reset",
|
||||
"globals.messages.resetToDefault": "Reset to default",
|
||||
"globals.messages.revoke": "Revoke",
|
||||
"globals.messages.save": "Save",
|
||||
"globals.messages.saveChanges": "Save changes",
|
||||
@@ -971,6 +972,8 @@
|
||||
"globals.terms.body": "Body",
|
||||
"globals.terms.brandName": "Brand name",
|
||||
"globals.terms.breadcrumb": "Breadcrumb",
|
||||
"globals.terms.browser": "Browser",
|
||||
"globals.terms.browserNotification": "Browser notification | Browser notifications",
|
||||
"globals.terms.businessHour": "Business hour | Business hours",
|
||||
"globals.terms.callbackURL": "Callback URL",
|
||||
"globals.terms.category": "Category",
|
||||
@@ -1042,6 +1045,7 @@
|
||||
"globals.terms.hour": "Hour | Hours",
|
||||
"globals.terms.icon": "Icon | Icons",
|
||||
"globals.terms.image": "Image | Images",
|
||||
"globals.terms.inApp": "In-app",
|
||||
"globals.terms.import": "Import | Imports",
|
||||
"globals.terms.inactive": "Inactive | Inactives",
|
||||
"globals.terms.inbox": "Inbox | Inboxes",
|
||||
@@ -1375,11 +1379,32 @@
|
||||
"navigation.keyboardShortcuts": "Keyboard shortcuts",
|
||||
"navigation.logout": "Logout",
|
||||
"navigation.reassignReplies": "Reassign replies",
|
||||
"notification.browserPush.blocked": "Browser notifications are blocked. Allow them in your browser settings to receive notifications on this device.",
|
||||
"notification.browserPush.unavailable": "Browser notifications are unavailable because push is not configured. Ask an administrator to configure push notifications.",
|
||||
"notification.browserPush.unsupported": "This browser does not support push notifications. Use a supported browser to receive notifications when libredesk is closed.",
|
||||
"notification.channelToggleLabel": "{channel} notifications for {type}",
|
||||
"notification.conversationAssigned": "Conversation assigned to you #{referenceNumber}",
|
||||
"notification.conversationReopened": "Conversation reopened #{referenceNumber}",
|
||||
"notification.emailChannelDisabled": "Email notifications are turned off for this workspace. Contact your administrator to enable them.",
|
||||
"notification.invalidPreference": "Unknown notification type or channel. The preference was not saved.",
|
||||
"notification.invalidPushSubscription": "This browser subscription is invalid. Disable and enable browser notifications, then try again.",
|
||||
"notification.mentionedInConversation": "{author} mentioned you in #{referenceNumber}",
|
||||
"notification.newReply": "New reply on #{referenceNumber}",
|
||||
"notification.settings": "Notification settings",
|
||||
"notification.slaAlert": "SLA {type}: {metric} for #{referenceNumber}",
|
||||
"notification.slaDueIn": "Due in {duration}",
|
||||
"notification.slaOverdue": "Overdue by {duration}",
|
||||
"notification.type.assignment": "Conversation assigned to me",
|
||||
"notification.type.conversationReopened": "Conversation assigned to me is reopened by a reply",
|
||||
"notification.type.mention": "Mentioned in a conversation",
|
||||
"notification.type.newReply": "New reply on a conversation assigned to me",
|
||||
"notification.type.newReplyParticipating": "New reply on a conversation I'm participating in",
|
||||
"notification.type.slaFirstResponseBreach": "First response SLA breached",
|
||||
"notification.type.slaFirstResponseWarning": "First response SLA breach warning",
|
||||
"notification.type.slaNextResponseBreach": "Next response SLA breached",
|
||||
"notification.type.slaNextResponseWarning": "Next response SLA breach warning",
|
||||
"notification.type.slaResolutionBreach": "Resolution SLA breached",
|
||||
"notification.type.slaResolutionWarning": "Resolution SLA breach warning",
|
||||
"oidc.edit": "Edit SSO",
|
||||
"oidc.new": "New SSO",
|
||||
"placeholders.chatIntroduction": "Ask us anything, or share your feedback.",
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
package conversation
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
htmltemplate "html/template"
|
||||
"io"
|
||||
"slices"
|
||||
"strconv"
|
||||
@@ -29,7 +32,6 @@ import (
|
||||
"github.com/abhinavxd/libredesk/internal/inbox"
|
||||
imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
|
||||
mmodels "github.com/abhinavxd/libredesk/internal/media/models"
|
||||
notifier "github.com/abhinavxd/libredesk/internal/notification"
|
||||
nmodels "github.com/abhinavxd/libredesk/internal/notification/models"
|
||||
slaModels "github.com/abhinavxd/libredesk/internal/sla/models"
|
||||
"github.com/abhinavxd/libredesk/internal/stringutil"
|
||||
@@ -65,6 +67,7 @@ const (
|
||||
<p>
|
||||
<a href="{{ RootURL }}/inboxes/all/conversation/{{ .Conversation.UUID }}">#{{ .Conversation.ReferenceNumber }}</a>
|
||||
</p>`
|
||||
newReplyEmailDelay = 5 * time.Minute
|
||||
)
|
||||
|
||||
var ListFilterRenderers = dbutil.FieldRenderers{
|
||||
@@ -80,6 +83,10 @@ var ListFilterAllowedFields = dbutil.AllowedFields{
|
||||
"inboxes": inboxesAllowedFields,
|
||||
}
|
||||
|
||||
type notificationDispatcher interface {
|
||||
Send(nmodels.Notification) ([]nmodels.DeliveryResult, error)
|
||||
}
|
||||
|
||||
// Manager handles the operations related to conversations
|
||||
type Manager struct {
|
||||
q queries
|
||||
@@ -93,7 +100,7 @@ type Manager struct {
|
||||
settingsStore settingsStore
|
||||
csatStore csatStore
|
||||
webhookStore webhookStore
|
||||
dispatcher *notifier.Dispatcher
|
||||
dispatcher notificationDispatcher
|
||||
lo *logf.Logger
|
||||
db *sqlx.DB
|
||||
i18n *i18n.I18n
|
||||
@@ -111,6 +118,13 @@ type Manager struct {
|
||||
aiAgent AIAgentEngine
|
||||
}
|
||||
|
||||
type replyNotificationGroup struct {
|
||||
nType nmodels.NotificationType
|
||||
tmpl string
|
||||
title string
|
||||
recipients []umodels.User
|
||||
}
|
||||
|
||||
// AIAgentEngine is notified when a conversation assigned to an AI assistant may need a response.
|
||||
type AIAgentEngine interface {
|
||||
HandleConversationEvent(conversationID, assigneeUserID int)
|
||||
@@ -163,7 +177,6 @@ type teamStore interface {
|
||||
|
||||
type userStore interface {
|
||||
Get(int, string, []string) (umodels.User, error)
|
||||
GetAgent(int, string) (umodels.User, error)
|
||||
GetAgentCachedOrLoad(int) (umodels.User, error)
|
||||
GetSystemUser() (umodels.User, error)
|
||||
ResolveContact(user *umodels.User, policy umodels.ContactPolicy) error
|
||||
@@ -239,7 +252,7 @@ func New(
|
||||
automation *automation.Engine,
|
||||
template *template.Manager,
|
||||
webhook webhookStore,
|
||||
dispatcher *notifier.Dispatcher,
|
||||
dispatcher notificationDispatcher,
|
||||
opts Opts) (*Manager, error) {
|
||||
|
||||
var q queries
|
||||
@@ -309,6 +322,7 @@ type queries struct {
|
||||
GetContactConversationsForAI *sqlx.Stmt `query:"get-contact-conversations-for-ai"`
|
||||
GetConversationsByContactEmailForAI *sqlx.Stmt `query:"get-conversations-by-contact-email-for-ai"`
|
||||
GetConversationParticipants *sqlx.Stmt `query:"get-conversation-participants"`
|
||||
GetConversationParticipantAgents *sqlx.Stmt `query:"get-conversation-participant-agents"`
|
||||
GetUserActiveConversationsCount *sqlx.Stmt `query:"get-user-active-conversations-count"`
|
||||
GetSidebarStandardCounts *sqlx.Stmt `query:"get-sidebar-standard-counts"`
|
||||
GetConversationsCountBase string `query:"get-conversations-count-base"`
|
||||
@@ -683,14 +697,14 @@ func (c *Manager) GetConversations(viewingUserID, userID int, teamIDs []int, lis
|
||||
}
|
||||
|
||||
// ReOpenConversation reopens a conversation if it's snoozed, resolved or closed.
|
||||
func (c *Manager) ReOpenConversation(conversationUUID string, actor umodels.User) error {
|
||||
func (c *Manager) ReOpenConversation(conversationUUID string, actor umodels.User) (bool, error) {
|
||||
var conversationID int
|
||||
if err := c.q.ReOpenConversation.QueryRow(conversationUUID).Scan(&conversationID); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil
|
||||
return false, nil
|
||||
}
|
||||
c.lo.Error("error reopening conversation", "uuid", conversationUUID, "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
|
||||
return false, envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
|
||||
}
|
||||
|
||||
// Reopening revives any first response or resolution deadline the resolved status had nulled.
|
||||
@@ -701,9 +715,9 @@ func (c *Manager) ReOpenConversation(conversationUUID string, actor umodels.User
|
||||
c.BroadcastConversationUpdate(conversationUUID, map[string]any{"status": models.StatusOpen})
|
||||
|
||||
if err := c.RecordStatusChange(models.StatusOpen, conversationUUID, actor); err != nil {
|
||||
return err
|
||||
return true, err
|
||||
}
|
||||
return nil
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// ActiveUserConversationsCount returns the count of active conversations for a user. i.e. conversations not closed or resolved status.
|
||||
@@ -811,7 +825,7 @@ func (c *Manager) afterUserAssignedHooks(uuid string, assigneeID int, actor umod
|
||||
return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
|
||||
}
|
||||
|
||||
agent, err := c.userStore.GetAgent(assigneeID, "")
|
||||
agent, err := c.userStore.GetAgentCachedOrLoad(assigneeID)
|
||||
if err == nil {
|
||||
c.SignAvatarURL(&agent.AvatarURL)
|
||||
// Always send expectation (empty for humans) so it clears when the AI hands off to an agent.
|
||||
@@ -1211,64 +1225,157 @@ func (m *Manager) SendTransientEmail(inboxID, conversationID int, conversationUU
|
||||
|
||||
// NotifyAssignment sends notifications (in-app, WebSocket, email) for an assigned conversation.
|
||||
func (m *Manager) NotifyAssignment(userIDs []int, conversation models.Conversation) error {
|
||||
agent, err := m.userStore.GetAgent(userIDs[0], "")
|
||||
agent, err := m.userStore.GetAgentCachedOrLoad(userIDs[0])
|
||||
if err != nil {
|
||||
m.lo.Error("error fetching agent", "user_id", userIDs[0], "error", err)
|
||||
return fmt.Errorf("fetching agent: %w", err)
|
||||
}
|
||||
|
||||
// Render email template.
|
||||
// Automated messages do not have an author.
|
||||
content, subject, err := m.template.RenderStoredEmailTemplate(template.TmplConversationAssigned,
|
||||
map[string]any{
|
||||
"Conversation": map[string]any{
|
||||
"ReferenceNumber": conversation.ReferenceNumber,
|
||||
"Subject": conversation.Subject.String,
|
||||
"Priority": conversation.Priority.String,
|
||||
"UUID": conversation.UUID,
|
||||
},
|
||||
"Contact": map[string]any{
|
||||
"FirstName": conversation.Contact.FirstName,
|
||||
"LastName": conversation.Contact.LastName,
|
||||
"FullName": conversation.Contact.FullName(),
|
||||
"Email": conversation.Contact.Email.String,
|
||||
},
|
||||
"Recipient": map[string]any{
|
||||
"FirstName": agent.FirstName,
|
||||
"LastName": agent.LastName,
|
||||
"FullName": agent.FullName(),
|
||||
"Email": agent.Email.String,
|
||||
},
|
||||
// Automated messages do not have an author.
|
||||
"Author": map[string]any{
|
||||
"FirstName": "",
|
||||
"LastName": "",
|
||||
"FullName": "",
|
||||
"Email": "",
|
||||
},
|
||||
})
|
||||
notificationTemplateData(conversation, agent, umodels.User{}))
|
||||
if err != nil {
|
||||
m.lo.Error("error rendering template", "template", template.TmplConversationAssigned, "conversation_uuid", conversation.UUID, "error", err)
|
||||
return fmt.Errorf("rendering template: %w", err)
|
||||
}
|
||||
|
||||
// Send notification.
|
||||
m.dispatcher.Send(notifier.Notification{
|
||||
Type: nmodels.NotificationTypeAssignment,
|
||||
RecipientIDs: []int{agent.ID},
|
||||
if _, err := m.dispatcher.Send(nmodels.Notification{
|
||||
Type: nmodels.NotificationTypeAssignment,
|
||||
Recipients: []nmodels.Recipient{{
|
||||
UserID: agent.ID,
|
||||
Email: &nmodels.EmailNotification{
|
||||
Recipient: agent.Email.String,
|
||||
Subject: subject,
|
||||
Content: content,
|
||||
},
|
||||
}},
|
||||
Title: m.i18n.Ts("notification.conversationAssigned", "referenceNumber", conversation.ReferenceNumber),
|
||||
Body: conversation.Subject,
|
||||
Body: null.StringFrom(cmp.Or(conversation.Subject.String, conversation.LastMessage.String)),
|
||||
ConversationID: null.IntFrom(conversation.ID),
|
||||
ConversationUUID: conversation.UUID,
|
||||
// Parms required for email
|
||||
Email: ¬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
|
||||
}
|
||||
messageText := replyNotificationText(message)
|
||||
candidateIDs := make([]int, len(group.recipients))
|
||||
for i, recipient := range group.recipients {
|
||||
candidateIDs[i] = recipient.ID
|
||||
}
|
||||
|
||||
recipients := m.buildNotificationRecipients(candidateIDs, func(recipient umodels.User) (string, string, error) {
|
||||
data := notificationTemplateData(conversation, recipient, author)
|
||||
data["Message"] = map[string]any{
|
||||
"UUID": message.UUID,
|
||||
"Content": htmltemplate.HTML(strings.ReplaceAll(html.EscapeString(messageText), "\n", "<br>")),
|
||||
}
|
||||
content, subject, err := m.template.RenderStoredEmailTemplate(group.tmpl, data)
|
||||
return subject, content, err
|
||||
})
|
||||
for i := range recipients {
|
||||
if recipients[i].Email != nil {
|
||||
recipients[i].Email.Delay = newReplyEmailDelay
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := m.dispatcher.Send(nmodels.Notification{
|
||||
Type: group.nType,
|
||||
Recipients: recipients,
|
||||
Title: group.title,
|
||||
Body: null.StringFrom(cmp.Or(messageText, conversation.Subject.String)),
|
||||
ConversationID: null.IntFrom(conversation.ID),
|
||||
MessageID: null.IntFrom(message.ID),
|
||||
ConversationUUID: conversation.UUID,
|
||||
MessageUUID: message.UUID,
|
||||
}); err != nil {
|
||||
m.lo.Error("error sending reply notification", "type", group.nType, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyMention sends notifications (in-app, WebSocket, email) for mentions.
|
||||
// For team mentions, expands to all team members.
|
||||
func (m *Manager) NotifyMention(conversationUUID string, message models.Message, mentions []models.MentionInput, mentionedByUserID int) {
|
||||
@@ -1279,7 +1386,7 @@ func (m *Manager) NotifyMention(conversationUUID string, message models.Message,
|
||||
}
|
||||
|
||||
// Get the user who made the mention.
|
||||
author, err := m.userStore.GetAgent(mentionedByUserID, "")
|
||||
author, err := m.userStore.GetAgentCachedOrLoad(mentionedByUserID)
|
||||
if err != nil {
|
||||
m.lo.Error("error fetching author for mention notification", "user_id", mentionedByUserID, "error", err)
|
||||
return
|
||||
@@ -1317,61 +1424,43 @@ func (m *Manager) NotifyMention(conversationUUID string, message models.Message,
|
||||
if err != nil {
|
||||
m.lo.Error("error fetching root URL for mention notification", "error", err)
|
||||
}
|
||||
messageContent := absolutizeConversationReferenceLinks(message.Content, rootURL)
|
||||
messageContent := htmltemplate.HTML(absolutizeConversationReferenceLinks(message.Content, rootURL))
|
||||
|
||||
recipientIDs, emails := m.buildRecipientEmails(userIDs, func(recipient umodels.User) (string, string, error) {
|
||||
content, subject, err := m.template.RenderStoredEmailTemplate(template.TmplMentioned,
|
||||
map[string]any{
|
||||
"Conversation": map[string]any{
|
||||
"ReferenceNumber": conversation.ReferenceNumber,
|
||||
"Subject": conversation.Subject.String,
|
||||
"Priority": conversation.Priority.String,
|
||||
"UUID": conversation.UUID,
|
||||
},
|
||||
"Recipient": map[string]any{
|
||||
"FirstName": recipient.FirstName,
|
||||
"LastName": recipient.LastName,
|
||||
"FullName": recipient.FullName(),
|
||||
"Email": recipient.Email.String,
|
||||
},
|
||||
"Message": map[string]any{
|
||||
"UUID": message.UUID,
|
||||
"Content": messageContent,
|
||||
},
|
||||
"MentionedBy": map[string]any{
|
||||
"FirstName": author.FirstName,
|
||||
"LastName": author.LastName,
|
||||
"FullName": author.FullName(),
|
||||
"Email": author.Email.String,
|
||||
},
|
||||
// Automated messages do not have an author.
|
||||
"Author": map[string]any{
|
||||
"FirstName": "",
|
||||
"LastName": "",
|
||||
"FullName": "",
|
||||
"Email": "",
|
||||
},
|
||||
})
|
||||
recipients := m.buildNotificationRecipients(userIDs, func(recipient umodels.User) (string, string, error) {
|
||||
data := notificationTemplateData(conversation, recipient, umodels.User{})
|
||||
data["Message"] = map[string]any{
|
||||
"UUID": message.UUID,
|
||||
"Content": messageContent,
|
||||
}
|
||||
data["MentionedBy"] = map[string]any{
|
||||
"FirstName": author.FirstName,
|
||||
"LastName": author.LastName,
|
||||
"FullName": author.FullName(),
|
||||
"Email": author.Email.String,
|
||||
}
|
||||
content, subject, err := m.template.RenderStoredEmailTemplate(template.TmplMentioned, data)
|
||||
return subject, content, err
|
||||
})
|
||||
|
||||
if len(recipientIDs) == 0 {
|
||||
if len(recipients) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Send notification via dispatcher (handles in-app, WebSocket, and email).
|
||||
m.dispatcher.SendWithEmails(notifier.Notification{
|
||||
if _, err := m.dispatcher.Send(nmodels.Notification{
|
||||
Type: nmodels.NotificationTypeMention,
|
||||
RecipientIDs: recipientIDs,
|
||||
Recipients: recipients,
|
||||
Title: m.i18n.Ts("notification.mentionedInConversation", "author", author.FullName(), "referenceNumber", conversation.ReferenceNumber),
|
||||
Body: null.StringFrom(message.TextContent),
|
||||
ConversationID: null.IntFrom(conversation.ID),
|
||||
MessageID: null.IntFrom(message.ID),
|
||||
ActorID: null.IntFrom(mentionedByUserID),
|
||||
ConversationUUID: conversation.UUID,
|
||||
MessageUUID: message.UUID,
|
||||
ActorFirstName: author.FirstName,
|
||||
ActorLastName: author.LastName,
|
||||
}, emails)
|
||||
}); err != nil {
|
||||
m.lo.Error("error sending mention notification", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// UnassignOpen unassigns all open conversations belonging to a user.
|
||||
@@ -1542,83 +1631,53 @@ func (m *Manager) notifyAutomation(subject, message string, entries []string, co
|
||||
userIDs = userIDs[:amodels.MaxNotifyRecipients]
|
||||
}
|
||||
|
||||
recipientIDs, emails := m.buildRecipientEmails(userIDs, func(recipient umodels.User) (string, string, error) {
|
||||
content, err := m.template.RenderEmailWithTemplate(
|
||||
map[string]any{
|
||||
"Conversation": map[string]any{
|
||||
"ReferenceNumber": conv.ReferenceNumber,
|
||||
"Subject": conv.Subject.String,
|
||||
"Priority": conv.Priority.String,
|
||||
"UUID": conv.UUID,
|
||||
},
|
||||
"Recipient": map[string]any{
|
||||
"FirstName": recipient.FirstName,
|
||||
"LastName": recipient.LastName,
|
||||
"FullName": recipient.FullName(),
|
||||
"Email": recipient.Email.String,
|
||||
},
|
||||
"Contact": map[string]any{
|
||||
"FirstName": conv.Contact.FirstName,
|
||||
"LastName": conv.Contact.LastName,
|
||||
"FullName": conv.Contact.FullName(),
|
||||
"Email": conv.Contact.Email.String,
|
||||
},
|
||||
// Automated messages do not have an author.
|
||||
"Author": map[string]any{
|
||||
"FirstName": "",
|
||||
"LastName": "",
|
||||
"FullName": "",
|
||||
"Email": "",
|
||||
},
|
||||
"Message": message,
|
||||
},
|
||||
automationNotifyEmailContent)
|
||||
recipients := m.buildNotificationRecipients(userIDs, func(recipient umodels.User) (string, string, error) {
|
||||
data := notificationTemplateData(conv, recipient, umodels.User{})
|
||||
data["Message"] = message
|
||||
content, err := m.template.RenderEmailWithTemplate(data, automationNotifyEmailContent)
|
||||
return subject, content, err
|
||||
})
|
||||
|
||||
m.dispatcher.SendWithEmails(notifier.Notification{
|
||||
Type: nmodels.NotificationTypeMention,
|
||||
RecipientIDs: recipientIDs,
|
||||
if _, err := m.dispatcher.Send(nmodels.Notification{
|
||||
Type: nmodels.NotificationTypeAutomation,
|
||||
Recipients: recipients,
|
||||
Title: subject,
|
||||
Body: null.StringFrom(message),
|
||||
ConversationID: null.IntFrom(conv.ID),
|
||||
ConversationUUID: conv.UUID,
|
||||
}, emails)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("sending automation notification: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildRecipientEmails fetches each recipient and renders their email, returning emails index-aligned with recipient IDs; on fetch or render failure the in-app notification still goes out with a zero-value email.
|
||||
func (m *Manager) buildRecipientEmails(userIDs []int, render func(recipient umodels.User) (subject, content string, err error)) ([]int, []notifier.EmailNotification) {
|
||||
var (
|
||||
recipientIDs []int
|
||||
emails []notifier.EmailNotification
|
||||
)
|
||||
func (m *Manager) buildNotificationRecipients(userIDs []int, render func(recipient umodels.User) (subject, content string, err error)) []nmodels.Recipient {
|
||||
recipients := make([]nmodels.Recipient, 0, len(userIDs))
|
||||
for _, id := range userIDs {
|
||||
recipientIDs = append(recipientIDs, id)
|
||||
delivery := nmodels.Recipient{UserID: id}
|
||||
|
||||
recipient, err := m.userStore.GetAgentCachedOrLoad(id)
|
||||
if err != nil {
|
||||
m.lo.Error("error fetching agent for email notification", "user_id", id, "error", err)
|
||||
emails = append(emails, notifier.EmailNotification{})
|
||||
recipients = append(recipients, delivery)
|
||||
continue
|
||||
}
|
||||
|
||||
var email notifier.EmailNotification
|
||||
if recipient.Email.String != "" {
|
||||
subject, content, err := render(recipient)
|
||||
if err != nil {
|
||||
m.lo.Error("error rendering email notification", "user_id", id, "error", err)
|
||||
} else {
|
||||
email = notifier.EmailNotification{
|
||||
Recipients: []string{recipient.Email.String},
|
||||
Subject: subject,
|
||||
Content: content,
|
||||
delivery.Email = &nmodels.EmailNotification{
|
||||
Recipient: recipient.Email.String,
|
||||
Subject: subject,
|
||||
Content: content,
|
||||
}
|
||||
}
|
||||
}
|
||||
emails = append(emails, email)
|
||||
recipients = append(recipients, delivery)
|
||||
}
|
||||
return recipientIDs, emails
|
||||
return recipients
|
||||
}
|
||||
|
||||
func (m *Manager) resolveNotifyRecipients(entries []string, conv models.Conversation) []int {
|
||||
@@ -1956,7 +2015,7 @@ func (m *Manager) BuildWidgetConversationView(conversation models.Conversation)
|
||||
|
||||
// Fetch assignee details if assigned
|
||||
if conversation.AssignedUserID.Int > 0 {
|
||||
assignee, err := m.userStore.GetAgent(conversation.AssignedUserID.Int, "")
|
||||
assignee, err := m.userStore.GetAgentCachedOrLoad(conversation.AssignedUserID.Int)
|
||||
if err != nil {
|
||||
m.lo.Error("error fetching conversation assignee for widget", "conversation_uuid", conversation.UUID, "error", err)
|
||||
} else {
|
||||
@@ -2253,6 +2312,35 @@ func renderTagFilter(operator, value string, paramIndex int) (string, []any, err
|
||||
}
|
||||
}
|
||||
|
||||
func notificationTemplateData(conversation models.Conversation, recipient, author umodels.User) map[string]any {
|
||||
return map[string]any{
|
||||
"Conversation": map[string]any{
|
||||
"ReferenceNumber": conversation.ReferenceNumber,
|
||||
"Subject": conversation.Subject.String,
|
||||
"Priority": conversation.Priority.String,
|
||||
"UUID": conversation.UUID,
|
||||
},
|
||||
"Contact": map[string]any{
|
||||
"FirstName": conversation.Contact.FirstName,
|
||||
"LastName": conversation.Contact.LastName,
|
||||
"FullName": conversation.Contact.FullName(),
|
||||
"Email": conversation.Contact.Email.String,
|
||||
},
|
||||
"Recipient": map[string]any{
|
||||
"FirstName": recipient.FirstName,
|
||||
"LastName": recipient.LastName,
|
||||
"FullName": recipient.FullName(),
|
||||
"Email": recipient.Email.String,
|
||||
},
|
||||
"Author": map[string]any{
|
||||
"FirstName": author.FirstName,
|
||||
"LastName": author.LastName,
|
||||
"FullName": author.FullName(),
|
||||
"Email": author.Email.String,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// appendListTypeConditions returns the SQL conditions for the list types, appending their bind parameters to args.
|
||||
func appendListTypeConditions(listTypes []string, viewingUserID, userID int, teamIDs []int, args *[]any) ([]string, error) {
|
||||
conditions := make([]string, 0, len(listTypes))
|
||||
@@ -2308,3 +2396,11 @@ func listTypeWhereClause(conditions []string) string {
|
||||
}
|
||||
return "AND (" + strings.Join(conditions, " OR ") + ")"
|
||||
}
|
||||
|
||||
func replyNotificationText(message models.Message) string {
|
||||
full := strings.TrimSpace(message.TextContent)
|
||||
if message.ContentType == models.ContentTypeHTML {
|
||||
return cmp.Or(stringutil.HTML2TextNoQuotes(message.Content), full)
|
||||
}
|
||||
return cmp.Or(stringutil.TrimPlainTextQuotes(full), full)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package conversation
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func absolutizeConversationReferenceLinks(content, rootURL string) string {
|
||||
if rootURL == "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
package conversation
|
||||
|
||||
import (
|
||||
"errors"
|
||||
htmltemplate "html/template"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
authzmodels "github.com/abhinavxd/libredesk/internal/authz/models"
|
||||
"github.com/abhinavxd/libredesk/internal/conversation/models"
|
||||
"github.com/abhinavxd/libredesk/internal/dbutil"
|
||||
notifier "github.com/abhinavxd/libredesk/internal/notification"
|
||||
nchannels "github.com/abhinavxd/libredesk/internal/notification/channels"
|
||||
nmodels "github.com/abhinavxd/libredesk/internal/notification/models"
|
||||
"github.com/abhinavxd/libredesk/internal/template"
|
||||
"github.com/abhinavxd/libredesk/internal/testutil"
|
||||
umodels "github.com/abhinavxd/libredesk/internal/user/models"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/volatiletech/null/v9"
|
||||
"github.com/zerodha/logf"
|
||||
)
|
||||
|
||||
type replyUserStore struct {
|
||||
userStore
|
||||
agent umodels.User
|
||||
err error
|
||||
}
|
||||
|
||||
type replyPreferences struct {
|
||||
recipients []int
|
||||
channels []nmodels.NotificationChannel
|
||||
err error
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type countingPush struct {
|
||||
count int
|
||||
}
|
||||
|
||||
func (p *countingPush) Send(int, nmodels.PushPayload) bool {
|
||||
p.count++
|
||||
return true
|
||||
}
|
||||
|
||||
func (s replyUserStore) Get(int, string, []string) (umodels.User, error) {
|
||||
return umodels.User{FirstName: "Contact"}, nil
|
||||
}
|
||||
|
||||
func (s replyUserStore) GetAgentCachedOrLoad(int) (umodels.User, error) { return s.agent, s.err }
|
||||
|
||||
func (p *replyPreferences) EnabledChannels(ids []int, _ nmodels.NotificationType) (map[int][]nmodels.NotificationChannel, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.recipients = append(p.recipients, ids...)
|
||||
if p.err != nil {
|
||||
return nil, p.err
|
||||
}
|
||||
enabled := make(map[int][]nmodels.NotificationChannel)
|
||||
if len(p.channels) > 0 {
|
||||
for _, id := range ids {
|
||||
enabled[id] = slices.Clone(p.channels)
|
||||
}
|
||||
}
|
||||
return enabled, nil
|
||||
}
|
||||
|
||||
func TestNotifyNewReplyChecksParticipantAccess(t *testing.T) {
|
||||
db := testutil.NewDB(t, "reply_access")
|
||||
var agentID, inboxID int
|
||||
if err := db.Get(&agentID, `INSERT INTO users (type, email, first_name, last_name) VALUES ('agent', 'participant@example.com', 'Agent', '') RETURNING id`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Get(&inboxID, `INSERT INTO inboxes (name, channel) VALUES ('Test', 'email') RETURNING id`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var conv models.Conversation
|
||||
if err := db.Get(&conv, `INSERT INTO conversations (contact_id, inbox_id, status_id) VALUES ($1, $2, (SELECT id FROM conversation_statuses LIMIT 1)) RETURNING id, uuid`, agentID, inboxID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.MustExec(`INSERT INTO conversation_participants (conversation_id, user_id) VALUES ($1, $2)`, conv.ID, agentID)
|
||||
var q struct {
|
||||
Participants *sqlx.Stmt `query:"get-conversation-participant-agents"`
|
||||
}
|
||||
if err := dbutil.ScanSQLFile("queries.sql", &q, db, efs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lo := logf.New(logf.Opts{})
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
permissions []string
|
||||
enabled bool
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{"former assignee", []string{authzmodels.PermConversationsRead, authzmodels.PermConversationsReadAssigned}, true, nil, false},
|
||||
{"read all", []string{authzmodels.PermConversationsRead, authzmodels.PermConversationsReadAll}, true, nil, true},
|
||||
{"missing base permission", []string{authzmodels.PermConversationsReadAll}, true, nil, false},
|
||||
{"disabled", []string{authzmodels.PermConversationsRead, authzmodels.PermConversationsReadAll}, false, nil, false},
|
||||
{"load failed", nil, true, errors.New("unavailable"), false},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
m := &Manager{
|
||||
lo: &lo,
|
||||
userStore: replyUserStore{
|
||||
agent: umodels.User{ID: agentID, Enabled: tt.enabled, Permissions: tt.permissions},
|
||||
err: tt.err,
|
||||
},
|
||||
}
|
||||
m.q.GetConversationParticipantAgents = q.Participants
|
||||
conv.AssignedUserID = null.IntFrom(agentID + 1)
|
||||
recipients := m.replyNotificationParticipants(conv, agentID+1)
|
||||
if got := slices.ContainsFunc(recipients, func(recipient umodels.User) bool { return recipient.ID == agentID }); got != tt.want {
|
||||
t.Fatalf("participant eligible = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyNewReplyChecksAssigneeAccess(t *testing.T) {
|
||||
lo := logf.New(logf.Opts{})
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
enabled bool
|
||||
permissions []string
|
||||
want bool
|
||||
}{
|
||||
{"assigned access", true, []string{authzmodels.PermConversationsRead, authzmodels.PermConversationsReadAssigned}, true},
|
||||
{"disabled", false, []string{authzmodels.PermConversationsRead, authzmodels.PermConversationsReadAssigned}, false},
|
||||
{"missing base permission", true, []string{authzmodels.PermConversationsReadAssigned}, false},
|
||||
{"missing assignment access", true, []string{authzmodels.PermConversationsRead}, false},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
m := &Manager{lo: &lo, userStore: replyUserStore{agent: umodels.User{ID: 42, Enabled: tt.enabled, Permissions: tt.permissions}}}
|
||||
recipients := m.replyNotificationAssignee(models.Conversation{AssignedUserID: null.IntFrom(42)}, 7)
|
||||
if got := slices.ContainsFunc(recipients, func(recipient umodels.User) bool { return recipient.ID == 42 }); got != tt.want {
|
||||
t.Fatalf("assignee eligible = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplyNotificationsAlertForEveryMessage(t *testing.T) {
|
||||
db := testutil.NewDB(t, "reply_notification_history")
|
||||
var userID, inboxID int
|
||||
if err := db.Get(&userID, `INSERT INTO users (type, email, first_name, last_name) VALUES ('agent', 'history@example.com', 'Agent', '') RETURNING id`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Get(&inboxID, `INSERT INTO inboxes (name, channel) VALUES ('Test', 'email') RETURNING id`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var conv models.Conversation
|
||||
if err := db.Get(&conv, `INSERT INTO conversations (contact_id, inbox_id, status_id) VALUES ($1, $2, (SELECT id FROM conversation_statuses LIMIT 1)) RETURNING id, uuid`, userID, inboxID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var messageID int
|
||||
if err := db.Get(&messageID, `INSERT INTO conversation_messages (conversation_id, sender_id, sender_type, type, status, content, text_content, created_at) VALUES ($1, $2, 'contact', 'incoming', 'received', 'Old reply', 'Old reply', $3) RETURNING id`, conv.ID, userID, time.Now().Add(-time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var q struct {
|
||||
Participants *sqlx.Stmt `query:"get-conversation-participant-agents"`
|
||||
}
|
||||
if err := dbutil.ScanSQLFile("queries.sql", &q, db, efs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lo := logf.New(logf.Opts{})
|
||||
i18n := testutil.NewI18n(t)
|
||||
inApp, err := notifier.NewUserNotificationManager(notifier.UserNotificationOpts{DB: db, Lo: &lo, I18n: i18n})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
emailQueue, err := notifier.NewEmailQueue(notifier.EmailQueueOpts{DB: db, Lo: &lo})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
templates, err := template.New(&lo, db, nil, nil, htmltemplate.FuncMap{"RootURL": func() string { return "http://localhost" }}, i18n)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conv.AssignedUserID = null.IntFrom(userID)
|
||||
|
||||
newManager := func(channels ...nmodels.NotificationChannel) *Manager {
|
||||
db.MustExec(`DELETE FROM notification_email_queue`)
|
||||
db.MustExec(`DELETE FROM user_notifications`)
|
||||
m := &Manager{lo: &lo, i18n: i18n, template: templates,
|
||||
userStore: replyUserStore{agent: umodels.User{ID: userID, Email: null.StringFrom("history@example.com"), Enabled: true, Permissions: []string{authzmodels.PermConversationsRead, authzmodels.PermConversationsReadAssigned}}},
|
||||
dispatcher: notifier.NewDispatcher(notifier.DispatcherOpts{
|
||||
Pipeline: nchannels.NewPipeline(nchannels.NewInApp(inApp, nil, &lo), nchannels.NewEmail(emailQueue)),
|
||||
Prefs: &replyPreferences{channels: channels},
|
||||
}),
|
||||
}
|
||||
m.q.GetConversationParticipantAgents = q.Participants
|
||||
return m
|
||||
}
|
||||
notifications := func() int {
|
||||
var count int
|
||||
db.Get(&count, `SELECT count(*) FROM user_notifications`)
|
||||
return count
|
||||
}
|
||||
message := models.Message{ID: messageID, SenderID: userID + 1, CreatedAt: time.Now().UTC().Truncate(time.Microsecond)}
|
||||
|
||||
t.Run("each reply creates an in-app alert", func(t *testing.T) {
|
||||
m := newManager(nmodels.NotificationChannelInApp)
|
||||
|
||||
m.NotifyNewReply(conv, message, false)
|
||||
if notifications() != 1 {
|
||||
t.Fatalf("notifications after first reply = %d, want 1", notifications())
|
||||
}
|
||||
|
||||
next := message
|
||||
next.CreatedAt = message.CreatedAt.Add(time.Second)
|
||||
m.NotifyNewReply(conv, next, false)
|
||||
if notifications() != 2 {
|
||||
t.Fatalf("notifications after second reply = %d, want 2", notifications())
|
||||
}
|
||||
|
||||
m.NotifyNewReply(conv, next, true)
|
||||
if notifications() != 3 {
|
||||
t.Fatalf("notifications after reopened reply = %d, want 3", notifications())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reply notifications remove quoted text", func(t *testing.T) {
|
||||
m := newManager(nmodels.NotificationChannelInApp, nmodels.NotificationChannelEmail)
|
||||
replyConversation := conv
|
||||
replyConversation.Subject = null.StringFrom("Conversation subject")
|
||||
reply := message
|
||||
reply.ContentType = models.ContentTypeHTML
|
||||
reply.Content = `<div>Live chat reply</div><div class="gmail_quote gmail_quote_container"><blockquote>Earlier message</blockquote></div>`
|
||||
reply.TextContent = "Live chat reply\nEarlier message"
|
||||
m.NotifyNewReply(replyConversation, reply, false)
|
||||
|
||||
var body string
|
||||
db.Get(&body, `SELECT body FROM user_notifications LIMIT 1`)
|
||||
if body != "Live chat reply" {
|
||||
t.Fatalf("notification body = %q, want fresh reply", body)
|
||||
}
|
||||
|
||||
var emailContent string
|
||||
db.Get(&emailContent, `SELECT content FROM notification_email_queue LIMIT 1`)
|
||||
if !strings.Contains(emailContent, "Live chat reply") || strings.Contains(emailContent, "Earlier message") {
|
||||
t.Fatalf("queued email contains quoted history: %q", emailContent)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("assignment body falls back to last message", func(t *testing.T) {
|
||||
m := newManager(nmodels.NotificationChannelInApp)
|
||||
assigned := conv
|
||||
assigned.LastMessage = null.StringFrom("Live chat opening message")
|
||||
if err := m.NotifyAssignment([]int{userID}, assigned); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var body string
|
||||
db.Get(&body, `SELECT body FROM user_notifications LIMIT 1`)
|
||||
if body != assigned.LastMessage.String {
|
||||
t.Fatalf("notification body = %q, want %q", body, assigned.LastMessage.String)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("replies arriving together alert individually", func(t *testing.T) {
|
||||
m := newManager(nmodels.NotificationChannelInApp)
|
||||
var wg sync.WaitGroup
|
||||
for range 8 {
|
||||
wg.Go(func() { m.NotifyNewReply(conv, message, false) })
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if notifications() != 8 {
|
||||
t.Fatalf("notifications from concurrent replies = %d, want 8", notifications())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("push alerts for every reply", func(t *testing.T) {
|
||||
m := newManager(nmodels.NotificationChannelPush)
|
||||
push := &countingPush{}
|
||||
m.dispatcher = notifier.NewDispatcher(notifier.DispatcherOpts{
|
||||
Pipeline: nchannels.NewPipeline(nchannels.NewInApp(inApp, nil, &lo), nchannels.NewEmail(emailQueue), nchannels.NewPush(push)),
|
||||
Prefs: &replyPreferences{channels: []nmodels.NotificationChannel{nmodels.NotificationChannelPush}},
|
||||
})
|
||||
m.NotifyNewReply(conv, message, false)
|
||||
m.NotifyNewReply(conv, message, false)
|
||||
if push.count != 2 {
|
||||
t.Fatalf("push notifications = %d, want 2", push.count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("agent with no channel enabled is not notified", func(t *testing.T) {
|
||||
m := newManager()
|
||||
m.NotifyNewReply(conv, message, false)
|
||||
if notifications() != 0 {
|
||||
t.Fatalf("notifications = %d, want 0", notifications())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preference failure sends nothing", func(t *testing.T) {
|
||||
m := newManager()
|
||||
m.dispatcher = notifier.NewDispatcher(notifier.DispatcherOpts{Prefs: &replyPreferences{err: errors.New("lookup failed")}})
|
||||
m.NotifyNewReply(conv, message, false)
|
||||
if notifications() != 0 {
|
||||
t.Fatalf("notifications = %d, want 0", notifications())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("email replies are clubbed in the queue", func(t *testing.T) {
|
||||
m := newManager(nmodels.NotificationChannelEmail)
|
||||
m.NotifyNewReply(conv, message, false)
|
||||
next := message
|
||||
next.CreatedAt = message.CreatedAt.Add(time.Minute)
|
||||
m.NotifyNewReply(conv, next, false)
|
||||
|
||||
var queued int
|
||||
db.Get(&queued, `SELECT count(*) FROM notification_email_queue`)
|
||||
if queued != 1 {
|
||||
t.Fatalf("queued emails = %d, want 1", queued)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -535,6 +535,16 @@ WHERE conversation_id =
|
||||
SELECT id FROM conversations WHERE uuid = $1
|
||||
);
|
||||
|
||||
-- name: get-conversation-participant-agents
|
||||
SELECT users.id, users.first_name, users.last_name, users.email
|
||||
FROM conversation_participants
|
||||
INNER JOIN users ON users.id = conversation_participants.user_id
|
||||
WHERE conversation_participants.conversation_id = (SELECT id FROM conversations WHERE uuid = $1)
|
||||
AND users.type = 'agent'
|
||||
AND users.email != 'System'
|
||||
AND users.enabled
|
||||
AND users.deleted_at IS NULL;
|
||||
|
||||
-- name: insert-conversation-participant
|
||||
INSERT INTO conversation_participants
|
||||
(user_id, conversation_id)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,206 @@ import (
|
||||
"github.com/knadh/stuffbin"
|
||||
)
|
||||
|
||||
var notificationTypesV2_9_0 = []string{
|
||||
"new_reply",
|
||||
"new_reply_participating",
|
||||
"sla_first_response_warning",
|
||||
"sla_first_response_breach",
|
||||
"sla_next_response_warning",
|
||||
"sla_next_response_breach",
|
||||
"sla_resolution_warning",
|
||||
"sla_resolution_breach",
|
||||
"conversation_reopened",
|
||||
"automation",
|
||||
}
|
||||
|
||||
func V2_9_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
|
||||
_, err := db.Exec(`
|
||||
if _, err := db.Exec(`
|
||||
UPDATE roles
|
||||
SET permissions = array_append(permissions, 'messages:write_private')
|
||||
WHERE 'messages:write' = ANY(permissions)
|
||||
AND NOT ('messages:write_private' = ANY(permissions));
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, notificationType := range notificationTypesV2_9_0 {
|
||||
if _, err := db.Exec(`ALTER TYPE user_notification_type ADD VALUE IF NOT EXISTS '` + notificationType + `';`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'notification_channel') THEN
|
||||
CREATE TYPE notification_channel AS ENUM ('in_app', 'email', 'push');
|
||||
END IF;
|
||||
END$$;
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(`ALTER TYPE notification_channel ADD VALUE IF NOT EXISTS 'push';`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_notification_preferences (
|
||||
id SERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
|
||||
notification_type user_notification_type NOT NULL,
|
||||
channel notification_channel NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
CONSTRAINT constraint_uniq_user_notification_preferences UNIQUE (user_id, notification_type, channel)
|
||||
);
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS notification_push_subscriptions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL
|
||||
);
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(`
|
||||
CREATE INDEX IF NOT EXISTS index_notification_push_subscriptions_on_user_id ON notification_push_subscriptions(user_id);
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS notification_email_queue (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
|
||||
notification_id BIGINT REFERENCES user_notifications(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
notification_type user_notification_type NOT NULL,
|
||||
conversation_id BIGINT REFERENCES conversations(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
recipient_email TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
queued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
send_at TIMESTAMPTZ NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
CONSTRAINT constraint_uniq_notification_email_queue UNIQUE (user_id, notification_type, conversation_id)
|
||||
);
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(`
|
||||
CREATE INDEX IF NOT EXISTS index_notification_email_queue_on_send_at ON notification_email_queue(send_at);
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(`
|
||||
ALTER TABLE notification_email_queue ADD COLUMN IF NOT EXISTS attempts INTEGER NOT NULL DEFAULT 0;
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO settings ("key", value) VALUES
|
||||
('notification.push.vapid_public_key', '""'::jsonb),
|
||||
('notification.push.vapid_private_key', '""'::jsonb)
|
||||
ON CONFLICT ("key") DO NOTHING;
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO templates ("type", body, is_default, "name", subject, is_builtin)
|
||||
SELECT 'email_notification'::template_type, '
|
||||
<p>{{ .Author.FullName }} replied to a conversation assigned to you:</p>
|
||||
|
||||
<div>
|
||||
Reference number: {{ .Conversation.ReferenceNumber }} <br>
|
||||
Subject: {{ .Conversation.Subject }}
|
||||
</div>
|
||||
|
||||
<blockquote style="background-color: #f5f5f5; padding: 12px; margin: 16px 0; border-left: 4px solid #ddd;">
|
||||
{{ .Message.Content }}
|
||||
</blockquote>
|
||||
|
||||
<p>
|
||||
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
|
||||
</p>
|
||||
|
||||
<div>
|
||||
Best regards,<br>
|
||||
Libredesk
|
||||
</div>
|
||||
|
||||
', false, 'New reply from contact', 'New reply on conversation #{{ .Conversation.ReferenceNumber }}', true
|
||||
WHERE NOT EXISTS (SELECT 1 FROM templates WHERE "name" = 'New reply from contact');
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO templates ("type", body, is_default, "name", subject, is_builtin)
|
||||
SELECT 'email_notification'::template_type, '
|
||||
<p>{{ .Author.FullName }} replied to a conversation you are participating in:</p>
|
||||
|
||||
<div>
|
||||
Reference number: {{ .Conversation.ReferenceNumber }} <br>
|
||||
Subject: {{ .Conversation.Subject }}
|
||||
</div>
|
||||
|
||||
<blockquote style="background-color: #f5f5f5; padding: 12px; margin: 16px 0; border-left: 4px solid #ddd;">
|
||||
{{ .Message.Content }}
|
||||
</blockquote>
|
||||
|
||||
<p>
|
||||
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
|
||||
</p>
|
||||
|
||||
<div>
|
||||
Best regards,<br>
|
||||
Libredesk
|
||||
</div>
|
||||
|
||||
', false, 'New reply on participating conversation', 'New reply on conversation #{{ .Conversation.ReferenceNumber }}', true
|
||||
WHERE NOT EXISTS (SELECT 1 FROM templates WHERE "name" = 'New reply on participating conversation');
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO templates ("type", body, is_default, "name", subject, is_builtin)
|
||||
SELECT 'email_notification'::template_type, '
|
||||
<p>{{ .Author.FullName }} replied and reopened a conversation assigned to you:</p>
|
||||
|
||||
<div>
|
||||
Reference number: {{ .Conversation.ReferenceNumber }} <br>
|
||||
Subject: {{ .Conversation.Subject }}
|
||||
</div>
|
||||
|
||||
<blockquote style="background-color: #f5f5f5; padding: 12px; margin: 16px 0; border-left: 4px solid #ddd;">
|
||||
{{ .Message.Content }}
|
||||
</blockquote>
|
||||
|
||||
<p>
|
||||
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
|
||||
</p>
|
||||
|
||||
<div>
|
||||
Best regards,<br>
|
||||
Libredesk
|
||||
</div>
|
||||
|
||||
', false, 'Conversation reopened', 'Conversation #{{ .Conversation.ReferenceNumber }} reopened', true
|
||||
WHERE NOT EXISTS (SELECT 1 FROM templates WHERE "name" = 'Conversation reopened');
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -52,3 +52,107 @@ func TestV2_9_0PrivateNotePermissionMigration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestV2_9_0NotificationMigration(t *testing.T) {
|
||||
db := testutil.NewDB(t, "migration_v2_9_0_notifications")
|
||||
db.MustExec(`
|
||||
DROP TABLE notification_email_queue;
|
||||
DROP TABLE notification_push_subscriptions;
|
||||
DROP TABLE user_notification_preferences;
|
||||
DROP TYPE notification_channel;
|
||||
DELETE FROM settings WHERE "key" IN (
|
||||
'notification.push.vapid_public_key',
|
||||
'notification.push.vapid_private_key'
|
||||
);
|
||||
DELETE FROM templates WHERE "name" IN (
|
||||
'New reply from contact',
|
||||
'New reply on participating conversation',
|
||||
'Conversation reopened'
|
||||
);
|
||||
`)
|
||||
|
||||
for range 2 {
|
||||
if err := V2_9_0(db, nil, nil); err != nil {
|
||||
t.Fatalf("running migration: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, table := range []string{
|
||||
"user_notification_preferences",
|
||||
"notification_push_subscriptions",
|
||||
"notification_email_queue",
|
||||
} {
|
||||
var exists bool
|
||||
if err := db.Get(&exists, `SELECT to_regclass($1) IS NOT NULL`, table); err != nil {
|
||||
t.Fatalf("checking table %q: %v", table, err)
|
||||
}
|
||||
if !exists {
|
||||
t.Errorf("table %q was not created", table)
|
||||
}
|
||||
}
|
||||
|
||||
var pushChannel bool
|
||||
if err := db.Get(&pushChannel, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM pg_enum e
|
||||
JOIN pg_type t ON t.oid = e.enumtypid
|
||||
WHERE t.typname = 'notification_channel' AND e.enumlabel = 'push'
|
||||
)
|
||||
`); err != nil {
|
||||
t.Fatalf("checking push channel: %v", err)
|
||||
}
|
||||
if !pushChannel {
|
||||
t.Error("push notification channel was not created")
|
||||
}
|
||||
|
||||
var settingCount, templateCount int
|
||||
if err := db.Get(&settingCount, `SELECT COUNT(*) FROM settings WHERE "key" LIKE 'notification.push.vapid_%'`); err != nil {
|
||||
t.Fatalf("counting VAPID settings: %v", err)
|
||||
}
|
||||
if settingCount != 2 {
|
||||
t.Errorf("VAPID setting count = %d, want 2", settingCount)
|
||||
}
|
||||
if err := db.Get(&templateCount, `
|
||||
SELECT COUNT(*) FROM templates WHERE "name" IN (
|
||||
'New reply from contact',
|
||||
'New reply on participating conversation',
|
||||
'Conversation reopened'
|
||||
)
|
||||
`); err != nil {
|
||||
t.Fatalf("counting notification templates: %v", err)
|
||||
}
|
||||
if templateCount != 3 {
|
||||
t.Errorf("notification template count = %d, want 3", templateCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV2_9_0PreservesExistingQueuedEmails(t *testing.T) {
|
||||
db := testutil.NewDB(t, "migration_v2_9_0_queue")
|
||||
columnQuery := `SELECT data_type || ':' || is_nullable || ':' || COALESCE(column_default, '') FROM information_schema.columns WHERE table_name = 'notification_email_queue' AND column_name = $1`
|
||||
var expectedAttemptsColumn string
|
||||
if err := db.Get(&expectedAttemptsColumn, columnQuery, "attempts"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.MustExec(`ALTER TABLE notification_email_queue DROP COLUMN attempts`)
|
||||
db.MustExec(`INSERT INTO users (type, email, first_name, last_name) VALUES ('agent', 'queued@example.com', 'Agent', '')`)
|
||||
db.MustExec(`INSERT INTO notification_email_queue (user_id, notification_type, recipient_email, subject, content, send_at) VALUES ((SELECT id FROM users LIMIT 1), 'new_reply', 'queued@example.com', 'Reply', 'Pending reply', now())`)
|
||||
for range 2 {
|
||||
if err := V2_9_0(db, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var actualAttemptsColumn string
|
||||
if err := db.Get(&actualAttemptsColumn, columnQuery, "attempts"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if actualAttemptsColumn != expectedAttemptsColumn {
|
||||
t.Fatalf("attempts column = %q, schema column = %q", actualAttemptsColumn, expectedAttemptsColumn)
|
||||
}
|
||||
var preserved bool
|
||||
if err := db.Get(&preserved, `SELECT content = 'Pending reply' AND attempts = 0 FROM notification_email_queue`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !preserved {
|
||||
t.Fatal("existing queued email changed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package channels
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/notification/models"
|
||||
)
|
||||
|
||||
type EmailQueue interface {
|
||||
Send(models.Email) bool
|
||||
SendAfter(models.Email, time.Duration) bool
|
||||
}
|
||||
|
||||
type email struct {
|
||||
queue EmailQueue
|
||||
}
|
||||
|
||||
func NewEmail(queue EmailQueue) Provider {
|
||||
return &email{queue: queue}
|
||||
}
|
||||
|
||||
func (p *email) Channel() models.NotificationChannel {
|
||||
return models.NotificationChannelEmail
|
||||
}
|
||||
|
||||
func (p *email) Send(delivery Delivery) Result {
|
||||
notification := delivery.Recipient.Email
|
||||
if notification == nil || notification.Recipient == "" {
|
||||
return Result{}
|
||||
}
|
||||
message := models.Email{
|
||||
UserID: delivery.Recipient.UserID,
|
||||
NotificationID: delivery.NotificationID,
|
||||
Type: delivery.Notification.Type,
|
||||
ConversationID: delivery.Notification.ConversationID,
|
||||
Recipient: notification.Recipient,
|
||||
Subject: notification.Subject,
|
||||
Content: notification.Content,
|
||||
}
|
||||
if notification.Delay > 0 {
|
||||
return Result{Sent: p.queue.SendAfter(message, notification.Delay)}
|
||||
}
|
||||
return Result{Sent: p.queue.Send(message)}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package channels
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/notification/models"
|
||||
)
|
||||
|
||||
type emailQueueStub struct {
|
||||
sent []models.Email
|
||||
delayed []models.Email
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
func (q *emailQueueStub) Send(email models.Email) bool {
|
||||
q.sent = append(q.sent, email)
|
||||
return true
|
||||
}
|
||||
|
||||
func (q *emailQueueStub) SendAfter(email models.Email, delay time.Duration) bool {
|
||||
q.delayed = append(q.delayed, email)
|
||||
q.delay = delay
|
||||
return true
|
||||
}
|
||||
|
||||
func TestEmailProviderSkipsMissingRecipient(t *testing.T) {
|
||||
for _, email := range []*models.EmailNotification{nil, {}} {
|
||||
queue := &emailQueueStub{}
|
||||
result := NewEmail(queue).Send(Delivery{Recipient: models.Recipient{UserID: 42, Email: email}})
|
||||
if result.Sent || len(queue.sent) != 0 || len(queue.delayed) != 0 {
|
||||
t.Fatalf("unexpected delivery: result=%#v queue=%#v", result, queue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailProviderSendsImmediately(t *testing.T) {
|
||||
queue := &emailQueueStub{}
|
||||
result := NewEmail(queue).Send(Delivery{
|
||||
Recipient: models.Recipient{UserID: 42, Email: &models.EmailNotification{
|
||||
Recipient: "agent@example.com",
|
||||
Subject: "Assigned",
|
||||
Content: "Conversation assigned",
|
||||
}},
|
||||
Notification: models.Notification{Type: models.NotificationTypeAssignment},
|
||||
})
|
||||
if !result.Sent || len(queue.sent) != 1 || queue.sent[0].Recipient != "agent@example.com" {
|
||||
t.Fatalf("unexpected delivery: result=%#v queue=%#v", result, queue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailProviderSchedulesDelay(t *testing.T) {
|
||||
queue := &emailQueueStub{}
|
||||
result := NewEmail(queue).Send(Delivery{
|
||||
Recipient: models.Recipient{UserID: 42, Email: &models.EmailNotification{
|
||||
Recipient: "agent@example.com",
|
||||
Delay: time.Minute,
|
||||
}},
|
||||
})
|
||||
if !result.Sent || len(queue.delayed) != 1 || queue.delay != time.Minute {
|
||||
t.Fatalf("unexpected delivery: result=%#v queue=%#v", result, queue)
|
||||
}
|
||||
}
|
||||
@@ -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}})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package channels
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/notification/models"
|
||||
)
|
||||
|
||||
type PushSender interface {
|
||||
Send(userID int, payload models.PushPayload) bool
|
||||
}
|
||||
|
||||
type push struct {
|
||||
sender PushSender
|
||||
}
|
||||
|
||||
func NewPush(sender PushSender) Provider {
|
||||
return &push{sender: sender}
|
||||
}
|
||||
|
||||
func (p *push) Channel() models.NotificationChannel {
|
||||
return models.NotificationChannelPush
|
||||
}
|
||||
|
||||
func (p *push) Send(delivery Delivery) Result {
|
||||
n := delivery.Notification
|
||||
sent := p.sender.Send(delivery.Recipient.UserID, models.PushPayload{
|
||||
Title: n.Title,
|
||||
Body: n.Body.String,
|
||||
Tag: fmt.Sprintf("%s_%s", n.Type, n.ConversationUUID),
|
||||
URL: pushRoute(n.Type, n.ConversationUUID, n.MessageUUID),
|
||||
})
|
||||
return Result{Sent: sent}
|
||||
}
|
||||
|
||||
func pushRoute(notificationType models.NotificationType, conversationUUID, messageUUID string) string {
|
||||
list := "assigned"
|
||||
if notificationType == models.NotificationTypeMention {
|
||||
list = "mentioned"
|
||||
}
|
||||
route := fmt.Sprintf("/inboxes/%s/conversation/%s", list, conversationUUID)
|
||||
if messageUUID != "" {
|
||||
route += "?scrollTo=" + messageUUID
|
||||
}
|
||||
return route
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/dbutil"
|
||||
"github.com/abhinavxd/libredesk/internal/notification/models"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/volatiletech/null/v9"
|
||||
"github.com/zerodha/logf"
|
||||
)
|
||||
|
||||
const (
|
||||
emailQueueTick = 15 * time.Second
|
||||
emailQueueBatch = 500
|
||||
emailQueueClaimLease = 5 * time.Minute
|
||||
emailQueueMaxAttempts = 3
|
||||
emailQueueRetryDelay = time.Minute
|
||||
)
|
||||
|
||||
type EmailSender interface {
|
||||
Send(Message) error
|
||||
SendSync(Message) error
|
||||
}
|
||||
|
||||
type emailQueueQueries struct {
|
||||
IsSeen *sqlx.Stmt `query:"is-notification-seen"`
|
||||
Enqueue *sqlx.Stmt `query:"enqueue-notification-email"`
|
||||
Claim *sqlx.Stmt `query:"claim-due-notification-emails"`
|
||||
Delete *sqlx.Stmt `query:"delete-claimed-notification-email"`
|
||||
Retry *sqlx.Stmt `query:"retry-claimed-notification-email"`
|
||||
}
|
||||
|
||||
type queuedEmail struct {
|
||||
ID int64 `db:"id"`
|
||||
ClaimedAt time.Time `db:"updated_at"`
|
||||
UserID int `db:"user_id"`
|
||||
NotificationID null.Int `db:"notification_id"`
|
||||
Type models.NotificationType `db:"notification_type"`
|
||||
ConversationID null.Int `db:"conversation_id"`
|
||||
Attempts int `db:"attempts"`
|
||||
Recipient string `db:"recipient_email"`
|
||||
Subject string `db:"subject"`
|
||||
Content string `db:"content"`
|
||||
QueuedAt time.Time `db:"queued_at"`
|
||||
}
|
||||
|
||||
type EmailQueue struct {
|
||||
q emailQueueQueries
|
||||
outbound EmailSender
|
||||
lo *logf.Logger
|
||||
}
|
||||
|
||||
type EmailQueueOpts struct {
|
||||
DB *sqlx.DB
|
||||
Outbound EmailSender
|
||||
Lo *logf.Logger
|
||||
}
|
||||
|
||||
func NewEmailQueue(opts EmailQueueOpts) (*EmailQueue, error) {
|
||||
var q emailQueueQueries
|
||||
if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, queriesFS); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &EmailQueue{
|
||||
q: q,
|
||||
outbound: opts.Outbound,
|
||||
lo: opts.Lo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (q *EmailQueue) Send(e models.Email) bool {
|
||||
if err := q.outbound.Send(Message{
|
||||
RecipientEmails: []string{e.Recipient},
|
||||
Subject: e.Subject,
|
||||
Content: e.Content,
|
||||
Provider: ProviderEmail,
|
||||
}); err != nil {
|
||||
q.lo.Error("error sending notification email", "user_id", e.UserID, "type", e.Type, "error", err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (q *EmailQueue) SendAfter(e models.Email, delay time.Duration) bool {
|
||||
if _, err := q.q.Enqueue.Exec(e.UserID, e.NotificationID, e.Type, e.ConversationID, e.Recipient,
|
||||
e.Subject, e.Content, time.Now().Add(delay)); err != nil {
|
||||
q.lo.Error("error queueing notification email", "user_id", e.UserID, "type", e.Type, "error", err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (q *EmailQueue) Run(ctx context.Context) {
|
||||
ticker := time.NewTicker(emailQueueTick)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
for _, e := range q.due() {
|
||||
seen, err := q.seen(e)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if seen {
|
||||
q.delete(e)
|
||||
continue
|
||||
}
|
||||
q.deliver(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (q *EmailQueue) due() []queuedEmail {
|
||||
var due []queuedEmail
|
||||
if err := q.q.Claim.Select(&due, emailQueueBatch, time.Now().Add(emailQueueClaimLease)); err != nil {
|
||||
q.lo.Error("error claiming due notification emails", "error", err)
|
||||
return nil
|
||||
}
|
||||
return due
|
||||
}
|
||||
|
||||
func (q *EmailQueue) seen(e queuedEmail) (bool, error) {
|
||||
var seen bool
|
||||
if err := q.q.IsSeen.Get(&seen, e.UserID, e.NotificationID, e.ConversationID, e.QueuedAt); err != nil {
|
||||
q.lo.Error("error checking notification seen state", "user_id", e.UserID, "type", e.Type, "error", err)
|
||||
return false, err
|
||||
}
|
||||
return seen, nil
|
||||
}
|
||||
|
||||
func (q *EmailQueue) deliver(e queuedEmail) bool {
|
||||
if err := q.outbound.SendSync(Message{
|
||||
RecipientEmails: []string{e.Recipient},
|
||||
Subject: e.Subject,
|
||||
Content: e.Content,
|
||||
Provider: ProviderEmail,
|
||||
}); err != nil {
|
||||
q.lo.Error("error delivering notification email", "user_id", e.UserID, "type", e.Type, "error", err)
|
||||
if e.Attempts+1 >= emailQueueMaxAttempts {
|
||||
q.delete(e)
|
||||
} else {
|
||||
q.retry(e)
|
||||
}
|
||||
return false
|
||||
}
|
||||
q.delete(e)
|
||||
return true
|
||||
}
|
||||
|
||||
func (q *EmailQueue) delete(e queuedEmail) bool {
|
||||
result, err := q.q.Delete.Exec(e.ID, e.ClaimedAt)
|
||||
if err != nil {
|
||||
q.lo.Error("error deleting delivered notification email", "user_id", e.UserID, "type", e.Type, "error", err)
|
||||
return false
|
||||
}
|
||||
deleted, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
q.lo.Error("error counting deleted notification emails", "user_id", e.UserID, "type", e.Type, "error", err)
|
||||
return false
|
||||
}
|
||||
return deleted > 0
|
||||
}
|
||||
|
||||
func (q *EmailQueue) retry(e queuedEmail) {
|
||||
if _, err := q.q.Retry.Exec(e.ID, time.Now().Add(emailQueueRetryDelay), e.ClaimedAt); err != nil {
|
||||
q.lo.Error("error scheduling notification email retry", "user_id", e.UserID, "type", e.Type, "error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/notification/channels"
|
||||
"github.com/abhinavxd/libredesk/internal/notification/models"
|
||||
"github.com/abhinavxd/libredesk/internal/testutil"
|
||||
"github.com/volatiletech/null/v9"
|
||||
"github.com/zerodha/logf"
|
||||
)
|
||||
|
||||
type emailDeliveryProvider struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (p *emailDeliveryProvider) Send(Message) error {
|
||||
return p.err
|
||||
}
|
||||
|
||||
func (p *emailDeliveryProvider) Name() string {
|
||||
return ProviderEmail
|
||||
}
|
||||
|
||||
func TestDelayedReplyEmailUsesQueueTime(t *testing.T) {
|
||||
db := testutil.NewDB(t, "reply_email_time")
|
||||
var userID, inboxID, convID int
|
||||
if err := db.Get(&userID, `INSERT INTO users (type, email, first_name, last_name) VALUES ('agent', 'read@example.com', 'Agent', '') RETURNING id`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Get(&inboxID, `INSERT INTO inboxes (name, channel) VALUES ('Test', 'email') RETURNING id`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Get(&convID, `INSERT INTO conversations (contact_id, inbox_id, status_id) VALUES ($1, $2, (SELECT id FROM conversation_statuses LIMIT 1)) RETURNING id`, userID, inboxID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lo := logf.New(logf.Opts{})
|
||||
provider := &emailDeliveryProvider{}
|
||||
outbound := NewService(map[string]Notifier{ProviderEmail: provider}, 1, 1, &lo)
|
||||
queue, err := NewEmailQueue(EmailQueueOpts{DB: db, Outbound: outbound, Lo: &lo})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := NewDispatcher(DispatcherOpts{
|
||||
Pipeline: channels.NewPipeline(channels.NewEmail(queue)),
|
||||
Prefs: fakePreferences{channels: map[int][]models.NotificationChannel{userID: {models.NotificationChannelEmail}}},
|
||||
})
|
||||
n := models.Notification{
|
||||
Type: models.NotificationTypeNewReply,
|
||||
Recipients: []models.Recipient{{
|
||||
UserID: userID,
|
||||
Email: &models.EmailNotification{
|
||||
Recipient: "read@example.com",
|
||||
Subject: "Reply",
|
||||
Content: "First reply",
|
||||
Delay: time.Minute,
|
||||
},
|
||||
}},
|
||||
ConversationID: null.IntFrom(convID),
|
||||
}
|
||||
db.MustExec(`INSERT INTO conversation_last_seen (user_id, conversation_id, last_seen_at) VALUES ($1, $2, now() - interval '1 hour')`, userID, convID)
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
readOffset time.Duration
|
||||
want bool
|
||||
}{
|
||||
{"read before queue", -time.Second, false},
|
||||
{"read at queue", 0, true},
|
||||
{"read after queue", time.Second, true},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
d.Send(n)
|
||||
var queuedAt time.Time
|
||||
if err := db.Get(&queuedAt, `SELECT queued_at FROM notification_email_queue`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.MustExec(`UPDATE conversation_last_seen SET last_seen_at = $1`, queuedAt.Add(tt.readOffset))
|
||||
db.MustExec(`UPDATE notification_email_queue SET send_at = now() - interval '1 second'`)
|
||||
due := queue.due()
|
||||
if len(due) != 1 {
|
||||
t.Fatalf("queued emails = %d, want 1", len(due))
|
||||
}
|
||||
got, err := queue.seen(due[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("seen = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("coalesced reply uses latest message", func(t *testing.T) {
|
||||
db.MustExec(`UPDATE conversation_last_seen SET last_seen_at = now() - interval '1 hour'`)
|
||||
d.Send(n)
|
||||
var firstSendAt time.Time
|
||||
if err := db.Get(&firstSendAt, `SELECT send_at FROM notification_email_queue`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n.Recipients[0].Email.Content = "Second reply"
|
||||
n.Recipients[0].Email.Delay = 2 * time.Minute
|
||||
d.Send(n)
|
||||
var second struct {
|
||||
SendAt time.Time `db:"send_at"`
|
||||
QueuedAt time.Time `db:"queued_at"`
|
||||
}
|
||||
if err := db.Get(&second, `SELECT send_at, queued_at FROM notification_email_queue`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !second.SendAt.After(firstSendAt) {
|
||||
t.Fatalf("coalesced email send time = %v, want after %v", second.SendAt, firstSendAt)
|
||||
}
|
||||
db.MustExec(`UPDATE notification_email_queue SET send_at = now() - interval '1 second'`)
|
||||
due := queue.due()
|
||||
if len(due) != 1 || due[0].Content != "Second reply" {
|
||||
t.Fatalf("unexpected coalesced emails: %#v", due)
|
||||
}
|
||||
db.MustExec(`UPDATE conversation_last_seen SET last_seen_at = $1`, second.QueuedAt.Add(-time.Second))
|
||||
seen, err := queue.seen(due[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seen {
|
||||
t.Fatal("unread second reply suppressed")
|
||||
}
|
||||
db.MustExec(`UPDATE conversation_last_seen SET last_seen_at = $1`, second.QueuedAt)
|
||||
seen, err = queue.seen(due[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !seen {
|
||||
t.Fatal("read second reply was not suppressed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDelayedEmailSeenCheckReturnsDatabaseError(t *testing.T) {
|
||||
db := testutil.NewDB(t, "notification_email_seen_error")
|
||||
lo := logf.New(logf.Opts{})
|
||||
queue, err := NewEmailQueue(EmailQueueOpts{DB: db, Lo: &lo})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.MustExec(`DROP TABLE conversation_last_seen`)
|
||||
|
||||
if _, err := queue.seen(queuedEmail{}); err == nil {
|
||||
t.Fatal("seen-state database error was ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelayedEmailRetriesThenGivesUp(t *testing.T) {
|
||||
db := testutil.NewDB(t, "reply_email_recipients")
|
||||
var userID, inboxID, convID int
|
||||
if err := db.Get(&userID, `INSERT INTO users (type, email, first_name, last_name) VALUES ('agent', 'queued@example.com', 'Agent', '') RETURNING id`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Get(&inboxID, `INSERT INTO inboxes (name, channel) VALUES ('Test', 'email') RETURNING id`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Get(&convID, `INSERT INTO conversations (contact_id, inbox_id, status_id) VALUES ($1, $2, (SELECT id FROM conversation_statuses LIMIT 1)) RETURNING id`, userID, inboxID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lo := logf.New(logf.Opts{})
|
||||
provider := &emailDeliveryProvider{}
|
||||
outbound := NewService(map[string]Notifier{ProviderEmail: provider}, 1, 1, &lo)
|
||||
queue, err := NewEmailQueue(EmailQueueOpts{DB: db, Outbound: outbound, Lo: &lo})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := NewDispatcher(DispatcherOpts{
|
||||
Pipeline: channels.NewPipeline(channels.NewEmail(queue)),
|
||||
Prefs: fakePreferences{channels: map[int][]models.NotificationChannel{userID: {models.NotificationChannelEmail}}},
|
||||
})
|
||||
n := models.Notification{Type: models.NotificationTypeNewReply, Recipients: []models.Recipient{{UserID: userID}}, ConversationID: null.IntFrom(convID)}
|
||||
email := &models.EmailNotification{Recipient: "queued@example.com", Subject: "Reply", Content: "Reply", Delay: time.Minute}
|
||||
send := func(email *models.EmailNotification) {
|
||||
n.Recipients[0].Email = email
|
||||
d.Send(n)
|
||||
}
|
||||
queued := func() int {
|
||||
var count int
|
||||
if err := db.Get(&count, `SELECT COUNT(*) FROM notification_email_queue`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return count
|
||||
}
|
||||
claimOne := func() queuedEmail {
|
||||
t.Helper()
|
||||
db.MustExec(`UPDATE notification_email_queue SET send_at = now() - interval '1 second'`)
|
||||
due := queue.due()
|
||||
if len(due) != 1 {
|
||||
t.Fatalf("queued emails = %d, want 1", len(due))
|
||||
}
|
||||
return due[0]
|
||||
}
|
||||
|
||||
send(nil)
|
||||
if queued() != 0 {
|
||||
t.Fatal("a notification without an email body was queued")
|
||||
}
|
||||
|
||||
send(email)
|
||||
if queued() != 1 {
|
||||
t.Fatal("email was not queued")
|
||||
}
|
||||
if !queue.deliver(claimOne()) {
|
||||
t.Fatal("email was not delivered")
|
||||
}
|
||||
if queued() != 0 {
|
||||
t.Fatalf("queued emails after delivery = %d, want 0", queued())
|
||||
}
|
||||
|
||||
provider.err = errors.New("SMTP unavailable")
|
||||
send(email)
|
||||
if queue.deliver(claimOne()) {
|
||||
t.Fatal("failed send reported as delivered")
|
||||
}
|
||||
if queued() != 1 {
|
||||
t.Fatalf("queued emails after failure = %d, want 1", queued())
|
||||
}
|
||||
for attempt := 2; attempt <= emailQueueMaxAttempts; attempt++ {
|
||||
queue.deliver(claimOne())
|
||||
}
|
||||
if queued() != 0 {
|
||||
t.Fatalf("queued emails after final failure = %d, want 0", queued())
|
||||
}
|
||||
|
||||
send(email)
|
||||
db.MustExec(`UPDATE notification_email_queue SET attempts = $1`, emailQueueMaxAttempts-1)
|
||||
send(email)
|
||||
var attempts int
|
||||
if err := db.Get(&attempts, `SELECT attempts FROM notification_email_queue`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if attempts != 0 {
|
||||
t.Fatalf("attempts after replacement = %d, want 0", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimedEmailRemainsRecoverable(t *testing.T) {
|
||||
db := testutil.NewDB(t, "notification_email_recovery")
|
||||
var userID int
|
||||
if err := db.Get(&userID, `INSERT INTO users (type, email, first_name, last_name) VALUES ('agent', 'recover@example.com', 'Agent', '') RETURNING id`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lo := logf.New(logf.Opts{})
|
||||
provider := &emailDeliveryProvider{}
|
||||
queue, err := NewEmailQueue(EmailQueueOpts{
|
||||
DB: db,
|
||||
Outbound: NewService(map[string]Notifier{ProviderEmail: provider}, 1, 1, &lo),
|
||||
Lo: &lo,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
email := models.Email{
|
||||
UserID: userID,
|
||||
Type: models.NotificationTypeNewReply,
|
||||
Recipient: "recover@example.com",
|
||||
Subject: "Reply",
|
||||
Content: "Pending reply",
|
||||
}
|
||||
if !queue.SendAfter(email, -time.Second) {
|
||||
t.Fatal("queueing email failed")
|
||||
}
|
||||
claimed := queue.due()
|
||||
if len(claimed) != 1 {
|
||||
t.Fatalf("claimed emails = %d, want 1", len(claimed))
|
||||
}
|
||||
var queued int
|
||||
if err := db.Get(&queued, `SELECT COUNT(*) FROM notification_email_queue`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if queued != 1 {
|
||||
t.Fatalf("queued emails after claim = %d, want 1", queued)
|
||||
}
|
||||
if due := queue.due(); len(due) != 0 {
|
||||
t.Fatalf("emails reclaimed before lease expiry = %d, want 0", len(due))
|
||||
}
|
||||
db.MustExec(`UPDATE notification_email_queue SET send_at = now() - interval '1 second'`)
|
||||
if due := queue.due(); len(due) != 1 {
|
||||
t.Fatalf("emails after lease expiry = %d, want 1", len(due))
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/volatiletech/null/v9"
|
||||
@@ -10,13 +11,120 @@ import (
|
||||
// NotificationType represents the type of user notification.
|
||||
type NotificationType string
|
||||
|
||||
// NotificationChannel represents the delivery channel of a notification.
|
||||
type NotificationChannel string
|
||||
|
||||
const (
|
||||
NotificationTypeMention NotificationType = "mention"
|
||||
NotificationTypeAssignment NotificationType = "assignment"
|
||||
NotificationTypeMention NotificationType = "mention"
|
||||
NotificationTypeAssignment NotificationType = "assignment"
|
||||
NotificationTypeNewReply NotificationType = "new_reply"
|
||||
NotificationTypeNewReplyParticipating NotificationType = "new_reply_participating"
|
||||
NotificationTypeConversationReopened NotificationType = "conversation_reopened"
|
||||
NotificationTypeAutomation NotificationType = "automation"
|
||||
NotificationTypeSLAFirstResponseWarn NotificationType = "sla_first_response_warning"
|
||||
NotificationTypeSLAFirstResponseBreach NotificationType = "sla_first_response_breach"
|
||||
NotificationTypeSLANextResponseWarn NotificationType = "sla_next_response_warning"
|
||||
NotificationTypeSLANextResponseBreach NotificationType = "sla_next_response_breach"
|
||||
NotificationTypeSLAResolutionWarn NotificationType = "sla_resolution_warning"
|
||||
NotificationTypeSLAResolutionBreach NotificationType = "sla_resolution_breach"
|
||||
|
||||
NotificationTypeSLAWarning NotificationType = "sla_warning"
|
||||
NotificationTypeSLABreach NotificationType = "sla_breach"
|
||||
|
||||
NotificationChannelInApp NotificationChannel = "in_app"
|
||||
NotificationChannelEmail NotificationChannel = "email"
|
||||
NotificationChannelPush NotificationChannel = "push"
|
||||
)
|
||||
|
||||
// AgentNotificationTypes are the notification types agents can set preferences for.
|
||||
var AgentNotificationTypes = []NotificationType{
|
||||
NotificationTypeAssignment,
|
||||
NotificationTypeMention,
|
||||
NotificationTypeConversationReopened,
|
||||
NotificationTypeNewReply,
|
||||
NotificationTypeNewReplyParticipating,
|
||||
NotificationTypeSLAFirstResponseWarn,
|
||||
NotificationTypeSLAFirstResponseBreach,
|
||||
NotificationTypeSLANextResponseWarn,
|
||||
NotificationTypeSLANextResponseBreach,
|
||||
NotificationTypeSLAResolutionWarn,
|
||||
NotificationTypeSLAResolutionBreach,
|
||||
}
|
||||
|
||||
// NotificationChannels are the delivery channels a preference can target.
|
||||
var NotificationChannels = []NotificationChannel{
|
||||
NotificationChannelInApp,
|
||||
NotificationChannelEmail,
|
||||
NotificationChannelPush,
|
||||
}
|
||||
|
||||
// defaultDisabledTypes are off until an agent opts in. Every other type defaults to on.
|
||||
var defaultDisabledTypes = []NotificationType{
|
||||
NotificationTypeNewReply,
|
||||
NotificationTypeNewReplyParticipating,
|
||||
}
|
||||
|
||||
// NotificationPreference is a per-channel toggle for a notification type.
|
||||
type NotificationPreference struct {
|
||||
NotificationType NotificationType `db:"notification_type" json:"notification_type"`
|
||||
Channel NotificationChannel `db:"channel" json:"channel"`
|
||||
Enabled bool `db:"enabled" json:"enabled"`
|
||||
}
|
||||
|
||||
type Recipient struct {
|
||||
UserID int
|
||||
Email *EmailNotification
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
Type NotificationType
|
||||
Recipients []Recipient
|
||||
Title string
|
||||
Body null.String
|
||||
ConversationID null.Int
|
||||
MessageID null.Int
|
||||
ActorID null.Int
|
||||
Meta json.RawMessage
|
||||
ConversationUUID string
|
||||
MessageUUID string
|
||||
ActorFirstName string
|
||||
ActorLastName string
|
||||
}
|
||||
|
||||
type EmailNotification struct {
|
||||
Recipient string
|
||||
Subject string
|
||||
Content string
|
||||
Delay time.Duration
|
||||
}
|
||||
|
||||
type Email struct {
|
||||
UserID int
|
||||
NotificationID null.Int
|
||||
Type NotificationType
|
||||
ConversationID null.Int
|
||||
Recipient string
|
||||
Subject string
|
||||
Content string
|
||||
}
|
||||
|
||||
type PushPayload struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body,omitempty"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type DeliveryResult struct {
|
||||
RecipientID int
|
||||
Channels []NotificationChannel
|
||||
}
|
||||
|
||||
// DefaultEnabled reports the preference for a type when the agent has no stored row for it.
|
||||
func DefaultEnabled(nType NotificationType) bool {
|
||||
return !slices.Contains(defaultDisabledTypes, nType)
|
||||
}
|
||||
|
||||
// UserNotification represents an in-app notification for a user.
|
||||
type UserNotification struct {
|
||||
ID int `db:"id" json:"id"`
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDefaultEnabled(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
nType NotificationType
|
||||
want bool
|
||||
}{
|
||||
{"assignment", NotificationTypeAssignment, true},
|
||||
{"mention", NotificationTypeMention, true},
|
||||
{"new reply", NotificationTypeNewReply, false},
|
||||
{"new reply participating", NotificationTypeNewReplyParticipating, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := DefaultEnabled(tt.nType); got != tt.want {
|
||||
t.Fatalf("DefaultEnabled(%q) = %v, want %v", tt.nType, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
webpush "github.com/SherClockHolmes/webpush-go"
|
||||
"github.com/abhinavxd/libredesk/internal/dbutil"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
nmodels "github.com/abhinavxd/libredesk/internal/notification/models"
|
||||
"github.com/abhinavxd/libredesk/internal/ssrf"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/jmoiron/sqlx/types"
|
||||
"github.com/knadh/go-i18n"
|
||||
"github.com/zerodha/logf"
|
||||
)
|
||||
|
||||
const (
|
||||
pushPublicKeySetting = "notification.push.vapid_public_key"
|
||||
pushPrivateKeySetting = "notification.push.vapid_private_key"
|
||||
pushTTL = 86400
|
||||
pushTitleRunes = 100
|
||||
pushBodyRunes = 400
|
||||
)
|
||||
|
||||
type pushSender func(context.Context, []byte, PushSubscription, string, string, string) (*http.Response, error)
|
||||
|
||||
type pushDelivery struct {
|
||||
UserID int
|
||||
Payload nmodels.PushPayload
|
||||
}
|
||||
|
||||
type pushSettingStore interface {
|
||||
Get(key string) (types.JSONText, error)
|
||||
Update(settings any) error
|
||||
}
|
||||
|
||||
type PushManager struct {
|
||||
store pushSubscriptionStore
|
||||
lo *logf.Logger
|
||||
i18n *i18n.I18n
|
||||
publicKey string
|
||||
privateKey string
|
||||
subject string
|
||||
queue chan pushDelivery
|
||||
concurrency int
|
||||
sender pushSender
|
||||
}
|
||||
|
||||
type PushManagerOpts struct {
|
||||
DB *sqlx.DB
|
||||
Settings pushSettingStore
|
||||
Lo *logf.Logger
|
||||
I18n *i18n.I18n
|
||||
RootURL string
|
||||
Concurrency int
|
||||
QueueSize int
|
||||
}
|
||||
|
||||
func NewPushManager(opts PushManagerOpts) (*PushManager, error) {
|
||||
var q pushQueries
|
||||
if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, queriesFS); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
privateKey, publicKey, err := loadVAPIDKeys(opts.Settings)
|
||||
if err != nil {
|
||||
opts.Lo.Error("error loading VAPID keys, push notifications are off", "error", err)
|
||||
}
|
||||
m := &PushManager{
|
||||
store: &sqlPushStore{q: q},
|
||||
lo: opts.Lo,
|
||||
i18n: opts.I18n,
|
||||
publicKey: publicKey,
|
||||
privateKey: privateKey,
|
||||
subject: vapidSubject(opts.RootURL),
|
||||
queue: make(chan pushDelivery, opts.QueueSize),
|
||||
concurrency: opts.Concurrency,
|
||||
}
|
||||
pushHTTPClient := newPushHTTPClient(opts.Lo)
|
||||
m.sender = func(ctx context.Context, payload []byte, subscription PushSubscription, subject, publicKey, privateKey string) (*http.Response, error) {
|
||||
return sendWebPush(ctx, payload, subscription, subject, publicKey, privateKey, pushHTTPClient)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *PushManager) PublicKey() string {
|
||||
return m.publicKey
|
||||
}
|
||||
|
||||
func (m *PushManager) Endpoints(userID int) ([]string, error) {
|
||||
subscriptions, err := m.store.List(userID)
|
||||
if err != nil {
|
||||
m.lo.Error("error fetching push subscriptions", "user_id", userID, "error", err)
|
||||
return nil, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
||||
}
|
||||
endpoints := make([]string, 0, len(subscriptions))
|
||||
for _, subscription := range subscriptions {
|
||||
endpoints = append(endpoints, subscription.Endpoint)
|
||||
}
|
||||
return endpoints, nil
|
||||
}
|
||||
|
||||
func (m *PushManager) Upsert(userID int, subscription PushSubscriptionInput) error {
|
||||
if !validPushSubscription(subscription) {
|
||||
return envelope.NewError(envelope.InputError, m.i18n.T("notification.invalidPushSubscription"), nil)
|
||||
}
|
||||
if err := m.store.Upsert(userID, subscription); err != nil {
|
||||
m.lo.Error("error saving push subscription", "user_id", userID, "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *PushManager) Delete(userID int, endpoint string) error {
|
||||
if endpoint == "" {
|
||||
return envelope.NewError(envelope.InputError, m.i18n.T("notification.invalidPushSubscription"), nil)
|
||||
}
|
||||
if err := m.store.Delete(userID, endpoint); err != nil {
|
||||
m.lo.Error("error deleting push subscription", "user_id", userID, "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *PushManager) Send(userID int, payload nmodels.PushPayload) bool {
|
||||
if m.publicKey == "" || m.privateKey == "" {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case m.queue <- pushDelivery{UserID: userID, Payload: payload}:
|
||||
return true
|
||||
default:
|
||||
m.lo.Error("push notification queue is full", "user_id", userID)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (m *PushManager) Run(ctx context.Context) {
|
||||
var wg sync.WaitGroup
|
||||
for range m.concurrency {
|
||||
wg.Go(func() { m.worker(ctx) })
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (m *PushManager) worker(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case delivery := <-m.queue:
|
||||
if err := m.deliver(ctx, delivery); err != nil {
|
||||
m.lo.Error("error sending push notification", "user_id", delivery.UserID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *PushManager) deliver(ctx context.Context, delivery pushDelivery) error {
|
||||
subscriptions, err := m.store.List(delivery.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
delivery.Payload.Title = pushPreview(delivery.Payload.Title, pushTitleRunes)
|
||||
delivery.Payload.Body = pushPreview(delivery.Payload.Body, pushBodyRunes)
|
||||
payload, err := json.Marshal(delivery.Payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, subscription := range subscriptions {
|
||||
resp, err := m.sender(ctx, payload, subscription, m.subject, m.publicKey, m.privateKey)
|
||||
if err != nil {
|
||||
m.lo.Error("push endpoint rejected notification", "subscription_id", subscription.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
if resp == nil {
|
||||
continue
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone {
|
||||
if err := m.store.DeleteByID(subscription.ID); err != nil {
|
||||
m.lo.Error("error deleting expired push subscription", "subscription_id", subscription.ID, "error", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
m.lo.Error("push endpoint returned error", "subscription_id", subscription.ID, "status", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadVAPIDKeys(settings pushSettingStore) (string, string, error) {
|
||||
privateKey, err := readStringSetting(settings, pushPrivateKeySetting)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
publicKey, err := readStringSetting(settings, pushPublicKeySetting)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if privateKey != "" && publicKey != "" {
|
||||
return privateKey, publicKey, nil
|
||||
}
|
||||
privateKey, publicKey, err = webpush.GenerateVAPIDKeys()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := settings.Update(map[string]string{
|
||||
pushPrivateKeySetting: privateKey,
|
||||
pushPublicKeySetting: publicKey,
|
||||
}); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return privateKey, publicKey, nil
|
||||
}
|
||||
|
||||
func readStringSetting(settings pushSettingStore, key string) (string, error) {
|
||||
b, err := settings.Get(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var value string
|
||||
if err := json.Unmarshal(b, &value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func sendWebPush(ctx context.Context, payload []byte, subscription PushSubscription, subject, publicKey, privateKey string, client *http.Client) (*http.Response, error) {
|
||||
return webpush.SendNotificationWithContext(ctx, payload, &webpush.Subscription{
|
||||
Endpoint: subscription.Endpoint,
|
||||
Keys: webpush.Keys{
|
||||
P256dh: subscription.P256DH,
|
||||
Auth: subscription.Auth,
|
||||
},
|
||||
}, &webpush.Options{
|
||||
HTTPClient: client,
|
||||
Subscriber: subject,
|
||||
VAPIDPublicKey: publicKey,
|
||||
VAPIDPrivateKey: privateKey,
|
||||
TTL: pushTTL,
|
||||
})
|
||||
}
|
||||
|
||||
func validPushSubscription(subscription PushSubscriptionInput) bool {
|
||||
endpoint, err := url.Parse(subscription.Endpoint)
|
||||
return err == nil && endpoint.Scheme == "https" && endpoint.Host != "" && subscription.P256DH != "" && subscription.Auth != ""
|
||||
}
|
||||
|
||||
func vapidSubject(rootURL string) string {
|
||||
rootURL = strings.TrimRight(rootURL, "/")
|
||||
if strings.HasPrefix(rootURL, "https://") {
|
||||
return rootURL
|
||||
}
|
||||
return "support@libredesk.io"
|
||||
}
|
||||
|
||||
func newPushHTTPClient(lo *logf.Logger) *http.Client {
|
||||
control := ssrf.NewControl(true, nil, lo)
|
||||
return &http.Client{
|
||||
Transport: ssrf.NewTransport(control, 10*time.Second),
|
||||
Timeout: 10 * time.Second,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||
}
|
||||
}
|
||||
|
||||
func pushPreview(text string, limit int) string {
|
||||
count := 0
|
||||
for i := range text {
|
||||
if count == limit {
|
||||
return text[:i] + "…"
|
||||
}
|
||||
count++
|
||||
}
|
||||
return text
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -46,3 +46,84 @@ DELETE FROM user_notifications WHERE user_id = $1;
|
||||
|
||||
-- name: delete-old-notifications
|
||||
DELETE FROM user_notifications WHERE created_at < NOW() - INTERVAL '30 days';
|
||||
|
||||
-- name: get-notification-preferences
|
||||
SELECT notification_type, channel, enabled
|
||||
FROM user_notification_preferences
|
||||
WHERE user_id = $1;
|
||||
|
||||
-- name: get-notification-preferences-for-type
|
||||
SELECT user_id, channel, enabled
|
||||
FROM user_notification_preferences
|
||||
WHERE user_id = ANY($1::bigint[]) AND notification_type = $2;
|
||||
|
||||
-- name: upsert-notification-preference
|
||||
INSERT INTO user_notification_preferences (user_id, notification_type, channel, enabled)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id, notification_type, channel)
|
||||
DO UPDATE SET enabled = EXCLUDED.enabled, updated_at = now();
|
||||
|
||||
-- name: get-push-subscriptions
|
||||
SELECT id, endpoint, p256dh, auth
|
||||
FROM notification_push_subscriptions
|
||||
WHERE user_id = $1;
|
||||
|
||||
-- name: upsert-push-subscription
|
||||
INSERT INTO notification_push_subscriptions (user_id, endpoint, p256dh, auth)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (endpoint) DO UPDATE SET
|
||||
user_id = EXCLUDED.user_id,
|
||||
p256dh = EXCLUDED.p256dh,
|
||||
auth = EXCLUDED.auth,
|
||||
updated_at = now();
|
||||
|
||||
-- name: delete-push-subscription
|
||||
DELETE FROM notification_push_subscriptions WHERE user_id = $1 AND endpoint = $2;
|
||||
|
||||
-- name: delete-push-subscription-by-id
|
||||
DELETE FROM notification_push_subscriptions WHERE id = $1;
|
||||
|
||||
-- name: is-notification-seen
|
||||
SELECT COALESCE((SELECT is_read FROM user_notifications WHERE id = $2 AND user_id = $1), false)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM conversation_last_seen
|
||||
WHERE conversation_id = $3 AND user_id = $1 AND last_seen_at >= $4
|
||||
);
|
||||
|
||||
-- name: enqueue-notification-email
|
||||
INSERT INTO notification_email_queue
|
||||
(user_id, notification_id, notification_type, conversation_id, recipient_email, subject, content, send_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (user_id, notification_type, conversation_id) DO UPDATE SET
|
||||
notification_id = EXCLUDED.notification_id,
|
||||
recipient_email = EXCLUDED.recipient_email,
|
||||
subject = EXCLUDED.subject,
|
||||
content = EXCLUDED.content,
|
||||
send_at = EXCLUDED.send_at,
|
||||
attempts = 0,
|
||||
queued_at = now(),
|
||||
updated_at = now();
|
||||
|
||||
-- name: claim-due-notification-emails
|
||||
WITH due AS (
|
||||
SELECT id
|
||||
FROM notification_email_queue
|
||||
WHERE send_at <= now()
|
||||
ORDER BY send_at
|
||||
LIMIT $1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
UPDATE notification_email_queue AS q
|
||||
SET send_at = $2, updated_at = now()
|
||||
FROM due
|
||||
WHERE q.id = due.id
|
||||
RETURNING q.id, q.updated_at, q.user_id, q.notification_id, q.notification_type, q.conversation_id, q.attempts,
|
||||
q.recipient_email, q.subject, q.content, q.queued_at;
|
||||
|
||||
-- name: delete-claimed-notification-email
|
||||
DELETE FROM notification_email_queue WHERE id = $1 AND updated_at = $2;
|
||||
|
||||
-- name: retry-claimed-notification-email
|
||||
UPDATE notification_email_queue
|
||||
SET send_at = $2, attempts = attempts + 1, updated_at = now()
|
||||
WHERE id = $1 AND updated_at = $3;
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -264,6 +264,12 @@ SET processed_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: update-notification-recipients
|
||||
UPDATE scheduled_sla_notifications
|
||||
SET recipients = $2,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: insert-next-response-sla-event
|
||||
INSERT INTO sla_events (applied_sla_id, sla_policy_id, type, deadline_at)
|
||||
SELECT $1, $2, 'next_response', $3
|
||||
|
||||
+35
-29
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,11 +11,14 @@ import (
|
||||
|
||||
const (
|
||||
// Built-in templates names stored in the database.
|
||||
TmplConversationAssigned = "Conversation assigned"
|
||||
TmplSLABreachWarning = "SLA breach warning"
|
||||
TmplSLABreached = "SLA breached"
|
||||
TmplMentioned = "Mentioned in conversation"
|
||||
TmplCSATRequest = "CSAT request"
|
||||
TmplConversationAssigned = "Conversation assigned"
|
||||
TmplSLABreachWarning = "SLA breach warning"
|
||||
TmplSLABreached = "SLA breached"
|
||||
TmplMentioned = "Mentioned in conversation"
|
||||
TmplCSATRequest = "CSAT request"
|
||||
TmplNewReply = "New reply from contact"
|
||||
TmplNewReplyParticipating = "New reply on participating conversation"
|
||||
TmplConversationReopened = "Conversation reopened"
|
||||
|
||||
// Built-in templates fetched from memory stored in `static` directory.
|
||||
TmplResetPassword = "reset-password"
|
||||
|
||||
+123
-2
@@ -22,7 +22,8 @@ DROP TYPE IF EXISTS "sla_metric" CASCADE; CREATE TYPE "sla_metric" AS ENUM ('fir
|
||||
DROP TYPE IF EXISTS "sla_notification_type" CASCADE; CREATE TYPE "sla_notification_type" AS ENUM ('warning', 'breach');
|
||||
DROP TYPE IF EXISTS "activity_log_type" CASCADE; CREATE TYPE "activity_log_type" AS ENUM ('agent_login', 'agent_logout', 'agent_away', 'agent_away_reassigned', 'agent_online', 'agent_password_set', 'agent_role_permissions_changed', 'contact_deleted', 'contact_data_exported');
|
||||
DROP TYPE IF EXISTS "macro_visible_when" CASCADE; CREATE TYPE "macro_visible_when" AS ENUM ('replying', 'starting_conversation', 'adding_private_note');
|
||||
DROP TYPE IF EXISTS "user_notification_type" CASCADE; CREATE TYPE "user_notification_type" AS ENUM ('mention', 'assignment', 'sla_warning', 'sla_breach');
|
||||
DROP TYPE IF EXISTS "user_notification_type" CASCADE; CREATE TYPE "user_notification_type" AS ENUM ('mention', 'assignment', 'sla_warning', 'sla_breach', 'new_reply', 'new_reply_participating', 'sla_first_response_warning', 'sla_first_response_breach', 'sla_next_response_warning', 'sla_next_response_breach', 'sla_resolution_warning', 'sla_resolution_breach', 'conversation_reopened', 'automation');
|
||||
DROP TYPE IF EXISTS "notification_channel" CASCADE; CREATE TYPE "notification_channel" AS ENUM ('in_app', 'email', 'push');
|
||||
DROP TYPE IF EXISTS "conversation_status_category" CASCADE; CREATE TYPE "conversation_status_category" AS ENUM ('open', 'waiting', 'resolved');
|
||||
DROP TYPE IF EXISTS "ai_knowledge_type" CASCADE; CREATE TYPE "ai_knowledge_type" AS ENUM ('snippet');
|
||||
DROP TYPE IF EXISTS "webhook_event" CASCADE; CREATE TYPE webhook_event AS ENUM (
|
||||
@@ -953,6 +954,49 @@ CREATE INDEX index_user_notifications_on_user_id_is_read ON user_notifications(u
|
||||
CREATE INDEX index_user_notifications_on_created_at ON user_notifications(created_at);
|
||||
CREATE INDEX index_user_notifications_on_conversation_id ON user_notifications(conversation_id);
|
||||
|
||||
DROP TABLE IF EXISTS user_notification_preferences CASCADE;
|
||||
CREATE TABLE user_notification_preferences (
|
||||
id SERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
|
||||
notification_type user_notification_type NOT NULL,
|
||||
channel notification_channel NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
CONSTRAINT constraint_uniq_user_notification_preferences UNIQUE (user_id, notification_type, channel)
|
||||
);
|
||||
|
||||
DROP TABLE IF EXISTS notification_push_subscriptions CASCADE;
|
||||
CREATE TABLE notification_push_subscriptions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX index_notification_push_subscriptions_on_user_id ON notification_push_subscriptions(user_id);
|
||||
|
||||
DROP TABLE IF EXISTS notification_email_queue CASCADE;
|
||||
CREATE TABLE notification_email_queue (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
|
||||
notification_id BIGINT REFERENCES user_notifications(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
notification_type user_notification_type NOT NULL,
|
||||
conversation_id BIGINT REFERENCES conversations(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
recipient_email TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
queued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
send_at TIMESTAMPTZ NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
CONSTRAINT constraint_uniq_notification_email_queue UNIQUE (user_id, notification_type, conversation_id)
|
||||
);
|
||||
CREATE INDEX index_notification_email_queue_on_send_at ON notification_email_queue(send_at);
|
||||
|
||||
INSERT INTO ai_providers
|
||||
("name", provider, type, config, is_default)
|
||||
VALUES
|
||||
@@ -996,7 +1040,9 @@ VALUES
|
||||
('notification.email.hello_hostname', '""'::jsonb),
|
||||
('notification.email.email_address', '"admin@yourcompany.com"'::jsonb),
|
||||
('notification.email.max_msg_retries', '3'::jsonb),
|
||||
('notification.email.enabled', 'false'::jsonb);
|
||||
('notification.email.enabled', 'false'::jsonb),
|
||||
('notification.push.vapid_public_key', '""'::jsonb),
|
||||
('notification.push.vapid_private_key', '""'::jsonb);
|
||||
|
||||
-- Default conversation priorities
|
||||
INSERT INTO conversation_priorities (name) VALUES
|
||||
@@ -1053,6 +1099,81 @@ VALUES('email_notification'::template_type, '
|
||||
|
||||
', false, 'Conversation assigned', 'New conversation assigned to you', true);
|
||||
|
||||
INSERT INTO templates
|
||||
("type", body, is_default, "name", subject, is_builtin)
|
||||
VALUES('email_notification'::template_type, '
|
||||
<p>{{ .Author.FullName }} replied to a conversation assigned to you:</p>
|
||||
|
||||
<div>
|
||||
Reference number: {{ .Conversation.ReferenceNumber }} <br>
|
||||
Subject: {{ .Conversation.Subject }}
|
||||
</div>
|
||||
|
||||
<blockquote style="background-color: #f5f5f5; padding: 12px; margin: 16px 0; border-left: 4px solid #ddd;">
|
||||
{{ .Message.Content }}
|
||||
</blockquote>
|
||||
|
||||
<p>
|
||||
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
|
||||
</p>
|
||||
|
||||
<div>
|
||||
Best regards,<br>
|
||||
Libredesk
|
||||
</div>
|
||||
|
||||
', false, 'New reply from contact', 'New reply on conversation #{{ .Conversation.ReferenceNumber }}', true);
|
||||
|
||||
INSERT INTO templates
|
||||
("type", body, is_default, "name", subject, is_builtin)
|
||||
VALUES('email_notification'::template_type, '
|
||||
<p>{{ .Author.FullName }} replied to a conversation you are participating in:</p>
|
||||
|
||||
<div>
|
||||
Reference number: {{ .Conversation.ReferenceNumber }} <br>
|
||||
Subject: {{ .Conversation.Subject }}
|
||||
</div>
|
||||
|
||||
<blockquote style="background-color: #f5f5f5; padding: 12px; margin: 16px 0; border-left: 4px solid #ddd;">
|
||||
{{ .Message.Content }}
|
||||
</blockquote>
|
||||
|
||||
<p>
|
||||
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
|
||||
</p>
|
||||
|
||||
<div>
|
||||
Best regards,<br>
|
||||
Libredesk
|
||||
</div>
|
||||
|
||||
', false, 'New reply on participating conversation', 'New reply on conversation #{{ .Conversation.ReferenceNumber }}', true);
|
||||
|
||||
INSERT INTO templates
|
||||
("type", body, is_default, "name", subject, is_builtin)
|
||||
VALUES('email_notification'::template_type, '
|
||||
<p>{{ .Author.FullName }} replied and reopened a conversation assigned to you:</p>
|
||||
|
||||
<div>
|
||||
Reference number: {{ .Conversation.ReferenceNumber }} <br>
|
||||
Subject: {{ .Conversation.Subject }}
|
||||
</div>
|
||||
|
||||
<blockquote style="background-color: #f5f5f5; padding: 12px; margin: 16px 0; border-left: 4px solid #ddd;">
|
||||
{{ .Message.Content }}
|
||||
</blockquote>
|
||||
|
||||
<p>
|
||||
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
|
||||
</p>
|
||||
|
||||
<div>
|
||||
Best regards,<br>
|
||||
Libredesk
|
||||
</div>
|
||||
|
||||
', false, 'Conversation reopened', 'Conversation #{{ .Conversation.ReferenceNumber }} reopened', true);
|
||||
|
||||
INSERT INTO templates
|
||||
("type", body, is_default, "name", subject, is_builtin)
|
||||
VALUES (
|
||||
|
||||
Reference in New Issue
Block a user