mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-10-25 22:09:36 +00:00
merge main into WhatsApp branch
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/knadh/stuffbin"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
func TestServePWAFiles(t *testing.T) {
|
||||
fs, err := stuffbin.NewLocalFS("/",
|
||||
"../frontend/public/manifest.webmanifest:frontend/dist/main/manifest.webmanifest",
|
||||
"../frontend/public/sw.js:frontend/dist/main/sw.js",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("creating test filesystem: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
handler fastglue.FastRequestHandler
|
||||
contentType string
|
||||
wantServiceWorkerHdr bool
|
||||
}{
|
||||
{"manifest", serveManifest, "application/manifest+json", false},
|
||||
{"service worker", serveServiceWorker, "application/javascript", true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := &fasthttp.RequestCtx{}
|
||||
req := &fastglue.Request{RequestCtx: ctx, Context: &App{fs: fs}}
|
||||
if err := tc.handler(req); err != nil {
|
||||
t.Fatalf("serving file: %v", err)
|
||||
}
|
||||
if got := string(ctx.Response.Header.ContentType()); got != tc.contentType {
|
||||
t.Errorf("content type = %q, want %q", got, tc.contentType)
|
||||
}
|
||||
if got := string(ctx.Response.Header.Peek("Cache-Control")); got != "no-cache" {
|
||||
t.Errorf("cache control = %q, want no-cache", got)
|
||||
}
|
||||
if tc.wantServiceWorkerHdr && string(ctx.Response.Header.Peek("Service-Worker-Allowed")) != "/" {
|
||||
t.Error("service worker scope header is missing")
|
||||
}
|
||||
if strings.TrimSpace(string(ctx.Response.Body())) == "" {
|
||||
t.Error("response body is empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/abhinavxd/libredesk/internal/httputil"
|
||||
"github.com/abhinavxd/libredesk/internal/setting/models"
|
||||
"github.com/abhinavxd/libredesk/internal/stringutil"
|
||||
"github.com/valyala/fasthttp"
|
||||
@@ -57,6 +58,9 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error {
|
||||
}
|
||||
// Trim whitespace and trailing slash from root URL.
|
||||
req.RootURL = strings.TrimRight(strings.TrimSpace(req.RootURL), "/")
|
||||
if !httputil.IsValidHTTPURL(req.RootURL) {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("admin.general.rootURL.valid"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
app.Lock()
|
||||
oldLang := ko.String("app.lang")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
|
||||
<meta name="theme-color" content="hsl(152 39% 30%)" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" crossorigin="use-credentials" />
|
||||
<link rel="apple-touch-icon" href="/images/pwa-icon-192.png" />
|
||||
<link rel="preload" href="/static/public/static/fonts/Geist-Variable.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="stylesheet" href="/static/public/static/fonts.css">
|
||||
</head>
|
||||
@@ -14,4 +17,4 @@
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -92,6 +92,7 @@ import { useTagStore } from './stores/tag'
|
||||
import { useCustomAttributeStore } from './stores/customAttributes'
|
||||
import { useIdleDetection } from './composables/useIdleDetection'
|
||||
import { useNotificationStore } from './stores/notification'
|
||||
import { useAiPromptStore } from '@main/stores/aiPrompt'
|
||||
import { useViewStore } from './stores/view'
|
||||
import { useKeyboardShortcutsDialog } from './composables/useKeyboardShortcutsDialog'
|
||||
import KeyboardShortcutsDialog from './components/KeyboardShortcutsDialog.vue'
|
||||
@@ -122,6 +123,7 @@ import NotificationBell from '@main/components/sidebar/NotificationBell.vue'
|
||||
import PrimaryNavItems from '@main/components/sidebar/PrimaryNavItems.vue'
|
||||
import { useIsMobile } from '@shared-ui/composables'
|
||||
import api from '@main/api'
|
||||
import { usePushNotifications } from '@/composables/usePushNotifications'
|
||||
|
||||
const route = useRoute()
|
||||
const emitter = useEmitter()
|
||||
@@ -165,6 +167,8 @@ const openCreateConversationDialog = ref(false)
|
||||
const createConversationContact = ref(null)
|
||||
const { t } = useI18n()
|
||||
const notificationStore = useNotificationStore()
|
||||
const aiPromptStore = useAiPromptStore()
|
||||
const pushNotifications = usePushNotifications()
|
||||
|
||||
// Update browser tab title with unread notification count.
|
||||
// Watch both unreadCount and route so the prefix is preserved after navigation.
|
||||
@@ -214,10 +218,17 @@ const initStores = async () => {
|
||||
inboxStore.fetchInboxes(),
|
||||
slaStore.fetchSlas(),
|
||||
tagStore.fetchTags(),
|
||||
customAttributeStore.fetchCustomAttributes()
|
||||
customAttributeStore.fetchCustomAttributes(),
|
||||
aiPromptStore.fetchPrompts(),
|
||||
refreshPushSubscription()
|
||||
])
|
||||
}
|
||||
|
||||
const refreshPushSubscription = async () => {
|
||||
const { data } = await api.getNotificationPreferences()
|
||||
await pushNotifications.refresh(data.data.vapid_public_key, data.data.push_endpoints)
|
||||
}
|
||||
|
||||
const createView = () => {
|
||||
view.value = {}
|
||||
openCreateViewForm.value = true
|
||||
|
||||
@@ -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;">😢</span>
|
||||
<span style="font-size: 10px; display: block; font-weight: 600; color: #b0b5bd; text-transform: uppercase; letter-spacing: 0.05em; margin-top: 4px;">Poor</span>
|
||||
</a>
|
||||
</div>
|
||||
<div style="display: inline-block; width: 72px; text-align: center; vertical-align: top; padding: 4px 0;">
|
||||
<a href="{{ .CSATLink }}?rating=2" style="text-decoration: none; display: block;">
|
||||
<span style="font-size: 34px; display: block; line-height: 1.4;">😕</span>
|
||||
<span style="font-size: 10px; display: block; font-weight: 600; color: #b0b5bd; text-transform: uppercase; letter-spacing: 0.05em; margin-top: 4px;">Fair</span>
|
||||
</a>
|
||||
</div>
|
||||
<div style="display: inline-block; width: 72px; text-align: center; vertical-align: top; padding: 4px 0;">
|
||||
<a href="{{ .CSATLink }}?rating=3" style="text-decoration: none; display: block;">
|
||||
<span style="font-size: 34px; display: block; line-height: 1.4;">😊</span>
|
||||
<span style="font-size: 10px; display: block; font-weight: 600; color: #b0b5bd; text-transform: uppercase; letter-spacing: 0.05em; margin-top: 4px;">Good</span>
|
||||
</a>
|
||||
</div>
|
||||
<div style="display: inline-block; width: 72px; text-align: center; vertical-align: top; padding: 4px 0;">
|
||||
<a href="{{ .CSATLink }}?rating=4" style="text-decoration: none; display: block;">
|
||||
<span style="font-size: 34px; display: block; line-height: 1.4;">😃</span>
|
||||
<span style="font-size: 10px; display: block; font-weight: 600; color: #b0b5bd; text-transform: uppercase; letter-spacing: 0.05em; margin-top: 4px;">Great</span>
|
||||
</a>
|
||||
</div>
|
||||
<div style="display: inline-block; width: 72px; text-align: center; vertical-align: top; padding: 4px 0;">
|
||||
<a href="{{ .CSATLink }}?rating=5" style="text-decoration: none; display: block;">
|
||||
<span style="font-size: 34px; display: block; line-height: 1.4;">🤩</span>
|
||||
<span style="font-size: 10px; display: block; font-weight: 600; color: #b0b5bd; text-transform: uppercase; letter-spacing: 0.05em; margin-top: 4px;">Excellent</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
} from 'lucide-vue-next'
|
||||
import { useUserStore } from '@main/stores/user'
|
||||
import { useKeyboardShortcutsDialog } from '@main/composables/useKeyboardShortcutsDialog'
|
||||
import { SECTIONS } from '../sections'
|
||||
import { useLogout } from '@main/composables/useLogout'
|
||||
import { SECTIONS } from '@main/features/command/sections'
|
||||
|
||||
const AWAY_STATUSES = ['away_manual', 'away_and_reassigning']
|
||||
|
||||
@@ -21,6 +22,7 @@ export function useAccountCommands() {
|
||||
const mode = useColorMode()
|
||||
const userStore = useUserStore()
|
||||
const shortcutsDialog = useKeyboardShortcutsDialog()
|
||||
const logout = useLogout()
|
||||
|
||||
return computed(() => {
|
||||
const status = userStore.user.availability_status
|
||||
@@ -70,9 +72,7 @@ export function useAccountCommands() {
|
||||
keywords: ['sign out'],
|
||||
section: SECTIONS.ACCOUNT,
|
||||
icon: LogOut,
|
||||
run: () => {
|
||||
window.location.href = '/logout'
|
||||
}
|
||||
run: logout
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Generated
+113
-95
@@ -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 |
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "libredesk",
|
||||
"short_name": "libredesk",
|
||||
"description": "Customer support desk",
|
||||
"start_url": "/inboxes/assigned",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "hsl(0 0% 99.2%)",
|
||||
"theme_color": "hsl(152 39% 30%)",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/images/pwa-icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/images/pwa-icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
const readPayload = (event) => {
|
||||
try {
|
||||
return event.data ? event.data.json() : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
self.addEventListener('install', () => self.skipWaiting())
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
const notification = readPayload(event)
|
||||
if (!notification?.title) return
|
||||
event.waitUntil(self.registration.showNotification(notification.title, {
|
||||
body: notification.body,
|
||||
tag: notification.tag,
|
||||
icon: '/images/pwa-icon-192.png',
|
||||
badge: '/images/pwa-icon-192.png',
|
||||
data: { url: notification.url }
|
||||
}))
|
||||
})
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close()
|
||||
const url = event.notification.data?.url
|
||||
if (!url) return
|
||||
const target = new URL(url, self.location.origin)
|
||||
if (target.origin !== self.location.origin) return
|
||||
|
||||
event.waitUntil(self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(async (clients) => {
|
||||
const client = clients[0]
|
||||
if (!client) return self.clients.openWindow(target.href)
|
||||
await client.focus()
|
||||
return client.navigate(target.href).catch(() => self.clients.openWindow(target.href))
|
||||
}))
|
||||
})
|
||||
@@ -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>
|
||||
@@ -3,6 +3,7 @@ module github.com/abhinavxd/libredesk
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||
github.com/abhinavxd/ssrfguard v0.1.0
|
||||
github.com/alicebob/miniredis/v2 v2.33.0
|
||||
github.com/coreos/go-oidc/v3 v3.11.0
|
||||
|
||||
@@ -8,6 +8,8 @@ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOEl
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
||||
github.com/abhinavxd/ssrfguard v0.1.0 h1:Ns/llAQ63uGFehxSvhCd+WGDKmBEEmIH+E1AW1CGgWM=
|
||||
github.com/abhinavxd/ssrfguard v0.1.0/go.mod h1:eNVubb+m/r3KrKWYdG6hxzeAfj+t2ZmZss4V/x7D6Ws=
|
||||
github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 h1:uvdUDbHQHO85qeSydJtItA4T55Pw6BtAejd0APRJOCE=
|
||||
@@ -103,10 +105,12 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
@@ -293,6 +297,10 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
@@ -300,6 +308,9 @@ golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
|
||||
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
@@ -311,6 +322,10 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx
|
||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
|
||||
@@ -318,6 +333,10 @@ golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -329,24 +348,41 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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: ¬ifier.EmailNotification{
|
||||
Recipients: []string{agent.Email.String},
|
||||
Subject: subject,
|
||||
Content: content,
|
||||
},
|
||||
})
|
||||
}); err != nil {
|
||||
return fmt.Errorf("sending assignment notification: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NotifyNewReply notifies the assigned agent and the other participating agents of an incoming reply.
|
||||
func (m *Manager) NotifyNewReply(conversation models.Conversation, message models.Message, reopened bool) {
|
||||
assigneeType, assigneeTmpl, assigneeTitleKey := nmodels.NotificationTypeNewReply, template.TmplNewReply, "notification.newReply"
|
||||
if reopened {
|
||||
assigneeType, assigneeTmpl, assigneeTitleKey = nmodels.NotificationTypeConversationReopened, template.TmplConversationReopened, "notification.conversationReopened"
|
||||
}
|
||||
|
||||
groups := []replyNotificationGroup{
|
||||
{
|
||||
nType: assigneeType,
|
||||
tmpl: assigneeTmpl,
|
||||
title: m.i18n.Ts(assigneeTitleKey, "referenceNumber", conversation.ReferenceNumber),
|
||||
recipients: m.replyNotificationAssignee(conversation, message.SenderID),
|
||||
},
|
||||
{
|
||||
nType: nmodels.NotificationTypeNewReplyParticipating,
|
||||
tmpl: template.TmplNewReplyParticipating,
|
||||
title: m.i18n.Ts("notification.newReply", "referenceNumber", conversation.ReferenceNumber),
|
||||
recipients: m.replyNotificationParticipants(conversation, message.SenderID),
|
||||
},
|
||||
}
|
||||
if !slices.ContainsFunc(groups, func(g replyNotificationGroup) bool { return len(g.recipients) > 0 }) {
|
||||
return
|
||||
}
|
||||
|
||||
author, err := m.userStore.Get(message.SenderID, "", []string{})
|
||||
if err != nil {
|
||||
m.lo.Error("error fetching sender for new reply notification", "user_id", message.SenderID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, group := range groups {
|
||||
m.sendReplyNotification(conversation, message, group, author)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) replyNotificationAssignee(conversation models.Conversation, senderID int) []umodels.User {
|
||||
assigneeID := conversation.AssignedUserID.Int
|
||||
if assigneeID == 0 || assigneeID == senderID {
|
||||
return nil
|
||||
}
|
||||
agent, ok := m.notifiableAgent(assigneeID, conversation)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return []umodels.User{agent}
|
||||
}
|
||||
|
||||
func (m *Manager) replyNotificationParticipants(conversation models.Conversation, senderID int) []umodels.User {
|
||||
var participants []umodels.User
|
||||
if err := m.q.GetConversationParticipantAgents.Select(&participants, conversation.UUID); err != nil {
|
||||
m.lo.Error("error fetching participants for new reply notification", "conversation_uuid", conversation.UUID, "error", err)
|
||||
return nil
|
||||
}
|
||||
agents := make([]umodels.User, 0, len(participants))
|
||||
for _, participant := range participants {
|
||||
if participant.ID == senderID || participant.ID == conversation.AssignedUserID.Int {
|
||||
continue
|
||||
}
|
||||
if agent, ok := m.notifiableAgent(participant.ID, conversation); ok {
|
||||
agents = append(agents, agent)
|
||||
}
|
||||
}
|
||||
return agents
|
||||
}
|
||||
|
||||
func (m *Manager) notifiableAgent(userID int, conversation models.Conversation) (umodels.User, bool) {
|
||||
agent, err := m.userStore.GetAgentCachedOrLoad(userID)
|
||||
if err != nil {
|
||||
m.lo.Error("error fetching agent for new reply notification", "user_id", userID, "error", err)
|
||||
return umodels.User{}, false
|
||||
}
|
||||
if !agent.Enabled || !authz.CanReadAssignment(agent, conversation.AssignedUserID, conversation.AssignedTeamID) {
|
||||
return umodels.User{}, false
|
||||
}
|
||||
return agent, true
|
||||
}
|
||||
|
||||
func (m *Manager) sendReplyNotification(conversation models.Conversation, message models.Message, group replyNotificationGroup, author umodels.User) {
|
||||
if len(group.recipients) == 0 {
|
||||
return
|
||||
}
|
||||
messageText := replyNotificationText(message)
|
||||
candidateIDs := make([]int, len(group.recipients))
|
||||
for i, recipient := range group.recipients {
|
||||
candidateIDs[i] = recipient.ID
|
||||
}
|
||||
|
||||
recipients := m.buildNotificationRecipients(candidateIDs, func(recipient umodels.User) (string, string, error) {
|
||||
data := notificationTemplateData(conversation, recipient, author)
|
||||
data["Message"] = map[string]any{
|
||||
"UUID": message.UUID,
|
||||
"Content": htmltemplate.HTML(strings.ReplaceAll(html.EscapeString(messageText), "\n", "<br>")),
|
||||
}
|
||||
content, subject, err := m.template.RenderStoredEmailTemplate(group.tmpl, data)
|
||||
return subject, content, err
|
||||
})
|
||||
for i := range recipients {
|
||||
if recipients[i].Email != nil {
|
||||
recipients[i].Email.Delay = newReplyEmailDelay
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := m.dispatcher.Send(nmodels.Notification{
|
||||
Type: group.nType,
|
||||
Recipients: recipients,
|
||||
Title: group.title,
|
||||
Body: null.StringFrom(cmp.Or(messageText, conversation.Subject.String)),
|
||||
ConversationID: null.IntFrom(conversation.ID),
|
||||
MessageID: null.IntFrom(message.ID),
|
||||
ConversationUUID: conversation.UUID,
|
||||
MessageUUID: message.UUID,
|
||||
}); err != nil {
|
||||
m.lo.Error("error sending reply notification", "type", group.nType, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyMention sends notifications (in-app, WebSocket, email) for mentions.
|
||||
// For team mentions, expands to all team members.
|
||||
func (m *Manager) NotifyMention(conversationUUID string, message models.Message, mentions []models.MentionInput, mentionedByUserID int) {
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -11,7 +11,7 @@ func IsValidHTTPURL(raw string) bool {
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return u.Scheme == "http" || u.Scheme == "https"
|
||||
return (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
|
||||
}
|
||||
|
||||
// IsOriginTrusted checks if the given origin is trusted based on the trusted domains list
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package httputil
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsValidHTTPURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
want bool
|
||||
}{
|
||||
{name: "HTTPS", url: "https://desk.example.com", want: true},
|
||||
{name: "HTTP localhost", url: "http://localhost:9000", want: true},
|
||||
{name: "path", url: "https://desk.example.com/app", want: true},
|
||||
{name: "empty", url: "", want: false},
|
||||
{name: "missing host", url: "https:", want: false},
|
||||
{name: "relative", url: "/app", want: false},
|
||||
{name: "unsupported scheme", url: "javascript:alert(1)", want: false},
|
||||
{name: "attribute injection", url: `https://desk.example.com" onclick="alert(1)`, want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := IsValidHTTPURL(tt.url); got != tt.want {
|
||||
t.Fatalf("IsValidHTTPURL(%q) = %v, want %v", tt.url, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -52,3 +52,107 @@ func TestV2_9_0PrivateNotePermissionMigration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestV2_9_0NotificationMigration(t *testing.T) {
|
||||
db := testutil.NewDB(t, "migration_v2_9_0_notifications")
|
||||
db.MustExec(`
|
||||
DROP TABLE notification_email_queue;
|
||||
DROP TABLE notification_push_subscriptions;
|
||||
DROP TABLE user_notification_preferences;
|
||||
DROP TYPE notification_channel;
|
||||
DELETE FROM settings WHERE "key" IN (
|
||||
'notification.push.vapid_public_key',
|
||||
'notification.push.vapid_private_key'
|
||||
);
|
||||
DELETE FROM templates WHERE "name" IN (
|
||||
'New reply from contact',
|
||||
'New reply on participating conversation',
|
||||
'Conversation reopened'
|
||||
);
|
||||
`)
|
||||
|
||||
for range 2 {
|
||||
if err := V2_9_0(db, nil, nil); err != nil {
|
||||
t.Fatalf("running migration: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, table := range []string{
|
||||
"user_notification_preferences",
|
||||
"notification_push_subscriptions",
|
||||
"notification_email_queue",
|
||||
} {
|
||||
var exists bool
|
||||
if err := db.Get(&exists, `SELECT to_regclass($1) IS NOT NULL`, table); err != nil {
|
||||
t.Fatalf("checking table %q: %v", table, err)
|
||||
}
|
||||
if !exists {
|
||||
t.Errorf("table %q was not created", table)
|
||||
}
|
||||
}
|
||||
|
||||
var pushChannel bool
|
||||
if err := db.Get(&pushChannel, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM pg_enum e
|
||||
JOIN pg_type t ON t.oid = e.enumtypid
|
||||
WHERE t.typname = 'notification_channel' AND e.enumlabel = 'push'
|
||||
)
|
||||
`); err != nil {
|
||||
t.Fatalf("checking push channel: %v", err)
|
||||
}
|
||||
if !pushChannel {
|
||||
t.Error("push notification channel was not created")
|
||||
}
|
||||
|
||||
var settingCount, templateCount int
|
||||
if err := db.Get(&settingCount, `SELECT COUNT(*) FROM settings WHERE "key" LIKE 'notification.push.vapid_%'`); err != nil {
|
||||
t.Fatalf("counting VAPID settings: %v", err)
|
||||
}
|
||||
if settingCount != 2 {
|
||||
t.Errorf("VAPID setting count = %d, want 2", settingCount)
|
||||
}
|
||||
if err := db.Get(&templateCount, `
|
||||
SELECT COUNT(*) FROM templates WHERE "name" IN (
|
||||
'New reply from contact',
|
||||
'New reply on participating conversation',
|
||||
'Conversation reopened'
|
||||
)
|
||||
`); err != nil {
|
||||
t.Fatalf("counting notification templates: %v", err)
|
||||
}
|
||||
if templateCount != 3 {
|
||||
t.Errorf("notification template count = %d, want 3", templateCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV2_9_0PreservesExistingQueuedEmails(t *testing.T) {
|
||||
db := testutil.NewDB(t, "migration_v2_9_0_queue")
|
||||
columnQuery := `SELECT data_type || ':' || is_nullable || ':' || COALESCE(column_default, '') FROM information_schema.columns WHERE table_name = 'notification_email_queue' AND column_name = $1`
|
||||
var expectedAttemptsColumn string
|
||||
if err := db.Get(&expectedAttemptsColumn, columnQuery, "attempts"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.MustExec(`ALTER TABLE notification_email_queue DROP COLUMN attempts`)
|
||||
db.MustExec(`INSERT INTO users (type, email, first_name, last_name) VALUES ('agent', 'queued@example.com', 'Agent', '')`)
|
||||
db.MustExec(`INSERT INTO notification_email_queue (user_id, notification_type, recipient_email, subject, content, send_at) VALUES ((SELECT id FROM users LIMIT 1), 'new_reply', 'queued@example.com', 'Reply', 'Pending reply', now())`)
|
||||
for range 2 {
|
||||
if err := V2_9_0(db, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var actualAttemptsColumn string
|
||||
if err := db.Get(&actualAttemptsColumn, columnQuery, "attempts"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if actualAttemptsColumn != expectedAttemptsColumn {
|
||||
t.Fatalf("attempts column = %q, schema column = %q", actualAttemptsColumn, expectedAttemptsColumn)
|
||||
}
|
||||
var preserved bool
|
||||
if err := db.Get(&preserved, `SELECT content = 'Pending reply' AND attempts = 0 FROM notification_email_queue`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !preserved {
|
||||
t.Fatal("existing queued email changed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package channels
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/notification/models"
|
||||
)
|
||||
|
||||
type EmailQueue interface {
|
||||
Send(models.Email) bool
|
||||
SendAfter(models.Email, time.Duration) bool
|
||||
}
|
||||
|
||||
type email struct {
|
||||
queue EmailQueue
|
||||
}
|
||||
|
||||
func NewEmail(queue EmailQueue) Provider {
|
||||
return &email{queue: queue}
|
||||
}
|
||||
|
||||
func (p *email) Channel() models.NotificationChannel {
|
||||
return models.NotificationChannelEmail
|
||||
}
|
||||
|
||||
func (p *email) Send(delivery Delivery) Result {
|
||||
notification := delivery.Recipient.Email
|
||||
if notification == nil || notification.Recipient == "" {
|
||||
return Result{}
|
||||
}
|
||||
message := models.Email{
|
||||
UserID: delivery.Recipient.UserID,
|
||||
NotificationID: delivery.NotificationID,
|
||||
Type: delivery.Notification.Type,
|
||||
ConversationID: delivery.Notification.ConversationID,
|
||||
Recipient: notification.Recipient,
|
||||
Subject: notification.Subject,
|
||||
Content: notification.Content,
|
||||
}
|
||||
if notification.Delay > 0 {
|
||||
return Result{Sent: p.queue.SendAfter(message, notification.Delay)}
|
||||
}
|
||||
return Result{Sent: p.queue.Send(message)}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package channels
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/notification/models"
|
||||
)
|
||||
|
||||
type emailQueueStub struct {
|
||||
sent []models.Email
|
||||
delayed []models.Email
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
func (q *emailQueueStub) Send(email models.Email) bool {
|
||||
q.sent = append(q.sent, email)
|
||||
return true
|
||||
}
|
||||
|
||||
func (q *emailQueueStub) SendAfter(email models.Email, delay time.Duration) bool {
|
||||
q.delayed = append(q.delayed, email)
|
||||
q.delay = delay
|
||||
return true
|
||||
}
|
||||
|
||||
func TestEmailProviderSkipsMissingRecipient(t *testing.T) {
|
||||
for _, email := range []*models.EmailNotification{nil, {}} {
|
||||
queue := &emailQueueStub{}
|
||||
result := NewEmail(queue).Send(Delivery{Recipient: models.Recipient{UserID: 42, Email: email}})
|
||||
if result.Sent || len(queue.sent) != 0 || len(queue.delayed) != 0 {
|
||||
t.Fatalf("unexpected delivery: result=%#v queue=%#v", result, queue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailProviderSendsImmediately(t *testing.T) {
|
||||
queue := &emailQueueStub{}
|
||||
result := NewEmail(queue).Send(Delivery{
|
||||
Recipient: models.Recipient{UserID: 42, Email: &models.EmailNotification{
|
||||
Recipient: "agent@example.com",
|
||||
Subject: "Assigned",
|
||||
Content: "Conversation assigned",
|
||||
}},
|
||||
Notification: models.Notification{Type: models.NotificationTypeAssignment},
|
||||
})
|
||||
if !result.Sent || len(queue.sent) != 1 || queue.sent[0].Recipient != "agent@example.com" {
|
||||
t.Fatalf("unexpected delivery: result=%#v queue=%#v", result, queue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailProviderSchedulesDelay(t *testing.T) {
|
||||
queue := &emailQueueStub{}
|
||||
result := NewEmail(queue).Send(Delivery{
|
||||
Recipient: models.Recipient{UserID: 42, Email: &models.EmailNotification{
|
||||
Recipient: "agent@example.com",
|
||||
Delay: time.Minute,
|
||||
}},
|
||||
})
|
||||
if !result.Sent || len(queue.delayed) != 1 || queue.delay != time.Minute {
|
||||
t.Fatalf("unexpected delivery: result=%#v queue=%#v", result, queue)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package channels
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/notification/models"
|
||||
wsmodels "github.com/abhinavxd/libredesk/internal/ws/models"
|
||||
"github.com/volatiletech/null/v9"
|
||||
"github.com/zerodha/logf"
|
||||
)
|
||||
|
||||
type InAppStore interface {
|
||||
Create(int, models.NotificationType, string, null.String, null.Int, null.Int, null.Int, json.RawMessage) (models.UserNotification, error)
|
||||
}
|
||||
|
||||
type WSHub interface {
|
||||
BroadcastMessage(msg wsmodels.BroadcastMessage)
|
||||
}
|
||||
|
||||
type inApp struct {
|
||||
store InAppStore
|
||||
wsHub WSHub
|
||||
lo *logf.Logger
|
||||
}
|
||||
|
||||
func NewInApp(store InAppStore, wsHub WSHub, lo *logf.Logger) Provider {
|
||||
return &inApp{store: store, wsHub: wsHub, lo: lo}
|
||||
}
|
||||
|
||||
func (p *inApp) Channel() models.NotificationChannel {
|
||||
return models.NotificationChannelInApp
|
||||
}
|
||||
|
||||
func (p *inApp) Send(delivery Delivery) Result {
|
||||
n := delivery.Notification
|
||||
notification, err := p.store.Create(delivery.Recipient.UserID, n.Type, n.Title, n.Body,
|
||||
n.ConversationID, n.MessageID, n.ActorID, n.Meta)
|
||||
if err != nil {
|
||||
p.lo.Error("error creating in-app notification", "recipient_id", delivery.Recipient.UserID,
|
||||
"type", n.Type, "error", err)
|
||||
return Result{}
|
||||
}
|
||||
notification.ConversationUUID = null.StringFrom(n.ConversationUUID)
|
||||
notification.MessageUUID = null.StringFrom(n.MessageUUID)
|
||||
notification.ActorFirstName = null.StringFrom(n.ActorFirstName)
|
||||
notification.ActorLastName = null.StringFrom(n.ActorLastName)
|
||||
p.broadcast(delivery.Recipient.UserID, notification)
|
||||
return Result{Sent: true, NotificationID: null.IntFrom(notification.ID)}
|
||||
}
|
||||
|
||||
func (p *inApp) broadcast(userID int, notification any) {
|
||||
if p.wsHub == nil {
|
||||
return
|
||||
}
|
||||
message := wsmodels.Message{Type: wsmodels.MessageTypeNewNotification, Data: notification}
|
||||
data, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
p.lo.Error("error marshalling notification for Websocket", "error", err)
|
||||
return
|
||||
}
|
||||
p.wsHub.BroadcastMessage(wsmodels.BroadcastMessage{Data: data, Users: []int{userID}})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package channels
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/notification/models"
|
||||
)
|
||||
|
||||
type PushSender interface {
|
||||
Send(userID int, payload models.PushPayload) bool
|
||||
}
|
||||
|
||||
type push struct {
|
||||
sender PushSender
|
||||
}
|
||||
|
||||
func NewPush(sender PushSender) Provider {
|
||||
return &push{sender: sender}
|
||||
}
|
||||
|
||||
func (p *push) Channel() models.NotificationChannel {
|
||||
return models.NotificationChannelPush
|
||||
}
|
||||
|
||||
func (p *push) Send(delivery Delivery) Result {
|
||||
n := delivery.Notification
|
||||
sent := p.sender.Send(delivery.Recipient.UserID, models.PushPayload{
|
||||
Title: n.Title,
|
||||
Body: n.Body.String,
|
||||
Tag: fmt.Sprintf("%s_%s", n.Type, n.ConversationUUID),
|
||||
URL: pushRoute(n.Type, n.ConversationUUID, n.MessageUUID),
|
||||
})
|
||||
return Result{Sent: sent}
|
||||
}
|
||||
|
||||
func pushRoute(notificationType models.NotificationType, conversationUUID, messageUUID string) string {
|
||||
list := "assigned"
|
||||
if notificationType == models.NotificationTypeMention {
|
||||
list = "mentioned"
|
||||
}
|
||||
route := fmt.Sprintf("/inboxes/%s/conversation/%s", list, conversationUUID)
|
||||
if messageUUID != "" {
|
||||
route += "?scrollTo=" + messageUUID
|
||||
}
|
||||
return route
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package channels
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"slices"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/notification/models"
|
||||
"github.com/volatiletech/null/v9"
|
||||
)
|
||||
|
||||
type Provider interface {
|
||||
Channel() models.NotificationChannel
|
||||
Send(Delivery) Result
|
||||
}
|
||||
|
||||
type Delivery struct {
|
||||
Recipient models.Recipient
|
||||
Notification models.Notification
|
||||
NotificationID null.Int
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Sent bool
|
||||
NotificationID null.Int
|
||||
}
|
||||
|
||||
type Pipeline struct {
|
||||
providers []Provider
|
||||
}
|
||||
|
||||
func NewPipeline(providers ...Provider) Pipeline {
|
||||
providers = slices.Clone(providers)
|
||||
slices.SortStableFunc(providers, func(a, b Provider) int {
|
||||
return cmp.Compare(channelOrder(a.Channel()), channelOrder(b.Channel()))
|
||||
})
|
||||
return Pipeline{providers: providers}
|
||||
}
|
||||
|
||||
func (p Pipeline) Send(delivery Delivery, enabled []models.NotificationChannel) []models.NotificationChannel {
|
||||
var sent []models.NotificationChannel
|
||||
for _, provider := range p.providers {
|
||||
if !slices.Contains(enabled, provider.Channel()) {
|
||||
continue
|
||||
}
|
||||
result := provider.Send(delivery)
|
||||
if result.Sent {
|
||||
sent = append(sent, provider.Channel())
|
||||
}
|
||||
if result.NotificationID.Valid {
|
||||
delivery.NotificationID = result.NotificationID
|
||||
}
|
||||
}
|
||||
return sent
|
||||
}
|
||||
|
||||
func channelOrder(channel models.NotificationChannel) int {
|
||||
switch channel {
|
||||
case models.NotificationChannelInApp:
|
||||
return 0
|
||||
case models.NotificationChannelEmail:
|
||||
return 1
|
||||
case models.NotificationChannelPush:
|
||||
return 2
|
||||
default:
|
||||
return 3
|
||||
}
|
||||
}
|
||||
@@ -1,169 +1,51 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/notification/channels"
|
||||
"github.com/abhinavxd/libredesk/internal/notification/models"
|
||||
wsmodels "github.com/abhinavxd/libredesk/internal/ws/models"
|
||||
"github.com/volatiletech/null/v9"
|
||||
"github.com/zerodha/logf"
|
||||
)
|
||||
|
||||
// WSHub defines the interface for the Websocket hub.
|
||||
type WSHub interface {
|
||||
BroadcastMessage(msg wsmodels.BroadcastMessage)
|
||||
type NotificationPreferenceStore interface {
|
||||
EnabledChannels(recipientIDs []int, nType models.NotificationType) (map[int][]models.NotificationChannel, error)
|
||||
}
|
||||
|
||||
// Notification represents a notification to be sent through all channels.
|
||||
type Notification struct {
|
||||
// Core notification fields
|
||||
Type models.NotificationType
|
||||
RecipientIDs []int
|
||||
Title string
|
||||
Body null.String
|
||||
ConversationID null.Int
|
||||
MessageID null.Int
|
||||
ActorID null.Int
|
||||
Meta json.RawMessage
|
||||
|
||||
// For Websocket broadcast
|
||||
ConversationUUID string
|
||||
ActorFirstName string
|
||||
ActorLastName string
|
||||
|
||||
// Email fields (optional - if empty, no email sent)
|
||||
Email *EmailNotification
|
||||
}
|
||||
|
||||
// EmailNotification holds email channel notification details.
|
||||
type EmailNotification struct {
|
||||
Recipients []string
|
||||
Subject string
|
||||
Content string
|
||||
}
|
||||
|
||||
// Dispatcher coordinates sending notifications through multiple channels: WS, DB, email.
|
||||
type Dispatcher struct {
|
||||
inApp *UserNotificationManager
|
||||
outbound *Service
|
||||
wsHub WSHub
|
||||
emailEnabled bool
|
||||
lo *logf.Logger
|
||||
pipeline channels.Pipeline
|
||||
prefs NotificationPreferenceStore
|
||||
}
|
||||
|
||||
// DispatcherOpts contains options for creating a new Dispatcher.
|
||||
type DispatcherOpts struct {
|
||||
InApp *UserNotificationManager
|
||||
Outbound *Service
|
||||
WSHub WSHub
|
||||
EmailEnabled bool
|
||||
Lo *logf.Logger
|
||||
Pipeline channels.Pipeline
|
||||
Prefs NotificationPreferenceStore
|
||||
}
|
||||
|
||||
// NewDispatcher creates a new notification Dispatcher.
|
||||
func NewDispatcher(opts DispatcherOpts) *Dispatcher {
|
||||
return &Dispatcher{
|
||||
inApp: opts.InApp,
|
||||
outbound: opts.Outbound,
|
||||
wsHub: opts.WSHub,
|
||||
emailEnabled: opts.EmailEnabled,
|
||||
lo: opts.Lo,
|
||||
}
|
||||
return &Dispatcher{pipeline: opts.Pipeline, prefs: opts.Prefs}
|
||||
}
|
||||
|
||||
// Send sends a notification through all configured channels.
|
||||
// For each recipient: creates in-app notification (DB), broadcasts via Websocket,
|
||||
// and sends email if Email field is provided.
|
||||
func (d *Dispatcher) Send(n Notification) {
|
||||
for i, recipientID := range n.RecipientIDs {
|
||||
d.sendToRecipient(recipientID, n)
|
||||
|
||||
if d.outbound != nil && n.Email != nil && d.emailEnabled {
|
||||
var email string
|
||||
if i < len(n.Email.Recipients) {
|
||||
email = n.Email.Recipients[i]
|
||||
} else if len(n.Email.Recipients) == 1 {
|
||||
email = n.Email.Recipients[0] // Broadcast mode
|
||||
}
|
||||
if email != "" {
|
||||
d.sendEmail(recipientID, email, n.Email.Subject, n.Email.Content, n.Type)
|
||||
}
|
||||
}
|
||||
func (d *Dispatcher) Send(n models.Notification) ([]models.DeliveryResult, error) {
|
||||
recipientIDs := make([]int, len(n.Recipients))
|
||||
for i, recipient := range n.Recipients {
|
||||
recipientIDs[i] = recipient.UserID
|
||||
}
|
||||
}
|
||||
|
||||
// SendWithEmails sends notifications where each recipient has their own email content.
|
||||
// This is useful when email content is personalized per recipient.
|
||||
func (d *Dispatcher) SendWithEmails(n Notification, emails []EmailNotification) {
|
||||
for i, recipientID := range n.RecipientIDs {
|
||||
d.sendToRecipient(recipientID, n)
|
||||
|
||||
if d.outbound != nil && i < len(emails) && len(emails[i].Recipients) > 0 && d.emailEnabled {
|
||||
e := emails[i]
|
||||
d.sendEmail(recipientID, e.Recipients[0], e.Subject, e.Content, n.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendToRecipient creates in-app notification and broadcasts via Websocket.
|
||||
// Returns the created notification or nil if creation failed.
|
||||
func (d *Dispatcher) sendToRecipient(recipientID int, n Notification) *models.UserNotification {
|
||||
notification, err := d.inApp.Create(
|
||||
recipientID,
|
||||
n.Type,
|
||||
n.Title,
|
||||
n.Body,
|
||||
n.ConversationID,
|
||||
n.MessageID,
|
||||
n.ActorID,
|
||||
n.Meta,
|
||||
)
|
||||
enabled, err := d.prefs.EnabledChannels(recipientIDs, n.Type)
|
||||
if err != nil {
|
||||
d.lo.Error("error creating in-app notification",
|
||||
"recipient_id", recipientID,
|
||||
"type", n.Type,
|
||||
"error", err)
|
||||
return nil
|
||||
return nil, fmt.Errorf("fetching notification preferences: %w", err)
|
||||
}
|
||||
notification.ConversationUUID = null.StringFrom(n.ConversationUUID)
|
||||
notification.ActorFirstName = null.StringFrom(n.ActorFirstName)
|
||||
notification.ActorLastName = null.StringFrom(n.ActorLastName)
|
||||
d.broadcastNotification([]int{recipientID}, notification)
|
||||
return ¬ification
|
||||
}
|
||||
|
||||
// sendEmail sends an email notification through the outbound service.
|
||||
func (d *Dispatcher) sendEmail(recipientID int, email, subject, content string, nType models.NotificationType) {
|
||||
if err := d.outbound.Send(Message{
|
||||
RecipientEmails: []string{email},
|
||||
Subject: subject,
|
||||
Content: content,
|
||||
Provider: ProviderEmail,
|
||||
}); err != nil {
|
||||
d.lo.Error("error sending email notification",
|
||||
"recipient_id", recipientID,
|
||||
"email", email,
|
||||
"type", nType,
|
||||
"error", err)
|
||||
results := make([]models.DeliveryResult, 0, len(n.Recipients))
|
||||
for _, recipient := range n.Recipients {
|
||||
results = append(results, models.DeliveryResult{
|
||||
RecipientID: recipient.UserID,
|
||||
Channels: d.pipeline.Send(
|
||||
channels.Delivery{Recipient: recipient, Notification: n},
|
||||
enabled[recipient.UserID],
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// broadcastNotification broadcasts a notification via Websocket to specified users.
|
||||
func (d *Dispatcher) broadcastNotification(userIDs []int, notification any) {
|
||||
if d.wsHub == nil {
|
||||
return
|
||||
}
|
||||
message := wsmodels.Message{
|
||||
Type: wsmodels.MessageTypeNewNotification,
|
||||
Data: notification,
|
||||
}
|
||||
msgB, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
d.lo.Error("error marshalling notification for Websocket", "error", err)
|
||||
return
|
||||
}
|
||||
d.wsHub.BroadcastMessage(wsmodels.BroadcastMessage{
|
||||
Data: msgB,
|
||||
Users: userIDs,
|
||||
})
|
||||
return results, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/notification/channels"
|
||||
"github.com/abhinavxd/libredesk/internal/notification/models"
|
||||
"github.com/volatiletech/null/v9"
|
||||
)
|
||||
|
||||
type fakePreferences struct {
|
||||
channels map[int][]models.NotificationChannel
|
||||
err error
|
||||
}
|
||||
|
||||
func (p fakePreferences) EnabledChannels([]int, models.NotificationType) (map[int][]models.NotificationChannel, error) {
|
||||
return p.channels, p.err
|
||||
}
|
||||
|
||||
type fakePushSender struct {
|
||||
userID int
|
||||
payload models.PushPayload
|
||||
}
|
||||
|
||||
type fakeChannelProvider struct {
|
||||
channel models.NotificationChannel
|
||||
result channels.Result
|
||||
notificationID null.Int
|
||||
}
|
||||
|
||||
func (p *fakePushSender) Send(userID int, payload models.PushPayload) bool {
|
||||
p.userID = userID
|
||||
p.payload = payload
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *fakeChannelProvider) Channel() models.NotificationChannel {
|
||||
return p.channel
|
||||
}
|
||||
|
||||
func (p *fakeChannelProvider) Send(delivery channels.Delivery) channels.Result {
|
||||
p.notificationID = delivery.NotificationID
|
||||
return p.result
|
||||
}
|
||||
|
||||
func TestDispatcherPassesResultsThroughProviders(t *testing.T) {
|
||||
inApp := &fakeChannelProvider{
|
||||
channel: models.NotificationChannelInApp,
|
||||
result: channels.Result{Sent: true, NotificationID: null.IntFrom(7)},
|
||||
}
|
||||
email := &fakeChannelProvider{channel: models.NotificationChannelEmail}
|
||||
d := NewDispatcher(DispatcherOpts{
|
||||
Pipeline: channels.NewPipeline(email, inApp),
|
||||
Prefs: fakePreferences{channels: map[int][]models.NotificationChannel{
|
||||
42: {models.NotificationChannelInApp, models.NotificationChannelEmail},
|
||||
}},
|
||||
})
|
||||
|
||||
results, err := d.Send(models.Notification{Type: models.NotificationTypeMention, Recipients: []models.Recipient{{UserID: 42}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if email.notificationID.Int != 7 {
|
||||
t.Fatalf("email notification ID = %d, want 7", email.notificationID.Int)
|
||||
}
|
||||
if len(results) != 1 || !slices.Equal(results[0].Channels, []models.NotificationChannel{models.NotificationChannelInApp}) {
|
||||
t.Fatalf("delivery results = %#v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcherSendsPushUsingNotificationRoute(t *testing.T) {
|
||||
push := &fakePushSender{}
|
||||
d := NewDispatcher(DispatcherOpts{
|
||||
Prefs: fakePreferences{channels: map[int][]models.NotificationChannel{
|
||||
42: {models.NotificationChannelPush},
|
||||
}},
|
||||
Pipeline: channels.NewPipeline(channels.NewPush(push)),
|
||||
})
|
||||
d.Send(models.Notification{
|
||||
Type: models.NotificationTypeMention,
|
||||
Recipients: []models.Recipient{{UserID: 42}},
|
||||
Title: "You were mentioned",
|
||||
Body: null.StringFrom("A teammate mentioned you"),
|
||||
ConversationUUID: "conversation-uuid",
|
||||
MessageUUID: "message-uuid",
|
||||
})
|
||||
|
||||
if push.userID != 42 {
|
||||
t.Fatalf("sent to user %d, want 42", push.userID)
|
||||
}
|
||||
if push.payload.URL != "/inboxes/mentioned/conversation/conversation-uuid?scrollTo=message-uuid" {
|
||||
t.Fatalf("push URL = %q", push.payload.URL)
|
||||
}
|
||||
if push.payload.Title != "You were mentioned" || push.payload.Body != "A teammate mentioned you" {
|
||||
t.Fatalf("unexpected push payload: %#v", push.payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcherDoesNotSendPushWhenDisabled(t *testing.T) {
|
||||
push := &fakePushSender{}
|
||||
d := NewDispatcher(DispatcherOpts{
|
||||
Prefs: fakePreferences{channels: map[int][]models.NotificationChannel{
|
||||
42: nil,
|
||||
}},
|
||||
Pipeline: channels.NewPipeline(channels.NewPush(push)),
|
||||
})
|
||||
d.Send(models.Notification{
|
||||
Type: models.NotificationTypeMention,
|
||||
Recipients: []models.Recipient{{UserID: 42}},
|
||||
Title: "You were mentioned",
|
||||
})
|
||||
|
||||
if push.userID != 0 {
|
||||
t.Fatalf("sent push to user %d when push is disabled", push.userID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcherReturnsPreferenceLookupFailure(t *testing.T) {
|
||||
lookupErr := errors.New("lookup failed")
|
||||
push := &fakePushSender{}
|
||||
d := NewDispatcher(DispatcherOpts{
|
||||
Prefs: fakePreferences{err: lookupErr},
|
||||
Pipeline: channels.NewPipeline(channels.NewPush(push)),
|
||||
})
|
||||
_, err := d.Send(models.Notification{
|
||||
Type: models.NotificationTypeMention,
|
||||
Recipients: []models.Recipient{{UserID: 42}},
|
||||
Title: "You were mentioned",
|
||||
})
|
||||
|
||||
if !errors.Is(err, lookupErr) {
|
||||
t.Fatalf("error = %v, want %v", err, lookupErr)
|
||||
}
|
||||
if push.userID != 0 {
|
||||
t.Fatalf("sent push to user %d after preference lookup failed", push.userID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/dbutil"
|
||||
"github.com/abhinavxd/libredesk/internal/notification/models"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/volatiletech/null/v9"
|
||||
"github.com/zerodha/logf"
|
||||
)
|
||||
|
||||
const (
|
||||
emailQueueTick = 15 * time.Second
|
||||
emailQueueBatch = 500
|
||||
emailQueueClaimLease = 5 * time.Minute
|
||||
emailQueueMaxAttempts = 3
|
||||
emailQueueRetryDelay = time.Minute
|
||||
)
|
||||
|
||||
type EmailSender interface {
|
||||
Send(Message) error
|
||||
SendSync(Message) error
|
||||
}
|
||||
|
||||
type emailQueueQueries struct {
|
||||
IsSeen *sqlx.Stmt `query:"is-notification-seen"`
|
||||
Enqueue *sqlx.Stmt `query:"enqueue-notification-email"`
|
||||
Claim *sqlx.Stmt `query:"claim-due-notification-emails"`
|
||||
Delete *sqlx.Stmt `query:"delete-claimed-notification-email"`
|
||||
Retry *sqlx.Stmt `query:"retry-claimed-notification-email"`
|
||||
}
|
||||
|
||||
type queuedEmail struct {
|
||||
ID int64 `db:"id"`
|
||||
ClaimedAt time.Time `db:"updated_at"`
|
||||
UserID int `db:"user_id"`
|
||||
NotificationID null.Int `db:"notification_id"`
|
||||
Type models.NotificationType `db:"notification_type"`
|
||||
ConversationID null.Int `db:"conversation_id"`
|
||||
Attempts int `db:"attempts"`
|
||||
Recipient string `db:"recipient_email"`
|
||||
Subject string `db:"subject"`
|
||||
Content string `db:"content"`
|
||||
QueuedAt time.Time `db:"queued_at"`
|
||||
}
|
||||
|
||||
type EmailQueue struct {
|
||||
q emailQueueQueries
|
||||
outbound EmailSender
|
||||
lo *logf.Logger
|
||||
}
|
||||
|
||||
type EmailQueueOpts struct {
|
||||
DB *sqlx.DB
|
||||
Outbound EmailSender
|
||||
Lo *logf.Logger
|
||||
}
|
||||
|
||||
func NewEmailQueue(opts EmailQueueOpts) (*EmailQueue, error) {
|
||||
var q emailQueueQueries
|
||||
if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, queriesFS); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &EmailQueue{
|
||||
q: q,
|
||||
outbound: opts.Outbound,
|
||||
lo: opts.Lo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (q *EmailQueue) Send(e models.Email) bool {
|
||||
if err := q.outbound.Send(Message{
|
||||
RecipientEmails: []string{e.Recipient},
|
||||
Subject: e.Subject,
|
||||
Content: e.Content,
|
||||
Provider: ProviderEmail,
|
||||
}); err != nil {
|
||||
q.lo.Error("error sending notification email", "user_id", e.UserID, "type", e.Type, "error", err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (q *EmailQueue) SendAfter(e models.Email, delay time.Duration) bool {
|
||||
if _, err := q.q.Enqueue.Exec(e.UserID, e.NotificationID, e.Type, e.ConversationID, e.Recipient,
|
||||
e.Subject, e.Content, time.Now().Add(delay)); err != nil {
|
||||
q.lo.Error("error queueing notification email", "user_id", e.UserID, "type", e.Type, "error", err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (q *EmailQueue) Run(ctx context.Context) {
|
||||
ticker := time.NewTicker(emailQueueTick)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
for _, e := range q.due() {
|
||||
seen, err := q.seen(e)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if seen {
|
||||
q.delete(e)
|
||||
continue
|
||||
}
|
||||
q.deliver(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (q *EmailQueue) due() []queuedEmail {
|
||||
var due []queuedEmail
|
||||
if err := q.q.Claim.Select(&due, emailQueueBatch, time.Now().Add(emailQueueClaimLease)); err != nil {
|
||||
q.lo.Error("error claiming due notification emails", "error", err)
|
||||
return nil
|
||||
}
|
||||
return due
|
||||
}
|
||||
|
||||
func (q *EmailQueue) seen(e queuedEmail) (bool, error) {
|
||||
var seen bool
|
||||
if err := q.q.IsSeen.Get(&seen, e.UserID, e.NotificationID, e.ConversationID, e.QueuedAt); err != nil {
|
||||
q.lo.Error("error checking notification seen state", "user_id", e.UserID, "type", e.Type, "error", err)
|
||||
return false, err
|
||||
}
|
||||
return seen, nil
|
||||
}
|
||||
|
||||
func (q *EmailQueue) deliver(e queuedEmail) bool {
|
||||
if err := q.outbound.SendSync(Message{
|
||||
RecipientEmails: []string{e.Recipient},
|
||||
Subject: e.Subject,
|
||||
Content: e.Content,
|
||||
Provider: ProviderEmail,
|
||||
}); err != nil {
|
||||
q.lo.Error("error delivering notification email", "user_id", e.UserID, "type", e.Type, "error", err)
|
||||
if e.Attempts+1 >= emailQueueMaxAttempts {
|
||||
q.delete(e)
|
||||
} else {
|
||||
q.retry(e)
|
||||
}
|
||||
return false
|
||||
}
|
||||
q.delete(e)
|
||||
return true
|
||||
}
|
||||
|
||||
func (q *EmailQueue) delete(e queuedEmail) bool {
|
||||
result, err := q.q.Delete.Exec(e.ID, e.ClaimedAt)
|
||||
if err != nil {
|
||||
q.lo.Error("error deleting delivered notification email", "user_id", e.UserID, "type", e.Type, "error", err)
|
||||
return false
|
||||
}
|
||||
deleted, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
q.lo.Error("error counting deleted notification emails", "user_id", e.UserID, "type", e.Type, "error", err)
|
||||
return false
|
||||
}
|
||||
return deleted > 0
|
||||
}
|
||||
|
||||
func (q *EmailQueue) retry(e queuedEmail) {
|
||||
if _, err := q.q.Retry.Exec(e.ID, time.Now().Add(emailQueueRetryDelay), e.ClaimedAt); err != nil {
|
||||
q.lo.Error("error scheduling notification email retry", "user_id", e.UserID, "type", e.Type, "error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/notification/channels"
|
||||
"github.com/abhinavxd/libredesk/internal/notification/models"
|
||||
"github.com/abhinavxd/libredesk/internal/testutil"
|
||||
"github.com/volatiletech/null/v9"
|
||||
"github.com/zerodha/logf"
|
||||
)
|
||||
|
||||
type emailDeliveryProvider struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (p *emailDeliveryProvider) Send(Message) error {
|
||||
return p.err
|
||||
}
|
||||
|
||||
func (p *emailDeliveryProvider) Name() string {
|
||||
return ProviderEmail
|
||||
}
|
||||
|
||||
func TestDelayedReplyEmailUsesQueueTime(t *testing.T) {
|
||||
db := testutil.NewDB(t, "reply_email_time")
|
||||
var userID, inboxID, convID int
|
||||
if err := db.Get(&userID, `INSERT INTO users (type, email, first_name, last_name) VALUES ('agent', 'read@example.com', 'Agent', '') RETURNING id`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Get(&inboxID, `INSERT INTO inboxes (name, channel) VALUES ('Test', 'email') RETURNING id`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Get(&convID, `INSERT INTO conversations (contact_id, inbox_id, status_id) VALUES ($1, $2, (SELECT id FROM conversation_statuses LIMIT 1)) RETURNING id`, userID, inboxID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lo := logf.New(logf.Opts{})
|
||||
provider := &emailDeliveryProvider{}
|
||||
outbound := NewService(map[string]Notifier{ProviderEmail: provider}, 1, 1, &lo)
|
||||
queue, err := NewEmailQueue(EmailQueueOpts{DB: db, Outbound: outbound, Lo: &lo})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := NewDispatcher(DispatcherOpts{
|
||||
Pipeline: channels.NewPipeline(channels.NewEmail(queue)),
|
||||
Prefs: fakePreferences{channels: map[int][]models.NotificationChannel{userID: {models.NotificationChannelEmail}}},
|
||||
})
|
||||
n := models.Notification{
|
||||
Type: models.NotificationTypeNewReply,
|
||||
Recipients: []models.Recipient{{
|
||||
UserID: userID,
|
||||
Email: &models.EmailNotification{
|
||||
Recipient: "read@example.com",
|
||||
Subject: "Reply",
|
||||
Content: "First reply",
|
||||
Delay: time.Minute,
|
||||
},
|
||||
}},
|
||||
ConversationID: null.IntFrom(convID),
|
||||
}
|
||||
db.MustExec(`INSERT INTO conversation_last_seen (user_id, conversation_id, last_seen_at) VALUES ($1, $2, now() - interval '1 hour')`, userID, convID)
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
readOffset time.Duration
|
||||
want bool
|
||||
}{
|
||||
{"read before queue", -time.Second, false},
|
||||
{"read at queue", 0, true},
|
||||
{"read after queue", time.Second, true},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
d.Send(n)
|
||||
var queuedAt time.Time
|
||||
if err := db.Get(&queuedAt, `SELECT queued_at FROM notification_email_queue`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.MustExec(`UPDATE conversation_last_seen SET last_seen_at = $1`, queuedAt.Add(tt.readOffset))
|
||||
db.MustExec(`UPDATE notification_email_queue SET send_at = now() - interval '1 second'`)
|
||||
due := queue.due()
|
||||
if len(due) != 1 {
|
||||
t.Fatalf("queued emails = %d, want 1", len(due))
|
||||
}
|
||||
got, err := queue.seen(due[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("seen = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("coalesced reply uses latest message", func(t *testing.T) {
|
||||
db.MustExec(`UPDATE conversation_last_seen SET last_seen_at = now() - interval '1 hour'`)
|
||||
d.Send(n)
|
||||
var firstSendAt time.Time
|
||||
if err := db.Get(&firstSendAt, `SELECT send_at FROM notification_email_queue`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n.Recipients[0].Email.Content = "Second reply"
|
||||
n.Recipients[0].Email.Delay = 2 * time.Minute
|
||||
d.Send(n)
|
||||
var second struct {
|
||||
SendAt time.Time `db:"send_at"`
|
||||
QueuedAt time.Time `db:"queued_at"`
|
||||
}
|
||||
if err := db.Get(&second, `SELECT send_at, queued_at FROM notification_email_queue`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !second.SendAt.After(firstSendAt) {
|
||||
t.Fatalf("coalesced email send time = %v, want after %v", second.SendAt, firstSendAt)
|
||||
}
|
||||
db.MustExec(`UPDATE notification_email_queue SET send_at = now() - interval '1 second'`)
|
||||
due := queue.due()
|
||||
if len(due) != 1 || due[0].Content != "Second reply" {
|
||||
t.Fatalf("unexpected coalesced emails: %#v", due)
|
||||
}
|
||||
db.MustExec(`UPDATE conversation_last_seen SET last_seen_at = $1`, second.QueuedAt.Add(-time.Second))
|
||||
seen, err := queue.seen(due[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seen {
|
||||
t.Fatal("unread second reply suppressed")
|
||||
}
|
||||
db.MustExec(`UPDATE conversation_last_seen SET last_seen_at = $1`, second.QueuedAt)
|
||||
seen, err = queue.seen(due[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !seen {
|
||||
t.Fatal("read second reply was not suppressed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDelayedEmailSeenCheckReturnsDatabaseError(t *testing.T) {
|
||||
db := testutil.NewDB(t, "notification_email_seen_error")
|
||||
lo := logf.New(logf.Opts{})
|
||||
queue, err := NewEmailQueue(EmailQueueOpts{DB: db, Lo: &lo})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.MustExec(`DROP TABLE conversation_last_seen`)
|
||||
|
||||
if _, err := queue.seen(queuedEmail{}); err == nil {
|
||||
t.Fatal("seen-state database error was ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelayedEmailRetriesThenGivesUp(t *testing.T) {
|
||||
db := testutil.NewDB(t, "reply_email_recipients")
|
||||
var userID, inboxID, convID int
|
||||
if err := db.Get(&userID, `INSERT INTO users (type, email, first_name, last_name) VALUES ('agent', 'queued@example.com', 'Agent', '') RETURNING id`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Get(&inboxID, `INSERT INTO inboxes (name, channel) VALUES ('Test', 'email') RETURNING id`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Get(&convID, `INSERT INTO conversations (contact_id, inbox_id, status_id) VALUES ($1, $2, (SELECT id FROM conversation_statuses LIMIT 1)) RETURNING id`, userID, inboxID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lo := logf.New(logf.Opts{})
|
||||
provider := &emailDeliveryProvider{}
|
||||
outbound := NewService(map[string]Notifier{ProviderEmail: provider}, 1, 1, &lo)
|
||||
queue, err := NewEmailQueue(EmailQueueOpts{DB: db, Outbound: outbound, Lo: &lo})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := NewDispatcher(DispatcherOpts{
|
||||
Pipeline: channels.NewPipeline(channels.NewEmail(queue)),
|
||||
Prefs: fakePreferences{channels: map[int][]models.NotificationChannel{userID: {models.NotificationChannelEmail}}},
|
||||
})
|
||||
n := models.Notification{Type: models.NotificationTypeNewReply, Recipients: []models.Recipient{{UserID: userID}}, ConversationID: null.IntFrom(convID)}
|
||||
email := &models.EmailNotification{Recipient: "queued@example.com", Subject: "Reply", Content: "Reply", Delay: time.Minute}
|
||||
send := func(email *models.EmailNotification) {
|
||||
n.Recipients[0].Email = email
|
||||
d.Send(n)
|
||||
}
|
||||
queued := func() int {
|
||||
var count int
|
||||
if err := db.Get(&count, `SELECT COUNT(*) FROM notification_email_queue`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return count
|
||||
}
|
||||
claimOne := func() queuedEmail {
|
||||
t.Helper()
|
||||
db.MustExec(`UPDATE notification_email_queue SET send_at = now() - interval '1 second'`)
|
||||
due := queue.due()
|
||||
if len(due) != 1 {
|
||||
t.Fatalf("queued emails = %d, want 1", len(due))
|
||||
}
|
||||
return due[0]
|
||||
}
|
||||
|
||||
send(nil)
|
||||
if queued() != 0 {
|
||||
t.Fatal("a notification without an email body was queued")
|
||||
}
|
||||
|
||||
send(email)
|
||||
if queued() != 1 {
|
||||
t.Fatal("email was not queued")
|
||||
}
|
||||
if !queue.deliver(claimOne()) {
|
||||
t.Fatal("email was not delivered")
|
||||
}
|
||||
if queued() != 0 {
|
||||
t.Fatalf("queued emails after delivery = %d, want 0", queued())
|
||||
}
|
||||
|
||||
provider.err = errors.New("SMTP unavailable")
|
||||
send(email)
|
||||
if queue.deliver(claimOne()) {
|
||||
t.Fatal("failed send reported as delivered")
|
||||
}
|
||||
if queued() != 1 {
|
||||
t.Fatalf("queued emails after failure = %d, want 1", queued())
|
||||
}
|
||||
for attempt := 2; attempt <= emailQueueMaxAttempts; attempt++ {
|
||||
queue.deliver(claimOne())
|
||||
}
|
||||
if queued() != 0 {
|
||||
t.Fatalf("queued emails after final failure = %d, want 0", queued())
|
||||
}
|
||||
|
||||
send(email)
|
||||
db.MustExec(`UPDATE notification_email_queue SET attempts = $1`, emailQueueMaxAttempts-1)
|
||||
send(email)
|
||||
var attempts int
|
||||
if err := db.Get(&attempts, `SELECT attempts FROM notification_email_queue`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if attempts != 0 {
|
||||
t.Fatalf("attempts after replacement = %d, want 0", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimedEmailRemainsRecoverable(t *testing.T) {
|
||||
db := testutil.NewDB(t, "notification_email_recovery")
|
||||
var userID int
|
||||
if err := db.Get(&userID, `INSERT INTO users (type, email, first_name, last_name) VALUES ('agent', 'recover@example.com', 'Agent', '') RETURNING id`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lo := logf.New(logf.Opts{})
|
||||
provider := &emailDeliveryProvider{}
|
||||
queue, err := NewEmailQueue(EmailQueueOpts{
|
||||
DB: db,
|
||||
Outbound: NewService(map[string]Notifier{ProviderEmail: provider}, 1, 1, &lo),
|
||||
Lo: &lo,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
email := models.Email{
|
||||
UserID: userID,
|
||||
Type: models.NotificationTypeNewReply,
|
||||
Recipient: "recover@example.com",
|
||||
Subject: "Reply",
|
||||
Content: "Pending reply",
|
||||
}
|
||||
if !queue.SendAfter(email, -time.Second) {
|
||||
t.Fatal("queueing email failed")
|
||||
}
|
||||
claimed := queue.due()
|
||||
if len(claimed) != 1 {
|
||||
t.Fatalf("claimed emails = %d, want 1", len(claimed))
|
||||
}
|
||||
var queued int
|
||||
if err := db.Get(&queued, `SELECT COUNT(*) FROM notification_email_queue`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if queued != 1 {
|
||||
t.Fatalf("queued emails after claim = %d, want 1", queued)
|
||||
}
|
||||
if due := queue.due(); len(due) != 0 {
|
||||
t.Fatalf("emails reclaimed before lease expiry = %d, want 0", len(due))
|
||||
}
|
||||
db.MustExec(`UPDATE notification_email_queue SET send_at = now() - interval '1 second'`)
|
||||
if due := queue.due(); len(due) != 1 {
|
||||
t.Fatalf("emails after lease expiry = %d, want 1", len(due))
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/volatiletech/null/v9"
|
||||
@@ -10,13 +11,120 @@ import (
|
||||
// NotificationType represents the type of user notification.
|
||||
type NotificationType string
|
||||
|
||||
// NotificationChannel represents the delivery channel of a notification.
|
||||
type NotificationChannel string
|
||||
|
||||
const (
|
||||
NotificationTypeMention NotificationType = "mention"
|
||||
NotificationTypeAssignment NotificationType = "assignment"
|
||||
NotificationTypeMention NotificationType = "mention"
|
||||
NotificationTypeAssignment NotificationType = "assignment"
|
||||
NotificationTypeNewReply NotificationType = "new_reply"
|
||||
NotificationTypeNewReplyParticipating NotificationType = "new_reply_participating"
|
||||
NotificationTypeConversationReopened NotificationType = "conversation_reopened"
|
||||
NotificationTypeAutomation NotificationType = "automation"
|
||||
NotificationTypeSLAFirstResponseWarn NotificationType = "sla_first_response_warning"
|
||||
NotificationTypeSLAFirstResponseBreach NotificationType = "sla_first_response_breach"
|
||||
NotificationTypeSLANextResponseWarn NotificationType = "sla_next_response_warning"
|
||||
NotificationTypeSLANextResponseBreach NotificationType = "sla_next_response_breach"
|
||||
NotificationTypeSLAResolutionWarn NotificationType = "sla_resolution_warning"
|
||||
NotificationTypeSLAResolutionBreach NotificationType = "sla_resolution_breach"
|
||||
|
||||
NotificationTypeSLAWarning NotificationType = "sla_warning"
|
||||
NotificationTypeSLABreach NotificationType = "sla_breach"
|
||||
|
||||
NotificationChannelInApp NotificationChannel = "in_app"
|
||||
NotificationChannelEmail NotificationChannel = "email"
|
||||
NotificationChannelPush NotificationChannel = "push"
|
||||
)
|
||||
|
||||
// AgentNotificationTypes are the notification types agents can set preferences for.
|
||||
var AgentNotificationTypes = []NotificationType{
|
||||
NotificationTypeAssignment,
|
||||
NotificationTypeMention,
|
||||
NotificationTypeConversationReopened,
|
||||
NotificationTypeNewReply,
|
||||
NotificationTypeNewReplyParticipating,
|
||||
NotificationTypeSLAFirstResponseWarn,
|
||||
NotificationTypeSLAFirstResponseBreach,
|
||||
NotificationTypeSLANextResponseWarn,
|
||||
NotificationTypeSLANextResponseBreach,
|
||||
NotificationTypeSLAResolutionWarn,
|
||||
NotificationTypeSLAResolutionBreach,
|
||||
}
|
||||
|
||||
// NotificationChannels are the delivery channels a preference can target.
|
||||
var NotificationChannels = []NotificationChannel{
|
||||
NotificationChannelInApp,
|
||||
NotificationChannelEmail,
|
||||
NotificationChannelPush,
|
||||
}
|
||||
|
||||
// defaultDisabledTypes are off until an agent opts in. Every other type defaults to on.
|
||||
var defaultDisabledTypes = []NotificationType{
|
||||
NotificationTypeNewReply,
|
||||
NotificationTypeNewReplyParticipating,
|
||||
}
|
||||
|
||||
// NotificationPreference is a per-channel toggle for a notification type.
|
||||
type NotificationPreference struct {
|
||||
NotificationType NotificationType `db:"notification_type" json:"notification_type"`
|
||||
Channel NotificationChannel `db:"channel" json:"channel"`
|
||||
Enabled bool `db:"enabled" json:"enabled"`
|
||||
}
|
||||
|
||||
type Recipient struct {
|
||||
UserID int
|
||||
Email *EmailNotification
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
Type NotificationType
|
||||
Recipients []Recipient
|
||||
Title string
|
||||
Body null.String
|
||||
ConversationID null.Int
|
||||
MessageID null.Int
|
||||
ActorID null.Int
|
||||
Meta json.RawMessage
|
||||
ConversationUUID string
|
||||
MessageUUID string
|
||||
ActorFirstName string
|
||||
ActorLastName string
|
||||
}
|
||||
|
||||
type EmailNotification struct {
|
||||
Recipient string
|
||||
Subject string
|
||||
Content string
|
||||
Delay time.Duration
|
||||
}
|
||||
|
||||
type Email struct {
|
||||
UserID int
|
||||
NotificationID null.Int
|
||||
Type NotificationType
|
||||
ConversationID null.Int
|
||||
Recipient string
|
||||
Subject string
|
||||
Content string
|
||||
}
|
||||
|
||||
type PushPayload struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body,omitempty"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type DeliveryResult struct {
|
||||
RecipientID int
|
||||
Channels []NotificationChannel
|
||||
}
|
||||
|
||||
// DefaultEnabled reports the preference for a type when the agent has no stored row for it.
|
||||
func DefaultEnabled(nType NotificationType) bool {
|
||||
return !slices.Contains(defaultDisabledTypes, nType)
|
||||
}
|
||||
|
||||
// UserNotification represents an in-app notification for a user.
|
||||
type UserNotification struct {
|
||||
ID int `db:"id" json:"id"`
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDefaultEnabled(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
nType NotificationType
|
||||
want bool
|
||||
}{
|
||||
{"assignment", NotificationTypeAssignment, true},
|
||||
{"mention", NotificationTypeMention, true},
|
||||
{"new reply", NotificationTypeNewReply, false},
|
||||
{"new reply participating", NotificationTypeNewReplyParticipating, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := DefaultEnabled(tt.nType); got != tt.want {
|
||||
t.Fatalf("DefaultEnabled(%q) = %v, want %v", tt.nType, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/dbutil"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/abhinavxd/libredesk/internal/notification/models"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/go-i18n"
|
||||
"github.com/lib/pq"
|
||||
"github.com/zerodha/logf"
|
||||
)
|
||||
|
||||
type prefQueries struct {
|
||||
GetPreferences *sqlx.Stmt `query:"get-notification-preferences"`
|
||||
GetPreferencesType *sqlx.Stmt `query:"get-notification-preferences-for-type"`
|
||||
UpsertPreference *sqlx.Stmt `query:"upsert-notification-preference"`
|
||||
}
|
||||
|
||||
type userChannelPreference struct {
|
||||
UserID int `db:"user_id"`
|
||||
Channel models.NotificationChannel `db:"channel"`
|
||||
Enabled bool `db:"enabled"`
|
||||
}
|
||||
|
||||
type PreferenceManager struct {
|
||||
db *sqlx.DB
|
||||
lo *logf.Logger
|
||||
i18n *i18n.I18n
|
||||
q prefQueries
|
||||
}
|
||||
|
||||
type PreferenceManagerOpts struct {
|
||||
DB *sqlx.DB
|
||||
Lo *logf.Logger
|
||||
I18n *i18n.I18n
|
||||
}
|
||||
|
||||
// NewPreferenceManager creates a new PreferenceManager.
|
||||
func NewPreferenceManager(opts PreferenceManagerOpts) (*PreferenceManager, error) {
|
||||
var q prefQueries
|
||||
if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, queriesFS); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PreferenceManager{
|
||||
db: opts.DB,
|
||||
q: q,
|
||||
lo: opts.Lo,
|
||||
i18n: opts.I18n,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *PreferenceManager) EnabledChannels(userIDs []int, nType models.NotificationType) (map[int][]models.NotificationChannel, error) {
|
||||
var rows []userChannelPreference
|
||||
if err := m.q.GetPreferencesType.Select(&rows, pq.Array(userIDs), nType); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stored := make(map[[2]any]bool, len(rows))
|
||||
for _, r := range rows {
|
||||
stored[[2]any{r.UserID, r.Channel}] = r.Enabled
|
||||
}
|
||||
|
||||
enabled := make(map[int][]models.NotificationChannel, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
for _, channel := range models.NotificationChannels {
|
||||
on, ok := stored[[2]any{userID, channel}]
|
||||
if !ok {
|
||||
on = models.DefaultEnabled(nType)
|
||||
}
|
||||
if on {
|
||||
enabled[userID] = append(enabled[userID], channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
return enabled, nil
|
||||
}
|
||||
|
||||
// GetMatrix returns the effective preference for every agent notification type and channel.
|
||||
func (m *PreferenceManager) GetMatrix(userID int) ([]models.NotificationPreference, error) {
|
||||
var rows []models.NotificationPreference
|
||||
if err := m.q.GetPreferences.Select(&rows, userID); err != nil {
|
||||
m.lo.Error("error fetching notification preferences", "user_id", userID, "error", err)
|
||||
return nil, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
||||
}
|
||||
|
||||
stored := make(map[[2]any]bool, len(rows))
|
||||
for _, r := range rows {
|
||||
stored[[2]any{r.NotificationType, r.Channel}] = r.Enabled
|
||||
}
|
||||
|
||||
matrix := make([]models.NotificationPreference, 0, len(models.AgentNotificationTypes)*len(models.NotificationChannels))
|
||||
for _, nType := range models.AgentNotificationTypes {
|
||||
for _, channel := range models.NotificationChannels {
|
||||
on, ok := stored[[2]any{nType, channel}]
|
||||
if !ok {
|
||||
on = models.DefaultEnabled(nType)
|
||||
}
|
||||
matrix = append(matrix, models.NotificationPreference{
|
||||
NotificationType: nType,
|
||||
Channel: channel,
|
||||
Enabled: on,
|
||||
})
|
||||
}
|
||||
}
|
||||
return matrix, nil
|
||||
}
|
||||
|
||||
// Update upserts the given preferences for the user. Unknown types or channels are rejected.
|
||||
func (m *PreferenceManager) Update(userID int, prefs []models.NotificationPreference) error {
|
||||
for _, p := range prefs {
|
||||
if !slices.Contains(models.AgentNotificationTypes, p.NotificationType) ||
|
||||
!slices.Contains(models.NotificationChannels, p.Channel) {
|
||||
return envelope.NewError(envelope.InputError, m.i18n.T("notification.invalidPreference"), nil)
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := m.db.Beginx()
|
||||
if err != nil {
|
||||
m.lo.Error("error starting notification preference update", "user_id", userID, "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, p := range prefs {
|
||||
if _, err := tx.Stmtx(m.q.UpsertPreference).Exec(userID, p.NotificationType, p.Channel, p.Enabled); err != nil {
|
||||
m.lo.Error("error updating notification preference", "user_id", userID, "type", p.NotificationType, "channel", p.Channel, "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
m.lo.Error("error committing notification preference update", "user_id", userID, "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/notification/models"
|
||||
"github.com/abhinavxd/libredesk/internal/testutil"
|
||||
"github.com/zerodha/logf"
|
||||
)
|
||||
|
||||
func TestEnabledChannelsReturnsLookupFailure(t *testing.T) {
|
||||
db := testutil.NewDB(t, "notification_preferences_read_failure")
|
||||
lo := logf.New(logf.Opts{})
|
||||
manager, err := NewPreferenceManager(PreferenceManagerOpts{
|
||||
DB: db,
|
||||
Lo: &lo,
|
||||
I18n: testutil.NewI18n(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := manager.q.GetPreferencesType.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
channels, err := manager.EnabledChannels([]int{1}, models.NotificationTypeAssignment)
|
||||
if err == nil {
|
||||
t.Fatal("preference lookup failure was not returned")
|
||||
}
|
||||
if channels != nil {
|
||||
t.Fatalf("enabled channels = %v, want nil", channels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreferenceUpdateIsAtomic(t *testing.T) {
|
||||
db := testutil.NewDB(t, "notification_preferences_atomic")
|
||||
var userID int
|
||||
if err := db.Get(&userID, `INSERT INTO users (type, email, first_name, last_name) VALUES ('agent', 'prefs@example.com', 'Agent', '') RETURNING id`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lo := logf.New(logf.Opts{})
|
||||
manager, err := NewPreferenceManager(PreferenceManagerOpts{
|
||||
DB: db,
|
||||
Lo: &lo,
|
||||
I18n: testutil.NewI18n(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Run("invalid preference", func(t *testing.T) {
|
||||
err := manager.Update(userID, []models.NotificationPreference{
|
||||
{NotificationType: models.NotificationTypeAssignment, Channel: models.NotificationChannelEmail, Enabled: false},
|
||||
{NotificationType: "unknown", Channel: models.NotificationChannelPush, Enabled: true},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("invalid update succeeded")
|
||||
}
|
||||
var count int
|
||||
if err := db.Get(&count, `SELECT COUNT(*) FROM user_notification_preferences WHERE user_id = $1`, userID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("stored preferences = %d, want 0", count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("database failure", func(t *testing.T) {
|
||||
db.MustExec(`
|
||||
CREATE FUNCTION reject_push_preference() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF NEW.channel = 'push' THEN
|
||||
RAISE EXCEPTION 'push rejected';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
CREATE TRIGGER reject_push_preference
|
||||
BEFORE INSERT OR UPDATE ON user_notification_preferences
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_push_preference();
|
||||
`)
|
||||
err := manager.Update(userID, []models.NotificationPreference{
|
||||
{NotificationType: models.NotificationTypeAssignment, Channel: models.NotificationChannelEmail, Enabled: false},
|
||||
{NotificationType: models.NotificationTypeAssignment, Channel: models.NotificationChannelPush, Enabled: true},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("update succeeded despite database failure")
|
||||
}
|
||||
var count int
|
||||
if err := db.Get(&count, `SELECT COUNT(*) FROM user_notification_preferences WHERE user_id = $1`, userID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("stored preferences = %d, want 0", count)
|
||||
}
|
||||
})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user