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.
This commit is contained in:
Abhinav Raut
2025-08-28 00:34:56 +05:30
parent 0dec822c1c
commit 634fc66e9f
49 changed files with 505 additions and 454 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

2
go.mod
View File

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

View File

@@ -188,6 +188,7 @@
"globals.terms.recipient": "Recipient | Recipients",
"globals.terms.tls": "TLS | TLSs",
"globals.terms.credential": "Credential | Credentials",
"globals.messages.welcomeToLibredesk": "Welcome to Libredesk",
"globals.messages.invalid": "Invalid {name}",
"globals.messages.custom": "Custom {name}",
"globals.messages.replying": "Replying",
@@ -568,7 +569,6 @@
"search.noResultsForQuery": "No results found for query `{query}`. Try a different search term.",
"search.minQueryLength": " Please enter at least {length} characters to search.",
"search.searchBy": "Search by reference number, contact email address or messages in conversations.",
"search.adjustSearchTerms": "Try adjusting your search terms or filters.",
"sla.overdueBy": "Overdue by",
"sla.met": "SLA met",
"view.form.description": "Create and save custom filter views for quick access to your conversations.",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -930,7 +930,7 @@ func (m *Manager) ApplyAction(action amodels.RuleAction, conv models.Conversatio
if err != nil {
return fmt.Errorf("making recipients for reply action: %w", err)
}
_, err = m.SendReply(
_, err = m.QueueReply(
[]mmodels.Media{},
conv.InboxID,
user.ID,
@@ -1001,8 +1001,8 @@ func (m *Manager) SendCSATReply(actorUserID int, conversation models.Conversatio
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.csat}"), nil)
}
// Send CSAT reply.
_, err = m.SendReply(nil /**media**/, conversation.InboxID, actorUserID, conversation.UUID, message, to, cc, bcc, meta)
// Queue CSAT reply.
_, err = m.QueueReply(nil /**media**/, conversation.InboxID, actorUserID, conversation.UUID, message, to, cc, bcc, meta)
if err != nil {
m.lo.Error("error sending CSAT reply", "conversation_uuid", conversation.UUID, "error", err)
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.csat}"), nil)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,12 @@
-- 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
-- Skips the `client_secret` and returns all enabled OIDC configurations for login
SELECT id, name, enabled, provider, client_id, updated_at FROM oidc WHERE enabled = true order by updated_at desc;
-- name: get-oidc
SELECT * FROM oidc WHERE id = $1;
SELECT id, created_at, updated_at, name, provider_url, client_id, client_secret, enabled, provider FROM oidc WHERE id = $1;
-- name: insert-oidc
INSERT INTO oidc (name, provider, provider_url, client_id, client_secret)

View File

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

View File

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

View File

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

View File

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

View File

@@ -85,8 +85,8 @@ func (m *Manager) GetAllJSON() (types.JSONText, error) {
return b, nil
}
// Update updates settings.
func (m *Manager) Update(s interface{}) error {
// Update updates settings with the passed values.
func (m *Manager) Update(s any) error {
// Marshal settings.
b, err := json.Marshal(s)
if err != nil {
@@ -109,7 +109,7 @@ func (m *Manager) Update(s interface{}) error {
return nil
}
// GetByPrefix retrieves settings by prefix as JSON.
// GetByPrefix retrieves all settings start with the given prefix.
func (m *Manager) GetByPrefix(prefix string) (types.JSONText, error) {
var b types.JSONText
if err := m.q.GetByPrefix.Get(&b, prefix+"%"); err != nil {

View File

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

View File

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

View File

@@ -15,13 +15,25 @@ type Team struct {
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Emoji null.String `db:"emoji" json:"emoji"`
Name string `db:"name" json:"name"`
ConversationAssignmentType string `db:"conversation_assignment_type" json:"conversation_assignment_type,omitempty"`
Timezone string `db:"timezone" json:"timezone,omitempty"`
BusinessHoursID null.Int `db:"business_hours_id" json:"business_hours_id,omitempty"`
SLAPolicyID null.Int `db:"sla_policy_id" json:"sla_policy_id,omitempty"`
ConversationAssignmentType string `db:"conversation_assignment_type" json:"conversation_assignment_type"`
Timezone string `db:"timezone" json:"timezone"`
BusinessHoursID null.Int `db:"business_hours_id" json:"business_hours_id"`
SLAPolicyID null.Int `db:"sla_policy_id" json:"sla_policy_id"`
MaxAutoAssignedConversations int `db:"max_auto_assigned_conversations" json:"max_auto_assigned_conversations"`
}
type 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 Teams []Team
// Scan implements the sql.Scanner interface for Teams
@@ -44,15 +56,6 @@ func (t Teams) Value() (driver.Value, error) {
return json.Marshal(t)
}
// Names returns the names of the teams in Teams slice.
func (t Teams) Names() []string {
names := make([]string, len(t))
for i, team := range t {
names[i] = team.Name
}
return names
}
// IDs returns a slice of all team IDs in the Teams slice.
func (t Teams) IDs() []int {
ids := make([]int, len(t))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,6 +30,20 @@ const (
AwayAndReassigning = "away_and_reassigning"
)
type UserCompact struct {
ID int `db:"id" json:"id"`
Type string `db:"type" json:"type"`
FirstName string `db:"first_name" json:"first_name"`
LastName string `db:"last_name" json:"last_name"`
Email null.String `db:"email" json:"email"`
Enabled bool `db:"enabled" json:"enabled"`
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Total int `db:"total" json:"total"`
}
type User struct {
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
@@ -48,7 +62,6 @@ type User struct {
LastLoginAt null.Time `db:"last_login_at" json:"last_login_at"`
Roles pq.StringArray `db:"roles" json:"roles"`
Permissions pq.StringArray `db:"permissions" json:"permissions"`
Meta pq.StringArray `db:"meta" json:"meta"`
CustomAttributes json.RawMessage `db:"custom_attributes" json:"custom_attributes"`
Teams tmodels.Teams `db:"teams" json:"teams"`
ContactChannelID int `db:"contact_channel_id" json:"contact_channel_id,omitempty"`
@@ -62,8 +75,6 @@ type User struct {
APIKey null.String `db:"api_key" json:"api_key"`
APIKeyLastUsedAt null.Time `db:"api_key_last_used_at" json:"api_key_last_used_at"`
APISecret null.String `db:"api_secret" json:"-"`
Total int `json:"total,omitempty"`
}
type Note struct {

View File

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

View File

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

View File

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

View File

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