Merge pull request #130 from abhinavxd/refactor-apis

Clean up APIs and fixes
This commit is contained in:
Abhinav Raut
2025-09-02 02:27:28 +05:30
committed by GitHub
79 changed files with 1217 additions and 771 deletions

63
cmd/config.go Normal file
View File

@@ -0,0 +1,63 @@
package main
import (
"encoding/json"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/zerodha/fastglue"
)
// 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)
// Get app settings
settingsJSON, err := app.setting.GetByPrefix("app")
if err != nil {
return sendErrorEnvelope(r, err)
}
// Unmarshal settings
var settings map[string]any
if err := json.Unmarshal(settingsJSON, &settings); err != nil {
app.lo.Error("error unmarshalling settings", "err", err)
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorFetching", "name", app.i18n.T("globals.terms.setting")), nil))
}
// Filter to only include public fields needed for initial app load
publicSettings := map[string]any{
"app.lang": settings["app.lang"],
"app.favicon_url": settings["app.favicon_url"],
"app.logo_url": settings["app.logo_url"],
"app.site_name": settings["app.site_name"],
}
// Get all OIDC providers
oidcProviders, err := app.oidc.GetAll()
if err != nil {
return sendErrorEnvelope(r, err)
}
// Filter for enabled providers and remove client_secret
enabledProviders := make([]map[string]any, 0)
for _, provider := range oidcProviders {
if provider.Enabled {
providerMap := map[string]any{
"id": provider.ID,
"name": provider.Name,
"provider": provider.Provider,
"provider_url": provider.ProviderURL,
"client_id": provider.ClientID,
"logo_url": provider.ProviderLogoURL,
"enabled": provider.Enabled,
"redirect_uri": provider.RedirectURI,
}
enabledProviders = append(enabledProviders, providerMap)
}
}
// Add SSO providers to the response
publicSettings["app.sso_providers"] = enabledProviders
return r.SendEnvelope(publicSettings)
}

View File

@@ -164,11 +164,17 @@ func handleUpdateContact(r *fastglue.Request) error {
// Upload avatar? // Upload avatar?
files, ok := form.File["files"] files, ok := form.File["files"]
if ok && len(files) > 0 { if ok && len(files) > 0 {
if err := uploadUserAvatar(r, &contact, files); err != nil { if err := uploadUserAvatar(r, contact, files); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
} }
return r.SendEnvelope(true)
// Refetch contact and return it
contact, err = app.user.GetContact(id, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(contact)
} }
// handleGetContactNotes returns all notes for a contact. // handleGetContactNotes returns all notes for a contact.
@@ -195,18 +201,21 @@ func handleCreateContactNote(r *fastglue.Request) error {
auser = r.RequestCtx.UserValue("user").(amodels.User) auser = r.RequestCtx.UserValue("user").(amodels.User)
req = createContactNoteReq{} req = createContactNoteReq{}
) )
if err := r.Decode(&req, "json"); err != nil { if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil)) return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
} }
if len(req.Note) == 0 { if len(req.Note) == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "note"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "note"), nil, envelope.InputError)
} }
if err := app.user.CreateNote(contactID, auser.ID, req.Note); err != nil { n, err := app.user.CreateNote(contactID, auser.ID, req.Note)
if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(true) n, err = app.user.GetNote(n.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(n)
} }
// handleDeleteContactNote deletes a note for a contact. // handleDeleteContactNote deletes a note for a contact.
@@ -240,6 +249,8 @@ func handleDeleteContactNote(r *fastglue.Request) error {
} }
} }
app.lo.Info("deleting contact note", "note_id", noteID, "contact_id", contactID, "actor_id", auser.ID)
if err := app.user.DeleteNote(noteID, contactID); err != nil { if err := app.user.DeleteNote(noteID, contactID); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
@@ -251,6 +262,7 @@ func handleBlockContact(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
contactID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) contactID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
auser = r.RequestCtx.UserValue("user").(amodels.User)
req = blockContactReq{} req = blockContactReq{}
) )
@@ -262,8 +274,15 @@ func handleBlockContact(r *fastglue.Request) error {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil)) return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
} }
app.lo.Info("setting contact block status", "contact_id", contactID, "enabled", req.Enabled, "actor_id", auser.ID)
if err := app.user.ToggleEnabled(contactID, models.UserTypeContact, req.Enabled); err != nil { if err := app.user.ToggleEnabled(contactID, models.UserTypeContact, req.Enabled); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(true)
contact, err := app.user.GetContact(contactID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(contact)
} }

View File

@@ -49,6 +49,7 @@ type createConversationRequest struct {
Subject string `json:"subject"` Subject string `json:"subject"`
Content string `json:"content"` Content string `json:"content"`
Attachments []int `json:"attachments"` Attachments []int `json:"attachments"`
Initiator string `json:"initiator"` // "contact" | "agent"
} }
// handleGetAllConversations retrieves all conversations. // handleGetAllConversations retrieves all conversations.
@@ -273,8 +274,8 @@ func handleGetConversation(r *fastglue.Request) error {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
prev, _ := app.conversation.GetContactConversations(conv.ContactID) prev, _ := app.conversation.GetContactPreviousConversations(conv.ContactID, 10)
conv.PreviousConversations = filterCurrentConv(prev, conv.UUID) conv.PreviousConversations = filterCurrentPreviousConv(prev, conv.UUID)
return r.SendEnvelope(conv) return r.SendEnvelope(conv)
} }
@@ -649,14 +650,14 @@ func handleRemoveTeamAssignee(r *fastglue.Request) error {
return r.SendEnvelope(true) return r.SendEnvelope(true)
} }
// filterCurrentConv removes the current conversation from the list of conversations. // filterCurrentPreviousConv removes the current conversation from the list of previous conversations.
func filterCurrentConv(convs []cmodels.Conversation, uuid string) []cmodels.Conversation { func filterCurrentPreviousConv(convs []cmodels.PreviousConversation, uuid string) []cmodels.PreviousConversation {
for i, c := range convs { for i, c := range convs {
if c.UUID == uuid { if c.UUID == uuid {
return append(convs[:i], convs[i+1:]...) return append(convs[:i], convs[i+1:]...)
} }
} }
return []cmodels.Conversation{} return []cmodels.PreviousConversation{}
} }
// handleCreateConversation creates a new conversation and sends a message to it. // handleCreateConversation creates a new conversation and sends a message to it.
@@ -672,39 +673,17 @@ func handleCreateConversation(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
} }
// Validate the request
if err := validateCreateConversationRequest(req, app); err != nil {
return sendErrorEnvelope(r, err)
}
to := []string{req.Email} to := []string{req.Email}
// Validate required fields
if req.InboxID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`inbox_id`"), nil, envelope.InputError)
}
if req.Content == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`content`"), nil, envelope.InputError)
}
if req.Email == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`contact_email`"), nil, envelope.InputError)
}
if req.FirstName == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`first_name`"), nil, envelope.InputError)
}
if !stringutil.ValidEmail(req.Email) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`contact_email`"), nil, envelope.InputError)
}
user, err := app.user.GetAgent(auser.ID, "") user, err := app.user.GetAgent(auser.ID, "")
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
// Check if inbox exists and is enabled.
inbox, err := app.inbox.GetDBRecord(req.InboxID)
if err != nil {
return sendErrorEnvelope(r, err)
}
if !inbox.Enabled {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "inbox"), nil, envelope.InputError)
}
// Find or create contact. // Find or create contact.
contact := umodels.User{ contact := umodels.User{
Email: null.StringFrom(req.Email), Email: null.StringFrom(req.Email),
@@ -717,7 +696,7 @@ func handleCreateConversation(r *fastglue.Request) error {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.contact}"), nil)) return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.contact}"), nil))
} }
// Create conversation // Create conversation first.
conversationID, conversationUUID, err := app.conversation.CreateConversation( conversationID, conversationUUID, err := app.conversation.CreateConversation(
contact.ID, contact.ID,
contact.ContactChannelID, contact.ContactChannelID,
@@ -725,14 +704,14 @@ func handleCreateConversation(r *fastglue.Request) error {
"", /** last_message **/ "", /** last_message **/
time.Now(), /** last_message_at **/ time.Now(), /** last_message_at **/
req.Subject, req.Subject,
true, /** append reference number to subject **/ true, /** append reference number to subject? **/
) )
if err != nil { if err != nil {
app.lo.Error("error creating conversation", "error", err) app.lo.Error("error creating conversation", "error", err)
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.conversation}"), nil)) return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.conversation}"), nil))
} }
// Prepare attachments. // Get media for the attachment ids.
var media = make([]medModels.Media, 0, len(req.Attachments)) var media = make([]medModels.Media, 0, len(req.Attachments))
for _, id := range req.Attachments { for _, id := range req.Attachments {
m, err := app.media.Get(id, "") m, err := app.media.Get(id, "")
@@ -743,13 +722,29 @@ func handleCreateConversation(r *fastglue.Request) error {
media = append(media, m) media = append(media, m)
} }
// Send reply to the created conversation. // Send initial message based on the initiator of conversation.
if _, err := app.conversation.SendReply(media, req.InboxID, auser.ID /**sender_id**/, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil { switch req.Initiator {
// Delete the conversation if reply fails. case umodels.UserTypeAgent:
if err := app.conversation.DeleteConversation(conversationUUID); err != nil { // Queue reply.
app.lo.Error("error deleting conversation", "error", err) if _, err := app.conversation.QueueReply(media, req.InboxID, auser.ID /**sender_id**/, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil {
// Delete the conversation if msg queue fails.
if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
app.lo.Error("error deleting conversation", "error", err)
}
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil))
} }
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil)) case umodels.UserTypeContact:
// Create message on behalf of contact.
if _, err := app.conversation.CreateContactMessage(media, contact.ID, conversationUUID, req.Content, cmodels.ContentTypeHTML); err != nil {
// Delete the conversation if message creation fails.
if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
app.lo.Error("error deleting conversation", "error", err)
}
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil))
}
default:
// Guard anyway.
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`initiator`"), nil, envelope.InputError)
} }
// Assign the conversation to the agent or team. // Assign the conversation to the agent or team.
@@ -768,3 +763,36 @@ func handleCreateConversation(r *fastglue.Request) error {
return r.SendEnvelope(conversation) return r.SendEnvelope(conversation)
} }
// validateCreateConversationRequest validates the create conversation request fields.
func validateCreateConversationRequest(req createConversationRequest, app *App) error {
if req.InboxID <= 0 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`inbox_id`"), nil)
}
if req.Content == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`content`"), nil)
}
if req.Email == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`contact_email`"), nil)
}
if req.FirstName == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`first_name`"), nil)
}
if !stringutil.ValidEmail(req.Email) {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`contact_email`"), nil)
}
if req.Initiator != umodels.UserTypeContact && req.Initiator != umodels.UserTypeAgent {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`initiator`"), nil)
}
// Check if inbox exists and is enabled.
inbox, err := app.inbox.GetDBRecord(req.InboxID)
if err != nil {
return err
}
if !inbox.Enabled {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.disabled", "name", "inbox"), nil)
}
return nil
}

View File

@@ -17,7 +17,7 @@ func handleShowCSAT(r *fastglue.Request) error {
if err != nil { if err != nil {
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{ return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
"Data": map[string]interface{}{ "Data": map[string]interface{}{
"ErrorMessage": "Page not found", "ErrorMessage": app.i18n.T("globals.messages.pageNotFound"),
}, },
}) })
} }
@@ -25,8 +25,8 @@ func handleShowCSAT(r *fastglue.Request) error {
if csat.ResponseTimestamp.Valid { if csat.ResponseTimestamp.Valid {
return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{ return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{
"Data": map[string]interface{}{ "Data": map[string]interface{}{
"Title": "Thank you!", "Title": app.i18n.T("globals.messages.thankYou"),
"Message": "We appreciate you taking the time to submit your feedback.", "Message": app.i18n.T("csat.thankYouMessage"),
}, },
}) })
} }
@@ -35,14 +35,14 @@ func handleShowCSAT(r *fastglue.Request) error {
if err != nil { if err != nil {
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{ return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
"Data": map[string]interface{}{ "Data": map[string]interface{}{
"ErrorMessage": "Page not found", "ErrorMessage": app.i18n.T("globals.messages.pageNotFound"),
}, },
}) })
} }
return app.tmpl.RenderWebPage(r.RequestCtx, "csat", map[string]interface{}{ return app.tmpl.RenderWebPage(r.RequestCtx, "csat", map[string]interface{}{
"Data": map[string]interface{}{ "Data": map[string]interface{}{
"Title": "Rate your interaction with us", "Title": app.i18n.T("csat.pageTitle"),
"CSAT": map[string]interface{}{ "CSAT": map[string]interface{}{
"UUID": csat.UUID, "UUID": csat.UUID,
}, },
@@ -67,7 +67,7 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
if err != nil { if err != nil {
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{ return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
"Data": map[string]interface{}{ "Data": map[string]interface{}{
"ErrorMessage": "Invalid `rating`", "ErrorMessage": app.i18n.T("globals.messages.somethingWentWrong"),
}, },
}) })
} }
@@ -75,7 +75,7 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
if ratingI < 1 || ratingI > 5 { if ratingI < 1 || ratingI > 5 {
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{ return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
"Data": map[string]interface{}{ "Data": map[string]interface{}{
"ErrorMessage": "Invalid `rating`", "ErrorMessage": app.i18n.T("globals.messages.somethingWentWrong"),
}, },
}) })
} }
@@ -83,7 +83,7 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
if uuid == "" { if uuid == "" {
return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{ return app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
"Data": map[string]interface{}{ "Data": map[string]interface{}{
"ErrorMessage": "Invalid `uuid`", "ErrorMessage": app.i18n.T("globals.messages.somethingWentWrong"),
}, },
}) })
} }
@@ -98,8 +98,8 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{ return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{
"Data": map[string]interface{}{ "Data": map[string]interface{}{
"Title": "Thank you!", "Title": app.i18n.T("globals.messages.thankYou"),
"Message": "We appreciate you taking the time to submit your feedback.", "Message": app.i18n.T("csat.thankYouMessage"),
}, },
}) })
} }

View File

@@ -23,18 +23,20 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// i18n. // i18n.
g.GET("/api/v1/lang/{lang}", handleGetI18nLang) g.GET("/api/v1/lang/{lang}", handleGetI18nLang)
// Public config for app initialization.
g.GET("/api/v1/config", handleGetConfig)
// Media. // Media.
g.GET("/uploads/{uuid}", auth(handleServeMedia)) g.GET("/uploads/{uuid}", auth(handleServeMedia))
g.POST("/api/v1/media", auth(handleMediaUpload)) g.POST("/api/v1/media", auth(handleMediaUpload))
// Settings. // Settings.
g.GET("/api/v1/settings/general", handleGetGeneralSettings) g.GET("/api/v1/settings/general", auth(handleGetGeneralSettings))
g.PUT("/api/v1/settings/general", perm(handleUpdateGeneralSettings, "general_settings:manage")) g.PUT("/api/v1/settings/general", perm(handleUpdateGeneralSettings, "general_settings:manage"))
g.GET("/api/v1/settings/notifications/email", perm(handleGetEmailNotificationSettings, "notification_settings:manage")) g.GET("/api/v1/settings/notifications/email", perm(handleGetEmailNotificationSettings, "notification_settings:manage"))
g.PUT("/api/v1/settings/notifications/email", perm(handleUpdateEmailNotificationSettings, "notification_settings:manage")) g.PUT("/api/v1/settings/notifications/email", perm(handleUpdateEmailNotificationSettings, "notification_settings:manage"))
// OpenID connect single sign-on. // OpenID connect single sign-on.
g.GET("/api/v1/oidc/enabled", handleGetAllEnabledOIDC)
g.GET("/api/v1/oidc", perm(handleGetAllOIDC, "oidc:manage")) g.GET("/api/v1/oidc", perm(handleGetAllOIDC, "oidc:manage"))
g.POST("/api/v1/oidc", perm(handleCreateOIDC, "oidc:manage")) g.POST("/api/v1/oidc", perm(handleCreateOIDC, "oidc:manage"))
g.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "oidc:manage")) g.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "oidc:manage"))

View File

@@ -17,6 +17,12 @@ func handleGetInboxes(r *fastglue.Request) error {
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
for i := range inboxes {
if err := inboxes[i].ClearPasswords(); err != nil {
app.lo.Error("error clearing inbox passwords from response", "error", err)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.inbox}"), nil)
}
}
return r.SendEnvelope(inboxes) return r.SendEnvelope(inboxes)
} }

View File

@@ -181,11 +181,10 @@ func loadSettings(m *setting.Manager) {
} }
// initSettings inits setting manager. // initSettings inits setting manager.
func initSettings(db *sqlx.DB, i18n *i18n.I18n) *setting.Manager { func initSettings(db *sqlx.DB) *setting.Manager {
s, err := setting.New(setting.Opts{ s, err := setting.New(setting.Opts{
DB: db, DB: db,
Lo: initLogger("settings"), Lo: initLogger("settings"),
I18n: i18n,
}) })
if err != nil { if err != nil {
log.Fatalf("error initializing setting manager: %v", err) log.Fatalf("error initializing setting manager: %v", err)
@@ -329,7 +328,7 @@ func initWS(user *user.Manager) *ws.Hub {
func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants, i18n *i18n.I18n) *tmpl.Manager { func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants, i18n *i18n.I18n) *tmpl.Manager {
var ( var (
lo = initLogger("template") lo = initLogger("template")
funcMap = getTmplFuncs(consts) funcMap = getTmplFuncs(consts, i18n)
) )
tpls, err := stuffbin.ParseTemplatesGlob(funcMap, fs, "/static/email-templates/*.html") tpls, err := stuffbin.ParseTemplatesGlob(funcMap, fs, "/static/email-templates/*.html")
if err != nil { if err != nil {
@@ -347,7 +346,7 @@ func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants, i18n *
} }
// getTmplFuncs returns the template functions. // getTmplFuncs returns the template functions.
func getTmplFuncs(consts *constants) template.FuncMap { func getTmplFuncs(consts *constants, i18n *i18n.I18n) template.FuncMap {
return template.FuncMap{ return template.FuncMap{
"RootURL": func() string { "RootURL": func() string {
return consts.AppBaseURL return consts.AppBaseURL
@@ -367,6 +366,9 @@ func getTmplFuncs(consts *constants) template.FuncMap {
"SiteName": func() string { "SiteName": func() string {
return consts.SiteName return consts.SiteName
}, },
"L": func() interface{} {
return i18n
},
} }
} }
@@ -383,7 +385,10 @@ func reloadSettings(app *App) error {
app.lo.Error("error unmarshalling settings from DB", "error", err) app.lo.Error("error unmarshalling settings from DB", "error", err)
return err return err
} }
if err := ko.Load(confmap.Provider(out, "."), nil); err != nil { app.Lock()
err = ko.Load(confmap.Provider(out, "."), nil)
app.Unlock()
if err != nil {
app.lo.Error("error loading settings into koanf", "error", err) app.lo.Error("error loading settings into koanf", "error", err)
return err return err
} }
@@ -395,7 +400,7 @@ func reloadSettings(app *App) error {
// reloadTemplates reloads the templates from the filesystem. // reloadTemplates reloads the templates from the filesystem.
func reloadTemplates(app *App) error { func reloadTemplates(app *App) error {
app.lo.Info("reloading templates") app.lo.Info("reloading templates")
funcMap := getTmplFuncs(app.consts.Load().(*constants)) funcMap := getTmplFuncs(app.consts.Load().(*constants), app.i18n)
tpls, err := stuffbin.ParseTemplatesGlob(funcMap, app.fs, "/static/email-templates/*.html") tpls, err := stuffbin.ParseTemplatesGlob(funcMap, app.fs, "/static/email-templates/*.html")
if err != nil { if err != nil {
app.lo.Error("error parsing email templates", "error", err) app.lo.Error("error parsing email templates", "error", err)

View File

@@ -97,6 +97,8 @@ type App struct {
// Global state that stores data on an available app update. // Global state that stores data on an available app update.
update *AppUpdate update *AppUpdate
// Flag to indicate if app restart is required for settings to take effect.
restartRequired bool
sync.Mutex sync.Mutex
} }
@@ -157,10 +159,8 @@ func main() {
// Check for pending upgrade. // Check for pending upgrade.
checkPendingUpgrade(db) checkPendingUpgrade(db)
i18n := initI18n(fs)
// Load app settings from DB into the Koanf instance. // Load app settings from DB into the Koanf instance.
settings := initSettings(db, i18n) settings := initSettings(db)
loadSettings(settings) loadSettings(settings)
// Fallback for config typo. Logs a warning but continues to work with the incorrect key. // Fallback for config typo. Logs a warning but continues to work with the incorrect key.
@@ -184,6 +184,7 @@ func main() {
lo = initLogger(appName) lo = initLogger(appName)
rdb = initRedis() rdb = initRedis()
constants = initConstants() constants = initConstants()
i18n = initI18n(fs)
csat = initCSAT(db, i18n) csat = initCSAT(db, i18n)
oidc = initOIDC(db, settings, i18n) oidc = initOIDC(db, settings, i18n)
status = initStatus(db, i18n) status = initStatus(db, i18n)

View File

@@ -99,7 +99,7 @@ func handleGetMessage(r *fastglue.Request) error {
return r.SendEnvelope(message) return r.SendEnvelope(message)
} }
// handleRetryMessage changes message status so it can be retried for sending. // handleRetryMessage changes message status to `pending`, so it's enqueued for sending.
func handleRetryMessage(r *fastglue.Request) error { func handleRetryMessage(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
@@ -168,7 +168,7 @@ func handleSendMessage(r *fastglue.Request) error {
} }
return r.SendEnvelope(message) return r.SendEnvelope(message)
} }
message, err := app.conversation.SendReply(media, conv.InboxID, user.ID, cuuid, req.Message, req.To, req.CC, req.BCC, map[string]any{} /**meta**/) message, err := app.conversation.QueueReply(media, conv.InboxID, user.ID, cuuid, req.Message, req.To, req.CC, req.BCC, map[string]any{} /**meta**/)
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }

View File

@@ -11,16 +11,6 @@ import (
"github.com/zerodha/fastglue" "github.com/zerodha/fastglue"
) )
// handleGetAllEnabledOIDC returns all enabled OIDC records
func handleGetAllEnabledOIDC(r *fastglue.Request) error {
app := r.Context.(*App)
out, err := app.oidc.GetAllEnabled()
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(out)
}
// handleGetAllOIDC returns all OIDC records // handleGetAllOIDC returns all OIDC records
func handleGetAllOIDC(r *fastglue.Request) error { func handleGetAllOIDC(r *fastglue.Request) error {
app := r.Context.(*App) app := r.Context.(*App)
@@ -74,10 +64,10 @@ func handleCreateOIDC(r *fastglue.Request) error {
if err := reloadAuth(app); err != nil { if err := reloadAuth(app); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "OIDC"), nil, envelope.GeneralError) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "OIDC"), nil, envelope.GeneralError)
} }
// Clear client secret before returning // Clear client secret before returning
createdOIDC.ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10) createdOIDC.ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10)
return r.SendEnvelope(createdOIDC) return r.SendEnvelope(createdOIDC)
} }
@@ -110,10 +100,10 @@ func handleUpdateOIDC(r *fastglue.Request) error {
if err := reloadAuth(app); err != nil { if err := reloadAuth(app); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "OIDC"), nil, envelope.GeneralError) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "OIDC"), nil, envelope.GeneralError)
} }
// Clear client secret before returning // Clear client secret before returning
updatedOIDC.ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10) updatedOIDC.ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10)
return r.SendEnvelope(updatedOIDC) return r.SendEnvelope(updatedOIDC)
} }

View File

@@ -31,6 +31,8 @@ func handleGetGeneralSettings(r *fastglue.Request) error {
settings["app.update"] = app.update settings["app.update"] = app.update
// Set app version. // Set app version.
settings["app.version"] = versionString settings["app.version"] = versionString
// Set restart required flag.
settings["app.restart_required"] = app.restartRequired
return r.SendEnvelope(settings) return r.SendEnvelope(settings)
} }
@@ -45,6 +47,11 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.badRequest"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.badRequest"), nil, envelope.InputError)
} }
// Get current language before update.
app.Lock()
oldLang := ko.String("app.lang")
app.Unlock()
// Remove any trailing slash `/` from the root url. // Remove any trailing slash `/` from the root url.
req.RootURL = strings.TrimRight(req.RootURL, "/") req.RootURL = strings.TrimRight(req.RootURL, "/")
@@ -55,6 +62,17 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error {
if err := reloadSettings(app); err != nil { if err := reloadSettings(app); err != nil {
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.couldNotReload", "name", app.i18n.T("globals.terms.setting")), nil) return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.couldNotReload", "name", app.i18n.T("globals.terms.setting")), nil)
} }
// Check if language changed and reload i18n if needed.
app.Lock()
newLang := ko.String("app.lang")
if oldLang != newLang {
app.lo.Info("language changed, reloading i18n", "old_lang", oldLang, "new_lang", newLang)
app.i18n = initI18n(app.fs)
app.lo.Info("reloaded i18n", "old_lang", oldLang, "new_lang", newLang)
}
app.Unlock()
if err := reloadTemplates(app); err != nil { if err := reloadTemplates(app); err != nil {
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.couldNotReload", "name", app.i18n.T("globals.terms.setting")), nil) return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.couldNotReload", "name", app.i18n.T("globals.terms.setting")), nil)
} }
@@ -109,6 +127,7 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.invalidFromAddress"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.invalidFromAddress"), nil, envelope.InputError)
} }
// If empty then retain previous password.
if req.Password == "" { if req.Password == "" {
req.Password = cur.Password req.Password = cur.Password
} }
@@ -117,6 +136,10 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
// No reload implemented, so user has to restart the app. // Email notification settings require app restart to take effect.
app.Lock()
app.restartRequired = true
app.Unlock()
return r.SendEnvelope(true) return r.SendEnvelope(true)
} }

View File

@@ -83,7 +83,7 @@ func handleUpdateTeam(r *fastglue.Request) error {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil)) return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
} }
updatedTeam, err := app.team.Update(id, req.Name, req.Timezone, req.ConversationAssignmentType, req.BusinessHoursID, req.SLAPolicyID, req.Emoji.String, req.MaxAutoAssignedConversations); updatedTeam, err := app.team.Update(id, req.Name, req.Timezone, req.ConversationAssignmentType, req.BusinessHoursID, req.SLAPolicyID, req.Emoji.String, req.MaxAutoAssignedConversations)
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }

View File

@@ -26,34 +26,38 @@ const (
maxAvatarSizeMB = 2 maxAvatarSizeMB = 2
) )
// Request structs for user-related endpoints type updateAvailabilityRequest struct {
// UpdateAvailabilityRequest represents the request to update user availability
type UpdateAvailabilityRequest struct {
Status string `json:"status"` Status string `json:"status"`
} }
// ResetPasswordRequest represents the password reset request type resetPasswordRequest struct {
type ResetPasswordRequest struct {
Email string `json:"email"` Email string `json:"email"`
} }
// SetPasswordRequest represents the set password request type setPasswordRequest struct {
type SetPasswordRequest struct {
Token string `json:"token"` Token string `json:"token"`
Password string `json:"password"` Password string `json:"password"`
} }
// AvailabilityRequest represents the request to update agent availability type availabilityRequest struct {
type AvailabilityRequest struct {
Status string `json:"status"` Status string `json:"status"`
} }
type agentReq struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
SendWelcomeEmail bool `json:"send_welcome_email"`
Teams []string `json:"teams"`
Roles []string `json:"roles"`
Enabled bool `json:"enabled"`
AvailabilityStatus string `json:"availability_status"`
NewPassword string `json:"new_password,omitempty"`
}
// handleGetAgents returns all agents. // handleGetAgents returns all agents.
func handleGetAgents(r *fastglue.Request) error { func handleGetAgents(r *fastglue.Request) error {
var ( var app = r.Context.(*App)
app = r.Context.(*App)
)
agents, err := app.user.GetAgents() agents, err := app.user.GetAgents()
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
@@ -73,9 +77,7 @@ func handleGetAgentsCompact(r *fastglue.Request) error {
// handleGetAgent returns an agent. // handleGetAgent returns an agent.
func handleGetAgent(r *fastglue.Request) error { func handleGetAgent(r *fastglue.Request) error {
var ( var app = r.Context.(*App)
app = r.Context.(*App)
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string)) id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id <= 0 { if err != nil || id <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
@@ -93,7 +95,7 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error {
app = r.Context.(*App) app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User) auser = r.RequestCtx.UserValue("user").(amodels.User)
ip = realip.FromRequest(r.RequestCtx) ip = realip.FromRequest(r.RequestCtx)
availReq AvailabilityRequest availReq availabilityRequest
) )
// Decode JSON request // Decode JSON request
@@ -101,6 +103,7 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
} }
// Fetch entire agent
agent, err := app.user.GetAgent(auser.ID, "") agent, err := app.user.GetAgent(auser.ID, "")
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
@@ -108,10 +111,10 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error {
// Same status? // Same status?
if agent.AvailabilityStatus == availReq.Status { if agent.AvailabilityStatus == availReq.Status {
return r.SendEnvelope(true) return r.SendEnvelope(agent)
} }
// Update availability status. // Update availability status
if err := app.user.UpdateAvailability(auser.ID, availReq.Status); err != nil { if err := app.user.UpdateAvailability(auser.ID, availReq.Status); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
@@ -123,21 +126,22 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error {
} }
} }
return r.SendEnvelope(true) // Fetch updated agent and return
agent, err = app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(agent)
} }
// handleGetCurrentAgentTeams returns the teams of an agent. // handleGetCurrentAgentTeams returns the teams of current agent.
func handleGetCurrentAgentTeams(r *fastglue.Request) error { func handleGetCurrentAgentTeams(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User) auser = r.RequestCtx.UserValue("user").(amodels.User)
) )
agent, err := app.user.GetAgent(auser.ID, "") teams, err := app.team.GetUserTeams(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
teams, err := app.team.GetUserTeams(agent.ID)
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
@@ -150,11 +154,6 @@ func handleUpdateCurrentAgent(r *fastglue.Request) error {
app = r.Context.(*App) app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User) auser = r.RequestCtx.UserValue("user").(amodels.User)
) )
agent, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
form, err := r.RequestCtx.MultipartForm() form, err := r.RequestCtx.MultipartForm()
if err != nil { if err != nil {
app.lo.Error("error parsing form data", "error", err) app.lo.Error("error parsing form data", "error", err)
@@ -165,54 +164,53 @@ func handleUpdateCurrentAgent(r *fastglue.Request) error {
// Upload avatar? // Upload avatar?
if ok && len(files) > 0 { if ok && len(files) > 0 {
if err := uploadUserAvatar(r, &agent, files); err != nil { agent, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
if err := uploadUserAvatar(r, agent, files); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
} }
return r.SendEnvelope(true)
// Fetch updated agent and return.
agent, err := app.user.GetAgent(auser.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(agent)
} }
// handleCreateAgent creates a new agent. // handleCreateAgent creates a new agent.
func handleCreateAgent(r *fastglue.Request) error { func handleCreateAgent(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
user = models.User{} req = agentReq{}
) )
if err := r.Decode(&user, "json"); err != nil {
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
} }
if user.Email.String == "" { // Validate agent request
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError) if err := validateAgentRequest(r, &req); err != nil {
} return err
user.Email = null.StringFrom(strings.TrimSpace(strings.ToLower(user.Email.String)))
if !stringutil.ValidEmail(user.Email.String) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`email`"), nil, envelope.InputError)
} }
if user.Roles == nil { agent, err := app.user.CreateAgent(req.FirstName, req.LastName, req.Email, req.Roles)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError) if err != nil {
}
if user.FirstName == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError)
}
if err := app.user.CreateAgent(&user); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
// Upsert user teams. // Upsert user teams.
if len(user.Teams) > 0 { if len(req.Teams) > 0 {
if err := app.team.UpsertUserTeams(user.ID, user.Teams.Names()); err != nil { app.team.UpsertUserTeams(agent.ID, req.Teams)
return sendErrorEnvelope(r, err)
}
} }
if user.SendWelcomeEmail { if req.SendWelcomeEmail {
// Generate reset token. // Generate reset token.
resetToken, err := app.user.SetResetPasswordToken(user.ID) resetToken, err := app.user.SetResetPasswordToken(agent.ID)
if err != nil { if err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
@@ -220,31 +218,36 @@ func handleCreateAgent(r *fastglue.Request) error {
// Render template and send email. // Render template and send email.
content, err := app.tmpl.RenderInMemoryTemplate(tmpl.TmplWelcome, map[string]any{ content, err := app.tmpl.RenderInMemoryTemplate(tmpl.TmplWelcome, map[string]any{
"ResetToken": resetToken, "ResetToken": resetToken,
"Email": user.Email.String, "Email": req.Email,
}) })
if err != nil { if err != nil {
app.lo.Error("error rendering template", "error", err) app.lo.Error("error rendering template", "error", err)
return r.SendEnvelope(true)
} }
if err := app.notifier.Send(notifier.Message{ if err := app.notifier.Send(notifier.Message{
RecipientEmails: []string{user.Email.String}, RecipientEmails: []string{req.Email},
Subject: "Welcome to Libredesk", Subject: app.i18n.T("globals.messages.welcomeToLibredesk"),
Content: content, Content: content,
Provider: notifier.ProviderEmail, Provider: notifier.ProviderEmail,
}); err != nil { }); err != nil {
app.lo.Error("error sending notification message", "error", err) app.lo.Error("error sending notification message", "error", err)
return r.SendEnvelope(true)
} }
} }
return r.SendEnvelope(true)
// Refetch agent as other details might've changed.
agent, err = app.user.GetAgent(agent.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(agent)
} }
// handleUpdateAgent updates an agent. // handleUpdateAgent updates an agent.
func handleUpdateAgent(r *fastglue.Request) error { func handleUpdateAgent(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
user = models.User{} req = agentReq{}
auser = r.RequestCtx.UserValue("user").(amodels.User) auser = r.RequestCtx.UserValue("user").(amodels.User)
ip = realip.FromRequest(r.RequestCtx) ip = realip.FromRequest(r.RequestCtx)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
@@ -253,25 +256,13 @@ func handleUpdateAgent(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
} }
if err := r.Decode(&user, "json"); err != nil { if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
} }
if user.Email.String == "" { // Validate agent request
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError) if err := validateAgentRequest(r, &req); err != nil {
} return err
user.Email = null.StringFrom(strings.TrimSpace(strings.ToLower(user.Email.String)))
if !stringutil.ValidEmail(user.Email.String) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`email`"), nil, envelope.InputError)
}
if user.Roles == nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError)
}
if user.FirstName == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError)
} }
agent, err := app.user.GetAgent(id, "") agent, err := app.user.GetAgent(id, "")
@@ -280,8 +271,8 @@ func handleUpdateAgent(r *fastglue.Request) error {
} }
oldAvailabilityStatus := agent.AvailabilityStatus oldAvailabilityStatus := agent.AvailabilityStatus
// Update agent. // Update agent with individual fields
if err = app.user.UpdateAgent(id, user); err != nil { if err = app.user.UpdateAgent(id, req.FirstName, req.LastName, req.Email, req.Roles, req.Enabled, req.AvailabilityStatus, req.NewPassword); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
@@ -289,18 +280,24 @@ func handleUpdateAgent(r *fastglue.Request) error {
defer app.authz.InvalidateUserCache(id) defer app.authz.InvalidateUserCache(id)
// Create activity log if user availability status changed. // Create activity log if user availability status changed.
if oldAvailabilityStatus != user.AvailabilityStatus { if oldAvailabilityStatus != req.AvailabilityStatus {
if err := app.activityLog.UserAvailability(auser.ID, auser.Email, user.AvailabilityStatus, ip, user.Email.String, id); err != nil { if err := app.activityLog.UserAvailability(auser.ID, auser.Email, req.AvailabilityStatus, ip, req.Email, id); err != nil {
app.lo.Error("error creating activity log", "error", err) app.lo.Error("error creating activity log", "error", err)
} }
} }
// Upsert agent teams. // Upsert agent teams.
if err := app.team.UpsertUserTeams(id, user.Teams.Names()); err != nil { if err := app.team.UpsertUserTeams(id, req.Teams); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
return r.SendEnvelope(true) // Refetch agent and return.
agent, err = app.user.GetAgent(id, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(agent)
} }
// handleDeleteAgent soft deletes an agent. // handleDeleteAgent soft deletes an agent.
@@ -381,7 +378,7 @@ func handleResetPassword(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
auser, ok = r.RequestCtx.UserValue("user").(amodels.User) auser, ok = r.RequestCtx.UserValue("user").(amodels.User)
resetReq ResetPasswordRequest resetReq resetPasswordRequest
) )
if ok && auser.ID > 0 { if ok && auser.ID > 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("user.userAlreadyLoggedIn"), nil, envelope.InputError) return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("user.userAlreadyLoggedIn"), nil, envelope.InputError)
@@ -399,7 +396,7 @@ func handleResetPassword(r *fastglue.Request) error {
agent, err := app.user.GetAgent(0, resetReq.Email) agent, err := app.user.GetAgent(0, resetReq.Email)
if err != nil { if err != nil {
// Send 200 even if user not found, to prevent email enumeration. // Send 200 even if user not found, to prevent email enumeration.
return r.SendEnvelope("Reset password email sent successfully.") return r.SendEnvelope(true)
} }
token, err := app.user.SetResetPasswordToken(agent.ID) token, err := app.user.SetResetPasswordToken(agent.ID)
@@ -434,7 +431,7 @@ func handleSetPassword(r *fastglue.Request) error {
var ( var (
app = r.Context.(*App) app = r.Context.(*App)
agent, ok = r.RequestCtx.UserValue("user").(amodels.User) agent, ok = r.RequestCtx.UserValue("user").(amodels.User)
req = SetPasswordRequest{} req setPasswordRequest
) )
if ok && agent.ID > 0 { if ok && agent.ID > 0 {
@@ -457,13 +454,13 @@ func handleSetPassword(r *fastglue.Request) error {
} }
// uploadUserAvatar uploads the user avatar. // uploadUserAvatar uploads the user avatar.
func uploadUserAvatar(r *fastglue.Request, user *models.User, files []*multipart.FileHeader) error { func uploadUserAvatar(r *fastglue.Request, user models.User, files []*multipart.FileHeader) error {
var app = r.Context.(*App) var app = r.Context.(*App)
fileHeader := files[0] fileHeader := files[0]
file, err := fileHeader.Open() file, err := fileHeader.Open()
if err != nil { if err != nil {
app.lo.Error("error opening uploaded file", "error", err) app.lo.Error("error opening uploaded file", "user_id", user.ID, "error", err)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorReading", "name", "{globals.terms.file}"), nil) return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorReading", "name", "{globals.terms.file}"), nil)
} }
defer file.Close() defer file.Close()
@@ -480,7 +477,7 @@ func uploadUserAvatar(r *fastglue.Request, user *models.User, files []*multipart
// Check file size // Check file size
if bytesToMegabytes(srcFileSize) > maxAvatarSizeMB { if bytesToMegabytes(srcFileSize) > maxAvatarSizeMB {
app.lo.Error("error uploaded file size is larger than max allowed", "size", bytesToMegabytes(srcFileSize), "max_allowed", maxAvatarSizeMB) app.lo.Error("error uploaded file size is larger than max allowed", "user_id", user.ID, "size", bytesToMegabytes(srcFileSize), "max_allowed", maxAvatarSizeMB)
return envelope.NewError( return envelope.NewError(
envelope.InputError, envelope.InputError,
app.i18n.Ts("media.fileSizeTooLarge", "size", fmt.Sprintf("%dMB", maxAvatarSizeMB)), app.i18n.Ts("media.fileSizeTooLarge", "size", fmt.Sprintf("%dMB", maxAvatarSizeMB)),
@@ -497,23 +494,25 @@ func uploadUserAvatar(r *fastglue.Request, user *models.User, files []*multipart
meta := []byte("{}") meta := []byte("{}")
media, err := app.media.UploadAndInsert(srcFileName, srcContentType, contentID, linkedModel, linkedID, file, int(srcFileSize), disposition, meta) media, err := app.media.UploadAndInsert(srcFileName, srcContentType, contentID, linkedModel, linkedID, file, int(srcFileSize), disposition, meta)
if err != nil { if err != nil {
app.lo.Error("error uploading file", "error", err) app.lo.Error("error uploading file", "user_id", user.ID, "error", err)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.file}"), nil) return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.file}"), nil)
} }
// Delete current avatar. // Delete current avatar.
if user.AvatarURL.Valid { if user.AvatarURL.Valid {
fileName := filepath.Base(user.AvatarURL.String) fileName := filepath.Base(user.AvatarURL.String)
app.media.Delete(fileName) if err := app.media.Delete(fileName); err != nil {
app.lo.Error("error deleting user avatar", "user_id", user.ID, "error", err)
}
} }
// Save file path. // Save file path.
path, err := stringutil.GetPathFromURL(media.URL) path, err := stringutil.GetPathFromURL(media.URL)
if err != nil { if err != nil {
app.lo.Debug("error getting path from URL", "url", media.URL, "error", err) app.lo.Debug("error getting path from URL", "user_id", user.ID, "url", media.URL, "error", err)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.file}"), nil) return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.file}"), nil)
} }
fmt.Println("path", path)
if err := app.user.UpdateAvatar(user.ID, path); err != nil { if err := app.user.UpdateAvatar(user.ID, path); err != nil {
return sendErrorEnvelope(r, err) return sendErrorEnvelope(r, err)
} }
@@ -577,3 +576,28 @@ func handleRevokeAPIKey(r *fastglue.Request) error {
return r.SendEnvelope(true) return r.SendEnvelope(true)
} }
// validateAgentRequest validates common agent request fields and normalizes the email
func validateAgentRequest(r *fastglue.Request, req *agentReq) error {
var app = r.Context.(*App)
// Normalize email
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
if req.Email == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
}
if !stringutil.ValidEmail(req.Email) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`email`"), nil, envelope.InputError)
}
if req.Roles == nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError)
}
if req.FirstName == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError)
}
return nil
}

View File

@@ -2,23 +2,33 @@
describe('Login Component', () => { describe('Login Component', () => {
beforeEach(() => { beforeEach(() => {
// Visit the login page
cy.visit('/')
// Mock the API response for OIDC providers // Mock the API response for OIDC providers
cy.intercept('GET', '**/api/v1/oidc/enabled', { cy.intercept('GET', '**/api/v1/config', {
statusCode: 200, statusCode: 200,
body: { body: {
data: [ data: {
{ "app.favicon_url": "http://localhost:9000/favicon.ico",
id: 1, "app.lang": "en",
name: 'Google', "app.logo_url": "http://localhost:9000/logo.png",
logo_url: 'https://example.com/google-logo.png', "app.site_name": "Libredesk",
disabled: false "app.sso_providers": [
} {
] "client_id": "xx",
"enabled": true,
"id": 1,
"logo_url": "/images/google-logo.png",
"name": "Google",
"provider": "Google",
"provider_url": "https://accounts.google.com",
"redirect_uri": "http://localhost:9000/api/v1/oidc/1/finish"
}
]
}
} }
}).as('getOIDCProviders') }).as('getOIDCProviders')
// Visit the login page
cy.visit('/')
}) })
it('should display login form', () => { it('should display login form', () => {

View File

@@ -88,8 +88,8 @@
@create-conversation="() => (openCreateConversationDialog = true)" @create-conversation="() => (openCreateConversationDialog = true)"
> >
<div class="flex flex-col h-screen"> <div class="flex flex-col h-screen">
<!-- Show app update only in admin routes --> <!-- Show admin banner only in admin routes -->
<AppUpdate v-if="route.path.startsWith('/admin')" /> <AdminBanner v-if="route.path.startsWith('/admin')" />
<!-- Common header for all pages --> <!-- Common header for all pages -->
<PageHeader /> <PageHeader />
@@ -128,7 +128,7 @@ import { useCustomAttributeStore } from '@/stores/customAttributes'
import { useIdleDetection } from '@/composables/useIdleDetection' import { useIdleDetection } from '@/composables/useIdleDetection'
import PageHeader from './components/layout/PageHeader.vue' import PageHeader from './components/layout/PageHeader.vue'
import ViewForm from '@/features/view/ViewForm.vue' import ViewForm from '@/features/view/ViewForm.vue'
import AppUpdate from '@/components/update/AppUpdate.vue' import AdminBanner from '@/components/banner/AdminBanner.vue'
import api from '@/api' import api from '@/api'
import { toast as sooner } from 'vue-sonner' import { toast as sooner } from 'vue-sonner'
import Sidebar from '@/components/sidebar/Sidebar.vue' import Sidebar from '@/components/sidebar/Sidebar.vue'

View File

@@ -122,7 +122,7 @@ const createOIDC = (data) =>
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
}) })
const getAllEnabledOIDC = () => http.get('/api/v1/oidc/enabled') const getConfig = () => http.get('/api/v1/config')
const getAllOIDC = () => http.get('/api/v1/oidc') const getAllOIDC = () => http.get('/api/v1/oidc')
const getOIDC = (id) => http.get(`/api/v1/oidc/${id}`) const getOIDC = (id) => http.get(`/api/v1/oidc/${id}`)
const updateOIDC = (id, data) => const updateOIDC = (id, data) =>
@@ -514,7 +514,7 @@ export default {
updateSettings, updateSettings,
createOIDC, createOIDC,
getAllOIDC, getAllOIDC,
getAllEnabledOIDC, getConfig,
getOIDC, getOIDC,
updateOIDC, updateOIDC,
deleteOIDC, deleteOIDC,

View File

@@ -0,0 +1,63 @@
<template>
<div class="border-b">
<!-- Update notification -->
<div
v-if="appSettingsStore.settings['app.update']?.update?.is_new"
class="px-4 py-2.5 border-b border-border/50 last:border-b-0"
>
<div class="flex items-center gap-3">
<div class="flex-shrink-0">
<Download class="w-5 h-5 text-primary" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 text-sm text-foreground">
<span>{{ $t('update.newUpdateAvailable') }}</span>
<a
:href="appSettingsStore.settings['app.update'].update.url"
target="_blank"
rel="nofollow noreferrer"
class="font-semibold text-primary hover:text-primary/80 underline transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1"
>
{{ appSettingsStore.settings['app.update'].update.release_version }}
</a>
<span class="text-muted-foreground"></span>
<span class="text-muted-foreground">
{{ appSettingsStore.settings['app.update'].update.release_date }}
</span>
</div>
<!-- Update description -->
<div
v-if="appSettingsStore.settings['app.update'].update.description"
class="mt-2 text-xs text-muted-foreground"
>
{{ appSettingsStore.settings['app.update'].update.description }}
</div>
</div>
</div>
</div>
<!-- Restart required notification -->
<div
v-if="appSettingsStore.settings['app.restart_required']"
class="px-4 py-2.5 border-b border-border/50 last:border-b-0"
>
<div class="flex items-center gap-3">
<div class="flex-shrink-0">
<Info class="w-5 h-5 text-primary" />
</div>
<div class="min-w-0 flex-1">
<div class="text-sm text-foreground">
{{ $t('admin.banner.restartMessage') }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { Download, Info } from 'lucide-vue-next'
import { useAppSettingsStore } from '@/stores/appSettings'
const appSettingsStore = useAppSettingsStore()
</script>

View File

@@ -4,9 +4,9 @@
@click="handleClick"> @click="handleClick">
<div class="flex items-center mb-2"> <div class="flex items-center mb-2">
<component :is="icon" size="24" class="mr-2 text-primary" /> <component :is="icon" size="24" class="mr-2 text-primary" />
<h3 class="text-lg font-medium text-gray-800">{{ title }}</h3> <h3 class="text-lg font-medium">{{ title }}</h3>
</div> </div>
<p class="text-sm text-gray-600">{{ subTitle }}</p> <p class="text-sm text-gray-600 dark:text-gray-400">{{ subTitle }}</p>
</div> </div>
</template> </template>

View File

@@ -1,25 +0,0 @@
<template>
<div
v-if="appSettingsStore.settings['app.update']?.update?.is_new"
class="p-2 mb-2 border-b bg-secondary text-secondary-foreground"
>
{{ $t('update.newUpdateAvailable') }}:
{{ appSettingsStore.settings['app.update'].update.release_version }} ({{
appSettingsStore.settings['app.update'].update.release_date
}})
<a
:href="appSettingsStore.settings['app.update'].update.url"
target="_blank"
nofollow
noreferrer
class="underline ml-2"
>
{{ $t('globals.messages.viewDetails') }}
</a>
</div>
</template>
<script setup>
import { useAppSettingsStore } from '@/stores/appSettings'
const appSettingsStore = useAppSettingsStore()
</script>

View File

@@ -1 +1,3 @@
export const Roles = ["Admin", "Agent"] export const Roles = ["Admin", "Agent"]
export const UserTypeAgent = "agent"
export const UserTypeContact = "contact"

View File

@@ -418,7 +418,6 @@ const onSubmit = form.handleSubmit((values) => {
if (values.availability_status === 'active_group') { if (values.availability_status === 'active_group') {
values.availability_status = 'online' values.availability_status = 'online'
} }
values.teams = values.teams.map((team) => ({ name: team }))
props.submitForm(values) props.submitForm(values)
}) })

View File

@@ -1,5 +1,106 @@
<template> <template>
<div class="h-screen w-full flex items-center justify-center min-w-[400px]"> <div class="placeholder-container">
<p>{{ $t('conversation.placeholder') }}</p> <Spinner v-if="isLoading" />
<template v-else>
<div v-if="showGettingStarted" class="getting-started-wrapper">
<div class="text-center">
<h2 class="text-2xl font-semibold text-foreground mb-6">
{{ $t('setup.completeYourSetup') }}
</h2>
<div class="space-y-4 mb-6">
<div class="checklist-item" :class="{ completed: hasInboxes }">
<CheckCircle v-if="hasInboxes" class="check-icon completed" />
<Circle v-else class="w-5 h-5 text-muted-foreground" />
<span class="flex-1 text-left ml-3 text-foreground">
{{ $t('setup.createFirstInbox') }}
</span>
<Button
v-if="!hasInboxes"
variant="ghost"
size="sm"
@click="router.push({ name: 'inbox-list' })"
class="ml-auto"
>
{{ $t('globals.messages.setUp') }}
</Button>
</div>
<div class="checklist-item" :class="{ completed: hasAgents, disabled: !hasInboxes }">
<CheckCircle v-if="hasAgents" class="check-icon completed" />
<Circle v-else class="w-5 h-5 text-muted-foreground" />
<span class="flex-1 text-left ml-3 text-foreground">
{{ $t('setup.inviteTeammates') }}
</span>
<Button
v-if="!hasAgents && hasInboxes"
variant="ghost"
size="sm"
@click="router.push({ name: 'agent-list' })"
class="ml-auto"
>
{{ $t('globals.messages.invite') }}
</Button>
</div>
</div>
</div>
</div>
<div v-else>
<p class="placeholder-text">{{ $t('conversation.placeholder') }}</p>
</div>
</template>
</div> </div>
</template> </template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { CheckCircle, Circle } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/spinner'
import { useInboxStore } from '@/stores/inbox'
import { useUsersStore } from '@/stores/users'
const router = useRouter()
const inboxStore = useInboxStore()
const usersStore = useUsersStore()
const isLoading = ref(true)
onMounted(async () => {
try {
await Promise.all([inboxStore.fetchInboxes(), usersStore.fetchUsers()])
} finally {
isLoading.value = false
}
})
const hasInboxes = computed(() => inboxStore.inboxes.length > 0)
const hasAgents = computed(() => usersStore.users.length > 0)
const showGettingStarted = computed(() => !hasInboxes.value || !hasAgents.value)
</script>
<style scoped>
.placeholder-container {
@apply h-screen w-full flex items-center justify-center min-w-[400px] relative;
}
.getting-started-wrapper {
@apply w-full max-w-md mx-auto px-4;
}
.checklist-item {
@apply flex items-center justify-between py-3 px-4 rounded-lg border border-border;
}
.checklist-item.completed {
@apply bg-muted/50;
}
.checklist-item.disabled {
@apply opacity-50;
}
.check-icon.completed {
@apply w-5 h-5 text-primary;
}
</style>

View File

@@ -10,7 +10,7 @@
}) })
}} }}
</DialogTitle> </DialogTitle>
<DialogDescription/> <DialogDescription />
</DialogHeader> </DialogHeader>
<form @submit="createConversation" class="flex flex-col flex-1 overflow-hidden"> <form @submit="createConversation" class="flex flex-col flex-1 overflow-hidden">
<!-- Form Fields Section --> <!-- Form Fields Section -->
@@ -263,6 +263,7 @@ import { useFileUpload } from '@/composables/useFileUpload'
import Editor from '@/components/editor/TextEditor.vue' import Editor from '@/components/editor/TextEditor.vue'
import { useMacroStore } from '@/stores/macro' import { useMacroStore } from '@/stores/macro'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue' import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
import { UserTypeAgent } from '@/constants/user'
import api from '@/api' import api from '@/api'
const dialogOpen = defineModel({ const dialogOpen = defineModel({
@@ -393,12 +394,14 @@ const selectContact = (contact) => {
const createConversation = form.handleSubmit(async (values) => { const createConversation = form.handleSubmit(async (values) => {
loading.value = true loading.value = true
try { try {
// convert ids to numbers if they are not already // Convert ids to numbers if they are not already
values.inbox_id = Number(values.inbox_id) values.inbox_id = Number(values.inbox_id)
values.team_id = values.team_id ? Number(values.team_id) : null values.team_id = values.team_id ? Number(values.team_id) : null
values.agent_id = values.agent_id ? Number(values.agent_id) : null values.agent_id = values.agent_id ? Number(values.agent_id) : null
// array of attachment ids. // Array of attachment ids.
values.attachments = mediaFiles.value.map((file) => file.id) values.attachments = mediaFiles.value.map((file) => file.id)
// Initiator of this conversation is always agent
values.initiator = UserTypeAgent
const conversation = await api.createConversation(values) const conversation = await api.createConversation(values)
const conversationUUID = conversation.data.data.uuid const conversationUUID = conversation.data.data.uuid

View File

@@ -18,14 +18,14 @@ const setFavicon = (url) => {
} }
async function initApp () { async function initApp () {
const settings = (await api.getSettings('general')).data.data const config = (await api.getConfig()).data.data
const emitter = mitt() const emitter = mitt()
const lang = settings['app.lang'] || 'en' const lang = config['app.lang'] || 'en'
const langMessages = await api.getLanguage(lang) const langMessages = await api.getLanguage(lang)
// Set favicon. // Set favicon.
if (settings['app.favicon_url']) if (config['app.favicon_url'])
setFavicon(settings['app.favicon_url']) setFavicon(config['app.favicon_url'])
// Initialize i18n. // Initialize i18n.
const i18nConfig = { const i18nConfig = {
@@ -42,9 +42,17 @@ async function initApp () {
const pinia = createPinia() const pinia = createPinia()
app.use(pinia) app.use(pinia)
// Store app settings in Pinia // Fetch and store app settings in store (after pinia is initialized)
const settingsStore = useAppSettingsStore() const settingsStore = useAppSettingsStore()
settingsStore.setSettings(settings)
// Store the public config in the store
settingsStore.setPublicConfig(config)
try {
await settingsStore.fetchSettings('general')
} catch (error) {
// Pass
}
// Add emitter to global properties. // Add emitter to global properties.
app.config.globalProperties.emitter = emitter app.config.globalProperties.emitter = emitter

View File

@@ -1,12 +1,35 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import api from '@/api'
export const useAppSettingsStore = defineStore('settings', { export const useAppSettingsStore = defineStore('settings', {
state: () => ({ state: () => ({
settings: {} settings: {},
public_config: {}
}), }),
actions: { actions: {
async fetchSettings (key = 'general') {
try {
const response = await api.getSettings(key)
this.settings = response?.data?.data || {}
return this.settings
} catch (error) {
// Pass
}
},
async fetchPublicConfig () {
try {
const response = await api.getConfig()
this.public_config = response?.data?.data || {}
return this.public_config
} catch (error) {
// Pass
}
},
setSettings (newSettings) { setSettings (newSettings) {
this.settings = newSettings this.settings = newSettings
},
setPublicConfig (newPublicConfig) {
this.public_config = newPublicConfig
} }
} }
}) })

View File

@@ -12,14 +12,13 @@ export const useInboxStore = defineStore('inbox', () => {
label: inb.name, label: inb.name,
value: String(inb.id) value: String(inb.id)
}))) })))
const fetchInboxes = async () => { const fetchInboxes = async (force = false) => {
if (inboxes.value.length) return if (!force && inboxes.value.length) return
try { try {
const response = await api.getInboxes() const response = await api.getInboxes()
inboxes.value = response?.data?.data || [] inboxes.value = response?.data?.data || []
} catch (error) { } catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive', variant: 'destructive',
description: handleHTTPError(error).message description: handleHTTPError(error).message
}) })

View File

@@ -5,6 +5,7 @@ import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents' import { EMITTER_EVENTS } from '@/constants/emitterEvents'
import api from '@/api' import api from '@/api'
// TODO: rename this store to agents
export const useUsersStore = defineStore('users', () => { export const useUsersStore = defineStore('users', () => {
const users = ref([]) const users = ref([])
const emitter = useEmitter() const emitter = useEmitter()
@@ -13,8 +14,8 @@ export const useUsersStore = defineStore('users', () => {
value: String(user.id), value: String(user.id),
avatar_url: user.avatar_url, avatar_url: user.avatar_url,
}))) })))
const fetchUsers = async () => { const fetchUsers = async (force = false) => {
if (users.value.length) return if (!force && users.value.length) return
try { try {
const response = await api.getUsersCompact() const response = await api.getUsersCompact()
users.value = response?.data?.data || [] users.value = response?.data?.data || []

View File

@@ -17,7 +17,7 @@
</template> </template>
<script setup> <script setup>
import { onMounted, ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
import { createColumns } from '@/features/admin/agents/dataTableColumns.js' import { createColumns } from '@/features/admin/agents/dataTableColumns.js'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import DataTable from '@/components/datatable/DataTable.vue' import DataTable from '@/components/datatable/DataTable.vue'
@@ -25,10 +25,11 @@ import { handleHTTPError } from '@/utils/http'
import { Spinner } from '@/components/ui/spinner' import { Spinner } from '@/components/ui/spinner'
import { useEmitter } from '@/composables/useEmitter' import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js' import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import api from '@/api' import { useUsersStore } from '@/stores/users'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
const isLoading = ref(false) const isLoading = ref(false)
const usersStore = useUsersStore()
const { t } = useI18n() const { t } = useI18n()
const data = ref([]) const data = ref([])
const emitter = useEmitter() const emitter = useEmitter()
@@ -40,11 +41,15 @@ onMounted(async () => {
}) })
}) })
onUnmounted(() => {
emitter.off(EMITTER_EVENTS.REFRESH_LIST)
})
const getData = async () => { const getData = async () => {
try { try {
isLoading.value = true isLoading.value = true
const response = await api.getUsers() await usersStore.fetchUsers(true)
data.value = response.data.data data.value = usersStore.users
} catch (error) { } catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive', variant: 'destructive',

View File

@@ -20,15 +20,17 @@ import { ref, onMounted } from 'vue'
import { Spinner } from '@/components/ui/spinner' import { Spinner } from '@/components/ui/spinner'
import GeneralSettingForm from '@/features/admin/general/GeneralSettingForm.vue' import GeneralSettingForm from '@/features/admin/general/GeneralSettingForm.vue'
import AdminPageWithHelp from '@/layouts/admin/AdminPageWithHelp.vue' import AdminPageWithHelp from '@/layouts/admin/AdminPageWithHelp.vue'
import { useAppSettingsStore } from '@/stores/appSettings'
import api from '@/api' import api from '@/api'
const initialValues = ref({}) const initialValues = ref({})
const isLoading = ref(false) const isLoading = ref(false)
const settingsStore = useAppSettingsStore()
onMounted(async () => { onMounted(async () => {
isLoading.value = true isLoading.value = true
const response = await api.getSettings('general') await settingsStore.fetchSettings('general')
const data = response.data.data const data = settingsStore.settings
isLoading.value = false isLoading.value = false
initialValues.value = Object.keys(data).reduce((acc, key) => { initialValues.value = Object.keys(data).reduce((acc, key) => {
// Remove 'app.' prefix // Remove 'app.' prefix

View File

@@ -32,11 +32,13 @@ import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { format } from 'date-fns' import { format } from 'date-fns'
import { Spinner } from '@/components/ui/spinner' import { Spinner } from '@/components/ui/spinner'
import { useInboxStore } from '@/stores/inbox'
import api from '@/api' import api from '@/api'
const { t } = useI18n() const { t } = useI18n()
const router = useRouter() const router = useRouter()
const emitter = useEmitter() const emitter = useEmitter()
const inboxStore = useInboxStore()
const isLoading = ref(false) const isLoading = ref(false)
const data = ref([]) const data = ref([])
@@ -47,8 +49,8 @@ onMounted(async () => {
const getInboxes = async () => { const getInboxes = async () => {
try { try {
isLoading.value = true isLoading.value = true
const response = await api.getInboxes() await inboxStore.fetchInboxes(true)
data.value = response.data.data data.value = inboxStore.inboxes
} catch (error) { } catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive', variant: 'destructive',

View File

@@ -9,7 +9,7 @@
<CardContent class="p-6 space-y-6"> <CardContent class="p-6 space-y-6">
<div class="space-y-2 text-center"> <div class="space-y-2 text-center">
<CardTitle class="text-3xl font-bold text-foreground"> <CardTitle class="text-3xl font-bold text-foreground">
{{ appSettingsStore.settings?.['app.site_name'] || 'Libredesk' }} {{ appSettingsStore.public_config?.['app.site_name'] || 'LIBREDESK' }}
</CardTitle> </CardTitle>
<p class="text-muted-foreground">{{ t('auth.signIn') }}</p> <p class="text-muted-foreground">{{ t('auth.signIn') }}</p>
</div> </div>
@@ -25,9 +25,8 @@
> >
<img <img
:src="oidcProvider.logo_url" :src="oidcProvider.logo_url"
:alt="oidcProvider.name"
width="20" width="20"
class="mr-2"
alt=""
v-if="oidcProvider.logo_url" v-if="oidcProvider.logo_url"
/> />
{{ oidcProvider.name }} {{ oidcProvider.name }}
@@ -89,7 +88,9 @@
type="submit" type="submit"
> >
<span v-if="isLoading" class="flex items-center justify-center"> <span v-if="isLoading" class="flex items-center justify-center">
<div class="w-5 h-5 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin mr-3"></div> <div
class="w-5 h-5 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin mr-3"
></div>
{{ t('auth.loggingIn') }} {{ t('auth.loggingIn') }}
</span> </span>
<span v-else>{{ t('auth.signInButton') }}</span> <span v-else>{{ t('auth.signInButton') }}</span>
@@ -159,8 +160,10 @@ onMounted(async () => {
const fetchOIDCProviders = async () => { const fetchOIDCProviders = async () => {
try { try {
const resp = await api.getAllEnabledOIDC() const config = appSettingsStore.public_config
oidcProviders.value = resp.data.data if (config && config['app.sso_providers']) {
oidcProviders.value = config['app.sso_providers'] || []
}
} catch (error) { } catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive', variant: 'destructive',
@@ -204,6 +207,9 @@ const loginAction = () => {
if (resp?.data?.data) { if (resp?.data?.data) {
userStore.setCurrentUser(resp.data.data) userStore.setCurrentUser(resp.data.data)
} }
// Also fetch general setting as user's logged in.
appSettingsStore.fetchSettings('general')
// Navigate to inboxes
router.push({ name: 'inboxes' }) router.push({ name: 'inboxes' })
}) })
.catch((error) => { .catch((error) => {

2
go.mod
View File

@@ -7,6 +7,7 @@ require (
github.com/coreos/go-oidc/v3 v3.11.0 github.com/coreos/go-oidc/v3 v3.11.0
github.com/disintegration/imaging v1.6.2 github.com/disintegration/imaging v1.6.2
github.com/emersion/go-imap/v2 v2.0.0-beta.3 github.com/emersion/go-imap/v2 v2.0.0-beta.3
github.com/emersion/go-message v0.18.1
github.com/fasthttp/websocket v1.5.9 github.com/fasthttp/websocket v1.5.9
github.com/ferluci/fast-realip v1.0.1 github.com/ferluci/fast-realip v1.0.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
@@ -49,7 +50,6 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/emersion/go-message v0.18.1 // indirect
github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect
github.com/fasthttp/router v1.5.0 // indirect github.com/fasthttp/router v1.5.0 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect

View File

@@ -188,6 +188,7 @@
"globals.terms.recipient": "Recipient | Recipients", "globals.terms.recipient": "Recipient | Recipients",
"globals.terms.tls": "TLS | TLSs", "globals.terms.tls": "TLS | TLSs",
"globals.terms.credential": "Credential | Credentials", "globals.terms.credential": "Credential | Credentials",
"globals.messages.welcomeToLibredesk": "Welcome to Libredesk",
"globals.messages.invalid": "Invalid {name}", "globals.messages.invalid": "Invalid {name}",
"globals.messages.custom": "Custom {name}", "globals.messages.custom": "Custom {name}",
"globals.messages.replying": "Replying", "globals.messages.replying": "Replying",
@@ -294,6 +295,8 @@
"globals.messages.submit": "Submit", "globals.messages.submit": "Submit",
"globals.messages.send": "Send {name}", "globals.messages.send": "Send {name}",
"globals.messages.update": "Update {name}", "globals.messages.update": "Update {name}",
"globals.messages.setUp": "Set up",
"globals.messages.invite": "Invite",
"globals.messages.enable": "Enable", "globals.messages.enable": "Enable",
"globals.messages.disable": "Disable", "globals.messages.disable": "Disable",
"globals.messages.block": "Block {name}", "globals.messages.block": "Block {name}",
@@ -306,6 +309,12 @@
"globals.messages.reset": "Reset {name}", "globals.messages.reset": "Reset {name}",
"globals.messages.lastNItems": "Last {n} {name} | Last {n} {name}", "globals.messages.lastNItems": "Last {n} {name} | Last {n} {name}",
"globals.messages.correctEmailErrors": "Please correct the email errors", "globals.messages.correctEmailErrors": "Please correct the email errors",
"globals.messages.additionalFeedback": "Additional feedback (optional)",
"globals.messages.pleaseSelect": "Please select {name} before submitting",
"globals.messages.poweredBy": "Powered by",
"globals.messages.thankYou": "Thank you!",
"globals.messages.pageNotFound": "Page not found",
"globals.messages.somethingWentWrong": "Something went wrong",
"form.error.min": "Must be at least {min} characters", "form.error.min": "Must be at least {min} characters",
"form.error.max": "Must be at most {max} characters", "form.error.max": "Must be at most {max} characters",
"form.error.minmax": "Must be between {min} and {max} characters", "form.error.minmax": "Must be between {min} and {max} characters",
@@ -339,6 +348,14 @@
"conversationStatus.alreadyInUse": "Cannot delete status as it is in use, Please remove this status from all conversations before deleting", "conversationStatus.alreadyInUse": "Cannot delete status as it is in use, Please remove this status from all conversations before deleting",
"conversationStatus.cannotUpdateDefault": "Cannot update default conversation status", "conversationStatus.cannotUpdateDefault": "Cannot update default conversation status",
"csat.alreadySubmitted": "CSAT already submitted", "csat.alreadySubmitted": "CSAT already submitted",
"csat.rateYourInteraction": "Rate your recent interaction",
"csat.rating.poor": "Poor",
"csat.rating.fair": "Fair",
"csat.rating.good": "Good",
"csat.rating.great": "Great",
"csat.rating.excellent": "Excellent",
"csat.pageTitle": "Rate your interaction with us",
"csat.thankYouMessage": "We appreciate you taking the time to submit your feedback.",
"auth.csrfTokenMismatch": "CSRF token mismatch", "auth.csrfTokenMismatch": "CSRF token mismatch",
"auth.invalidOrExpiredSession": "Invalid or expired session", "auth.invalidOrExpiredSession": "Invalid or expired session",
"auth.invalidOrExpiredSessionClearCookie": "Invalid or expired session. Please clear your cookies and try again.", "auth.invalidOrExpiredSessionClearCookie": "Invalid or expired session. Please clear your cookies and try again.",
@@ -508,6 +525,7 @@
"admin.role.contactNotes.write": "Add Contact Notes", "admin.role.contactNotes.write": "Add Contact Notes",
"admin.role.contactNotes.delete": "Delete Contact Notes", "admin.role.contactNotes.delete": "Delete Contact Notes",
"admin.role.customAttributes.manage": "Manage Custom Attributes", "admin.role.customAttributes.manage": "Manage Custom Attributes",
"admin.role.webhooks.manage": "Manage Webhooks",
"admin.role.activityLog.manage": "Manage Activity Log", "admin.role.activityLog.manage": "Manage Activity Log",
"admin.automation.newConversation.description": "Rules that run when a new conversation is created, drag and drop to reorder rules.", "admin.automation.newConversation.description": "Rules that run when a new conversation is created, drag and drop to reorder rules.",
"admin.automation.conversationUpdate": "Conversation Update", "admin.automation.conversationUpdate": "Conversation Update",
@@ -533,6 +551,7 @@
"admin.automation.event.message.incoming": "Incoming message", "admin.automation.event.message.incoming": "Incoming message",
"admin.automation.invalid": "Make sure you have atleast one action and one rule and their values are not empty.", "admin.automation.invalid": "Make sure you have atleast one action and one rule and their values are not empty.",
"admin.notification.restartApp": "Settings updated successfully, Please restart the app for changes to take effect.", "admin.notification.restartApp": "Settings updated successfully, Please restart the app for changes to take effect.",
"admin.banner.restartMessage": "Some settings have been changed that require an application restart to take effect.",
"admin.template.outgoingEmailTemplates": "Outgoing email templates", "admin.template.outgoingEmailTemplates": "Outgoing email templates",
"admin.template.emailNotificationTemplates": "Email notification templates", "admin.template.emailNotificationTemplates": "Email notification templates",
"admin.template.makeSureTemplateHasContent": "Make sure the template has {content} only once.", "admin.template.makeSureTemplateHasContent": "Make sure the template has {content} only once.",
@@ -622,5 +641,8 @@
"contact.unblockConfirm": "Are you sure you want to unblock this contact? They will be able to interact with you again.", "contact.unblockConfirm": "Are you sure you want to unblock this contact? They will be able to interact with you again.",
"contact.alreadyExistsWithEmail": "Another contact with same email already exists", "contact.alreadyExistsWithEmail": "Another contact with same email already exists",
"contact.notes.empty": "No notes yet", "contact.notes.empty": "No notes yet",
"contact.notes.help": "Add note for this contact to keep track of important information and conversations." "contact.notes.help": "Add note for this contact to keep track of important information and conversations.",
"setup.completeYourSetup": "Complete your setup",
"setup.createFirstInbox": "Create your first inbox",
"setup.inviteTeammates": "Invite teammates"
} }

View File

@@ -82,18 +82,9 @@ func (m *Manager) GetAll(order, orderBy, filtersJSON string, page, pageSize int)
return activityLogs, nil return activityLogs, nil
} }
// Create adds a new activity log.
func (m *Manager) Create(activityType, activityDescription string, actorID int, targetModelType string, targetModelID int, ip string) error {
if _, err := m.q.InsertActivity.Exec(activityType, activityDescription, actorID, targetModelType, targetModelID, ip); err != nil {
m.lo.Error("error inserting activity", "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.activityLog}"), nil)
}
return nil
}
// Login records a login event for the given user. // Login records a login event for the given user.
func (al *Manager) Login(userID int, email, ip string) error { func (al *Manager) Login(userID int, email, ip string) error {
return al.Create( return al.create(
models.AgentLogin, models.AgentLogin,
fmt.Sprintf("%s (#%d) logged in", email, userID), fmt.Sprintf("%s (#%d) logged in", email, userID),
userID, userID,
@@ -105,7 +96,7 @@ func (al *Manager) Login(userID int, email, ip string) error {
// Logout records a logout event for the given user. // Logout records a logout event for the given user.
func (al *Manager) Logout(userID int, email, ip string) error { func (al *Manager) Logout(userID int, email, ip string) error {
return al.Create( return al.create(
models.AgentLogout, models.AgentLogout,
fmt.Sprintf("%s (#%d) logged out", email, userID), fmt.Sprintf("%s (#%d) logged out", email, userID),
userID, userID,
@@ -123,7 +114,7 @@ func (al *Manager) Away(actorID int, actorEmail, ip string, targetID int, target
} else { } else {
description = fmt.Sprintf("%s (#%d) is away", actorEmail, actorID) description = fmt.Sprintf("%s (#%d) is away", actorEmail, actorID)
} }
return al.Create( return al.create(
models.AgentAway, /* activity type*/ models.AgentAway, /* activity type*/
description, description,
actorID, /*actor_id*/ actorID, /*actor_id*/
@@ -141,7 +132,7 @@ func (al *Manager) AwayReassigned(actorID int, actorEmail, ip string, targetID i
} else { } else {
description = fmt.Sprintf("%s (#%d) is away and reassigning", actorEmail, actorID) description = fmt.Sprintf("%s (#%d) is away and reassigning", actorEmail, actorID)
} }
return al.Create( return al.create(
models.AgentAwayReassigned, /* activity type*/ models.AgentAwayReassigned, /* activity type*/
description, description,
actorID, /*actor_id*/ actorID, /*actor_id*/
@@ -159,7 +150,7 @@ func (al *Manager) Online(actorID int, actorEmail, ip string, targetID int, targ
} else { } else {
description = fmt.Sprintf("%s (#%d) is online", actorEmail, actorID) description = fmt.Sprintf("%s (#%d) is online", actorEmail, actorID)
} }
return al.Create( return al.create(
models.AgentOnline, /* activity type*/ models.AgentOnline, /* activity type*/
description, description,
actorID, /*actor_id*/ actorID, /*actor_id*/
@@ -190,6 +181,16 @@ func (al *Manager) UserAvailability(actorID int, actorEmail, status, ip, targetE
return nil return nil
} }
// create creates a new activity log in DB.
func (m *Manager) create(activityType, activityDescription string, actorID int, targetModelType string, targetModelID int, ip string) error {
var activityLog models.ActivityLog
if err := m.q.InsertActivity.Get(&activityLog, activityType, activityDescription, actorID, targetModelType, targetModelID, ip); err != nil {
m.lo.Error("error inserting activity log", "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.activityLog}"), nil)
}
return nil
}
// makeQuery constructs the SQL query for fetching activity logs with filters and pagination. // makeQuery constructs the SQL query for fetching activity logs with filters and pagination.
func (m *Manager) makeQuery(page, pageSize int, order, orderBy, filtersJSON string) (string, []any, error) { func (m *Manager) makeQuery(page, pageSize int, order, orderBy, filtersJSON string) (string, []any, error) {
var ( var (

View File

@@ -2,10 +2,10 @@
SELECT id, name, provider, config, is_default FROM ai_providers where is_default is true; SELECT id, name, provider, config, is_default FROM ai_providers where is_default is true;
-- name: get-prompt -- name: get-prompt
SELECT id, key, title, content FROM ai_prompts where key = $1; SELECT id, created_at, updated_at, key, title, content FROM ai_prompts where key = $1;
-- name: get-prompts -- name: get-prompts
SELECT id, key, title FROM ai_prompts order by title; SELECT id, created_at, updated_at, key, title FROM ai_prompts order by title;
-- name: set-openai-key -- name: set-openai-key
UPDATE ai_providers UPDATE ai_providers

View File

@@ -183,6 +183,7 @@ func (e *Enforcer) EnforceConversationAccess(user umodels.User, conversation cmo
// EnforceMediaAccess checks for read access on linked model to media. // EnforceMediaAccess checks for read access on linked model to media.
func (e *Enforcer) EnforceMediaAccess(user umodels.User, model string) (bool, error) { func (e *Enforcer) EnforceMediaAccess(user umodels.User, model string) (bool, error) {
switch model { switch model {
// TODO: Pick this table / model name from the package/models/models.go
case "messages": case "messages":
allowed, err := e.Enforce(user, model, "read") allowed, err := e.Enforce(user, model, "read")
if err != nil { if err != nil {

View File

@@ -33,7 +33,7 @@ type conversationStore interface {
type teamStore interface { type teamStore interface {
GetAll() ([]tmodels.Team, error) GetAll() ([]tmodels.Team, error)
GetMembers(teamID int) ([]umodels.User, error) GetMembers(teamID int) ([]tmodels.TeamMember, error)
} }
// Engine represents a manager for assigning unassigned conversations // Engine represents a manager for assigning unassigned conversations

View File

@@ -7,10 +7,10 @@ select
from automation_rules where enabled is TRUE ORDER BY weight ASC; from automation_rules where enabled is TRUE ORDER BY weight ASC;
-- name: get-all -- name: get-all
SELECT id, created_at, updated_at, enabled, name, description, type, events, rules, execution_mode from automation_rules where type = $1 ORDER BY weight ASC; SELECT id, created_at, updated_at, "name", description, "type", rules, events, enabled, weight, execution_mode from automation_rules where type = $1 ORDER BY weight ASC;
-- name: get-rule -- name: get-rule
SELECT id, created_at, updated_at, enabled, name, description, type, events, rules, execution_mode from automation_rules where id = $1; SELECT id, created_at, updated_at, "name", description, "type", rules, events, enabled, weight, execution_mode from automation_rules where id = $1;
-- name: update-rule -- name: update-rule
INSERT INTO automation_rules(id, name, description, type, events, rules, enabled) INSERT INTO automation_rules(id, name, description, type, events, rules, enabled)

View File

@@ -15,7 +15,10 @@ SELECT id,
created_at, created_at,
updated_at, updated_at,
"name", "name",
description description,
is_always_open,
hours,
holidays
FROM business_hours FROM business_hours
ORDER BY updated_at DESC; ORDER BY updated_at DESC;

View File

@@ -200,7 +200,7 @@ type queries struct {
GetConversationsCreatedAfter *sqlx.Stmt `query:"get-conversations-created-after"` GetConversationsCreatedAfter *sqlx.Stmt `query:"get-conversations-created-after"`
GetUnassignedConversations *sqlx.Stmt `query:"get-unassigned-conversations"` GetUnassignedConversations *sqlx.Stmt `query:"get-unassigned-conversations"`
GetConversations string `query:"get-conversations"` GetConversations string `query:"get-conversations"`
GetContactConversations *sqlx.Stmt `query:"get-contact-conversations"` GetContactPreviousConversations *sqlx.Stmt `query:"get-contact-previous-conversations"`
GetConversationParticipants *sqlx.Stmt `query:"get-conversation-participants"` GetConversationParticipants *sqlx.Stmt `query:"get-conversation-participants"`
GetUserActiveConversationsCount *sqlx.Stmt `query:"get-user-active-conversations-count"` GetUserActiveConversationsCount *sqlx.Stmt `query:"get-user-active-conversations-count"`
UpdateConversationFirstReplyAt *sqlx.Stmt `query:"update-conversation-first-reply-at"` UpdateConversationFirstReplyAt *sqlx.Stmt `query:"update-conversation-first-reply-at"`
@@ -280,11 +280,11 @@ func (c *Manager) GetConversation(id int, uuid string) (models.Conversation, err
return conversation, nil return conversation, nil
} }
// GetContactConversations retrieves conversations for a contact. // GetContactPreviousConversations retrieves previous conversations for a contact with a configurable limit.
func (c *Manager) GetContactConversations(contactID int) ([]models.Conversation, error) { func (c *Manager) GetContactPreviousConversations(contactID int, limit int) ([]models.PreviousConversation, error) {
var conversations = make([]models.Conversation, 0) var conversations = make([]models.PreviousConversation, 0)
if err := c.q.GetContactConversations.Select(&conversations, contactID); err != nil { if err := c.q.GetContactPreviousConversations.Select(&conversations, contactID, limit); err != nil {
c.lo.Error("error fetching conversations", "error", err) c.lo.Error("error fetching previous conversations", "error", err)
return conversations, envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.conversation}"), nil) return conversations, envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.conversation}"), nil)
} }
return conversations, nil return conversations, nil
@@ -348,32 +348,32 @@ func (c *Manager) GetConversationUUID(id int) (string, error) {
} }
// GetAllConversationsList retrieves all conversations with optional filtering, ordering, and pagination. // GetAllConversationsList retrieves all conversations with optional filtering, ordering, and pagination.
func (c *Manager) GetAllConversationsList(order, orderBy, filters string, page, pageSize int) ([]models.Conversation, error) { func (c *Manager) GetAllConversationsList(order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
return c.GetConversations(0, []int{}, []string{models.AllConversations}, order, orderBy, filters, page, pageSize) return c.GetConversations(0, []int{}, []string{models.AllConversations}, order, orderBy, filters, page, pageSize)
} }
// GetAssignedConversationsList retrieves conversations assigned to a specific user with optional filtering, ordering, and pagination. // GetAssignedConversationsList retrieves conversations assigned to a specific user with optional filtering, ordering, and pagination.
func (c *Manager) GetAssignedConversationsList(userID int, order, orderBy, filters string, page, pageSize int) ([]models.Conversation, error) { func (c *Manager) GetAssignedConversationsList(userID int, order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
return c.GetConversations(userID, []int{}, []string{models.AssignedConversations}, order, orderBy, filters, page, pageSize) return c.GetConversations(userID, []int{}, []string{models.AssignedConversations}, order, orderBy, filters, page, pageSize)
} }
// GetUnassignedConversationsList retrieves conversations assigned to a team the user is part of with optional filtering, ordering, and pagination. // GetUnassignedConversationsList retrieves conversations assigned to a team the user is part of with optional filtering, ordering, and pagination.
func (c *Manager) GetUnassignedConversationsList(order, orderBy, filters string, page, pageSize int) ([]models.Conversation, error) { func (c *Manager) GetUnassignedConversationsList(order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
return c.GetConversations(0, []int{}, []string{models.UnassignedConversations}, order, orderBy, filters, page, pageSize) return c.GetConversations(0, []int{}, []string{models.UnassignedConversations}, order, orderBy, filters, page, pageSize)
} }
// GetTeamUnassignedConversationsList retrieves conversations assigned to a team with optional filtering, ordering, and pagination. // GetTeamUnassignedConversationsList retrieves conversations assigned to a team with optional filtering, ordering, and pagination.
func (c *Manager) GetTeamUnassignedConversationsList(teamID int, order, orderBy, filters string, page, pageSize int) ([]models.Conversation, error) { func (c *Manager) GetTeamUnassignedConversationsList(teamID int, order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
return c.GetConversations(0, []int{teamID}, []string{models.TeamUnassignedConversations}, order, orderBy, filters, page, pageSize) return c.GetConversations(0, []int{teamID}, []string{models.TeamUnassignedConversations}, order, orderBy, filters, page, pageSize)
} }
func (c *Manager) GetViewConversationsList(userID int, teamIDs []int, listType []string, order, orderBy, filters string, page, pageSize int) ([]models.Conversation, error) { func (c *Manager) GetViewConversationsList(userID int, teamIDs []int, listType []string, order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
return c.GetConversations(userID, teamIDs, listType, order, orderBy, filters, page, pageSize) return c.GetConversations(userID, teamIDs, listType, order, orderBy, filters, page, pageSize)
} }
// GetConversations retrieves conversations list based on user ID, type, and optional filtering, ordering, and pagination. // GetConversations retrieves conversations list based on user ID, type, and optional filtering, ordering, and pagination.
func (c *Manager) GetConversations(userID int, teamIDs []int, listTypes []string, order, orderBy, filters string, page, pageSize int) ([]models.Conversation, error) { func (c *Manager) GetConversations(userID int, teamIDs []int, listTypes []string, order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
var conversations = make([]models.Conversation, 0) var conversations = make([]models.ConversationListItem, 0)
// Make the query. // Make the query.
query, qArgs, err := c.makeConversationsListQuery(userID, teamIDs, listTypes, c.q.GetConversations, order, orderBy, page, pageSize, filters) query, qArgs, err := c.makeConversationsListQuery(userID, teamIDs, listTypes, c.q.GetConversations, order, orderBy, page, pageSize, filters)
@@ -930,7 +930,7 @@ func (m *Manager) ApplyAction(action amodels.RuleAction, conv models.Conversatio
if err != nil { if err != nil {
return fmt.Errorf("making recipients for reply action: %w", err) return fmt.Errorf("making recipients for reply action: %w", err)
} }
_, err = m.SendReply( _, err = m.QueueReply(
[]mmodels.Media{}, []mmodels.Media{},
conv.InboxID, conv.InboxID,
user.ID, user.ID,
@@ -1001,8 +1001,8 @@ func (m *Manager) SendCSATReply(actorUserID int, conversation models.Conversatio
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.csat}"), nil) return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.csat}"), nil)
} }
// Send CSAT reply. // Queue CSAT reply.
_, err = m.SendReply(nil /**media**/, conversation.InboxID, actorUserID, conversation.UUID, message, to, cc, bcc, meta) _, err = m.QueueReply(nil /**media**/, conversation.InboxID, actorUserID, conversation.UUID, message, to, cc, bcc, meta)
if err != nil { if err != nil {
m.lo.Error("error sending CSAT reply", "conversation_uuid", conversation.UUID, "error", err) m.lo.Error("error sending CSAT reply", "conversation_uuid", conversation.UUID, "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.csat}"), nil) return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.csat}"), nil)

View File

@@ -167,7 +167,7 @@ func (m *Manager) sendOutgoingMessage(message models.Message) {
} }
// References is sorted in DESC i.e newest message first, so reverse it to keep the references in order. // References is sorted in DESC i.e newest message first, so reverse it to keep the references in order.
stringutil.ReverseSlice(message.References) slices.Reverse(message.References)
// Remove the current message ID from the references. // Remove the current message ID from the references.
message.References = stringutil.RemoveItemByValue(message.References, message.SourceID.String) message.References = stringutil.RemoveItemByValue(message.References, message.SourceID.String)
@@ -347,9 +347,10 @@ func (m *Manager) UpdateMessageStatus(messageUUID string, status string) error {
return nil return nil
} }
// MarkMessageAsPending updates message status to `Pending`, so if it's a outgoing message it can be picked up again by a worker. // MarkMessageAsPending updates message status to `Pending`, enqueuing it for sending.
func (m *Manager) MarkMessageAsPending(uuid string) error { func (m *Manager) MarkMessageAsPending(uuid string) error {
if err := m.UpdateMessageStatus(uuid, models.MessageStatusPending); err != nil { if err := m.UpdateMessageStatus(uuid, models.MessageStatusPending); err != nil {
m.lo.Error("error marking message as pending", "uuid", uuid, "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil) return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil)
} }
return nil return nil
@@ -374,8 +375,27 @@ func (m *Manager) SendPrivateNote(media []mmodels.Media, senderID int, conversat
return message, nil return message, nil
} }
// SendReply inserts a reply message in a conversation. // CreateContactMessage creates a contact message in a conversation.
func (m *Manager) SendReply(media []mmodels.Media, inboxID, senderID int, conversationUUID, content string, to, cc, bcc []string, meta map[string]interface{}) (models.Message, error) { func (m *Manager) CreateContactMessage(media []mmodels.Media, contactID int, conversationUUID, content, contentType string) (models.Message, error) {
message := models.Message{
ConversationUUID: conversationUUID,
SenderID: contactID,
Type: models.MessageIncoming,
SenderType: models.SenderTypeContact,
Status: models.MessageStatusReceived,
Content: content,
ContentType: contentType,
Private: false,
Media: media,
}
if err := m.InsertMessage(&message); err != nil {
return models.Message{}, err
}
return message, nil
}
// QueueReply queues a reply message in a conversation.
func (m *Manager) QueueReply(media []mmodels.Media, inboxID, senderID int, conversationUUID, content string, to, cc, bcc []string, meta map[string]interface{}) (models.Message, error) {
var ( var (
message = models.Message{} message = models.Message{}
) )
@@ -402,7 +422,7 @@ func (m *Manager) SendReply(media []mmodels.Media, inboxID, senderID int, conver
return message, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorMarshalling", "name", "{globals.terms.meta}"), nil) return message, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorMarshalling", "name", "{globals.terms.meta}"), nil)
} }
// Generage unique source ID i.e. message-id for email. // Generate unique source ID i.e. message-id for email.
inbox, err := m.inboxStore.GetDBRecord(inboxID) inbox, err := m.inboxStore.GetDBRecord(inboxID)
if err != nil { if err != nil {
return message, err return message, err

View File

@@ -52,48 +52,124 @@ var (
ContentTypeHTML = "html" ContentTypeHTML = "html"
) )
// ConversationListItem represents a conversation in list views
type ConversationListItem struct {
Total int `db:"total" json:"-"`
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
UUID string `db:"uuid" json:"uuid"`
WaitingSince null.Time `db:"waiting_since" json:"waiting_since"`
AssigneeLastSeenAt null.Time `db:"assignee_last_seen_at" json:"assignee_last_seen_at"`
Contact ConversationListContact `db:"contact" json:"contact"`
InboxChannel string `db:"inbox_channel" json:"inbox_channel"`
InboxName string `db:"inbox_name" json:"inbox_name"`
SLAPolicyID null.Int `db:"sla_policy_id" json:"sla_policy_id"`
FirstReplyAt null.Time `db:"first_reply_at" json:"first_reply_at"`
LastReplyAt null.Time `db:"last_reply_at" json:"last_reply_at"`
ResolvedAt null.Time `db:"resolved_at" json:"resolved_at"`
Subject null.String `db:"subject" json:"subject"`
LastMessage null.String `db:"last_message" json:"last_message"`
LastMessageAt null.Time `db:"last_message_at" json:"last_message_at"`
LastMessageSender null.String `db:"last_message_sender" json:"last_message_sender"`
NextSLADeadlineAt null.Time `db:"next_sla_deadline_at" json:"next_sla_deadline_at"`
PriorityID null.Int `db:"priority_id" json:"priority_id"`
UnreadMessageCount int `db:"unread_message_count" json:"unread_message_count"`
Status null.String `db:"status" json:"status"`
Priority null.String `db:"priority" json:"priority"`
FirstResponseDueAt null.Time `db:"first_response_deadline_at" json:"first_response_deadline_at"`
ResolutionDueAt null.Time `db:"resolution_deadline_at" json:"resolution_deadline_at"`
AppliedSLAID null.Int `db:"applied_sla_id" json:"applied_sla_id"`
NextResponseDueAt null.Time `db:"next_response_deadline_at" json:"next_response_deadline_at"`
NextResponseMetAt null.Time `db:"next_response_met_at" json:"next_response_met_at"`
}
// ConversationListContact represents contact info in conversation list views
type ConversationListContact struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
FirstName string `db:"first_name" json:"first_name"`
LastName string `db:"last_name" json:"last_name"`
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
}
type Conversation struct { type Conversation struct {
ID int `db:"id" json:"id,omitempty"` ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"` CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
UUID string `db:"uuid" json:"uuid"` UUID string `db:"uuid" json:"uuid"`
ContactID int `db:"contact_id" json:"contact_id"` ContactID int `db:"contact_id" json:"contact_id"`
InboxID int `db:"inbox_id" json:"inbox_id,omitempty"` InboxID int `db:"inbox_id" json:"inbox_id"`
ClosedAt null.Time `db:"closed_at" json:"closed_at,omitempty"` ClosedAt null.Time `db:"closed_at" json:"closed_at"`
ResolvedAt null.Time `db:"resolved_at" json:"resolved_at,omitempty"` ResolvedAt null.Time `db:"resolved_at" json:"resolved_at"`
ReferenceNumber string `db:"reference_number" json:"reference_number,omitempty"` ReferenceNumber string `db:"reference_number" json:"reference_number"`
Priority null.String `db:"priority" json:"priority"` Priority null.String `db:"priority" json:"priority"`
PriorityID null.Int `db:"priority_id" json:"priority_id"` PriorityID null.Int `db:"priority_id" json:"priority_id"`
Status null.String `db:"status" json:"status"` Status null.String `db:"status" json:"status"`
StatusID null.Int `db:"status_id" json:"status_id"` StatusID null.Int `db:"status_id" json:"status_id"`
FirstReplyAt null.Time `db:"first_reply_at" json:"first_reply_at"` FirstReplyAt null.Time `db:"first_reply_at" json:"first_reply_at"`
LastReplyAt null.Time `db:"last_reply_at" json:"last_reply_at"` LastReplyAt null.Time `db:"last_reply_at" json:"last_reply_at"`
AssignedUserID null.Int `db:"assigned_user_id" json:"assigned_user_id"` AssignedUserID null.Int `db:"assigned_user_id" json:"assigned_user_id"`
AssignedTeamID null.Int `db:"assigned_team_id" json:"assigned_team_id"` AssignedTeamID null.Int `db:"assigned_team_id" json:"assigned_team_id"`
AssigneeLastSeenAt null.Time `db:"assignee_last_seen_at" json:"assignee_last_seen_at"` AssigneeLastSeenAt null.Time `db:"assignee_last_seen_at" json:"assignee_last_seen_at"`
WaitingSince null.Time `db:"waiting_since" json:"waiting_since"` WaitingSince null.Time `db:"waiting_since" json:"waiting_since"`
Subject null.String `db:"subject" json:"subject"` Subject null.String `db:"subject" json:"subject"`
UnreadMessageCount int `db:"unread_message_count" json:"unread_message_count"` InboxMail string `db:"inbox_mail" json:"inbox_mail"`
InboxMail string `db:"inbox_mail" json:"inbox_mail"` InboxName string `db:"inbox_name" json:"inbox_name"`
InboxName string `db:"inbox_name" json:"inbox_name"` InboxChannel string `db:"inbox_channel" json:"inbox_channel"`
InboxChannel string `db:"inbox_channel" json:"inbox_channel"` Tags null.JSON `db:"tags" json:"tags"`
Tags null.JSON `db:"tags" json:"tags"` Meta pq.StringArray `db:"meta" json:"meta"`
Meta pq.StringArray `db:"meta" json:"meta"` CustomAttributes json.RawMessage `db:"custom_attributes" json:"custom_attributes"`
CustomAttributes json.RawMessage `db:"custom_attributes" json:"custom_attributes"` LastMessageAt null.Time `db:"last_message_at" json:"last_message_at"`
LastMessageAt null.Time `db:"last_message_at" json:"last_message_at"` LastMessage null.String `db:"last_message" json:"last_message"`
LastMessage null.String `db:"last_message" json:"last_message"` LastMessageSender null.String `db:"last_message_sender" json:"last_message_sender"`
LastMessageSender null.String `db:"last_message_sender" json:"last_message_sender"` Contact ConversationContact `db:"contact" json:"contact"`
Contact umodels.User `db:"contact" json:"contact"` SLAPolicyID null.Int `db:"sla_policy_id" json:"sla_policy_id"`
SLAPolicyID null.Int `db:"sla_policy_id" json:"sla_policy_id"` SlaPolicyName null.String `db:"sla_policy_name" json:"sla_policy_name"`
SlaPolicyName null.String `db:"sla_policy_name" json:"sla_policy_name"` AppliedSLAID null.Int `db:"applied_sla_id" json:"applied_sla_id"`
AppliedSLAID null.Int `db:"applied_sla_id" json:"applied_sla_id"` FirstResponseDueAt null.Time `db:"first_response_deadline_at" json:"first_response_deadline_at"`
NextSLADeadlineAt null.Time `db:"next_sla_deadline_at" json:"next_sla_deadline_at"` ResolutionDueAt null.Time `db:"resolution_deadline_at" json:"resolution_deadline_at"`
FirstResponseDueAt null.Time `db:"first_response_deadline_at" json:"first_response_deadline_at"` NextResponseDueAt null.Time `db:"next_response_deadline_at" json:"next_response_deadline_at"`
ResolutionDueAt null.Time `db:"resolution_deadline_at" json:"resolution_deadline_at"` NextResponseMetAt null.Time `db:"next_response_met_at" json:"next_response_met_at"`
NextResponseDueAt null.Time `db:"next_response_deadline_at" json:"next_response_deadline_at"` PreviousConversations []PreviousConversation `db:"-" json:"previous_conversations"`
NextResponseMetAt null.Time `db:"next_response_met_at" json:"next_response_met_at"` }
PreviousConversations []Conversation `db:"-" json:"previous_conversations"`
Total int `db:"total" json:"-"` type ConversationContact struct {
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
FirstName string `db:"first_name" json:"first_name"`
LastName string `db:"last_name" json:"last_name"`
Email null.String `db:"email" json:"email"`
Type string `db:"type" json:"type"`
AvailabilityStatus string `db:"availability_status" json:"availability_status"`
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
PhoneNumber null.String `db:"phone_number" json:"phone_number"`
PhoneNumberCallingCode null.String `db:"phone_number_calling_code" json:"phone_number_calling_code"`
CustomAttributes json.RawMessage `db:"custom_attributes" json:"custom_attributes"`
Enabled bool `db:"enabled" json:"enabled"`
LastActiveAt null.Time `db:"last_active_at" json:"last_active_at"`
LastLoginAt null.Time `db:"last_login_at" json:"last_login_at"`
}
func (c *ConversationContact) FullName() string {
return c.FirstName + " " + c.LastName
}
type PreviousConversation struct {
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
UUID string `db:"uuid" json:"uuid"`
Contact PreviousConversationContact `db:"contact" json:"contact"`
LastMessage null.String `db:"last_message" json:"last_message"`
LastMessageAt null.Time `db:"last_message_at" json:"last_message_at"`
}
type PreviousConversationContact struct {
FirstName string `db:"first_name" json:"first_name"`
LastName string `db:"last_name" json:"last_name"`
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
} }
type ConversationParticipant struct { type ConversationParticipant struct {
@@ -117,13 +193,15 @@ type NewConversationsStats struct {
// Message represents a message in a conversation // Message represents a message in a conversation
type Message struct { type Message struct {
ID int `db:"id" json:"id,omitempty"` Total int `db:"total" json:"-"`
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"` CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
UUID string `db:"uuid" json:"uuid"` UUID string `db:"uuid" json:"uuid"`
Type string `db:"type" json:"type"` Type string `db:"type" json:"type"`
Status string `db:"status" json:"status"` Status string `db:"status" json:"status"`
ConversationID int `db:"conversation_id" json:"conversation_id"` ConversationID int `db:"conversation_id" json:"conversation_id"`
ConversationUUID string `db:"conversation_uuid" json:"conversation_uuid"`
Content string `db:"content" json:"content"` Content string `db:"content" json:"content"`
TextContent string `db:"text_content" json:"text_content"` TextContent string `db:"text_content" json:"text_content"`
ContentType string `db:"content_type" json:"content_type"` ContentType string `db:"content_type" json:"content_type"`
@@ -134,7 +212,6 @@ type Message struct {
InboxID int `db:"inbox_id" json:"-"` InboxID int `db:"inbox_id" json:"-"`
Meta json.RawMessage `db:"meta" json:"meta"` Meta json.RawMessage `db:"meta" json:"meta"`
Attachments attachment.Attachments `db:"attachments" json:"attachments"` Attachments attachment.Attachments `db:"attachments" json:"attachments"`
ConversationUUID string `db:"conversation_uuid" json:"-"`
From string `db:"from" json:"-"` From string `db:"from" json:"-"`
Subject string `db:"subject" json:"-"` Subject string `db:"subject" json:"-"`
Channel string `db:"channel" json:"-"` Channel string `db:"channel" json:"-"`
@@ -144,10 +221,9 @@ type Message struct {
References []string `json:"-"` References []string `json:"-"`
InReplyTo string `json:"-"` InReplyTo string `json:"-"`
Headers textproto.MIMEHeader `json:"-"` Headers textproto.MIMEHeader `json:"-"`
AltContent string `db:"-" json:"-"` AltContent string `json:"-"`
Media []mmodels.Media `db:"-" json:"-"` Media []mmodels.Media `json:"-"`
IsCSAT bool `db:"-" json:"-"` IsCSAT bool `json:"-"`
Total int `db:"total" json:"-"`
} }
// CensorCSATContent redacts the content of a CSAT message to prevent leaking the CSAT survey public link. // CensorCSATContent redacts the content of a CSAT message to prevent leaking the CSAT survey public link.

View File

@@ -99,6 +99,8 @@ SELECT
c.closed_at, c.closed_at,
c.resolved_at, c.resolved_at,
c.inbox_id, c.inbox_id,
c.assignee_last_seen_at,
inb.name as inbox_name,
COALESCE(inb.from, '') as inbox_mail, COALESCE(inb.from, '') as inbox_mail,
COALESCE(inb.channel::TEXT, '') as inbox_channel, COALESCE(inb.channel::TEXT, '') as inbox_channel,
c.status_id, c.status_id,
@@ -140,7 +142,6 @@ SELECT
ct.phone_number as "contact.phone_number", ct.phone_number as "contact.phone_number",
ct.phone_number_calling_code as "contact.phone_number_calling_code", ct.phone_number_calling_code as "contact.phone_number_calling_code",
ct.custom_attributes as "contact.custom_attributes", ct.custom_attributes as "contact.custom_attributes",
ct.avatar_url as "contact.avatar_url",
ct.enabled as "contact.enabled", ct.enabled as "contact.enabled",
ct.last_active_at as "contact.last_active_at", ct.last_active_at as "contact.last_active_at",
ct.last_login_at as "contact.last_login_at", ct.last_login_at as "contact.last_login_at",
@@ -183,8 +184,11 @@ SELECT
FROM conversations c FROM conversations c
WHERE c.created_at > $1; WHERE c.created_at > $1;
-- name: get-contact-conversations -- name: get-contact-previous-conversations
SELECT SELECT
c.id,
c.created_at,
c.updated_at,
c.uuid, c.uuid,
u.first_name AS "contact.first_name", u.first_name AS "contact.first_name",
u.last_name AS "contact.last_name", u.last_name AS "contact.last_name",
@@ -195,7 +199,7 @@ FROM users u
JOIN conversations c ON c.contact_id = u.id JOIN conversations c ON c.contact_id = u.id
WHERE c.contact_id = $1 WHERE c.contact_id = $1
ORDER BY c.created_at DESC ORDER BY c.created_at DESC
LIMIT 10; LIMIT $2;
-- name: get-conversation-uuid -- name: get-conversation-uuid
SELECT uuid from conversations where id = $1; SELECT uuid from conversations where id = $1;
@@ -400,22 +404,27 @@ LIMIT $2;
-- name: get-outgoing-pending-messages -- name: get-outgoing-pending-messages
SELECT SELECT
m.created_at,
m.id, m.id,
m.uuid, m.created_at,
m.sender_id, m.updated_at,
m.type,
m.private,
m.status, m.status,
m.type,
m.content, m.content,
m.text_content,
m.content_type,
m.conversation_id, m.conversation_id,
m.uuid,
m.private,
m.sender_type,
m.sender_id,
m.meta,
c.uuid as conversation_uuid,
m.content_type, m.content_type,
m.source_id, m.source_id,
ARRAY(SELECT jsonb_array_elements_text(m.meta->'cc')) AS cc, ARRAY(SELECT jsonb_array_elements_text(m.meta->'cc')) AS cc,
ARRAY(SELECT jsonb_array_elements_text(m.meta->'bcc')) AS bcc, ARRAY(SELECT jsonb_array_elements_text(m.meta->'bcc')) AS bcc,
ARRAY(SELECT jsonb_array_elements_text(m.meta->'to')) AS to, ARRAY(SELECT jsonb_array_elements_text(m.meta->'to')) AS to,
c.inbox_id, c.inbox_id,
c.uuid as conversation_uuid,
c.subject c.subject
FROM conversation_messages m FROM conversation_messages m
INNER JOIN conversations c ON c.id = m.conversation_id INNER JOIN conversations c ON c.id = m.conversation_id
@@ -463,16 +472,21 @@ ORDER BY m.created_at;
-- name: get-messages -- name: get-messages
SELECT SELECT
COUNT(*) OVER() AS total, COUNT(*) OVER() AS total,
m.id,
m.created_at, m.created_at,
m.updated_at, m.updated_at,
m.status, m.status,
m.type, m.type,
m.content, m.content,
m.text_content,
m.content_type,
m.conversation_id,
m.uuid, m.uuid,
m.private, m.private,
m.sender_id, m.sender_id,
m.sender_type, m.sender_type,
m.meta, m.meta,
$1::uuid AS conversation_uuid,
COALESCE( COALESCE(
(SELECT json_agg( (SELECT json_agg(
json_build_object( json_build_object(

View File

@@ -93,7 +93,7 @@ func (m *Manager) UpdateResponse(uuid string, score int, feedback string) error
return err return err
} }
if csat.Score > 0 || !csat.ResponseTimestamp.IsZero() { if csat.Rating > 0 || !csat.ResponseTimestamp.IsZero() {
return envelope.NewError(envelope.InputError, m.i18n.T("csat.alreadySubmitted"), nil) return envelope.NewError(envelope.InputError, m.i18n.T("csat.alreadySubmitted"), nil)
} }

View File

@@ -10,11 +10,11 @@ import (
// CSATResponse represents a customer satisfaction survey response. // CSATResponse represents a customer satisfaction survey response.
type CSATResponse struct { type CSATResponse struct {
ID int `db:"id"` ID int `db:"id"`
UUID string `db:"uuid"`
CreatedAt time.Time `db:"created_at"` CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"` UpdatedAt time.Time `db:"updated_at"`
UUID string `db:"uuid"`
ConversationID int `db:"conversation_id"` ConversationID int `db:"conversation_id"`
Score int `db:"rating"` Rating int `db:"rating"`
Feedback null.String `db:"feedback"` Feedback null.String `db:"feedback"`
ResponseTimestamp null.Time `db:"response_timestamp"` ResponseTimestamp null.Time `db:"response_timestamp"`
} }

View File

@@ -10,9 +10,9 @@ type CustomAttribute struct {
ID int `db:"id" json:"id"` ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"` CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
AppliesTo string `db:"applies_to" json:"applies_to"`
Name string `db:"name" json:"name"` Name string `db:"name" json:"name"`
Description string `db:"description" json:"description"` Description string `db:"description" json:"description"`
AppliesTo string `db:"applies_to" json:"applies_to"`
Key string `db:"key" json:"key"` Key string `db:"key" json:"key"`
Values pq.StringArray `db:"values" json:"values"` Values pq.StringArray `db:"values" json:"values"`
DataType string `db:"data_type" json:"data_type"` DataType string `db:"data_type" json:"data_type"`

View File

@@ -3,9 +3,9 @@ SELECT
id, id,
created_at, created_at,
updated_at, updated_at,
applies_to,
name, name,
description, description,
applies_to,
key, key,
values, values,
data_type, data_type,
@@ -25,9 +25,9 @@ SELECT
id, id,
created_at, created_at,
updated_at, updated_at,
applies_to,
name, name,
description, description,
applies_to,
key, key,
values, values,
data_type, data_type,

View File

@@ -2,8 +2,6 @@
package envelope package envelope
import ( import (
"net/http"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
) )
@@ -53,13 +51,13 @@ func NewError(etype string, message string, data interface{}) error {
case GeneralError: case GeneralError:
err.Code = fasthttp.StatusInternalServerError err.Code = fasthttp.StatusInternalServerError
case PermissionError: case PermissionError:
err.Code = http.StatusForbidden err.Code = fasthttp.StatusForbidden
case InputError: case InputError:
err.Code = fasthttp.StatusBadRequest err.Code = fasthttp.StatusBadRequest
case DataError: case DataError:
err.Code = http.StatusBadGateway err.Code = fasthttp.StatusUnprocessableEntity
case NetworkError: case NetworkError:
err.Code = http.StatusGatewayTimeout err.Code = fasthttp.StatusGatewayTimeout
case NotFoundError: case NotFoundError:
err.Code = fasthttp.StatusNotFound err.Code = fasthttp.StatusNotFound
case ConflictError: case ConflictError:

View File

@@ -1,8 +1,8 @@
-- name: get-active-inboxes -- name: get-active-inboxes
SELECT * from inboxes where enabled is TRUE and deleted_at is NULL; SELECT id, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, config, "from" FROM inboxes where enabled is TRUE and deleted_at is NULL;
-- name: get-all-inboxes -- name: get-all-inboxes
SELECT id, created_at, updated_at, name, channel, enabled from inboxes where deleted_at is NULL; SELECT id, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, config, "from" FROM inboxes where deleted_at is NULL;
-- name: insert-inbox -- name: insert-inbox
INSERT INTO inboxes INSERT INTO inboxes
@@ -11,7 +11,7 @@ VALUES($1, $2, $3, $4, $5)
RETURNING * RETURNING *
-- name: get-inbox -- name: get-inbox
SELECT * from inboxes where id = $1 and deleted_at is NULL; SELECT id, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, config, "from" FROM inboxes where id = $1 and deleted_at is NULL;
-- name: update -- name: update
UPDATE inboxes UPDATE inboxes
@@ -20,7 +20,7 @@ where id = $1 and deleted_at is NULL
RETURNING *; RETURNING *;
-- name: soft-delete -- name: soft-delete
UPDATE inboxes set deleted_at = now(), config = '{}' where id = $1 and deleted_at is NULL; UPDATE inboxes set deleted_at = now(), updated_at = now(), config = '{}' where id = $1 and deleted_at is NULL;
-- name: toggle -- name: toggle
UPDATE inboxes UPDATE inboxes

View File

@@ -12,11 +12,11 @@ type Macro struct {
CreatedAt time.Time `db:"created_at" json:"created_at"` CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Name string `db:"name" json:"name"` Name string `db:"name" json:"name"`
MessageContent string `db:"message_content" json:"message_content"` Actions json.RawMessage `db:"actions" json:"actions"`
VisibleWhen pq.StringArray `db:"visible_when" json:"visible_when"`
Visibility string `db:"visibility" json:"visibility"` Visibility string `db:"visibility" json:"visibility"`
VisibleWhen pq.StringArray `db:"visible_when" json:"visible_when"`
MessageContent string `db:"message_content" json:"message_content"`
UserID *int `db:"user_id" json:"user_id,string"` UserID *int `db:"user_id" json:"user_id,string"`
TeamID *int `db:"team_id" json:"team_id,string"` TeamID *int `db:"team_id" json:"team_id,string"`
UsageCount int `db:"usage_count" json:"usage_count"` UsageCount int `db:"usage_count" json:"usage_count"`
Actions json.RawMessage `db:"actions" json:"actions"`
} }

View File

@@ -1,15 +1,15 @@
-- name: get -- name: get
SELECT SELECT
id, id,
name,
message_content,
created_at, created_at,
updated_at, updated_at,
name,
actions,
visibility, visibility,
visible_when,
message_content,
user_id, user_id,
team_id, team_id,
actions,
visible_when,
usage_count usage_count
FROM FROM
macros macros
@@ -19,15 +19,15 @@ WHERE
-- name: get-all -- name: get-all
SELECT SELECT
id, id,
name,
message_content,
created_at, created_at,
updated_at, updated_at,
name,
actions,
visibility, visibility,
visible_when,
message_content,
user_id, user_id,
team_id, team_id,
actions,
visible_when,
usage_count usage_count
FROM FROM
macros macros
@@ -67,7 +67,6 @@ WHERE
UPDATE UPDATE
macros macros
SET SET
usage_count = usage_count + 1, usage_count = usage_count + 1
updated_at = NOW()
WHERE WHERE
id = $1; id = $1;

View File

@@ -214,6 +214,7 @@ func (m *Manager) deleteUnlinkedMessageMedia() error {
m.lo.Error("error deleting unlinked media", "error", err) m.lo.Error("error deleting unlinked media", "error", err)
continue continue
} }
// TODO: If it's an image also delete the `thumb_uuid` image.
} }
return nil return nil
} }

View File

@@ -1,31 +1,37 @@
package models package models
import ( import (
"encoding/json"
"time" "time"
"github.com/volatiletech/null/v9" "github.com/volatiletech/null/v9"
) )
const ( const (
// TODO: pick these table names from their respective package/models/models.go
ModelMessages = "messages" ModelMessages = "messages"
ModelUser = "users" ModelUser = "users"
DispositionInline = "inline" DispositionInline = "inline"
) )
// Media represents an uploaded object. // Media represents an uploaded object in DB and storage backend.
type Media struct { type Media struct {
ID int `db:"id" json:"id"` ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"` CreatedAt time.Time `db:"created_at" json:"created_at"`
UUID string `db:"uuid" json:"uuid"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Filename string `db:"filename" json:"filename"` UUID string `db:"uuid" json:"uuid"`
ContentType string `db:"content_type" json:"content_type"` Store string `db:"store" json:"store"`
Model null.String `db:"model_type" json:"-"` Filename string `db:"filename" json:"filename"`
ModelID null.Int `db:"model_id" json:"-"` ContentType string `db:"content_type" json:"content_type"`
Size int `db:"size" json:"size"` ContentID string `db:"content_id" json:"content_id"`
Store string `db:"store" json:"store"` ModelID null.Int `db:"model_id" json:"model_id"`
Disposition null.String `db:"disposition" json:"disposition"` Model null.String `db:"model_type" json:"model_type"`
URL string `json:"url"` Disposition null.String `db:"disposition" json:"disposition"`
ContentID string `json:"-"` Size int `db:"size" json:"size"`
Content []byte `json:"-"` Meta json.RawMessage `db:"meta" json:"meta"`
// Pseudo fields
URL string `json:"url"`
Content []byte `json:"-"`
} }

View File

@@ -15,7 +15,7 @@ VALUES(
RETURNING id; RETURNING id;
-- name: get-media -- name: get-media
SELECT id, created_at, "uuid", store, filename, content_type, model_id, model_type, "size", disposition SELECT id, created_at, updated_at, "uuid", store, filename, content_type, content_id, model_id, model_type, disposition, "size", meta
FROM media FROM media
WHERE WHERE
($1 > 0 AND id = $1) ($1 > 0 AND id = $1)
@@ -23,7 +23,7 @@ WHERE
($2 != '' AND uuid = $2::uuid) ($2 != '' AND uuid = $2::uuid)
-- name: get-media-by-uuid -- name: get-media-by-uuid
SELECT id, created_at, "uuid", store, filename, content_type, model_id, model_type, "size", disposition SELECT id, created_at, updated_at, "uuid", store, filename, content_type, content_id, model_id, model_type, disposition, "size", meta
FROM media FROM media
WHERE uuid = $1; WHERE uuid = $1;
@@ -38,13 +38,13 @@ SET model_type = $2,
WHERE id = $1; WHERE id = $1;
-- name: get-model-media -- name: get-model-media
SELECT id, created_at, "uuid", store, filename, content_type, model_id, model_type, "size", disposition SELECT id, created_at, updated_at, "uuid", store, filename, content_type, content_id, model_id, model_type, disposition, "size", meta
FROM media FROM media
WHERE model_type = $1 WHERE model_type = $1
AND model_id = $2; AND model_id = $2;
-- name: get-unlinked-message-media -- name: get-unlinked-message-media
SELECT id, created_at, "uuid", store, filename, content_type, model_id, model_type, "size", disposition SELECT id, created_at, updated_at, "uuid", store, filename, content_type, content_id, model_id, model_type, disposition, "size", meta
FROM media FROM media
WHERE model_type = 'messages' WHERE model_type = 'messages'
AND (model_id IS NULL OR model_id = 0) AND (model_id IS NULL OR model_id = 0)

View File

@@ -4,6 +4,12 @@ import (
"time" "time"
) )
// providerLogos holds known provider logos.
var providerLogos = map[string]string{
"Google": "/images/google-logo.png",
"Custom": "",
}
// OIDC represents an OpenID Connect configuration. // OIDC represents an OpenID Connect configuration.
type OIDC struct { type OIDC struct {
ID int `db:"id" json:"id"` ID int `db:"id" json:"id"`
@@ -19,12 +25,6 @@ type OIDC struct {
ProviderLogoURL string `db:"-" json:"logo_url"` ProviderLogoURL string `db:"-" json:"logo_url"`
} }
// providerLogos holds known provider logos.
var providerLogos = map[string]string{
"Google": "/images/google-logo.png",
"Custom": "",
}
// SetProviderLogo provider logo to the OIDC model. // SetProviderLogo provider logo to the OIDC model.
func (oidc *OIDC) SetProviderLogo() { func (oidc *OIDC) SetProviderLogo() {
for provider, logo := range providerLogos { for provider, logo := range providerLogos {

View File

@@ -38,10 +38,9 @@ type Opts struct {
// queries contains prepared SQL queries. // queries contains prepared SQL queries.
type queries struct { type queries struct {
GetAllOIDC *sqlx.Stmt `query:"get-all-oidc"` GetAllOIDC *sqlx.Stmt `query:"get-all-oidc"`
GetAllEnabled *sqlx.Stmt `query:"get-all-enabled"` GetOIDC *sqlx.Stmt `query:"get-oidc"`
GetOIDC *sqlx.Stmt `query:"get-oidc"` InsertOIDC *sqlx.Stmt `query:"insert-oidc"`
InsertOIDC *sqlx.Stmt `query:"insert-oidc"`
UpdateOIDC *sqlx.Stmt `query:"update-oidc"` UpdateOIDC *sqlx.Stmt `query:"update-oidc"`
DeleteOIDC *sqlx.Stmt `query:"delete-oidc"` DeleteOIDC *sqlx.Stmt `query:"delete-oidc"`
} }
@@ -111,19 +110,6 @@ func (o *Manager) GetAll() ([]models.OIDC, error) {
return oidc, nil return oidc, nil
} }
// GetAllEnabled retrieves all enabled oidc.
func (o *Manager) GetAllEnabled() ([]models.OIDC, error) {
var oidc = make([]models.OIDC, 0)
if err := o.q.GetAllEnabled.Select(&oidc); err != nil {
o.lo.Error("error fetching oidc", "error", err)
return oidc, envelope.NewError(envelope.GeneralError, o.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.oidcProvider}"), nil)
}
for i := range oidc {
oidc[i].SetProviderLogo()
}
return oidc, nil
}
// Create adds a new oidc. // Create adds a new oidc.
func (o *Manager) Create(oidc models.OIDC) (models.OIDC, error) { func (o *Manager) Create(oidc models.OIDC) (models.OIDC, error) {
var createdOIDC models.OIDC var createdOIDC models.OIDC

View File

@@ -1,11 +1,8 @@
-- name: get-all-oidc -- name: get-all-oidc
SELECT id, created_at, updated_at, name, provider, client_id, client_secret, provider_url, enabled FROM oidc order by updated_at desc; SELECT id, created_at, updated_at, name, provider_url, client_id, client_secret, enabled, provider FROM oidc order by updated_at desc;
-- name: get-all-enabled
SELECT id, name, enabled, provider, client_id, updated_at FROM oidc WHERE enabled = true order by updated_at desc;
-- name: get-oidc -- name: get-oidc
SELECT * FROM oidc WHERE id = $1; SELECT id, created_at, updated_at, name, provider_url, client_id, client_secret, enabled, provider FROM oidc WHERE id = $1;
-- name: insert-oidc -- name: insert-oidc
INSERT INTO oidc (name, provider, provider_url, client_id, client_secret) INSERT INTO oidc (name, provider, provider_url, client_id, client_secret)

View File

@@ -2,7 +2,7 @@
SELECT id, created_at, updated_at, name, description, permissions FROM roles; SELECT id, created_at, updated_at, name, description, permissions FROM roles;
-- name: get-role -- name: get-role
SELECT * FROM roles where id = $1; SELECT id, created_at, updated_at, name, description, permissions FROM roles where id = $1;
-- name: delete-role -- name: delete-role
DELETE FROM roles where id = $1; DELETE FROM roles where id = $1;

View File

@@ -153,7 +153,7 @@ func (u *Manager) filterValidPermissions(permissions []string) ([]string, error)
if amodels.PermissionExists(perm) { if amodels.PermissionExists(perm) {
validPermissions = append(validPermissions, perm) validPermissions = append(validPermissions, perm)
} else { } else {
u.lo.Warn("ignoring unknown permission", "permission", perm) u.lo.Warn("skipping unknown permission for role", "permission", perm)
} }
} }
return validPermissions, nil return validPermissions, nil

View File

@@ -2,14 +2,14 @@ package models
import "time" import "time"
type Conversation struct { type ConversationResult struct {
CreatedAt time.Time `db:"created_at" json:"created_at"` CreatedAt time.Time `db:"created_at" json:"created_at"`
UUID string `db:"uuid" json:"uuid"` UUID string `db:"uuid" json:"uuid"`
ReferenceNumber string `db:"reference_number" json:"reference_number"` ReferenceNumber string `db:"reference_number" json:"reference_number"`
Subject string `db:"subject" json:"subject"` Subject string `db:"subject" json:"subject"`
} }
type Message struct { type MessageResult struct {
CreatedAt time.Time `db:"created_at" json:"created_at"` CreatedAt time.Time `db:"created_at" json:"created_at"`
TextContent string `db:"text_content" json:"text_content"` TextContent string `db:"text_content" json:"text_content"`
ConversationCreatedAt time.Time `db:"conversation_created_at" json:"conversation_created_at"` ConversationCreatedAt time.Time `db:"conversation_created_at" json:"conversation_created_at"`
@@ -17,7 +17,7 @@ type Message struct {
ConversationReferenceNumber string `db:"conversation_reference_number" json:"conversation_reference_number"` ConversationReferenceNumber string `db:"conversation_reference_number" json:"conversation_reference_number"`
} }
type Contact struct { type ContactResult struct {
CreatedAt time.Time `db:"created_at" json:"created_at"` CreatedAt time.Time `db:"created_at" json:"created_at"`
FirstName string `db:"first_name" json:"first_name"` FirstName string `db:"first_name" json:"first_name"`
LastName string `db:"last_name" json:"last_name"` LastName string `db:"last_name" json:"last_name"`

View File

@@ -13,73 +13,73 @@ import (
) )
var ( var (
//go:embed queries.sql //go:embed queries.sql
efs embed.FS efs embed.FS
) )
// Manager is the search manager // Manager is the search manager
type Manager struct { type Manager struct {
q queries q queries
lo *logf.Logger lo *logf.Logger
i18n *i18n.I18n i18n *i18n.I18n
} }
// Opts contains the options for creating a new search manager // Opts contains the options for creating a new search manager
type Opts struct { type Opts struct {
DB *sqlx.DB DB *sqlx.DB
Lo *logf.Logger Lo *logf.Logger
I18n *i18n.I18n I18n *i18n.I18n
} }
// queries contains all the prepared queries // queries contains all the prepared queries
type queries struct { type queries struct {
SearchConversationsByRefNum *sqlx.Stmt `query:"search-conversations-by-reference-number"` SearchConversationsByRefNum *sqlx.Stmt `query:"search-conversations-by-reference-number"`
SearchConversationsByContactEmail *sqlx.Stmt `query:"search-conversations-by-contact-email"` SearchConversationsByContactEmail *sqlx.Stmt `query:"search-conversations-by-contact-email"`
SearchMessages *sqlx.Stmt `query:"search-messages"` SearchMessages *sqlx.Stmt `query:"search-messages"`
SearchContacts *sqlx.Stmt `query:"search-contacts"` SearchContacts *sqlx.Stmt `query:"search-contacts"`
} }
// New creates a new search manager // New creates a new search manager
func New(opts Opts) (*Manager, error) { func New(opts Opts) (*Manager, error) {
var q queries var q queries
if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil { if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
return nil, err return nil, err
} }
return &Manager{q: q, lo: opts.Lo, i18n: opts.I18n}, nil return &Manager{q: q, lo: opts.Lo, i18n: opts.I18n}, nil
} }
// Conversations searches conversations based on the query // Conversations searches conversations based on the query
func (s *Manager) Conversations(query string) ([]models.Conversation, error) { func (s *Manager) Conversations(query string) ([]models.ConversationResult, error) {
var refNumResults = make([]models.Conversation, 0) var refNumResults = make([]models.ConversationResult, 0)
if err := s.q.SearchConversationsByRefNum.Select(&refNumResults, query); err != nil { if err := s.q.SearchConversationsByRefNum.Select(&refNumResults, query); err != nil {
s.lo.Error("error searching conversations", "error", err) s.lo.Error("error searching conversations", "error", err)
return nil, envelope.NewError(envelope.GeneralError, s.i18n.Ts("globals.messages.errorSearching", "name", s.i18n.Ts("globals.terms.conversation")), nil) return nil, envelope.NewError(envelope.GeneralError, s.i18n.Ts("globals.messages.errorSearching", "name", s.i18n.Ts("globals.terms.conversation")), nil)
} }
var emailResults = make([]models.Conversation, 0) var emailResults = make([]models.ConversationResult, 0)
if err := s.q.SearchConversationsByContactEmail.Select(&emailResults, query); err != nil { if err := s.q.SearchConversationsByContactEmail.Select(&emailResults, query); err != nil {
s.lo.Error("error searching conversations", "error", err) s.lo.Error("error searching conversations", "error", err)
return nil, envelope.NewError(envelope.GeneralError, s.i18n.Ts("globals.messages.errorSearching", "name", s.i18n.Ts("globals.terms.conversation")), nil) return nil, envelope.NewError(envelope.GeneralError, s.i18n.Ts("globals.messages.errorSearching", "name", s.i18n.Ts("globals.terms.conversation")), nil)
} }
return append(refNumResults, emailResults...), nil return append(refNumResults, emailResults...), nil
} }
// Messages searches messages based on the query // Messages searches messages based on the query
func (s *Manager) Messages(query string) ([]models.Message, error) { func (s *Manager) Messages(query string) ([]models.MessageResult, error) {
var results = make([]models.Message, 0) var results = make([]models.MessageResult, 0)
if err := s.q.SearchMessages.Select(&results, query); err != nil { if err := s.q.SearchMessages.Select(&results, query); err != nil {
s.lo.Error("error searching messages", "error", err) s.lo.Error("error searching messages", "error", err)
return nil, envelope.NewError(envelope.GeneralError, s.i18n.Ts("globals.messages.errorSearching", "name", s.i18n.Ts("globals.terms.message")), nil) return nil, envelope.NewError(envelope.GeneralError, s.i18n.Ts("globals.messages.errorSearching", "name", s.i18n.Ts("globals.terms.message")), nil)
} }
return results, nil return results, nil
} }
// Contacts searches contacts based on the query // Contacts searches contacts based on the query
func (s *Manager) Contacts(query string) ([]models.Contact, error) { func (s *Manager) Contacts(query string) ([]models.ContactResult, error) {
var results = make([]models.Contact, 0) var results = make([]models.ContactResult, 0)
if err := s.q.SearchContacts.Select(&results, query); err != nil { if err := s.q.SearchContacts.Select(&results, query); err != nil {
s.lo.Error("error searching contacts", "error", err) s.lo.Error("error searching contacts", "error", err)
return nil, envelope.NewError(envelope.GeneralError, s.i18n.Ts("globals.messages.errorSearching", "name", s.i18n.Ts("globals.terms.contact")), nil) return nil, envelope.NewError(envelope.GeneralError, s.i18n.Ts("globals.messages.errorSearching", "name", s.i18n.Ts("globals.terms.contact")), nil)
} }
return results, nil return results, nil
} }

View File

@@ -11,7 +11,6 @@ import (
"github.com/abhinavxd/libredesk/internal/setting/models" "github.com/abhinavxd/libredesk/internal/setting/models"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx/types" "github.com/jmoiron/sqlx/types"
"github.com/knadh/go-i18n"
"github.com/zerodha/logf" "github.com/zerodha/logf"
) )
@@ -22,16 +21,14 @@ var (
// Manager handles setting-related operations. // Manager handles setting-related operations.
type Manager struct { type Manager struct {
q queries q queries
lo *logf.Logger lo *logf.Logger
i18n *i18n.I18n
} }
// Opts contains options for initializing the Manager. // Opts contains options for initializing the Manager.
type Opts struct { type Opts struct {
DB *sqlx.DB DB *sqlx.DB
Lo *logf.Logger Lo *logf.Logger
I18n *i18n.I18n
} }
// queries contains prepared SQL queries. // queries contains prepared SQL queries.
@@ -51,9 +48,8 @@ func New(opts Opts) (*Manager, error) {
} }
return &Manager{ return &Manager{
q: q, q: q,
lo: opts.Lo, lo: opts.Lo,
i18n: opts.I18n,
}, nil }, nil
} }
@@ -85,15 +81,15 @@ func (m *Manager) GetAllJSON() (types.JSONText, error) {
return b, nil return b, nil
} }
// Update updates settings. // Update updates settings with the passed values.
func (m *Manager) Update(s interface{}) error { func (m *Manager) Update(s any) error {
// Marshal settings. // Marshal settings.
b, err := json.Marshal(s) b, err := json.Marshal(s)
if err != nil { if err != nil {
m.lo.Error("error marshalling settings", "error", err) m.lo.Error("error marshalling settings", "error", err)
return envelope.NewError( return envelope.NewError(
envelope.GeneralError, envelope.GeneralError,
m.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.setting}"), "Error marshalling settings",
nil, nil,
) )
} }
@@ -102,21 +98,21 @@ func (m *Manager) Update(s interface{}) error {
m.lo.Error("error updating settings", "error", err) m.lo.Error("error updating settings", "error", err)
return envelope.NewError( return envelope.NewError(
envelope.GeneralError, envelope.GeneralError,
m.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.setting}"), "Error updating settings",
nil, nil,
) )
} }
return nil return nil
} }
// GetByPrefix retrieves settings by prefix as JSON. // GetByPrefix retrieves all settings start with the given prefix.
func (m *Manager) GetByPrefix(prefix string) (types.JSONText, error) { func (m *Manager) GetByPrefix(prefix string) (types.JSONText, error) {
var b types.JSONText var b types.JSONText
if err := m.q.GetByPrefix.Get(&b, prefix+"%"); err != nil { if err := m.q.GetByPrefix.Get(&b, prefix+"%"); err != nil {
m.lo.Error("error fetching settings", "prefix", prefix, "error", err) m.lo.Error("error fetching settings", "prefix", prefix, "error", err)
return b, envelope.NewError( return b, envelope.NewError(
envelope.GeneralError, envelope.GeneralError,
m.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.setting}"), "Error fetching settings",
nil, nil,
) )
} }
@@ -130,7 +126,7 @@ func (m *Manager) Get(key string) (types.JSONText, error) {
m.lo.Error("error fetching setting", "key", key, "error", err) m.lo.Error("error fetching setting", "key", key, "error", err)
return b, envelope.NewError( return b, envelope.NewError(
envelope.GeneralError, envelope.GeneralError,
m.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.setting}"), "Error fetching settings",
nil, nil,
) )
} }
@@ -144,7 +140,7 @@ func (m *Manager) GetAppRootURL() (string, error) {
m.lo.Error("error fetching root URL", "error", err) m.lo.Error("error fetching root URL", "error", err)
return "", envelope.NewError( return "", envelope.NewError(
envelope.GeneralError, envelope.GeneralError,
m.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.appRootURL}"), "Error fetching root URL",
nil, nil,
) )
} }

View File

@@ -144,13 +144,6 @@ func GenerateEmailMessageID(messageID string, fromAddress string) (string, error
), nil ), nil
} }
// ReverseSlice reverses a slice of strings in place.
func ReverseSlice(source []string) {
for i, j := 0, len(source)-1; i < j; i, j = i+1, j-1 {
source[i], source[j] = source[j], source[i]
}
}
// RemoveItemByValue removes all instances of a value from a slice of strings. // RemoveItemByValue removes all instances of a value from a slice of strings.
func RemoveItemByValue(slice []string, value string) []string { func RemoveItemByValue(slice []string, value string) []string {
result := []string{} result := []string{}

View File

@@ -5,46 +5,6 @@ import (
"time" "time"
) )
func TestReverseSlice(t *testing.T) {
tests := []struct {
name string
input []string
expected []string
}{
{
name: "empty slice",
input: []string{},
expected: []string{},
},
{
name: "single element",
input: []string{"a"},
expected: []string{"a"},
},
{
name: "multiple elements",
input: []string{"a", "b", "c"},
expected: []string{"c", "b", "a"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
input := make([]string, len(tt.input))
copy(input, tt.input)
ReverseSlice(input)
if len(input) != len(tt.expected) {
t.Errorf("got len %d, want %d", len(input), len(tt.expected))
}
for i := range input {
if input[i] != tt.expected[i] {
t.Errorf("at index %d got %s, want %s", i, input[i], tt.expected[i])
}
}
})
}
}
func TestRemoveItemByValue(t *testing.T) { func TestRemoveItemByValue(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View File

@@ -15,17 +15,37 @@ type Team struct {
UpdatedAt time.Time `db:"updated_at" json:"updated_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Emoji null.String `db:"emoji" json:"emoji"` Emoji null.String `db:"emoji" json:"emoji"`
Name string `db:"name" json:"name"` Name string `db:"name" json:"name"`
ConversationAssignmentType string `db:"conversation_assignment_type" json:"conversation_assignment_type,omitempty"` ConversationAssignmentType string `db:"conversation_assignment_type" json:"conversation_assignment_type"`
Timezone string `db:"timezone" json:"timezone,omitempty"` Timezone string `db:"timezone" json:"timezone"`
BusinessHoursID null.Int `db:"business_hours_id" json:"business_hours_id,omitempty"` BusinessHoursID null.Int `db:"business_hours_id" json:"business_hours_id"`
SLAPolicyID null.Int `db:"sla_policy_id" json:"sla_policy_id,omitempty"` SLAPolicyID null.Int `db:"sla_policy_id" json:"sla_policy_id"`
MaxAutoAssignedConversations int `db:"max_auto_assigned_conversations" json:"max_auto_assigned_conversations"` MaxAutoAssignedConversations int `db:"max_auto_assigned_conversations" json:"max_auto_assigned_conversations"`
} }
type Teams []Team type TeamCompact struct {
ID int `db:"id" json:"id"`
Name string `db:"name" json:"name"`
Emoji null.String `db:"emoji" json:"emoji"`
}
type TeamMember struct {
ID int `db:"id" json:"id"`
AvailabilityStatus string `db:"availability_status" json:"availability_status"`
TeamID int `db:"team_id" json:"team_id"`
}
type TeamsCompact []TeamCompact
func (t TeamsCompact) IDs() []int {
ids := make([]int, len(t))
for i, team := range t {
ids[i] = team.ID
}
return ids
}
// Scan implements the sql.Scanner interface for Teams // Scan implements the sql.Scanner interface for Teams
func (t *Teams) Scan(src interface{}) error { func (t *TeamsCompact) Scan(src interface{}) error {
if src == nil { if src == nil {
*t = nil *t = nil
return nil return nil
@@ -40,24 +60,6 @@ func (t *Teams) Scan(src interface{}) error {
} }
// Value implements the driver.Valuer interface for Teams // Value implements the driver.Valuer interface for Teams
func (t Teams) Value() (driver.Value, error) { func (t TeamsCompact) Value() (driver.Value, error) {
return json.Marshal(t) return json.Marshal(t)
} }
// Names returns the names of the teams in Teams slice.
func (t Teams) Names() []string {
names := make([]string, len(t))
for i, team := range t {
names[i] = team.Name
}
return names
}
// IDs returns a slice of all team IDs in the Teams slice.
func (t Teams) IDs() []int {
ids := make([]int, len(t))
for i, team := range t {
ids[i] = team.ID
}
return ids
}

View File

@@ -1,14 +1,14 @@
-- name: get-teams -- name: get-teams
SELECT id, emoji, created_at, updated_at, name, conversation_assignment_type, timezone, max_auto_assigned_conversations from teams order by updated_at desc; SELECT id, created_at, updated_at, name, emoji, conversation_assignment_type, max_auto_assigned_conversations, business_hours_id, sla_policy_id, timezone from teams order by updated_at desc;
-- name: get-teams-compact -- name: get-teams-compact
SELECT id, name, emoji from teams order by name; SELECT id, name, emoji from teams order by name;
-- name: get-user-teams -- name: get-user-teams
SELECT id, emoji, created_at, updated_at, name, conversation_assignment_type, timezone, max_auto_assigned_conversations from teams WHERE id IN (SELECT team_id FROM team_members WHERE user_id = $1) order by updated_at desc; SELECT id, created_at, updated_at, name, emoji, conversation_assignment_type, max_auto_assigned_conversations, business_hours_id, sla_policy_id, timezone from teams WHERE id IN (SELECT team_id FROM team_members WHERE user_id = $1) order by updated_at desc;
-- name: get-team -- name: get-team
SELECT id, emoji, name, conversation_assignment_type, timezone, business_hours_id, sla_policy_id, max_auto_assigned_conversations from teams where id = $1; SELECT id, created_at, updated_at, name, emoji, conversation_assignment_type, max_auto_assigned_conversations, business_hours_id, sla_policy_id, timezone from teams where id = $1;
-- name: get-team-members -- name: get-team-members
SELECT u.id, t.id as team_id, u.availability_status SELECT u.id, t.id as team_id, u.availability_status

View File

@@ -10,7 +10,6 @@ import (
"github.com/abhinavxd/libredesk/internal/dbutil" "github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/envelope" "github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/team/models" "github.com/abhinavxd/libredesk/internal/team/models"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/knadh/go-i18n" "github.com/knadh/go-i18n"
"github.com/lib/pq" "github.com/lib/pq"
@@ -78,8 +77,8 @@ func (u *Manager) GetAll() ([]models.Team, error) {
} }
// GetAllCompact retrieves all teams with limited fields. // GetAllCompact retrieves all teams with limited fields.
func (u *Manager) GetAllCompact() ([]models.Team, error) { func (u *Manager) GetAllCompact() ([]models.TeamCompact, error) {
var teams = make([]models.Team, 0) var teams = make([]models.TeamCompact, 0)
if err := u.q.GetTeamsCompact.Select(&teams); err != nil { if err := u.q.GetTeamsCompact.Select(&teams); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return teams, nil return teams, nil
@@ -169,14 +168,14 @@ func (u *Manager) UserBelongsToTeam(teamID, userID int) (bool, error) {
} }
// GetMembers retrieves members of a team. // GetMembers retrieves members of a team.
func (u *Manager) GetMembers(id int) ([]umodels.User, error) { func (u *Manager) GetMembers(id int) ([]models.TeamMember, error) {
var users []umodels.User var members = make([]models.TeamMember, 0)
if err := u.q.GetTeamMembers.Select(&users, id); err != nil { if err := u.q.GetTeamMembers.Select(&members, id); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return users, nil return members, nil
} }
u.lo.Error("error fetching team members", "team_id", id, "error", err) u.lo.Error("error fetching team members", "team_id", id, "error", err)
return users, fmt.Errorf("fetching team members: %w", err) return members, fmt.Errorf("fetching team members: %w", err)
} }
return users, nil return members, nil
} }

View File

@@ -19,19 +19,19 @@ WITH u AS (
SELECT * FROM u LIMIT 1; SELECT * FROM u LIMIT 1;
-- name: get-default -- name: get-default
SELECT id, type, name, body, subject FROM templates WHERE is_default is TRUE; SELECT id, created_at, updated_at, type, body, is_default, name, subject, is_builtin FROM templates WHERE is_default is TRUE;
-- name: get-all -- name: get-all
SELECT id, created_at, updated_at, type, name, is_default, is_builtin FROM templates WHERE type = $1 ORDER BY updated_at DESC; SELECT id, created_at, updated_at, type, body, is_default, name, subject, is_builtin FROM templates WHERE type = $1 ORDER BY updated_at DESC;
-- name: get-template -- name: get-template
SELECT id, type, name, body, subject, is_default, type FROM templates WHERE id = $1; SELECT id, created_at, updated_at, type, body, is_default, name, subject, is_builtin FROM templates WHERE id = $1;
-- name: delete -- name: delete
DELETE FROM templates WHERE id = $1; DELETE FROM templates WHERE id = $1;
-- name: get-by-name -- name: get-by-name
SELECT id, type, name, body, subject, is_default, type FROM templates WHERE name = $1; SELECT id, created_at, updated_at, type, body, is_default, name, subject, is_builtin FROM templates WHERE name = $1;
-- name: is-builtin -- name: is-builtin
SELECT EXISTS(SELECT 1 FROM templates WHERE id = $1 AND is_builtin is TRUE); SELECT EXISTS(SELECT 1 FROM templates WHERE id = $1 AND is_builtin is TRUE);

View File

@@ -2,8 +2,6 @@ package user
import ( import (
"context" "context"
"database/sql"
"errors"
"strings" "strings"
"time" "time"
@@ -69,13 +67,10 @@ func (u *Manager) InvalidateAgentCache(id int) {
delete(u.agentCache, id) delete(u.agentCache, id)
} }
// GetAgentsCompact returns a compact list of users with limited fields. // GetAgentsCompact returns a compact list of agents with limited fields.
func (u *Manager) GetAgentsCompact() ([]models.User, error) { func (u *Manager) GetAgentsCompact() ([]models.UserCompact, error) {
var users = make([]models.User, 0) var users = make([]models.UserCompact, 0)
if err := u.q.GetAgentsCompact.Select(&users); err != nil { if err := u.db.Select(&users, u.q.GetUsersCompact, pq.Array([]string{models.UserTypeAgent})); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return users, nil
}
u.lo.Error("error fetching users from db", "error", err) u.lo.Error("error fetching users from db", "error", err)
return users, envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorFetching", "name", u.i18n.P("globals.terms.user")), nil) return users, envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorFetching", "name", u.i18n.P("globals.terms.user")), nil)
} }
@@ -83,36 +78,39 @@ func (u *Manager) GetAgentsCompact() ([]models.User, error) {
} }
// CreateAgent creates a new agent user. // CreateAgent creates a new agent user.
func (u *Manager) CreateAgent(user *models.User) (error) { func (u *Manager) CreateAgent(firstName, lastName, email string, roles []string) (models.User, error) {
password, err := u.generatePassword() password, err := u.generatePassword()
if err != nil { if err != nil {
u.lo.Error("error generating password", "error", err) u.lo.Error("error generating password", "error", err)
return envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.user}"), nil) return models.User{}, envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.user}"), nil)
} }
user.Email = null.NewString(strings.TrimSpace(strings.ToLower(user.Email.String)), user.Email.Valid)
if err := u.q.InsertAgent.QueryRow(user.Email, user.FirstName, user.LastName, password, user.AvatarURL, pq.Array(user.Roles)).Scan(&user.ID); err != nil { var id = 0
avatarURL := null.String{}
email = strings.TrimSpace(strings.ToLower(email))
if err := u.q.InsertAgent.QueryRow(email, firstName, lastName, password, avatarURL, pq.Array(roles)).Scan(&id); err != nil {
if dbutil.IsUniqueViolationError(err) { if dbutil.IsUniqueViolationError(err) {
return envelope.NewError(envelope.GeneralError, u.i18n.T("user.sameEmailAlreadyExists"), nil) return models.User{}, envelope.NewError(envelope.GeneralError, u.i18n.T("user.sameEmailAlreadyExists"), nil)
} }
u.lo.Error("error creating user", "error", err) u.lo.Error("error creating user", "error", err)
return envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.user}"), nil) return models.User{}, envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.user}"), nil)
} }
return nil return u.Get(id, "", models.UserTypeAgent)
} }
// UpdateAgent updates an agent in the database, including their password if provided. // UpdateAgent updates an agent with individual field parameters
func (u *Manager) UpdateAgent(id int, user models.User) error { func (u *Manager) UpdateAgent(id int, firstName, lastName, email string, roles []string, enabled bool, availabilityStatus, newPassword string) error {
var ( var (
hashedPassword any hashedPassword any
err error err error
) )
// Set password? // Set password?
if user.NewPassword != "" { if newPassword != "" {
if !IsStrongPassword(user.NewPassword) { if !IsStrongPassword(newPassword) {
return envelope.NewError(envelope.InputError, PasswordHint, nil) return envelope.NewError(envelope.InputError, PasswordHint, nil)
} }
hashedPassword, err = bcrypt.GenerateFromPassword([]byte(user.NewPassword), bcrypt.DefaultCost) hashedPassword, err = bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil { if err != nil {
u.lo.Error("error generating bcrypt password", "error", err) u.lo.Error("error generating bcrypt password", "error", err)
return envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}"), nil) return envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}"), nil)
@@ -121,7 +119,10 @@ func (u *Manager) UpdateAgent(id int, user models.User) error {
} }
// Update user in the database and clear cache. // Update user in the database and clear cache.
if _, err := u.q.UpdateAgent.Exec(id, user.FirstName, user.LastName, user.Email, pq.Array(user.Roles), user.AvatarURL, hashedPassword, user.Enabled, user.AvailabilityStatus); err != nil { if _, err := u.q.UpdateAgent.Exec(id, firstName, lastName, email, pq.Array(roles), null.String{}, hashedPassword, enabled, availabilityStatus); err != nil {
if dbutil.IsUniqueViolationError(err) {
return envelope.NewError(envelope.GeneralError, u.i18n.T("user.sameEmailAlreadyExists"), nil)
}
u.lo.Error("error updating user", "error", err) u.lo.Error("error updating user", "error", err)
return envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}"), nil) return envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}"), nil)
} }
@@ -159,7 +160,7 @@ func (u *Manager) markInactiveAgentsOffline() {
} }
// GetAllAgents returns a list of all agents. // GetAllAgents returns a list of all agents.
func (u *Manager) GetAgents() ([]models.User, error) { func (u *Manager) GetAgents() ([]models.UserCompact, error) {
// Some dirty hack. // Some dirty hack.
return u.GetAllUsers(1, 999999999, models.UserTypeAgent, "desc", "users.updated_at", "") return u.GetAllUsers(1, 999999999, models.UserTypeAgent, "desc", "users.updated_at", "")
} }

View File

@@ -42,7 +42,7 @@ func (u *Manager) GetContact(id int, email string) (models.User, error) {
} }
// GetAllContacts returns a list of all contacts. // GetAllContacts returns a list of all contacts.
func (u *Manager) GetContacts(page, pageSize int, order, orderBy string, filtersJSON string) ([]models.User, error) { func (u *Manager) GetContacts(page, pageSize int, order, orderBy string, filtersJSON string) ([]models.UserCompact, error) {
if pageSize > maxListPageSize { if pageSize > maxListPageSize {
return nil, envelope.NewError(envelope.InputError, u.i18n.Ts("globals.messages.pageTooLarge", "max", fmt.Sprintf("%d", maxListPageSize)), nil) return nil, envelope.NewError(envelope.InputError, u.i18n.Ts("globals.messages.pageTooLarge", "max", fmt.Sprintf("%d", maxListPageSize)), nil)
} }

View File

@@ -30,40 +30,51 @@ const (
AwayAndReassigning = "away_and_reassigning" AwayAndReassigning = "away_and_reassigning"
) )
type UserCompact struct {
ID int `db:"id" json:"id"`
Type string `db:"type" json:"type"`
FirstName string `db:"first_name" json:"first_name"`
LastName string `db:"last_name" json:"last_name"`
Email null.String `db:"email" json:"email"`
Enabled bool `db:"enabled" json:"enabled"`
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Total int `db:"total" json:"total"`
}
type User struct { type User struct {
ID int `db:"id" json:"id"` ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"` CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
FirstName string `db:"first_name" json:"first_name"` FirstName string `db:"first_name" json:"first_name"`
LastName string `db:"last_name" json:"last_name"` LastName string `db:"last_name" json:"last_name"`
Email null.String `db:"email" json:"email"` Email null.String `db:"email" json:"email"`
Type string `db:"type" json:"type"` Type string `db:"type" json:"type"`
AvailabilityStatus string `db:"availability_status" json:"availability_status"` AvailabilityStatus string `db:"availability_status" json:"availability_status"`
PhoneNumberCallingCode null.String `db:"phone_number_calling_code" json:"phone_number_calling_code"` PhoneNumberCallingCode null.String `db:"phone_number_calling_code" json:"phone_number_calling_code"`
PhoneNumber null.String `db:"phone_number" json:"phone_number"` PhoneNumber null.String `db:"phone_number" json:"phone_number"`
AvatarURL null.String `db:"avatar_url" json:"avatar_url"` AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
Enabled bool `db:"enabled" json:"enabled"` Enabled bool `db:"enabled" json:"enabled"`
Password string `db:"password" json:"-"` Password string `db:"password" json:"-"`
LastActiveAt null.Time `db:"last_active_at" json:"last_active_at"` LastActiveAt null.Time `db:"last_active_at" json:"last_active_at"`
LastLoginAt null.Time `db:"last_login_at" json:"last_login_at"` LastLoginAt null.Time `db:"last_login_at" json:"last_login_at"`
Roles pq.StringArray `db:"roles" json:"roles"` Roles pq.StringArray `db:"roles" json:"roles"`
Permissions pq.StringArray `db:"permissions" json:"permissions"` Permissions pq.StringArray `db:"permissions" json:"permissions"`
Meta pq.StringArray `db:"meta" json:"meta"` CustomAttributes json.RawMessage `db:"custom_attributes" json:"custom_attributes"`
CustomAttributes json.RawMessage `db:"custom_attributes" json:"custom_attributes"` Teams tmodels.TeamsCompact `db:"teams" json:"teams"`
Teams tmodels.Teams `db:"teams" json:"teams"` ContactChannelID int `db:"contact_channel_id" json:"contact_channel_id,omitempty"`
ContactChannelID int `db:"contact_channel_id" json:"contact_channel_id,omitempty"` NewPassword string `db:"-" json:"new_password,omitempty"`
NewPassword string `db:"-" json:"new_password,omitempty"` SendWelcomeEmail bool `db:"-" json:"send_welcome_email,omitempty"`
SendWelcomeEmail bool `db:"-" json:"send_welcome_email,omitempty"` InboxID int `json:"-"`
InboxID int `json:"-"` SourceChannel null.String `json:"-"`
SourceChannel null.String `json:"-"` SourceChannelID null.String `json:"-"`
SourceChannelID null.String `json:"-"`
// API Key fields // API Key fields
APIKey null.String `db:"api_key" json:"api_key"` APIKey null.String `db:"api_key" json:"api_key"`
APIKeyLastUsedAt null.Time `db:"api_key_last_used_at" json:"api_key_last_used_at"` APIKeyLastUsedAt null.Time `db:"api_key_last_used_at" json:"api_key_last_used_at"`
APISecret null.String `db:"api_secret" json:"-"` APISecret null.String `db:"api_secret" json:"-"`
Total int `json:"total,omitempty"`
} }
type Note struct { type Note struct {

View File

@@ -26,12 +26,13 @@ func (u *Manager) GetNote(id int) (models.Note, error) {
} }
// CreateNote creates a new note for a user. // CreateNote creates a new note for a user.
func (u *Manager) CreateNote(userID, authorID int, note string) error { func (u *Manager) CreateNote(userID, authorID int, note string) (models.Note, error) {
if _, err := u.q.InsertNote.Exec(userID, authorID, note); err != nil { var createdNote models.Note
if err := u.q.InsertNote.Get(&createdNote, userID, authorID, note); err != nil {
u.lo.Error("error creating user note", "error", err) u.lo.Error("error creating user note", "error", err)
return envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorCreating", "name", u.i18n.P("globals.terms.note")), nil) return createdNote, envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorCreating", "name", u.i18n.P("globals.terms.note")), nil)
} }
return nil return createdNote, nil
} }
// DeleteNote deletes a note for a user. // DeleteNote deletes a note for a user.

View File

@@ -1,7 +1,8 @@
-- name: get-users -- name: get-users-compact
-- TODO: Remove hardcoded `type` of user in some queries in this file.
SELECT COUNT(*) OVER() as total, users.id, users.avatar_url, users.type, users.created_at, users.updated_at, users.first_name, users.last_name, users.email, users.enabled SELECT COUNT(*) OVER() as total, users.id, users.avatar_url, users.type, users.created_at, users.updated_at, users.first_name, users.last_name, users.email, users.enabled
FROM users FROM users
WHERE users.email != 'System' AND users.deleted_at IS NULL AND type = $1 WHERE users.email != 'System' AND users.deleted_at IS NULL AND type = ANY($1)
-- name: soft-delete-agent -- name: soft-delete-agent
WITH soft_delete AS ( WITH soft_delete AS (
@@ -23,12 +24,6 @@ delete_user_roles AS (
) )
SELECT 1; SELECT 1;
-- name: get-agents-compact
SELECT u.id, u.type, u.first_name, u.last_name, u.enabled, u.avatar_url
FROM users u
WHERE u.email != 'System' AND u.deleted_at IS NULL AND u.type = 'agent'
ORDER BY u.updated_at DESC;
-- name: get-user -- name: get-user
SELECT SELECT
u.id, u.id,
@@ -37,8 +32,6 @@ SELECT
u.email, u.email,
u.password, u.password,
u.type, u.type,
u.created_at,
u.updated_at,
u.enabled, u.enabled,
u.avatar_url, u.avatar_url,
u.first_name, u.first_name,
@@ -50,6 +43,7 @@ SELECT
u.phone_number, u.phone_number,
u.api_key, u.api_key,
u.api_key_last_used_at, u.api_key_last_used_at,
u.api_secret,
array_agg(DISTINCT r.name) FILTER (WHERE r.name IS NOT NULL) AS roles, array_agg(DISTINCT r.name) FILTER (WHERE r.name IS NOT NULL) AS roles,
COALESCE( COALESCE(
(SELECT json_agg(json_build_object('id', t.id, 'name', t.name, 'emoji', t.emoji)) (SELECT json_agg(json_build_object('id', t.id, 'name', t.name, 'emoji', t.emoji))
@@ -135,7 +129,7 @@ WHERE id = $1 AND type = 'agent';
-- name: set-password -- name: set-password
UPDATE users UPDATE users
SET password = $1, reset_password_token = NULL, reset_password_token_expiry = NULL SET password = $1, reset_password_token = NULL, reset_password_token_expiry = NULL
WHERE reset_password_token = $2 AND reset_password_token_expiry > now() AND type = 'agent'; WHERE reset_password_token = $2 AND reset_password_token_expiry > now();
-- name: insert-agent -- name: insert-agent
WITH inserted_user AS ( WITH inserted_user AS (
@@ -202,7 +196,8 @@ ORDER BY cn.created_at DESC;
-- name: insert-note -- name: insert-note
INSERT INTO contact_notes (contact_id, user_id, note) INSERT INTO contact_notes (contact_id, user_id, note)
VALUES ($1, $2, $3); VALUES ($1, $2, $3)
RETURNING *;
-- name: delete-note -- name: delete-note
DELETE FROM contact_notes DELETE FROM contact_notes
@@ -229,6 +224,7 @@ SELECT
u.created_at, u.created_at,
u.updated_at, u.updated_at,
u.email, u.email,
u.password,
u.type, u.type,
u.enabled, u.enabled,
u.avatar_url, u.avatar_url,
@@ -239,6 +235,8 @@ SELECT
u.last_login_at, u.last_login_at,
u.phone_number_calling_code, u.phone_number_calling_code,
u.phone_number, u.phone_number,
u.api_key,
u.api_key_last_used_at,
u.api_secret, u.api_secret,
array_agg(DISTINCT r.name) FILTER (WHERE r.name IS NOT NULL) AS roles, array_agg(DISTINCT r.name) FILTER (WHERE r.name IS NOT NULL) AS roles,
COALESCE( COALESCE(
@@ -256,7 +254,7 @@ LEFT JOIN LATERAL unnest(r.permissions) AS p ON true
WHERE u.api_key = $1 AND u.enabled = true AND u.deleted_at IS NULL WHERE u.api_key = $1 AND u.enabled = true AND u.deleted_at IS NULL
GROUP BY u.id; GROUP BY u.id;
-- name: generate-api-key -- name: set-api-key
UPDATE users UPDATE users
SET api_key = $2, api_secret = $3, api_key_last_used_at = NULL, updated_at = now() SET api_key = $2, api_secret = $3, api_key_last_used_at = NULL, updated_at = now()
WHERE id = $1; WHERE id = $1;

View File

@@ -22,6 +22,7 @@ import (
"github.com/abhinavxd/libredesk/internal/user/models" "github.com/abhinavxd/libredesk/internal/user/models"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/knadh/go-i18n" "github.com/knadh/go-i18n"
"github.com/lib/pq"
"github.com/volatiletech/null/v9" "github.com/volatiletech/null/v9"
"github.com/zerodha/logf" "github.com/zerodha/logf"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
@@ -61,10 +62,9 @@ type Opts struct {
// queries contains prepared SQL queries. // queries contains prepared SQL queries.
type queries struct { type queries struct {
GetUser *sqlx.Stmt `query:"get-user"` GetUser *sqlx.Stmt `query:"get-user"`
GetUsers string `query:"get-users"`
GetNotes *sqlx.Stmt `query:"get-notes"` GetNotes *sqlx.Stmt `query:"get-notes"`
GetNote *sqlx.Stmt `query:"get-note"` GetNote *sqlx.Stmt `query:"get-note"`
GetAgentsCompact *sqlx.Stmt `query:"get-agents-compact"` GetUsersCompact string `query:"get-users-compact"`
UpdateContact *sqlx.Stmt `query:"update-contact"` UpdateContact *sqlx.Stmt `query:"update-contact"`
UpdateAgent *sqlx.Stmt `query:"update-agent"` UpdateAgent *sqlx.Stmt `query:"update-agent"`
UpdateCustomAttributes *sqlx.Stmt `query:"update-custom-attributes"` UpdateCustomAttributes *sqlx.Stmt `query:"update-custom-attributes"`
@@ -84,7 +84,7 @@ type queries struct {
ToggleEnable *sqlx.Stmt `query:"toggle-enable"` ToggleEnable *sqlx.Stmt `query:"toggle-enable"`
// API key queries // API key queries
GetUserByAPIKey *sqlx.Stmt `query:"get-user-by-api-key"` GetUserByAPIKey *sqlx.Stmt `query:"get-user-by-api-key"`
GenerateAPIKey *sqlx.Stmt `query:"generate-api-key"` SetAPIKey *sqlx.Stmt `query:"set-api-key"`
RevokeAPIKey *sqlx.Stmt `query:"revoke-api-key"` RevokeAPIKey *sqlx.Stmt `query:"revoke-api-key"`
UpdateAPIKeyLastUsed *sqlx.Stmt `query:"update-api-key-last-used"` UpdateAPIKeyLastUsed *sqlx.Stmt `query:"update-api-key-last-used"`
} }
@@ -93,7 +93,7 @@ type queries struct {
func New(i18n *i18n.I18n, opts Opts) (*Manager, error) { func New(i18n *i18n.I18n, opts Opts) (*Manager, error) {
var q queries var q queries
if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil { if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
return nil, err return nil, fmt.Errorf("error scanning SQL file: %w", err)
} }
return &Manager{ return &Manager{
q: q, q: q,
@@ -121,7 +121,7 @@ func (u *Manager) VerifyPassword(email string, password []byte) (models.User, er
} }
// GetAllUsers returns a list of all users. // GetAllUsers returns a list of all users.
func (u *Manager) GetAllUsers(page, pageSize int, userType, order, orderBy string, filtersJSON string) ([]models.User, error) { func (u *Manager) GetAllUsers(page, pageSize int, userType, order, orderBy string, filtersJSON string) ([]models.UserCompact, error) {
query, qArgs, err := u.makeUserListQuery(page, pageSize, userType, order, orderBy, filtersJSON) query, qArgs, err := u.makeUserListQuery(page, pageSize, userType, order, orderBy, filtersJSON)
if err != nil { if err != nil {
u.lo.Error("error creating user list query", "error", err) u.lo.Error("error creating user list query", "error", err)
@@ -139,7 +139,7 @@ func (u *Manager) GetAllUsers(page, pageSize int, userType, order, orderBy strin
defer tx.Rollback() defer tx.Rollback()
// Execute query // Execute query
var users = make([]models.User, 0) var users = make([]models.UserCompact, 0)
if err := tx.Select(&users, query, qArgs...); err != nil { if err := tx.Select(&users, query, qArgs...); err != nil {
u.lo.Error("error fetching users", "error", err) u.lo.Error("error fetching users", "error", err)
return nil, envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil) return nil, envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil)
@@ -186,6 +186,7 @@ func (u *Manager) UpdateLastLoginAt(id int) error {
// SetResetPasswordToken sets a reset password token for an user and returns the token. // SetResetPasswordToken sets a reset password token for an user and returns the token.
func (u *Manager) SetResetPasswordToken(id int) (string, error) { func (u *Manager) SetResetPasswordToken(id int) (string, error) {
// TODO: column `reset_password_token`, does not have a UNIQUE constraint. Add it in a future migration.
token, err := stringutil.RandomAlphanumeric(32) token, err := stringutil.RandomAlphanumeric(32)
if err != nil { if err != nil {
u.lo.Error("error generating reset password token", "error", err) u.lo.Error("error generating reset password token", "error", err)
@@ -198,7 +199,7 @@ func (u *Manager) SetResetPasswordToken(id int) (string, error) {
return token, nil return token, nil
} }
// ResetPassword sets a new password for an user. // ResetPassword sets a password for a given user's reset password token.
func (u *Manager) ResetPassword(token, password string) error { func (u *Manager) ResetPassword(token, password string) error {
if !IsStrongPassword(password) { if !IsStrongPassword(password) {
return envelope.NewError(envelope.InputError, "Password is not strong enough, "+PasswordHint, nil) return envelope.NewError(envelope.InputError, "Password is not strong enough, "+PasswordHint, nil)
@@ -255,44 +256,6 @@ func (u *Manager) UpdateCustomAttributes(id int, customAttributes map[string]any
return nil return nil
} }
// makeUserListQuery generates a query to fetch users based on the provided filters.
func (u *Manager) makeUserListQuery(page, pageSize int, typ, order, orderBy, filtersJSON string) (string, []interface{}, error) {
var (
baseQuery = u.q.GetUsers
qArgs []any
)
// Set the type of user to fetch.
qArgs = append(qArgs, typ)
return dbutil.BuildPaginatedQuery(baseQuery, qArgs, dbutil.PaginationOptions{
Order: order,
OrderBy: orderBy,
Page: page,
PageSize: pageSize,
}, filtersJSON, dbutil.AllowedFields{
"users": {"email", "created_at", "updated_at"},
})
}
// verifyPassword compares the provided password with the stored password hash.
func (u *Manager) verifyPassword(pwd []byte, pwdHash string) error {
if err := bcrypt.CompareHashAndPassword([]byte(pwdHash), pwd); err != nil {
u.lo.Error("error verifying password", "error", err)
return fmt.Errorf("error verifying password: %w", err)
}
return nil
}
// generatePassword generates a random password and returns its bcrypt hash.
func (u *Manager) generatePassword() ([]byte, error) {
password, _ := stringutil.RandomAlphanumeric(70)
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
u.lo.Error("error generating bcrypt password", "error", err)
return nil, fmt.Errorf("generating bcrypt password: %w", err)
}
return bytes, nil
}
// ToggleEnabled toggles the enabled status of an user. // ToggleEnabled toggles the enabled status of an user.
func (u *Manager) ToggleEnabled(id int, typ string, enabled bool) error { func (u *Manager) ToggleEnabled(id int, typ string, enabled bool) error {
if _, err := u.q.ToggleEnable.Exec(id, typ, enabled); err != nil { if _, err := u.q.ToggleEnable.Exec(id, typ, enabled); err != nil {
@@ -326,7 +289,7 @@ func (u *Manager) GenerateAPIKey(userID int) (string, string, error) {
} }
// Update user with API key. // Update user with API key.
if _, err := u.q.GenerateAPIKey.Exec(userID, apiKey, string(secretHash)); err != nil { if _, err := u.q.SetAPIKey.Exec(userID, apiKey, string(secretHash)); err != nil {
u.lo.Error("error saving API key", "error", err, "user_id", userID) u.lo.Error("error saving API key", "error", err, "user_id", userID)
return "", "", envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorGenerating", "name", "{globals.terms.apiKey}"), nil) return "", "", envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorGenerating", "name", "{globals.terms.apiKey}"), nil)
} }
@@ -469,3 +432,37 @@ func updateSystemUserPassword(db *sqlx.DB, hashedPassword []byte) error {
} }
return nil return nil
} }
// makeUserListQuery generates a query to fetch users based on the provided filters.
func (u *Manager) makeUserListQuery(page, pageSize int, typ, order, orderBy, filtersJSON string) (string, []interface{}, error) {
var qArgs []any
qArgs = append(qArgs, pq.Array([]string{typ}))
return dbutil.BuildPaginatedQuery(u.q.GetUsersCompact, qArgs, dbutil.PaginationOptions{
Order: order,
OrderBy: orderBy,
Page: page,
PageSize: pageSize,
}, filtersJSON, dbutil.AllowedFields{
"users": {"email", "created_at", "updated_at"},
})
}
// verifyPassword compares the provided password with the stored password hash.
func (u *Manager) verifyPassword(pwd []byte, pwdHash string) error {
if err := bcrypt.CompareHashAndPassword([]byte(pwdHash), pwd); err != nil {
u.lo.Error("error verifying password", "error", err)
return fmt.Errorf("error verifying password: %w", err)
}
return nil
}
// generatePassword generates a random password and returns its bcrypt hash.
func (u *Manager) generatePassword() ([]byte, error) {
password, _ := stringutil.RandomAlphanumeric(70)
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
u.lo.Error("error generating bcrypt password", "error", err)
return nil, fmt.Errorf("generating bcrypt password: %w", err)
}
return bytes, nil
}

View File

@@ -1,7 +1,6 @@
package models package models
import ( import (
"encoding/json"
"time" "time"
"github.com/lib/pq" "github.com/lib/pq"
@@ -37,10 +36,3 @@ const (
// Test event // Test event
EventWebhookTest WebhookEvent = "webhook.test" EventWebhookTest WebhookEvent = "webhook.test"
) )
// WebhookPayload represents the payload sent to a webhook
type WebhookPayload struct {
Event WebhookEvent `json:"event"`
Timestamp time.Time `json:"timestamp"`
Data json.RawMessage `json:",inline"`
}

View File

@@ -5,7 +5,7 @@
{{ if ne SiteName "" }} {{ if ne SiteName "" }}
Welcome to {{ SiteName }} Welcome to {{ SiteName }}
{{ else }} {{ else }}
Welcome Welcome to Libredesk
{{ end }} {{ end }}
</h1> </h1>

View File

@@ -183,7 +183,7 @@ footer.container {
margin-top: 2rem; margin-top: 2rem;
text-align: center; text-align: center;
color: #9ca3af; color: #9ca3af;
font-size: 0.875rem; font-size: 0.70rem;
} }
footer a { footer a {

View File

@@ -2,10 +2,7 @@
{{ template "header" . }} {{ template "header" . }}
<div class="csat-container"> <div class="csat-container">
<div class="csat-header"> <div class="csat-header">
<h2>Rate your recent interaction</h2> <h2>{{ L.T "csat.rateYourInteraction" }}</h2>
{{ if .Data.Conversation.Subject }}
<p class="conversation-subject"><i>{{ .Data.Conversation.Subject }}</i></p>
{{ end }}
</div> </div>
<form action="/csat/{{ .Data.CSAT.UUID }}" method="POST" class="csat-form" novalidate> <form action="/csat/{{ .Data.CSAT.UUID }}" method="POST" class="csat-form" novalidate>
@@ -16,7 +13,7 @@
<div class="emoji-wrapper"> <div class="emoji-wrapper">
<span class="emoji">😢</span> <span class="emoji">😢</span>
</div> </div>
<span class="rating-text">Poor</span> <span class="rating-text">{{ L.T "csat.rating.poor" }}</span>
</label> </label>
<input type="radio" id="rating-2" name="rating" value="2"> <input type="radio" id="rating-2" name="rating" value="2">
@@ -24,7 +21,7 @@
<div class="emoji-wrapper"> <div class="emoji-wrapper">
<span class="emoji">😕</span> <span class="emoji">😕</span>
</div> </div>
<span class="rating-text">Fair</span> <span class="rating-text">{{ L.T "csat.rating.fair" }}</span>
</label> </label>
<input type="radio" id="rating-3" name="rating" value="3"> <input type="radio" id="rating-3" name="rating" value="3">
@@ -32,7 +29,7 @@
<div class="emoji-wrapper"> <div class="emoji-wrapper">
<span class="emoji">😊</span> <span class="emoji">😊</span>
</div> </div>
<span class="rating-text">Good</span> <span class="rating-text">{{ L.T "csat.rating.good" }}</span>
</label> </label>
<input type="radio" id="rating-4" name="rating" value="4"> <input type="radio" id="rating-4" name="rating" value="4">
@@ -40,7 +37,7 @@
<div class="emoji-wrapper"> <div class="emoji-wrapper">
<span class="emoji">😃</span> <span class="emoji">😃</span>
</div> </div>
<span class="rating-text">Great</span> <span class="rating-text">{{ L.T "csat.rating.great" }}</span>
</label> </label>
<input type="radio" id="rating-5" name="rating" value="5"> <input type="radio" id="rating-5" name="rating" value="5">
@@ -48,18 +45,18 @@
<div class="emoji-wrapper"> <div class="emoji-wrapper">
<span class="emoji">🤩</span> <span class="emoji">🤩</span>
</div> </div>
<span class="rating-text">Excellent</span> <span class="rating-text">{{ L.T "csat.rating.excellent" }}</span>
</label> </label>
</div> </div>
<!-- Validation message for rating --> <!-- Validation message for rating -->
<div class="validation-message" id="ratingValidationMessage" <div class="validation-message" id="ratingValidationMessage"
style="display: none; color: #dc2626; text-align: center; margin-top: 10px; font-size: 0.9em;"> style="display: none; color: #dc2626; text-align: center; margin-top: 10px; font-size: 0.9em;">
Please select a rating before submitting. {{ L.Ts "globals.messages.pleaseSelect" "name" "rating" }}
</div> </div>
</div> </div>
<div class="feedback-container"> <div class="feedback-container">
<label for="feedback" class="feedback-label">Additional feedback (optional)</label> <label for="feedback" class="feedback-label">{{ L.T "globals.messages.additionalFeedback" }}</label>
<textarea id="feedback" name="feedback" placeholder="" rows="6" maxlength="1000" <textarea id="feedback" name="feedback" placeholder="" rows="6" maxlength="1000"
onkeyup="updateCharCount(this)"></textarea> onkeyup="updateCharCount(this)"></textarea>
<div class="char-counter"> <div class="char-counter">
@@ -67,7 +64,7 @@
</div> </div>
</div> </div>
<button type="submit" class="button submit-button">Submit</button> <button type="submit" class="button submit-button">{{ L.T "globals.messages.submit" }}</button>
</form> </form>
</div> </div>
@@ -148,9 +145,9 @@
.rating-options { .rating-options {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 25px; gap: 15px;
flex-wrap: wrap;
margin-top: 30px; margin-top: 30px;
align-items: center;
} }
.rating-options input[type="radio"] { .rating-options input[type="radio"] {
@@ -163,9 +160,10 @@
align-items: center; align-items: center;
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; transition: all 0.3s ease;
padding: 15px; padding: 12px;
position: relative; position: relative;
width: 110px; min-width: 90px;
flex-shrink: 0;
} }
.rating-option:hover { .rating-option:hover {
@@ -173,7 +171,8 @@
} }
.rating-option:focus { .rating-option:focus {
outline: 2px solid #0055d4; outline: 2px solid #3b82f6;
outline-offset: 2px;
border-radius: 8px; border-radius: 8px;
} }
@@ -181,41 +180,33 @@
transform: translateY(-3px); transform: translateY(-3px);
} }
.rating-options input[type="radio"]:checked+.rating-option::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 40px;
height: 3px;
background-color: #0055d4;
border-radius: 2px;
}
.emoji-wrapper { .emoji-wrapper {
background: #f8f9ff; background: #f8fafc;
border-radius: 50%; border-radius: 50%;
width: 70px; width: 65px;
height: 70px; height: 65px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-bottom: 12px; margin-bottom: 8px;
transition: all 0.3s ease; transition: all 0.3s ease;
border: 2px solid transparent; border: 2px solid #e2e8f0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
} }
.rating-option:hover .emoji-wrapper { .rating-option:hover .emoji-wrapper {
transform: scale(1.1); transform: scale(1.05);
background: #f0f5ff; background: #f1f5f9;
border-color: #0055d4; border-color: #3b82f6;
box-shadow: 0 4px 8px rgba(59, 130, 246, 0.15);
} }
.rating-options input[type="radio"]:checked+.rating-option .emoji-wrapper { .rating-options input[type="radio"]:checked+.rating-option .emoji-wrapper {
transform: scale(1.1); transform: scale(1.05);
background: #e8f0ff; background: #dbeafe;
border-color: #0055d4; border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
} }
.emoji { .emoji {
@@ -225,10 +216,11 @@
} }
.rating-text { .rating-text {
font-size: 0.9em; font-size: 0.85em;
text-align: center; text-align: center;
color: #666; color: #64748b;
font-weight: 500; font-weight: 500;
line-height: 1.2;
} }
.feedback-container { .feedback-container {
@@ -254,8 +246,9 @@
} }
textarea:focus { textarea:focus {
border-color: #0055d4; border-color: #3b82f6;
outline: none; outline: none;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
} }
.char-counter { .char-counter {
@@ -279,28 +272,23 @@
transition: all 0.3s ease; transition: all 0.3s ease;
} }
@media screen and (max-width: 650px) { @media screen and (max-width: 600px) {
.csat-container { .csat-container {
margin: 0; margin: 0;
padding: 30px; padding: 20px;
border-radius: 0; border-radius: 0;
} }
.rating-options { .rating-options {
flex-direction: column; gap: 8px;
} }
.rating-option { .rating-option {
flex-direction: row; min-width: 70px;
justify-content: flex-start; padding: 8px;
gap: 15px;
width: 100%;
padding: 15px;
} }
.emoji-wrapper { .emoji-wrapper {
margin-bottom: 0;
width: 50px; width: 50px;
height: 50px; height: 50px;
} }
@@ -310,7 +298,31 @@
} }
.rating-text { .rating-text {
text-align: left; font-size: 0.8em;
}
}
@media screen and (max-width: 480px) {
.rating-options {
gap: 5px;
}
.rating-option {
min-width: 60px;
padding: 6px;
}
.emoji-wrapper {
width: 45px;
height: 45px;
}
.emoji {
font-size: 1.6em;
}
.rating-text {
font-size: 0.75em;
} }
} }
</style> </style>

View File

@@ -31,7 +31,7 @@
{{ define "footer" }} {{ define "footer" }}
</div> </div>
<footer class="container"> <footer class="container">
Powered by <a target="_blank" rel="noreferrer" href="https://libredesk.io/">Libredesk</a> {{ L.T "globals.messages.poweredBy" }} <a target="_blank" rel="noreferrer" href="https://libredesk.io/">Libredesk</a>
</footer> </footer>
</body> </body>
</html> </html>