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

View File

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

View File

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

View File

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

View File

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

View File

@@ -250,11 +250,12 @@ func initTag(db *sqlx.DB, i18n *i18n.I18n) *tag.Manager {
} }
// initViews inits view 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") var lo = initLogger("view_manager")
m, err := view.New(view.Opts{ m, err := view.New(view.Opts{
DB: db, DB: db,
Lo: lo, Lo: lo,
I18n: i18n,
}) })
if err != nil { if err != nil {
log.Fatalf("error initializing view manager: %v", err) 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 { func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants, i18n *i18n.I18n) *tmpl.Manager {
var ( var (
lo = initLogger("template") lo = initLogger("template")
funcMap = getTmplFuncs(consts) funcMap = getTmplFuncs(consts, i18n)
) )
tpls, err := stuffbin.ParseTemplatesGlob(funcMap, fs, "/static/email-templates/*.html") tpls, err := stuffbin.ParseTemplatesGlob(funcMap, fs, "/static/email-templates/*.html")
if err != nil { if err != nil {
@@ -345,7 +346,7 @@ func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants, i18n *
} }
// getTmplFuncs returns the template functions. // getTmplFuncs returns the template functions.
func getTmplFuncs(consts *constants) template.FuncMap { func getTmplFuncs(consts *constants, i18n *i18n.I18n) template.FuncMap {
return template.FuncMap{ return template.FuncMap{
"RootURL": func() string { "RootURL": func() string {
return consts.AppBaseURL return consts.AppBaseURL
@@ -365,6 +366,9 @@ func getTmplFuncs(consts *constants) template.FuncMap {
"SiteName": func() string { "SiteName": func() string {
return consts.SiteName return consts.SiteName
}, },
"L": func() interface{} {
return i18n
},
} }
} }
@@ -381,7 +385,10 @@ func reloadSettings(app *App) error {
app.lo.Error("error unmarshalling settings from DB", "error", err) app.lo.Error("error unmarshalling settings from DB", "error", err)
return err return err
} }
if err := ko.Load(confmap.Provider(out, "."), nil); err != nil { app.Lock()
err = ko.Load(confmap.Provider(out, "."), nil)
app.Unlock()
if err != nil {
app.lo.Error("error loading settings into koanf", "error", err) app.lo.Error("error loading settings into koanf", "error", err)
return err return err
} }
@@ -393,7 +400,7 @@ func reloadSettings(app *App) error {
// reloadTemplates reloads the templates from the filesystem. // reloadTemplates reloads the templates from the filesystem.
func reloadTemplates(app *App) error { func reloadTemplates(app *App) error {
app.lo.Info("reloading templates") app.lo.Info("reloading templates")
funcMap := getTmplFuncs(app.consts.Load().(*constants)) funcMap := getTmplFuncs(app.consts.Load().(*constants), app.i18n)
tpls, err := stuffbin.ParseTemplatesGlob(funcMap, app.fs, "/static/email-templates/*.html") tpls, err := stuffbin.ParseTemplatesGlob(funcMap, app.fs, "/static/email-templates/*.html")
if err != nil { if err != nil {
app.lo.Error("error parsing email templates", "error", err) app.lo.Error("error parsing email templates", "error", err)

View File

@@ -3,7 +3,6 @@ package main
import ( import (
amodels "github.com/abhinavxd/libredesk/internal/auth/models" amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/envelope" "github.com/abhinavxd/libredesk/internal/envelope"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
realip "github.com/ferluci/fast-realip" realip "github.com/ferluci/fast-realip"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
"github.com/zerodha/fastglue" "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)) 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{ if err := app.auth.SaveSession(amodels.User{
ID: user.ID, ID: user.ID,
Email: user.Email.String, Email: user.Email.String,

View File

@@ -97,6 +97,8 @@ type App struct {
// Global state that stores data on an available app update. // Global state that stores data on an available app update.
update *AppUpdate update *AppUpdate
// Flag to indicate if app restart is required for settings to take effect.
restartRequired bool
sync.Mutex sync.Mutex
} }
@@ -239,7 +241,7 @@ func main() {
activityLog: initActivityLog(db, i18n), activityLog: initActivityLog(db, i18n),
customAttribute: initCustomAttribute(db, i18n), customAttribute: initCustomAttribute(db, i18n),
authz: initAuthz(i18n), authz: initAuthz(i18n),
view: initView(db), view: initView(db, i18n),
report: initReport(db, i18n), report: initReport(db, i18n),
csat: initCSAT(db, i18n), csat: initCSAT(db, i18n),
search: initSearch(db, i18n), search: initSearch(db, i18n),

View File

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

View File

@@ -11,16 +11,6 @@ import (
"github.com/zerodha/fastglue" "github.com/zerodha/fastglue"
) )
// handleGetAllEnabledOIDC returns all enabled OIDC records
func handleGetAllEnabledOIDC(r *fastglue.Request) error {
app := r.Context.(*App)
out, err := app.oidc.GetAllEnabled()
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(out)
}
// handleGetAllOIDC returns all OIDC records // handleGetAllOIDC returns all OIDC records
func handleGetAllOIDC(r *fastglue.Request) error { func handleGetAllOIDC(r *fastglue.Request) error {
app := r.Context.(*App) app := r.Context.(*App)

View File

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

View File

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

View File

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

View File

@@ -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 - Email Templates: templating.md
- SSO Setup: sso.md - SSO Setup: sso.md
- Webhooks: webhooks.md - Webhooks: webhooks.md
- API Getting Started: api-getting-started.md
- Contributions: - Contributions:
- Developer Setup: developer-setup.md - Developer Setup: developer-setup.md
- Translate Libredesk: translations.md - Translate Libredesk: translations.md

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,105 +1,139 @@
<template> <template>
<div class="max-w-5xl mx-auto p-6 min-h-screen"> <div class="max-w-5xl mx-auto p-6 min-h-screen">
<div class="space-y-8"> <Tabs :default-value="defaultTab" v-model="activeTab">
<div <TabsList class="grid w-full mb-6" :class="tabsGridClass">
v-for="(items, type) in results" <TabsTrigger v-for="(items, type) in results" :key="type" :value="type" class="capitalize">
:key="type" {{ type }} ({{ items.length }})
class="bg-card rounded shadow overflow-hidden" </TabsTrigger>
> </TabsList>
<!-- 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>
<!-- No results message --> <TabsContent v-for="(items, type) in results" :key="type" :value="type" class="mt-0">
<div v-if="items.length === 0" class="p-6 text-gray-500 dark:text-muted-foreground"> <div class="bg-background rounded border overflow-hidden">
{{ <!-- No results message -->
$t('globals.messages.noResults', { <div v-if="items.length === 0" class="p-8 text-center text-muted-foreground">
name: type <div class="text-lg font-medium mb-2">
}) {{
}} $t('globals.messages.noResults', {
</div> name: type
})
}}
</div>
<div class="text-sm">{{ $t('search.adjustSearchTerms') }}</div>
</div>
<!-- Results list --> <!-- Results list -->
<div class="divide-y divide-gray-200 dark:divide-border"> <div v-else class="divide-y divide-border">
<div <div
v-for="item in items" v-for="item in items"
:key="item.id || item.uuid" :key="item.id || item.uuid"
class="p-6 hover:bg-gray-100 dark:hover:bg-accent transition duration-300 ease-in-out group" class="p-6 hover:bg-accent/50 transition duration-200 ease-in-out group"
>
<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"> <router-link
<div class="flex-grow"> :to="{
<!-- Reference number --> name: 'inbox-conversation',
<div params: {
class="text-sm font-semibold mb-2 group-hover:text-primary dark:group-hover:text-primary transition duration-300" uuid: type === 'conversations' ? item.uuid : item.conversation_uuid,
> type: 'assigned'
#{{ }
type === 'conversations' }"
? item.reference_number class="block"
: item.conversation_reference_number >
}} <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> </div>
<!-- Content --> <!-- Right arrow icon -->
<div <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"
> >
{{ <ChevronRightIcon
truncateText(type === 'conversations' ? item.subject : item.text_content, 100) class="h-5 w-5 text-secondary-foreground group-hover:text-primary-foreground"
}} aria-hidden="true"
</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
)
}}
</div> </div>
</div> </div>
</router-link>
<!-- Right arrow icon --> </div>
<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>
</div> </div>
</div> </div>
</div> </TabsContent>
</div> </Tabs>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, ref, watch } from 'vue'
import { ChevronRightIcon, ClockIcon } from 'lucide-vue-next' import { ChevronRightIcon, ClockIcon } from 'lucide-vue-next'
import { format, parseISO } from 'date-fns' import { format, parseISO } from 'date-fns'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
defineProps({ const props = defineProps({
results: { results: {
type: Object, type: Object,
required: true 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 formatDate = (dateString) => {
const date = parseISO(dateString) const date = parseISO(dateString)
return format(date, 'MMM d, yyyy HH:mm') return format(date, 'MMM d, yyyy HH:mm')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

4
go.mod
View File

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

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/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 h1:51VEyMF8eOO+NUHFm8fpg+IOc1xFuFOhxs3R+kPu1FM=
github.com/redis/go-redis/v9 v9.5.5/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= 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 h1:pYfEe2wTjx8B2zFzUdy4kZn3I3Otd9ZvzIhHkFR85kE=
github.com/rhnvrm/simples3 v0.9.1/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA= github.com/rhnvrm/simples3 v0.9.1/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 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.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 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 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.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 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-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.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -140,6 +140,7 @@ func (e *Email) fetchAndProcessMessages(ctx context.Context, client *imapclient.
headerAutoSubmitted, headerAutoSubmitted,
headerAutoreply, headerAutoreply,
headerLibredeskLoopPrevention, headerLibredeskLoopPrevention,
headerMessageID,
}, },
}, },
}, },
@@ -147,10 +148,11 @@ func (e *Email) fetchAndProcessMessages(ctx context.Context, client *imapclient.
// Collect messages to process later. // Collect messages to process later.
type msgData struct { type msgData struct {
env *imap.Envelope env *imap.Envelope
seqNum uint32 seqNum uint32
autoReply bool autoReply bool
isLoop bool isLoop bool
extractedMessageID string
} }
var messages []msgData var messages []msgData
@@ -182,9 +184,10 @@ func (e *Email) fetchAndProcessMessages(ctx context.Context, client *imapclient.
} }
var ( var (
env *imap.Envelope env *imap.Envelope
autoReply bool autoReply bool
isLoop bool isLoop bool
extractedMessageID string
) )
// Process all fetch items for the current message. // Process all fetch items for the current message.
for { for {
@@ -215,6 +218,9 @@ func (e *Email) fetchAndProcessMessages(ctx context.Context, client *imapclient.
if isLoopMessage(envelope, inboxEmail) { if isLoopMessage(envelope, inboxEmail) {
isLoop = true isLoop = true
} }
// Extract Message-Id from raw headers as fallback for problematic Message IDs
extractedMessageID = extractMessageIDFromHeaders(envelope)
} }
// 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 { if env == nil {
e.lo.Warn("skipping message without envelope", "seq_num", msg.SeqNum, "inbox_id", e.Identifier())
continue 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. // Now process each collected message.
@@ -253,7 +260,7 @@ func (e *Email) fetchAndProcessMessages(ctx context.Context, client *imapclient.
} }
// Process the envelope. // 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) 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. // 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 { if len(env.From) == 0 {
e.lo.Warn("no sender received for email", "message_id", env.MessageID) e.lo.Warn("no sender received for email", "message_id", env.MessageID)
return nil return nil
} }
var fromAddress = strings.ToLower(env.From[0].Addr()) 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. // 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 { 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) return fmt.Errorf("checking if message exists in DB: %w", err)
} }
if exists { if exists {
@@ -291,7 +313,7 @@ func (e *Email) processEnvelope(ctx context.Context, client *imapclient.Client,
return nil 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. // Make contact.
firstName, lastName := getContactName(env.From[0]) firstName, lastName := getContactName(env.From[0])
@@ -350,7 +372,7 @@ func (e *Email) processEnvelope(ctx context.Context, client *imapclient.Client,
InboxID: inboxID, InboxID: inboxID,
Status: models.MessageStatusReceived, Status: models.MessageStatusReceived,
Subject: env.Subject, Subject: env.Subject,
SourceID: null.StringFrom(env.MessageID), SourceID: null.StringFrom(messageID),
Meta: meta, Meta: meta,
}, },
Contact: contact, Contact: contact,
@@ -385,7 +407,7 @@ func (e *Email) processEnvelope(ctx context.Context, client *imapclient.Client,
} }
if fullItem, ok := fullFetchItem.(imapclient.FetchItemDataBodySection); ok { 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) return e.processFullMessage(fullItem, incomingMsg)
} }
} }
@@ -534,3 +556,13 @@ func extractAllHTMLParts(part *enmime.Part) []string {
return htmlParts 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 -- name: get-active-inboxes
SELECT * from inboxes where enabled is TRUE and deleted_at is NULL; SELECT id, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, config, "from" FROM inboxes where enabled is TRUE and deleted_at is NULL;
-- name: get-all-inboxes -- name: get-all-inboxes
SELECT id, created_at, updated_at, name, channel, enabled from inboxes where deleted_at is NULL; SELECT id, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, config, "from" FROM inboxes where deleted_at is NULL;
-- name: insert-inbox -- name: insert-inbox
INSERT INTO inboxes INSERT INTO inboxes
@@ -11,7 +11,7 @@ VALUES($1, $2, $3, $4, $5)
RETURNING * RETURNING *
-- name: get-inbox -- name: get-inbox
SELECT * from inboxes where id = $1 and deleted_at is NULL; SELECT id, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, config, "from" FROM inboxes where id = $1 and deleted_at is NULL;
-- name: update -- name: update
UPDATE inboxes UPDATE inboxes
@@ -20,7 +20,7 @@ where id = $1 and deleted_at is NULL
RETURNING *; RETURNING *;
-- name: soft-delete -- name: soft-delete
UPDATE inboxes set deleted_at = now(), config = '{}' where id = $1 and deleted_at is NULL; UPDATE inboxes set deleted_at = now(), updated_at = now(), config = '{}' where id = $1 and deleted_at is NULL;
-- name: toggle -- name: toggle
UPDATE inboxes UPDATE inboxes

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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