merge main into WhatsApp branch

This commit is contained in:
Abhinav Raut
2026-09-17 00:46:54 +05:30
114 changed files with 6019 additions and 741 deletions
+1
View File
@@ -9,6 +9,7 @@ Thanks for your interest in contributing.
- Bug fixes and documentation improvements can go straight to a PR, no prior discussion needed.
- Anything larger should start with an issue. See below for why.
- Keep changes focused on a single concern wherever possible.
- For translations, please contribute through [Crowdin](https://crowdin.com/project/libredesk) instead of opening PRs for translation changes.
## Pull Requests
+4 -3
View File
@@ -4,9 +4,10 @@ The open-source customer support platform you actually own. One inbox for email,
## Near Term
- WhatsApp channel - WIP
- Web / Customer Portal to manage tickets - WIP
- 2FA for password login - WIP
- Telegram channel - TODO
## Mid Term
- Portal to manage tickets - TODO
- Conversation reports - TODO
- Extensive conversation reports - TODO
- Contact merging - TODO
- 2FA for password login - TODO
+64
View File
@@ -45,6 +45,11 @@ type suggestTagsReq struct {
ConversationUUID string `json:"conversation_uuid"`
}
type promptReq struct {
Title string `json:"title"`
Content string `json:"content"`
}
type snippetReq struct {
Title string `json:"title"`
Content string `json:"content"`
@@ -81,6 +86,65 @@ func handleGetAIPrompts(r *fastglue.Request) error {
return r.SendEnvelope(resp)
}
func handleGetAIPrompt(r *fastglue.Request) error {
app := r.Context.(*App)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError)
}
p, err := app.ai.GetPromptByID(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(p)
}
func handleCreateAIPrompt(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req promptReq
)
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
}
p, err := app.ai.CreatePrompt(req.Title, req.Content)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(p)
}
func handleUpdateAIPrompt(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req promptReq
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
}
p, err := app.ai.UpdatePrompt(id, req.Title, req.Content)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(p)
}
func handleDeleteAIPrompt(r *fastglue.Request) error {
app := r.Context.(*App)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError)
}
if err := app.ai.DeletePrompt(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
// handleGetAIConfig returns the sanitized provider config for a type (completion/embedding).
func handleGetAIConfig(r *fastglue.Request) error {
app := r.Context.(*App)
+1 -1
View File
@@ -283,7 +283,7 @@ func handleChatInit(r *fastglue.Request) error {
}
// Process post-message hooks for the new conversation and initial message.
if err := app.conversation.ProcessIncomingMessageHooks(conversationUUID, true); err != nil {
if err := app.conversation.ProcessIncomingMessageHooks(message, true); err != nil {
app.lo.Error("error processing incoming message hooks for initial message", "conversation_uuid", conversationUUID, "error", err)
}
+20
View File
@@ -4,9 +4,12 @@ import (
"encoding/json"
"github.com/abhinavxd/libredesk/internal/envelope"
oidcmodels "github.com/abhinavxd/libredesk/internal/oidc/models"
"github.com/zerodha/fastglue"
)
const localLoginEnabledKey = "auth.local_login_enabled"
// handleGetConfig returns the public configuration needed for app initialization, this includes minimal app settings and enabled SSO providers (without secrets).
func handleGetConfig(r *fastglue.Request) error {
var app = r.Context.(*App)
@@ -58,6 +61,23 @@ func handleGetConfig(r *fastglue.Request) error {
// Add SSO providers to the response
publicSettings["app.sso_providers"] = enabledProviders
publicSettings["auth.local_login_enabled"] = isLocalLoginEnabled(oidcProviders)
return r.SendEnvelope(publicSettings)
}
func localLoginDisabledInConfig() bool {
return ko.Exists(localLoginEnabledKey) && !ko.Bool(localLoginEnabledKey)
}
func isLocalLoginEnabled(providers []oidcmodels.OIDC) bool {
if !localLoginDisabledInConfig() {
return true
}
for _, provider := range providers {
if provider.Enabled {
return false
}
}
return true
}
+41
View File
@@ -0,0 +1,41 @@
package main
import (
"testing"
oidcmodels "github.com/abhinavxd/libredesk/internal/oidc/models"
)
func TestIsLocalLoginEnabled(t *testing.T) {
tests := []struct {
name string
configured *bool
providers []oidcmodels.OIDC
want bool
}{
{name: "defaults to enabled", want: true},
{name: "remains enabled when configured", configured: boolPointer(true), want: true},
{name: "remains enabled without an enabled OIDC provider", configured: boolPointer(false), providers: []oidcmodels.OIDC{{Enabled: false}}, want: true},
{name: "is disabled with an enabled OIDC provider", configured: boolPointer(false), providers: []oidcmodels.OIDC{{Enabled: true}}, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ko.Delete(localLoginEnabledKey)
if tt.configured != nil {
if err := ko.Set(localLoginEnabledKey, *tt.configured); err != nil {
t.Fatalf("set config: %v", err)
}
}
t.Cleanup(func() { ko.Delete(localLoginEnabledKey) })
if got := isLocalLoginEnabled(tt.providers); got != tt.want {
t.Fatalf("isLocalLoginEnabled() = %v, want %v", got, tt.want)
}
})
}
}
func boolPointer(value bool) *bool {
return &value
}
+70 -71
View File
@@ -2,6 +2,7 @@ package main
import (
"fmt"
"mime/multipart"
"path/filepath"
"strconv"
"strings"
@@ -24,6 +25,23 @@ type blockContactReq struct {
Enabled bool `json:"enabled"`
}
func handleCreateContact(r *fastglue.Request) error {
var app = r.Context.(*App)
contact, _, err := contactFromForm(r)
if err != nil {
return sendErrorEnvelope(r, err)
}
if err := app.user.CreateContact(&contact); err != nil {
return sendErrorEnvelope(r, err)
}
created, err := app.user.GetContactOrVisitor(contact.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(created)
}
// handleGetContacts returns a list of contacts from the database.
func handleGetContacts(r *fastglue.Request) error {
var (
@@ -84,75 +102,9 @@ func handleUpdateContact(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
form, err := r.RequestCtx.MultipartForm()
contactToUpdate, form, err := contactFromForm(r)
if err != nil {
app.lo.Error("error parsing form data", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("errors.parsingRequest"), nil, envelope.GeneralError)
}
// Parse form data
firstName := ""
if v, ok := form.Value["first_name"]; ok && len(v) > 0 {
firstName = string(v[0])
}
lastName := ""
if v, ok := form.Value["last_name"]; ok && len(v) > 0 {
lastName = string(v[0])
}
email := ""
if v, ok := form.Value["email"]; ok && len(v) > 0 {
email = strings.TrimSpace(string(v[0]))
}
phoneNumber := ""
if v, ok := form.Value["phone_number"]; ok && len(v) > 0 {
phoneNumber = string(v[0])
}
phoneNumberCountryCode := ""
if v, ok := form.Value["phone_number_country_code"]; ok && len(v) > 0 {
phoneNumberCountryCode = string(v[0])
}
country := ""
if v, ok := form.Value["country"]; ok && len(v) > 0 {
country = string(v[0])
}
avatarURL := ""
if v, ok := form.Value["avatar_url"]; ok && len(v) > 0 {
avatarURL = string(v[0])
}
// Set nulls to empty strings.
if avatarURL == "null" {
avatarURL = ""
}
if phoneNumberCountryCode == "null" {
phoneNumberCountryCode = ""
}
if phoneNumber == "null" {
phoneNumber = ""
}
if country == "null" {
country = ""
}
// Validate mandatory fields.
if email == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "email"), nil, envelope.InputError)
}
if !stringutil.ValidEmail(email) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("validation.invalidEmail"), nil, envelope.InputError)
}
if firstName == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "first_name"), nil, envelope.InputError)
}
contactToUpdate := models.User{
FirstName: firstName,
LastName: lastName,
Email: null.StringFrom(email),
AvatarURL: null.NewString(avatarURL, avatarURL != ""),
PhoneNumber: null.NewString(phoneNumber, phoneNumber != ""),
PhoneNumberCountryCode: null.NewString(phoneNumberCountryCode, phoneNumberCountryCode != ""),
Country: null.NewString(country, country != ""),
return sendErrorEnvelope(r, err)
}
if err := app.user.UpdateContact(id, contactToUpdate); err != nil {
@@ -160,7 +112,7 @@ func handleUpdateContact(r *fastglue.Request) error {
}
// Delete avatar?
if avatarURL == "" && contact.AvatarURL.Valid {
if !contactToUpdate.AvatarURL.Valid && contact.AvatarURL.Valid {
fileName := filepath.Base(contact.AvatarURL.String)
app.media.Delete(fileName)
contact.AvatarURL.Valid = false
@@ -168,8 +120,7 @@ func handleUpdateContact(r *fastglue.Request) error {
}
// Upload avatar?
files, ok := form.File["files"]
if ok && len(files) > 0 {
if files := form.File["files"]; len(files) > 0 {
if err := uploadUserAvatar(r, contact, files); err != nil {
return sendErrorEnvelope(r, err)
}
@@ -366,3 +317,51 @@ func handleBlockContact(r *fastglue.Request) error {
}
return r.SendEnvelope(contact)
}
func contactFromForm(r *fastglue.Request) (models.User, *multipart.Form, error) {
var app = r.Context.(*App)
form, err := r.RequestCtx.MultipartForm()
if err != nil {
app.lo.Error("error parsing form data", "error", err)
return models.User{}, nil, envelope.NewError(envelope.GeneralError, app.i18n.T("errors.parsingRequest"), nil)
}
value := func(key string) string {
v := ""
if vals := form.Value[key]; len(vals) > 0 {
v = strings.TrimSpace(vals[0])
}
// The edit page sends cleared fields as the string "null".
if v == "null" {
return ""
}
return v
}
optional := func(key string) null.String {
v := value(key)
return null.NewString(v, v != "")
}
email := value("email")
if email == "" {
return models.User{}, nil, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "email"), nil)
}
if !stringutil.ValidEmail(email) {
return models.User{}, nil, envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidEmail"), nil)
}
firstName := value("first_name")
if firstName == "" {
return models.User{}, nil, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "first_name"), nil)
}
return models.User{
FirstName: firstName,
LastName: value("last_name"),
Email: null.StringFrom(email),
AvatarURL: optional("avatar_url"),
PhoneNumber: optional("phone_number"),
PhoneNumberCountryCode: optional("phone_number_country_code"),
Country: optional("country"),
}, form, nil
}
+20 -8
View File
@@ -86,9 +86,15 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
}
if err := app.csat.UpdateResponse(uuid, rating, feedback, metaJSON); err != nil {
// Surface only known, user-safe messages; never leak raw driver errors
// (e.g. a malformed UUID triggers a Postgres syntax error string).
msg := app.i18n.T("globals.messages.somethingWentWrong")
if e, ok := err.(envelope.Error); ok {
msg = e.Error()
}
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
"Data": map[string]interface{}{
"ErrorMessage": err.Error(),
"ErrorMessage": msg,
},
})
}
@@ -152,10 +158,7 @@ func handleSubmitCSATResponse(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid UUID", nil, envelope.InputError)
}
// Trim feedback if it exceeds max length.
if len(req.Feedback) > maxCsatFeedbackLength {
req.Feedback = req.Feedback[:maxCsatFeedbackLength]
}
req.Feedback = truncateFeedback(req.Feedback)
// Update CSAT response
if err := app.csat.UpdateResponse(uuid, req.Rating, req.Feedback, nil); err != nil {
@@ -187,9 +190,7 @@ func validateCSATForm(r *fastglue.Request) (int, string, json.RawMessage, string
return 0, "", nil, "csat.pleaseFillRequired"
}
if len(feedback) > maxCsatFeedbackLength {
feedback = feedback[:maxCsatFeedbackLength]
}
feedback = truncateFeedback(feedback)
// Collect extra form fields into meta, skipping the known fields.
meta := make(map[string]string)
@@ -220,3 +221,14 @@ func validateCSATForm(r *fastglue.Request) (int, string, json.RawMessage, string
return rating, feedback, metaJSON, ""
}
func truncateFeedback(s string) string {
if len(s) <= maxCsatFeedbackLength {
return s
}
r := []rune(s)
if len(r) <= maxCsatFeedbackLength {
return s
}
return string(r[:maxCsatFeedbackLength])
}
+32
View File
@@ -152,6 +152,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// Contacts.
g.GET("/api/v1/contacts", perm(handleGetContacts, "contacts:read_all"))
g.POST("/api/v1/contacts", perm(handleCreateContact, "contacts:write"))
g.GET("/api/v1/contacts/{id}", perm(handleGetContact, "contacts:read"))
g.PUT("/api/v1/contacts/{id}", perm(handleUpdateContact, "contacts:write"))
g.PUT("/api/v1/contacts/{id}/block", perm(handleBlockContact, "contacts:block"))
@@ -251,6 +252,10 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// AI completions.
g.GET("/api/v1/ai/prompts", auth(handleGetAIPrompts))
g.GET("/api/v1/ai/prompts/{id}", perm(handleGetAIPrompt, "ai:manage"))
g.POST("/api/v1/ai/prompts", perm(handleCreateAIPrompt, "ai:manage"))
g.PUT("/api/v1/ai/prompts/{id}", perm(handleUpdateAIPrompt, "ai:manage"))
g.DELETE("/api/v1/ai/prompts/{id}", perm(handleDeleteAIPrompt, "ai:manage"))
g.POST("/api/v1/ai/completion", auth(handleAICompletion))
// AI provider config (completion / embedding).
@@ -348,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 {
@@ -405,6 +414,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.
@@ -524,6 +535,27 @@ func serveFrontendStaticFiles(r *fastglue.Request) error {
return nil
}
func serveManifest(r *fastglue.Request) error {
return serveMainFrontendFile(r, "manifest.webmanifest", "application/manifest+json", "no-cache")
}
func serveServiceWorker(r *fastglue.Request) error {
r.RequestCtx.Response.Header.Set("Service-Worker-Allowed", "/")
return serveMainFrontendFile(r, "sw.js", "application/javascript", "no-cache")
}
func serveMainFrontendFile(r *fastglue.Request, name, contentType, cacheControl string) error {
app := r.Context.(*App)
file, err := app.fs.Get(filepath.Join(frontendDir, name))
if err != nil {
return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.T("validation.notFoundFile"), nil, envelope.NotFoundError)
}
r.RequestCtx.Response.Header.Set("Content-Type", contentType)
r.RequestCtx.Response.Header.Set("Cache-Control", cacheControl)
r.RequestCtx.SetBody(file.ReadBytes())
return nil
}
// serveWidgetStaticFiles serves widget static assets from the embedded filesystem.
func serveWidgetStaticFiles(r *fastglue.Request) error {
app := r.Context.(*App)
+52 -6
View File
@@ -40,6 +40,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"
@@ -1242,6 +1243,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{
@@ -1250,14 +1264,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,
})
}
+11
View File
@@ -21,6 +21,17 @@ func handleLogin(r *fastglue.Request) error {
loginReq loginRequest
)
if localLoginDisabledInConfig() {
oidcProviders, err := app.oidc.GetAll()
if err != nil {
return sendErrorEnvelope(r, err)
}
if !isLocalLoginEnabled(oidcProviders) {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("auth.localLoginDisabled"), nil, envelope.PermissionError)
}
app.lo.Warn("local login is disabled in config but no OIDC provider is enabled, allowing password login")
}
// Decode JSON request.
if err := r.Decode(&loginReq, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
+12 -1
View File
@@ -128,6 +128,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
@@ -257,7 +259,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)
@@ -295,6 +300,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)
@@ -341,6 +350,8 @@ func main() {
userNotification: userNotification,
whatsappClient: waClient,
whatsappTemplate: waTemplates,
notificationPref: notificationPreference,
pushNotification: pushNotification,
wsHub: wsHub,
}
app.consts.Store(constants)
+51
View File
@@ -0,0 +1,51 @@
package main
import (
"strings"
"testing"
"github.com/knadh/stuffbin"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
func TestServePWAFiles(t *testing.T) {
fs, err := stuffbin.NewLocalFS("/",
"../frontend/public/manifest.webmanifest:frontend/dist/main/manifest.webmanifest",
"../frontend/public/sw.js:frontend/dist/main/sw.js",
)
if err != nil {
t.Fatalf("creating test filesystem: %v", err)
}
tests := []struct {
name string
handler fastglue.FastRequestHandler
contentType string
wantServiceWorkerHdr bool
}{
{"manifest", serveManifest, "application/manifest+json", false},
{"service worker", serveServiceWorker, "application/javascript", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctx := &fasthttp.RequestCtx{}
req := &fastglue.Request{RequestCtx: ctx, Context: &App{fs: fs}}
if err := tc.handler(req); err != nil {
t.Fatalf("serving file: %v", err)
}
if got := string(ctx.Response.Header.ContentType()); got != tc.contentType {
t.Errorf("content type = %q, want %q", got, tc.contentType)
}
if got := string(ctx.Response.Header.Peek("Cache-Control")); got != "no-cache" {
t.Errorf("cache control = %q, want no-cache", got)
}
if tc.wantServiceWorkerHdr && string(ctx.Response.Header.Peek("Service-Worker-Allowed")) != "/" {
t.Error("service worker scope header is missing")
}
if strings.TrimSpace(string(ctx.Response.Body())) == "" {
t.Error("response body is empty")
}
})
}
}
+4
View File
@@ -6,6 +6,7 @@ import (
"strings"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/httputil"
"github.com/abhinavxd/libredesk/internal/setting/models"
"github.com/abhinavxd/libredesk/internal/stringutil"
"github.com/valyala/fasthttp"
@@ -57,6 +58,9 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error {
}
// Trim whitespace and trailing slash from root URL.
req.RootURL = strings.TrimRight(strings.TrimSpace(req.RootURL), "/")
if !httputil.IsValidHTTPURL(req.RootURL) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("admin.general.rootURL.valid"), nil, envelope.InputError)
}
app.Lock()
oldLang := ko.String("app.lang")
+71
View File
@@ -5,6 +5,8 @@ import (
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/envelope"
notifier "github.com/abhinavxd/libredesk/internal/notification"
nmodels "github.com/abhinavxd/libredesk/internal/notification/models"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
@@ -97,3 +99,72 @@ func handleDeleteAllNotifications(r *fastglue.Request) error {
}
return r.SendEnvelope(true)
}
func handleGetNotificationPreferences(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
prefs, err := app.notificationPref.GetMatrix(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
pushEndpoints, err := app.pushNotification.Endpoints(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(map[string]any{
"preferences": prefs,
"email_enabled": ko.Bool("notification.email.enabled"),
"vapid_public_key": app.pushNotification.PublicKey(),
"push_endpoints": pushEndpoints,
})
}
func handleUpdateNotificationPreferences(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
prefs []nmodels.NotificationPreference
)
if err := r.Decode(&prefs, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
}
if err := app.notificationPref.Update(auser.ID, prefs); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
func handleCreatePushSubscription(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
subscription notifier.PushSubscriptionInput
)
if err := r.Decode(&subscription, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
}
if err := app.pushNotification.Upsert(auser.ID, subscription); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
func handleDeletePushSubscription(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
request struct {
Endpoint string `json:"endpoint"`
}
)
if err := r.Decode(&request, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
}
if err := app.pushNotification.Delete(auser.ID, request.Endpoint); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
+7
View File
@@ -34,6 +34,13 @@ read_buffer_size = 65536
# Keepalive settings.
keepalive_timeout = "10s"
[auth]
# Allow users to sign in with their libredesk email and password.
# Disable this only after configuring and enabling at least one OIDC provider.
# libredesk automatically keeps local login available when no OIDC provider is enabled to prevent lockout.
# If an enabled provider is unavailable or misconfigured, set auth.local_login_enabled = true to restore password login.
local_login_enabled = true
# File upload provider to use, either `fs` or `s3`.
[upload]
provider = "fs"
+4 -1
View File
@@ -5,6 +5,9 @@
<meta charset="UTF-8" />
<meta name="robots" content="noindex, nofollow" />
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
<meta name="theme-color" content="hsl(152 39% 30%)" />
<link rel="manifest" href="/manifest.webmanifest" crossorigin="use-credentials" />
<link rel="apple-touch-icon" href="/images/pwa-icon-192.png" />
<link rel="preload" href="/static/public/static/fonts/Geist-Variable.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/static/public/static/fonts.css">
</head>
@@ -14,4 +17,4 @@
<script type="module" src="/src/main.js"></script>
</body>
</html>
</html>
+12 -1
View File
@@ -92,6 +92,7 @@ import { useTagStore } from './stores/tag'
import { useCustomAttributeStore } from './stores/customAttributes'
import { useIdleDetection } from './composables/useIdleDetection'
import { useNotificationStore } from './stores/notification'
import { useAiPromptStore } from '@main/stores/aiPrompt'
import { useViewStore } from './stores/view'
import { useKeyboardShortcutsDialog } from './composables/useKeyboardShortcutsDialog'
import KeyboardShortcutsDialog from './components/KeyboardShortcutsDialog.vue'
@@ -122,6 +123,7 @@ import NotificationBell from '@main/components/sidebar/NotificationBell.vue'
import PrimaryNavItems from '@main/components/sidebar/PrimaryNavItems.vue'
import { useIsMobile } from '@shared-ui/composables'
import api from '@main/api'
import { usePushNotifications } from '@/composables/usePushNotifications'
const route = useRoute()
const emitter = useEmitter()
@@ -165,6 +167,8 @@ const openCreateConversationDialog = ref(false)
const createConversationContact = ref(null)
const { t } = useI18n()
const notificationStore = useNotificationStore()
const aiPromptStore = useAiPromptStore()
const pushNotifications = usePushNotifications()
// Update browser tab title with unread notification count.
// Watch both unreadCount and route so the prefix is preserved after navigation.
@@ -214,10 +218,17 @@ const initStores = async () => {
inboxStore.fetchInboxes(),
slaStore.fetchSlas(),
tagStore.fetchTags(),
customAttributeStore.fetchCustomAttributes()
customAttributeStore.fetchCustomAttributes(),
aiPromptStore.fetchPrompts(),
refreshPushSubscription()
])
}
const refreshPushSubscription = async () => {
const { data } = await api.getNotificationPreferences()
await pushNotifications.refresh(data.data.vapid_public_key, data.data.push_endpoints)
}
const createView = () => {
view.value = {}
openCreateViewForm.value = true
+23
View File
@@ -204,6 +204,12 @@ const updateRole = (id, data) =>
const deleteRole = (id) => http.delete(`/api/v1/roles/${id}`)
const getContacts = (params) => http.get('/api/v1/contacts', { params })
const getContact = (id) => http.get(`/api/v1/contacts/${id}`)
const createContact = (data) =>
http.post('/api/v1/contacts', data, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
const updateContact = (id, data) =>
http.put(`/api/v1/contacts/${id}`, data, {
headers: {
@@ -470,6 +476,10 @@ const updateSharedView = (id, data) =>
const deleteSharedView = (id) => http.delete(`/api/v1/shared-views/${id}`)
const getAiPrompts = () => http.get('/api/v1/ai/prompts')
const getAIPrompt = (id) => http.get(`/api/v1/ai/prompts/${id}`)
const createAIPrompt = (data) => http.post('/api/v1/ai/prompts', data)
const updateAIPrompt = (id, data) => http.put(`/api/v1/ai/prompts/${id}`, data)
const deleteAIPrompt = (id) => http.delete(`/api/v1/ai/prompts/${id}`)
const aiCompletion = (data) => http.post('/api/v1/ai/completion', data, {
timeout: AI_TIMEOUT,
headers: {
@@ -624,6 +634,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,
@@ -762,6 +776,10 @@ export default {
updateSharedView,
deleteSharedView,
getAiPrompts,
getAIPrompt,
createAIPrompt,
updateAIPrompt,
deleteAIPrompt,
aiCompletion,
getAIConfig,
updateAIConfig,
@@ -824,6 +842,7 @@ export default {
removeAssignee,
getContacts,
getContact,
createContact,
updateContact,
blockContact,
deleteContact,
@@ -862,5 +881,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,17 +41,18 @@ 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 },
enableConversationReferences: { type: Boolean, default: false },
getConversationSuggestions: { type: Function, default: null },
enableInlineImages: { type: Boolean, default: false },
linkedModel: { type: String, default: 'messages' }
})
const emit = defineEmits([
'send',
'aiPromptSelected',
'aiGenerationChange',
'mentionsChanged',
'filesDropped',
'toggleMessageType'
@@ -59,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 }) => {
@@ -91,6 +90,8 @@ const { editor, extractMentions, focus } = useTextEditor({
linkedModel: props.linkedModel,
getSuggestions: props.getSuggestions,
enableMentions: () => props.enableMentions,
getConversationSuggestions: props.getConversationSuggestions,
conversationReferencesEnabled: () => props.enableConversationReferences,
onSend: () => {
emit('send')
stopTyping()
@@ -0,0 +1,77 @@
<template>
<div
v-if="items.length > 0"
class="conversation-reference-list bg-background border rounded-lg shadow-lg overflow-hidden max-h-60 overflow-y-auto"
>
<button
v-for="(item, index) in items"
:key="item.id"
class="conversation-reference-item w-full text-left px-3 py-2 hover:bg-muted"
:class="{ 'bg-muted': index === selectedIndex }"
@click="selectItem(index)"
>
<div class="flex items-center gap-2">
<span class="font-medium">#{{ item.label }}</span>
<span class="text-xs text-muted-foreground">{{ item.status }}</span>
</div>
<div v-if="item.subject" class="text-sm text-muted-foreground truncate">
{{ item.subject }}
</div>
</button>
</div>
<div v-else class="conversation-reference-list bg-background border rounded-lg shadow-lg p-3">
<span v-if="query.length < MIN_REFERENCE_QUERY_LENGTH" class="text-sm text-muted-foreground">
{{
$t('search.minQueryLength', {
length: MIN_REFERENCE_QUERY_LENGTH
})
}}
</span>
<span v-else class="text-sm text-muted-foreground">{{
$t('globals.messages.noResultsFound')
}}</span>
</div>
</template>
<script setup>
import { ref, watch, nextTick } from 'vue'
import { MIN_REFERENCE_QUERY_LENGTH } from './conversationReference'
const props = defineProps({
items: { type: Array, default: () => [] },
command: { type: Function, required: true },
query: { type: String, default: '' }
})
const selectedIndex = ref(0)
const selectItem = (index) => props.items[index] && props.command(props.items[index])
watch(
() => props.items,
() => {
selectedIndex.value = 0
}
)
watch(selectedIndex, () =>
nextTick(() =>
document.querySelector('.conversation-reference-item.bg-muted')?.scrollIntoView({ block: 'nearest' })
)
)
defineExpose({
onKeyDown: ({ event }) => {
if (!props.items.length) return false
if (event.key === 'ArrowUp')
selectedIndex.value = (selectedIndex.value + props.items.length - 1) % props.items.length
else if (event.key === 'ArrowDown')
selectedIndex.value = (selectedIndex.value + 1) % props.items.length
else if (event.key === 'Enter') selectItem(selectedIndex.value)
else return false
return true
}
})
</script>
<style scoped>
.conversation-reference-list {
min-width: 240px;
max-width: 360px;
}
</style>
@@ -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>
@@ -0,0 +1,27 @@
import api from '@main/api'
export const MIN_REFERENCE_QUERY_LENGTH = 3
const SUGGESTION_LIMIT = 10
export const createLatestConversationSuggestionFetcher = (fetchSuggestions) => {
let latestRequestID = 0
return async (query) => {
const requestID = ++latestRequestID
const suggestions = await fetchSuggestions(query)
return requestID === latestRequestID ? suggestions : []
}
}
export const getConversationSuggestions = async (query, search = api.searchConversations) => {
const reference = query.trim()
if (reference.length < MIN_REFERENCE_QUERY_LENGTH) return []
const response = await search({ query: reference })
return (response.data?.data || []).slice(0, SUGGESTION_LIMIT).map((conversation) => ({
id: conversation.uuid,
label: conversation.reference_number,
subject: conversation.subject,
status: conversation.status
}))
}
@@ -0,0 +1,55 @@
import { describe, expect, it, vi } from 'vitest'
import * as conversationReference from './conversationReference'
const { getConversationSuggestions } = conversationReference
describe('conversation reference suggestions', () => {
it('does not search until three characters are entered', async () => {
const search = vi.fn()
await expect(getConversationSuggestions('10', search)).resolves.toEqual([])
expect(search).not.toHaveBeenCalled()
})
it('maps a conversation result to a suggestion', async () => {
const search = vi.fn().mockResolvedValue({
data: {
data: [
{
uuid: 'conversation-uuid',
reference_number: '108',
subject: 'Payment failed',
status: 'Open'
}
]
}
})
await expect(getConversationSuggestions('108', search)).resolves.toEqual([
{ id: 'conversation-uuid', label: '108', subject: 'Payment failed', status: 'Open' }
])
})
it('caps the suggestion list', async () => {
const rows = Array.from({ length: 30 }, (_, i) => ({ uuid: `u${i}`, reference_number: `${i}` }))
const search = vi.fn().mockResolvedValue({ data: { data: rows } })
await expect(getConversationSuggestions('108', search)).resolves.toHaveLength(10)
})
it('discards a response superseded by a newer query', async () => {
let resolveFirst
let resolveSecond
const fetchSuggestions = vi
.fn()
.mockImplementationOnce(() => new Promise((resolve) => (resolveFirst = resolve)))
.mockImplementationOnce(() => new Promise((resolve) => (resolveSecond = resolve)))
const getLatestSuggestions =
conversationReference.createLatestConversationSuggestionFetcher(fetchSuggestions)
const firstRequest = getLatestSuggestions('108')
const secondRequest = getLatestSuggestions('109')
resolveSecond([{ label: '109' }])
await expect(secondRequest).resolves.toEqual([{ label: '109' }])
resolveFirst([{ label: '108' }])
await expect(firstRequest).resolves.toEqual([])
})
})
@@ -0,0 +1,20 @@
import Mention from '@tiptap/extension-mention'
export const conversationReferenceHref = (uuid) =>
`/inboxes/all/conversation/${encodeURIComponent(uuid)}`
export const ConversationReference = Mention.extend({
name: 'conversationReference',
// Link's a[href] mark rule is registered first at the default priority and would swallow the anchor.
parseHTML: () => [{ tag: 'a.ld-conversation-reference', priority: 51 }],
renderText: ({ node }) => `#${node.attrs.label}`,
renderHTML: ({ node, HTMLAttributes }) => [
'a',
{
...HTMLAttributes,
href: conversationReferenceHref(node.attrs.id),
class: [HTMLAttributes.class, 'ld-conversation-reference'].filter(Boolean).join(' ')
},
`#${node.attrs.label}`
]
})
@@ -0,0 +1,39 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { Editor } from '@tiptap/vue-3'
import { buildConversationExtensions } from './editorExtensions'
import { conversationReferenceHref } from './conversationReferenceExtension'
describe('conversation reference editor extension', () => {
it('builds a root-relative conversation link', () => {
expect(conversationReferenceHref('abc')).toBe('/inboxes/all/conversation/abc')
})
it('round-trips a conversation reference through HTML in the conversation editor', () => {
const content =
'<p><a data-id="conversation-uuid" data-label="108" href="/inboxes/all/conversation/conversation-uuid" class="ld-conversation-reference">#108</a></p>'
const editor = new Editor({
extensions: buildConversationExtensions({ getPlaceholder: () => '' }),
content
})
expect(editor.getJSON().content[0].content[0]).toMatchObject({
type: 'conversationReference',
attrs: { id: 'conversation-uuid', label: '108' }
})
expect(editor.getHTML()).toContain('href="/inboxes/all/conversation/conversation-uuid"')
editor.destroy()
})
it('derives the link from the conversation id, ignoring a stored href', () => {
const content =
'<p><a data-id="conversation-uuid" data-label="108" href="https://evil.example/x" class="ld-conversation-reference">#108</a></p>'
const editor = new Editor({
extensions: buildConversationExtensions({ getPlaceholder: () => '' }),
content
})
expect(editor.getHTML()).toContain('href="/inboxes/all/conversation/conversation-uuid"')
expect(editor.getHTML()).not.toContain('evil.example')
editor.destroy()
})
})
@@ -0,0 +1,10 @@
import ConversationReferenceList from './ConversationReferenceList.vue'
import { createSuggestionRenderer } from './suggestionRenderer'
export default {
char: '#',
allowSpaces: false,
allow: ({ editor }) => editor.options.editorProps?.conversationReferencesEnabled?.() ?? false,
items: ({ query, editor }) => editor.options.editorProps?.getConversationSuggestions?.(query) ?? [],
render: () => createSuggestionRenderer(ConversationReferenceList)
}
@@ -17,6 +17,8 @@ import { Callout } from './extensions/Callout'
import { Details, DetailsSummary, DetailsContent } from './extensions/Collapsible'
import { TrailingNode } from './extensions/TrailingNode'
import mentionSuggestion from './mentionSuggestion'
import conversationReferenceSuggestion from './conversationReferenceSuggestion'
import { ConversationReference } from './conversationReferenceExtension'
const lowlight = createLowlight(codeGrammars)
@@ -142,6 +144,7 @@ export function buildConversationExtensions({ getPlaceholder }) {
HTMLAttributes: { class: 'ld-mention' },
suggestion: mentionSuggestion
}),
ConversationReference.configure({ suggestion: conversationReferenceSuggestion }),
CustomTable.configure({ resizable: false }),
TableRow,
CustomTableCell,
@@ -55,7 +55,8 @@
}
}
.ld-mention {
.ld-mention,
.ld-conversation-reference {
background-color: hsl(var(--primary) / 0.1);
border-radius: 0.25rem;
padding: 0 0.25rem;
@@ -1,111 +1,10 @@
import { VueRenderer } from '@tiptap/vue-3'
import MentionList from './MentionList.vue'
import { createSuggestionRenderer } from './suggestionRenderer'
export default {
char: '@',
allowSpaces: true,
allow: ({ editor }) => {
const enableMentions = editor.options.editorProps?.enableMentions
return enableMentions ? enableMentions() : false
},
items: async ({ query, editor }) => {
// Get the suggestion handler from editor options
const getSuggestions = editor.options.editorProps?.getSuggestions
if (!getSuggestions) return []
const items = await getSuggestions(query)
return items
},
render: () => {
let component
let popup
return {
onStart: (props) => {
component = new VueRenderer(MentionList, {
props: {
...props,
query: props.query
},
editor: props.editor
})
if (!props.clientRect) {
return
}
// Create popup container with CSS positioning
popup = document.createElement('div')
popup.style.position = 'fixed'
popup.style.zIndex = '9999'
if (component.element) {
popup.appendChild(component.element)
}
document.body.appendChild(popup)
// Position the popup
updatePosition(popup, props.clientRect)
},
onUpdate: (props) => {
component.updateProps({
...props,
query: props.query
})
if (!props.clientRect || !popup) {
return
}
updatePosition(popup, props.clientRect)
},
onKeyDown: (props) => {
if (props.event.key === 'Escape') {
if (popup) {
popup.style.display = 'none'
}
return true
}
return component.ref?.onKeyDown(props)
},
onExit: () => {
if (popup && popup.parentNode) {
popup.parentNode.removeChild(popup)
}
component.destroy()
}
}
}
}
function updatePosition(popup, clientRect) {
const rect = clientRect()
if (!rect) return
// Position below the cursor
popup.style.left = `${rect.left}px`
popup.style.top = `${rect.bottom + 4}px`
// Ensure popup doesn't go off-screen to the right
requestAnimationFrame(() => {
const popupRect = popup.getBoundingClientRect()
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
// Adjust horizontal position if needed
if (popupRect.right > viewportWidth) {
popup.style.left = `${viewportWidth - popupRect.width - 8}px`
}
// If popup goes below viewport, show above cursor
if (popupRect.bottom > viewportHeight) {
popup.style.top = `${rect.top - popupRect.height - 4}px`
}
})
allow: ({ editor }) => editor.options.editorProps?.enableMentions?.() ?? false,
items: ({ query, editor }) => editor.options.editorProps?.getSuggestions?.(query) ?? [],
render: () => createSuggestionRenderer(MentionList)
}
@@ -0,0 +1,64 @@
import { VueRenderer } from '@tiptap/vue-3'
export function createSuggestionRenderer(ListComponent) {
let component
let popup
let dismissed = false
return {
onStart: (props) => {
dismissed = false
component = new VueRenderer(ListComponent, {
props: { ...props, query: props.query },
editor: props.editor
})
if (!props.clientRect) return
popup = document.createElement('div')
popup.style.position = 'fixed'
popup.style.zIndex = '9999'
if (component.element) popup.appendChild(component.element)
document.body.appendChild(popup)
updatePosition(popup, props.clientRect)
},
onUpdate: (props) => {
component.updateProps({ ...props, query: props.query })
if (!props.clientRect || !popup) return
updatePosition(popup, props.clientRect)
},
onKeyDown: (props) => {
if (dismissed) return false
if (props.event.key === 'Escape') {
dismissed = true
if (popup) popup.style.display = 'none'
return true
}
return component?.ref?.onKeyDown(props) ?? false
},
onExit: () => {
popup?.remove()
component.destroy()
}
}
}
function updatePosition(popup, clientRect) {
const rect = clientRect()
if (!rect) return
popup.style.left = `${rect.left}px`
popup.style.top = `${rect.bottom + 4}px`
requestAnimationFrame(() => {
const popupRect = popup.getBoundingClientRect()
if (popupRect.right > window.innerWidth) {
popup.style.left = `${window.innerWidth - popupRect.width - 8}px`
}
if (popupRect.bottom > window.innerHeight) {
popup.style.top = `${rect.top - popupRect.height - 4}px`
}
})
}
@@ -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)
})
})
@@ -13,6 +13,8 @@ export function useTextEditor({
linkedModel = 'messages',
getSuggestions = null,
enableMentions = () => false,
getConversationSuggestions = null,
conversationReferencesEnabled = () => false,
onSend = () => {},
onToggleMessageType = null,
onUpdate = () => {},
@@ -53,6 +55,8 @@ export function useTextEditor({
attributes: { class: 'outline-none' },
getSuggestions,
enableMentions,
getConversationSuggestions,
conversationReferencesEnabled,
handlePaste,
handleDrop,
handleKeyDown: (view, event) => {
@@ -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,
@@ -23,6 +24,7 @@ import {
Timer,
UserRound,
UsersRound,
WandSparkles,
Webhook,
Workflow,
Wrench,
@@ -33,6 +35,7 @@ import WhatsAppIcon from '@main/components/icons/WhatsAppIcon.vue'
// Icon names referenced by string in navigation.js.
export const navIconMap = {
BarChart3,
Bell,
Bot,
BookOpen,
CircleDot,
@@ -56,6 +59,7 @@ export const navIconMap = {
Timer,
UserRound,
UsersRound,
WandSparkles,
Webhook,
Workflow,
Wrench,
@@ -65,6 +65,13 @@ export const adminNavItems = [
isTitleKeyPlural: true,
icon: 'NotebookText'
},
{
titleKey: 'admin.ai.editorPrompts',
href: '/admin/ai/editor-prompts',
permission: 'ai:manage',
isTitleKeyPlural: true,
icon: 'WandSparkles'
},
{
titleKey: 'admin.ai.suggestions',
href: '/admin/ai/suggestions',
@@ -264,6 +271,12 @@ export const accountNavItems = [
titleKey: 'globals.terms.profile',
href: '/account/profile',
icon: 'CircleUser'
},
{
titleKey: 'globals.terms.notification',
href: '/account/notifications',
isTitleKeyPlural: true,
icon: 'Bell'
}
]
@@ -0,0 +1,98 @@
<template>
<form @submit="onSubmit" novalidate class="space-y-6 w-full">
<FormField v-slot="{ componentField }" name="title">
<FormItem>
<FormLabel>{{ t('globals.terms.title') }}</FormLabel>
<FormControl>
<Input
type="text"
:placeholder="t('admin.ai.editorPrompt.titlePlaceholder')"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="content">
<FormItem>
<FormLabel>{{ t('globals.terms.content') }}</FormLabel>
<FormControl>
<Textarea
class="min-h-[240px] max-h-[60vh]"
:placeholder="t('admin.ai.editorPrompt.contentPlaceholder')"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<DialogFooter class="mt-10">
<Button type="submit" :isLoading="formLoading">
{{ isEditing ? t('globals.messages.save') : t('globals.messages.create') }}
</Button>
</DialogFooter>
</form>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
import { Button } from '@shared-ui/components/ui/button/index.js'
import { Input } from '@shared-ui/components/ui/input/index.js'
import { Textarea } from '@shared-ui/components/ui/textarea/index.js'
import { DialogFooter } from '@shared-ui/components/ui/dialog/index.js'
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@shared-ui/components/ui/form/index.js'
import { useI18n } from 'vue-i18n'
const props = defineProps({
initialValues: { type: Object, default: () => ({}) },
isEditing: { type: Boolean, default: false },
submitForm: { type: Function, required: true }
})
const { t } = useI18n()
const formLoading = ref(false)
const form = useForm({
validationSchema: toTypedSchema(
z.object({
title: z
.string({ required_error: t('globals.messages.required') })
.min(1, { message: t('globals.messages.required') })
.max(140),
content: z
.string({ required_error: t('globals.messages.required') })
.min(1, { message: t('globals.messages.required') })
})
),
initialValues: { title: '', content: '' }
})
watch(
() => props.initialValues,
(values) => {
form.setValues({ title: values.title || '', content: values.content || '' }, false)
form.setErrors({})
},
{ immediate: true, deep: true }
)
const onSubmit = form.handleSubmit(async (values) => {
try {
formLoading.value = true
await props.submitForm({ title: values.title, content: values.content })
} finally {
formLoading.value = false
}
})
</script>
@@ -0,0 +1,38 @@
import { h } from 'vue'
import { format } from 'date-fns'
import dropdown from './editorPromptDropdown.vue'
export const createEditorPromptColumns = (t, { onEdit } = {}) => [
{
accessorKey: 'title',
header: () => h('div', { class: 'text-center' }, t('globals.terms.title')),
cell: ({ row }) =>
h(
'div',
{ class: 'text-center' },
onEdit
? h(
'span',
{
class: 'text-foreground font-medium hover:underline cursor-pointer',
onClick: () => onEdit(row.original)
},
row.getValue('title')
)
: row.getValue('title')
)
},
{
accessorKey: 'updated_at',
enableGlobalFilter: false,
header: () => h('div', { class: 'text-center' }, t('globals.terms.updatedAt')),
cell: ({ row }) =>
h('div', { class: 'text-center' }, format(row.getValue('updated_at'), 'PPpp'))
},
{
id: 'actions',
enableHiding: false,
enableSorting: false,
cell: ({ row }) => h('div', { class: 'relative' }, h(dropdown, { prompt: row.original }))
}
]
@@ -0,0 +1,94 @@
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" class="w-8 h-8 p-0">
<span class="sr-only">{{ t('globals.terms.openMenu') }}</span>
<MoreVertical class="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="editPrompt">
<Pencil class="mr-2 h-4 w-4" />
{{ t('globals.messages.edit') }}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
@click="() => (alertOpen = true)"
class="text-destructive focus:text-destructive"
>
<Trash class="mr-2 h-4 w-4" />
{{ t('globals.messages.delete') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog :open="alertOpen" @update:open="alertOpen = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
<AlertDialogDescription>
{{ t('admin.ai.editorPrompt.deleteConfirmation') }}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{{ t('globals.messages.cancel') }}</AlertDialogCancel>
<AlertDialogAction variant="destructive" @click="deletePrompt">{{
t('globals.messages.delete')
}}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template>
<script setup>
import { ref } from 'vue'
import { MoreVertical, Pencil, Trash } from 'lucide-vue-next'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@shared-ui/components/ui/dropdown-menu/index.js'
import { Button } from '@shared-ui/components/ui/button/index.js'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@shared-ui/components/ui/alert-dialog/index.js'
import { useEmitter } from '@/composables/useEmitter.js'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { handleHTTPError } from '@shared-ui/utils/http.js'
import { useI18n } from 'vue-i18n'
import api from '@/api'
const { t } = useI18n()
const alertOpen = ref(false)
const emitter = useEmitter()
const props = defineProps({
prompt: { type: Object, required: true }
})
const editPrompt = () => {
emitter.emit(EMITTER_EVENTS.EDIT_MODEL, { model: 'ai_editor_prompts', data: props.prompt })
}
const deletePrompt = async () => {
try {
await api.deleteAIPrompt(props.prompt.id)
alertOpen.value = false
emitter.emit(EMITTER_EVENTS.REFRESH_LIST, { model: 'ai_editor_prompts' })
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
</script>
@@ -54,7 +54,18 @@
</FormItem>
</FormField>
<Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
<div class="flex gap-2">
<Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
<Button
v-if="initialValues?.is_builtin && defaultBody"
type="button"
variant="outline"
:disabled="isLoading"
@click="resetToDefault"
>
{{ $t('globals.messages.resetToDefault') }}
</Button>
</div>
</form>
</template>
@@ -78,6 +89,7 @@ import CodeEditor from '@main/components/editor/CodeEditor.vue'
import { Checkbox } from '@shared-ui/components/ui/checkbox/index.js'
import { Label } from '@shared-ui/components/ui/label/index.js'
import { useI18n } from 'vue-i18n'
import { BUILT_IN_TEMPLATE_BODIES } from './defaults.js'
const props = defineProps({
initialValues: {
@@ -121,6 +133,14 @@ const hideSubject = computed(() => {
return isOutgoingTemplate.value || props.initialValues?.name === 'CSAT request'
})
const defaultBody = computed(() => {
return BUILT_IN_TEMPLATE_BODIES[props.initialValues?.name]
})
const resetToDefault = () => {
form.setFieldValue('body', defaultBody.value, false)
}
// Watch for changes in initialValues and update the form.
watch(
() => props.initialValues,
@@ -0,0 +1,164 @@
export const BUILT_IN_TEMPLATE_BODIES = {
'Conversation assigned': `<p>A new conversation has been assigned to you:</p>
<div>
Reference number: {{ .Conversation.ReferenceNumber }} <br>
Subject: {{ .Conversation.Subject }}
</div>
<p>
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
</p>
<div>
Best regards,<br>
Libredesk
</div>`,
'New reply from contact': `<p>{{ .Author.FullName }} replied to a conversation assigned to you:</p>
<div>
Reference number: {{ .Conversation.ReferenceNumber }} <br>
Subject: {{ .Conversation.Subject }}
</div>
<blockquote style="background-color: #f5f5f5; padding: 12px; margin: 16px 0; border-left: 4px solid #ddd;">
{{ .Message.Content }}
</blockquote>
<p>
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
</p>
<div>
Best regards,<br>
Libredesk
</div>`,
'New reply on participating conversation': `<p>{{ .Author.FullName }} replied to a conversation you are participating in:</p>
<div>
Reference number: {{ .Conversation.ReferenceNumber }} <br>
Subject: {{ .Conversation.Subject }}
</div>
<blockquote style="background-color: #f5f5f5; padding: 12px; margin: 16px 0; border-left: 4px solid #ddd;">
{{ .Message.Content }}
</blockquote>
<p>
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
</p>
<div>
Best regards,<br>
Libredesk
</div>`,
'Conversation reopened': `<p>{{ .Author.FullName }} replied and reopened a conversation assigned to you:</p>
<div>
Reference number: {{ .Conversation.ReferenceNumber }} <br>
Subject: {{ .Conversation.Subject }}
</div>
<blockquote style="background-color: #f5f5f5; padding: 12px; margin: 16px 0; border-left: 4px solid #ddd;">
{{ .Message.Content }}
</blockquote>
<p>
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
</p>
<div>
Best regards,<br>
Libredesk
</div>`,
'SLA breach warning': `<p>This is a notification that the SLA for conversation {{ .Conversation.ReferenceNumber }} is approaching the SLA deadline for {{ .SLA.Metric }}.</p>
<p>
Details:<br>
- Conversation reference number: {{ .Conversation.ReferenceNumber }}<br>
- Metric: {{ .SLA.Metric }}<br>
- Due in: {{ .SLA.DueIn }}
</p>
<p>
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
</p>
<p>
Best regards,<br>
Libredesk
</p>`,
'SLA breached': `<p>This is an urgent alert that the SLA for conversation {{ .Conversation.ReferenceNumber }} has been breached for {{ .SLA.Metric }}. Please take immediate action.</p>
<p>
Details:<br>
- Conversation reference number: {{ .Conversation.ReferenceNumber }}<br>
- Metric: {{ .SLA.Metric }}<br>
- Overdue by: {{ .SLA.OverdueBy }}
</p>
<p>
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
</p>
<p>
Best regards,<br>
Libredesk
</p>`,
'Mentioned in conversation': `<p>{{ .MentionedBy.FullName }} mentioned you in a private note on conversation #{{ .Conversation.ReferenceNumber }}.</p>
<blockquote style="background-color: #f5f5f5; padding: 12px; margin: 16px 0; border-left: 4px solid #ddd;">
{{ .Message.Content }}
</blockquote>
<p>
<a href="{{ RootURL }}/inboxes/mentioned/conversation/{{ .Conversation.UUID }}?scrollTo={{ .Message.UUID }}">View Conversation</a>
</p>
<p>
Best regards,<br>
libredesk
</p>`,
'CSAT request': `<p style="margin: 0 0 4px; font-size: 15px; color: #374151; text-align: center; line-height: 1.5;">
Your conversation <strong style="color: #111827;">#{{ .Conversation.ReferenceNumber }}</strong> has been resolved.
</p>
<p style="margin: 0 0 28px; font-size: 13px; color: #9ca3af; text-align: center;">
We would love to hear how it went.
</p>
<p style="margin: 0 0 20px; font-size: 14px; font-weight: 600; color: #374151; text-align: center;">
How would you rate your experience?
</p>
<!-- Variable CSATUUID is also available -->
<div style="text-align: center; margin: 0 auto; max-width: 400px; font-size: 0;">
<div style="display: inline-block; width: 72px; text-align: center; vertical-align: top; padding: 4px 0;">
<a href="{{ .CSATLink }}?rating=1" style="text-decoration: none; display: block;">
<span style="font-size: 34px; display: block; line-height: 1.4;">&#128546;</span>
<span style="font-size: 10px; display: block; font-weight: 600; color: #b0b5bd; text-transform: uppercase; letter-spacing: 0.05em; margin-top: 4px;">Poor</span>
</a>
</div>
<div style="display: inline-block; width: 72px; text-align: center; vertical-align: top; padding: 4px 0;">
<a href="{{ .CSATLink }}?rating=2" style="text-decoration: none; display: block;">
<span style="font-size: 34px; display: block; line-height: 1.4;">&#128533;</span>
<span style="font-size: 10px; display: block; font-weight: 600; color: #b0b5bd; text-transform: uppercase; letter-spacing: 0.05em; margin-top: 4px;">Fair</span>
</a>
</div>
<div style="display: inline-block; width: 72px; text-align: center; vertical-align: top; padding: 4px 0;">
<a href="{{ .CSATLink }}?rating=3" style="text-decoration: none; display: block;">
<span style="font-size: 34px; display: block; line-height: 1.4;">&#128522;</span>
<span style="font-size: 10px; display: block; font-weight: 600; color: #b0b5bd; text-transform: uppercase; letter-spacing: 0.05em; margin-top: 4px;">Good</span>
</a>
</div>
<div style="display: inline-block; width: 72px; text-align: center; vertical-align: top; padding: 4px 0;">
<a href="{{ .CSATLink }}?rating=4" style="text-decoration: none; display: block;">
<span style="font-size: 34px; display: block; line-height: 1.4;">&#128515;</span>
<span style="font-size: 10px; display: block; font-weight: 600; color: #b0b5bd; text-transform: uppercase; letter-spacing: 0.05em; margin-top: 4px;">Great</span>
</a>
</div>
<div style="display: inline-block; width: 72px; text-align: center; vertical-align: top; padding: 4px 0;">
<a href="{{ .CSATLink }}?rating=5" style="text-decoration: none; display: block;">
<span style="font-size: 34px; display: block; line-height: 1.4;">&#129321;</span>
<span style="font-size: 10px; display: block; font-weight: 600; color: #b0b5bd; text-transform: uppercase; letter-spacing: 0.05em; margin-top: 4px;">Excellent</span>
</a>
</div>
</div>`
}
@@ -12,7 +12,8 @@ import {
} from 'lucide-vue-next'
import { useUserStore } from '@main/stores/user'
import { useKeyboardShortcutsDialog } from '@main/composables/useKeyboardShortcutsDialog'
import { SECTIONS } from '../sections'
import { useLogout } from '@main/composables/useLogout'
import { SECTIONS } from '@main/features/command/sections'
const AWAY_STATUSES = ['away_manual', 'away_and_reassigning']
@@ -21,6 +22,7 @@ export function useAccountCommands() {
const mode = useColorMode()
const userStore = useUserStore()
const shortcutsDialog = useKeyboardShortcutsDialog()
const logout = useLogout()
return computed(() => {
const status = userStore.user.availability_status
@@ -70,9 +72,7 @@ export function useAccountCommands() {
keywords: ['sign out'],
section: SECTIONS.ACCOUNT,
icon: LogOut,
run: () => {
window.location.href = '/logout'
}
run: logout
}
]
})
@@ -79,7 +79,7 @@
<div v-if="userStore.can('contacts:write')">
<Button type="submit" :isLoading="formLoading" :disabled="formLoading">
{{ t('contact.updateContact') }}
{{ submitLabel || t('contact.updateContact') }}
</Button>
</div>
</form>
@@ -99,9 +99,9 @@ import ComboBox from '@shared-ui/components/ui/combobox/ComboBox.vue'
import PhoneNumberInput from '@shared-ui/components/PhoneNumberInput.vue'
import { countryOptions } from '@shared-ui/constants/countries.js'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '../../stores/user'
import { useUserStore } from '@main/stores/user'
defineProps(['formLoading', 'onSubmit'])
defineProps(['formLoading', 'onSubmit', 'submitLabel'])
const { t } = useI18n()
const userStore = useUserStore()
@@ -2,10 +2,11 @@
<div class="min-h-screen flex flex-col">
<!-- Main Content Area -->
<div class="flex flex-wrap gap-4 pb-4">
<div class="flex items-center gap-4 mb-4">
<div class="flex items-center gap-4 mb-4 w-full">
<!-- Search Input -->
<Input
type="text"
class="max-w-sm"
v-model="searchTerm"
:placeholder="$t('contact.searchByEmail')"
@input="fetchContactsDebounced"
@@ -25,7 +26,9 @@
<SelectValue :placeholder="orderByField" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="'users.created_at'">{{ $t('globals.terms.createdAt') }}</SelectItem>
<SelectItem :value="'users.created_at'">{{
$t('globals.terms.createdAt')
}}</SelectItem>
<SelectItem :value="'users.email'">{{ $t('globals.terms.email') }}</SelectItem>
</SelectContent>
</Select>
@@ -42,6 +45,16 @@
</Select>
</PopoverContent>
</Popover>
<Button
v-if="userStore.can('contacts:write')"
size="sm"
class="flex items-center h-8 gap-1 ml-auto"
@click="showCreateDialog = true"
>
<UserPlus size="16" />
{{ $t('contact.new') }}
</Button>
</div>
<!-- Loading State -->
@@ -79,13 +92,20 @@
{{ contact.first_name }} {{ contact.last_name }}
</h4>
<Badge v-if="contact.type" variant="secondary" class="text-xs px-1.5 py-0">
{{ contact.type === 'visitor' ? $t('contact.type.visitor') : $t('contact.type.contact') }}
{{
contact.type === 'visitor'
? $t('contact.type.visitor')
: $t('contact.type.contact')
}}
</Badge>
</div>
<p class="text-xs text-muted-foreground truncate">
{{ contact.email }}
</p>
<div v-if="contact.external_user_id" class="flex items-center gap-1 text-xs text-muted-foreground">
<div
v-if="contact.external_user_id"
class="flex items-center gap-1 text-xs text-muted-foreground"
>
<IdCardIcon size="12" class="flex-shrink-0" />
<span class="truncate">{{ contact.external_user_id }}</span>
</div>
@@ -98,16 +118,15 @@
</template>
</div>
<PaginationBar
v-model:page="page"
v-model:per-page="perPage"
:total-pages="totalPages"
/>
<PaginationBar v-model:page="page" v-model:per-page="perPage" :total-pages="totalPages" />
</div>
<CreateContactDialog v-model:open="showCreateDialog" @created="onContactCreated" />
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Card } from '@shared-ui/components/ui/card'
import { Skeleton } from '@shared-ui/components/ui/skeleton'
import { Avatar, AvatarImage, AvatarFallback } from '@shared-ui/components/ui/avatar'
@@ -121,13 +140,15 @@ 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 } from 'lucide-vue-next'
import { ArrowDownWideNarrow, IdCardIcon, UserPlus } 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'
import { useEmitter } from '@main/composables/useEmitter'
import { handleHTTPError } from '@shared-ui/utils/http.js'
import PaginationBar from '@main/components/pagination/PaginationBar.vue'
import CreateContactDialog from './CreateContactDialog.vue'
import { useUserStore } from '@main/stores/user'
import api from '@main/api'
const contacts = ref([])
@@ -139,7 +160,10 @@ const searchTerm = ref('')
const orderByField = ref('users.created_at')
const orderByDirection = ref('desc')
const total = ref(0)
const showCreateDialog = ref(false)
const emitter = useEmitter()
const router = useRouter()
const userStore = useUserStore()
let fetchRequestId = 0
const fetchContactsDebounced = useDebounceFn(() => {
@@ -191,6 +215,10 @@ const getInitials = (firstName, lastName) => {
return `${firstName?.[0] || ''}${lastName?.[0] || ''}`.toUpperCase()
}
const onContactCreated = (contact) => {
router.push({ name: 'contact-detail', params: { id: contact.id } })
}
watch([page, perPage], fetchContacts)
onMounted(() => {
@@ -0,0 +1,69 @@
<template>
<Dialog :open="open" @update:open="$emit('update:open', $event)">
<DialogContent class="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{{ t('contact.new') }}</DialogTitle>
</DialogHeader>
<ContactForm
:formLoading="loading"
:onSubmit="onSubmit"
:submitLabel="t('globals.messages.create')"
/>
</DialogContent>
</Dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@shared-ui/components/ui/dialog'
import ContactForm from './ContactForm.vue'
import { createFormSchema } from './formSchema.js'
import api from '@main/api'
import { useEmitter } from '@main/composables/useEmitter'
import { EMITTER_EVENTS } from '@main/constants/emitterEvents'
import { handleHTTPError } from '@shared-ui/utils/http.js'
const props = defineProps({
open: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:open', 'created'])
const { t } = useI18n()
const emitter = useEmitter()
const loading = ref(false)
const form = useForm({
validationSchema: toTypedSchema(createFormSchema(t))
})
watch(
() => props.open,
(val) => {
if (!val) form.resetForm()
}
)
const onSubmit = form.handleSubmit(async (values) => {
loading.value = true
try {
const { data } = await api.createContact(values)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { description: t('globals.messages.savedSuccessfully') })
emit('update:open', false)
emit('created', data.data)
} catch (err) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(err).message
})
} finally {
loading.value = false
}
})
</script>
@@ -0,0 +1,44 @@
<template>
<Tooltip v-if="priorityName">
<TooltipTrigger asChild>
<PriorityIcon
:level="marker.level"
:size="13"
class="flex-shrink-0"
:class="marker.class"
role="img"
:aria-label="priorityLabel"
/>
</TooltipTrigger>
<TooltipContent>{{ priorityLabel }}</TooltipContent>
</Tooltip>
</template>
<script setup>
import { computed } from 'vue'
import PriorityIcon from '@shared-ui/components/icons/PriorityIcon.vue'
import { Tooltip, TooltipContent, TooltipTrigger } from '@shared-ui/components/ui/tooltip'
import { useI18n } from 'vue-i18n'
const PRIORITY_MARKERS = {
low: { level: 1, class: 'text-muted-foreground' },
medium: { level: 2, class: 'text-warning-600' },
high: { level: 3, class: 'text-destructive' }
}
const UNKNOWN_MARKER = { level: 0, class: 'text-muted-foreground' }
const props = defineProps({
priority: {
type: String,
default: ''
}
})
const { t } = useI18n()
const priorityName = computed(() => (props.priority || '').trim())
const marker = computed(() => PRIORITY_MARKERS[priorityName.value.toLowerCase()] || UNKNOWN_MARKER)
const priorityLabel = computed(() => `${t('globals.terms.priority', 1)}: ${priorityName.value}`)
</script>
@@ -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"
@@ -159,7 +157,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,
@@ -307,8 +304,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 = () =>
@@ -318,8 +313,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
@@ -339,9 +332,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,14 +91,15 @@
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"
:enableMentions="messageType === 'private_note'"
:enableConversationReferences="messageType === 'private_note'"
:enableInlineImages="conversationStore.current.inbox_channel === 'email'"
:getSuggestions="getSuggestions"
@aiPromptSelected="handleAiPromptSelected"
:getConversationSuggestions="getConversationSuggestions"
@aiGenerationChange="emit('aiGenerationChange', $event)"
@send="handleSend"
@mentionsChanged="handleMentionsChanged"
@filesDropped="handleFilesDropped"
@@ -145,9 +146,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'
@@ -169,6 +167,10 @@ import { useI18n } from 'vue-i18n'
import { validateEmail } from '@shared-ui/utils/string'
import { useMacroStore } from '@main/stores/macro'
import api from '@main/api'
import {
createLatestConversationSuggestionFetcher,
getConversationSuggestions as fetchConversationSuggestions
} from '@main/components/editor/conversationReference'
const MENTION_LIMIT = 10
const MENTION_DEBOUNCE_MS = 250
@@ -217,6 +219,18 @@ const getSuggestions = async (query) => {
return (await debouncedFetchSuggestions(query)) || []
}
const debouncedFetchConversationSuggestions = useDebounceFn(fetchConversationSuggestions, MENTION_DEBOUNCE_MS)
const fetchLatestConversationSuggestions = createLatestConversationSuggestionFetcher(
debouncedFetchConversationSuggestions
)
const getConversationSuggestions = async (query) => {
if (messageType.value !== 'private_note') return []
const messageTypeAtRequest = messageType.value
const suggestions = (await fetchLatestConversationSuggestions(query)) || []
return messageType.value === messageTypeAtRequest ? suggestions : []
}
// Handle mentions changed from editor
const handleMentionsChanged = (newMentions) => {
mentions.value = newMentions
@@ -232,10 +246,6 @@ const props = defineProps({
type: Boolean,
default: false
},
aiPrompts: {
type: Array,
required: true
},
isSending: {
type: Boolean,
required: true
@@ -276,7 +286,7 @@ const emit = defineEmits([
'inlineImageUpload',
'fileDelete',
'filesDropped',
'aiPromptSelected',
'aiGenerationChange',
'generateReply'
])
@@ -387,14 +397,10 @@ 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,
(newType, oldType) => {
(newType) => {
if (newType === 'reply') {
macroStore.setCurrentView('replying')
} else if (newType === 'private_note') {
@@ -59,6 +59,7 @@
<TooltipContent>{{ contactFullName }}</TooltipContent>
</Tooltip>
<div class="flex items-center gap-1 flex-shrink-0">
<PriorityMarker :priority="conversation.priority" />
<Tooltip>
<TooltipTrigger asChild>
<component
@@ -176,6 +177,7 @@ import {
ContextMenuTrigger
} from '@shared-ui/components/ui/context-menu'
import SlaBadge from '@main/features/sla/SlaBadge.vue'
import PriorityMarker from '@main/features/conversation/PriorityMarker.vue'
import { Tooltip, TooltipContent, TooltipTrigger } from '@shared-ui/components/ui/tooltip'
import { Checkbox } from '@shared-ui/components/ui/checkbox'
import { useConversationStore } from '@main/stores/conversation'
@@ -116,10 +116,14 @@
v-else-if="message.content_type !== 'text' && sanitizedContent"
ref="messageContentEl"
@click="onMessageContentClick"
:class="{
'email-light-canvas': !isOutgoing && convStore.current?.inbox_channel === 'email'
}"
>
<Letter
:html="sanitizedContent"
:allowedSchemas="['cid', 'https', 'http', 'mailto']"
:allowedSchemas="allowedSchemas"
:rewriteExternalLinks="rewriteMessageLink"
:allowed-css-properties="extendedCssProperties"
class="mb-1 native-html break-words"
:class="{ 'mb-3': message.attachments.length > 0 }"
@@ -414,6 +418,14 @@ const avatarFallback = computed(() => {
return firstName.toUpperCase().substring(0, 2)
})
const allowedSchemas = ['cid', 'https', 'http', 'mailto']
// vue-letter skips its own href schema check once a rewrite hook is set.
const rewriteMessageLink = (href) => {
if (href.startsWith('/') && !href.startsWith('//')) return `${window.location.origin}${href}`
return allowedSchemas.includes(href.toLowerCase().split(':')[0]) ? href : ''
}
const sanitizedContent = computed(() => {
if (props.message.meta?.is_csat) {
return t('globals.messages.pleaseRateConversation')
+13
View File
@@ -173,6 +173,13 @@ const routes = [
name: 'profile',
component: () => import('@main/views/account/profile/ProfileEditView.vue'),
meta: { titleKey: 'account.editProfile' }
},
{
path: 'notifications',
name: 'account-notifications',
component: () =>
import('@main/views/account/notifications/NotificationPreferences.vue'),
meta: { titleKey: 'globals.terms.notification', titleCount: 2 }
}
]
},
@@ -236,6 +243,12 @@ const routes = [
component: () => import('@main/views/admin/ai/AISnippets.vue'),
meta: { titleKey: 'admin.ai.snippets', titleCount: 2 }
},
{
path: 'ai/editor-prompts',
name: 'ai-editor-prompts',
component: () => import('@main/views/admin/ai/AIEditorPrompts.vue'),
meta: { titleKey: 'admin.ai.editorPrompts', titleCount: 2 }
},
{
path: 'ai/suggestions',
name: 'ai-suggestions',
+54 -27
View File
@@ -1,34 +1,61 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { handleHTTPError } from '@shared-ui/utils/http.js'
import { useEmitter } from '../composables/useEmitter'
import { EMITTER_EVENTS } from '../constants/emitterEvents'
import api from '../api'
import { useEmitter } from '@main/composables/useEmitter'
import { EMITTER_EVENTS } from '@main/constants/emitterEvents'
import api from '@main/api'
export const useAiPromptStore = defineStore('aiPrompt', () => {
const prompts = ref([])
const emitter = useEmitter()
let inflight = null
let hasFetched = false
const fetchPrompts = () => {
if (hasFetched) return Promise.resolve()
if (inflight) return inflight
inflight = api.getAiPrompts()
.then(response => {
prompts.value = response?.data?.data || []
hasFetched = true
})
.catch(error => {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
})
.finally(() => { inflight = null })
return inflight
}
return {
prompts,
fetchPrompts,
const prompts = ref([])
const emitter = useEmitter()
let inflight = null
let hasFetched = false
const showError = (error) => {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
const fetchPrompts = () => {
if (hasFetched) return Promise.resolve()
if (inflight) return inflight
inflight = api
.getAiPrompts()
.then((response) => {
prompts.value = response?.data?.data || []
hasFetched = true
})
.catch(showError)
.finally(() => {
inflight = null
})
return inflight
}
const refreshPrompts = async () => {
if (inflight) await inflight
hasFetched = false
return fetchPrompts()
}
const complete = async (promptKey, content) => {
try {
const response = await api.aiCompletion({ prompt_key: promptKey, content })
return response?.data?.data ?? ''
} catch (error) {
showError(error)
return null
}
}
return {
prompts,
fetchPrompts,
refreshPrompts,
complete
}
})
@@ -0,0 +1,185 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, h, nextTick, ref } from 'vue'
const { getPreferences, savePreferences, refresh, enable, disable, emit } = vi.hoisted(() => ({
getPreferences: vi.fn(),
savePreferences: vi.fn().mockResolvedValue({}),
refresh: vi.fn(),
enable: vi.fn().mockResolvedValue(true),
disable: vi.fn().mockResolvedValue(),
emit: vi.fn()
}))
vi.mock('@/api', () => ({
default: {
getNotificationPreferences: getPreferences,
updateNotificationPreferences: savePreferences
}
}))
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key) => key }) }))
vi.mock('@/composables/useEmitter', () => ({ useEmitter: () => ({ emit }) }))
vi.mock('@shared-ui/utils/http.js', () => ({ handleHTTPError: (error) => error }))
vi.mock('@/composables/usePushNotifications', () => ({
usePushNotifications: () => ({
supported: ref(true),
enabled: ref(false),
permission: ref('granted'),
refresh,
enable,
disable
})
}))
vi.mock('@shared-ui/components/ui/switch', () => ({
Switch: {
props: ['checked', 'disabled'],
emits: ['update:checked'],
setup:
(props, { emit }) =>
() =>
h('button', {
type: 'button',
disabled: props.disabled,
onClick: () => emit('update:checked', !props.checked)
})
}
}))
import NotificationPreferences from './NotificationPreferences.vue'
let app
let root
const settle = async () => {
for (let i = 0; i < 10; i++) await nextTick()
}
afterEach(() => {
app?.unmount()
root?.remove()
vi.clearAllMocks()
})
const mount = async (vapidPublicKey = 'test-key') => {
getPreferences.mockResolvedValue({
data: {
data: {
email_enabled: true,
vapid_public_key: vapidPublicKey,
push_endpoints: ['https://push.example/subscription'],
preferences: [
{ notification_type: 'new_reply', channel: 'email', enabled: true },
{ notification_type: 'new_reply', channel: 'in_app', enabled: false }
]
}
}
})
root = document.createElement('div')
document.body.append(root)
app = createApp(NotificationPreferences)
app.config.globalProperties.$t = (key) => key
app.mount(root)
await settle()
}
describe('notification preferences', () => {
it('shows a loader until preferences are loaded', async () => {
let finishLoad
getPreferences.mockReturnValueOnce(
new Promise((resolve) => {
finishLoad = resolve
})
)
root = document.createElement('div')
document.body.append(root)
app = createApp(NotificationPreferences)
app.config.globalProperties.$t = (key) => key
app.mount(root)
await nextTick()
expect(root.querySelector('[role="status"]')).not.toBeNull()
expect(root.querySelectorAll('button')).toHaveLength(0)
finishLoad({
data: {
data: {
email_enabled: true,
vapid_public_key: 'test-key',
push_endpoints: [],
preferences: []
}
}
})
await settle()
expect(root.querySelector('[role="status"]')).toBeNull()
expect(root.querySelectorAll('button')).toHaveLength(1)
})
it('saves a channel toggle', async () => {
await mount()
const email = root.querySelectorAll('button')[2]
expect(email).toBeDefined()
email.click()
await settle()
expect(savePreferences).toHaveBeenCalledWith([
{ notification_type: 'new_reply', channel: 'email', enabled: false }
])
})
it('prevents overlapping saves for the same preference', async () => {
let finishSave
savePreferences.mockImplementationOnce(
() =>
new Promise((resolve) => {
finishSave = resolve
})
)
await mount()
const switches = root.querySelectorAll('button')
const inApp = switches[1]
const email = switches[2]
email.click()
email.click()
await nextTick()
expect(savePreferences).toHaveBeenCalledTimes(1)
expect(email.disabled).toBe(true)
expect(inApp.disabled).toBe(false)
inApp.click()
await settle()
expect(savePreferences).toHaveBeenCalledTimes(2)
finishSave({})
await settle()
expect(email.disabled).toBe(false)
})
it('restores this browser push state from the saved endpoints', async () => {
await mount()
expect(refresh).toHaveBeenCalledWith('test-key', ['https://push.example/subscription'])
})
it('disables browser notifications when push is unavailable', async () => {
await mount('')
const browserPush = root.querySelector('button')
expect(browserPush.disabled).toBe(true)
expect(root.textContent).toContain('notification.browserPush.unavailable')
})
it('shows a loader while browser notifications are being enabled', async () => {
let finishEnable
enable.mockReturnValueOnce(
new Promise((resolve) => {
finishEnable = resolve
})
)
await mount()
root.querySelector('button').click()
await nextTick()
expect(root.querySelector('.animate-dot-flashing')).not.toBeNull()
finishEnable(true)
await settle()
expect(root.querySelector('.animate-dot-flashing')).toBeNull()
})
})
@@ -0,0 +1,205 @@
<template>
<div class="h-full relative">
<div class="flex flex-col space-y-5 max-w-2xl">
<div class="space-y-1">
<span class="sub-title">{{ $t('globals.terms.notification', 2) }}</span>
</div>
<Spinner v-if="isLoading" />
<template v-else>
<p v-if="!emailEnabled" class="text-muted-foreground text-xs">
{{ $t('notification.emailChannelDisabled') }}
</p>
<div
class="flex items-center justify-between gap-4 border border-border rounded-md px-4 py-3"
>
<div class="space-y-1">
<p class="text-sm text-foreground">
{{ $t('globals.terms.browserNotification', 2) }}
</p>
<p v-if="browserPushDescription" class="text-xs text-muted-foreground">
{{ browserPushDescription }}
</p>
</div>
<DotLoader
v-if="isUpdatingBrowserPush"
role="status"
:aria-label="$t('globals.terms.loading')"
/>
<Switch
v-else
:checked="pushNotifications.enabled.value"
:disabled="
!pushNotifications.supported.value ||
pushNotifications.permission.value === 'denied' ||
!vapidPublicKey
"
:aria-label="$t('globals.terms.browserNotification', 2)"
@update:checked="updateBrowserPush"
/>
</div>
<div class="border border-border rounded-md divide-y divide-border">
<div class="flex items-center px-4 py-2 text-xs font-medium text-muted-foreground">
<div class="flex-grow" />
<div v-for="channel in channels" :key="channel.key" class="w-20 text-center">
{{ $t(channel.labelKey) }}
</div>
</div>
<div v-for="row in rows" :key="row.type" class="flex items-center px-4 py-3">
<div class="flex-grow pr-4">
<p class="text-sm text-foreground">{{ typeLabel(row.type) }}</p>
</div>
<div v-for="channel in channels" :key="channel.key" class="w-20 flex justify-center">
<Switch
:checked="row[channel.key]"
:disabled="
(channel.key === 'email' && !emailEnabled) ||
pendingUpdates.has(preferenceKey(row.type, channel.key))
"
:aria-label="
$t('notification.channelToggleLabel', {
channel: $t(channel.labelKey),
type: typeLabel(row.type)
})
"
@update:checked="update(row, channel.key, $event)"
/>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
<script setup>
import { computed, ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { Switch } from '@shared-ui/components/ui/switch'
import { DotLoader } from '@shared-ui/components/ui/loader'
import { Spinner } from '@shared-ui/components/ui/spinner'
import { useEmitter } from '@/composables/useEmitter'
import { handleHTTPError } from '@shared-ui/utils/http.js'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import api from '@/api'
import { usePushNotifications } from '@/composables/usePushNotifications'
const { t } = useI18n()
const emitter = useEmitter()
const rows = ref([])
const isLoading = ref(true)
const emailEnabled = ref(true)
const vapidPublicKey = ref('')
const pendingUpdates = ref(new Set())
const isUpdatingBrowserPush = ref(false)
const pushNotifications = usePushNotifications()
const channels = [
{ key: 'in_app', labelKey: 'globals.terms.inApp' },
{ key: 'email', labelKey: 'globals.terms.email' },
{ key: 'push', labelKey: 'globals.terms.browser' }
]
const typeLabels = {
assignment: 'notification.type.assignment',
mention: 'notification.type.mention',
conversation_reopened: 'notification.type.conversationReopened',
new_reply: 'notification.type.newReply',
new_reply_participating: 'notification.type.newReplyParticipating',
sla_first_response_warning: 'notification.type.slaFirstResponseWarning',
sla_first_response_breach: 'notification.type.slaFirstResponseBreach',
sla_next_response_warning: 'notification.type.slaNextResponseWarning',
sla_next_response_breach: 'notification.type.slaNextResponseBreach',
sla_resolution_warning: 'notification.type.slaResolutionWarning',
sla_resolution_breach: 'notification.type.slaResolutionBreach'
}
const typeLabel = (type) => (typeLabels[type] ? t(typeLabels[type]) : type)
const preferenceKey = (type, channel) => `${type}:${channel}`
const fetchPreferences = async () => {
try {
const { data } = await api.getNotificationPreferences()
emailEnabled.value = data.data.email_enabled
vapidPublicKey.value = data.data.vapid_public_key
await pushNotifications.refresh(vapidPublicKey.value, data.data.push_endpoints)
const byType = {}
for (const pref of data.data.preferences) {
byType[pref.notification_type] ??= { type: pref.notification_type }
byType[pref.notification_type][pref.channel] = pref.enabled
}
rows.value = Object.values(byType)
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
isLoading.value = false
}
}
const browserPushDescription = computed(() => {
if (!pushNotifications.supported.value) return t('notification.browserPush.unsupported')
if (pushNotifications.permission.value === 'denied') return t('notification.browserPush.blocked')
if (!vapidPublicKey.value) return t('notification.browserPush.unavailable')
return ''
})
const updateBrowserPush = async (value) => {
if (isUpdatingBrowserPush.value) return
try {
isUpdatingBrowserPush.value = true
if (value) {
const granted = await pushNotifications.enable(vapidPublicKey.value)
if (!granted) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: t('notification.browserPush.blocked')
})
}
} else {
await pushNotifications.disable()
}
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
isUpdatingBrowserPush.value = false
}
}
const update = async (row, channel, value) => {
const key = preferenceKey(row.type, channel)
if (pendingUpdates.value.has(key)) return
const previous = row[channel]
row[channel] = value
pendingUpdates.value.add(key)
try {
await api.updateNotificationPreferences([
{ notification_type: row.type, channel, enabled: value }
])
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.savedSuccessfully')
})
} catch (error) {
row[channel] = previous
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
pendingUpdates.value.delete(key)
}
}
onMounted(fetchPreferences)
</script>
@@ -0,0 +1,142 @@
<template>
<AdminSplitLayout>
<template #content>
<LoadingOverlay :loading="isLoading" reserve-height>
<div class="flex justify-end mb-4">
<Dialog v-model:open="dialogOpen">
<DialogTrigger as-child @click="newPrompt">
<Button>{{ t('admin.ai.editorPrompt.new') }}</Button>
</DialogTrigger>
<DialogContent class="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
{{ isEditing ? t('admin.ai.editorPrompt.edit') : t('admin.ai.editorPrompt.new') }}
</DialogTitle>
</DialogHeader>
<EditorPromptForm
:initial-values="initialValues"
:is-editing="isEditing"
:submit-form="submitPrompt"
/>
</DialogContent>
</Dialog>
</div>
<DataTable
:columns="createEditorPromptColumns(t, { onEdit: editPrompt })"
:data="prompts"
:loading="isLoading"
/>
</LoadingOverlay>
</template>
<template #help>
<p>{{ t('admin.ai.editorPromptsHelp') }}</p>
</template>
</AdminSplitLayout>
</template>
<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'
import { Button } from '@shared-ui/components/ui/button/index.js'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger
} from '@shared-ui/components/ui/dialog/index.js'
import EditorPromptForm from '@/features/admin/ai/EditorPromptForm.vue'
import { createEditorPromptColumns } from '@/features/admin/ai/editorPromptColumns.js'
import { useEmitter } from '@/composables/useEmitter.js'
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 dialogOpen = ref(false)
const isEditing = ref(false)
const initialValues = ref({})
const editingId = ref(null)
const refreshHandler = (data) => {
if (data?.model === 'ai_editor_prompts') refreshPrompts()
}
const editHandler = (data) => {
if (data?.model === 'ai_editor_prompts') editPrompt(data.data)
}
onMounted(() => {
loadPrompts()
emitter.on(EMITTER_EVENTS.REFRESH_LIST, refreshHandler)
emitter.on(EMITTER_EVENTS.EDIT_MODEL, editHandler)
})
onUnmounted(() => {
emitter.off(EMITTER_EVENTS.REFRESH_LIST, refreshHandler)
emitter.off(EMITTER_EVENTS.EDIT_MODEL, editHandler)
})
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 = () => {
isEditing.value = false
editingId.value = null
initialValues.value = {}
}
const editPrompt = async (item) => {
try {
const resp = await api.getAIPrompt(item.id)
isEditing.value = true
editingId.value = item.id
initialValues.value = resp.data.data
dialogOpen.value = true
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
const submitPrompt = async (values) => {
try {
if (isEditing.value) {
await api.updateAIPrompt(editingId.value, values)
} else {
await api.createAIPrompt(values)
}
dialogOpen.value = false
await refreshPrompts()
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.savedSuccessfully')
})
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
</script>
@@ -32,7 +32,7 @@
{{ oidcProvider.name }}
</Button>
<div class="relative">
<div v-if="localLoginEnabled" class="relative">
<div class="absolute inset-0 flex items-center">
<span class="w-full border-t border-border"></span>
</div>
@@ -42,7 +42,7 @@
</div>
</div>
<form @submit.prevent="loginAction" class="space-y-3">
<form v-if="localLoginEnabled" @submit.prevent="loginAction" class="space-y-3">
<div class="space-y-2">
<Label for="email" class="text-muted-foreground">{{ t('globals.terms.email') }}</Label>
<Input
@@ -259,6 +259,10 @@ const enabledOIDCProviders = computed(() => {
return oidcProviders.value.filter((provider) => !provider.disabled)
})
const localLoginEnabled = computed(
() => appSettingsStore.public_config?.['auth.local_login_enabled'] !== false
)
const emailHasError = computed(() => {
if (!submitted.value) return false
const email = loginForm.value.email
@@ -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'")
})
})
})
+2 -2
View File
@@ -61,7 +61,7 @@
"lucide-vue-next": "^0.525.0",
"mitt": "^3.0.1",
"pinia": "^2.3.0",
"qs": "^6.15.2",
"qs": "^6.16.0",
"radix-vue": "^1.9.17",
"reka-ui": "^2.9.7",
"tailwind-merge": "^2.6.0",
@@ -94,7 +94,7 @@
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"vite": "^6.4.3",
"vitest": "^4.1.0"
"vitest": "^4.1.11"
},
"packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a"
}
+113 -95
View File
@@ -129,8 +129,8 @@ importers:
specifier: ^2.3.0
version: 2.3.0(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3))
qs:
specifier: ^6.15.2
version: 6.15.2
specifier: ^6.16.0
version: 6.16.0
radix-vue:
specifier: ^1.9.17
version: 1.9.17(vue@3.5.13(typescript@5.7.3))
@@ -223,8 +223,8 @@ importers:
specifier: ^6.4.3
version: 6.4.3(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.9.0)
vitest:
specifier: ^4.1.0
version: 4.1.0(@types/node@25.8.0)(jsdom@29.0.1)(vite@6.4.3(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.9.0))
specifier: ^4.1.11
version: 4.1.11(@types/node@25.8.0)(jsdom@29.0.1)(vite@6.4.3(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.9.0))
packages:
@@ -687,8 +687,8 @@ packages:
resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
engines: {node: '>=6.0.0'}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/sourcemap-codec@1.6.0':
resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
'@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
@@ -1460,34 +1460,34 @@ packages:
vite: ^5.0.0 || ^6.0.0
vue: ^3.2.25
'@vitest/expect@4.1.0':
resolution: {integrity: sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==}
'@vitest/expect@4.1.11':
resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==}
'@vitest/mocker@4.1.0':
resolution: {integrity: sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==}
'@vitest/mocker@4.1.11':
resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==}
peerDependencies:
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@4.1.0':
resolution: {integrity: sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==}
'@vitest/pretty-format@4.1.11':
resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==}
'@vitest/runner@4.1.0':
resolution: {integrity: sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==}
'@vitest/runner@4.1.11':
resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==}
'@vitest/snapshot@4.1.0':
resolution: {integrity: sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==}
'@vitest/snapshot@4.1.11':
resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==}
'@vitest/spy@4.1.0':
resolution: {integrity: sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==}
'@vitest/spy@4.1.11':
resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==}
'@vitest/utils@4.1.0':
resolution: {integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==}
'@vitest/utils@4.1.11':
resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==}
'@vue/compiler-core@3.5.13':
resolution: {integrity: sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==}
@@ -2184,8 +2184,8 @@ packages:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
es-module-lexer@2.1.0:
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
es-module-lexer@2.3.2:
resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==}
es-object-atoms@1.1.2:
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
@@ -2300,8 +2300,8 @@ packages:
resolution: {integrity: sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==}
engines: {node: '>=4'}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
expect-type@1.4.0:
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
engines: {node: '>=12.0.0'}
extend@3.0.2:
@@ -2920,8 +2920,9 @@ packages:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
obug@2.1.1:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
obug@2.2.1:
resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==}
engines: {node: '>=12.20.0'}
ohash@2.0.11:
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
@@ -3022,6 +3023,10 @@ packages:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
picomatch@4.0.7:
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
engines: {node: '>=12'}
pify@2.3.0:
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
engines: {node: '>=0.10.0'}
@@ -3196,8 +3201,8 @@ packages:
resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==}
engines: {node: '>=0.6'}
qs@6.15.2:
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
qs@6.16.0:
resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==}
engines: {node: '>=0.6'}
queue-microtask@1.2.3:
@@ -3330,8 +3335,8 @@ packages:
resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
engines: {node: '>= 0.4'}
side-channel@1.1.0:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
side-channel@1.1.1:
resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
engines: {node: '>= 0.4'}
siginfo@2.0.0:
@@ -3395,8 +3400,8 @@ packages:
engines: {node: '>=16'}
hasBin: true
std-env@4.1.0:
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
std-env@4.2.0:
resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
stream-combiner@0.0.4:
resolution: {integrity: sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==}
@@ -3526,8 +3531,8 @@ packages:
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyexec@1.2.4:
resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==}
tinyexec@1.3.1:
resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==}
engines: {node: '>=18'}
tinyglobby@0.2.17:
@@ -3537,8 +3542,8 @@ packages:
tinyqueue@2.0.3:
resolution: {integrity: sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==}
tinyrainbow@3.1.0:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
tinyrainbow@3.1.1:
resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==}
engines: {node: '>=14.0.0'}
tippy.js@6.3.7:
@@ -3706,21 +3711,23 @@ packages:
yaml:
optional: true
vitest@4.1.0:
resolution: {integrity: sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==}
vitest@4.1.11:
resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 4.1.0
'@vitest/browser-preview': 4.1.0
'@vitest/browser-webdriverio': 4.1.0
'@vitest/ui': 4.1.0
'@vitest/browser-playwright': 4.1.11
'@vitest/browser-preview': 4.1.11
'@vitest/browser-webdriverio': 4.1.11
'@vitest/coverage-istanbul': 4.1.11
'@vitest/coverage-v8': 4.1.11
'@vitest/ui': 4.1.11
happy-dom: '*'
jsdom: '*'
vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
@@ -3734,6 +3741,10 @@ packages:
optional: true
'@vitest/browser-webdriverio':
optional: true
'@vitest/coverage-istanbul':
optional: true
'@vitest/coverage-v8':
optional: true
'@vitest/ui':
optional: true
happy-dom:
@@ -4379,30 +4390,30 @@ snapshots:
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/sourcemap-codec': 1.6.0
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/gen-mapping@0.3.8':
dependencies:
'@jridgewell/set-array': 1.2.1
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/sourcemap-codec': 1.6.0
'@jridgewell/trace-mapping': 0.3.25
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/set-array@1.2.1': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/sourcemap-codec@1.6.0': {}
'@jridgewell/trace-mapping@0.3.25':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/sourcemap-codec': 1.6.0
'@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/sourcemap-codec': 1.6.0
'@juggle/resize-observer@3.4.0': {}
@@ -4513,7 +4524,7 @@ snapshots:
detect-libc: 2.1.2
is-glob: 4.0.3
node-addon-api: 7.1.1
picomatch: 4.0.4
picomatch: 4.0.7
optionalDependencies:
'@parcel/watcher-android-arm64': 2.5.6
'@parcel/watcher-darwin-arm64': 2.5.6
@@ -5200,46 +5211,46 @@ snapshots:
vite: 6.4.3(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.9.0)
vue: 3.5.13(typescript@5.7.3)
'@vitest/expect@4.1.0':
'@vitest/expect@4.1.11':
dependencies:
'@standard-schema/spec': 1.1.0
'@types/chai': 5.2.3
'@vitest/spy': 4.1.0
'@vitest/utils': 4.1.0
'@vitest/spy': 4.1.11
'@vitest/utils': 4.1.11
chai: 6.2.2
tinyrainbow: 3.1.0
tinyrainbow: 3.1.1
'@vitest/mocker@4.1.0(vite@6.4.3(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.9.0))':
'@vitest/mocker@4.1.11(vite@6.4.3(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.9.0))':
dependencies:
'@vitest/spy': 4.1.0
'@vitest/spy': 4.1.11
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 6.4.3(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.9.0)
'@vitest/pretty-format@4.1.0':
'@vitest/pretty-format@4.1.11':
dependencies:
tinyrainbow: 3.1.0
tinyrainbow: 3.1.1
'@vitest/runner@4.1.0':
'@vitest/runner@4.1.11':
dependencies:
'@vitest/utils': 4.1.0
'@vitest/utils': 4.1.11
pathe: 2.0.3
'@vitest/snapshot@4.1.0':
'@vitest/snapshot@4.1.11':
dependencies:
'@vitest/pretty-format': 4.1.0
'@vitest/utils': 4.1.0
'@vitest/pretty-format': 4.1.11
'@vitest/utils': 4.1.11
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/spy@4.1.0': {}
'@vitest/spy@4.1.11': {}
'@vitest/utils@4.1.0':
'@vitest/utils@4.1.11':
dependencies:
'@vitest/pretty-format': 4.1.0
'@vitest/pretty-format': 4.1.11
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
tinyrainbow: 3.1.1
'@vue/compiler-core@3.5.13':
dependencies:
@@ -6010,7 +6021,7 @@ snapshots:
es-errors@1.3.0: {}
es-module-lexer@2.1.0: {}
es-module-lexer@2.3.2: {}
es-object-atoms@1.1.2:
dependencies:
@@ -6204,7 +6215,7 @@ snapshots:
dependencies:
pify: 2.3.0
expect-type@1.3.0: {}
expect-type@1.4.0: {}
extend@3.0.2: {}
@@ -6248,6 +6259,10 @@ snapshots:
optionalDependencies:
picomatch: 4.0.4
fdir@6.5.0(picomatch@4.0.7):
optionalDependencies:
picomatch: 4.0.7
file-entry-cache@6.0.1:
dependencies:
flat-cache: 3.2.0
@@ -6682,7 +6697,7 @@ snapshots:
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/sourcemap-codec': 1.6.0
map-stream@0.1.0: {}
@@ -6802,7 +6817,7 @@ snapshots:
object-inspect@1.13.4: {}
obug@2.1.1: {}
obug@2.2.1: {}
ohash@2.0.11: {}
@@ -6894,6 +6909,8 @@ snapshots:
picomatch@4.0.4: {}
picomatch@4.0.7: {}
pify@2.3.0: {}
pinia@2.3.0(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3)):
@@ -7088,11 +7105,12 @@ snapshots:
qs@6.14.2:
dependencies:
side-channel: 1.1.0
side-channel: 1.1.1
qs@6.15.2:
qs@6.16.0:
dependencies:
side-channel: 1.1.0
es-define-property: 1.0.1
side-channel: 1.1.1
queue-microtask@1.2.3: {}
@@ -7271,7 +7289,7 @@ snapshots:
object-inspect: 1.13.4
side-channel-map: 1.0.1
side-channel@1.1.0:
side-channel@1.1.1:
dependencies:
es-errors: 1.3.0
object-inspect: 1.13.4
@@ -7346,7 +7364,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
std-env@4.1.0: {}
std-env@4.2.0: {}
stream-combiner@0.0.4:
dependencies:
@@ -7499,16 +7517,16 @@ snapshots:
tinybench@2.9.0: {}
tinyexec@1.2.4: {}
tinyexec@1.3.1: {}
tinyglobby@0.2.17:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
fdir: 6.5.0(picomatch@4.0.7)
picomatch: 4.0.7
tinyqueue@2.0.3: {}
tinyrainbow@3.1.0: {}
tinyrainbow@3.1.1: {}
tippy.js@6.3.7:
dependencies:
@@ -7628,26 +7646,26 @@ snapshots:
stylus: 0.57.0
yaml: 2.9.0
vitest@4.1.0(@types/node@25.8.0)(jsdom@29.0.1)(vite@6.4.3(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.9.0)):
vitest@4.1.11(@types/node@25.8.0)(jsdom@29.0.1)(vite@6.4.3(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.9.0)):
dependencies:
'@vitest/expect': 4.1.0
'@vitest/mocker': 4.1.0(vite@6.4.3(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.9.0))
'@vitest/pretty-format': 4.1.0
'@vitest/runner': 4.1.0
'@vitest/snapshot': 4.1.0
'@vitest/spy': 4.1.0
'@vitest/utils': 4.1.0
es-module-lexer: 2.1.0
expect-type: 1.3.0
'@vitest/expect': 4.1.11
'@vitest/mocker': 4.1.11(vite@6.4.3(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.9.0))
'@vitest/pretty-format': 4.1.11
'@vitest/runner': 4.1.11
'@vitest/snapshot': 4.1.11
'@vitest/spy': 4.1.11
'@vitest/utils': 4.1.11
es-module-lexer: 2.3.2
expect-type: 1.4.0
magic-string: 0.30.21
obug: 2.1.1
obug: 2.2.1
pathe: 2.0.3
picomatch: 4.0.4
std-env: 4.1.0
picomatch: 4.0.7
std-env: 4.2.0
tinybench: 2.9.0
tinyexec: 1.2.4
tinyexec: 1.3.1
tinyglobby: 0.2.17
tinyrainbow: 3.1.0
tinyrainbow: 3.1.1
vite: 6.4.3(@types/node@25.8.0)(jiti@1.21.7)(sass@1.99.0)(stylus@0.57.0)(yaml@2.9.0)
why-is-node-running: 2.3.0
optionalDependencies:
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+22
View File
@@ -0,0 +1,22 @@
{
"name": "libredesk",
"short_name": "libredesk",
"description": "Customer support desk",
"start_url": "/inboxes/assigned",
"scope": "/",
"display": "standalone",
"background_color": "hsl(0 0% 99.2%)",
"theme_color": "hsl(152 39% 30%)",
"icons": [
{
"src": "/images/pwa-icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/images/pwa-icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+40
View File
@@ -0,0 +1,40 @@
const readPayload = (event) => {
try {
return event.data ? event.data.json() : null
} catch {
return null
}
}
self.addEventListener('install', () => self.skipWaiting())
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim())
})
self.addEventListener('push', (event) => {
const notification = readPayload(event)
if (!notification?.title) return
event.waitUntil(self.registration.showNotification(notification.title, {
body: notification.body,
tag: notification.tag,
icon: '/images/pwa-icon-192.png',
badge: '/images/pwa-icon-192.png',
data: { url: notification.url }
}))
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
const url = event.notification.data?.url
if (!url) return
const target = new URL(url, self.location.origin)
if (target.origin !== self.location.origin) return
event.waitUntil(self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(async (clients) => {
const client = clients[0]
if (!client) return self.clients.openWindow(target.href)
await client.focus()
return client.navigate(target.href).catch(() => self.clients.openWindow(target.href))
}))
})
+12 -1
View File
@@ -121,7 +121,9 @@
// Mention styling — vue-letter scopes classes as msg_XXX_ld-mention
[class$="_ld-mention"],
.ld-mention {
[class$="_ld-conversation-reference"],
.ld-mention,
.ld-conversation-reference {
background-color: hsl(var(--primary) / 0.1);
border-radius: 0.25rem;
padding: 0 0.25rem;
@@ -272,6 +274,15 @@
}
}
// Sender colours are only legible against white, so incoming email HTML is not themed.
.dark .email-light-canvas {
@apply p-2 rounded border border-border;
color-scheme: light;
background: #fff;
color: #1a1a1a;
}
.overlay {
background: hsl(var(--foreground) / 0.5);
}
@@ -0,0 +1,30 @@
<template>
<svg :width="size" :height="size" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<template v-if="level">
<rect
v-for="bar in 3"
:key="bar"
:x="1 + (bar - 1) * 5"
:y="14 - bar * 4"
width="3"
:height="bar * 4"
rx="0.8"
:class="bar <= level ? 'fill-current' : 'fill-border'"
/>
</template>
<circle v-else cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="1.5" />
</svg>
</template>
<script setup>
defineProps({
level: {
type: Number,
default: 0
},
size: {
type: [Number, String],
default: 16
}
})
</script>
+1
View File
@@ -3,6 +3,7 @@ module github.com/abhinavxd/libredesk
go 1.25.0
require (
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/abhinavxd/ssrfguard v0.1.0
github.com/alicebob/miniredis/v2 v2.33.0
github.com/coreos/go-oidc/v3 v3.11.0
+36
View File
@@ -8,6 +8,8 @@ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOEl
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
github.com/abhinavxd/ssrfguard v0.1.0 h1:Ns/llAQ63uGFehxSvhCd+WGDKmBEEmIH+E1AW1CGgWM=
github.com/abhinavxd/ssrfguard v0.1.0/go.mod h1:eNVubb+m/r3KrKWYdG6hxzeAfj+t2ZmZss4V/x7D6Ws=
github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 h1:uvdUDbHQHO85qeSydJtItA4T55Pw6BtAejd0APRJOCE=
@@ -103,10 +105,12 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -293,6 +297,10 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
@@ -300,6 +308,9 @@ golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
@@ -311,6 +322,10 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
@@ -318,6 +333,10 @@ golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -329,24 +348,41 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+34
View File
@@ -118,6 +118,13 @@
"admin.ai.completion": "Completion",
"admin.ai.embedding": "Embedding",
"admin.ai.snippets": "Snippet | Snippets",
"admin.ai.editorPrompts": "Editor prompt | Editor prompts",
"admin.ai.editorPromptsHelp": "Editor prompts are the AI actions agents can run on their draft from the reply box, such as fixing grammar or changing tone.",
"admin.ai.editorPrompt.new": "New editor prompt",
"admin.ai.editorPrompt.edit": "Edit editor prompt",
"admin.ai.editorPrompt.deleteConfirmation": "This will permanently delete the editor prompt and remove it from the reply box.",
"admin.ai.editorPrompt.titlePlaceholder": "Make it friendlier",
"admin.ai.editorPrompt.contentPlaceholder": "Rewrite the text in a warm, friendly tone while keeping the original meaning.",
"admin.ai.tools": "Tools",
"admin.ai.suggestions": "Suggestions",
"admin.ai.suggestion.source": "Source",
@@ -640,6 +647,7 @@
"auth.invalidOrExpiredSessionClearCookie": "Invalid or expired session. Please clear your cookies and try again.",
"auth.invalidResetLink": "Invalid reset link. Please request a new password reset link.",
"auth.loggingIn": "Logging in...",
"auth.localLoginDisabled": "Password login is disabled. Sign in with your SSO provider.",
"auth.newPassword": "New password",
"auth.oidcAccessDenied": "The sign-in was cancelled or denied at the identity provider. Try signing in again.",
"auth.oidcInvalidClient": "Sign-in is unavailable due to a configuration problem. Ask your administrator to check the SSO client ID and secret.",
@@ -715,6 +723,7 @@
"contact.identityNotVerified": "Identity not verified",
"contact.identityVerified": "Identity verified",
"contact.newNote": "New note",
"contact.new": "New contact",
"contact.noContactsFound": "No contacts found",
"contact.notes.empty": "No notes yet",
"contact.notes.help": "Add note for this contact to keep track of important information and conversations.",
@@ -968,6 +977,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",
@@ -1039,6 +1049,8 @@
"globals.terms.bodyText": "Body text",
"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.button": "Button | Buttons",
"globals.terms.buttonText": "Button text",
@@ -1116,6 +1128,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",
@@ -1449,11 +1462,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.",
+67
View File
@@ -98,6 +98,10 @@ type queries struct {
UpdateProviderConfig *sqlx.Stmt `query:"update-provider-config"`
GetPrompt *sqlx.Stmt `query:"get-prompt"`
GetPrompts *sqlx.Stmt `query:"get-prompts"`
GetPromptByID *sqlx.Stmt `query:"get-prompt-by-id"`
InsertPrompt *sqlx.Stmt `query:"insert-prompt"`
UpdatePrompt *sqlx.Stmt `query:"update-prompt"`
DeletePrompt *sqlx.Stmt `query:"delete-prompt"`
GetKnowledgeBaseItems *sqlx.Stmt `query:"get-knowledge-base-items"`
GetKnowledgeBaseItem *sqlx.Stmt `query:"get-knowledge-base-item"`
KnowledgeBaseItemExists *sqlx.Stmt `query:"knowledge-base-item-exists"`
@@ -242,6 +246,59 @@ func (m *Manager) GetPrompts() ([]models.Prompt, error) {
return prompts, nil
}
// GetPromptByID returns a prompt with its content for the admin editor.
func (m *Manager) GetPromptByID(id int) (models.Prompt, error) {
var p models.Prompt
if err := m.q.GetPromptByID.Get(&p, id); err != nil {
if err == sql.ErrNoRows {
return p, envelope.NewError(envelope.NotFoundError, m.i18n.T("globals.messages.notFound"), nil)
}
m.lo.Error("error fetching prompt", "error", err)
return p, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
return p, nil
}
// CreatePrompt stores a new editor prompt; the key is derived from the title and never changes.
func (m *Manager) CreatePrompt(title, content string) (models.Prompt, error) {
var p models.Prompt
if err := m.validatePrompt(title, content); err != nil {
return p, err
}
key := strings.ReplaceAll(stringutil.GenerateSlug(title), "-", "_")
if err := m.q.InsertPrompt.Get(&p, key, strings.TrimSpace(title), strings.TrimSpace(content)); err != nil {
if dbutil.IsUniqueViolationError(err) {
return p, envelope.NewError(envelope.ConflictError, m.i18n.T("globals.messages.errorAlreadyExists"), nil)
}
m.lo.Error("error creating prompt", "error", err)
return p, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
return p, nil
}
func (m *Manager) UpdatePrompt(id int, title, content string) (models.Prompt, error) {
var p models.Prompt
if err := m.validatePrompt(title, content); err != nil {
return p, err
}
if err := m.q.UpdatePrompt.Get(&p, id, strings.TrimSpace(title), strings.TrimSpace(content)); err != nil {
if err == sql.ErrNoRows {
return p, envelope.NewError(envelope.NotFoundError, m.i18n.T("globals.messages.notFound"), nil)
}
m.lo.Error("error updating prompt", "error", err)
return p, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
return p, nil
}
func (m *Manager) DeletePrompt(id int) error {
if _, err := m.q.DeletePrompt.Exec(id); err != nil {
m.lo.Error("error deleting prompt", "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
return nil
}
// GetProviderConfig returns the sanitized config for a provider type (no API key).
func (m *Manager) GetProviderConfig(providerType string) (ProviderConfigView, error) {
cfg, err := m.getProviderConfig(providerType)
@@ -350,6 +407,16 @@ func (m *Manager) TestProviderConfig(providerType string, in models.ProviderConf
return nil
}
func (m *Manager) validatePrompt(title, content string) error {
if strings.TrimSpace(title) == "" {
return envelope.NewError(envelope.InputError, m.i18n.Ts("globals.messages.empty", "name", m.i18n.T("globals.terms.title")), nil)
}
if strings.TrimSpace(content) == "" {
return envelope.NewError(envelope.InputError, m.i18n.Ts("globals.messages.empty", "name", m.i18n.T("globals.terms.content")), nil)
}
return nil
}
func (m *Manager) getPrompt(k string) (string, error) {
var p models.Prompt
if err := m.q.GetPrompt.Get(&p, k); err != nil {
+14
View File
@@ -10,6 +10,20 @@ SELECT id, created_at, updated_at, key, title, content FROM ai_prompts WHERE key
-- name: get-prompts
SELECT id, created_at, updated_at, key, title FROM ai_prompts ORDER BY title;
-- name: get-prompt-by-id
SELECT id, created_at, updated_at, key, title, content FROM ai_prompts WHERE id = $1;
-- name: insert-prompt
INSERT INTO ai_prompts (key, title, content) VALUES ($1, $2, $3)
RETURNING id, created_at, updated_at, key, title, content;
-- name: update-prompt
UPDATE ai_prompts SET title = $2, content = $3, updated_at = now() WHERE id = $1
RETURNING id, created_at, updated_at, key, title, content;
-- name: delete-prompt
DELETE FROM ai_prompts WHERE id = $1;
-- name: get-knowledge-base-items
SELECT id, created_at, updated_at, type, title, content, enabled, source, source_url, embedded_fingerprint FROM ai_knowledge_base ORDER BY updated_at DESC;
+243 -141
View File
@@ -2,12 +2,15 @@
package conversation
import (
"cmp"
"context"
"database/sql"
"embed"
"encoding/json"
"errors"
"fmt"
"html"
htmltemplate "html/template"
"io"
"slices"
"strconv"
@@ -29,7 +32,6 @@ import (
"github.com/abhinavxd/libredesk/internal/inbox"
imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
mmodels "github.com/abhinavxd/libredesk/internal/media/models"
notifier "github.com/abhinavxd/libredesk/internal/notification"
nmodels "github.com/abhinavxd/libredesk/internal/notification/models"
slaModels "github.com/abhinavxd/libredesk/internal/sla/models"
"github.com/abhinavxd/libredesk/internal/stringutil"
@@ -67,6 +69,7 @@ const (
<p>
<a href="{{ RootURL }}/inboxes/all/conversation/{{ .Conversation.UUID }}">#{{ .Conversation.ReferenceNumber }}</a>
</p>`
newReplyEmailDelay = 5 * time.Minute
)
var conversationFilterRenderers = dbutil.FieldRenderers{
@@ -82,6 +85,10 @@ var conversationListAllowedFields = dbutil.AllowedFields{
"inboxes": inboxesAllowedFields,
}
type notificationDispatcher interface {
Send(nmodels.Notification) ([]nmodels.DeliveryResult, error)
}
// Manager handles the operations related to conversations
type Manager struct {
q queries
@@ -95,7 +102,7 @@ type Manager struct {
settingsStore settingsStore
csatStore csatStore
webhookStore webhookStore
dispatcher *notifier.Dispatcher
dispatcher notificationDispatcher
lo *logf.Logger
db *sqlx.DB
i18n *i18n.I18n
@@ -114,6 +121,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)
@@ -166,7 +180,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
@@ -251,7 +264,7 @@ func New(
automation *automation.Engine,
template *template.Manager,
webhook webhookStore,
dispatcher *notifier.Dispatcher,
dispatcher notificationDispatcher,
opts Opts) (*Manager, error) {
var q queries
@@ -327,6 +340,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"`
@@ -711,14 +725,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.
@@ -729,9 +743,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.
@@ -839,7 +853,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.
@@ -1239,64 +1253,157 @@ func (m *Manager) SendTransientEmail(inboxID, conversationID int, conversationUU
// NotifyAssignment sends notifications (in-app, WebSocket, email) for an assigned conversation.
func (m *Manager) NotifyAssignment(userIDs []int, conversation models.Conversation) error {
agent, err := m.userStore.GetAgent(userIDs[0], "")
agent, err := m.userStore.GetAgentCachedOrLoad(userIDs[0])
if err != nil {
m.lo.Error("error fetching agent", "user_id", userIDs[0], "error", err)
return fmt.Errorf("fetching agent: %w", err)
}
// Render email template.
// Automated messages do not have an author.
content, subject, err := m.template.RenderStoredEmailTemplate(template.TmplConversationAssigned,
map[string]any{
"Conversation": map[string]any{
"ReferenceNumber": conversation.ReferenceNumber,
"Subject": conversation.Subject.String,
"Priority": conversation.Priority.String,
"UUID": conversation.UUID,
},
"Contact": map[string]any{
"FirstName": conversation.Contact.FirstName,
"LastName": conversation.Contact.LastName,
"FullName": conversation.Contact.FullName(),
"Email": conversation.Contact.Email.String,
},
"Recipient": map[string]any{
"FirstName": agent.FirstName,
"LastName": agent.LastName,
"FullName": agent.FullName(),
"Email": agent.Email.String,
},
// Automated messages do not have an author.
"Author": map[string]any{
"FirstName": "",
"LastName": "",
"FullName": "",
"Email": "",
},
})
notificationTemplateData(conversation, agent, umodels.User{}))
if err != nil {
m.lo.Error("error rendering template", "template", template.TmplConversationAssigned, "conversation_uuid", conversation.UUID, "error", err)
return fmt.Errorf("rendering template: %w", err)
}
// Send notification.
m.dispatcher.Send(notifier.Notification{
Type: nmodels.NotificationTypeAssignment,
RecipientIDs: []int{agent.ID},
if _, err := m.dispatcher.Send(nmodels.Notification{
Type: nmodels.NotificationTypeAssignment,
Recipients: []nmodels.Recipient{{
UserID: agent.ID,
Email: &nmodels.EmailNotification{
Recipient: agent.Email.String,
Subject: subject,
Content: content,
},
}},
Title: m.i18n.Ts("notification.conversationAssigned", "referenceNumber", conversation.ReferenceNumber),
Body: conversation.Subject,
Body: null.StringFrom(cmp.Or(conversation.Subject.String, conversation.LastMessage.String)),
ConversationID: null.IntFrom(conversation.ID),
ConversationUUID: conversation.UUID,
// Parms required for email
Email: &notifier.EmailNotification{
Recipients: []string{agent.Email.String},
Subject: subject,
Content: content,
},
})
}); err != nil {
return fmt.Errorf("sending assignment notification: %w", err)
}
return nil
}
// NotifyNewReply notifies the assigned agent and the other participating agents of an incoming reply.
func (m *Manager) NotifyNewReply(conversation models.Conversation, message models.Message, reopened bool) {
assigneeType, assigneeTmpl, assigneeTitleKey := nmodels.NotificationTypeNewReply, template.TmplNewReply, "notification.newReply"
if reopened {
assigneeType, assigneeTmpl, assigneeTitleKey = nmodels.NotificationTypeConversationReopened, template.TmplConversationReopened, "notification.conversationReopened"
}
groups := []replyNotificationGroup{
{
nType: assigneeType,
tmpl: assigneeTmpl,
title: m.i18n.Ts(assigneeTitleKey, "referenceNumber", conversation.ReferenceNumber),
recipients: m.replyNotificationAssignee(conversation, message.SenderID),
},
{
nType: nmodels.NotificationTypeNewReplyParticipating,
tmpl: template.TmplNewReplyParticipating,
title: m.i18n.Ts("notification.newReply", "referenceNumber", conversation.ReferenceNumber),
recipients: m.replyNotificationParticipants(conversation, message.SenderID),
},
}
if !slices.ContainsFunc(groups, func(g replyNotificationGroup) bool { return len(g.recipients) > 0 }) {
return
}
author, err := m.userStore.Get(message.SenderID, "", []string{})
if err != nil {
m.lo.Error("error fetching sender for new reply notification", "user_id", message.SenderID, "error", err)
return
}
for _, group := range groups {
m.sendReplyNotification(conversation, message, group, author)
}
}
func (m *Manager) replyNotificationAssignee(conversation models.Conversation, senderID int) []umodels.User {
assigneeID := conversation.AssignedUserID.Int
if assigneeID == 0 || assigneeID == senderID {
return nil
}
agent, ok := m.notifiableAgent(assigneeID, conversation)
if !ok {
return nil
}
return []umodels.User{agent}
}
func (m *Manager) replyNotificationParticipants(conversation models.Conversation, senderID int) []umodels.User {
var participants []umodels.User
if err := m.q.GetConversationParticipantAgents.Select(&participants, conversation.UUID); err != nil {
m.lo.Error("error fetching participants for new reply notification", "conversation_uuid", conversation.UUID, "error", err)
return nil
}
agents := make([]umodels.User, 0, len(participants))
for _, participant := range participants {
if participant.ID == senderID || participant.ID == conversation.AssignedUserID.Int {
continue
}
if agent, ok := m.notifiableAgent(participant.ID, conversation); ok {
agents = append(agents, agent)
}
}
return agents
}
func (m *Manager) notifiableAgent(userID int, conversation models.Conversation) (umodels.User, bool) {
agent, err := m.userStore.GetAgentCachedOrLoad(userID)
if err != nil {
m.lo.Error("error fetching agent for new reply notification", "user_id", userID, "error", err)
return umodels.User{}, false
}
if !agent.Enabled || !authz.CanReadAssignment(agent, conversation.AssignedUserID, conversation.AssignedTeamID) {
return umodels.User{}, false
}
return agent, true
}
func (m *Manager) sendReplyNotification(conversation models.Conversation, message models.Message, group replyNotificationGroup, author umodels.User) {
if len(group.recipients) == 0 {
return
}
messageText := replyNotificationText(message)
candidateIDs := make([]int, len(group.recipients))
for i, recipient := range group.recipients {
candidateIDs[i] = recipient.ID
}
recipients := m.buildNotificationRecipients(candidateIDs, func(recipient umodels.User) (string, string, error) {
data := notificationTemplateData(conversation, recipient, author)
data["Message"] = map[string]any{
"UUID": message.UUID,
"Content": htmltemplate.HTML(strings.ReplaceAll(html.EscapeString(messageText), "\n", "<br>")),
}
content, subject, err := m.template.RenderStoredEmailTemplate(group.tmpl, data)
return subject, content, err
})
for i := range recipients {
if recipients[i].Email != nil {
recipients[i].Email.Delay = newReplyEmailDelay
}
}
if _, err := m.dispatcher.Send(nmodels.Notification{
Type: group.nType,
Recipients: recipients,
Title: group.title,
Body: null.StringFrom(cmp.Or(messageText, conversation.Subject.String)),
ConversationID: null.IntFrom(conversation.ID),
MessageID: null.IntFrom(message.ID),
ConversationUUID: conversation.UUID,
MessageUUID: message.UUID,
}); err != nil {
m.lo.Error("error sending reply notification", "type", group.nType, "error", err)
}
}
// NotifyMention sends notifications (in-app, WebSocket, email) for mentions.
// For team mentions, expands to all team members.
func (m *Manager) NotifyMention(conversationUUID string, message models.Message, mentions []models.MentionInput, mentionedByUserID int) {
@@ -1307,7 +1414,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
@@ -1341,59 +1448,47 @@ func (m *Manager) NotifyMention(conversationUUID string, message models.Message,
userIDs = append(userIDs, userID)
}
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": message.Content,
},
"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": "",
},
})
rootURL, err := m.settingsStore.GetAppRootURL()
if err != nil {
m.lo.Error("error fetching root URL for mention notification", "error", err)
}
messageContent := htmltemplate.HTML(absolutizeConversationReferenceLinks(message.Content, rootURL))
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.
@@ -1568,83 +1663,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 {
@@ -1990,7 +2055,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 {
@@ -2287,6 +2352,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))
@@ -2342,3 +2436,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)
}
@@ -0,0 +1,16 @@
package conversation
import (
"strings"
)
func absolutizeConversationReferenceLinks(content, rootURL string) string {
if rootURL == "" {
return content
}
return strings.ReplaceAll(
content,
`href="/inboxes/all/conversation/`,
`href="`+strings.TrimRight(rootURL, "/")+`/inboxes/all/conversation/`,
)
}
@@ -0,0 +1,12 @@
package conversation
import "testing"
func TestAbsolutizeConversationReferenceLinks(t *testing.T) {
content := `<p><a href="/inboxes/all/conversation/abc" class="ld-conversation-reference">#108</a> <a href="/help">Help</a> <a href="https://old.example.com/inboxes/all/conversation/def">#109</a></p>`
want := `<p><a href="https://desk.example.com/inboxes/all/conversation/abc" class="ld-conversation-reference">#108</a> <a href="/help">Help</a> <a href="https://old.example.com/inboxes/all/conversation/def">#109</a></p>`
if got := absolutizeConversationReferenceLinks(content, "https://desk.example.com"); got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
+12 -6
View File
@@ -565,7 +565,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)
}
@@ -982,7 +982,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)
}
@@ -1104,7 +1104,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)
}
@@ -1124,7 +1124,7 @@ func (m *Manager) ProcessIncomingWhatsAppMessage(msg models.Message, isNewConver
// Hooks run even if the window update fails: the message is stored, so the queue retry only repairs the window.
windowErr := m.UpdateConversationLastInboundAt(msg.ConversationID, inboundAt)
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)
}
@@ -1512,7 +1512,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())
@@ -1533,11 +1535,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)
}
}
@@ -1551,6 +1555,8 @@ func (m *Manager) ProcessIncomingMessageHooks(conversationUUID string, isNewConv
// Trigger automations on incoming message event.
m.automation.EvaluateConversationUpdateRules(conversation, amodels.EventConversationMessageIncoming, previousValues, umodels.User{ID: conversation.ContactID})
go m.NotifyNewReply(conversation, message, reopened)
// If assigned to an AI assistant, let it respond to this inbound customer message.
if m.aiAgent != nil && conversation.AssignedUserID.Valid {
m.aiAgent.HandleConversationEvent(conversation.ID, conversation.AssignedUserID.Int)
+321
View File
@@ -0,0 +1,321 @@
package conversation
import (
"errors"
htmltemplate "html/template"
"slices"
"strings"
"sync"
"testing"
"time"
authzmodels "github.com/abhinavxd/libredesk/internal/authz/models"
"github.com/abhinavxd/libredesk/internal/conversation/models"
"github.com/abhinavxd/libredesk/internal/dbutil"
notifier "github.com/abhinavxd/libredesk/internal/notification"
nchannels "github.com/abhinavxd/libredesk/internal/notification/channels"
nmodels "github.com/abhinavxd/libredesk/internal/notification/models"
"github.com/abhinavxd/libredesk/internal/template"
"github.com/abhinavxd/libredesk/internal/testutil"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
"github.com/jmoiron/sqlx"
"github.com/volatiletech/null/v9"
"github.com/zerodha/logf"
)
type replyUserStore struct {
userStore
agent umodels.User
err error
}
type replyPreferences struct {
recipients []int
channels []nmodels.NotificationChannel
err error
mu sync.Mutex
}
type countingPush struct {
count int
}
func (p *countingPush) Send(int, nmodels.PushPayload) bool {
p.count++
return true
}
func (s replyUserStore) Get(int, string, []string) (umodels.User, error) {
return umodels.User{FirstName: "Contact"}, nil
}
func (s replyUserStore) GetAgentCachedOrLoad(int) (umodels.User, error) { return s.agent, s.err }
func (p *replyPreferences) EnabledChannels(ids []int, _ nmodels.NotificationType) (map[int][]nmodels.NotificationChannel, error) {
p.mu.Lock()
defer p.mu.Unlock()
p.recipients = append(p.recipients, ids...)
if p.err != nil {
return nil, p.err
}
enabled := make(map[int][]nmodels.NotificationChannel)
if len(p.channels) > 0 {
for _, id := range ids {
enabled[id] = slices.Clone(p.channels)
}
}
return enabled, nil
}
func TestNotifyNewReplyChecksParticipantAccess(t *testing.T) {
db := testutil.NewDB(t, "reply_access")
var agentID, inboxID int
if err := db.Get(&agentID, `INSERT INTO users (type, email, first_name, last_name) VALUES ('agent', 'participant@example.com', 'Agent', '') RETURNING id`); err != nil {
t.Fatal(err)
}
if err := db.Get(&inboxID, `INSERT INTO inboxes (name, channel) VALUES ('Test', 'email') RETURNING id`); err != nil {
t.Fatal(err)
}
var conv models.Conversation
if err := db.Get(&conv, `INSERT INTO conversations (contact_id, inbox_id, status_id) VALUES ($1, $2, (SELECT id FROM conversation_statuses LIMIT 1)) RETURNING id, uuid`, agentID, inboxID); err != nil {
t.Fatal(err)
}
db.MustExec(`INSERT INTO conversation_participants (conversation_id, user_id) VALUES ($1, $2)`, conv.ID, agentID)
var q struct {
Participants *sqlx.Stmt `query:"get-conversation-participant-agents"`
}
if err := dbutil.ScanSQLFile("queries.sql", &q, db, efs); err != nil {
t.Fatal(err)
}
lo := logf.New(logf.Opts{})
for _, tt := range []struct {
name string
permissions []string
enabled bool
err error
want bool
}{
{"former assignee", []string{authzmodels.PermConversationsRead, authzmodels.PermConversationsReadAssigned}, true, nil, false},
{"read all", []string{authzmodels.PermConversationsRead, authzmodels.PermConversationsReadAll}, true, nil, true},
{"missing base permission", []string{authzmodels.PermConversationsReadAll}, true, nil, false},
{"disabled", []string{authzmodels.PermConversationsRead, authzmodels.PermConversationsReadAll}, false, nil, false},
{"load failed", nil, true, errors.New("unavailable"), false},
} {
t.Run(tt.name, func(t *testing.T) {
m := &Manager{
lo: &lo,
userStore: replyUserStore{
agent: umodels.User{ID: agentID, Enabled: tt.enabled, Permissions: tt.permissions},
err: tt.err,
},
}
m.q.GetConversationParticipantAgents = q.Participants
conv.AssignedUserID = null.IntFrom(agentID + 1)
recipients := m.replyNotificationParticipants(conv, agentID+1)
if got := slices.ContainsFunc(recipients, func(recipient umodels.User) bool { return recipient.ID == agentID }); got != tt.want {
t.Fatalf("participant eligible = %v, want %v", got, tt.want)
}
})
}
}
func TestNotifyNewReplyChecksAssigneeAccess(t *testing.T) {
lo := logf.New(logf.Opts{})
for _, tt := range []struct {
name string
enabled bool
permissions []string
want bool
}{
{"assigned access", true, []string{authzmodels.PermConversationsRead, authzmodels.PermConversationsReadAssigned}, true},
{"disabled", false, []string{authzmodels.PermConversationsRead, authzmodels.PermConversationsReadAssigned}, false},
{"missing base permission", true, []string{authzmodels.PermConversationsReadAssigned}, false},
{"missing assignment access", true, []string{authzmodels.PermConversationsRead}, false},
} {
t.Run(tt.name, func(t *testing.T) {
m := &Manager{lo: &lo, userStore: replyUserStore{agent: umodels.User{ID: 42, Enabled: tt.enabled, Permissions: tt.permissions}}}
recipients := m.replyNotificationAssignee(models.Conversation{AssignedUserID: null.IntFrom(42)}, 7)
if got := slices.ContainsFunc(recipients, func(recipient umodels.User) bool { return recipient.ID == 42 }); got != tt.want {
t.Fatalf("assignee eligible = %v, want %v", got, tt.want)
}
})
}
}
func TestReplyNotificationsAlertForEveryMessage(t *testing.T) {
db := testutil.NewDB(t, "reply_notification_history")
var userID, inboxID int
if err := db.Get(&userID, `INSERT INTO users (type, email, first_name, last_name) VALUES ('agent', 'history@example.com', 'Agent', '') RETURNING id`); err != nil {
t.Fatal(err)
}
if err := db.Get(&inboxID, `INSERT INTO inboxes (name, channel) VALUES ('Test', 'email') RETURNING id`); err != nil {
t.Fatal(err)
}
var conv models.Conversation
if err := db.Get(&conv, `INSERT INTO conversations (contact_id, inbox_id, status_id) VALUES ($1, $2, (SELECT id FROM conversation_statuses LIMIT 1)) RETURNING id, uuid`, userID, inboxID); err != nil {
t.Fatal(err)
}
var messageID int
if err := db.Get(&messageID, `INSERT INTO conversation_messages (conversation_id, sender_id, sender_type, type, status, content, text_content, created_at) VALUES ($1, $2, 'contact', 'incoming', 'received', 'Old reply', 'Old reply', $3) RETURNING id`, conv.ID, userID, time.Now().Add(-time.Hour)); err != nil {
t.Fatal(err)
}
var q struct {
Participants *sqlx.Stmt `query:"get-conversation-participant-agents"`
}
if err := dbutil.ScanSQLFile("queries.sql", &q, db, efs); err != nil {
t.Fatal(err)
}
lo := logf.New(logf.Opts{})
i18n := testutil.NewI18n(t)
inApp, err := notifier.NewUserNotificationManager(notifier.UserNotificationOpts{DB: db, Lo: &lo, I18n: i18n})
if err != nil {
t.Fatal(err)
}
emailQueue, err := notifier.NewEmailQueue(notifier.EmailQueueOpts{DB: db, Lo: &lo})
if err != nil {
t.Fatal(err)
}
templates, err := template.New(&lo, db, nil, nil, htmltemplate.FuncMap{"RootURL": func() string { return "http://localhost" }}, i18n)
if err != nil {
t.Fatal(err)
}
conv.AssignedUserID = null.IntFrom(userID)
newManager := func(channels ...nmodels.NotificationChannel) *Manager {
db.MustExec(`DELETE FROM notification_email_queue`)
db.MustExec(`DELETE FROM user_notifications`)
m := &Manager{lo: &lo, i18n: i18n, template: templates,
userStore: replyUserStore{agent: umodels.User{ID: userID, Email: null.StringFrom("history@example.com"), Enabled: true, Permissions: []string{authzmodels.PermConversationsRead, authzmodels.PermConversationsReadAssigned}}},
dispatcher: notifier.NewDispatcher(notifier.DispatcherOpts{
Pipeline: nchannels.NewPipeline(nchannels.NewInApp(inApp, nil, &lo), nchannels.NewEmail(emailQueue)),
Prefs: &replyPreferences{channels: channels},
}),
}
m.q.GetConversationParticipantAgents = q.Participants
return m
}
notifications := func() int {
var count int
db.Get(&count, `SELECT count(*) FROM user_notifications`)
return count
}
message := models.Message{ID: messageID, SenderID: userID + 1, CreatedAt: time.Now().UTC().Truncate(time.Microsecond)}
t.Run("each reply creates an in-app alert", func(t *testing.T) {
m := newManager(nmodels.NotificationChannelInApp)
m.NotifyNewReply(conv, message, false)
if notifications() != 1 {
t.Fatalf("notifications after first reply = %d, want 1", notifications())
}
next := message
next.CreatedAt = message.CreatedAt.Add(time.Second)
m.NotifyNewReply(conv, next, false)
if notifications() != 2 {
t.Fatalf("notifications after second reply = %d, want 2", notifications())
}
m.NotifyNewReply(conv, next, true)
if notifications() != 3 {
t.Fatalf("notifications after reopened reply = %d, want 3", notifications())
}
})
t.Run("reply notifications remove quoted text", func(t *testing.T) {
m := newManager(nmodels.NotificationChannelInApp, nmodels.NotificationChannelEmail)
replyConversation := conv
replyConversation.Subject = null.StringFrom("Conversation subject")
reply := message
reply.ContentType = models.ContentTypeHTML
reply.Content = `<div>Live chat reply</div><div class="gmail_quote gmail_quote_container"><blockquote>Earlier message</blockquote></div>`
reply.TextContent = "Live chat reply\nEarlier message"
m.NotifyNewReply(replyConversation, reply, false)
var body string
db.Get(&body, `SELECT body FROM user_notifications LIMIT 1`)
if body != "Live chat reply" {
t.Fatalf("notification body = %q, want fresh reply", body)
}
var emailContent string
db.Get(&emailContent, `SELECT content FROM notification_email_queue LIMIT 1`)
if !strings.Contains(emailContent, "Live chat reply") || strings.Contains(emailContent, "Earlier message") {
t.Fatalf("queued email contains quoted history: %q", emailContent)
}
})
t.Run("assignment body falls back to last message", func(t *testing.T) {
m := newManager(nmodels.NotificationChannelInApp)
assigned := conv
assigned.LastMessage = null.StringFrom("Live chat opening message")
if err := m.NotifyAssignment([]int{userID}, assigned); err != nil {
t.Fatal(err)
}
var body string
db.Get(&body, `SELECT body FROM user_notifications LIMIT 1`)
if body != assigned.LastMessage.String {
t.Fatalf("notification body = %q, want %q", body, assigned.LastMessage.String)
}
})
t.Run("replies arriving together alert individually", func(t *testing.T) {
m := newManager(nmodels.NotificationChannelInApp)
var wg sync.WaitGroup
for range 8 {
wg.Go(func() { m.NotifyNewReply(conv, message, false) })
}
wg.Wait()
if notifications() != 8 {
t.Fatalf("notifications from concurrent replies = %d, want 8", notifications())
}
})
t.Run("push alerts for every reply", func(t *testing.T) {
m := newManager(nmodels.NotificationChannelPush)
push := &countingPush{}
m.dispatcher = notifier.NewDispatcher(notifier.DispatcherOpts{
Pipeline: nchannels.NewPipeline(nchannels.NewInApp(inApp, nil, &lo), nchannels.NewEmail(emailQueue), nchannels.NewPush(push)),
Prefs: &replyPreferences{channels: []nmodels.NotificationChannel{nmodels.NotificationChannelPush}},
})
m.NotifyNewReply(conv, message, false)
m.NotifyNewReply(conv, message, false)
if push.count != 2 {
t.Fatalf("push notifications = %d, want 2", push.count)
}
})
t.Run("agent with no channel enabled is not notified", func(t *testing.T) {
m := newManager()
m.NotifyNewReply(conv, message, false)
if notifications() != 0 {
t.Fatalf("notifications = %d, want 0", notifications())
}
})
t.Run("preference failure sends nothing", func(t *testing.T) {
m := newManager()
m.dispatcher = notifier.NewDispatcher(notifier.DispatcherOpts{Prefs: &replyPreferences{err: errors.New("lookup failed")}})
m.NotifyNewReply(conv, message, false)
if notifications() != 0 {
t.Fatalf("notifications = %d, want 0", notifications())
}
})
t.Run("email replies are clubbed in the queue", func(t *testing.T) {
m := newManager(nmodels.NotificationChannelEmail)
m.NotifyNewReply(conv, message, false)
next := message
next.CreatedAt = message.CreatedAt.Add(time.Minute)
m.NotifyNewReply(conv, next, false)
var queued int
db.Get(&queued, `SELECT count(*) FROM notification_email_queue`)
if queued != 1 {
t.Fatalf("queued emails = %d, want 1", queued)
}
})
}
+10
View File
@@ -545,6 +545,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)
+6 -1
View File
@@ -106,11 +106,16 @@ func (m *Manager) UpdateResponse(uuid string, score int, feedback string, meta j
meta = json.RawMessage(`{}`)
}
_, err = m.q.Update.Exec(uuid, score, feedback, meta)
res, err := m.q.Update.Exec(uuid, score, feedback, meta)
if err != nil {
m.lo.Error("error updating CSAT", "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
// The UPDATE is guarded by response_timestamp IS NULL, so a concurrent
// request that already recorded a response updates zero rows here.
if n, _ := res.RowsAffected(); n == 0 {
return envelope.NewError(envelope.InputError, m.i18n.T("csat.alreadySubmitted"), nil)
}
return nil
}
+1 -1
View File
@@ -23,4 +23,4 @@ SET rating = $2,
feedback = $3,
meta = COALESCE($4::jsonb, '{}'),
response_timestamp = NOW()
WHERE uuid = $1;
WHERE uuid = $1 AND response_timestamp IS NULL;
+1 -1
View File
@@ -11,7 +11,7 @@ func IsValidHTTPURL(raw string) bool {
if err != nil {
return false
}
return u.Scheme == "http" || u.Scheme == "https"
return (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
}
// IsOriginTrusted checks if the given origin is trusted based on the trusted domains list
+28
View File
@@ -0,0 +1,28 @@
package httputil
import "testing"
func TestIsValidHTTPURL(t *testing.T) {
tests := []struct {
name string
url string
want bool
}{
{name: "HTTPS", url: "https://desk.example.com", want: true},
{name: "HTTP localhost", url: "http://localhost:9000", want: true},
{name: "path", url: "https://desk.example.com/app", want: true},
{name: "empty", url: "", want: false},
{name: "missing host", url: "https:", want: false},
{name: "relative", url: "/app", want: false},
{name: "unsupported scheme", url: "javascript:alert(1)", want: false},
{name: "attribute injection", url: `https://desk.example.com" onclick="alert(1)`, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsValidHTTPURL(tt.url); got != tt.want {
t.Fatalf("IsValidHTTPURL(%q) = %v, want %v", tt.url, got, tt.want)
}
})
}
}
+195 -1
View File
@@ -11,6 +11,19 @@ import (
const lastResolvedAtBatchSize = 10000
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(`ALTER TYPE channels ADD VALUE IF NOT EXISTS 'whatsapp';`)
if err != nil {
@@ -117,11 +130,192 @@ func V2_9_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
return err
}
_, 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');
`)
if err != nil {
return err
+104
View File
@@ -52,3 +52,107 @@ func TestV2_9_0PrivateNotePermissionMigration(t *testing.T) {
}
}
}
func TestV2_9_0NotificationMigration(t *testing.T) {
db := testutil.NewDB(t, "migration_v2_9_0_notifications")
db.MustExec(`
DROP TABLE notification_email_queue;
DROP TABLE notification_push_subscriptions;
DROP TABLE user_notification_preferences;
DROP TYPE notification_channel;
DELETE FROM settings WHERE "key" IN (
'notification.push.vapid_public_key',
'notification.push.vapid_private_key'
);
DELETE FROM templates WHERE "name" IN (
'New reply from contact',
'New reply on participating conversation',
'Conversation reopened'
);
`)
for range 2 {
if err := V2_9_0(db, nil, nil); err != nil {
t.Fatalf("running migration: %v", err)
}
}
for _, table := range []string{
"user_notification_preferences",
"notification_push_subscriptions",
"notification_email_queue",
} {
var exists bool
if err := db.Get(&exists, `SELECT to_regclass($1) IS NOT NULL`, table); err != nil {
t.Fatalf("checking table %q: %v", table, err)
}
if !exists {
t.Errorf("table %q was not created", table)
}
}
var pushChannel bool
if err := db.Get(&pushChannel, `
SELECT EXISTS (
SELECT 1 FROM pg_enum e
JOIN pg_type t ON t.oid = e.enumtypid
WHERE t.typname = 'notification_channel' AND e.enumlabel = 'push'
)
`); err != nil {
t.Fatalf("checking push channel: %v", err)
}
if !pushChannel {
t.Error("push notification channel was not created")
}
var settingCount, templateCount int
if err := db.Get(&settingCount, `SELECT COUNT(*) FROM settings WHERE "key" LIKE 'notification.push.vapid_%'`); err != nil {
t.Fatalf("counting VAPID settings: %v", err)
}
if settingCount != 2 {
t.Errorf("VAPID setting count = %d, want 2", settingCount)
}
if err := db.Get(&templateCount, `
SELECT COUNT(*) FROM templates WHERE "name" IN (
'New reply from contact',
'New reply on participating conversation',
'Conversation reopened'
)
`); err != nil {
t.Fatalf("counting notification templates: %v", err)
}
if templateCount != 3 {
t.Errorf("notification template count = %d, want 3", templateCount)
}
}
func TestV2_9_0PreservesExistingQueuedEmails(t *testing.T) {
db := testutil.NewDB(t, "migration_v2_9_0_queue")
columnQuery := `SELECT data_type || ':' || is_nullable || ':' || COALESCE(column_default, '') FROM information_schema.columns WHERE table_name = 'notification_email_queue' AND column_name = $1`
var expectedAttemptsColumn string
if err := db.Get(&expectedAttemptsColumn, columnQuery, "attempts"); err != nil {
t.Fatal(err)
}
db.MustExec(`ALTER TABLE notification_email_queue DROP COLUMN attempts`)
db.MustExec(`INSERT INTO users (type, email, first_name, last_name) VALUES ('agent', 'queued@example.com', 'Agent', '')`)
db.MustExec(`INSERT INTO notification_email_queue (user_id, notification_type, recipient_email, subject, content, send_at) VALUES ((SELECT id FROM users LIMIT 1), 'new_reply', 'queued@example.com', 'Reply', 'Pending reply', now())`)
for range 2 {
if err := V2_9_0(db, nil, nil); err != nil {
t.Fatal(err)
}
}
var actualAttemptsColumn string
if err := db.Get(&actualAttemptsColumn, columnQuery, "attempts"); err != nil {
t.Fatal(err)
}
if actualAttemptsColumn != expectedAttemptsColumn {
t.Fatalf("attempts column = %q, schema column = %q", actualAttemptsColumn, expectedAttemptsColumn)
}
var preserved bool
if err := db.Get(&preserved, `SELECT content = 'Pending reply' AND attempts = 0 FROM notification_email_queue`); err != nil {
t.Fatal(err)
}
if !preserved {
t.Fatal("existing queued email changed")
}
}
+44
View File
@@ -0,0 +1,44 @@
package channels
import (
"time"
"github.com/abhinavxd/libredesk/internal/notification/models"
)
type EmailQueue interface {
Send(models.Email) bool
SendAfter(models.Email, time.Duration) bool
}
type email struct {
queue EmailQueue
}
func NewEmail(queue EmailQueue) Provider {
return &email{queue: queue}
}
func (p *email) Channel() models.NotificationChannel {
return models.NotificationChannelEmail
}
func (p *email) Send(delivery Delivery) Result {
notification := delivery.Recipient.Email
if notification == nil || notification.Recipient == "" {
return Result{}
}
message := models.Email{
UserID: delivery.Recipient.UserID,
NotificationID: delivery.NotificationID,
Type: delivery.Notification.Type,
ConversationID: delivery.Notification.ConversationID,
Recipient: notification.Recipient,
Subject: notification.Subject,
Content: notification.Content,
}
if notification.Delay > 0 {
return Result{Sent: p.queue.SendAfter(message, notification.Delay)}
}
return Result{Sent: p.queue.Send(message)}
}
@@ -0,0 +1,63 @@
package channels
import (
"testing"
"time"
"github.com/abhinavxd/libredesk/internal/notification/models"
)
type emailQueueStub struct {
sent []models.Email
delayed []models.Email
delay time.Duration
}
func (q *emailQueueStub) Send(email models.Email) bool {
q.sent = append(q.sent, email)
return true
}
func (q *emailQueueStub) SendAfter(email models.Email, delay time.Duration) bool {
q.delayed = append(q.delayed, email)
q.delay = delay
return true
}
func TestEmailProviderSkipsMissingRecipient(t *testing.T) {
for _, email := range []*models.EmailNotification{nil, {}} {
queue := &emailQueueStub{}
result := NewEmail(queue).Send(Delivery{Recipient: models.Recipient{UserID: 42, Email: email}})
if result.Sent || len(queue.sent) != 0 || len(queue.delayed) != 0 {
t.Fatalf("unexpected delivery: result=%#v queue=%#v", result, queue)
}
}
}
func TestEmailProviderSendsImmediately(t *testing.T) {
queue := &emailQueueStub{}
result := NewEmail(queue).Send(Delivery{
Recipient: models.Recipient{UserID: 42, Email: &models.EmailNotification{
Recipient: "agent@example.com",
Subject: "Assigned",
Content: "Conversation assigned",
}},
Notification: models.Notification{Type: models.NotificationTypeAssignment},
})
if !result.Sent || len(queue.sent) != 1 || queue.sent[0].Recipient != "agent@example.com" {
t.Fatalf("unexpected delivery: result=%#v queue=%#v", result, queue)
}
}
func TestEmailProviderSchedulesDelay(t *testing.T) {
queue := &emailQueueStub{}
result := NewEmail(queue).Send(Delivery{
Recipient: models.Recipient{UserID: 42, Email: &models.EmailNotification{
Recipient: "agent@example.com",
Delay: time.Minute,
}},
})
if !result.Sent || len(queue.delayed) != 1 || queue.delay != time.Minute {
t.Fatalf("unexpected delivery: result=%#v queue=%#v", result, queue)
}
}
+62
View File
@@ -0,0 +1,62 @@
package channels
import (
"encoding/json"
"github.com/abhinavxd/libredesk/internal/notification/models"
wsmodels "github.com/abhinavxd/libredesk/internal/ws/models"
"github.com/volatiletech/null/v9"
"github.com/zerodha/logf"
)
type InAppStore interface {
Create(int, models.NotificationType, string, null.String, null.Int, null.Int, null.Int, json.RawMessage) (models.UserNotification, error)
}
type WSHub interface {
BroadcastMessage(msg wsmodels.BroadcastMessage)
}
type inApp struct {
store InAppStore
wsHub WSHub
lo *logf.Logger
}
func NewInApp(store InAppStore, wsHub WSHub, lo *logf.Logger) Provider {
return &inApp{store: store, wsHub: wsHub, lo: lo}
}
func (p *inApp) Channel() models.NotificationChannel {
return models.NotificationChannelInApp
}
func (p *inApp) Send(delivery Delivery) Result {
n := delivery.Notification
notification, err := p.store.Create(delivery.Recipient.UserID, n.Type, n.Title, n.Body,
n.ConversationID, n.MessageID, n.ActorID, n.Meta)
if err != nil {
p.lo.Error("error creating in-app notification", "recipient_id", delivery.Recipient.UserID,
"type", n.Type, "error", err)
return Result{}
}
notification.ConversationUUID = null.StringFrom(n.ConversationUUID)
notification.MessageUUID = null.StringFrom(n.MessageUUID)
notification.ActorFirstName = null.StringFrom(n.ActorFirstName)
notification.ActorLastName = null.StringFrom(n.ActorLastName)
p.broadcast(delivery.Recipient.UserID, notification)
return Result{Sent: true, NotificationID: null.IntFrom(notification.ID)}
}
func (p *inApp) broadcast(userID int, notification any) {
if p.wsHub == nil {
return
}
message := wsmodels.Message{Type: wsmodels.MessageTypeNewNotification, Data: notification}
data, err := json.Marshal(message)
if err != nil {
p.lo.Error("error marshalling notification for Websocket", "error", err)
return
}
p.wsHub.BroadcastMessage(wsmodels.BroadcastMessage{Data: data, Users: []int{userID}})
}
+46
View File
@@ -0,0 +1,46 @@
package channels
import (
"fmt"
"github.com/abhinavxd/libredesk/internal/notification/models"
)
type PushSender interface {
Send(userID int, payload models.PushPayload) bool
}
type push struct {
sender PushSender
}
func NewPush(sender PushSender) Provider {
return &push{sender: sender}
}
func (p *push) Channel() models.NotificationChannel {
return models.NotificationChannelPush
}
func (p *push) Send(delivery Delivery) Result {
n := delivery.Notification
sent := p.sender.Send(delivery.Recipient.UserID, models.PushPayload{
Title: n.Title,
Body: n.Body.String,
Tag: fmt.Sprintf("%s_%s", n.Type, n.ConversationUUID),
URL: pushRoute(n.Type, n.ConversationUUID, n.MessageUUID),
})
return Result{Sent: sent}
}
func pushRoute(notificationType models.NotificationType, conversationUUID, messageUUID string) string {
list := "assigned"
if notificationType == models.NotificationTypeMention {
list = "mentioned"
}
route := fmt.Sprintf("/inboxes/%s/conversation/%s", list, conversationUUID)
if messageUUID != "" {
route += "?scrollTo=" + messageUUID
}
return route
}
+67
View File
@@ -0,0 +1,67 @@
package channels
import (
"cmp"
"slices"
"github.com/abhinavxd/libredesk/internal/notification/models"
"github.com/volatiletech/null/v9"
)
type Provider interface {
Channel() models.NotificationChannel
Send(Delivery) Result
}
type Delivery struct {
Recipient models.Recipient
Notification models.Notification
NotificationID null.Int
}
type Result struct {
Sent bool
NotificationID null.Int
}
type Pipeline struct {
providers []Provider
}
func NewPipeline(providers ...Provider) Pipeline {
providers = slices.Clone(providers)
slices.SortStableFunc(providers, func(a, b Provider) int {
return cmp.Compare(channelOrder(a.Channel()), channelOrder(b.Channel()))
})
return Pipeline{providers: providers}
}
func (p Pipeline) Send(delivery Delivery, enabled []models.NotificationChannel) []models.NotificationChannel {
var sent []models.NotificationChannel
for _, provider := range p.providers {
if !slices.Contains(enabled, provider.Channel()) {
continue
}
result := provider.Send(delivery)
if result.Sent {
sent = append(sent, provider.Channel())
}
if result.NotificationID.Valid {
delivery.NotificationID = result.NotificationID
}
}
return sent
}
func channelOrder(channel models.NotificationChannel) int {
switch channel {
case models.NotificationChannelInApp:
return 0
case models.NotificationChannelEmail:
return 1
case models.NotificationChannelPush:
return 2
default:
return 3
}
}
+25 -143
View File
@@ -1,169 +1,51 @@
package notifier
import (
"encoding/json"
"fmt"
"github.com/abhinavxd/libredesk/internal/notification/channels"
"github.com/abhinavxd/libredesk/internal/notification/models"
wsmodels "github.com/abhinavxd/libredesk/internal/ws/models"
"github.com/volatiletech/null/v9"
"github.com/zerodha/logf"
)
// WSHub defines the interface for the Websocket hub.
type WSHub interface {
BroadcastMessage(msg wsmodels.BroadcastMessage)
type NotificationPreferenceStore interface {
EnabledChannels(recipientIDs []int, nType models.NotificationType) (map[int][]models.NotificationChannel, error)
}
// Notification represents a notification to be sent through all channels.
type Notification struct {
// Core notification fields
Type models.NotificationType
RecipientIDs []int
Title string
Body null.String
ConversationID null.Int
MessageID null.Int
ActorID null.Int
Meta json.RawMessage
// For Websocket broadcast
ConversationUUID string
ActorFirstName string
ActorLastName string
// Email fields (optional - if empty, no email sent)
Email *EmailNotification
}
// EmailNotification holds email channel notification details.
type EmailNotification struct {
Recipients []string
Subject string
Content string
}
// Dispatcher coordinates sending notifications through multiple channels: WS, DB, email.
type Dispatcher struct {
inApp *UserNotificationManager
outbound *Service
wsHub WSHub
emailEnabled bool
lo *logf.Logger
pipeline channels.Pipeline
prefs NotificationPreferenceStore
}
// DispatcherOpts contains options for creating a new Dispatcher.
type DispatcherOpts struct {
InApp *UserNotificationManager
Outbound *Service
WSHub WSHub
EmailEnabled bool
Lo *logf.Logger
Pipeline channels.Pipeline
Prefs NotificationPreferenceStore
}
// NewDispatcher creates a new notification Dispatcher.
func NewDispatcher(opts DispatcherOpts) *Dispatcher {
return &Dispatcher{
inApp: opts.InApp,
outbound: opts.Outbound,
wsHub: opts.WSHub,
emailEnabled: opts.EmailEnabled,
lo: opts.Lo,
}
return &Dispatcher{pipeline: opts.Pipeline, prefs: opts.Prefs}
}
// Send sends a notification through all configured channels.
// For each recipient: creates in-app notification (DB), broadcasts via Websocket,
// and sends email if Email field is provided.
func (d *Dispatcher) Send(n Notification) {
for i, recipientID := range n.RecipientIDs {
d.sendToRecipient(recipientID, n)
if d.outbound != nil && n.Email != nil && d.emailEnabled {
var email string
if i < len(n.Email.Recipients) {
email = n.Email.Recipients[i]
} else if len(n.Email.Recipients) == 1 {
email = n.Email.Recipients[0] // Broadcast mode
}
if email != "" {
d.sendEmail(recipientID, email, n.Email.Subject, n.Email.Content, n.Type)
}
}
func (d *Dispatcher) Send(n models.Notification) ([]models.DeliveryResult, error) {
recipientIDs := make([]int, len(n.Recipients))
for i, recipient := range n.Recipients {
recipientIDs[i] = recipient.UserID
}
}
// SendWithEmails sends notifications where each recipient has their own email content.
// This is useful when email content is personalized per recipient.
func (d *Dispatcher) SendWithEmails(n Notification, emails []EmailNotification) {
for i, recipientID := range n.RecipientIDs {
d.sendToRecipient(recipientID, n)
if d.outbound != nil && i < len(emails) && len(emails[i].Recipients) > 0 && d.emailEnabled {
e := emails[i]
d.sendEmail(recipientID, e.Recipients[0], e.Subject, e.Content, n.Type)
}
}
}
// sendToRecipient creates in-app notification and broadcasts via Websocket.
// Returns the created notification or nil if creation failed.
func (d *Dispatcher) sendToRecipient(recipientID int, n Notification) *models.UserNotification {
notification, err := d.inApp.Create(
recipientID,
n.Type,
n.Title,
n.Body,
n.ConversationID,
n.MessageID,
n.ActorID,
n.Meta,
)
enabled, err := d.prefs.EnabledChannels(recipientIDs, n.Type)
if err != nil {
d.lo.Error("error creating in-app notification",
"recipient_id", recipientID,
"type", n.Type,
"error", err)
return nil
return nil, fmt.Errorf("fetching notification preferences: %w", err)
}
notification.ConversationUUID = null.StringFrom(n.ConversationUUID)
notification.ActorFirstName = null.StringFrom(n.ActorFirstName)
notification.ActorLastName = null.StringFrom(n.ActorLastName)
d.broadcastNotification([]int{recipientID}, notification)
return &notification
}
// sendEmail sends an email notification through the outbound service.
func (d *Dispatcher) sendEmail(recipientID int, email, subject, content string, nType models.NotificationType) {
if err := d.outbound.Send(Message{
RecipientEmails: []string{email},
Subject: subject,
Content: content,
Provider: ProviderEmail,
}); err != nil {
d.lo.Error("error sending email notification",
"recipient_id", recipientID,
"email", email,
"type", nType,
"error", err)
results := make([]models.DeliveryResult, 0, len(n.Recipients))
for _, recipient := range n.Recipients {
results = append(results, models.DeliveryResult{
RecipientID: recipient.UserID,
Channels: d.pipeline.Send(
channels.Delivery{Recipient: recipient, Notification: n},
enabled[recipient.UserID],
),
})
}
}
// broadcastNotification broadcasts a notification via Websocket to specified users.
func (d *Dispatcher) broadcastNotification(userIDs []int, notification any) {
if d.wsHub == nil {
return
}
message := wsmodels.Message{
Type: wsmodels.MessageTypeNewNotification,
Data: notification,
}
msgB, err := json.Marshal(message)
if err != nil {
d.lo.Error("error marshalling notification for Websocket", "error", err)
return
}
d.wsHub.BroadcastMessage(wsmodels.BroadcastMessage{
Data: msgB,
Users: userIDs,
})
return results, nil
}
+140
View File
@@ -0,0 +1,140 @@
package notifier
import (
"errors"
"slices"
"testing"
"github.com/abhinavxd/libredesk/internal/notification/channels"
"github.com/abhinavxd/libredesk/internal/notification/models"
"github.com/volatiletech/null/v9"
)
type fakePreferences struct {
channels map[int][]models.NotificationChannel
err error
}
func (p fakePreferences) EnabledChannels([]int, models.NotificationType) (map[int][]models.NotificationChannel, error) {
return p.channels, p.err
}
type fakePushSender struct {
userID int
payload models.PushPayload
}
type fakeChannelProvider struct {
channel models.NotificationChannel
result channels.Result
notificationID null.Int
}
func (p *fakePushSender) Send(userID int, payload models.PushPayload) bool {
p.userID = userID
p.payload = payload
return true
}
func (p *fakeChannelProvider) Channel() models.NotificationChannel {
return p.channel
}
func (p *fakeChannelProvider) Send(delivery channels.Delivery) channels.Result {
p.notificationID = delivery.NotificationID
return p.result
}
func TestDispatcherPassesResultsThroughProviders(t *testing.T) {
inApp := &fakeChannelProvider{
channel: models.NotificationChannelInApp,
result: channels.Result{Sent: true, NotificationID: null.IntFrom(7)},
}
email := &fakeChannelProvider{channel: models.NotificationChannelEmail}
d := NewDispatcher(DispatcherOpts{
Pipeline: channels.NewPipeline(email, inApp),
Prefs: fakePreferences{channels: map[int][]models.NotificationChannel{
42: {models.NotificationChannelInApp, models.NotificationChannelEmail},
}},
})
results, err := d.Send(models.Notification{Type: models.NotificationTypeMention, Recipients: []models.Recipient{{UserID: 42}}})
if err != nil {
t.Fatal(err)
}
if email.notificationID.Int != 7 {
t.Fatalf("email notification ID = %d, want 7", email.notificationID.Int)
}
if len(results) != 1 || !slices.Equal(results[0].Channels, []models.NotificationChannel{models.NotificationChannelInApp}) {
t.Fatalf("delivery results = %#v", results)
}
}
func TestDispatcherSendsPushUsingNotificationRoute(t *testing.T) {
push := &fakePushSender{}
d := NewDispatcher(DispatcherOpts{
Prefs: fakePreferences{channels: map[int][]models.NotificationChannel{
42: {models.NotificationChannelPush},
}},
Pipeline: channels.NewPipeline(channels.NewPush(push)),
})
d.Send(models.Notification{
Type: models.NotificationTypeMention,
Recipients: []models.Recipient{{UserID: 42}},
Title: "You were mentioned",
Body: null.StringFrom("A teammate mentioned you"),
ConversationUUID: "conversation-uuid",
MessageUUID: "message-uuid",
})
if push.userID != 42 {
t.Fatalf("sent to user %d, want 42", push.userID)
}
if push.payload.URL != "/inboxes/mentioned/conversation/conversation-uuid?scrollTo=message-uuid" {
t.Fatalf("push URL = %q", push.payload.URL)
}
if push.payload.Title != "You were mentioned" || push.payload.Body != "A teammate mentioned you" {
t.Fatalf("unexpected push payload: %#v", push.payload)
}
}
func TestDispatcherDoesNotSendPushWhenDisabled(t *testing.T) {
push := &fakePushSender{}
d := NewDispatcher(DispatcherOpts{
Prefs: fakePreferences{channels: map[int][]models.NotificationChannel{
42: nil,
}},
Pipeline: channels.NewPipeline(channels.NewPush(push)),
})
d.Send(models.Notification{
Type: models.NotificationTypeMention,
Recipients: []models.Recipient{{UserID: 42}},
Title: "You were mentioned",
})
if push.userID != 0 {
t.Fatalf("sent push to user %d when push is disabled", push.userID)
}
}
func TestDispatcherReturnsPreferenceLookupFailure(t *testing.T) {
lookupErr := errors.New("lookup failed")
push := &fakePushSender{}
d := NewDispatcher(DispatcherOpts{
Prefs: fakePreferences{err: lookupErr},
Pipeline: channels.NewPipeline(channels.NewPush(push)),
})
_, err := d.Send(models.Notification{
Type: models.NotificationTypeMention,
Recipients: []models.Recipient{{UserID: 42}},
Title: "You were mentioned",
})
if !errors.Is(err, lookupErr) {
t.Fatalf("error = %v, want %v", err, lookupErr)
}
if push.userID != 0 {
t.Fatalf("sent push to user %d after preference lookup failed", push.userID)
}
}
+174
View File
@@ -0,0 +1,174 @@
package notifier
import (
"context"
"time"
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/notification/models"
"github.com/jmoiron/sqlx"
"github.com/volatiletech/null/v9"
"github.com/zerodha/logf"
)
const (
emailQueueTick = 15 * time.Second
emailQueueBatch = 500
emailQueueClaimLease = 5 * time.Minute
emailQueueMaxAttempts = 3
emailQueueRetryDelay = time.Minute
)
type EmailSender interface {
Send(Message) error
SendSync(Message) error
}
type emailQueueQueries struct {
IsSeen *sqlx.Stmt `query:"is-notification-seen"`
Enqueue *sqlx.Stmt `query:"enqueue-notification-email"`
Claim *sqlx.Stmt `query:"claim-due-notification-emails"`
Delete *sqlx.Stmt `query:"delete-claimed-notification-email"`
Retry *sqlx.Stmt `query:"retry-claimed-notification-email"`
}
type queuedEmail struct {
ID int64 `db:"id"`
ClaimedAt time.Time `db:"updated_at"`
UserID int `db:"user_id"`
NotificationID null.Int `db:"notification_id"`
Type models.NotificationType `db:"notification_type"`
ConversationID null.Int `db:"conversation_id"`
Attempts int `db:"attempts"`
Recipient string `db:"recipient_email"`
Subject string `db:"subject"`
Content string `db:"content"`
QueuedAt time.Time `db:"queued_at"`
}
type EmailQueue struct {
q emailQueueQueries
outbound EmailSender
lo *logf.Logger
}
type EmailQueueOpts struct {
DB *sqlx.DB
Outbound EmailSender
Lo *logf.Logger
}
func NewEmailQueue(opts EmailQueueOpts) (*EmailQueue, error) {
var q emailQueueQueries
if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, queriesFS); err != nil {
return nil, err
}
return &EmailQueue{
q: q,
outbound: opts.Outbound,
lo: opts.Lo,
}, nil
}
func (q *EmailQueue) Send(e models.Email) bool {
if err := q.outbound.Send(Message{
RecipientEmails: []string{e.Recipient},
Subject: e.Subject,
Content: e.Content,
Provider: ProviderEmail,
}); err != nil {
q.lo.Error("error sending notification email", "user_id", e.UserID, "type", e.Type, "error", err)
return false
}
return true
}
func (q *EmailQueue) SendAfter(e models.Email, delay time.Duration) bool {
if _, err := q.q.Enqueue.Exec(e.UserID, e.NotificationID, e.Type, e.ConversationID, e.Recipient,
e.Subject, e.Content, time.Now().Add(delay)); err != nil {
q.lo.Error("error queueing notification email", "user_id", e.UserID, "type", e.Type, "error", err)
return false
}
return true
}
func (q *EmailQueue) Run(ctx context.Context) {
ticker := time.NewTicker(emailQueueTick)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
for _, e := range q.due() {
seen, err := q.seen(e)
if err != nil {
continue
}
if seen {
q.delete(e)
continue
}
q.deliver(e)
}
}
}
}
func (q *EmailQueue) due() []queuedEmail {
var due []queuedEmail
if err := q.q.Claim.Select(&due, emailQueueBatch, time.Now().Add(emailQueueClaimLease)); err != nil {
q.lo.Error("error claiming due notification emails", "error", err)
return nil
}
return due
}
func (q *EmailQueue) seen(e queuedEmail) (bool, error) {
var seen bool
if err := q.q.IsSeen.Get(&seen, e.UserID, e.NotificationID, e.ConversationID, e.QueuedAt); err != nil {
q.lo.Error("error checking notification seen state", "user_id", e.UserID, "type", e.Type, "error", err)
return false, err
}
return seen, nil
}
func (q *EmailQueue) deliver(e queuedEmail) bool {
if err := q.outbound.SendSync(Message{
RecipientEmails: []string{e.Recipient},
Subject: e.Subject,
Content: e.Content,
Provider: ProviderEmail,
}); err != nil {
q.lo.Error("error delivering notification email", "user_id", e.UserID, "type", e.Type, "error", err)
if e.Attempts+1 >= emailQueueMaxAttempts {
q.delete(e)
} else {
q.retry(e)
}
return false
}
q.delete(e)
return true
}
func (q *EmailQueue) delete(e queuedEmail) bool {
result, err := q.q.Delete.Exec(e.ID, e.ClaimedAt)
if err != nil {
q.lo.Error("error deleting delivered notification email", "user_id", e.UserID, "type", e.Type, "error", err)
return false
}
deleted, err := result.RowsAffected()
if err != nil {
q.lo.Error("error counting deleted notification emails", "user_id", e.UserID, "type", e.Type, "error", err)
return false
}
return deleted > 0
}
func (q *EmailQueue) retry(e queuedEmail) {
if _, err := q.q.Retry.Exec(e.ID, time.Now().Add(emailQueueRetryDelay), e.ClaimedAt); err != nil {
q.lo.Error("error scheduling notification email retry", "user_id", e.UserID, "type", e.Type, "error", err)
}
}
+286
View File
@@ -0,0 +1,286 @@
package notifier
import (
"errors"
"testing"
"time"
"github.com/abhinavxd/libredesk/internal/notification/channels"
"github.com/abhinavxd/libredesk/internal/notification/models"
"github.com/abhinavxd/libredesk/internal/testutil"
"github.com/volatiletech/null/v9"
"github.com/zerodha/logf"
)
type emailDeliveryProvider struct {
err error
}
func (p *emailDeliveryProvider) Send(Message) error {
return p.err
}
func (p *emailDeliveryProvider) Name() string {
return ProviderEmail
}
func TestDelayedReplyEmailUsesQueueTime(t *testing.T) {
db := testutil.NewDB(t, "reply_email_time")
var userID, inboxID, convID int
if err := db.Get(&userID, `INSERT INTO users (type, email, first_name, last_name) VALUES ('agent', 'read@example.com', 'Agent', '') RETURNING id`); err != nil {
t.Fatal(err)
}
if err := db.Get(&inboxID, `INSERT INTO inboxes (name, channel) VALUES ('Test', 'email') RETURNING id`); err != nil {
t.Fatal(err)
}
if err := db.Get(&convID, `INSERT INTO conversations (contact_id, inbox_id, status_id) VALUES ($1, $2, (SELECT id FROM conversation_statuses LIMIT 1)) RETURNING id`, userID, inboxID); err != nil {
t.Fatal(err)
}
lo := logf.New(logf.Opts{})
provider := &emailDeliveryProvider{}
outbound := NewService(map[string]Notifier{ProviderEmail: provider}, 1, 1, &lo)
queue, err := NewEmailQueue(EmailQueueOpts{DB: db, Outbound: outbound, Lo: &lo})
if err != nil {
t.Fatal(err)
}
d := NewDispatcher(DispatcherOpts{
Pipeline: channels.NewPipeline(channels.NewEmail(queue)),
Prefs: fakePreferences{channels: map[int][]models.NotificationChannel{userID: {models.NotificationChannelEmail}}},
})
n := models.Notification{
Type: models.NotificationTypeNewReply,
Recipients: []models.Recipient{{
UserID: userID,
Email: &models.EmailNotification{
Recipient: "read@example.com",
Subject: "Reply",
Content: "First reply",
Delay: time.Minute,
},
}},
ConversationID: null.IntFrom(convID),
}
db.MustExec(`INSERT INTO conversation_last_seen (user_id, conversation_id, last_seen_at) VALUES ($1, $2, now() - interval '1 hour')`, userID, convID)
for _, tt := range []struct {
name string
readOffset time.Duration
want bool
}{
{"read before queue", -time.Second, false},
{"read at queue", 0, true},
{"read after queue", time.Second, true},
} {
t.Run(tt.name, func(t *testing.T) {
d.Send(n)
var queuedAt time.Time
if err := db.Get(&queuedAt, `SELECT queued_at FROM notification_email_queue`); err != nil {
t.Fatal(err)
}
db.MustExec(`UPDATE conversation_last_seen SET last_seen_at = $1`, queuedAt.Add(tt.readOffset))
db.MustExec(`UPDATE notification_email_queue SET send_at = now() - interval '1 second'`)
due := queue.due()
if len(due) != 1 {
t.Fatalf("queued emails = %d, want 1", len(due))
}
got, err := queue.seen(due[0])
if err != nil {
t.Fatal(err)
}
if got != tt.want {
t.Fatalf("seen = %v, want %v", got, tt.want)
}
})
}
t.Run("coalesced reply uses latest message", func(t *testing.T) {
db.MustExec(`UPDATE conversation_last_seen SET last_seen_at = now() - interval '1 hour'`)
d.Send(n)
var firstSendAt time.Time
if err := db.Get(&firstSendAt, `SELECT send_at FROM notification_email_queue`); err != nil {
t.Fatal(err)
}
n.Recipients[0].Email.Content = "Second reply"
n.Recipients[0].Email.Delay = 2 * time.Minute
d.Send(n)
var second struct {
SendAt time.Time `db:"send_at"`
QueuedAt time.Time `db:"queued_at"`
}
if err := db.Get(&second, `SELECT send_at, queued_at FROM notification_email_queue`); err != nil {
t.Fatal(err)
}
if !second.SendAt.After(firstSendAt) {
t.Fatalf("coalesced email send time = %v, want after %v", second.SendAt, firstSendAt)
}
db.MustExec(`UPDATE notification_email_queue SET send_at = now() - interval '1 second'`)
due := queue.due()
if len(due) != 1 || due[0].Content != "Second reply" {
t.Fatalf("unexpected coalesced emails: %#v", due)
}
db.MustExec(`UPDATE conversation_last_seen SET last_seen_at = $1`, second.QueuedAt.Add(-time.Second))
seen, err := queue.seen(due[0])
if err != nil {
t.Fatal(err)
}
if seen {
t.Fatal("unread second reply suppressed")
}
db.MustExec(`UPDATE conversation_last_seen SET last_seen_at = $1`, second.QueuedAt)
seen, err = queue.seen(due[0])
if err != nil {
t.Fatal(err)
}
if !seen {
t.Fatal("read second reply was not suppressed")
}
})
}
func TestDelayedEmailSeenCheckReturnsDatabaseError(t *testing.T) {
db := testutil.NewDB(t, "notification_email_seen_error")
lo := logf.New(logf.Opts{})
queue, err := NewEmailQueue(EmailQueueOpts{DB: db, Lo: &lo})
if err != nil {
t.Fatal(err)
}
db.MustExec(`DROP TABLE conversation_last_seen`)
if _, err := queue.seen(queuedEmail{}); err == nil {
t.Fatal("seen-state database error was ignored")
}
}
func TestDelayedEmailRetriesThenGivesUp(t *testing.T) {
db := testutil.NewDB(t, "reply_email_recipients")
var userID, inboxID, convID int
if err := db.Get(&userID, `INSERT INTO users (type, email, first_name, last_name) VALUES ('agent', 'queued@example.com', 'Agent', '') RETURNING id`); err != nil {
t.Fatal(err)
}
if err := db.Get(&inboxID, `INSERT INTO inboxes (name, channel) VALUES ('Test', 'email') RETURNING id`); err != nil {
t.Fatal(err)
}
if err := db.Get(&convID, `INSERT INTO conversations (contact_id, inbox_id, status_id) VALUES ($1, $2, (SELECT id FROM conversation_statuses LIMIT 1)) RETURNING id`, userID, inboxID); err != nil {
t.Fatal(err)
}
lo := logf.New(logf.Opts{})
provider := &emailDeliveryProvider{}
outbound := NewService(map[string]Notifier{ProviderEmail: provider}, 1, 1, &lo)
queue, err := NewEmailQueue(EmailQueueOpts{DB: db, Outbound: outbound, Lo: &lo})
if err != nil {
t.Fatal(err)
}
d := NewDispatcher(DispatcherOpts{
Pipeline: channels.NewPipeline(channels.NewEmail(queue)),
Prefs: fakePreferences{channels: map[int][]models.NotificationChannel{userID: {models.NotificationChannelEmail}}},
})
n := models.Notification{Type: models.NotificationTypeNewReply, Recipients: []models.Recipient{{UserID: userID}}, ConversationID: null.IntFrom(convID)}
email := &models.EmailNotification{Recipient: "queued@example.com", Subject: "Reply", Content: "Reply", Delay: time.Minute}
send := func(email *models.EmailNotification) {
n.Recipients[0].Email = email
d.Send(n)
}
queued := func() int {
var count int
if err := db.Get(&count, `SELECT COUNT(*) FROM notification_email_queue`); err != nil {
t.Fatal(err)
}
return count
}
claimOne := func() queuedEmail {
t.Helper()
db.MustExec(`UPDATE notification_email_queue SET send_at = now() - interval '1 second'`)
due := queue.due()
if len(due) != 1 {
t.Fatalf("queued emails = %d, want 1", len(due))
}
return due[0]
}
send(nil)
if queued() != 0 {
t.Fatal("a notification without an email body was queued")
}
send(email)
if queued() != 1 {
t.Fatal("email was not queued")
}
if !queue.deliver(claimOne()) {
t.Fatal("email was not delivered")
}
if queued() != 0 {
t.Fatalf("queued emails after delivery = %d, want 0", queued())
}
provider.err = errors.New("SMTP unavailable")
send(email)
if queue.deliver(claimOne()) {
t.Fatal("failed send reported as delivered")
}
if queued() != 1 {
t.Fatalf("queued emails after failure = %d, want 1", queued())
}
for attempt := 2; attempt <= emailQueueMaxAttempts; attempt++ {
queue.deliver(claimOne())
}
if queued() != 0 {
t.Fatalf("queued emails after final failure = %d, want 0", queued())
}
send(email)
db.MustExec(`UPDATE notification_email_queue SET attempts = $1`, emailQueueMaxAttempts-1)
send(email)
var attempts int
if err := db.Get(&attempts, `SELECT attempts FROM notification_email_queue`); err != nil {
t.Fatal(err)
}
if attempts != 0 {
t.Fatalf("attempts after replacement = %d, want 0", attempts)
}
}
func TestClaimedEmailRemainsRecoverable(t *testing.T) {
db := testutil.NewDB(t, "notification_email_recovery")
var userID int
if err := db.Get(&userID, `INSERT INTO users (type, email, first_name, last_name) VALUES ('agent', 'recover@example.com', 'Agent', '') RETURNING id`); err != nil {
t.Fatal(err)
}
lo := logf.New(logf.Opts{})
provider := &emailDeliveryProvider{}
queue, err := NewEmailQueue(EmailQueueOpts{
DB: db,
Outbound: NewService(map[string]Notifier{ProviderEmail: provider}, 1, 1, &lo),
Lo: &lo,
})
if err != nil {
t.Fatal(err)
}
email := models.Email{
UserID: userID,
Type: models.NotificationTypeNewReply,
Recipient: "recover@example.com",
Subject: "Reply",
Content: "Pending reply",
}
if !queue.SendAfter(email, -time.Second) {
t.Fatal("queueing email failed")
}
claimed := queue.due()
if len(claimed) != 1 {
t.Fatalf("claimed emails = %d, want 1", len(claimed))
}
var queued int
if err := db.Get(&queued, `SELECT COUNT(*) FROM notification_email_queue`); err != nil {
t.Fatal(err)
}
if queued != 1 {
t.Fatalf("queued emails after claim = %d, want 1", queued)
}
if due := queue.due(); len(due) != 0 {
t.Fatalf("emails reclaimed before lease expiry = %d, want 0", len(due))
}
db.MustExec(`UPDATE notification_email_queue SET send_at = now() - interval '1 second'`)
if due := queue.due(); len(due) != 1 {
t.Fatalf("emails after lease expiry = %d, want 1", len(due))
}
}
+110 -2
View File
@@ -2,6 +2,7 @@ package models
import (
"encoding/json"
"slices"
"time"
"github.com/volatiletech/null/v9"
@@ -10,13 +11,120 @@ import (
// NotificationType represents the type of user notification.
type NotificationType string
// NotificationChannel represents the delivery channel of a notification.
type NotificationChannel string
const (
NotificationTypeMention NotificationType = "mention"
NotificationTypeAssignment NotificationType = "assignment"
NotificationTypeMention NotificationType = "mention"
NotificationTypeAssignment NotificationType = "assignment"
NotificationTypeNewReply NotificationType = "new_reply"
NotificationTypeNewReplyParticipating NotificationType = "new_reply_participating"
NotificationTypeConversationReopened NotificationType = "conversation_reopened"
NotificationTypeAutomation NotificationType = "automation"
NotificationTypeSLAFirstResponseWarn NotificationType = "sla_first_response_warning"
NotificationTypeSLAFirstResponseBreach NotificationType = "sla_first_response_breach"
NotificationTypeSLANextResponseWarn NotificationType = "sla_next_response_warning"
NotificationTypeSLANextResponseBreach NotificationType = "sla_next_response_breach"
NotificationTypeSLAResolutionWarn NotificationType = "sla_resolution_warning"
NotificationTypeSLAResolutionBreach NotificationType = "sla_resolution_breach"
NotificationTypeSLAWarning NotificationType = "sla_warning"
NotificationTypeSLABreach NotificationType = "sla_breach"
NotificationChannelInApp NotificationChannel = "in_app"
NotificationChannelEmail NotificationChannel = "email"
NotificationChannelPush NotificationChannel = "push"
)
// AgentNotificationTypes are the notification types agents can set preferences for.
var AgentNotificationTypes = []NotificationType{
NotificationTypeAssignment,
NotificationTypeMention,
NotificationTypeConversationReopened,
NotificationTypeNewReply,
NotificationTypeNewReplyParticipating,
NotificationTypeSLAFirstResponseWarn,
NotificationTypeSLAFirstResponseBreach,
NotificationTypeSLANextResponseWarn,
NotificationTypeSLANextResponseBreach,
NotificationTypeSLAResolutionWarn,
NotificationTypeSLAResolutionBreach,
}
// NotificationChannels are the delivery channels a preference can target.
var NotificationChannels = []NotificationChannel{
NotificationChannelInApp,
NotificationChannelEmail,
NotificationChannelPush,
}
// defaultDisabledTypes are off until an agent opts in. Every other type defaults to on.
var defaultDisabledTypes = []NotificationType{
NotificationTypeNewReply,
NotificationTypeNewReplyParticipating,
}
// NotificationPreference is a per-channel toggle for a notification type.
type NotificationPreference struct {
NotificationType NotificationType `db:"notification_type" json:"notification_type"`
Channel NotificationChannel `db:"channel" json:"channel"`
Enabled bool `db:"enabled" json:"enabled"`
}
type Recipient struct {
UserID int
Email *EmailNotification
}
type Notification struct {
Type NotificationType
Recipients []Recipient
Title string
Body null.String
ConversationID null.Int
MessageID null.Int
ActorID null.Int
Meta json.RawMessage
ConversationUUID string
MessageUUID string
ActorFirstName string
ActorLastName string
}
type EmailNotification struct {
Recipient string
Subject string
Content string
Delay time.Duration
}
type Email struct {
UserID int
NotificationID null.Int
Type NotificationType
ConversationID null.Int
Recipient string
Subject string
Content string
}
type PushPayload struct {
Title string `json:"title"`
Body string `json:"body,omitempty"`
Tag string `json:"tag,omitempty"`
URL string `json:"url"`
}
type DeliveryResult struct {
RecipientID int
Channels []NotificationChannel
}
// DefaultEnabled reports the preference for a type when the agent has no stored row for it.
func DefaultEnabled(nType NotificationType) bool {
return !slices.Contains(defaultDisabledTypes, nType)
}
// UserNotification represents an in-app notification for a user.
type UserNotification struct {
ID int `db:"id" json:"id"`
@@ -0,0 +1,24 @@
package models
import "testing"
func TestDefaultEnabled(t *testing.T) {
tests := []struct {
name string
nType NotificationType
want bool
}{
{"assignment", NotificationTypeAssignment, true},
{"mention", NotificationTypeMention, true},
{"new reply", NotificationTypeNewReply, false},
{"new reply participating", NotificationTypeNewReplyParticipating, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := DefaultEnabled(tt.nType); got != tt.want {
t.Fatalf("DefaultEnabled(%q) = %v, want %v", tt.nType, got, tt.want)
}
})
}
}
+136
View File
@@ -0,0 +1,136 @@
package notifier
import (
"slices"
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/notification/models"
"github.com/jmoiron/sqlx"
"github.com/knadh/go-i18n"
"github.com/lib/pq"
"github.com/zerodha/logf"
)
type prefQueries struct {
GetPreferences *sqlx.Stmt `query:"get-notification-preferences"`
GetPreferencesType *sqlx.Stmt `query:"get-notification-preferences-for-type"`
UpsertPreference *sqlx.Stmt `query:"upsert-notification-preference"`
}
type userChannelPreference struct {
UserID int `db:"user_id"`
Channel models.NotificationChannel `db:"channel"`
Enabled bool `db:"enabled"`
}
type PreferenceManager struct {
db *sqlx.DB
lo *logf.Logger
i18n *i18n.I18n
q prefQueries
}
type PreferenceManagerOpts struct {
DB *sqlx.DB
Lo *logf.Logger
I18n *i18n.I18n
}
// NewPreferenceManager creates a new PreferenceManager.
func NewPreferenceManager(opts PreferenceManagerOpts) (*PreferenceManager, error) {
var q prefQueries
if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, queriesFS); err != nil {
return nil, err
}
return &PreferenceManager{
db: opts.DB,
q: q,
lo: opts.Lo,
i18n: opts.I18n,
}, nil
}
func (m *PreferenceManager) EnabledChannels(userIDs []int, nType models.NotificationType) (map[int][]models.NotificationChannel, error) {
var rows []userChannelPreference
if err := m.q.GetPreferencesType.Select(&rows, pq.Array(userIDs), nType); err != nil {
return nil, err
}
stored := make(map[[2]any]bool, len(rows))
for _, r := range rows {
stored[[2]any{r.UserID, r.Channel}] = r.Enabled
}
enabled := make(map[int][]models.NotificationChannel, len(userIDs))
for _, userID := range userIDs {
for _, channel := range models.NotificationChannels {
on, ok := stored[[2]any{userID, channel}]
if !ok {
on = models.DefaultEnabled(nType)
}
if on {
enabled[userID] = append(enabled[userID], channel)
}
}
}
return enabled, nil
}
// GetMatrix returns the effective preference for every agent notification type and channel.
func (m *PreferenceManager) GetMatrix(userID int) ([]models.NotificationPreference, error) {
var rows []models.NotificationPreference
if err := m.q.GetPreferences.Select(&rows, userID); err != nil {
m.lo.Error("error fetching notification preferences", "user_id", userID, "error", err)
return nil, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
stored := make(map[[2]any]bool, len(rows))
for _, r := range rows {
stored[[2]any{r.NotificationType, r.Channel}] = r.Enabled
}
matrix := make([]models.NotificationPreference, 0, len(models.AgentNotificationTypes)*len(models.NotificationChannels))
for _, nType := range models.AgentNotificationTypes {
for _, channel := range models.NotificationChannels {
on, ok := stored[[2]any{nType, channel}]
if !ok {
on = models.DefaultEnabled(nType)
}
matrix = append(matrix, models.NotificationPreference{
NotificationType: nType,
Channel: channel,
Enabled: on,
})
}
}
return matrix, nil
}
// Update upserts the given preferences for the user. Unknown types or channels are rejected.
func (m *PreferenceManager) Update(userID int, prefs []models.NotificationPreference) error {
for _, p := range prefs {
if !slices.Contains(models.AgentNotificationTypes, p.NotificationType) ||
!slices.Contains(models.NotificationChannels, p.Channel) {
return envelope.NewError(envelope.InputError, m.i18n.T("notification.invalidPreference"), nil)
}
}
tx, err := m.db.Beginx()
if err != nil {
m.lo.Error("error starting notification preference update", "user_id", userID, "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
defer tx.Rollback()
for _, p := range prefs {
if _, err := tx.Stmtx(m.q.UpsertPreference).Exec(userID, p.NotificationType, p.Channel, p.Enabled); err != nil {
m.lo.Error("error updating notification preference", "user_id", userID, "type", p.NotificationType, "channel", p.Channel, "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
}
if err := tx.Commit(); err != nil {
m.lo.Error("error committing notification preference update", "user_id", userID, "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
return nil
}
+97
View File
@@ -0,0 +1,97 @@
package notifier
import (
"testing"
"github.com/abhinavxd/libredesk/internal/notification/models"
"github.com/abhinavxd/libredesk/internal/testutil"
"github.com/zerodha/logf"
)
func TestEnabledChannelsReturnsLookupFailure(t *testing.T) {
db := testutil.NewDB(t, "notification_preferences_read_failure")
lo := logf.New(logf.Opts{})
manager, err := NewPreferenceManager(PreferenceManagerOpts{
DB: db,
Lo: &lo,
I18n: testutil.NewI18n(t),
})
if err != nil {
t.Fatal(err)
}
if err := manager.q.GetPreferencesType.Close(); err != nil {
t.Fatal(err)
}
channels, err := manager.EnabledChannels([]int{1}, models.NotificationTypeAssignment)
if err == nil {
t.Fatal("preference lookup failure was not returned")
}
if channels != nil {
t.Fatalf("enabled channels = %v, want nil", channels)
}
}
func TestPreferenceUpdateIsAtomic(t *testing.T) {
db := testutil.NewDB(t, "notification_preferences_atomic")
var userID int
if err := db.Get(&userID, `INSERT INTO users (type, email, first_name, last_name) VALUES ('agent', 'prefs@example.com', 'Agent', '') RETURNING id`); err != nil {
t.Fatal(err)
}
lo := logf.New(logf.Opts{})
manager, err := NewPreferenceManager(PreferenceManagerOpts{
DB: db,
Lo: &lo,
I18n: testutil.NewI18n(t),
})
if err != nil {
t.Fatal(err)
}
t.Run("invalid preference", func(t *testing.T) {
err := manager.Update(userID, []models.NotificationPreference{
{NotificationType: models.NotificationTypeAssignment, Channel: models.NotificationChannelEmail, Enabled: false},
{NotificationType: "unknown", Channel: models.NotificationChannelPush, Enabled: true},
})
if err == nil {
t.Fatal("invalid update succeeded")
}
var count int
if err := db.Get(&count, `SELECT COUNT(*) FROM user_notification_preferences WHERE user_id = $1`, userID); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("stored preferences = %d, want 0", count)
}
})
t.Run("database failure", func(t *testing.T) {
db.MustExec(`
CREATE FUNCTION reject_push_preference() RETURNS trigger AS $$
BEGIN
IF NEW.channel = 'push' THEN
RAISE EXCEPTION 'push rejected';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER reject_push_preference
BEFORE INSERT OR UPDATE ON user_notification_preferences
FOR EACH ROW EXECUTE FUNCTION reject_push_preference();
`)
err := manager.Update(userID, []models.NotificationPreference{
{NotificationType: models.NotificationTypeAssignment, Channel: models.NotificationChannelEmail, Enabled: false},
{NotificationType: models.NotificationTypeAssignment, Channel: models.NotificationChannelPush, Enabled: true},
})
if err == nil {
t.Fatal("update succeeded despite database failure")
}
var count int
if err := db.Get(&count, `SELECT COUNT(*) FROM user_notification_preferences WHERE user_id = $1`, userID); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("stored preferences = %d, want 0", count)
}
})
}

Some files were not shown because too many files have changed in this diff Show More