Merge pull request #130 from abhinavxd/refactor-apis

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

63
cmd/config.go Normal file
View File

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

View File

@@ -164,11 +164,17 @@ func handleUpdateContact(r *fastglue.Request) error {
// Upload avatar?
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)
}

View File

@@ -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,14 +722,30 @@ 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.
// 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))
}
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.
if req.AssignedAgentID > 0 {
@@ -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
}

View File

@@ -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"),
},
})
}

View File

@@ -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"))

View File

@@ -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)
}

View File

@@ -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,
})
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)

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -83,7 +83,7 @@ func handleUpdateTeam(r *fastglue.Request) error {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil))
}
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)
}

View File

@@ -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{}
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
}

View File

@@ -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: [
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": [
{
id: 1,
name: 'Google',
logo_url: 'https://example.com/google-logo.png',
disabled: false
"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', () => {

View File

@@ -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'

View File

@@ -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,

View File

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

View File

@@ -4,9 +4,9 @@
@click="handleClick">
<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>

View File

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

View File

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

View File

@@ -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)
})

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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
}
}
})

View File

@@ -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
})

View File

@@ -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 || []

View File

@@ -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',

View File

@@ -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

View File

@@ -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',

View File

@@ -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
View File

@@ -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

View File

@@ -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"
}

View File

@@ -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 (

View File

@@ -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

View File

@@ -183,6 +183,7 @@ func (e *Enforcer) EnforceConversationAccess(user umodels.User, conversation cmo
// EnforceMediaAccess checks for read access on linked model to media.
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 {

View File

@@ -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

View File

@@ -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)

View File

@@ -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;

View File

@@ -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)

View File

@@ -167,7 +167,7 @@ func (m *Manager) sendOutgoingMessage(message models.Message) {
}
// References is sorted in DESC i.e newest message first, so reverse it to keep the references in order.
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

View File

@@ -52,16 +52,57 @@ 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"`
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,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"`
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"`
@@ -73,7 +114,6 @@ type Conversation struct {
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"`
@@ -83,17 +123,53 @@ type Conversation struct {
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"`
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"`
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:"-"`
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.

View File

@@ -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(

View File

@@ -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)
}

View File

@@ -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"`
}

View File

@@ -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"`

View File

@@ -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,

View File

@@ -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:

View File

@@ -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

View File

@@ -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"`
}

View File

@@ -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;

View File

@@ -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
}

View File

@@ -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"`
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"`
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"`
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"`
ContentID string `json:"-"`
Content []byte `json:"-"`
}

View File

@@ -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)

View File

@@ -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 {

View File

@@ -39,7 +39,6 @@ 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"`
UpdateOIDC *sqlx.Stmt `query:"update-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

View File

@@ -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)

View File

@@ -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;

View File

@@ -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

View File

@@ -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"`

View File

@@ -49,14 +49,14 @@ func New(opts Opts) (*Manager, error) {
}
// Conversations searches conversations based on the query
func (s *Manager) Conversations(query string) ([]models.Conversation, error) {
var refNumResults = make([]models.Conversation, 0)
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)
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)
@@ -65,8 +65,8 @@ func (s *Manager) Conversations(query string) ([]models.Conversation, error) {
}
// Messages searches messages based on the query
func (s *Manager) Messages(query string) ([]models.Message, error) {
var results = make([]models.Message, 0)
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)
@@ -75,8 +75,8 @@ func (s *Manager) Messages(query string) ([]models.Message, error) {
}
// Contacts searches contacts based on the query
func (s *Manager) Contacts(query string) ([]models.Contact, error) {
var results = make([]models.Contact, 0)
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)

View File

@@ -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"
)
@@ -24,14 +23,12 @@ var (
type Manager struct {
q queries
lo *logf.Logger
i18n *i18n.I18n
}
// Opts contains options for initializing the Manager.
type Opts struct {
DB *sqlx.DB
Lo *logf.Logger
I18n *i18n.I18n
}
// queries contains prepared SQL queries.
@@ -53,7 +50,6 @@ func New(opts Opts) (*Manager, error) {
return &Manager{
q: q,
lo: opts.Lo,
i18n: opts.I18n,
}, 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,
)
}

View File

@@ -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{}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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);

View File

@@ -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", "")
}

View File

@@ -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)
}

View File

@@ -30,6 +30,20 @@ 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"`
@@ -48,9 +62,8 @@ type User struct {
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"`
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"`
@@ -62,8 +75,6 @@ type User struct {
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 {

View File

@@ -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.

View File

@@ -1,7 +1,8 @@
-- name: get-users
-- name: get-users-compact
-- TODO: Remove hardcoded `type` of user in some queries in this file.
SELECT COUNT(*) OVER() as total, users.id, users.avatar_url, users.type, users.created_at, users.updated_at, users.first_name, users.last_name, users.email, users.enabled
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;

View File

@@ -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
}

View File

@@ -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"`
}

View File

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

View File

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

View File

@@ -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>

View File

@@ -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>