mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-10-23 05:11:57 +00:00
Merge pull request #130 from abhinavxd/refactor-apis
Clean up APIs and fixes
This commit is contained in:
63
cmd/config.go
Normal file
63
cmd/config.go
Normal 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)
|
||||
}
|
@@ -164,11 +164,17 @@ func handleUpdateContact(r *fastglue.Request) error {
|
||||
// Upload avatar?
|
||||
files, ok := form.File["files"]
|
||||
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 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.
|
||||
@@ -195,18 +201,21 @@ func handleCreateContactNote(r *fastglue.Request) error {
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
req = createContactNoteReq{}
|
||||
)
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
if len(req.Note) == 0 {
|
||||
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 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.
|
||||
@@ -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 {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -251,6 +262,7 @@ func handleBlockContact(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
contactID, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
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))
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
@@ -49,6 +49,7 @@ type createConversationRequest struct {
|
||||
Subject string `json:"subject"`
|
||||
Content string `json:"content"`
|
||||
Attachments []int `json:"attachments"`
|
||||
Initiator string `json:"initiator"` // "contact" | "agent"
|
||||
}
|
||||
|
||||
// handleGetAllConversations retrieves all conversations.
|
||||
@@ -273,8 +274,8 @@ func handleGetConversation(r *fastglue.Request) error {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
prev, _ := app.conversation.GetContactConversations(conv.ContactID)
|
||||
conv.PreviousConversations = filterCurrentConv(prev, conv.UUID)
|
||||
prev, _ := app.conversation.GetContactPreviousConversations(conv.ContactID, 10)
|
||||
conv.PreviousConversations = filterCurrentPreviousConv(prev, conv.UUID)
|
||||
return r.SendEnvelope(conv)
|
||||
}
|
||||
|
||||
@@ -649,14 +650,14 @@ func handleRemoveTeamAssignee(r *fastglue.Request) error {
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
// filterCurrentConv removes the current conversation from the list of conversations.
|
||||
func filterCurrentConv(convs []cmodels.Conversation, uuid string) []cmodels.Conversation {
|
||||
// filterCurrentPreviousConv removes the current conversation from the list of previous conversations.
|
||||
func filterCurrentPreviousConv(convs []cmodels.PreviousConversation, uuid string) []cmodels.PreviousConversation {
|
||||
for i, c := range convs {
|
||||
if c.UUID == uuid {
|
||||
return append(convs[:i], convs[i+1:]...)
|
||||
}
|
||||
}
|
||||
return []cmodels.Conversation{}
|
||||
return []cmodels.PreviousConversation{}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Validate the request
|
||||
if err := validateCreateConversationRequest(req, app); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
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, "")
|
||||
if err != nil {
|
||||
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.
|
||||
contact := umodels.User{
|
||||
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))
|
||||
}
|
||||
|
||||
// Create conversation
|
||||
// Create conversation first.
|
||||
conversationID, conversationUUID, err := app.conversation.CreateConversation(
|
||||
contact.ID,
|
||||
contact.ContactChannelID,
|
||||
@@ -725,14 +704,14 @@ func handleCreateConversation(r *fastglue.Request) error {
|
||||
"", /** last_message **/
|
||||
time.Now(), /** last_message_at **/
|
||||
req.Subject,
|
||||
true, /** append reference number to subject **/
|
||||
true, /** append reference number to subject? **/
|
||||
)
|
||||
if err != nil {
|
||||
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))
|
||||
}
|
||||
|
||||
// Prepare attachments.
|
||||
// Get media for the attachment ids.
|
||||
var media = make([]medModels.Media, 0, len(req.Attachments))
|
||||
for _, id := range req.Attachments {
|
||||
m, err := app.media.Get(id, "")
|
||||
@@ -743,13 +722,29 @@ func handleCreateConversation(r *fastglue.Request) error {
|
||||
media = append(media, m)
|
||||
}
|
||||
|
||||
// Send reply to the created 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 {
|
||||
// Delete the conversation if reply fails.
|
||||
if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
|
||||
app.lo.Error("error deleting conversation", "error", err)
|
||||
// Send initial message based on the initiator of conversation.
|
||||
switch req.Initiator {
|
||||
case umodels.UserTypeAgent:
|
||||
// Queue reply.
|
||||
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.
|
||||
@@ -768,3 +763,36 @@ func handleCreateConversation(r *fastglue.Request) error {
|
||||
|
||||
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
|
||||
}
|
||||
|
20
cmd/csat.go
20
cmd/csat.go
@@ -17,7 +17,7 @@ func handleShowCSAT(r *fastglue.Request) error {
|
||||
if err != nil {
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "error", 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 {
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "info", map[string]interface{}{
|
||||
"Data": map[string]interface{}{
|
||||
"Title": "Thank you!",
|
||||
"Message": "We appreciate you taking the time to submit your feedback.",
|
||||
"Title": app.i18n.T("globals.messages.thankYou"),
|
||||
"Message": app.i18n.T("csat.thankYouMessage"),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -35,14 +35,14 @@ func handleShowCSAT(r *fastglue.Request) error {
|
||||
if err != nil {
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "error", 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{}{
|
||||
"Data": map[string]interface{}{
|
||||
"Title": "Rate your interaction with us",
|
||||
"Title": app.i18n.T("csat.pageTitle"),
|
||||
"CSAT": map[string]interface{}{
|
||||
"UUID": csat.UUID,
|
||||
},
|
||||
@@ -67,7 +67,7 @@ func handleUpdateCSATResponse(r *fastglue.Request) error {
|
||||
if err != nil {
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "error", 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 {
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "error", 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 == "" {
|
||||
return app.tmpl.RenderWebPage(r.RequestCtx, "error", 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{}{
|
||||
"Data": map[string]interface{}{
|
||||
"Title": "Thank you!",
|
||||
"Message": "We appreciate you taking the time to submit your feedback.",
|
||||
"Title": app.i18n.T("globals.messages.thankYou"),
|
||||
"Message": app.i18n.T("csat.thankYouMessage"),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
@@ -23,18 +23,20 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
||||
// i18n.
|
||||
g.GET("/api/v1/lang/{lang}", handleGetI18nLang)
|
||||
|
||||
// Public config for app initialization.
|
||||
g.GET("/api/v1/config", handleGetConfig)
|
||||
|
||||
// Media.
|
||||
g.GET("/uploads/{uuid}", auth(handleServeMedia))
|
||||
g.POST("/api/v1/media", auth(handleMediaUpload))
|
||||
|
||||
// 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.GET("/api/v1/settings/notifications/email", perm(handleGetEmailNotificationSettings, "notification_settings:manage"))
|
||||
g.PUT("/api/v1/settings/notifications/email", perm(handleUpdateEmailNotificationSettings, "notification_settings:manage"))
|
||||
|
||||
// OpenID connect single sign-on.
|
||||
g.GET("/api/v1/oidc/enabled", handleGetAllEnabledOIDC)
|
||||
g.GET("/api/v1/oidc", perm(handleGetAllOIDC, "oidc:manage"))
|
||||
g.POST("/api/v1/oidc", perm(handleCreateOIDC, "oidc:manage"))
|
||||
g.GET("/api/v1/oidc/{id}", perm(handleGetOIDC, "oidc:manage"))
|
||||
|
@@ -17,6 +17,12 @@ func handleGetInboxes(r *fastglue.Request) error {
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
|
21
cmd/init.go
21
cmd/init.go
@@ -181,11 +181,10 @@ func loadSettings(m *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{
|
||||
DB: db,
|
||||
Lo: initLogger("settings"),
|
||||
I18n: i18n,
|
||||
DB: db,
|
||||
Lo: initLogger("settings"),
|
||||
})
|
||||
if err != nil {
|
||||
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 {
|
||||
var (
|
||||
lo = initLogger("template")
|
||||
funcMap = getTmplFuncs(consts)
|
||||
funcMap = getTmplFuncs(consts, i18n)
|
||||
)
|
||||
tpls, err := stuffbin.ParseTemplatesGlob(funcMap, fs, "/static/email-templates/*.html")
|
||||
if err != nil {
|
||||
@@ -347,7 +346,7 @@ func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants, i18n *
|
||||
}
|
||||
|
||||
// getTmplFuncs returns the template functions.
|
||||
func getTmplFuncs(consts *constants) template.FuncMap {
|
||||
func getTmplFuncs(consts *constants, i18n *i18n.I18n) template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"RootURL": func() string {
|
||||
return consts.AppBaseURL
|
||||
@@ -367,6 +366,9 @@ func getTmplFuncs(consts *constants) template.FuncMap {
|
||||
"SiteName": func() string {
|
||||
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)
|
||||
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)
|
||||
return err
|
||||
}
|
||||
@@ -395,7 +400,7 @@ func reloadSettings(app *App) error {
|
||||
// reloadTemplates reloads the templates from the filesystem.
|
||||
func reloadTemplates(app *App) error {
|
||||
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")
|
||||
if err != nil {
|
||||
app.lo.Error("error parsing email templates", "error", err)
|
||||
|
@@ -97,6 +97,8 @@ type App struct {
|
||||
|
||||
// Global state that stores data on an available app update.
|
||||
update *AppUpdate
|
||||
// Flag to indicate if app restart is required for settings to take effect.
|
||||
restartRequired bool
|
||||
sync.Mutex
|
||||
}
|
||||
|
||||
@@ -157,10 +159,8 @@ func main() {
|
||||
// Check for pending upgrade.
|
||||
checkPendingUpgrade(db)
|
||||
|
||||
i18n := initI18n(fs)
|
||||
|
||||
// Load app settings from DB into the Koanf instance.
|
||||
settings := initSettings(db, i18n)
|
||||
settings := initSettings(db)
|
||||
loadSettings(settings)
|
||||
|
||||
// Fallback for config typo. Logs a warning but continues to work with the incorrect key.
|
||||
@@ -184,6 +184,7 @@ func main() {
|
||||
lo = initLogger(appName)
|
||||
rdb = initRedis()
|
||||
constants = initConstants()
|
||||
i18n = initI18n(fs)
|
||||
csat = initCSAT(db, i18n)
|
||||
oidc = initOIDC(db, settings, i18n)
|
||||
status = initStatus(db, i18n)
|
||||
|
@@ -99,7 +99,7 @@ func handleGetMessage(r *fastglue.Request) error {
|
||||
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 {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -168,7 +168,7 @@ func handleSendMessage(r *fastglue.Request) error {
|
||||
}
|
||||
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 {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
18
cmd/oidc.go
18
cmd/oidc.go
@@ -11,16 +11,6 @@ import (
|
||||
"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
|
||||
func handleGetAllOIDC(r *fastglue.Request) error {
|
||||
app := r.Context.(*App)
|
||||
@@ -74,10 +64,10 @@ func handleCreateOIDC(r *fastglue.Request) error {
|
||||
if err := reloadAuth(app); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "OIDC"), nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
|
||||
// Clear client secret before returning
|
||||
createdOIDC.ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10)
|
||||
|
||||
|
||||
return r.SendEnvelope(createdOIDC)
|
||||
}
|
||||
|
||||
@@ -110,10 +100,10 @@ func handleUpdateOIDC(r *fastglue.Request) error {
|
||||
if err := reloadAuth(app); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "OIDC"), nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
|
||||
// Clear client secret before returning
|
||||
updatedOIDC.ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10)
|
||||
|
||||
|
||||
return r.SendEnvelope(updatedOIDC)
|
||||
}
|
||||
|
||||
|
@@ -31,6 +31,8 @@ func handleGetGeneralSettings(r *fastglue.Request) error {
|
||||
settings["app.update"] = app.update
|
||||
// Set app version.
|
||||
settings["app.version"] = versionString
|
||||
// Set restart required flag.
|
||||
settings["app.restart_required"] = app.restartRequired
|
||||
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)
|
||||
}
|
||||
|
||||
// Get current language before update.
|
||||
app.Lock()
|
||||
oldLang := ko.String("app.lang")
|
||||
app.Unlock()
|
||||
|
||||
// Remove any trailing slash `/` from the root url.
|
||||
req.RootURL = strings.TrimRight(req.RootURL, "/")
|
||||
|
||||
@@ -55,6 +62,17 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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)
|
||||
}
|
||||
|
||||
// If empty then retain previous password.
|
||||
if req.Password == "" {
|
||||
req.Password = cur.Password
|
||||
}
|
||||
@@ -117,6 +136,10 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
|
||||
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)
|
||||
}
|
||||
|
@@ -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))
|
||||
}
|
||||
|
||||
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 {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
220
cmd/users.go
220
cmd/users.go
@@ -26,34 +26,38 @@ const (
|
||||
maxAvatarSizeMB = 2
|
||||
)
|
||||
|
||||
// Request structs for user-related endpoints
|
||||
|
||||
// UpdateAvailabilityRequest represents the request to update user availability
|
||||
type UpdateAvailabilityRequest struct {
|
||||
type updateAvailabilityRequest struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// ResetPasswordRequest represents the password reset request
|
||||
type ResetPasswordRequest struct {
|
||||
type resetPasswordRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// SetPasswordRequest represents the set password request
|
||||
type SetPasswordRequest struct {
|
||||
type setPasswordRequest struct {
|
||||
Token string `json:"token"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// AvailabilityRequest represents the request to update agent availability
|
||||
type AvailabilityRequest struct {
|
||||
type availabilityRequest struct {
|
||||
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.
|
||||
func handleGetAgents(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
)
|
||||
var app = r.Context.(*App)
|
||||
agents, err := app.user.GetAgents()
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
@@ -73,9 +77,7 @@ func handleGetAgentsCompact(r *fastglue.Request) error {
|
||||
|
||||
// handleGetAgent returns an agent.
|
||||
func handleGetAgent(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
)
|
||||
var app = r.Context.(*App)
|
||||
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
if err != nil || id <= 0 {
|
||||
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)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
ip = realip.FromRequest(r.RequestCtx)
|
||||
availReq AvailabilityRequest
|
||||
availReq availabilityRequest
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Fetch entire agent
|
||||
agent, err := app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
@@ -108,10 +111,10 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error {
|
||||
|
||||
// Same 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 {
|
||||
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 {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
agent, err := app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
teams, err := app.team.GetUserTeams(agent.ID)
|
||||
teams, err := app.team.GetUserTeams(auser.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -150,11 +154,6 @@ func handleUpdateCurrentAgent(r *fastglue.Request) error {
|
||||
app = r.Context.(*App)
|
||||
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()
|
||||
if err != nil {
|
||||
app.lo.Error("error parsing form data", "error", err)
|
||||
@@ -165,54 +164,53 @@ func handleUpdateCurrentAgent(r *fastglue.Request) error {
|
||||
|
||||
// Upload avatar?
|
||||
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 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.
|
||||
func handleCreateAgent(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
user = models.User{}
|
||||
app = r.Context.(*App)
|
||||
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)
|
||||
}
|
||||
|
||||
if user.Email.String == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
|
||||
}
|
||||
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)
|
||||
// Validate agent request
|
||||
if err := validateAgentRequest(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
if err := app.user.CreateAgent(&user); err != nil {
|
||||
agent, err := app.user.CreateAgent(req.FirstName, req.LastName, req.Email, req.Roles)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Upsert user teams.
|
||||
if len(user.Teams) > 0 {
|
||||
if err := app.team.UpsertUserTeams(user.ID, user.Teams.Names()); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
if len(req.Teams) > 0 {
|
||||
app.team.UpsertUserTeams(agent.ID, req.Teams)
|
||||
}
|
||||
|
||||
if user.SendWelcomeEmail {
|
||||
if req.SendWelcomeEmail {
|
||||
// Generate reset token.
|
||||
resetToken, err := app.user.SetResetPasswordToken(user.ID)
|
||||
resetToken, err := app.user.SetResetPasswordToken(agent.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -220,31 +218,36 @@ func handleCreateAgent(r *fastglue.Request) error {
|
||||
// Render template and send email.
|
||||
content, err := app.tmpl.RenderInMemoryTemplate(tmpl.TmplWelcome, map[string]any{
|
||||
"ResetToken": resetToken,
|
||||
"Email": user.Email.String,
|
||||
"Email": req.Email,
|
||||
})
|
||||
if err != nil {
|
||||
app.lo.Error("error rendering template", "error", err)
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
if err := app.notifier.Send(notifier.Message{
|
||||
RecipientEmails: []string{user.Email.String},
|
||||
Subject: "Welcome to Libredesk",
|
||||
RecipientEmails: []string{req.Email},
|
||||
Subject: app.i18n.T("globals.messages.welcomeToLibredesk"),
|
||||
Content: content,
|
||||
Provider: notifier.ProviderEmail,
|
||||
}); err != nil {
|
||||
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.
|
||||
func handleUpdateAgent(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
user = models.User{}
|
||||
req = agentReq{}
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
ip = realip.FromRequest(r.RequestCtx)
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
if user.Email.String == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
|
||||
}
|
||||
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)
|
||||
// Validate agent request
|
||||
if err := validateAgentRequest(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
agent, err := app.user.GetAgent(id, "")
|
||||
@@ -280,8 +271,8 @@ func handleUpdateAgent(r *fastglue.Request) error {
|
||||
}
|
||||
oldAvailabilityStatus := agent.AvailabilityStatus
|
||||
|
||||
// Update agent.
|
||||
if err = app.user.UpdateAgent(id, user); err != nil {
|
||||
// Update agent with individual fields
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -289,18 +280,24 @@ func handleUpdateAgent(r *fastglue.Request) error {
|
||||
defer app.authz.InvalidateUserCache(id)
|
||||
|
||||
// Create activity log if user availability status changed.
|
||||
if oldAvailabilityStatus != user.AvailabilityStatus {
|
||||
if err := app.activityLog.UserAvailability(auser.ID, auser.Email, user.AvailabilityStatus, ip, user.Email.String, id); err != nil {
|
||||
if oldAvailabilityStatus != req.AvailabilityStatus {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 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.
|
||||
@@ -381,7 +378,7 @@ func handleResetPassword(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
auser, ok = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
resetReq ResetPasswordRequest
|
||||
resetReq resetPasswordRequest
|
||||
)
|
||||
if ok && auser.ID > 0 {
|
||||
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)
|
||||
if err != nil {
|
||||
// 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)
|
||||
@@ -434,7 +431,7 @@ func handleSetPassword(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
agent, ok = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
req = SetPasswordRequest{}
|
||||
req setPasswordRequest
|
||||
)
|
||||
|
||||
if ok && agent.ID > 0 {
|
||||
@@ -457,13 +454,13 @@ func handleSetPassword(r *fastglue.Request) error {
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
fileHeader := files[0]
|
||||
file, err := fileHeader.Open()
|
||||
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)
|
||||
}
|
||||
defer file.Close()
|
||||
@@ -480,7 +477,7 @@ func uploadUserAvatar(r *fastglue.Request, user *models.User, files []*multipart
|
||||
|
||||
// Check file size
|
||||
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(
|
||||
envelope.InputError,
|
||||
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("{}")
|
||||
media, err := app.media.UploadAndInsert(srcFileName, srcContentType, contentID, linkedModel, linkedID, file, int(srcFileSize), disposition, meta)
|
||||
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)
|
||||
}
|
||||
|
||||
// Delete current avatar.
|
||||
if user.AvatarURL.Valid {
|
||||
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.
|
||||
path, err := stringutil.GetPathFromURL(media.URL)
|
||||
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)
|
||||
}
|
||||
fmt.Println("path", path)
|
||||
|
||||
if err := app.user.UpdateAvatar(user.ID, path); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -577,3 +576,28 @@ func handleRevokeAPIKey(r *fastglue.Request) error {
|
||||
|
||||
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
|
||||
}
|
||||
|
@@ -2,23 +2,33 @@
|
||||
|
||||
describe('Login Component', () => {
|
||||
beforeEach(() => {
|
||||
// Visit the login page
|
||||
cy.visit('/')
|
||||
|
||||
// Mock the API response for OIDC providers
|
||||
cy.intercept('GET', '**/api/v1/oidc/enabled', {
|
||||
cy.intercept('GET', '**/api/v1/config', {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Google',
|
||||
logo_url: 'https://example.com/google-logo.png',
|
||||
disabled: false
|
||||
}
|
||||
]
|
||||
data: {
|
||||
"app.favicon_url": "http://localhost:9000/favicon.ico",
|
||||
"app.lang": "en",
|
||||
"app.logo_url": "http://localhost:9000/logo.png",
|
||||
"app.site_name": "Libredesk",
|
||||
"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')
|
||||
|
||||
// Visit the login page
|
||||
cy.visit('/')
|
||||
})
|
||||
|
||||
it('should display login form', () => {
|
||||
|
@@ -88,8 +88,8 @@
|
||||
@create-conversation="() => (openCreateConversationDialog = true)"
|
||||
>
|
||||
<div class="flex flex-col h-screen">
|
||||
<!-- Show app update only in admin routes -->
|
||||
<AppUpdate v-if="route.path.startsWith('/admin')" />
|
||||
<!-- Show admin banner only in admin routes -->
|
||||
<AdminBanner v-if="route.path.startsWith('/admin')" />
|
||||
|
||||
<!-- Common header for all pages -->
|
||||
<PageHeader />
|
||||
@@ -128,7 +128,7 @@ import { useCustomAttributeStore } from '@/stores/customAttributes'
|
||||
import { useIdleDetection } from '@/composables/useIdleDetection'
|
||||
import PageHeader from './components/layout/PageHeader.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 { toast as sooner } from 'vue-sonner'
|
||||
import Sidebar from '@/components/sidebar/Sidebar.vue'
|
||||
|
@@ -122,7 +122,7 @@ const createOIDC = (data) =>
|
||||
'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 getOIDC = (id) => http.get(`/api/v1/oidc/${id}`)
|
||||
const updateOIDC = (id, data) =>
|
||||
@@ -514,7 +514,7 @@ export default {
|
||||
updateSettings,
|
||||
createOIDC,
|
||||
getAllOIDC,
|
||||
getAllEnabledOIDC,
|
||||
getConfig,
|
||||
getOIDC,
|
||||
updateOIDC,
|
||||
deleteOIDC,
|
||||
|
63
frontend/src/components/banner/AdminBanner.vue
Normal file
63
frontend/src/components/banner/AdminBanner.vue
Normal 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>
|
@@ -4,9 +4,9 @@
|
||||
@click="handleClick">
|
||||
<div class="flex items-center mb-2">
|
||||
<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>
|
||||
<p class="text-sm text-gray-600">{{ subTitle }}</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{ subTitle }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@@ -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>
|
@@ -1 +1,3 @@
|
||||
export const Roles = ["Admin", "Agent"]
|
||||
export const Roles = ["Admin", "Agent"]
|
||||
export const UserTypeAgent = "agent"
|
||||
export const UserTypeContact = "contact"
|
@@ -418,7 +418,6 @@ const onSubmit = form.handleSubmit((values) => {
|
||||
if (values.availability_status === 'active_group') {
|
||||
values.availability_status = 'online'
|
||||
}
|
||||
values.teams = values.teams.map((team) => ({ name: team }))
|
||||
props.submitForm(values)
|
||||
})
|
||||
|
||||
|
@@ -1,5 +1,106 @@
|
||||
<template>
|
||||
<div class="h-screen w-full flex items-center justify-center min-w-[400px]">
|
||||
<p>{{ $t('conversation.placeholder') }}</p>
|
||||
<div class="placeholder-container">
|
||||
<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>
|
||||
</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>
|
||||
|
@@ -10,7 +10,7 @@
|
||||
})
|
||||
}}
|
||||
</DialogTitle>
|
||||
<DialogDescription/>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
<form @submit="createConversation" class="flex flex-col flex-1 overflow-hidden">
|
||||
<!-- Form Fields Section -->
|
||||
@@ -263,6 +263,7 @@ import { useFileUpload } from '@/composables/useFileUpload'
|
||||
import Editor from '@/components/editor/TextEditor.vue'
|
||||
import { useMacroStore } from '@/stores/macro'
|
||||
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
|
||||
import { UserTypeAgent } from '@/constants/user'
|
||||
import api from '@/api'
|
||||
|
||||
const dialogOpen = defineModel({
|
||||
@@ -393,12 +394,14 @@ const selectContact = (contact) => {
|
||||
const createConversation = form.handleSubmit(async (values) => {
|
||||
loading.value = true
|
||||
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.team_id = values.team_id ? Number(values.team_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)
|
||||
// Initiator of this conversation is always agent
|
||||
values.initiator = UserTypeAgent
|
||||
const conversation = await api.createConversation(values)
|
||||
const conversationUUID = conversation.data.data.uuid
|
||||
|
||||
|
@@ -18,14 +18,14 @@ const setFavicon = (url) => {
|
||||
}
|
||||
|
||||
async function initApp () {
|
||||
const settings = (await api.getSettings('general')).data.data
|
||||
const config = (await api.getConfig()).data.data
|
||||
const emitter = mitt()
|
||||
const lang = settings['app.lang'] || 'en'
|
||||
const lang = config['app.lang'] || 'en'
|
||||
const langMessages = await api.getLanguage(lang)
|
||||
|
||||
// Set favicon.
|
||||
if (settings['app.favicon_url'])
|
||||
setFavicon(settings['app.favicon_url'])
|
||||
if (config['app.favicon_url'])
|
||||
setFavicon(config['app.favicon_url'])
|
||||
|
||||
// Initialize i18n.
|
||||
const i18nConfig = {
|
||||
@@ -42,9 +42,17 @@ async function initApp () {
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
|
||||
// Store app settings in Pinia
|
||||
// Fetch and store app settings in store (after pinia is initialized)
|
||||
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.
|
||||
app.config.globalProperties.emitter = emitter
|
||||
|
@@ -1,12 +1,35 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import api from '@/api'
|
||||
|
||||
export const useAppSettingsStore = defineStore('settings', {
|
||||
state: () => ({
|
||||
settings: {}
|
||||
settings: {},
|
||||
public_config: {}
|
||||
}),
|
||||
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) {
|
||||
this.settings = newSettings
|
||||
},
|
||||
setPublicConfig (newPublicConfig) {
|
||||
this.public_config = newPublicConfig
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@@ -12,14 +12,13 @@ export const useInboxStore = defineStore('inbox', () => {
|
||||
label: inb.name,
|
||||
value: String(inb.id)
|
||||
})))
|
||||
const fetchInboxes = async () => {
|
||||
if (inboxes.value.length) return
|
||||
const fetchInboxes = async (force = false) => {
|
||||
if (!force && inboxes.value.length) return
|
||||
try {
|
||||
const response = await api.getInboxes()
|
||||
inboxes.value = response?.data?.data || []
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Error',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
|
@@ -5,6 +5,7 @@ import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents'
|
||||
import api from '@/api'
|
||||
|
||||
// TODO: rename this store to agents
|
||||
export const useUsersStore = defineStore('users', () => {
|
||||
const users = ref([])
|
||||
const emitter = useEmitter()
|
||||
@@ -13,8 +14,8 @@ export const useUsersStore = defineStore('users', () => {
|
||||
value: String(user.id),
|
||||
avatar_url: user.avatar_url,
|
||||
})))
|
||||
const fetchUsers = async () => {
|
||||
if (users.value.length) return
|
||||
const fetchUsers = async (force = false) => {
|
||||
if (!force && users.value.length) return
|
||||
try {
|
||||
const response = await api.getUsersCompact()
|
||||
users.value = response?.data?.data || []
|
||||
|
@@ -17,7 +17,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { createColumns } from '@/features/admin/agents/dataTableColumns.js'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import DataTable from '@/components/datatable/DataTable.vue'
|
||||
@@ -25,10 +25,11 @@ import { handleHTTPError } from '@/utils/http'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import api from '@/api'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const isLoading = ref(false)
|
||||
const usersStore = useUsersStore()
|
||||
const { t } = useI18n()
|
||||
const data = ref([])
|
||||
const emitter = useEmitter()
|
||||
@@ -40,11 +41,15 @@ onMounted(async () => {
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
emitter.off(EMITTER_EVENTS.REFRESH_LIST)
|
||||
})
|
||||
|
||||
const getData = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const response = await api.getUsers()
|
||||
data.value = response.data.data
|
||||
await usersStore.fetchUsers(true)
|
||||
data.value = usersStore.users
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
|
@@ -20,15 +20,17 @@ import { ref, onMounted } from 'vue'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import GeneralSettingForm from '@/features/admin/general/GeneralSettingForm.vue'
|
||||
import AdminPageWithHelp from '@/layouts/admin/AdminPageWithHelp.vue'
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import api from '@/api'
|
||||
|
||||
const initialValues = ref({})
|
||||
const isLoading = ref(false)
|
||||
const settingsStore = useAppSettingsStore()
|
||||
|
||||
onMounted(async () => {
|
||||
isLoading.value = true
|
||||
const response = await api.getSettings('general')
|
||||
const data = response.data.data
|
||||
await settingsStore.fetchSettings('general')
|
||||
const data = settingsStore.settings
|
||||
isLoading.value = false
|
||||
initialValues.value = Object.keys(data).reduce((acc, key) => {
|
||||
// Remove 'app.' prefix
|
||||
|
@@ -32,11 +32,13 @@ import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { format } from 'date-fns'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { useInboxStore } from '@/stores/inbox'
|
||||
import api from '@/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const emitter = useEmitter()
|
||||
const inboxStore = useInboxStore()
|
||||
const isLoading = ref(false)
|
||||
const data = ref([])
|
||||
|
||||
@@ -47,8 +49,8 @@ onMounted(async () => {
|
||||
const getInboxes = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const response = await api.getInboxes()
|
||||
data.value = response.data.data
|
||||
await inboxStore.fetchInboxes(true)
|
||||
data.value = inboxStore.inboxes
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
|
@@ -9,7 +9,7 @@
|
||||
<CardContent class="p-6 space-y-6">
|
||||
<div class="space-y-2 text-center">
|
||||
<CardTitle class="text-3xl font-bold text-foreground">
|
||||
{{ appSettingsStore.settings?.['app.site_name'] || 'Libredesk' }}
|
||||
{{ appSettingsStore.public_config?.['app.site_name'] || 'LIBREDESK' }}
|
||||
</CardTitle>
|
||||
<p class="text-muted-foreground">{{ t('auth.signIn') }}</p>
|
||||
</div>
|
||||
@@ -25,9 +25,8 @@
|
||||
>
|
||||
<img
|
||||
:src="oidcProvider.logo_url"
|
||||
:alt="oidcProvider.name"
|
||||
width="20"
|
||||
class="mr-2"
|
||||
alt=""
|
||||
v-if="oidcProvider.logo_url"
|
||||
/>
|
||||
{{ oidcProvider.name }}
|
||||
@@ -89,7 +88,9 @@
|
||||
type="submit"
|
||||
>
|
||||
<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') }}
|
||||
</span>
|
||||
<span v-else>{{ t('auth.signInButton') }}</span>
|
||||
@@ -159,8 +160,10 @@ onMounted(async () => {
|
||||
|
||||
const fetchOIDCProviders = async () => {
|
||||
try {
|
||||
const resp = await api.getAllEnabledOIDC()
|
||||
oidcProviders.value = resp.data.data
|
||||
const config = appSettingsStore.public_config
|
||||
if (config && config['app.sso_providers']) {
|
||||
oidcProviders.value = config['app.sso_providers'] || []
|
||||
}
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
@@ -204,6 +207,9 @@ const loginAction = () => {
|
||||
if (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' })
|
||||
})
|
||||
.catch((error) => {
|
||||
|
2
go.mod
2
go.mod
@@ -7,6 +7,7 @@ require (
|
||||
github.com/coreos/go-oidc/v3 v3.11.0
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
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/ferluci/fast-realip v1.0.1
|
||||
github.com/google/uuid v1.6.0
|
||||
@@ -49,7 +50,6 @@ require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // 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/fasthttp/router v1.5.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.6.0 // indirect
|
||||
|
24
i18n/en.json
24
i18n/en.json
@@ -188,6 +188,7 @@
|
||||
"globals.terms.recipient": "Recipient | Recipients",
|
||||
"globals.terms.tls": "TLS | TLSs",
|
||||
"globals.terms.credential": "Credential | Credentials",
|
||||
"globals.messages.welcomeToLibredesk": "Welcome to Libredesk",
|
||||
"globals.messages.invalid": "Invalid {name}",
|
||||
"globals.messages.custom": "Custom {name}",
|
||||
"globals.messages.replying": "Replying",
|
||||
@@ -294,6 +295,8 @@
|
||||
"globals.messages.submit": "Submit",
|
||||
"globals.messages.send": "Send {name}",
|
||||
"globals.messages.update": "Update {name}",
|
||||
"globals.messages.setUp": "Set up",
|
||||
"globals.messages.invite": "Invite",
|
||||
"globals.messages.enable": "Enable",
|
||||
"globals.messages.disable": "Disable",
|
||||
"globals.messages.block": "Block {name}",
|
||||
@@ -306,6 +309,12 @@
|
||||
"globals.messages.reset": "Reset {name}",
|
||||
"globals.messages.lastNItems": "Last {n} {name} | Last {n} {name}",
|
||||
"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.max": "Must be at most {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.cannotUpdateDefault": "Cannot update default conversation status",
|
||||
"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.invalidOrExpiredSession": "Invalid or expired session",
|
||||
"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.delete": "Delete Contact Notes",
|
||||
"admin.role.customAttributes.manage": "Manage Custom Attributes",
|
||||
"admin.role.webhooks.manage": "Manage Webhooks",
|
||||
"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.conversationUpdate": "Conversation Update",
|
||||
@@ -533,6 +551,7 @@
|
||||
"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.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.emailNotificationTemplates": "Email notification templates",
|
||||
"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.alreadyExistsWithEmail": "Another contact with same email already exists",
|
||||
"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"
|
||||
}
|
@@ -82,18 +82,9 @@ func (m *Manager) GetAll(order, orderBy, filtersJSON string, page, pageSize int)
|
||||
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.
|
||||
func (al *Manager) Login(userID int, email, ip string) error {
|
||||
return al.Create(
|
||||
return al.create(
|
||||
models.AgentLogin,
|
||||
fmt.Sprintf("%s (#%d) logged in", email, 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.
|
||||
func (al *Manager) Logout(userID int, email, ip string) error {
|
||||
return al.Create(
|
||||
return al.create(
|
||||
models.AgentLogout,
|
||||
fmt.Sprintf("%s (#%d) logged out", email, userID),
|
||||
userID,
|
||||
@@ -123,7 +114,7 @@ func (al *Manager) Away(actorID int, actorEmail, ip string, targetID int, target
|
||||
} else {
|
||||
description = fmt.Sprintf("%s (#%d) is away", actorEmail, actorID)
|
||||
}
|
||||
return al.Create(
|
||||
return al.create(
|
||||
models.AgentAway, /* activity type*/
|
||||
description,
|
||||
actorID, /*actor_id*/
|
||||
@@ -141,7 +132,7 @@ func (al *Manager) AwayReassigned(actorID int, actorEmail, ip string, targetID i
|
||||
} else {
|
||||
description = fmt.Sprintf("%s (#%d) is away and reassigning", actorEmail, actorID)
|
||||
}
|
||||
return al.Create(
|
||||
return al.create(
|
||||
models.AgentAwayReassigned, /* activity type*/
|
||||
description,
|
||||
actorID, /*actor_id*/
|
||||
@@ -159,7 +150,7 @@ func (al *Manager) Online(actorID int, actorEmail, ip string, targetID int, targ
|
||||
} else {
|
||||
description = fmt.Sprintf("%s (#%d) is online", actorEmail, actorID)
|
||||
}
|
||||
return al.Create(
|
||||
return al.create(
|
||||
models.AgentOnline, /* activity type*/
|
||||
description,
|
||||
actorID, /*actor_id*/
|
||||
@@ -190,6 +181,16 @@ func (al *Manager) UserAvailability(actorID int, actorEmail, status, ip, targetE
|
||||
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.
|
||||
func (m *Manager) makeQuery(page, pageSize int, order, orderBy, filtersJSON string) (string, []any, error) {
|
||||
var (
|
||||
|
@@ -2,10 +2,10 @@
|
||||
SELECT id, name, provider, config, is_default FROM ai_providers where is_default is true;
|
||||
|
||||
-- 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
|
||||
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
|
||||
UPDATE ai_providers
|
||||
|
@@ -183,6 +183,7 @@ func (e *Enforcer) EnforceConversationAccess(user umodels.User, conversation cmo
|
||||
// EnforceMediaAccess checks for read access on linked model to media.
|
||||
func (e *Enforcer) EnforceMediaAccess(user umodels.User, model string) (bool, error) {
|
||||
switch model {
|
||||
// TODO: Pick this table / model name from the package/models/models.go
|
||||
case "messages":
|
||||
allowed, err := e.Enforce(user, model, "read")
|
||||
if err != nil {
|
||||
|
@@ -33,7 +33,7 @@ type conversationStore interface {
|
||||
|
||||
type teamStore interface {
|
||||
GetAll() ([]tmodels.Team, error)
|
||||
GetMembers(teamID int) ([]umodels.User, error)
|
||||
GetMembers(teamID int) ([]tmodels.TeamMember, error)
|
||||
}
|
||||
|
||||
// Engine represents a manager for assigning unassigned conversations
|
||||
|
@@ -7,10 +7,10 @@ select
|
||||
from automation_rules where enabled is TRUE ORDER BY weight ASC;
|
||||
|
||||
-- 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
|
||||
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
|
||||
INSERT INTO automation_rules(id, name, description, type, events, rules, enabled)
|
||||
|
@@ -15,7 +15,10 @@ SELECT id,
|
||||
created_at,
|
||||
updated_at,
|
||||
"name",
|
||||
description
|
||||
description,
|
||||
is_always_open,
|
||||
hours,
|
||||
holidays
|
||||
FROM business_hours
|
||||
ORDER BY updated_at DESC;
|
||||
|
||||
|
@@ -200,7 +200,7 @@ type queries struct {
|
||||
GetConversationsCreatedAfter *sqlx.Stmt `query:"get-conversations-created-after"`
|
||||
GetUnassignedConversations *sqlx.Stmt `query:"get-unassigned-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"`
|
||||
GetUserActiveConversationsCount *sqlx.Stmt `query:"get-user-active-conversations-count"`
|
||||
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
|
||||
}
|
||||
|
||||
// GetContactConversations retrieves conversations for a contact.
|
||||
func (c *Manager) GetContactConversations(contactID int) ([]models.Conversation, error) {
|
||||
var conversations = make([]models.Conversation, 0)
|
||||
if err := c.q.GetContactConversations.Select(&conversations, contactID); err != nil {
|
||||
c.lo.Error("error fetching conversations", "error", err)
|
||||
// GetContactPreviousConversations retrieves previous conversations for a contact with a configurable limit.
|
||||
func (c *Manager) GetContactPreviousConversations(contactID int, limit int) ([]models.PreviousConversation, error) {
|
||||
var conversations = make([]models.PreviousConversation, 0)
|
||||
if err := c.q.GetContactPreviousConversations.Select(&conversations, contactID, limit); err != nil {
|
||||
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, nil
|
||||
@@ -348,32 +348,32 @@ func (c *Manager) GetConversationUUID(id int) (string, error) {
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
var conversations = make([]models.Conversation, 0)
|
||||
func (c *Manager) GetConversations(userID int, teamIDs []int, listTypes []string, order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
|
||||
var conversations = make([]models.ConversationListItem, 0)
|
||||
|
||||
// Make the query.
|
||||
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 {
|
||||
return fmt.Errorf("making recipients for reply action: %w", err)
|
||||
}
|
||||
_, err = m.SendReply(
|
||||
_, err = m.QueueReply(
|
||||
[]mmodels.Media{},
|
||||
conv.InboxID,
|
||||
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)
|
||||
}
|
||||
|
||||
// Send CSAT reply.
|
||||
_, err = m.SendReply(nil /**media**/, conversation.InboxID, actorUserID, conversation.UUID, message, to, cc, bcc, meta)
|
||||
// Queue CSAT reply.
|
||||
_, err = m.QueueReply(nil /**media**/, conversation.InboxID, actorUserID, conversation.UUID, message, to, cc, bcc, meta)
|
||||
if err != nil {
|
||||
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)
|
||||
|
@@ -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.
|
||||
stringutil.ReverseSlice(message.References)
|
||||
slices.Reverse(message.References)
|
||||
|
||||
// Remove the current message ID from the references.
|
||||
message.References = stringutil.RemoveItemByValue(message.References, message.SourceID.String)
|
||||
@@ -347,9 +347,10 @@ func (m *Manager) UpdateMessageStatus(messageUUID string, status string) error {
|
||||
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 {
|
||||
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 nil
|
||||
@@ -374,8 +375,27 @@ func (m *Manager) SendPrivateNote(media []mmodels.Media, senderID int, conversat
|
||||
return message, nil
|
||||
}
|
||||
|
||||
// SendReply inserts a reply 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) {
|
||||
// CreateContactMessage creates a contact message in a conversation.
|
||||
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 (
|
||||
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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
return message, err
|
||||
|
@@ -52,48 +52,124 @@ var (
|
||||
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 {
|
||||
ID int `db:"id" json:"id,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
UUID string `db:"uuid" json:"uuid"`
|
||||
ContactID int `db:"contact_id" json:"contact_id"`
|
||||
InboxID int `db:"inbox_id" json:"inbox_id,omitempty"`
|
||||
ClosedAt null.Time `db:"closed_at" json:"closed_at,omitempty"`
|
||||
ResolvedAt null.Time `db:"resolved_at" json:"resolved_at,omitempty"`
|
||||
ReferenceNumber string `db:"reference_number" json:"reference_number,omitempty"`
|
||||
Priority null.String `db:"priority" json:"priority"`
|
||||
PriorityID null.Int `db:"priority_id" json:"priority_id"`
|
||||
Status null.String `db:"status" json:"status"`
|
||||
StatusID null.Int `db:"status_id" json:"status_id"`
|
||||
FirstReplyAt null.Time `db:"first_reply_at" json:"first_reply_at"`
|
||||
LastReplyAt null.Time `db:"last_reply_at" json:"last_reply_at"`
|
||||
AssignedUserID null.Int `db:"assigned_user_id" json:"assigned_user_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"`
|
||||
WaitingSince null.Time `db:"waiting_since" json:"waiting_since"`
|
||||
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"`
|
||||
InboxName string `db:"inbox_name" json:"inbox_name"`
|
||||
InboxChannel string `db:"inbox_channel" json:"inbox_channel"`
|
||||
Tags null.JSON `db:"tags" json:"tags"`
|
||||
Meta pq.StringArray `db:"meta" json:"meta"`
|
||||
CustomAttributes json.RawMessage `db:"custom_attributes" json:"custom_attributes"`
|
||||
LastMessageAt null.Time `db:"last_message_at" json:"last_message_at"`
|
||||
LastMessage null.String `db:"last_message" json:"last_message"`
|
||||
LastMessageSender null.String `db:"last_message_sender" json:"last_message_sender"`
|
||||
Contact umodels.User `db:"contact" json:"contact"`
|
||||
SLAPolicyID null.Int `db:"sla_policy_id" json:"sla_policy_id"`
|
||||
SlaPolicyName null.String `db:"sla_policy_name" json:"sla_policy_name"`
|
||||
AppliedSLAID null.Int `db:"applied_sla_id" json:"applied_sla_id"`
|
||||
NextSLADeadlineAt null.Time `db:"next_sla_deadline_at" json:"next_sla_deadline_at"`
|
||||
FirstResponseDueAt null.Time `db:"first_response_deadline_at" json:"first_response_deadline_at"`
|
||||
ResolutionDueAt null.Time `db:"resolution_deadline_at" json:"resolution_deadline_at"`
|
||||
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"`
|
||||
PreviousConversations []Conversation `db:"-" json:"previous_conversations"`
|
||||
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"`
|
||||
ContactID int `db:"contact_id" json:"contact_id"`
|
||||
InboxID int `db:"inbox_id" json:"inbox_id"`
|
||||
ClosedAt null.Time `db:"closed_at" json:"closed_at"`
|
||||
ResolvedAt null.Time `db:"resolved_at" json:"resolved_at"`
|
||||
ReferenceNumber string `db:"reference_number" json:"reference_number"`
|
||||
Priority null.String `db:"priority" json:"priority"`
|
||||
PriorityID null.Int `db:"priority_id" json:"priority_id"`
|
||||
Status null.String `db:"status" json:"status"`
|
||||
StatusID null.Int `db:"status_id" json:"status_id"`
|
||||
FirstReplyAt null.Time `db:"first_reply_at" json:"first_reply_at"`
|
||||
LastReplyAt null.Time `db:"last_reply_at" json:"last_reply_at"`
|
||||
AssignedUserID null.Int `db:"assigned_user_id" json:"assigned_user_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"`
|
||||
WaitingSince null.Time `db:"waiting_since" json:"waiting_since"`
|
||||
Subject null.String `db:"subject" json:"subject"`
|
||||
InboxMail string `db:"inbox_mail" json:"inbox_mail"`
|
||||
InboxName string `db:"inbox_name" json:"inbox_name"`
|
||||
InboxChannel string `db:"inbox_channel" json:"inbox_channel"`
|
||||
Tags null.JSON `db:"tags" json:"tags"`
|
||||
Meta pq.StringArray `db:"meta" json:"meta"`
|
||||
CustomAttributes json.RawMessage `db:"custom_attributes" json:"custom_attributes"`
|
||||
LastMessageAt null.Time `db:"last_message_at" json:"last_message_at"`
|
||||
LastMessage null.String `db:"last_message" json:"last_message"`
|
||||
LastMessageSender null.String `db:"last_message_sender" json:"last_message_sender"`
|
||||
Contact ConversationContact `db:"contact" json:"contact"`
|
||||
SLAPolicyID null.Int `db:"sla_policy_id" json:"sla_policy_id"`
|
||||
SlaPolicyName null.String `db:"sla_policy_name" json:"sla_policy_name"`
|
||||
AppliedSLAID null.Int `db:"applied_sla_id" json:"applied_sla_id"`
|
||||
FirstResponseDueAt null.Time `db:"first_response_deadline_at" json:"first_response_deadline_at"`
|
||||
ResolutionDueAt null.Time `db:"resolution_deadline_at" json:"resolution_deadline_at"`
|
||||
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"`
|
||||
PreviousConversations []PreviousConversation `db:"-" json:"previous_conversations"`
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -117,13 +193,15 @@ type NewConversationsStats struct {
|
||||
|
||||
// Message represents a message in a conversation
|
||||
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"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
UUID string `db:"uuid" json:"uuid"`
|
||||
Type string `db:"type" json:"type"`
|
||||
Status string `db:"status" json:"status"`
|
||||
ConversationID int `db:"conversation_id" json:"conversation_id"`
|
||||
ConversationUUID string `db:"conversation_uuid" json:"conversation_uuid"`
|
||||
Content string `db:"content" json:"content"`
|
||||
TextContent string `db:"text_content" json:"text_content"`
|
||||
ContentType string `db:"content_type" json:"content_type"`
|
||||
@@ -134,7 +212,6 @@ type Message struct {
|
||||
InboxID int `db:"inbox_id" json:"-"`
|
||||
Meta json.RawMessage `db:"meta" json:"meta"`
|
||||
Attachments attachment.Attachments `db:"attachments" json:"attachments"`
|
||||
ConversationUUID string `db:"conversation_uuid" json:"-"`
|
||||
From string `db:"from" json:"-"`
|
||||
Subject string `db:"subject" json:"-"`
|
||||
Channel string `db:"channel" json:"-"`
|
||||
@@ -144,10 +221,9 @@ type Message struct {
|
||||
References []string `json:"-"`
|
||||
InReplyTo string `json:"-"`
|
||||
Headers textproto.MIMEHeader `json:"-"`
|
||||
AltContent string `db:"-" json:"-"`
|
||||
Media []mmodels.Media `db:"-" json:"-"`
|
||||
IsCSAT bool `db:"-" json:"-"`
|
||||
Total int `db:"total" json:"-"`
|
||||
AltContent string `json:"-"`
|
||||
Media []mmodels.Media `json:"-"`
|
||||
IsCSAT bool `json:"-"`
|
||||
}
|
||||
|
||||
// CensorCSATContent redacts the content of a CSAT message to prevent leaking the CSAT survey public link.
|
||||
|
@@ -99,6 +99,8 @@ SELECT
|
||||
c.closed_at,
|
||||
c.resolved_at,
|
||||
c.inbox_id,
|
||||
c.assignee_last_seen_at,
|
||||
inb.name as inbox_name,
|
||||
COALESCE(inb.from, '') as inbox_mail,
|
||||
COALESCE(inb.channel::TEXT, '') as inbox_channel,
|
||||
c.status_id,
|
||||
@@ -140,7 +142,6 @@ SELECT
|
||||
ct.phone_number as "contact.phone_number",
|
||||
ct.phone_number_calling_code as "contact.phone_number_calling_code",
|
||||
ct.custom_attributes as "contact.custom_attributes",
|
||||
ct.avatar_url as "contact.avatar_url",
|
||||
ct.enabled as "contact.enabled",
|
||||
ct.last_active_at as "contact.last_active_at",
|
||||
ct.last_login_at as "contact.last_login_at",
|
||||
@@ -183,8 +184,11 @@ SELECT
|
||||
FROM conversations c
|
||||
WHERE c.created_at > $1;
|
||||
|
||||
-- name: get-contact-conversations
|
||||
-- name: get-contact-previous-conversations
|
||||
SELECT
|
||||
c.id,
|
||||
c.created_at,
|
||||
c.updated_at,
|
||||
c.uuid,
|
||||
u.first_name AS "contact.first_name",
|
||||
u.last_name AS "contact.last_name",
|
||||
@@ -195,7 +199,7 @@ FROM users u
|
||||
JOIN conversations c ON c.contact_id = u.id
|
||||
WHERE c.contact_id = $1
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT 10;
|
||||
LIMIT $2;
|
||||
|
||||
-- name: get-conversation-uuid
|
||||
SELECT uuid from conversations where id = $1;
|
||||
@@ -400,22 +404,27 @@ LIMIT $2;
|
||||
|
||||
-- name: get-outgoing-pending-messages
|
||||
SELECT
|
||||
m.created_at,
|
||||
m.id,
|
||||
m.uuid,
|
||||
m.sender_id,
|
||||
m.type,
|
||||
m.private,
|
||||
m.created_at,
|
||||
m.updated_at,
|
||||
m.status,
|
||||
m.type,
|
||||
m.content,
|
||||
m.text_content,
|
||||
m.content_type,
|
||||
m.conversation_id,
|
||||
m.uuid,
|
||||
m.private,
|
||||
m.sender_type,
|
||||
m.sender_id,
|
||||
m.meta,
|
||||
c.uuid as conversation_uuid,
|
||||
m.content_type,
|
||||
m.source_id,
|
||||
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->'to')) AS to,
|
||||
c.inbox_id,
|
||||
c.uuid as conversation_uuid,
|
||||
c.subject
|
||||
FROM conversation_messages m
|
||||
INNER JOIN conversations c ON c.id = m.conversation_id
|
||||
@@ -463,16 +472,21 @@ ORDER BY m.created_at;
|
||||
-- name: get-messages
|
||||
SELECT
|
||||
COUNT(*) OVER() AS total,
|
||||
m.id,
|
||||
m.created_at,
|
||||
m.updated_at,
|
||||
m.status,
|
||||
m.type,
|
||||
m.content,
|
||||
m.text_content,
|
||||
m.content_type,
|
||||
m.conversation_id,
|
||||
m.uuid,
|
||||
m.private,
|
||||
m.sender_id,
|
||||
m.sender_type,
|
||||
m.meta,
|
||||
$1::uuid AS conversation_uuid,
|
||||
COALESCE(
|
||||
(SELECT json_agg(
|
||||
json_build_object(
|
||||
|
@@ -93,7 +93,7 @@ func (m *Manager) UpdateResponse(uuid string, score int, feedback string) error
|
||||
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)
|
||||
}
|
||||
|
||||
|
@@ -10,11 +10,11 @@ import (
|
||||
// CSATResponse represents a customer satisfaction survey response.
|
||||
type CSATResponse struct {
|
||||
ID int `db:"id"`
|
||||
UUID string `db:"uuid"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
UUID string `db:"uuid"`
|
||||
ConversationID int `db:"conversation_id"`
|
||||
Score int `db:"rating"`
|
||||
Rating int `db:"rating"`
|
||||
Feedback null.String `db:"feedback"`
|
||||
ResponseTimestamp null.Time `db:"response_timestamp"`
|
||||
}
|
||||
|
@@ -10,9 +10,9 @@ type CustomAttribute 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"`
|
||||
AppliesTo string `db:"applies_to" json:"applies_to"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Description string `db:"description" json:"description"`
|
||||
AppliesTo string `db:"applies_to" json:"applies_to"`
|
||||
Key string `db:"key" json:"key"`
|
||||
Values pq.StringArray `db:"values" json:"values"`
|
||||
DataType string `db:"data_type" json:"data_type"`
|
||||
|
@@ -3,9 +3,9 @@ SELECT
|
||||
id,
|
||||
created_at,
|
||||
updated_at,
|
||||
applies_to,
|
||||
name,
|
||||
description,
|
||||
applies_to,
|
||||
key,
|
||||
values,
|
||||
data_type,
|
||||
@@ -25,9 +25,9 @@ SELECT
|
||||
id,
|
||||
created_at,
|
||||
updated_at,
|
||||
applies_to,
|
||||
name,
|
||||
description,
|
||||
applies_to,
|
||||
key,
|
||||
values,
|
||||
data_type,
|
||||
|
@@ -2,8 +2,6 @@
|
||||
package envelope
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
@@ -53,13 +51,13 @@ func NewError(etype string, message string, data interface{}) error {
|
||||
case GeneralError:
|
||||
err.Code = fasthttp.StatusInternalServerError
|
||||
case PermissionError:
|
||||
err.Code = http.StatusForbidden
|
||||
err.Code = fasthttp.StatusForbidden
|
||||
case InputError:
|
||||
err.Code = fasthttp.StatusBadRequest
|
||||
case DataError:
|
||||
err.Code = http.StatusBadGateway
|
||||
err.Code = fasthttp.StatusUnprocessableEntity
|
||||
case NetworkError:
|
||||
err.Code = http.StatusGatewayTimeout
|
||||
err.Code = fasthttp.StatusGatewayTimeout
|
||||
case NotFoundError:
|
||||
err.Code = fasthttp.StatusNotFound
|
||||
case ConflictError:
|
||||
|
@@ -1,8 +1,8 @@
|
||||
-- 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
|
||||
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
|
||||
INSERT INTO inboxes
|
||||
@@ -11,7 +11,7 @@ VALUES($1, $2, $3, $4, $5)
|
||||
RETURNING *
|
||||
|
||||
-- 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
|
||||
UPDATE inboxes
|
||||
@@ -20,7 +20,7 @@ where id = $1 and deleted_at is NULL
|
||||
RETURNING *;
|
||||
|
||||
-- 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
|
||||
UPDATE inboxes
|
||||
|
@@ -12,11 +12,11 @@ type Macro struct {
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
Name string `db:"name" json:"name"`
|
||||
MessageContent string `db:"message_content" json:"message_content"`
|
||||
VisibleWhen pq.StringArray `db:"visible_when" json:"visible_when"`
|
||||
Actions json.RawMessage `db:"actions" json:"actions"`
|
||||
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"`
|
||||
TeamID *int `db:"team_id" json:"team_id,string"`
|
||||
UsageCount int `db:"usage_count" json:"usage_count"`
|
||||
Actions json.RawMessage `db:"actions" json:"actions"`
|
||||
}
|
||||
|
@@ -1,15 +1,15 @@
|
||||
-- name: get
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
message_content,
|
||||
created_at,
|
||||
updated_at,
|
||||
name,
|
||||
actions,
|
||||
visibility,
|
||||
visible_when,
|
||||
message_content,
|
||||
user_id,
|
||||
team_id,
|
||||
actions,
|
||||
visible_when,
|
||||
usage_count
|
||||
FROM
|
||||
macros
|
||||
@@ -19,15 +19,15 @@ WHERE
|
||||
-- name: get-all
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
message_content,
|
||||
created_at,
|
||||
updated_at,
|
||||
name,
|
||||
actions,
|
||||
visibility,
|
||||
visible_when,
|
||||
message_content,
|
||||
user_id,
|
||||
team_id,
|
||||
actions,
|
||||
visible_when,
|
||||
usage_count
|
||||
FROM
|
||||
macros
|
||||
@@ -67,7 +67,6 @@ WHERE
|
||||
UPDATE
|
||||
macros
|
||||
SET
|
||||
usage_count = usage_count + 1,
|
||||
updated_at = NOW()
|
||||
usage_count = usage_count + 1
|
||||
WHERE
|
||||
id = $1;
|
@@ -214,6 +214,7 @@ func (m *Manager) deleteUnlinkedMessageMedia() error {
|
||||
m.lo.Error("error deleting unlinked media", "error", err)
|
||||
continue
|
||||
}
|
||||
// TODO: If it's an image also delete the `thumb_uuid` image.
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
@@ -1,31 +1,37 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/volatiletech/null/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
// TODO: pick these table names from their respective package/models/models.go
|
||||
ModelMessages = "messages"
|
||||
ModelUser = "users"
|
||||
|
||||
DispositionInline = "inline"
|
||||
)
|
||||
|
||||
// Media represents an uploaded object.
|
||||
// Media represents an uploaded object in DB and storage backend.
|
||||
type Media struct {
|
||||
ID int `db:"id" json:"id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UUID string `db:"uuid" json:"uuid"`
|
||||
Filename string `db:"filename" json:"filename"`
|
||||
ContentType string `db:"content_type" json:"content_type"`
|
||||
Model null.String `db:"model_type" json:"-"`
|
||||
ModelID null.Int `db:"model_id" json:"-"`
|
||||
Size int `db:"size" json:"size"`
|
||||
Store string `db:"store" json:"store"`
|
||||
Disposition null.String `db:"disposition" json:"disposition"`
|
||||
URL string `json:"url"`
|
||||
ContentID string `json:"-"`
|
||||
Content []byte `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"`
|
||||
Store string `db:"store" json:"store"`
|
||||
Filename string `db:"filename" json:"filename"`
|
||||
ContentType string `db:"content_type" json:"content_type"`
|
||||
ContentID string `db:"content_id" json:"content_id"`
|
||||
ModelID null.Int `db:"model_id" json:"model_id"`
|
||||
Model null.String `db:"model_type" json:"model_type"`
|
||||
Disposition null.String `db:"disposition" json:"disposition"`
|
||||
Size int `db:"size" json:"size"`
|
||||
Meta json.RawMessage `db:"meta" json:"meta"`
|
||||
|
||||
// Pseudo fields
|
||||
URL string `json:"url"`
|
||||
Content []byte `json:"-"`
|
||||
}
|
||||
|
@@ -15,7 +15,7 @@ VALUES(
|
||||
RETURNING id;
|
||||
|
||||
-- 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
|
||||
WHERE
|
||||
($1 > 0 AND id = $1)
|
||||
@@ -23,7 +23,7 @@ WHERE
|
||||
($2 != '' AND uuid = $2::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
|
||||
WHERE uuid = $1;
|
||||
|
||||
@@ -38,13 +38,13 @@ SET model_type = $2,
|
||||
WHERE id = $1;
|
||||
|
||||
-- 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
|
||||
WHERE model_type = $1
|
||||
AND model_id = $2;
|
||||
|
||||
-- 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
|
||||
WHERE model_type = 'messages'
|
||||
AND (model_id IS NULL OR model_id = 0)
|
||||
|
@@ -4,6 +4,12 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// providerLogos holds known provider logos.
|
||||
var providerLogos = map[string]string{
|
||||
"Google": "/images/google-logo.png",
|
||||
"Custom": "",
|
||||
}
|
||||
|
||||
// OIDC represents an OpenID Connect configuration.
|
||||
type OIDC struct {
|
||||
ID int `db:"id" json:"id"`
|
||||
@@ -19,12 +25,6 @@ type OIDC struct {
|
||||
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.
|
||||
func (oidc *OIDC) SetProviderLogo() {
|
||||
for provider, logo := range providerLogos {
|
||||
|
@@ -38,10 +38,9 @@ type Opts struct {
|
||||
|
||||
// queries contains prepared SQL queries.
|
||||
type queries struct {
|
||||
GetAllOIDC *sqlx.Stmt `query:"get-all-oidc"`
|
||||
GetAllEnabled *sqlx.Stmt `query:"get-all-enabled"`
|
||||
GetOIDC *sqlx.Stmt `query:"get-oidc"`
|
||||
InsertOIDC *sqlx.Stmt `query:"insert-oidc"`
|
||||
GetAllOIDC *sqlx.Stmt `query:"get-all-oidc"`
|
||||
GetOIDC *sqlx.Stmt `query:"get-oidc"`
|
||||
InsertOIDC *sqlx.Stmt `query:"insert-oidc"`
|
||||
UpdateOIDC *sqlx.Stmt `query:"update-oidc"`
|
||||
DeleteOIDC *sqlx.Stmt `query:"delete-oidc"`
|
||||
}
|
||||
@@ -111,19 +110,6 @@ func (o *Manager) GetAll() ([]models.OIDC, error) {
|
||||
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.
|
||||
func (o *Manager) Create(oidc models.OIDC) (models.OIDC, error) {
|
||||
var createdOIDC models.OIDC
|
||||
|
@@ -1,11 +1,8 @@
|
||||
-- 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;
|
||||
|
||||
-- name: get-all-enabled
|
||||
SELECT id, name, enabled, provider, client_id, updated_at FROM oidc WHERE enabled = true 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-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
|
||||
INSERT INTO oidc (name, provider, provider_url, client_id, client_secret)
|
||||
|
@@ -2,7 +2,7 @@
|
||||
SELECT id, created_at, updated_at, name, description, permissions FROM roles;
|
||||
|
||||
-- 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
|
||||
DELETE FROM roles where id = $1;
|
||||
|
@@ -153,7 +153,7 @@ func (u *Manager) filterValidPermissions(permissions []string) ([]string, error)
|
||||
if amodels.PermissionExists(perm) {
|
||||
validPermissions = append(validPermissions, perm)
|
||||
} else {
|
||||
u.lo.Warn("ignoring unknown permission", "permission", perm)
|
||||
u.lo.Warn("skipping unknown permission for role", "permission", perm)
|
||||
}
|
||||
}
|
||||
return validPermissions, nil
|
||||
|
@@ -2,14 +2,14 @@ package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Conversation struct {
|
||||
type ConversationResult struct {
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UUID string `db:"uuid" json:"uuid"`
|
||||
ReferenceNumber string `db:"reference_number" json:"reference_number"`
|
||||
Subject string `db:"subject" json:"subject"`
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
type MessageResult struct {
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
TextContent string `db:"text_content" json:"text_content"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type Contact struct {
|
||||
type ContactResult struct {
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
FirstName string `db:"first_name" json:"first_name"`
|
||||
LastName string `db:"last_name" json:"last_name"`
|
||||
|
@@ -13,73 +13,73 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed queries.sql
|
||||
efs embed.FS
|
||||
//go:embed queries.sql
|
||||
efs embed.FS
|
||||
)
|
||||
|
||||
// Manager is the search manager
|
||||
type Manager struct {
|
||||
q queries
|
||||
lo *logf.Logger
|
||||
i18n *i18n.I18n
|
||||
q queries
|
||||
lo *logf.Logger
|
||||
i18n *i18n.I18n
|
||||
}
|
||||
|
||||
// Opts contains the options for creating a new search manager
|
||||
type Opts struct {
|
||||
DB *sqlx.DB
|
||||
Lo *logf.Logger
|
||||
I18n *i18n.I18n
|
||||
DB *sqlx.DB
|
||||
Lo *logf.Logger
|
||||
I18n *i18n.I18n
|
||||
}
|
||||
|
||||
// queries contains all the prepared queries
|
||||
type queries struct {
|
||||
SearchConversationsByRefNum *sqlx.Stmt `query:"search-conversations-by-reference-number"`
|
||||
SearchConversationsByContactEmail *sqlx.Stmt `query:"search-conversations-by-contact-email"`
|
||||
SearchMessages *sqlx.Stmt `query:"search-messages"`
|
||||
SearchContacts *sqlx.Stmt `query:"search-contacts"`
|
||||
SearchConversationsByRefNum *sqlx.Stmt `query:"search-conversations-by-reference-number"`
|
||||
SearchConversationsByContactEmail *sqlx.Stmt `query:"search-conversations-by-contact-email"`
|
||||
SearchMessages *sqlx.Stmt `query:"search-messages"`
|
||||
SearchContacts *sqlx.Stmt `query:"search-contacts"`
|
||||
}
|
||||
|
||||
// New creates a new search manager
|
||||
func New(opts Opts) (*Manager, error) {
|
||||
var q queries
|
||||
if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Manager{q: q, lo: opts.Lo, i18n: opts.I18n}, nil
|
||||
var q queries
|
||||
if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Manager{q: q, lo: opts.Lo, i18n: opts.I18n}, nil
|
||||
}
|
||||
|
||||
// Conversations searches conversations based on the query
|
||||
func (s *Manager) Conversations(query string) ([]models.Conversation, error) {
|
||||
var refNumResults = make([]models.Conversation, 0)
|
||||
if err := s.q.SearchConversationsByRefNum.Select(&refNumResults, query); err != nil {
|
||||
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)
|
||||
}
|
||||
func (s *Manager) Conversations(query string) ([]models.ConversationResult, error) {
|
||||
var refNumResults = make([]models.ConversationResult, 0)
|
||||
if err := s.q.SearchConversationsByRefNum.Select(&refNumResults, query); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
var emailResults = make([]models.Conversation, 0)
|
||||
if err := s.q.SearchConversationsByContactEmail.Select(&emailResults, query); err != nil {
|
||||
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 append(refNumResults, emailResults...), nil
|
||||
var emailResults = make([]models.ConversationResult, 0)
|
||||
if err := s.q.SearchConversationsByContactEmail.Select(&emailResults, query); err != nil {
|
||||
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 append(refNumResults, emailResults...), nil
|
||||
}
|
||||
|
||||
// Messages searches messages based on the query
|
||||
func (s *Manager) Messages(query string) ([]models.Message, error) {
|
||||
var results = make([]models.Message, 0)
|
||||
if err := s.q.SearchMessages.Select(&results, query); err != nil {
|
||||
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 results, nil
|
||||
func (s *Manager) Messages(query string) ([]models.MessageResult, error) {
|
||||
var results = make([]models.MessageResult, 0)
|
||||
if err := s.q.SearchMessages.Select(&results, query); err != nil {
|
||||
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 results, nil
|
||||
}
|
||||
|
||||
// Contacts searches contacts based on the query
|
||||
func (s *Manager) Contacts(query string) ([]models.Contact, error) {
|
||||
var results = make([]models.Contact, 0)
|
||||
if err := s.q.SearchContacts.Select(&results, query); err != nil {
|
||||
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 results, nil
|
||||
}
|
||||
func (s *Manager) Contacts(query string) ([]models.ContactResult, error) {
|
||||
var results = make([]models.ContactResult, 0)
|
||||
if err := s.q.SearchContacts.Select(&results, query); err != nil {
|
||||
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 results, nil
|
||||
}
|
||||
|
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/abhinavxd/libredesk/internal/setting/models"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/jmoiron/sqlx/types"
|
||||
"github.com/knadh/go-i18n"
|
||||
"github.com/zerodha/logf"
|
||||
)
|
||||
|
||||
@@ -22,16 +21,14 @@ var (
|
||||
|
||||
// Manager handles setting-related operations.
|
||||
type Manager struct {
|
||||
q queries
|
||||
lo *logf.Logger
|
||||
i18n *i18n.I18n
|
||||
q queries
|
||||
lo *logf.Logger
|
||||
}
|
||||
|
||||
// Opts contains options for initializing the Manager.
|
||||
type Opts struct {
|
||||
DB *sqlx.DB
|
||||
Lo *logf.Logger
|
||||
I18n *i18n.I18n
|
||||
DB *sqlx.DB
|
||||
Lo *logf.Logger
|
||||
}
|
||||
|
||||
// queries contains prepared SQL queries.
|
||||
@@ -51,9 +48,8 @@ func New(opts Opts) (*Manager, error) {
|
||||
}
|
||||
|
||||
return &Manager{
|
||||
q: q,
|
||||
lo: opts.Lo,
|
||||
i18n: opts.I18n,
|
||||
q: q,
|
||||
lo: opts.Lo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -85,15 +81,15 @@ func (m *Manager) GetAllJSON() (types.JSONText, error) {
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// Update updates settings.
|
||||
func (m *Manager) Update(s interface{}) error {
|
||||
// Update updates settings with the passed values.
|
||||
func (m *Manager) Update(s any) error {
|
||||
// Marshal settings.
|
||||
b, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
m.lo.Error("error marshalling settings", "error", err)
|
||||
return envelope.NewError(
|
||||
envelope.GeneralError,
|
||||
m.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.setting}"),
|
||||
"Error marshalling settings",
|
||||
nil,
|
||||
)
|
||||
}
|
||||
@@ -102,21 +98,21 @@ func (m *Manager) Update(s interface{}) error {
|
||||
m.lo.Error("error updating settings", "error", err)
|
||||
return envelope.NewError(
|
||||
envelope.GeneralError,
|
||||
m.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.setting}"),
|
||||
"Error updating settings",
|
||||
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) {
|
||||
var b types.JSONText
|
||||
if err := m.q.GetByPrefix.Get(&b, prefix+"%"); err != nil {
|
||||
m.lo.Error("error fetching settings", "prefix", prefix, "error", err)
|
||||
return b, envelope.NewError(
|
||||
envelope.GeneralError,
|
||||
m.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.setting}"),
|
||||
"Error fetching settings",
|
||||
nil,
|
||||
)
|
||||
}
|
||||
@@ -130,7 +126,7 @@ func (m *Manager) Get(key string) (types.JSONText, error) {
|
||||
m.lo.Error("error fetching setting", "key", key, "error", err)
|
||||
return b, envelope.NewError(
|
||||
envelope.GeneralError,
|
||||
m.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.setting}"),
|
||||
"Error fetching settings",
|
||||
nil,
|
||||
)
|
||||
}
|
||||
@@ -144,7 +140,7 @@ func (m *Manager) GetAppRootURL() (string, error) {
|
||||
m.lo.Error("error fetching root URL", "error", err)
|
||||
return "", envelope.NewError(
|
||||
envelope.GeneralError,
|
||||
m.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.appRootURL}"),
|
||||
"Error fetching root URL",
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
@@ -144,13 +144,6 @@ func GenerateEmailMessageID(messageID string, fromAddress string) (string, error
|
||||
), 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.
|
||||
func RemoveItemByValue(slice []string, value string) []string {
|
||||
result := []string{}
|
||||
|
@@ -5,46 +5,6 @@ import (
|
||||
"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) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
@@ -15,17 +15,37 @@ type Team struct {
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
Emoji null.String `db:"emoji" json:"emoji"`
|
||||
Name string `db:"name" json:"name"`
|
||||
ConversationAssignmentType string `db:"conversation_assignment_type" json:"conversation_assignment_type,omitempty"`
|
||||
Timezone string `db:"timezone" json:"timezone,omitempty"`
|
||||
BusinessHoursID null.Int `db:"business_hours_id" json:"business_hours_id,omitempty"`
|
||||
SLAPolicyID null.Int `db:"sla_policy_id" json:"sla_policy_id,omitempty"`
|
||||
ConversationAssignmentType string `db:"conversation_assignment_type" json:"conversation_assignment_type"`
|
||||
Timezone string `db:"timezone" json:"timezone"`
|
||||
BusinessHoursID null.Int `db:"business_hours_id" json:"business_hours_id"`
|
||||
SLAPolicyID null.Int `db:"sla_policy_id" json:"sla_policy_id"`
|
||||
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
|
||||
func (t *Teams) Scan(src interface{}) error {
|
||||
func (t *TeamsCompact) Scan(src interface{}) error {
|
||||
if src == nil {
|
||||
*t = nil
|
||||
return nil
|
||||
@@ -40,24 +60,6 @@ func (t *Teams) Scan(src interface{}) error {
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
@@ -1,14 +1,14 @@
|
||||
-- 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
|
||||
SELECT id, name, emoji from teams order by name;
|
||||
|
||||
-- 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
|
||||
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
|
||||
SELECT u.id, t.id as team_id, u.availability_status
|
||||
|
@@ -10,7 +10,6 @@ import (
|
||||
"github.com/abhinavxd/libredesk/internal/dbutil"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/abhinavxd/libredesk/internal/team/models"
|
||||
umodels "github.com/abhinavxd/libredesk/internal/user/models"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/go-i18n"
|
||||
"github.com/lib/pq"
|
||||
@@ -78,8 +77,8 @@ func (u *Manager) GetAll() ([]models.Team, error) {
|
||||
}
|
||||
|
||||
// GetAllCompact retrieves all teams with limited fields.
|
||||
func (u *Manager) GetAllCompact() ([]models.Team, error) {
|
||||
var teams = make([]models.Team, 0)
|
||||
func (u *Manager) GetAllCompact() ([]models.TeamCompact, error) {
|
||||
var teams = make([]models.TeamCompact, 0)
|
||||
if err := u.q.GetTeamsCompact.Select(&teams); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return teams, nil
|
||||
@@ -169,14 +168,14 @@ func (u *Manager) UserBelongsToTeam(teamID, userID int) (bool, error) {
|
||||
}
|
||||
|
||||
// GetMembers retrieves members of a team.
|
||||
func (u *Manager) GetMembers(id int) ([]umodels.User, error) {
|
||||
var users []umodels.User
|
||||
if err := u.q.GetTeamMembers.Select(&users, id); err != nil {
|
||||
func (u *Manager) GetMembers(id int) ([]models.TeamMember, error) {
|
||||
var members = make([]models.TeamMember, 0)
|
||||
if err := u.q.GetTeamMembers.Select(&members, id); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return users, nil
|
||||
return members, nil
|
||||
}
|
||||
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
|
||||
}
|
||||
|
@@ -19,19 +19,19 @@ WITH u AS (
|
||||
SELECT * FROM u LIMIT 1;
|
||||
|
||||
-- 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
|
||||
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
|
||||
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
|
||||
DELETE FROM templates WHERE id = $1;
|
||||
|
||||
-- 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
|
||||
SELECT EXISTS(SELECT 1 FROM templates WHERE id = $1 AND is_builtin is TRUE);
|
@@ -2,8 +2,6 @@ package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -69,13 +67,10 @@ func (u *Manager) InvalidateAgentCache(id int) {
|
||||
delete(u.agentCache, id)
|
||||
}
|
||||
|
||||
// GetAgentsCompact returns a compact list of users with limited fields.
|
||||
func (u *Manager) GetAgentsCompact() ([]models.User, error) {
|
||||
var users = make([]models.User, 0)
|
||||
if err := u.q.GetAgentsCompact.Select(&users); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return users, nil
|
||||
}
|
||||
// GetAgentsCompact returns a compact list of agents with limited fields.
|
||||
func (u *Manager) GetAgentsCompact() ([]models.UserCompact, error) {
|
||||
var users = make([]models.UserCompact, 0)
|
||||
if err := u.db.Select(&users, u.q.GetUsersCompact, pq.Array([]string{models.UserTypeAgent})); err != nil {
|
||||
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)
|
||||
}
|
||||
@@ -83,36 +78,39 @@ func (u *Manager) GetAgentsCompact() ([]models.User, error) {
|
||||
}
|
||||
|
||||
// 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()
|
||||
if err != nil {
|
||||
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) {
|
||||
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)
|
||||
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.
|
||||
func (u *Manager) UpdateAgent(id int, user models.User) error {
|
||||
// UpdateAgent updates an agent with individual field parameters
|
||||
func (u *Manager) UpdateAgent(id int, firstName, lastName, email string, roles []string, enabled bool, availabilityStatus, newPassword string) error {
|
||||
var (
|
||||
hashedPassword any
|
||||
err error
|
||||
)
|
||||
|
||||
// Set password?
|
||||
if user.NewPassword != "" {
|
||||
if !IsStrongPassword(user.NewPassword) {
|
||||
if newPassword != "" {
|
||||
if !IsStrongPassword(newPassword) {
|
||||
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 {
|
||||
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)
|
||||
@@ -121,7 +119,10 @@ func (u *Manager) UpdateAgent(id int, user models.User) error {
|
||||
}
|
||||
|
||||
// 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)
|
||||
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.
|
||||
func (u *Manager) GetAgents() ([]models.User, error) {
|
||||
func (u *Manager) GetAgents() ([]models.UserCompact, error) {
|
||||
// Some dirty hack.
|
||||
return u.GetAllUsers(1, 999999999, models.UserTypeAgent, "desc", "users.updated_at", "")
|
||||
}
|
||||
|
@@ -42,7 +42,7 @@ func (u *Manager) GetContact(id int, email string) (models.User, error) {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return nil, envelope.NewError(envelope.InputError, u.i18n.Ts("globals.messages.pageTooLarge", "max", fmt.Sprintf("%d", maxListPageSize)), nil)
|
||||
}
|
||||
|
@@ -30,40 +30,51 @@ const (
|
||||
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 {
|
||||
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"`
|
||||
PhoneNumberCallingCode null.String `db:"phone_number_calling_code" json:"phone_number_calling_code"`
|
||||
PhoneNumber null.String `db:"phone_number" json:"phone_number"`
|
||||
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
|
||||
Enabled bool `db:"enabled" json:"enabled"`
|
||||
Password string `db:"password" json:"-"`
|
||||
LastActiveAt null.Time `db:"last_active_at" json:"last_active_at"`
|
||||
LastLoginAt null.Time `db:"last_login_at" json:"last_login_at"`
|
||||
Roles pq.StringArray `db:"roles" json:"roles"`
|
||||
Permissions pq.StringArray `db:"permissions" json:"permissions"`
|
||||
Meta pq.StringArray `db:"meta" json:"meta"`
|
||||
CustomAttributes json.RawMessage `db:"custom_attributes" json:"custom_attributes"`
|
||||
Teams tmodels.Teams `db:"teams" json:"teams"`
|
||||
ContactChannelID int `db:"contact_channel_id" json:"contact_channel_id,omitempty"`
|
||||
NewPassword string `db:"-" json:"new_password,omitempty"`
|
||||
SendWelcomeEmail bool `db:"-" json:"send_welcome_email,omitempty"`
|
||||
InboxID int `json:"-"`
|
||||
SourceChannel null.String `json:"-"`
|
||||
SourceChannelID null.String `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"`
|
||||
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"`
|
||||
PhoneNumberCallingCode null.String `db:"phone_number_calling_code" json:"phone_number_calling_code"`
|
||||
PhoneNumber null.String `db:"phone_number" json:"phone_number"`
|
||||
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
|
||||
Enabled bool `db:"enabled" json:"enabled"`
|
||||
Password string `db:"password" json:"-"`
|
||||
LastActiveAt null.Time `db:"last_active_at" json:"last_active_at"`
|
||||
LastLoginAt null.Time `db:"last_login_at" json:"last_login_at"`
|
||||
Roles pq.StringArray `db:"roles" json:"roles"`
|
||||
Permissions pq.StringArray `db:"permissions" json:"permissions"`
|
||||
CustomAttributes json.RawMessage `db:"custom_attributes" json:"custom_attributes"`
|
||||
Teams tmodels.TeamsCompact `db:"teams" json:"teams"`
|
||||
ContactChannelID int `db:"contact_channel_id" json:"contact_channel_id,omitempty"`
|
||||
NewPassword string `db:"-" json:"new_password,omitempty"`
|
||||
SendWelcomeEmail bool `db:"-" json:"send_welcome_email,omitempty"`
|
||||
InboxID int `json:"-"`
|
||||
SourceChannel null.String `json:"-"`
|
||||
SourceChannelID null.String `json:"-"`
|
||||
|
||||
// API Key fields
|
||||
APIKey null.String `db:"api_key" json:"api_key"`
|
||||
APIKeyLastUsedAt null.Time `db:"api_key_last_used_at" json:"api_key_last_used_at"`
|
||||
APISecret null.String `db:"api_secret" json:"-"`
|
||||
|
||||
Total int `json:"total,omitempty"`
|
||||
}
|
||||
|
||||
type Note struct {
|
||||
|
@@ -26,12 +26,13 @@ func (u *Manager) GetNote(id int) (models.Note, error) {
|
||||
}
|
||||
|
||||
// CreateNote creates a new note for a user.
|
||||
func (u *Manager) CreateNote(userID, authorID int, note string) error {
|
||||
if _, err := u.q.InsertNote.Exec(userID, authorID, note); err != nil {
|
||||
func (u *Manager) CreateNote(userID, authorID int, note string) (models.Note, error) {
|
||||
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)
|
||||
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.
|
||||
|
@@ -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
|
||||
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
|
||||
WITH soft_delete AS (
|
||||
@@ -23,12 +24,6 @@ delete_user_roles AS (
|
||||
)
|
||||
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
|
||||
SELECT
|
||||
u.id,
|
||||
@@ -37,8 +32,6 @@ SELECT
|
||||
u.email,
|
||||
u.password,
|
||||
u.type,
|
||||
u.created_at,
|
||||
u.updated_at,
|
||||
u.enabled,
|
||||
u.avatar_url,
|
||||
u.first_name,
|
||||
@@ -50,6 +43,7 @@ SELECT
|
||||
u.phone_number,
|
||||
u.api_key,
|
||||
u.api_key_last_used_at,
|
||||
u.api_secret,
|
||||
array_agg(DISTINCT r.name) FILTER (WHERE r.name IS NOT NULL) AS roles,
|
||||
COALESCE(
|
||||
(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
|
||||
UPDATE users
|
||||
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
|
||||
WITH inserted_user AS (
|
||||
@@ -202,7 +196,8 @@ ORDER BY cn.created_at DESC;
|
||||
|
||||
-- name: insert-note
|
||||
INSERT INTO contact_notes (contact_id, user_id, note)
|
||||
VALUES ($1, $2, $3);
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *;
|
||||
|
||||
-- name: delete-note
|
||||
DELETE FROM contact_notes
|
||||
@@ -229,6 +224,7 @@ SELECT
|
||||
u.created_at,
|
||||
u.updated_at,
|
||||
u.email,
|
||||
u.password,
|
||||
u.type,
|
||||
u.enabled,
|
||||
u.avatar_url,
|
||||
@@ -239,6 +235,8 @@ SELECT
|
||||
u.last_login_at,
|
||||
u.phone_number_calling_code,
|
||||
u.phone_number,
|
||||
u.api_key,
|
||||
u.api_key_last_used_at,
|
||||
u.api_secret,
|
||||
array_agg(DISTINCT r.name) FILTER (WHERE r.name IS NOT NULL) AS roles,
|
||||
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
|
||||
GROUP BY u.id;
|
||||
|
||||
-- name: generate-api-key
|
||||
-- name: set-api-key
|
||||
UPDATE users
|
||||
SET api_key = $2, api_secret = $3, api_key_last_used_at = NULL, updated_at = now()
|
||||
WHERE id = $1;
|
||||
|
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/abhinavxd/libredesk/internal/user/models"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/go-i18n"
|
||||
"github.com/lib/pq"
|
||||
"github.com/volatiletech/null/v9"
|
||||
"github.com/zerodha/logf"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -61,10 +62,9 @@ type Opts struct {
|
||||
// queries contains prepared SQL queries.
|
||||
type queries struct {
|
||||
GetUser *sqlx.Stmt `query:"get-user"`
|
||||
GetUsers string `query:"get-users"`
|
||||
GetNotes *sqlx.Stmt `query:"get-notes"`
|
||||
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"`
|
||||
UpdateAgent *sqlx.Stmt `query:"update-agent"`
|
||||
UpdateCustomAttributes *sqlx.Stmt `query:"update-custom-attributes"`
|
||||
@@ -84,7 +84,7 @@ type queries struct {
|
||||
ToggleEnable *sqlx.Stmt `query:"toggle-enable"`
|
||||
// API key queries
|
||||
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"`
|
||||
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) {
|
||||
var q queries
|
||||
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{
|
||||
q: q,
|
||||
@@ -121,7 +121,7 @@ func (u *Manager) VerifyPassword(email string, password []byte) (models.User, er
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
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()
|
||||
|
||||
// Execute query
|
||||
var users = make([]models.User, 0)
|
||||
var users = make([]models.UserCompact, 0)
|
||||
if err := tx.Select(&users, query, qArgs...); err != nil {
|
||||
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)
|
||||
@@ -186,6 +186,7 @@ func (u *Manager) UpdateLastLoginAt(id int) error {
|
||||
|
||||
// SetResetPasswordToken sets a reset password token for an user and returns the token.
|
||||
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)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if !IsStrongPassword(password) {
|
||||
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
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (u *Manager) ToggleEnabled(id int, typ string, enabled bool) error {
|
||||
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.
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
@@ -1,7 +1,6 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/lib/pq"
|
||||
@@ -37,10 +36,3 @@ const (
|
||||
// Test event
|
||||
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"`
|
||||
}
|
||||
|
@@ -5,7 +5,7 @@
|
||||
{{ if ne SiteName "" }}
|
||||
Welcome to {{ SiteName }}
|
||||
{{ else }}
|
||||
Welcome
|
||||
Welcome to Libredesk
|
||||
{{ end }}
|
||||
</h1>
|
||||
|
||||
|
@@ -183,7 +183,7 @@ footer.container {
|
||||
margin-top: 2rem;
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.70rem;
|
||||
}
|
||||
|
||||
footer a {
|
||||
|
@@ -2,10 +2,7 @@
|
||||
{{ template "header" . }}
|
||||
<div class="csat-container">
|
||||
<div class="csat-header">
|
||||
<h2>Rate your recent interaction</h2>
|
||||
{{ if .Data.Conversation.Subject }}
|
||||
<p class="conversation-subject"><i>{{ .Data.Conversation.Subject }}</i></p>
|
||||
{{ end }}
|
||||
<h2>{{ L.T "csat.rateYourInteraction" }}</h2>
|
||||
</div>
|
||||
|
||||
<form action="/csat/{{ .Data.CSAT.UUID }}" method="POST" class="csat-form" novalidate>
|
||||
@@ -16,7 +13,7 @@
|
||||
<div class="emoji-wrapper">
|
||||
<span class="emoji">😢</span>
|
||||
</div>
|
||||
<span class="rating-text">Poor</span>
|
||||
<span class="rating-text">{{ L.T "csat.rating.poor" }}</span>
|
||||
</label>
|
||||
|
||||
<input type="radio" id="rating-2" name="rating" value="2">
|
||||
@@ -24,7 +21,7 @@
|
||||
<div class="emoji-wrapper">
|
||||
<span class="emoji">😕</span>
|
||||
</div>
|
||||
<span class="rating-text">Fair</span>
|
||||
<span class="rating-text">{{ L.T "csat.rating.fair" }}</span>
|
||||
</label>
|
||||
|
||||
<input type="radio" id="rating-3" name="rating" value="3">
|
||||
@@ -32,7 +29,7 @@
|
||||
<div class="emoji-wrapper">
|
||||
<span class="emoji">😊</span>
|
||||
</div>
|
||||
<span class="rating-text">Good</span>
|
||||
<span class="rating-text">{{ L.T "csat.rating.good" }}</span>
|
||||
</label>
|
||||
|
||||
<input type="radio" id="rating-4" name="rating" value="4">
|
||||
@@ -40,7 +37,7 @@
|
||||
<div class="emoji-wrapper">
|
||||
<span class="emoji">😃</span>
|
||||
</div>
|
||||
<span class="rating-text">Great</span>
|
||||
<span class="rating-text">{{ L.T "csat.rating.great" }}</span>
|
||||
</label>
|
||||
|
||||
<input type="radio" id="rating-5" name="rating" value="5">
|
||||
@@ -48,18 +45,18 @@
|
||||
<div class="emoji-wrapper">
|
||||
<span class="emoji">🤩</span>
|
||||
</div>
|
||||
<span class="rating-text">Excellent</span>
|
||||
<span class="rating-text">{{ L.T "csat.rating.excellent" }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<!-- Validation message for rating -->
|
||||
<div class="validation-message" id="ratingValidationMessage"
|
||||
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 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"
|
||||
onkeyup="updateCharCount(this)"></textarea>
|
||||
<div class="char-counter">
|
||||
@@ -67,7 +64,7 @@
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -148,9 +145,9 @@
|
||||
.rating-options {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 25px;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
margin-top: 30px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rating-options input[type="radio"] {
|
||||
@@ -163,9 +160,10 @@
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
padding: 15px;
|
||||
padding: 12px;
|
||||
position: relative;
|
||||
width: 110px;
|
||||
min-width: 90px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rating-option:hover {
|
||||
@@ -173,7 +171,8 @@
|
||||
}
|
||||
|
||||
.rating-option:focus {
|
||||
outline: 2px solid #0055d4;
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -181,41 +180,33 @@
|
||||
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 {
|
||||
background: #f8f9ff;
|
||||
background: #f8fafc;
|
||||
border-radius: 50%;
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
width: 65px;
|
||||
height: 65px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 8px;
|
||||
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 {
|
||||
transform: scale(1.1);
|
||||
background: #f0f5ff;
|
||||
border-color: #0055d4;
|
||||
transform: scale(1.05);
|
||||
background: #f1f5f9;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 4px 8px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.rating-options input[type="radio"]:checked+.rating-option .emoji-wrapper {
|
||||
transform: scale(1.1);
|
||||
background: #e8f0ff;
|
||||
border-color: #0055d4;
|
||||
transform: scale(1.05);
|
||||
background: #dbeafe;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.emoji {
|
||||
@@ -225,10 +216,11 @@
|
||||
}
|
||||
|
||||
.rating-text {
|
||||
font-size: 0.9em;
|
||||
font-size: 0.85em;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.feedback-container {
|
||||
@@ -254,8 +246,9 @@
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
border-color: #0055d4;
|
||||
border-color: #3b82f6;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.char-counter {
|
||||
@@ -279,28 +272,23 @@
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 650px) {
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.csat-container {
|
||||
margin: 0;
|
||||
padding: 30px;
|
||||
padding: 20px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.rating-options {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rating-option {
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
gap: 15px;
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
min-width: 70px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.emoji-wrapper {
|
||||
margin-bottom: 0;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
@@ -310,7 +298,31 @@
|
||||
}
|
||||
|
||||
.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>
|
||||
|
@@ -31,7 +31,7 @@
|
||||
{{ define "footer" }}
|
||||
</div>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
|
Reference in New Issue
Block a user