Compare commits

...

34 Commits

Author SHA1 Message Date
Abhinav Raut
98492a1869 refactor: use team compact struct for user teams list
- Check for existing email before agent update and raise proper error
2025-09-02 02:00:35 +05:30
Abhinav Raut
18b50b11c8 reduce footer font size for webtemplates 2025-09-02 01:10:03 +05:30
Abhinav Raut
5a1628f710 fix fetch general settings after user logs in 2025-09-02 00:57:05 +05:30
Abhinav Raut
12ebe32ba3 return complete contact note by refetching it using GetNote 2025-09-02 00:32:29 +05:30
Abhinav Raut
fce2587a9d remove unncessary margin from oidc provider logo
add alt attribute
2025-09-01 03:47:10 +05:30
Abhinav Raut
7d92ac9cce fix cypress test 2025-09-01 03:43:13 +05:30
Abhinav Raut
3ce3c5e0ee store public config in pinia store 2025-09-01 03:20:09 +05:30
Abhinav Raut
35ad00ec51 Add loading spinner to ConversationPlaceholder
Add missing i18n translation
2025-09-01 02:41:47 +05:30
Abhinav Raut
9ec96be959 rename AppUpdate component with AdminBanner
- show banner when app restart is required.
- UI changes to admin banner
2025-08-31 20:08:38 +05:30
Abhinav Raut
6ca36d611f add missing i18n key 2025-08-31 18:57:08 +05:30
Abhinav Raut
5a87d24d72 update var name 2025-08-31 18:55:31 +05:30
Abhinav Raut
7d4e7e68c3 update user avatar upload function to accept user by value and improve error logging by logging the user id 2025-08-31 18:48:02 +05:30
Abhinav Raut
5b941fd993 Apply suggestion from @Copilot
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-31 18:37:05 +05:30
Abhinav Raut
63e348e512 remove subject from csat page 2025-08-30 22:27:48 +05:30
Abhinav Raut
10a845dc81 fix cypress test 2025-08-30 22:27:13 +05:30
Abhinav Raut
0228989202 fix cypress test 2025-08-30 22:18:00 +05:30
Abhinav Raut
3f7d151d33 - add getting started flow for new users
- Translate web template pass i18n dependency
- Fix colors in menu card
- Show update description if avaialble in AppUpdate component
- Remvoe i18n from settings as i18n and settings depend on each other to load initial lang.
- Clear inbox password as the update SQL query now returns the config.
- Fetch agents and inboxes from the store instead of directly fetching using axios instance.
2025-08-30 21:30:24 +05:30
Abhinav Raut
a516773b14 feat: add i18n support to web templates
- Add i18n object to template funcMap for direct access
  - Translate all hardcoded strings in CSAT and footer templates
  - Add reusable translation keys to globals.messages
2025-08-30 19:35:00 +05:30
Abhinav Raut
f6d3bd543f refactor: consolidate public config into single endpoint, move settings behind auth
- remove OIDC enabled endpoint
2025-08-30 18:46:37 +05:30
Abhinav Raut
c1c14f7f54 refactor: split converstion list item and conversation into different structs
- Add missing columns in message queries
2025-08-29 00:15:22 +05:30
Abhinav Raut
634fc66e9f Translate welcome to libredesk email subject
- Update all SQL queries to add missing columns

- Update the create conversation API to allow setting the initiator of a conversation. For example, we might want to use this API to create a conversation on behalf of a customer, with the first message coming from the customer instead of the agent. This param allows this.

- Minor refactors and clean up

- Tidy go.mod

- Rename structs to reflect purpose

- Create focus structs for scanning JSON payloads for clarity.
2025-08-28 00:34:56 +05:30
Abhinav Raut
0dec822c1c fix panic due to missing i18n dependency 2025-08-26 01:12:30 +05:30
Abhinav Raut
958f5e38c0 Merge pull request #127 from abhinavxd/fix/empty-message-id
fix: handle malformed & empty Message-IDs with multiple @ symbols in IMAP processing
2025-08-20 04:51:51 +05:30
Abhinav Raut
550a3fa801 fix: update Message-ID determination logic to prefer IMAP-parsed IDs over raw headers 2025-08-20 04:26:23 +05:30
Abhinav Raut
6bbfbe8cf6 add test cases for imap msg id parsing 2025-08-20 04:18:37 +05:30
Abhinav Raut
f9ed326d72 fix: handle message empty message ids in imap 2025-08-20 03:29:16 +05:30
Abhinav Raut
e0dc0285a4 fix: agents availability status changing to online after doing an email password login even after being Away or in Reassinging Replies status.
This was not affecting OIDC login just email password login
2025-08-19 16:34:15 +05:30
Abhinav Raut
b971619ea6 use tabs for search results seperation also looks better now. 2025-08-14 16:12:29 +05:30
Abhinav Raut
69accaebef fix: conversations not reopening on reply from contacts (only when there's an attachment in the reply) else the convo would reopen, the conversation_uuid field was empty as it wasn't part of the get-messages query. 2025-08-14 16:11:27 +05:30
Abhinav Raut
27de73536e Update confirmed-bug.md 2025-07-23 00:18:36 +05:30
Abhinav Raut
df108a3363 feat: add confirmed and possible bug report templates 2025-07-23 00:14:34 +05:30
Abhinav Raut
266c3dab72 Merge pull request #121 from abhinavxd/dependabot/go_modules/golang.org/x/oauth2-0.27.0
chore(deps): bump golang.org/x/oauth2 from 0.21.0 to 0.27.0
2025-07-20 14:42:29 +05:30
dependabot[bot]
bf2c1fff6f chore(deps): bump golang.org/x/oauth2 from 0.21.0 to 0.27.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.21.0 to 0.27.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.21.0...v0.27.0)

---
updated-dependencies:
- dependency-name: golang.org/x/oauth2
  dependency-version: 0.27.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-20 09:11:03 +00:00
Abhinav Raut
2930af0c4f feat: add API getting started guide and update navigation 2025-07-07 01:06:28 +05:30
88 changed files with 1569 additions and 874 deletions

16
.github/ISSUE_TEMPLATE/confirmed-bug.md vendored Normal file
View File

@@ -0,0 +1,16 @@
---
name: Confirmed Bug Report
about: Report a confirmed bug in Libredesk
title: "[Bug] <brief summary>"
labels: bug
assignees: ""
---
**Version:**
- libredesk: [eg: v0.7.0]
**Description of the bug and steps to reproduce:**
A clear and concise description of what the bug is.
**Logs / Screenshots:**
Attach any relevant logs or screenshots to help diagnose the issue.

16
.github/ISSUE_TEMPLATE/possible-bug.md vendored Normal file
View File

@@ -0,0 +1,16 @@
---
name: Possible Bug Report
about: Something in Libredesk might be broken but needs confirmation
title: "[Possible Bug] <brief summary>"
labels: bug, needs-investigation
assignees: ""
---
**Version:**
- libredesk: [eg: v0.7.0]
**Description of the bug and steps to reproduce:**
A clear and concise description of what the bug is.
**Logs / Screenshots:**
Attach any relevant logs or screenshots to help diagnose the issue.

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,13 +722,29 @@ func handleCreateConversation(r *fastglue.Request) error {
media = append(media, m)
}
// Send reply to the created conversation.
if _, err := app.conversation.SendReply(media, req.InboxID, auser.ID /**sender_id**/, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil {
// Delete the conversation if reply fails.
if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
app.lo.Error("error deleting conversation", "error", err)
// Send initial message based on the initiator of conversation.
switch req.Initiator {
case umodels.UserTypeAgent:
// Queue reply.
if _, err := app.conversation.QueueReply(media, req.InboxID, auser.ID /**sender_id**/, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil {
// Delete the conversation if msg queue fails.
if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
app.lo.Error("error deleting conversation", "error", err)
}
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil))
}
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil))
case umodels.UserTypeContact:
// Create message on behalf of contact.
if _, err := app.conversation.CreateContactMessage(media, contact.ID, conversationUUID, req.Content, cmodels.ContentTypeHTML); err != nil {
// Delete the conversation if message creation fails.
if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
app.lo.Error("error deleting conversation", "error", err)
}
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil))
}
default:
// Guard anyway.
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`initiator`"), nil, envelope.InputError)
}
// Assign the conversation to the agent or team.
@@ -768,3 +763,36 @@ func handleCreateConversation(r *fastglue.Request) error {
return r.SendEnvelope(conversation)
}
// validateCreateConversationRequest validates the create conversation request fields.
func validateCreateConversationRequest(req createConversationRequest, app *App) error {
if req.InboxID <= 0 {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`inbox_id`"), nil)
}
if req.Content == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`content`"), nil)
}
if req.Email == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`contact_email`"), nil)
}
if req.FirstName == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "`first_name`"), nil)
}
if !stringutil.ValidEmail(req.Email) {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`contact_email`"), nil)
}
if req.Initiator != umodels.UserTypeContact && req.Initiator != umodels.UserTypeAgent {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.invalid", "name", "`initiator`"), nil)
}
// Check if inbox exists and is enabled.
inbox, err := app.inbox.GetDBRecord(req.InboxID)
if err != nil {
return err
}
if !inbox.Enabled {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.disabled", "name", "inbox"), nil)
}
return nil
}

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

@@ -250,11 +250,12 @@ func initTag(db *sqlx.DB, i18n *i18n.I18n) *tag.Manager {
}
// initViews inits view manager.
func initView(db *sqlx.DB) *view.Manager {
func initView(db *sqlx.DB, i18n *i18n.I18n) *view.Manager {
var lo = initLogger("view_manager")
m, err := view.New(view.Opts{
DB: db,
Lo: lo,
DB: db,
Lo: lo,
I18n: i18n,
})
if err != nil {
log.Fatalf("error initializing view manager: %v", err)
@@ -327,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 {
@@ -345,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
@@ -365,6 +366,9 @@ func getTmplFuncs(consts *constants) template.FuncMap {
"SiteName": func() string {
return consts.SiteName
},
"L": func() interface{} {
return i18n
},
}
}
@@ -381,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
}
@@ -393,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

@@ -3,7 +3,6 @@ package main
import (
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/envelope"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
realip "github.com/ferluci/fast-realip"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
@@ -42,12 +41,6 @@ func handleLogin(r *fastglue.Request) error {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("user.accountDisabled"), nil))
}
// Set user availability status to online.
if err := app.user.UpdateAvailability(user.ID, umodels.Online); err != nil {
return sendErrorEnvelope(r, err)
}
user.AvailabilityStatus = umodels.Online
if err := app.auth.SaveSession(amodels.User{
ID: user.ID,
Email: user.Email.String,

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
}
@@ -239,7 +241,7 @@ func main() {
activityLog: initActivityLog(db, i18n),
customAttribute: initCustomAttribute(db, i18n),
authz: initAuthz(i18n),
view: initView(db),
view: initView(db, i18n),
report: initReport(db, i18n),
csat: initCSAT(db, i18n),
search: initSearch(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)
@@ -74,10 +64,10 @@ func handleCreateOIDC(r *fastglue.Request) error {
if err := reloadAuth(app); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "OIDC"), nil, envelope.GeneralError)
}
// Clear client secret before returning
createdOIDC.ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10)
return r.SendEnvelope(createdOIDC)
}
@@ -110,10 +100,10 @@ func handleUpdateOIDC(r *fastglue.Request) error {
if err := reloadAuth(app); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.couldNotReload", "name", "OIDC"), nil, envelope.GeneralError)
}
// Clear client secret before returning
updatedOIDC.ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10)
return r.SendEnvelope(updatedOIDC)
}

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{}
app = r.Context.(*App)
req = agentReq{}
)
if err := r.Decode(&user, "json"); err != nil {
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if user.Email.String == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
}
user.Email = null.StringFrom(strings.TrimSpace(strings.ToLower(user.Email.String)))
if !stringutil.ValidEmail(user.Email.String) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`email`"), nil, envelope.InputError)
// Validate agent request
if err := validateAgentRequest(r, &req); err != nil {
return err
}
if user.Roles == nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError)
}
if user.FirstName == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError)
}
if err := app.user.CreateAgent(&user); err != nil {
agent, err := app.user.CreateAgent(req.FirstName, req.LastName, req.Email, req.Roles)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Upsert user teams.
if len(user.Teams) > 0 {
if err := app.team.UpsertUserTeams(user.ID, user.Teams.Names()); err != nil {
return sendErrorEnvelope(r, err)
}
if len(req.Teams) > 0 {
app.team.UpsertUserTeams(agent.ID, req.Teams)
}
if user.SendWelcomeEmail {
if req.SendWelcomeEmail {
// Generate reset token.
resetToken, err := app.user.SetResetPasswordToken(user.ID)
resetToken, err := app.user.SetResetPasswordToken(agent.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -220,31 +218,36 @@ func handleCreateAgent(r *fastglue.Request) error {
// Render template and send email.
content, err := app.tmpl.RenderInMemoryTemplate(tmpl.TmplWelcome, map[string]any{
"ResetToken": resetToken,
"Email": user.Email.String,
"Email": req.Email,
})
if err != nil {
app.lo.Error("error rendering template", "error", err)
return r.SendEnvelope(true)
}
if err := app.notifier.Send(notifier.Message{
RecipientEmails: []string{user.Email.String},
Subject: "Welcome to Libredesk",
RecipientEmails: []string{req.Email},
Subject: app.i18n.T("globals.messages.welcomeToLibredesk"),
Content: content,
Provider: notifier.ProviderEmail,
}); err != nil {
app.lo.Error("error sending notification message", "error", err)
return r.SendEnvelope(true)
}
}
return r.SendEnvelope(true)
// Refetch agent as other details might've changed.
agent, err = app.user.GetAgent(agent.ID, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(agent)
}
// handleUpdateAgent updates an agent.
func handleUpdateAgent(r *fastglue.Request) error {
var (
app = r.Context.(*App)
user = models.User{}
req = agentReq{}
auser = r.RequestCtx.UserValue("user").(amodels.User)
ip = realip.FromRequest(r.RequestCtx)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
@@ -253,25 +256,13 @@ func handleUpdateAgent(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
}
if err := r.Decode(&user, "json"); err != nil {
if err := r.Decode(&req, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if user.Email.String == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
}
user.Email = null.StringFrom(strings.TrimSpace(strings.ToLower(user.Email.String)))
if !stringutil.ValidEmail(user.Email.String) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`email`"), nil, envelope.InputError)
}
if user.Roles == nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError)
}
if user.FirstName == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError)
// Validate agent request
if err := validateAgentRequest(r, &req); err != nil {
return err
}
agent, err := app.user.GetAgent(id, "")
@@ -280,8 +271,8 @@ func handleUpdateAgent(r *fastglue.Request) error {
}
oldAvailabilityStatus := agent.AvailabilityStatus
// Update agent.
if err = app.user.UpdateAgent(id, user); err != nil {
// Update agent with individual fields
if err = app.user.UpdateAgent(id, req.FirstName, req.LastName, req.Email, req.Roles, req.Enabled, req.AvailabilityStatus, req.NewPassword); err != nil {
return sendErrorEnvelope(r, err)
}
@@ -289,18 +280,24 @@ func handleUpdateAgent(r *fastglue.Request) error {
defer app.authz.InvalidateUserCache(id)
// Create activity log if user availability status changed.
if oldAvailabilityStatus != user.AvailabilityStatus {
if err := app.activityLog.UserAvailability(auser.ID, auser.Email, user.AvailabilityStatus, ip, user.Email.String, id); err != nil {
if oldAvailabilityStatus != req.AvailabilityStatus {
if err := app.activityLog.UserAvailability(auser.ID, auser.Email, req.AvailabilityStatus, ip, req.Email, id); err != nil {
app.lo.Error("error creating activity log", "error", err)
}
}
// Upsert agent teams.
if err := app.team.UpsertUserTeams(id, user.Teams.Names()); err != nil {
if err := app.team.UpsertUserTeams(id, req.Teams); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
// Refetch agent and return.
agent, err = app.user.GetAgent(id, "")
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(agent)
}
// handleDeleteAgent soft deletes an agent.
@@ -381,7 +378,7 @@ func handleResetPassword(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser, ok = r.RequestCtx.UserValue("user").(amodels.User)
resetReq ResetPasswordRequest
resetReq resetPasswordRequest
)
if ok && auser.ID > 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("user.userAlreadyLoggedIn"), nil, envelope.InputError)
@@ -399,7 +396,7 @@ func handleResetPassword(r *fastglue.Request) error {
agent, err := app.user.GetAgent(0, resetReq.Email)
if err != nil {
// Send 200 even if user not found, to prevent email enumeration.
return r.SendEnvelope("Reset password email sent successfully.")
return r.SendEnvelope(true)
}
token, err := app.user.SetResetPasswordToken(agent.ID)
@@ -434,7 +431,7 @@ func handleSetPassword(r *fastglue.Request) error {
var (
app = r.Context.(*App)
agent, ok = r.RequestCtx.UserValue("user").(amodels.User)
req = SetPasswordRequest{}
req setPasswordRequest
)
if ok && agent.ID > 0 {
@@ -457,13 +454,13 @@ func handleSetPassword(r *fastglue.Request) error {
}
// uploadUserAvatar uploads the user avatar.
func uploadUserAvatar(r *fastglue.Request, user *models.User, files []*multipart.FileHeader) error {
func uploadUserAvatar(r *fastglue.Request, user models.User, files []*multipart.FileHeader) error {
var app = r.Context.(*App)
fileHeader := files[0]
file, err := fileHeader.Open()
if err != nil {
app.lo.Error("error opening uploaded file", "error", err)
app.lo.Error("error opening uploaded file", "user_id", user.ID, "error", err)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorReading", "name", "{globals.terms.file}"), nil)
}
defer file.Close()
@@ -480,7 +477,7 @@ func uploadUserAvatar(r *fastglue.Request, user *models.User, files []*multipart
// Check file size
if bytesToMegabytes(srcFileSize) > maxAvatarSizeMB {
app.lo.Error("error uploaded file size is larger than max allowed", "size", bytesToMegabytes(srcFileSize), "max_allowed", maxAvatarSizeMB)
app.lo.Error("error uploaded file size is larger than max allowed", "user_id", user.ID, "size", bytesToMegabytes(srcFileSize), "max_allowed", maxAvatarSizeMB)
return envelope.NewError(
envelope.InputError,
app.i18n.Ts("media.fileSizeTooLarge", "size", fmt.Sprintf("%dMB", maxAvatarSizeMB)),
@@ -497,23 +494,25 @@ func uploadUserAvatar(r *fastglue.Request, user *models.User, files []*multipart
meta := []byte("{}")
media, err := app.media.UploadAndInsert(srcFileName, srcContentType, contentID, linkedModel, linkedID, file, int(srcFileSize), disposition, meta)
if err != nil {
app.lo.Error("error uploading file", "error", err)
app.lo.Error("error uploading file", "user_id", user.ID, "error", err)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.file}"), nil)
}
// Delete current avatar.
if user.AvatarURL.Valid {
fileName := filepath.Base(user.AvatarURL.String)
app.media.Delete(fileName)
if err := app.media.Delete(fileName); err != nil {
app.lo.Error("error deleting user avatar", "user_id", user.ID, "error", err)
}
}
// Save file path.
path, err := stringutil.GetPathFromURL(media.URL)
if err != nil {
app.lo.Debug("error getting path from URL", "url", media.URL, "error", err)
app.lo.Debug("error getting path from URL", "user_id", user.ID, "url", media.URL, "error", err)
return envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.file}"), nil)
}
fmt.Println("path", path)
if err := app.user.UpdateAvatar(user.ID, path); err != nil {
return sendErrorEnvelope(r, err)
}
@@ -577,3 +576,28 @@ func handleRevokeAPIKey(r *fastglue.Request) error {
return r.SendEnvelope(true)
}
// validateAgentRequest validates common agent request fields and normalizes the email
func validateAgentRequest(r *fastglue.Request, req *agentReq) error {
var app = r.Context.(*App)
// Normalize email
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
if req.Email == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
}
if !stringutil.ValidEmail(req.Email) {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`email`"), nil, envelope.InputError)
}
if req.Roles == nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError)
}
if req.FirstName == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError)
}
return nil
}

View File

@@ -0,0 +1,30 @@
# API getting started
You can access the Libredesk API to interact with your instance programmatically.
## Generating API keys
1. **Edit agent**: Go to Admin → Teammate → Agent → Edit
2. **Generate new API key**: An API Key and API Secret will be generated for the agent
3. **Save the credentials**: Keep both the API Key and API Secret secure
4. **Key management**: You can revoke / regenerate API keys at any time from the same page
## Using the API
LibreDesk supports two authentication schemes:
### Basic authentication
```bash
curl -X GET "https://your-libredesk-instance.com/api/endpoint" \
-H "Authorization: Basic <base64_encoded_key:secret>"
```
### Token authentication
```bash
curl -X GET "https://your-libredesk-instance.com/api/endpoint" \
-H "Authorization: token your_api_key:your_api_secret"
```
## API Documentation
Complete API documentation with available endpoints and examples coming soon.

View File

@@ -32,6 +32,7 @@ nav:
- Email Templates: templating.md
- SSO Setup: sso.md
- Webhooks: webhooks.md
- API Getting Started: api-getting-started.md
- Contributions:
- Developer Setup: developer-setup.md
- Translate Libredesk: translations.md

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: [
{
id: 1,
name: 'Google',
logo_url: 'https://example.com/google-logo.png',
disabled: false
}
]
data: {
"app.favicon_url": "http://localhost:9000/favicon.ico",
"app.lang": "en",
"app.logo_url": "http://localhost:9000/logo.png",
"app.site_name": "Libredesk",
"app.sso_providers": [
{
"client_id": "xx",
"enabled": true,
"id": 1,
"logo_url": "/images/google-logo.png",
"name": "Google",
"provider": "Google",
"provider_url": "https://accounts.google.com",
"redirect_uri": "http://localhost:9000/api/v1/oidc/1/finish"
}
]
}
}
}).as('getOIDCProviders')
// Visit the login page
cy.visit('/')
})
it('should display login form', () => {

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

@@ -1,105 +1,139 @@
<template>
<div class="max-w-5xl mx-auto p-6 min-h-screen">
<div class="space-y-8">
<div
v-for="(items, type) in results"
:key="type"
class="bg-card rounded shadow overflow-hidden"
>
<!-- Header for each section -->
<h2
class="bg-primary dark:bg-primary text-lg font-bold text-white dark:text-primary-foreground py-2 px-6 capitalize"
>
{{ type }}
</h2>
<Tabs :default-value="defaultTab" v-model="activeTab">
<TabsList class="grid w-full mb-6" :class="tabsGridClass">
<TabsTrigger v-for="(items, type) in results" :key="type" :value="type" class="capitalize">
{{ type }} ({{ items.length }})
</TabsTrigger>
</TabsList>
<!-- No results message -->
<div v-if="items.length === 0" class="p-6 text-gray-500 dark:text-muted-foreground">
{{
$t('globals.messages.noResults', {
name: type
})
}}
</div>
<TabsContent v-for="(items, type) in results" :key="type" :value="type" class="mt-0">
<div class="bg-background rounded border overflow-hidden">
<!-- No results message -->
<div v-if="items.length === 0" class="p-8 text-center text-muted-foreground">
<div class="text-lg font-medium mb-2">
{{
$t('globals.messages.noResults', {
name: type
})
}}
</div>
<div class="text-sm">{{ $t('search.adjustSearchTerms') }}</div>
</div>
<!-- Results list -->
<div class="divide-y divide-gray-200 dark:divide-border">
<div
v-for="item in items"
:key="item.id || item.uuid"
class="p-6 hover:bg-gray-100 dark:hover:bg-accent transition duration-300 ease-in-out group"
>
<router-link
:to="{
name: 'inbox-conversation',
params: {
uuid: type === 'conversations' ? item.uuid : item.conversation_uuid,
type: 'assigned'
}
}"
class="block"
<!-- Results list -->
<div v-else class="divide-y divide-border">
<div
v-for="item in items"
:key="item.id || item.uuid"
class="p-6 hover:bg-accent/50 transition duration-200 ease-in-out group"
>
<div class="flex justify-between items-start">
<div class="flex-grow">
<!-- Reference number -->
<div
class="text-sm font-semibold mb-2 group-hover:text-primary dark:group-hover:text-primary transition duration-300"
>
#{{
type === 'conversations'
? item.reference_number
: item.conversation_reference_number
}}
<router-link
:to="{
name: 'inbox-conversation',
params: {
uuid: type === 'conversations' ? item.uuid : item.conversation_uuid,
type: 'assigned'
}
}"
class="block"
>
<div class="flex justify-between items-start">
<div class="flex-grow">
<!-- Reference number -->
<div
class="text-sm font-semibold mb-2 text-muted-foreground group-hover:text-primary transition duration-200"
>
#{{
type === 'conversations'
? item.reference_number
: item.conversation_reference_number
}}
</div>
<!-- Content -->
<div
class="text-foreground font-medium mb-2 text-lg group-hover:text-primary transition duration-200"
>
{{
truncateText(
type === 'conversations' ? item.subject : item.text_content,
100
)
}}
</div>
<!-- Timestamp -->
<div class="text-sm text-muted-foreground flex items-center">
<ClockIcon class="h-4 w-4 mr-1" />
{{
formatDate(
type === 'conversations' ? item.created_at : item.conversation_created_at
)
}}
</div>
</div>
<!-- Content -->
<!-- Right arrow icon -->
<div
class="text-gray-900 dark:text-card-foreground font-medium mb-2 text-lg group-hover:text-gray-950 dark:group-hover:text-foreground transition duration-300"
class="bg-secondary rounded-full p-2 group-hover:bg-primary transition duration-200"
>
{{
truncateText(type === 'conversations' ? item.subject : item.text_content, 100)
}}
</div>
<!-- Timestamp -->
<div class="text-sm text-gray-500 dark:text-muted-foreground flex items-center">
<ClockIcon class="h-4 w-4 mr-1" />
{{
formatDate(
type === 'conversations' ? item.created_at : item.conversation_created_at
)
}}
<ChevronRightIcon
class="h-5 w-5 text-secondary-foreground group-hover:text-primary-foreground"
aria-hidden="true"
/>
</div>
</div>
<!-- Right arrow icon -->
<div
class="bg-gray-200 dark:bg-secondary rounded-full p-2 group-hover:bg-primary dark:group-hover:bg-primary transition duration-300"
>
<ChevronRightIcon
class="h-5 w-5 text-gray-700 dark:text-secondary-foreground group-hover:text-white dark:group-hover:text-primary-foreground"
aria-hidden="true"
/>
</div>
</div>
</router-link>
</router-link>
</div>
</div>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { ChevronRightIcon, ClockIcon } from 'lucide-vue-next'
import { format, parseISO } from 'date-fns'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
defineProps({
const props = defineProps({
results: {
type: Object,
required: true
}
})
// Get the first available tab as default
const defaultTab = computed(() => {
const types = Object.keys(props.results)
return types.length > 0 ? types[0] : ''
})
const activeTab = ref('')
// Watch for changes in results and set the first tab as active
watch(
() => props.results,
(newResults) => {
const types = Object.keys(newResults)
if (types.length > 0 && !activeTab.value) {
activeTab.value = types[0]
}
},
{ immediate: true }
)
// Dynamic grid class based on number of tabs
const tabsGridClass = computed(() => {
const tabCount = Object.keys(props.results).length
if (tabCount <= 2) return 'grid-cols-2'
if (tabCount <= 3) return 'grid-cols-3'
if (tabCount <= 4) return 'grid-cols-4'
return 'grid-cols-5'
})
const formatDate = (dateString) => {
const date = parseISO(dateString)
return format(date, 'MMM d, yyyy HH:mm')

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) => {

4
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
@@ -38,7 +39,7 @@ require (
github.com/zerodha/simplesessions/v3 v3.0.0
golang.org/x/crypto v0.38.0
golang.org/x/mod v0.17.0
golang.org/x/oauth2 v0.21.0
golang.org/x/oauth2 v0.27.0
)
require (
@@ -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

6
go.sum
View File

@@ -140,8 +140,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.5.5 h1:51VEyMF8eOO+NUHFm8fpg+IOc1xFuFOhxs3R+kPu1FM=
github.com/redis/go-redis/v9 v9.5.5/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
github.com/rhnvrm/simples3 v0.9.0 h1:It6/glyqRTRooRzXcYOuqpKwjGg3lsXgNmeGgxpBtjA=
github.com/rhnvrm/simples3 v0.9.0/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA=
github.com/rhnvrm/simples3 v0.9.1 h1:pYfEe2wTjx8B2zFzUdy4kZn3I3Otd9ZvzIhHkFR85kE=
github.com/rhnvrm/simples3 v0.9.1/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -211,8 +209,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=

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.",
@@ -568,6 +587,7 @@
"search.noResultsForQuery": "No results found for query `{query}`. Try a different search term.",
"search.minQueryLength": " Please enter at least {length} characters to search.",
"search.searchBy": "Search by reference number, contact email address or messages in conversations.",
"search.adjustSearchTerms": "Try adjusting your search terms or filters.",
"sla.overdueBy": "Overdue by",
"sla.met": "SLA met",
"view.form.description": "Create and save custom filter views for quick access to your conversations.",
@@ -621,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,48 +52,124 @@ var (
ContentTypeHTML = "html"
)
// ConversationListItem represents a conversation in list views
type ConversationListItem struct {
Total int `db:"total" json:"-"`
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
UUID string `db:"uuid" json:"uuid"`
WaitingSince null.Time `db:"waiting_since" json:"waiting_since"`
AssigneeLastSeenAt null.Time `db:"assignee_last_seen_at" json:"assignee_last_seen_at"`
Contact ConversationListContact `db:"contact" json:"contact"`
InboxChannel string `db:"inbox_channel" json:"inbox_channel"`
InboxName string `db:"inbox_name" json:"inbox_name"`
SLAPolicyID null.Int `db:"sla_policy_id" json:"sla_policy_id"`
FirstReplyAt null.Time `db:"first_reply_at" json:"first_reply_at"`
LastReplyAt null.Time `db:"last_reply_at" json:"last_reply_at"`
ResolvedAt null.Time `db:"resolved_at" json:"resolved_at"`
Subject null.String `db:"subject" json:"subject"`
LastMessage null.String `db:"last_message" json:"last_message"`
LastMessageAt null.Time `db:"last_message_at" json:"last_message_at"`
LastMessageSender null.String `db:"last_message_sender" json:"last_message_sender"`
NextSLADeadlineAt null.Time `db:"next_sla_deadline_at" json:"next_sla_deadline_at"`
PriorityID null.Int `db:"priority_id" json:"priority_id"`
UnreadMessageCount int `db:"unread_message_count" json:"unread_message_count"`
Status null.String `db:"status" json:"status"`
Priority null.String `db:"priority" json:"priority"`
FirstResponseDueAt null.Time `db:"first_response_deadline_at" json:"first_response_deadline_at"`
ResolutionDueAt null.Time `db:"resolution_deadline_at" json:"resolution_deadline_at"`
AppliedSLAID null.Int `db:"applied_sla_id" json:"applied_sla_id"`
NextResponseDueAt null.Time `db:"next_response_deadline_at" json:"next_response_deadline_at"`
NextResponseMetAt null.Time `db:"next_response_met_at" json:"next_response_met_at"`
}
// ConversationListContact represents contact info in conversation list views
type ConversationListContact struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
FirstName string `db:"first_name" json:"first_name"`
LastName string `db:"last_name" json:"last_name"`
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
}
type Conversation struct {
ID int `db:"id" json:"id,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
UUID string `db:"uuid" json:"uuid"`
ContactID int `db:"contact_id" json:"contact_id"`
InboxID int `db:"inbox_id" json:"inbox_id,omitempty"`
ClosedAt null.Time `db:"closed_at" json:"closed_at,omitempty"`
ResolvedAt null.Time `db:"resolved_at" json:"resolved_at,omitempty"`
ReferenceNumber string `db:"reference_number" json:"reference_number,omitempty"`
Priority null.String `db:"priority" json:"priority"`
PriorityID null.Int `db:"priority_id" json:"priority_id"`
Status null.String `db:"status" json:"status"`
StatusID null.Int `db:"status_id" json:"status_id"`
FirstReplyAt null.Time `db:"first_reply_at" json:"first_reply_at"`
LastReplyAt null.Time `db:"last_reply_at" json:"last_reply_at"`
AssignedUserID null.Int `db:"assigned_user_id" json:"assigned_user_id"`
AssignedTeamID null.Int `db:"assigned_team_id" json:"assigned_team_id"`
AssigneeLastSeenAt null.Time `db:"assignee_last_seen_at" json:"assignee_last_seen_at"`
WaitingSince null.Time `db:"waiting_since" json:"waiting_since"`
Subject null.String `db:"subject" json:"subject"`
UnreadMessageCount int `db:"unread_message_count" json:"unread_message_count"`
InboxMail string `db:"inbox_mail" json:"inbox_mail"`
InboxName string `db:"inbox_name" json:"inbox_name"`
InboxChannel string `db:"inbox_channel" json:"inbox_channel"`
Tags null.JSON `db:"tags" json:"tags"`
Meta pq.StringArray `db:"meta" json:"meta"`
CustomAttributes json.RawMessage `db:"custom_attributes" json:"custom_attributes"`
LastMessageAt null.Time `db:"last_message_at" json:"last_message_at"`
LastMessage null.String `db:"last_message" json:"last_message"`
LastMessageSender null.String `db:"last_message_sender" json:"last_message_sender"`
Contact umodels.User `db:"contact" json:"contact"`
SLAPolicyID null.Int `db:"sla_policy_id" json:"sla_policy_id"`
SlaPolicyName null.String `db:"sla_policy_name" json:"sla_policy_name"`
AppliedSLAID null.Int `db:"applied_sla_id" json:"applied_sla_id"`
NextSLADeadlineAt null.Time `db:"next_sla_deadline_at" json:"next_sla_deadline_at"`
FirstResponseDueAt null.Time `db:"first_response_deadline_at" json:"first_response_deadline_at"`
ResolutionDueAt null.Time `db:"resolution_deadline_at" json:"resolution_deadline_at"`
NextResponseDueAt null.Time `db:"next_response_deadline_at" json:"next_response_deadline_at"`
NextResponseMetAt null.Time `db:"next_response_met_at" json:"next_response_met_at"`
PreviousConversations []Conversation `db:"-" json:"previous_conversations"`
Total int `db:"total" json:"-"`
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
UUID string `db:"uuid" json:"uuid"`
ContactID int `db:"contact_id" json:"contact_id"`
InboxID int `db:"inbox_id" json:"inbox_id"`
ClosedAt null.Time `db:"closed_at" json:"closed_at"`
ResolvedAt null.Time `db:"resolved_at" json:"resolved_at"`
ReferenceNumber string `db:"reference_number" json:"reference_number"`
Priority null.String `db:"priority" json:"priority"`
PriorityID null.Int `db:"priority_id" json:"priority_id"`
Status null.String `db:"status" json:"status"`
StatusID null.Int `db:"status_id" json:"status_id"`
FirstReplyAt null.Time `db:"first_reply_at" json:"first_reply_at"`
LastReplyAt null.Time `db:"last_reply_at" json:"last_reply_at"`
AssignedUserID null.Int `db:"assigned_user_id" json:"assigned_user_id"`
AssignedTeamID null.Int `db:"assigned_team_id" json:"assigned_team_id"`
AssigneeLastSeenAt null.Time `db:"assignee_last_seen_at" json:"assignee_last_seen_at"`
WaitingSince null.Time `db:"waiting_since" json:"waiting_since"`
Subject null.String `db:"subject" json:"subject"`
InboxMail string `db:"inbox_mail" json:"inbox_mail"`
InboxName string `db:"inbox_name" json:"inbox_name"`
InboxChannel string `db:"inbox_channel" json:"inbox_channel"`
Tags null.JSON `db:"tags" json:"tags"`
Meta pq.StringArray `db:"meta" json:"meta"`
CustomAttributes json.RawMessage `db:"custom_attributes" json:"custom_attributes"`
LastMessageAt null.Time `db:"last_message_at" json:"last_message_at"`
LastMessage null.String `db:"last_message" json:"last_message"`
LastMessageSender null.String `db:"last_message_sender" json:"last_message_sender"`
Contact ConversationContact `db:"contact" json:"contact"`
SLAPolicyID null.Int `db:"sla_policy_id" json:"sla_policy_id"`
SlaPolicyName null.String `db:"sla_policy_name" json:"sla_policy_name"`
AppliedSLAID null.Int `db:"applied_sla_id" json:"applied_sla_id"`
FirstResponseDueAt null.Time `db:"first_response_deadline_at" json:"first_response_deadline_at"`
ResolutionDueAt null.Time `db:"resolution_deadline_at" json:"resolution_deadline_at"`
NextResponseDueAt null.Time `db:"next_response_deadline_at" json:"next_response_deadline_at"`
NextResponseMetAt null.Time `db:"next_response_met_at" json:"next_response_met_at"`
PreviousConversations []PreviousConversation `db:"-" json:"previous_conversations"`
}
type ConversationContact struct {
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
FirstName string `db:"first_name" json:"first_name"`
LastName string `db:"last_name" json:"last_name"`
Email null.String `db:"email" json:"email"`
Type string `db:"type" json:"type"`
AvailabilityStatus string `db:"availability_status" json:"availability_status"`
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
PhoneNumber null.String `db:"phone_number" json:"phone_number"`
PhoneNumberCallingCode null.String `db:"phone_number_calling_code" json:"phone_number_calling_code"`
CustomAttributes json.RawMessage `db:"custom_attributes" json:"custom_attributes"`
Enabled bool `db:"enabled" json:"enabled"`
LastActiveAt null.Time `db:"last_active_at" json:"last_active_at"`
LastLoginAt null.Time `db:"last_login_at" json:"last_login_at"`
}
func (c *ConversationContact) FullName() string {
return c.FirstName + " " + c.LastName
}
type PreviousConversation struct {
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
UUID string `db:"uuid" json:"uuid"`
Contact PreviousConversationContact `db:"contact" json:"contact"`
LastMessage null.String `db:"last_message" json:"last_message"`
LastMessageAt null.Time `db:"last_message_at" json:"last_message_at"`
}
type PreviousConversationContact struct {
FirstName string `db:"first_name" json:"first_name"`
LastName string `db:"last_name" json:"last_name"`
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
}
type ConversationParticipant struct {
@@ -117,13 +193,15 @@ type NewConversationsStats struct {
// Message represents a message in a conversation
type Message struct {
ID int `db:"id" json:"id,omitempty"`
Total int `db:"total" json:"-"`
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
UUID string `db:"uuid" json:"uuid"`
Type string `db:"type" json:"type"`
Status string `db:"status" json:"status"`
ConversationID int `db:"conversation_id" json:"conversation_id"`
ConversationUUID string `db:"conversation_uuid" json:"conversation_uuid"`
Content string `db:"content" json:"content"`
TextContent string `db:"text_content" json:"text_content"`
ContentType string `db:"content_type" json:"content_type"`
@@ -134,7 +212,6 @@ type Message struct {
InboxID int `db:"inbox_id" json:"-"`
Meta json.RawMessage `db:"meta" json:"meta"`
Attachments attachment.Attachments `db:"attachments" json:"attachments"`
ConversationUUID string `db:"conversation_uuid" json:"-"`
From string `db:"from" json:"-"`
Subject string `db:"subject" json:"-"`
Channel string `db:"channel" json:"-"`
@@ -144,10 +221,9 @@ type Message struct {
References []string `json:"-"`
InReplyTo string `json:"-"`
Headers textproto.MIMEHeader `json:"-"`
AltContent string `db:"-" json:"-"`
Media []mmodels.Media `db:"-" json:"-"`
IsCSAT bool `db:"-" json:"-"`
Total int `db:"total" json:"-"`
AltContent string `json:"-"`
Media []mmodels.Media `json:"-"`
IsCSAT bool `json:"-"`
}
// CensorCSATContent redacts the content of a CSAT message to prevent leaking the CSAT survey public link.

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
@@ -438,6 +447,7 @@ SELECT
m.sender_type,
m.sender_id,
m.meta,
c.uuid as conversation_uuid,
COALESCE(
json_agg(
json_build_object(
@@ -452,25 +462,31 @@ SELECT
'[]'::json
) AS attachments
FROM conversation_messages m
INNER JOIN conversations c ON c.id = m.conversation_id
LEFT JOIN media ON media.model_type = 'messages' AND media.model_id = m.id
WHERE m.uuid = $1
GROUP BY
m.id, m.created_at, m.updated_at, m.status, m.type, m.content, m.uuid, m.private, m.sender_type
m.id, m.created_at, m.updated_at, m.status, m.type, m.content, m.uuid, m.private, m.sender_type, c.uuid
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

@@ -140,6 +140,7 @@ func (e *Email) fetchAndProcessMessages(ctx context.Context, client *imapclient.
headerAutoSubmitted,
headerAutoreply,
headerLibredeskLoopPrevention,
headerMessageID,
},
},
},
@@ -147,10 +148,11 @@ func (e *Email) fetchAndProcessMessages(ctx context.Context, client *imapclient.
// Collect messages to process later.
type msgData struct {
env *imap.Envelope
seqNum uint32
autoReply bool
isLoop bool
env *imap.Envelope
seqNum uint32
autoReply bool
isLoop bool
extractedMessageID string
}
var messages []msgData
@@ -182,9 +184,10 @@ func (e *Email) fetchAndProcessMessages(ctx context.Context, client *imapclient.
}
var (
env *imap.Envelope
autoReply bool
isLoop bool
env *imap.Envelope
autoReply bool
isLoop bool
extractedMessageID string
)
// Process all fetch items for the current message.
for {
@@ -215,6 +218,9 @@ func (e *Email) fetchAndProcessMessages(ctx context.Context, client *imapclient.
if isLoopMessage(envelope, inboxEmail) {
isLoop = true
}
// Extract Message-Id from raw headers as fallback for problematic Message IDs
extractedMessageID = extractMessageIDFromHeaders(envelope)
}
// Envelope.
@@ -223,12 +229,13 @@ func (e *Email) fetchAndProcessMessages(ctx context.Context, client *imapclient.
}
}
// Skip if we couldn't get headers or envelope.
// Skip if we couldn't get the envelope.
if env == nil {
e.lo.Warn("skipping message without envelope", "seq_num", msg.SeqNum, "inbox_id", e.Identifier())
continue
}
messages = append(messages, msgData{env: env, seqNum: msg.SeqNum, autoReply: autoReply, isLoop: isLoop})
messages = append(messages, msgData{env: env, seqNum: msg.SeqNum, autoReply: autoReply, isLoop: isLoop, extractedMessageID: extractedMessageID})
}
// Now process each collected message.
@@ -253,7 +260,7 @@ func (e *Email) fetchAndProcessMessages(ctx context.Context, client *imapclient.
}
// Process the envelope.
if err := e.processEnvelope(ctx, client, msgData.env, msgData.seqNum, inboxID); err != nil && err != context.Canceled {
if err := e.processEnvelope(ctx, client, msgData.env, msgData.seqNum, inboxID, msgData.extractedMessageID); err != nil && err != context.Canceled {
e.lo.Error("error processing envelope", "error", err)
}
}
@@ -262,17 +269,32 @@ func (e *Email) fetchAndProcessMessages(ctx context.Context, client *imapclient.
}
// processEnvelope processes a single email envelope.
func (e *Email) processEnvelope(ctx context.Context, client *imapclient.Client, env *imap.Envelope, seqNum uint32, inboxID int) error {
func (e *Email) processEnvelope(ctx context.Context, client *imapclient.Client, env *imap.Envelope, seqNum uint32, inboxID int, extractedMessageID string) error {
if len(env.From) == 0 {
e.lo.Warn("no sender received for email", "message_id", env.MessageID)
return nil
}
var fromAddress = strings.ToLower(env.From[0].Addr())
// Determine final Message ID - prefer IMAP-parsed, fallback to raw header extraction
messageID := env.MessageID
if messageID == "" {
messageID = extractedMessageID
if messageID != "" {
e.lo.Debug("using raw header Message-ID as fallback for malformed ID", "message_id", messageID, "subject", env.Subject, "from", fromAddress)
}
}
// Drop message if we still don't have a valid Message ID
if messageID == "" {
e.lo.Error("dropping message: no valid Message-ID found in IMAP parsing or raw headers", "subject", env.Subject, "from", fromAddress)
return nil
}
// Check if the message already exists in the database; if it does, ignore it.
exists, err := e.messageStore.MessageExists(env.MessageID)
exists, err := e.messageStore.MessageExists(messageID)
if err != nil {
e.lo.Error("error checking if message exists", "message_id", env.MessageID)
e.lo.Error("error checking if message exists", "message_id", messageID)
return fmt.Errorf("checking if message exists in DB: %w", err)
}
if exists {
@@ -291,7 +313,7 @@ func (e *Email) processEnvelope(ctx context.Context, client *imapclient.Client,
return nil
}
e.lo.Debug("processing new incoming message", "message_id", env.MessageID, "subject", env.Subject, "from", fromAddress, "inbox_id", inboxID)
e.lo.Debug("processing new incoming message", "message_id", messageID, "subject", env.Subject, "from", fromAddress, "inbox_id", inboxID)
// Make contact.
firstName, lastName := getContactName(env.From[0])
@@ -350,7 +372,7 @@ func (e *Email) processEnvelope(ctx context.Context, client *imapclient.Client,
InboxID: inboxID,
Status: models.MessageStatusReceived,
Subject: env.Subject,
SourceID: null.StringFrom(env.MessageID),
SourceID: null.StringFrom(messageID),
Meta: meta,
},
Contact: contact,
@@ -385,7 +407,7 @@ func (e *Email) processEnvelope(ctx context.Context, client *imapclient.Client,
}
if fullItem, ok := fullFetchItem.(imapclient.FetchItemDataBodySection); ok {
e.lo.Debug("fetching full message body", "message_id", env.MessageID)
e.lo.Debug("fetching full message body", "message_id", messageID)
return e.processFullMessage(fullItem, incomingMsg)
}
}
@@ -534,3 +556,13 @@ func extractAllHTMLParts(part *enmime.Part) []string {
return htmlParts
}
// extractMessageIDFromHeaders extracts and cleans the Message-ID from email headers.
// This function handles problematic Message IDs by extracting them from raw headers
// and cleaning them of angle brackets and whitespace.
func extractMessageIDFromHeaders(envelope *enmime.Envelope) string {
if rawMessageID := envelope.GetHeader(headerMessageID); rawMessageID != "" {
return strings.TrimSpace(strings.Trim(rawMessageID, "<>"))
}
return ""
}

View File

@@ -0,0 +1,123 @@
package email
import (
"strings"
"testing"
"github.com/emersion/go-message/mail"
"github.com/jhillyerd/enmime"
)
// TestGoIMAPMessageIDParsing shows how go-imap fails to parse malformed Message-IDs
// and demonstrates the fallback solution.
// go-imap uses mail.Header.MessageID() which strictly follows RFC 5322 and returns
// empty strings for Message-IDs with multiple @ symbols.
//
// This caused emails to be dropped since we require Message-IDs for deduplication.
// References:
// - https://community.mailcow.email/d/701-multiple-at-in-message-id/5
// - https://github.com/emersion/go-message/issues/154#issuecomment-1425634946
func TestGoIMAPMessageIDParsing(t *testing.T) {
testCases := []struct {
input string
expectedIMAP string
expectedFallback string
name string
}{
{"<normal@example.com>", "normal@example.com", "normal@example.com", "normal message ID"},
{"<malformed@@example.com>", "", "malformed@@example.com", "double @ - IMAP fails, fallback works"},
{"<001c01d710db$a8137a50$f83a6ef0$@jones.smith@example.com>", "", "001c01d710db$a8137a50$f83a6ef0$@jones.smith@example.com", "mailcow-style - IMAP fails, fallback works"},
{"<test@@@domain.com>", "", "test@@@domain.com", "triple @ - IMAP fails, fallback works"},
{" <abc123@example.com> ", "abc123@example.com", "abc123@example.com", "with whitespace - both handle correctly"},
{"abc123@example.com", "", "abc123@example.com", "no angle brackets - IMAP fails, fallback works"},
{"", "", "", "empty input"},
{"<>", "", "", "empty brackets"},
{"<CAFnQjQFhY8z@mail.example.com@gateway.company.com>", "", "CAFnQjQFhY8z@mail.example.com@gateway.company.com", "gateway-style - IMAP fails, fallback works"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Test go-imap parsing behavior
var h mail.Header
h.Set("Message-Id", tc.input)
imapResult, _ := h.MessageID()
if imapResult != tc.expectedIMAP {
t.Errorf("IMAP parsing of %q: expected %q, got %q", tc.input, tc.expectedIMAP, imapResult)
}
// Test fallback solution
if tc.input != "" {
rawEmail := "From: test@example.com\nMessage-ID: " + tc.input + "\n\nBody"
envelope, err := enmime.ReadEnvelope(strings.NewReader(rawEmail))
if err != nil {
t.Fatal(err)
}
fallbackResult := extractMessageIDFromHeaders(envelope)
if fallbackResult != tc.expectedFallback {
t.Errorf("Fallback extraction of %q: expected %q, got %q", tc.input, tc.expectedFallback, fallbackResult)
}
// Critical check: ensure fallback works when IMAP fails
if imapResult == "" && tc.expectedFallback != "" && fallbackResult == "" {
t.Errorf("CRITICAL: Both IMAP and fallback failed for %q - would drop email!", tc.input)
}
}
})
}
}
// TestEdgeCasesMessageID tests additional edge cases for Message-ID extraction.
func TestEdgeCasesMessageID(t *testing.T) {
tests := []struct {
name string
email string
expected string
}{
{
name: "no Message-ID header",
email: `From: test@example.com
To: inbox@test.com
Subject: Test
Body`,
expected: "",
},
{
name: "malformed header syntax",
email: `From: test@example.com
Message-ID: malformed-no-brackets@@domain.com
To: inbox@test.com
Body`,
expected: "malformed-no-brackets@@domain.com",
},
{
name: "multiple Message-ID headers (first wins)",
email: `From: test@example.com
Message-ID: <first@example.com>
Message-ID: <second@@example.com>
To: inbox@test.com
Body`,
expected: "first@example.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
envelope, err := enmime.ReadEnvelope(strings.NewReader(tt.email))
if err != nil {
t.Fatal(err)
}
result := extractMessageIDFromHeaders(envelope)
if result != tt.expected {
t.Errorf("Expected %q, got %q", tt.expected, result)
}
})
}
}

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"`
UUID string `db:"uuid" json:"uuid"`
Filename string `db:"filename" json:"filename"`
ContentType string `db:"content_type" json:"content_type"`
Model null.String `db:"model_type" json:"-"`
ModelID null.Int `db:"model_id" json:"-"`
Size int `db:"size" json:"size"`
Store string `db:"store" json:"store"`
Disposition null.String `db:"disposition" json:"disposition"`
URL string `json:"url"`
ContentID string `json:"-"`
Content []byte `json:"-"`
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
UUID string `db:"uuid" json:"uuid"`
Store string `db:"store" json:"store"`
Filename string `db:"filename" json:"filename"`
ContentType string `db:"content_type" json:"content_type"`
ContentID string `db:"content_id" json:"content_id"`
ModelID null.Int `db:"model_id" json:"model_id"`
Model null.String `db:"model_type" json:"model_type"`
Disposition null.String `db:"disposition" json:"disposition"`
Size int `db:"size" json:"size"`
Meta json.RawMessage `db:"meta" json:"meta"`
// Pseudo fields
URL string `json:"url"`
Content []byte `json:"-"`
}

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

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

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

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

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

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

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>