diff --git a/cmd/contacts.go b/cmd/contacts.go index 7c9f47b..d8ca8e6 100644 --- a/cmd/contacts.go +++ b/cmd/contacts.go @@ -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) } diff --git a/cmd/conversation.go b/cmd/conversation.go index 2962efa..f6ac22d 100644 --- a/cmd/conversation.go +++ b/cmd/conversation.go @@ -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 +} diff --git a/cmd/messages.go b/cmd/messages.go index 7ce1633..ecdddc4 100644 --- a/cmd/messages.go +++ b/cmd/messages.go @@ -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) } diff --git a/cmd/oidc.go b/cmd/oidc.go index 8c6c048..13fa252 100644 --- a/cmd/oidc.go +++ b/cmd/oidc.go @@ -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) } diff --git a/cmd/teams.go b/cmd/teams.go index 5d4046b..127e185 100644 --- a/cmd/teams.go +++ b/cmd/teams.go @@ -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) } diff --git a/cmd/users.go b/cmd/users.go index d62d899..8afebfe 100644 --- a/cmd/users.go +++ b/cmd/users.go @@ -26,34 +26,38 @@ const ( maxAvatarSizeMB = 2 ) -// Request structs for user-related endpoints - -// UpdateAvailabilityRequest represents the request to update user availability -type UpdateAvailabilityRequest struct { +type updateAvailabilityRequest struct { Status string `json:"status"` } -// ResetPasswordRequest represents the password reset request -type ResetPasswordRequest struct { +type resetPasswordRequest struct { Email string `json:"email"` } -// SetPasswordRequest represents the set password request -type SetPasswordRequest struct { +type setPasswordRequest struct { Token string `json:"token"` Password string `json:"password"` } -// AvailabilityRequest represents the request to update agent availability -type AvailabilityRequest struct { +type availabilityRequest struct { Status string `json:"status"` } +type agentReq struct { + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email"` + SendWelcomeEmail bool `json:"send_welcome_email"` + Teams []string `json:"teams"` + Roles []string `json:"roles"` + Enabled bool `json:"enabled"` + AvailabilityStatus string `json:"availability_status"` + NewPassword string `json:"new_password,omitempty"` +} + // handleGetAgents returns all agents. func handleGetAgents(r *fastglue.Request) error { - var ( - app = r.Context.(*App) - ) + var app = r.Context.(*App) agents, err := app.user.GetAgents() if err != nil { return sendErrorEnvelope(r, err) @@ -73,9 +77,7 @@ func handleGetAgentsCompact(r *fastglue.Request) error { // handleGetAgent returns an agent. func handleGetAgent(r *fastglue.Request) error { - var ( - app = r.Context.(*App) - ) + var app = r.Context.(*App) id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string)) if err != nil || id <= 0 { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError) @@ -93,7 +95,7 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error { app = r.Context.(*App) auser = r.RequestCtx.UserValue("user").(amodels.User) ip = realip.FromRequest(r.RequestCtx) - availReq AvailabilityRequest + availReq availabilityRequest ) // Decode JSON request @@ -101,6 +103,7 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError) } + // Fetch entire agent agent, err := app.user.GetAgent(auser.ID, "") if err != nil { return sendErrorEnvelope(r, err) @@ -108,10 +111,10 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error { // Same status? if agent.AvailabilityStatus == availReq.Status { - return r.SendEnvelope(true) + return r.SendEnvelope(agent) } - // Update availability status. + // Update availability status if err := app.user.UpdateAvailability(auser.ID, availReq.Status); err != nil { return sendErrorEnvelope(r, err) } @@ -123,21 +126,22 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error { } } - return r.SendEnvelope(true) + // Fetch updated agent and return + agent, err = app.user.GetAgent(auser.ID, "") + if err != nil { + return sendErrorEnvelope(r, err) + } + + return r.SendEnvelope(agent) } -// handleGetCurrentAgentTeams returns the teams of an agent. +// handleGetCurrentAgentTeams returns the teams of current agent. func handleGetCurrentAgentTeams(r *fastglue.Request) error { var ( app = r.Context.(*App) auser = r.RequestCtx.UserValue("user").(amodels.User) ) - agent, err := app.user.GetAgent(auser.ID, "") - if err != nil { - return sendErrorEnvelope(r, err) - } - - teams, err := app.team.GetUserTeams(agent.ID) + teams, err := app.team.GetUserTeams(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } @@ -150,11 +154,6 @@ func handleUpdateCurrentAgent(r *fastglue.Request) error { app = r.Context.(*App) auser = r.RequestCtx.UserValue("user").(amodels.User) ) - agent, err := app.user.GetAgent(auser.ID, "") - if err != nil { - return sendErrorEnvelope(r, err) - } - form, err := r.RequestCtx.MultipartForm() if err != nil { app.lo.Error("error parsing form data", "error", err) @@ -165,54 +164,53 @@ func handleUpdateCurrentAgent(r *fastglue.Request) error { // Upload avatar? if ok && len(files) > 0 { + 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 +} diff --git a/frontend/src/constants/user.js b/frontend/src/constants/user.js index 002b087..e6c88e1 100644 --- a/frontend/src/constants/user.js +++ b/frontend/src/constants/user.js @@ -1 +1,3 @@ -export const Roles = ["Admin", "Agent"] \ No newline at end of file +export const Roles = ["Admin", "Agent"] +export const UserTypeAgent = "agent" +export const UserTypeContact = "contact" \ No newline at end of file diff --git a/frontend/src/features/admin/agents/AgentForm.vue b/frontend/src/features/admin/agents/AgentForm.vue index 3a333ee..0565fc7 100644 --- a/frontend/src/features/admin/agents/AgentForm.vue +++ b/frontend/src/features/admin/agents/AgentForm.vue @@ -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) }) diff --git a/frontend/src/features/conversation/CreateConversation.vue b/frontend/src/features/conversation/CreateConversation.vue index c57115c..4ce2c66 100644 --- a/frontend/src/features/conversation/CreateConversation.vue +++ b/frontend/src/features/conversation/CreateConversation.vue @@ -10,7 +10,7 @@ }) }} - +
@@ -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 diff --git a/go.mod b/go.mod index 68057b4..d10a5a7 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/coreos/go-oidc/v3 v3.11.0 github.com/disintegration/imaging v1.6.2 github.com/emersion/go-imap/v2 v2.0.0-beta.3 + github.com/emersion/go-message v0.18.1 github.com/fasthttp/websocket v1.5.9 github.com/ferluci/fast-realip v1.0.1 github.com/google/uuid v1.6.0 @@ -49,7 +50,6 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/emersion/go-message v0.18.1 // indirect github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect github.com/fasthttp/router v1.5.0 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect diff --git a/i18n/en.json b/i18n/en.json index 2ece3b3..cafee17 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -188,6 +188,7 @@ "globals.terms.recipient": "Recipient | Recipients", "globals.terms.tls": "TLS | TLSs", "globals.terms.credential": "Credential | Credentials", + "globals.messages.welcomeToLibredesk": "Welcome to Libredesk", "globals.messages.invalid": "Invalid {name}", "globals.messages.custom": "Custom {name}", "globals.messages.replying": "Replying", @@ -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.", diff --git a/internal/activity_log/activity_log.go b/internal/activity_log/activity_log.go index 3fee6cf..f249aa9 100644 --- a/internal/activity_log/activity_log.go +++ b/internal/activity_log/activity_log.go @@ -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 ( diff --git a/internal/authz/authz.go b/internal/authz/authz.go index 0889041..559e22b 100644 --- a/internal/authz/authz.go +++ b/internal/authz/authz.go @@ -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 { diff --git a/internal/autoassigner/autoassigner.go b/internal/autoassigner/autoassigner.go index 31ce0c2..fd65948 100644 --- a/internal/autoassigner/autoassigner.go +++ b/internal/autoassigner/autoassigner.go @@ -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 diff --git a/internal/automation/queries.sql b/internal/automation/queries.sql index bf9815a..32aac56 100644 --- a/internal/automation/queries.sql +++ b/internal/automation/queries.sql @@ -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) diff --git a/internal/business_hours/queries.sql b/internal/business_hours/queries.sql index bf45495..e787214 100644 --- a/internal/business_hours/queries.sql +++ b/internal/business_hours/queries.sql @@ -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; diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index d6dcad0..0cf97c6 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -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) diff --git a/internal/conversation/message.go b/internal/conversation/message.go index afb84d8..befc6dc 100644 --- a/internal/conversation/message.go +++ b/internal/conversation/message.go @@ -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 diff --git a/internal/csat/csat.go b/internal/csat/csat.go index 58b6bef..f5895ce 100644 --- a/internal/csat/csat.go +++ b/internal/csat/csat.go @@ -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) } diff --git a/internal/csat/models/models.go b/internal/csat/models/models.go index 8b3addf..ff09ec1 100644 --- a/internal/csat/models/models.go +++ b/internal/csat/models/models.go @@ -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"` } diff --git a/internal/custom_attribute/models/models.go b/internal/custom_attribute/models/models.go index 4f9a19b..cc83ccb 100644 --- a/internal/custom_attribute/models/models.go +++ b/internal/custom_attribute/models/models.go @@ -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"` diff --git a/internal/custom_attribute/queries.sql b/internal/custom_attribute/queries.sql index 8377b3d..be3a209 100644 --- a/internal/custom_attribute/queries.sql +++ b/internal/custom_attribute/queries.sql @@ -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, diff --git a/internal/envelope/envelope.go b/internal/envelope/envelope.go index c02fb4e..4617c4d 100644 --- a/internal/envelope/envelope.go +++ b/internal/envelope/envelope.go @@ -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: diff --git a/internal/inbox/queries.sql b/internal/inbox/queries.sql index ade0873..4a7c54d 100644 --- a/internal/inbox/queries.sql +++ b/internal/inbox/queries.sql @@ -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 diff --git a/internal/macro/models/models.go b/internal/macro/models/models.go index 3cfa840..4cec3f1 100644 --- a/internal/macro/models/models.go +++ b/internal/macro/models/models.go @@ -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"` } diff --git a/internal/macro/queries.sql b/internal/macro/queries.sql index 28fabe4..3b23aff 100644 --- a/internal/macro/queries.sql +++ b/internal/macro/queries.sql @@ -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; \ No newline at end of file diff --git a/internal/media/media.go b/internal/media/media.go index 26c6318..3e42aeb 100644 --- a/internal/media/media.go +++ b/internal/media/media.go @@ -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 } diff --git a/internal/media/models/models.go b/internal/media/models/models.go index f4a0573..87baf82 100644 --- a/internal/media/models/models.go +++ b/internal/media/models/models.go @@ -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:"-"` } diff --git a/internal/media/queries.sql b/internal/media/queries.sql index a73197a..72ae803 100644 --- a/internal/media/queries.sql +++ b/internal/media/queries.sql @@ -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) diff --git a/internal/oidc/models/models.go b/internal/oidc/models/models.go index 82dcb86..7ead9c6 100644 --- a/internal/oidc/models/models.go +++ b/internal/oidc/models/models.go @@ -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 { diff --git a/internal/oidc/queries.sql b/internal/oidc/queries.sql index 946bf11..d428f39 100644 --- a/internal/oidc/queries.sql +++ b/internal/oidc/queries.sql @@ -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) diff --git a/internal/role/queries.sql b/internal/role/queries.sql index d4a5645..6f2296e 100644 --- a/internal/role/queries.sql +++ b/internal/role/queries.sql @@ -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; diff --git a/internal/role/role.go b/internal/role/role.go index 0df4c17..352d931 100644 --- a/internal/role/role.go +++ b/internal/role/role.go @@ -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 diff --git a/internal/search/models/models.go b/internal/search/models/models.go index 79f8fad..7fdcac2 100644 --- a/internal/search/models/models.go +++ b/internal/search/models/models.go @@ -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"` diff --git a/internal/search/search.go b/internal/search/search.go index 41dd371..ebb984e 100644 --- a/internal/search/search.go +++ b/internal/search/search.go @@ -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 -} \ No newline at end of file +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 +} diff --git a/internal/setting/setting.go b/internal/setting/setting.go index 1fa296a..e4ad3a9 100644 --- a/internal/setting/setting.go +++ b/internal/setting/setting.go @@ -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 { diff --git a/internal/stringutil/stringutil.go b/internal/stringutil/stringutil.go index 3be74e7..b28ab80 100644 --- a/internal/stringutil/stringutil.go +++ b/internal/stringutil/stringutil.go @@ -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{} diff --git a/internal/stringutil/stringutil_test.go b/internal/stringutil/stringutil_test.go index d8812e0..45a1815 100644 --- a/internal/stringutil/stringutil_test.go +++ b/internal/stringutil/stringutil_test.go @@ -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 diff --git a/internal/team/models/models.go b/internal/team/models/models.go index b542dd1..b7a9481 100644 --- a/internal/team/models/models.go +++ b/internal/team/models/models.go @@ -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)) diff --git a/internal/team/queries.sql b/internal/team/queries.sql index cbb2b71..ac85be7 100644 --- a/internal/team/queries.sql +++ b/internal/team/queries.sql @@ -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 diff --git a/internal/team/team.go b/internal/team/team.go index fad55d1..7919993 100644 --- a/internal/team/team.go +++ b/internal/team/team.go @@ -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 } diff --git a/internal/template/queries.sql b/internal/template/queries.sql index 66773e1..7c2eccb 100644 --- a/internal/template/queries.sql +++ b/internal/template/queries.sql @@ -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); \ No newline at end of file diff --git a/internal/user/agent.go b/internal/user/agent.go index 61fdc85..0e99adb 100644 --- a/internal/user/agent.go +++ b/internal/user/agent.go @@ -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", "") } diff --git a/internal/user/contact.go b/internal/user/contact.go index bef7063..b792175 100644 --- a/internal/user/contact.go +++ b/internal/user/contact.go @@ -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) } diff --git a/internal/user/models/models.go b/internal/user/models/models.go index 57ad3c3..dfe174c 100644 --- a/internal/user/models/models.go +++ b/internal/user/models/models.go @@ -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 { diff --git a/internal/user/notes.go b/internal/user/notes.go index a5b4075..915aad6 100644 --- a/internal/user/notes.go +++ b/internal/user/notes.go @@ -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. diff --git a/internal/user/queries.sql b/internal/user/queries.sql index 27d25b6..4c0d285 100644 --- a/internal/user/queries.sql +++ b/internal/user/queries.sql @@ -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; diff --git a/internal/user/user.go b/internal/user/user.go index 73e8dca..6babbe4 100644 --- a/internal/user/user.go +++ b/internal/user/user.go @@ -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 +} diff --git a/internal/webhook/models/models.go b/internal/webhook/models/models.go index 194334d..57643c7 100644 --- a/internal/webhook/models/models.go +++ b/internal/webhook/models/models.go @@ -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"` -}