diff --git a/cmd/chat.go b/cmd/chat.go index 113a0dd0..91a0eb79 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -283,7 +283,7 @@ func handleChatInit(r *fastglue.Request) error { } // Process post-message hooks for the new conversation and initial message. - if err := app.conversation.ProcessIncomingMessageHooks(conversationUUID, true); err != nil { + if err := app.conversation.ProcessIncomingMessageHooks(message, true); err != nil { app.lo.Error("error processing incoming message hooks for initial message", "conversation_uuid", conversationUUID, "error", err) } diff --git a/cmd/handlers.go b/cmd/handlers.go index 4a31cf9a..bd2fdc1e 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -353,6 +353,10 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { g.PUT("/api/v1/notifications/read-all", auth(handleMarkAllNotificationsAsRead)) g.DELETE("/api/v1/notifications/{id}", auth(handleDeleteNotification)) g.DELETE("/api/v1/notifications", auth(handleDeleteAllNotifications)) + g.GET("/api/v1/notifications/preferences", auth(handleGetNotificationPreferences)) + g.PUT("/api/v1/notifications/preferences", auth(handleUpdateNotificationPreferences)) + g.POST("/api/v1/notifications/push-subscriptions", auth(handleCreatePushSubscription)) + g.DELETE("/api/v1/notifications/push-subscriptions", auth(handleDeletePushSubscription)) // WebSocket. g.GET("/ws", auth(func(r *fastglue.Request) error { @@ -399,6 +403,8 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { g.GET("/assets/{all:*}", serveFrontendStaticFiles) g.GET("/widget/assets/{all:*}", serveWidgetStaticFiles) g.GET("/images/{all:*}", serveFrontendStaticFiles) + g.GET("/manifest.webmanifest", serveManifest) + g.GET("/sw.js", serveServiceWorker) g.GET("/static/public/{all:*}", serveStaticFiles) // Public pages. @@ -518,6 +524,27 @@ func serveFrontendStaticFiles(r *fastglue.Request) error { return nil } +func serveManifest(r *fastglue.Request) error { + return serveMainFrontendFile(r, "manifest.webmanifest", "application/manifest+json", "no-cache") +} + +func serveServiceWorker(r *fastglue.Request) error { + r.RequestCtx.Response.Header.Set("Service-Worker-Allowed", "/") + return serveMainFrontendFile(r, "sw.js", "application/javascript", "no-cache") +} + +func serveMainFrontendFile(r *fastglue.Request, name, contentType, cacheControl string) error { + app := r.Context.(*App) + file, err := app.fs.Get(filepath.Join(frontendDir, name)) + if err != nil { + return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.T("validation.notFoundFile"), nil, envelope.NotFoundError) + } + r.RequestCtx.Response.Header.Set("Content-Type", contentType) + r.RequestCtx.Response.Header.Set("Cache-Control", cacheControl) + r.RequestCtx.SetBody(file.ReadBytes()) + return nil +} + // serveWidgetStaticFiles serves widget static assets from the embedded filesystem. func serveWidgetStaticFiles(r *fastglue.Request) error { app := r.Context.(*App) diff --git a/cmd/init.go b/cmd/init.go index d3f59932..e5f5886c 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -39,6 +39,7 @@ import ( fs "github.com/abhinavxd/libredesk/internal/media/stores/localfs" "github.com/abhinavxd/libredesk/internal/media/stores/s3" notifier "github.com/abhinavxd/libredesk/internal/notification" + notificationchannels "github.com/abhinavxd/libredesk/internal/notification/channels" emailnotifier "github.com/abhinavxd/libredesk/internal/notification/providers/email" "github.com/abhinavxd/libredesk/internal/oidc" "github.com/abhinavxd/libredesk/internal/ratelimit" @@ -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, }) } diff --git a/cmd/main.go b/cmd/main.go index 04778478..687c3e55 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -126,6 +126,8 @@ type App struct { activityLog *activitylog.Manager notifier *notifier.Service userNotification *notifier.UserNotificationManager + notificationPref *notifier.PreferenceManager + pushNotification *notifier.PushManager customAttribute *customAttribute.Manager report *report.Manager webhook *webhook.Manager @@ -249,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) diff --git a/cmd/pwa_test.go b/cmd/pwa_test.go new file mode 100644 index 00000000..65c1c7f9 --- /dev/null +++ b/cmd/pwa_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "strings" + "testing" + + "github.com/knadh/stuffbin" + "github.com/valyala/fasthttp" + "github.com/zerodha/fastglue" +) + +func TestServePWAFiles(t *testing.T) { + fs, err := stuffbin.NewLocalFS("/", + "../frontend/public/manifest.webmanifest:frontend/dist/main/manifest.webmanifest", + "../frontend/public/sw.js:frontend/dist/main/sw.js", + ) + if err != nil { + t.Fatalf("creating test filesystem: %v", err) + } + + tests := []struct { + name string + handler fastglue.FastRequestHandler + contentType string + wantServiceWorkerHdr bool + }{ + {"manifest", serveManifest, "application/manifest+json", false}, + {"service worker", serveServiceWorker, "application/javascript", true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctx := &fasthttp.RequestCtx{} + req := &fastglue.Request{RequestCtx: ctx, Context: &App{fs: fs}} + if err := tc.handler(req); err != nil { + t.Fatalf("serving file: %v", err) + } + if got := string(ctx.Response.Header.ContentType()); got != tc.contentType { + t.Errorf("content type = %q, want %q", got, tc.contentType) + } + if got := string(ctx.Response.Header.Peek("Cache-Control")); got != "no-cache" { + t.Errorf("cache control = %q, want no-cache", got) + } + if tc.wantServiceWorkerHdr && string(ctx.Response.Header.Peek("Service-Worker-Allowed")) != "/" { + t.Error("service worker scope header is missing") + } + if strings.TrimSpace(string(ctx.Response.Body())) == "" { + t.Error("response body is empty") + } + }) + } +} diff --git a/cmd/settings.go b/cmd/settings.go index 6cb48c8f..4f09c9ab 100644 --- a/cmd/settings.go +++ b/cmd/settings.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/abhinavxd/libredesk/internal/envelope" + "github.com/abhinavxd/libredesk/internal/httputil" "github.com/abhinavxd/libredesk/internal/setting/models" "github.com/abhinavxd/libredesk/internal/stringutil" "github.com/valyala/fasthttp" @@ -57,6 +58,9 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error { } // Trim whitespace and trailing slash from root URL. req.RootURL = strings.TrimRight(strings.TrimSpace(req.RootURL), "/") + if !httputil.IsValidHTTPURL(req.RootURL) { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("admin.general.rootURL.valid"), nil, envelope.InputError) + } // Get current language before update. app.Lock() diff --git a/cmd/user_notifications.go b/cmd/user_notifications.go index caadb242..3f39d96b 100644 --- a/cmd/user_notifications.go +++ b/cmd/user_notifications.go @@ -5,6 +5,8 @@ import ( amodels "github.com/abhinavxd/libredesk/internal/auth/models" "github.com/abhinavxd/libredesk/internal/envelope" + notifier "github.com/abhinavxd/libredesk/internal/notification" + nmodels "github.com/abhinavxd/libredesk/internal/notification/models" "github.com/valyala/fasthttp" "github.com/zerodha/fastglue" ) @@ -97,3 +99,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) +} diff --git a/frontend/apps/main/index.html b/frontend/apps/main/index.html index 781f17db..63340480 100644 --- a/frontend/apps/main/index.html +++ b/frontend/apps/main/index.html @@ -5,6 +5,9 @@ + + + @@ -14,4 +17,4 @@ - \ No newline at end of file + diff --git a/frontend/apps/main/src/App.vue b/frontend/apps/main/src/App.vue index e5d80238..b9dfe330 100644 --- a/frontend/apps/main/src/App.vue +++ b/frontend/apps/main/src/App.vue @@ -92,6 +92,7 @@ import { useTagStore } from './stores/tag' import { useCustomAttributeStore } from './stores/customAttributes' import { useIdleDetection } from './composables/useIdleDetection' import { useNotificationStore } from './stores/notification' +import { useAiPromptStore } from '@main/stores/aiPrompt' import { useViewStore } from './stores/view' import { useKeyboardShortcutsDialog } from './composables/useKeyboardShortcutsDialog' import KeyboardShortcutsDialog from './components/KeyboardShortcutsDialog.vue' @@ -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 diff --git a/frontend/apps/main/src/api/index.js b/frontend/apps/main/src/api/index.js index 01e44525..bab5473b 100644 --- a/frontend/apps/main/src/api/index.js +++ b/frontend/apps/main/src/api/index.js @@ -623,6 +623,10 @@ const markNotificationAsRead = (id) => http.put(`/api/v1/notifications/${id}/rea const markAllNotificationsAsRead = () => http.put('/api/v1/notifications/read-all') const deleteNotification = (id) => http.delete(`/api/v1/notifications/${id}`) const deleteAllNotifications = () => http.delete('/api/v1/notifications') +const getNotificationPreferences = () => http.get('/api/v1/notifications/preferences') +const updateNotificationPreferences = (data) => http.put('/api/v1/notifications/preferences', data) +const createPushSubscription = (data) => http.post('/api/v1/notifications/push-subscriptions', data) +const deletePushSubscription = (endpoint) => http.delete('/api/v1/notifications/push-subscriptions', { data: { endpoint } }) export default { login, @@ -861,5 +865,9 @@ export default { markAllNotificationsAsRead, deleteNotification, deleteAllNotifications, + getNotificationPreferences, + updateNotificationPreferences, + createPushSubscription, + deletePushSubscription, getContactPageVisits } diff --git a/frontend/apps/main/src/components/editor/ConversationEditor.vue b/frontend/apps/main/src/components/editor/ConversationEditor.vue index 9b7c7070..a0ae2cf1 100644 --- a/frontend/apps/main/src/components/editor/ConversationEditor.vue +++ b/frontend/apps/main/src/components/editor/ConversationEditor.vue @@ -12,9 +12,9 @@ > @@ -41,7 +41,6 @@ const props = defineProps({ insertContent: String, messageType: String, autoFocus: { type: Boolean, default: true }, - aiPrompts: { type: Array, default: () => [] }, disabled: { type: Boolean, default: false }, enableMentions: { type: Boolean, default: false }, getSuggestions: { type: Function, default: null }, @@ -53,7 +52,7 @@ const props = defineProps({ const emit = defineEmits([ 'send', - 'aiPromptSelected', + 'aiGenerationChange', 'mentionsChanged', 'filesDropped', 'toggleMessageType' @@ -61,8 +60,6 @@ const emit = defineEmits([ const linkDialog = ref(null) -const emitPrompt = (key) => emit('aiPromptSelected', key) - // Suppress the formatting bubble when an image node is selected so it // doesn't fight with the image's own size/remove toolbar. const shouldShowBubble = ({ editor: e, state }) => { diff --git a/frontend/apps/main/src/components/editor/EditorToolbar.vue b/frontend/apps/main/src/components/editor/EditorToolbar.vue index 79fab042..08085f95 100644 --- a/frontend/apps/main/src/components/editor/EditorToolbar.vue +++ b/frontend/apps/main/src/components/editor/EditorToolbar.vue @@ -1,8 +1,14 @@ diff --git a/frontend/apps/main/src/components/editor/suggestionRenderer.js b/frontend/apps/main/src/components/editor/suggestionRenderer.js index e0db90dd..2c9647ae 100644 --- a/frontend/apps/main/src/components/editor/suggestionRenderer.js +++ b/frontend/apps/main/src/components/editor/suggestionRenderer.js @@ -35,7 +35,7 @@ export function createSuggestionRenderer(ListComponent) { if (popup) popup.style.display = 'none' return true } - return component.ref?.onKeyDown(props) + return component?.ref?.onKeyDown(props) ?? false }, onExit: () => { diff --git a/frontend/apps/main/src/components/editor/suggestionRenderer.test.js b/frontend/apps/main/src/components/editor/suggestionRenderer.test.js new file mode 100644 index 00000000..3ba1649b --- /dev/null +++ b/frontend/apps/main/src/components/editor/suggestionRenderer.test.js @@ -0,0 +1,12 @@ +import { describe, expect, it, vi } from 'vitest' +import { createSuggestionRenderer } from './suggestionRenderer' + +vi.mock('@tiptap/vue-3', () => ({ VueRenderer: vi.fn() })) + +describe('createSuggestionRenderer', () => { + it('ignores key events before the suggestion list starts', () => { + const renderer = createSuggestionRenderer({}) + + expect(renderer.onKeyDown({ event: { key: 'Enter' } })).toBe(false) + }) +}) diff --git a/frontend/apps/main/src/components/sidebar/NotificationPanel.vue b/frontend/apps/main/src/components/sidebar/NotificationPanel.vue index 8ea422dd..cbc13e14 100644 --- a/frontend/apps/main/src/components/sidebar/NotificationPanel.vue +++ b/frontend/apps/main/src/components/sidebar/NotificationPanel.vue @@ -62,9 +62,9 @@
@@ -119,6 +119,17 @@
+ +
+ + +
@@ -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) => { diff --git a/frontend/apps/main/src/components/sidebar/SidebarNavUser.vue b/frontend/apps/main/src/components/sidebar/SidebarNavUser.vue index 8f18dc99..717ab1a7 100644 --- a/frontend/apps/main/src/components/sidebar/SidebarNavUser.vue +++ b/frontend/apps/main/src/components/sidebar/SidebarNavUser.vue @@ -130,6 +130,7 @@ import { useUserStore } from '@main/stores/user' import { useIsMobile } from '@shared-ui/composables' import { useRouter } from 'vue-router' import { useKeyboardShortcutsDialog } from '@main/composables/useKeyboardShortcutsDialog' +import { useLogout } from '@main/composables/useLogout' import { useColorMode } from '@vueuse/core' @@ -139,8 +140,5 @@ const userStore = useUserStore() const router = useRouter() const { t } = useI18n() const shortcutsDialog = useKeyboardShortcutsDialog() - -const logout = () => { - window.location.href = '/logout' -} +const logout = useLogout() diff --git a/frontend/apps/main/src/composables/useLogout.js b/frontend/apps/main/src/composables/useLogout.js new file mode 100644 index 00000000..61f7de9a --- /dev/null +++ b/frontend/apps/main/src/composables/useLogout.js @@ -0,0 +1,10 @@ +import { usePushNotifications } from '@main/composables/usePushNotifications' + +const defaultBrowser = typeof window === 'undefined' ? {} : window + +export const createLogout = (pushNotifications, browser = defaultBrowser) => async () => { + await Promise.resolve(pushNotifications.disable()).catch(() => {}) + browser.location.href = '/logout' +} + +export const useLogout = () => createLogout(usePushNotifications()) diff --git a/frontend/apps/main/src/composables/useLogout.test.js b/frontend/apps/main/src/composables/useLogout.test.js new file mode 100644 index 00000000..c229baf9 --- /dev/null +++ b/frontend/apps/main/src/composables/useLogout.test.js @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest' +import { createLogout } from './useLogout' + +describe('createLogout', () => { + it('clears the push association before logging out', async () => { + const pushNotifications = { disable: vi.fn().mockResolvedValue() } + const browser = { location: { href: '' } } + + await createLogout(pushNotifications, browser)() + + expect(pushNotifications.disable).toHaveBeenCalled() + expect(browser.location.href).toBe('/logout') + }) + + it('logs out when clearing the push association fails', async () => { + const pushNotifications = { disable: vi.fn().mockRejectedValue(new Error('offline')) } + const browser = { location: { href: '' } } + + await createLogout(pushNotifications, browser)() + + expect(browser.location.href).toBe('/logout') + }) +}) diff --git a/frontend/apps/main/src/composables/usePushNotifications.js b/frontend/apps/main/src/composables/usePushNotifications.js new file mode 100644 index 00000000..642c9035 --- /dev/null +++ b/frontend/apps/main/src/composables/usePushNotifications.js @@ -0,0 +1,82 @@ +import { ref } from 'vue' +import api from '@main/api' + +const defaultBrowser = typeof window === 'undefined' ? {} : window + +export const urlBase64ToUint8Array = (value) => { + const padding = '='.repeat((4 - value.length % 4) % 4) + const raw = atob((value + padding).replace(/-/g, '+').replace(/_/g, '/')) + return Uint8Array.from(raw, character => character.charCodeAt(0)) +} + +export const createPushNotifications = (apiClient = api, browser = defaultBrowser) => { + const supported = ref(Boolean(browser.Notification && browser.navigator?.serviceWorker && browser.PushManager)) + const permission = ref(browser.Notification?.permission || 'unsupported') + const enabled = ref(false) + let registration = null + + const registerWorker = async () => { + registration ??= await browser.navigator.serviceWorker.register('/sw.js') + return registration + } + + const existingWorker = async () => { + registration ??= await browser.navigator.serviceWorker.getRegistration('/sw.js') + return registration + } + + const save = async (subscription) => { + const value = subscription.toJSON() + await apiClient.createPushSubscription({ + endpoint: value.endpoint, + p256dh: value.keys.p256dh, + auth: value.keys.auth + }) + } + + const refresh = async (vapidPublicKey, 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 diff --git a/frontend/apps/main/src/composables/usePushNotifications.test.js b/frontend/apps/main/src/composables/usePushNotifications.test.js new file mode 100644 index 00000000..827b4761 --- /dev/null +++ b/frontend/apps/main/src/composables/usePushNotifications.test.js @@ -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() + }) +}) diff --git a/frontend/apps/main/src/constants/navIcons.js b/frontend/apps/main/src/constants/navIcons.js index 7535e953..b85cb6fe 100644 --- a/frontend/apps/main/src/constants/navIcons.js +++ b/frontend/apps/main/src/constants/navIcons.js @@ -1,5 +1,6 @@ import { BarChart3, + Bell, Bot, BookOpen, CircleDot, @@ -33,6 +34,7 @@ import { // Icon names referenced by string in navigation.js. export const navIconMap = { BarChart3, + Bell, Bot, BookOpen, CircleDot, diff --git a/frontend/apps/main/src/constants/navigation.js b/frontend/apps/main/src/constants/navigation.js index d91fce40..28a5b581 100644 --- a/frontend/apps/main/src/constants/navigation.js +++ b/frontend/apps/main/src/constants/navigation.js @@ -265,6 +265,12 @@ export const accountNavItems = [ titleKey: 'globals.terms.profile', href: '/account/profile', icon: 'CircleUser' + }, + { + titleKey: 'globals.terms.notification', + href: '/account/notifications', + isTitleKeyPlural: true, + icon: 'Bell' } ] diff --git a/frontend/apps/main/src/features/admin/templates/TemplateForm.vue b/frontend/apps/main/src/features/admin/templates/TemplateForm.vue index e5e4cc8c..1a615f58 100644 --- a/frontend/apps/main/src/features/admin/templates/TemplateForm.vue +++ b/frontend/apps/main/src/features/admin/templates/TemplateForm.vue @@ -54,7 +54,18 @@ - +
+ + +
@@ -78,6 +89,7 @@ import CodeEditor from '@main/components/editor/CodeEditor.vue' import { Checkbox } from '@shared-ui/components/ui/checkbox/index.js' import { Label } from '@shared-ui/components/ui/label/index.js' import { useI18n } from 'vue-i18n' +import { BUILT_IN_TEMPLATE_BODIES } from './defaults.js' const props = defineProps({ initialValues: { @@ -121,6 +133,14 @@ const hideSubject = computed(() => { return isOutgoingTemplate.value || props.initialValues?.name === 'CSAT request' }) +const defaultBody = computed(() => { + return BUILT_IN_TEMPLATE_BODIES[props.initialValues?.name] +}) + +const resetToDefault = () => { + form.setFieldValue('body', defaultBody.value, false) +} + // Watch for changes in initialValues and update the form. watch( () => props.initialValues, diff --git a/frontend/apps/main/src/features/admin/templates/defaults.js b/frontend/apps/main/src/features/admin/templates/defaults.js new file mode 100644 index 00000000..4df4b92d --- /dev/null +++ b/frontend/apps/main/src/features/admin/templates/defaults.js @@ -0,0 +1,164 @@ +export const BUILT_IN_TEMPLATE_BODIES = { + 'Conversation assigned': `

A new conversation has been assigned to you:

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

+ View Conversation +

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

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

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

+ View Conversation +

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

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

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

+ View Conversation +

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

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

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

+ View Conversation +

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

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

+ +

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

+ +

+ View Conversation +

+ +

+ Best regards,
+ Libredesk +

`, + 'SLA breached': `

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

+ +

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

+ +

+ View Conversation +

+ +

+ Best regards,
+ Libredesk +

`, + 'Mentioned in conversation': `

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

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

+View Conversation +

+ +

+Best regards,
+libredesk +

`, + 'CSAT request': `

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

+

+ We would love to hear how it went. +

+

+ How would you rate your experience? +

+ +
+
+ + 😢 + Poor + +
+
+ + 😕 + Fair + +
+
+ + 😊 + Good + +
+
+ + 😃 + Great + +
+
+ + 🤩 + Excellent + +
+
` +} diff --git a/frontend/apps/main/src/features/command/providers/useAccountCommands.js b/frontend/apps/main/src/features/command/providers/useAccountCommands.js index 02b00e60..572aaf0b 100644 --- a/frontend/apps/main/src/features/command/providers/useAccountCommands.js +++ b/frontend/apps/main/src/features/command/providers/useAccountCommands.js @@ -12,7 +12,8 @@ import { } from 'lucide-vue-next' import { useUserStore } from '@main/stores/user' import { useKeyboardShortcutsDialog } from '@main/composables/useKeyboardShortcutsDialog' -import { SECTIONS } from '../sections' +import { useLogout } from '@main/composables/useLogout' +import { SECTIONS } from '@main/features/command/sections' const AWAY_STATUSES = ['away_manual', 'away_and_reassigning'] @@ -21,6 +22,7 @@ export function useAccountCommands() { const mode = useColorMode() const userStore = useUserStore() const shortcutsDialog = useKeyboardShortcutsDialog() + const logout = useLogout() return computed(() => { const status = userStore.user.availability_status @@ -70,9 +72,7 @@ export function useAccountCommands() { keywords: ['sign out'], section: SECTIONS.ACCOUNT, icon: LogOut, - run: () => { - window.location.href = '/logout' - } + run: logout } ] }) diff --git a/frontend/apps/main/src/features/contact/ContactsList.vue b/frontend/apps/main/src/features/contact/ContactsList.vue index 71dba87c..aa300190 100644 --- a/frontend/apps/main/src/features/contact/ContactsList.vue +++ b/frontend/apps/main/src/features/contact/ContactsList.vue @@ -49,10 +49,9 @@ @@ -140,7 +139,7 @@ import { } from '@shared-ui/components/ui/select' import { Input } from '@shared-ui/components/ui/input' import { Button } from '@shared-ui/components/ui/button' -import { ArrowDownWideNarrow, IdCardIcon, UserPlus } from 'lucide-vue-next' +import { ArrowDownWideNarrow, IdCardIcon } from 'lucide-vue-next' import { Popover, PopoverContent, PopoverTrigger } from '@shared-ui/components/ui/popover' import { useDebounceFn } from '@vueuse/core' import { EMITTER_EVENTS } from '@main/constants/emitterEvents.js' diff --git a/frontend/apps/main/src/features/conversation/ReplyBox.vue b/frontend/apps/main/src/features/conversation/ReplyBox.vue index edee9956..5c7acda6 100644 --- a/frontend/apps/main/src/features/conversation/ReplyBox.vue +++ b/frontend/apps/main/src/features/conversation/ReplyBox.vue @@ -55,7 +55,6 @@ v-if="isEditorFullscreen" ref="fullscreenContentRef" :isFullscreen="true" - :aiPrompts="aiPrompts" :isSending="isSending" :isDraftLoading="isDraftLoading" :uploadingFiles="uploadingFiles" @@ -75,7 +74,7 @@ @fileUpload="handleFileUpload" @fileDelete="handleFileDelete" @filesDropped="uploadFiles" - @aiPromptSelected="handleAiPromptSelected" + @aiGenerationChange="isGenerating = $event" :isGenerating="isGenerating" :canSendReply="canSendReply" :canSendPrivateNote="canSendPrivateNote" @@ -117,7 +116,6 @@ aiPromptStore.prompts) const replyBoxContentRef = ref(null) const fullscreenContentRef = ref(null) const activeContentRef = () => @@ -265,8 +260,6 @@ const showMissingTagsWarning = ref(false) const deferredStatus = ref(null) const mentions = ref([]) -aiPromptStore.fetchPrompts() - const runAiGeneration = async (requestFn) => { if (isGenerating.value) return const uuid = currentConversationUUID.value @@ -286,9 +279,6 @@ const runAiGeneration = async (requestFn) => { } } -const handleAiPromptSelected = (key) => - runAiGeneration(() => api.aiCompletion({ prompt_key: key, content: htmlContent.value })) - const handleGenerateReply = () => runAiGeneration((uuid) => api.aiGenerateReply({ conversation_uuid: uuid, instruction: textContent.value }) diff --git a/frontend/apps/main/src/features/conversation/ReplyBoxContent.vue b/frontend/apps/main/src/features/conversation/ReplyBoxContent.vue index b97e132c..f207d2f2 100644 --- a/frontend/apps/main/src/features/conversation/ReplyBoxContent.vue +++ b/frontend/apps/main/src/features/conversation/ReplyBoxContent.vue @@ -7,18 +7,18 @@ :class="{ 'mb-4': !isFullscreen, 'border-b border-border pb-4': isFullscreen }" > - + {{ $t('globals.terms.reply') }} {{ $t('globals.terms.privateNote') }} @@ -91,7 +91,6 @@ v-model:textContent="textContent" :message-type="messageType" :placeholder="isCramped ? t('globals.terms.typeMessage') : t('editor.hint.full')" - :aiPrompts="aiPrompts" :insertContent="insertContent" :autoFocus="true" :disabled="isDraftLoading" @@ -100,7 +99,7 @@ :enableInlineImages="conversationStore.current.inbox_channel === 'email'" :getSuggestions="getSuggestions" :getConversationSuggestions="getConversationSuggestions" - @aiPromptSelected="handleAiPromptSelected" + @aiGenerationChange="emit('aiGenerationChange', $event)" @send="handleSend" @mentionsChanged="handleMentionsChanged" @filesDropped="handleFilesDropped" @@ -146,9 +145,6 @@ const RECIPIENT_INPUT_CLASS = 'flex-grow border-input bg-card px-3 py-2 text-sm shadow-none focus-visible:ring-1 focus-visible:ring-ring' -const TAB_TRIGGER_CLASS = - 'rounded-md px-3 py-1 text-sm transition-colors duration-150 max-md:py-2.5 data-[state=active]:bg-card data-[state=active]:shadow-sm' - import { ref, computed, nextTick, watch } from 'vue' import { useDebounceFn } from '@vueuse/core' import { EMITTER_EVENTS } from '@main/constants/emitterEvents.js' @@ -248,10 +244,6 @@ const props = defineProps({ type: Boolean, default: false }, - aiPrompts: { - type: Array, - required: true - }, isSending: { type: Boolean, required: true @@ -292,7 +284,7 @@ const emit = defineEmits([ 'inlineImageUpload', 'fileDelete', 'filesDropped', - 'aiPromptSelected', + 'aiGenerationChange', 'generateReply' ]) @@ -400,10 +392,6 @@ const handleEmojiSelect = (emoji) => { nextTick(() => (insertContent.value = emoji)) } -const handleAiPromptSelected = (key) => { - emit('aiPromptSelected', key) -} - // Watch and update macro view based on message type this filters our macros. watch( messageType, diff --git a/frontend/apps/main/src/router/index.js b/frontend/apps/main/src/router/index.js index 34eb0ba3..7ea886b3 100644 --- a/frontend/apps/main/src/router/index.js +++ b/frontend/apps/main/src/router/index.js @@ -173,6 +173,13 @@ const routes = [ name: 'profile', component: () => import('@main/views/account/profile/ProfileEditView.vue'), meta: { titleKey: 'account.editProfile' } + }, + { + path: 'notifications', + name: 'account-notifications', + component: () => + import('@main/views/account/notifications/NotificationPreferences.vue'), + meta: { titleKey: 'globals.terms.notification', titleCount: 2 } } ] }, diff --git a/frontend/apps/main/src/stores/aiPrompt.js b/frontend/apps/main/src/stores/aiPrompt.js index b706deee..e6050035 100644 --- a/frontend/apps/main/src/stores/aiPrompt.js +++ b/frontend/apps/main/src/stores/aiPrompt.js @@ -1,34 +1,61 @@ import { ref } from 'vue' import { defineStore } from 'pinia' import { handleHTTPError } from '@shared-ui/utils/http.js' -import { useEmitter } from '../composables/useEmitter' -import { EMITTER_EVENTS } from '../constants/emitterEvents' -import api from '../api' +import { useEmitter } from '@main/composables/useEmitter' +import { EMITTER_EVENTS } from '@main/constants/emitterEvents' +import api from '@main/api' export const useAiPromptStore = defineStore('aiPrompt', () => { - const prompts = ref([]) - const emitter = useEmitter() - let inflight = null - let hasFetched = false - const fetchPrompts = () => { - if (hasFetched) return Promise.resolve() - if (inflight) return inflight - inflight = api.getAiPrompts() - .then(response => { - prompts.value = response?.data?.data || [] - hasFetched = true - }) - .catch(error => { - emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { - variant: 'destructive', - description: handleHTTPError(error).message - }) - }) - .finally(() => { inflight = null }) - return inflight - } - return { - prompts, - fetchPrompts, + const prompts = ref([]) + const emitter = useEmitter() + let inflight = null + let hasFetched = false + + const showError = (error) => { + emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { + variant: 'destructive', + description: handleHTTPError(error).message + }) + } + + const fetchPrompts = () => { + if (hasFetched) return Promise.resolve() + if (inflight) return inflight + + inflight = api + .getAiPrompts() + .then((response) => { + prompts.value = response?.data?.data || [] + hasFetched = true + }) + .catch(showError) + .finally(() => { + inflight = null + }) + + return inflight + } + + const refreshPrompts = async () => { + if (inflight) await inflight + hasFetched = false + return fetchPrompts() + } + + const complete = async (promptKey, content) => { + try { + const response = await api.aiCompletion({ prompt_key: promptKey, content }) + return response?.data?.data ?? '' + } catch (error) { + showError(error) + return null } + } + + return { + prompts, + fetchPrompts, + refreshPrompts, + complete + } }) diff --git a/frontend/apps/main/src/views/account/notifications/NotificationPreferences.test.js b/frontend/apps/main/src/views/account/notifications/NotificationPreferences.test.js new file mode 100644 index 00000000..781df0a5 --- /dev/null +++ b/frontend/apps/main/src/views/account/notifications/NotificationPreferences.test.js @@ -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() + }) +}) diff --git a/frontend/apps/main/src/views/account/notifications/NotificationPreferences.vue b/frontend/apps/main/src/views/account/notifications/NotificationPreferences.vue new file mode 100644 index 00000000..3e21f9aa --- /dev/null +++ b/frontend/apps/main/src/views/account/notifications/NotificationPreferences.vue @@ -0,0 +1,205 @@ + + + diff --git a/frontend/apps/main/src/views/admin/ai/AIEditorPrompts.vue b/frontend/apps/main/src/views/admin/ai/AIEditorPrompts.vue index 5fc717e7..6a952362 100644 --- a/frontend/apps/main/src/views/admin/ai/AIEditorPrompts.vue +++ b/frontend/apps/main/src/views/admin/ai/AIEditorPrompts.vue @@ -37,6 +37,7 @@