mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-10-23 05:11:57 +00:00
WIP: manage contacts page
This commit is contained in:
@@ -77,7 +77,7 @@ func handleOIDCCallback(r *fastglue.Request) error {
|
||||
}
|
||||
|
||||
// Lookup the user by email and set the session.
|
||||
user, err := app.user.GetAgentByEmail(claims.Email)
|
||||
user, err := app.user.GetAgent(0, claims.Email)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
160
cmd/contacts.go
Normal file
160
cmd/contacts.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/abhinavxd/libredesk/internal/user/models"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/volatiletech/null/v9"
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
// handleGetContacts returns a list of contacts from the database.
|
||||
func handleGetContacts(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
order = string(r.RequestCtx.QueryArgs().Peek("order"))
|
||||
orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by"))
|
||||
filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
|
||||
page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
|
||||
pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size")))
|
||||
total = 0
|
||||
)
|
||||
contacts, err := app.user.GetContacts(page, pageSize, order, orderBy, filters)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
if len(contacts) > 0 {
|
||||
total = contacts[0].Total
|
||||
}
|
||||
return r.SendEnvelope(envelope.PageResults{
|
||||
Results: contacts,
|
||||
Total: total,
|
||||
PerPage: pageSize,
|
||||
TotalPages: (total + pageSize - 1) / pageSize,
|
||||
Page: page,
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetTags returns a contact from the database.
|
||||
func handleGetContact(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
)
|
||||
if id <= 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
|
||||
}
|
||||
c, err := app.user.GetContact(id, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(c)
|
||||
}
|
||||
|
||||
// handleUpdateContact updates a contact in the database.
|
||||
func handleUpdateContact(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
)
|
||||
if id <= 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
form, err := r.RequestCtx.MultipartForm()
|
||||
if err != nil {
|
||||
app.lo.Error("error parsing form data", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
// Parse form data
|
||||
firstName := ""
|
||||
if v, ok := form.Value["first_name"]; ok && len(v) > 0 {
|
||||
firstName = string(v[0])
|
||||
}
|
||||
lastName := ""
|
||||
if v, ok := form.Value["last_name"]; ok && len(v) > 0 {
|
||||
lastName = string(v[0])
|
||||
}
|
||||
email := ""
|
||||
if v, ok := form.Value["email"]; ok && len(v) > 0 {
|
||||
email = string(v[0])
|
||||
}
|
||||
phoneNumber := ""
|
||||
if v, ok := form.Value["phone_number"]; ok && len(v) > 0 {
|
||||
phoneNumber = string(v[0])
|
||||
}
|
||||
phoneNumberCallingCode := ""
|
||||
if v, ok := form.Value["phone_number_calling_code"]; ok && len(v) > 0 {
|
||||
phoneNumberCallingCode = string(v[0])
|
||||
}
|
||||
enabled := false
|
||||
if v, ok := form.Value["enabled"]; ok && len(v) > 0 {
|
||||
enabled = string(v[0]) == "true"
|
||||
}
|
||||
|
||||
user := models.User{
|
||||
FirstName: firstName,
|
||||
LastName: lastName,
|
||||
Email: null.StringFrom(email),
|
||||
PhoneNumber: null.StringFrom(phoneNumber),
|
||||
PhoneNumberCallingCode: null.StringFrom(phoneNumberCallingCode),
|
||||
Enabled: enabled,
|
||||
}
|
||||
|
||||
if err := app.user.UpdateContact(id, user); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Upload avatar?
|
||||
files, ok := form.File["files"]
|
||||
if ok && len(files) > 0 {
|
||||
contact, err := app.user.GetContact(id, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
if err := uploadUserAvatar(r, &contact, files); err != nil {
|
||||
app.lo.Error("error uploading avatar", "error", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
// handleDeleteContactAvatar deletes contact avatar from storage and database.
|
||||
func handleDeleteContactAvatar(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
)
|
||||
|
||||
if id <= 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
// Get user
|
||||
contact, err := app.user.GetContact(id, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Valid str?
|
||||
if contact.AvatarURL.String == "" {
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
fileName := filepath.Base(contact.AvatarURL.String)
|
||||
|
||||
// Delete file from the store.
|
||||
if err := app.media.Delete(fileName); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
if err = app.user.UpdateAvatar(contact.ID, ""); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
}
|
@@ -130,7 +130,7 @@ func handleGetViewConversations(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("conversation.viewPermissionDenied"), nil, envelope.PermissionError)
|
||||
}
|
||||
|
||||
user, err := app.user.GetAgent(auser.ID)
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -229,7 +229,7 @@ func handleGetConversation(r *fastglue.Request) error {
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
|
||||
user, err := app.user.GetAgent(auser.ID)
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -251,7 +251,7 @@ func handleUpdateConversationAssigneeLastSeen(r *fastglue.Request) error {
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
user, err := app.user.GetAgent(auser.ID)
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -272,7 +272,7 @@ func handleGetConversationParticipants(r *fastglue.Request) error {
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
user, err := app.user.GetAgent(auser.ID)
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -299,7 +299,7 @@ func handleUpdateUserAssignee(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`assignee_id`"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
user, err := app.user.GetAgent(auser.ID)
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -331,7 +331,7 @@ func handleUpdateTeamAssignee(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`assignee_id`"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
user, err := app.user.GetAgent(auser.ID)
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -367,7 +367,7 @@ func handleUpdateConversationPriority(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`priority`"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
user, err := app.user.GetAgent(auser.ID)
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -410,7 +410,7 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
|
||||
}
|
||||
|
||||
// Enforce conversation access.
|
||||
user, err := app.user.GetAgent(auser.ID)
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -463,7 +463,7 @@ func handleUpdateConversationtags(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
user, err := app.user.GetAgent(auser.ID)
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -525,7 +525,7 @@ func handleRemoveUserAssignee(r *fastglue.Request) error {
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
user, err := app.user.GetAgent(auser.ID)
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -546,7 +546,7 @@ func handleRemoveTeamAssignee(r *fastglue.Request) error {
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
user, err := app.user.GetAgent(auser.ID)
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -601,7 +601,7 @@ func handleCreateConversation(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`first_name`"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
user, err := app.user.GetAgent(auser.ID)
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
@@ -79,7 +79,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
||||
g.DELETE("/api/v1/statuses/{id}", perm(handleDeleteStatus, "status:manage"))
|
||||
g.GET("/api/v1/priorities", auth(handleGetPriorities))
|
||||
|
||||
// Tag.
|
||||
// Tags.
|
||||
g.GET("/api/v1/tags", auth(handleGetTags))
|
||||
g.POST("/api/v1/tags", perm(handleCreateTag, "tags:manage"))
|
||||
g.PUT("/api/v1/tags/{id}", perm(handleUpdateTag, "tags:manage"))
|
||||
@@ -93,22 +93,30 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
||||
g.DELETE("/api/v1/macros/{id}", perm(handleDeleteMacro, "macros:manage"))
|
||||
g.POST("/api/v1/conversations/{uuid}/macros/{id}/apply", auth(handleApplyMacro))
|
||||
|
||||
// User.
|
||||
g.GET("/api/v1/users/me", auth(handleGetCurrentUser))
|
||||
g.PUT("/api/v1/users/me", auth(handleUpdateCurrentUser))
|
||||
g.GET("/api/v1/users/me/teams", auth(handleGetCurrentUserTeams))
|
||||
g.PUT("/api/v1/users/me/availability", auth(handleUpdateUserAvailability))
|
||||
g.DELETE("/api/v1/users/me/avatar", auth(handleDeleteAvatar))
|
||||
g.GET("/api/v1/users/compact", auth(handleGetUsersCompact))
|
||||
g.GET("/api/v1/users", perm(handleGetUsers, "users:manage"))
|
||||
g.GET("/api/v1/users/{id}", perm(handleGetUser, "users:manage"))
|
||||
g.POST("/api/v1/users", perm(handleCreateUser, "users:manage"))
|
||||
g.PUT("/api/v1/users/{id}", perm(handleUpdateUser, "users:manage"))
|
||||
g.DELETE("/api/v1/users/{id}", perm(handleDeleteUser, "users:manage"))
|
||||
g.POST("/api/v1/users/reset-password", tryAuth(handleResetPassword))
|
||||
g.POST("/api/v1/users/set-password", tryAuth(handleSetPassword))
|
||||
// Agents.
|
||||
g.GET("/api/v1/agents/me", auth(handleGetCurrentAgent))
|
||||
g.PUT("/api/v1/agents/me", auth(handleUpdateCurrentAgent))
|
||||
g.GET("/api/v1/agents/me/teams", auth(handleGetCurrentAgentTeams))
|
||||
g.PUT("/api/v1/agents/me/availability", auth(handleUpdateAgentAvailability))
|
||||
g.DELETE("/api/v1/agents/me/avatar", auth(handleDeleteCurrentAgentAvatar))
|
||||
|
||||
// Team.
|
||||
g.GET("/api/v1/agents/compact", auth(handleGetAgentsCompact))
|
||||
g.GET("/api/v1/agents", perm(handleGetAgents, "users:manage"))
|
||||
g.GET("/api/v1/agents/{id}", perm(handleGetAgent, "users:manage"))
|
||||
g.POST("/api/v1/agents", perm(handleCreateAgent, "users:manage"))
|
||||
g.PUT("/api/v1/agents/{id}", perm(handleUpdateAgent, "users:manage"))
|
||||
g.DELETE("/api/v1/agents/{id}", perm(handleDeleteAgent, "users:manage"))
|
||||
g.POST("/api/v1/agents/reset-password", tryAuth(handleResetPassword))
|
||||
g.POST("/api/v1/agents/set-password", tryAuth(handleSetPassword))
|
||||
|
||||
// Contacts.
|
||||
// TODO: Add permission checks for contacts.
|
||||
g.GET("/api/v1/contacts", handleGetContacts)
|
||||
g.GET("/api/v1/contacts/{id}", handleGetContact)
|
||||
g.PUT("/api/v1/contacts/{id}", handleUpdateContact)
|
||||
g.DELETE("/api/v1/contacts/{id}/avatar", handleDeleteContactAvatar)
|
||||
|
||||
// Teams.
|
||||
g.GET("/api/v1/teams/compact", auth(handleGetTeamsCompact))
|
||||
g.GET("/api/v1/teams", perm(handleGetTeams, "teams:manage"))
|
||||
g.GET("/api/v1/teams/{id}", perm(handleGetTeam, "teams:manage"))
|
||||
@@ -119,17 +127,17 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
||||
// i18n.
|
||||
g.GET("/api/v1/lang/{lang}", handleGetI18nLang)
|
||||
|
||||
// Automation.
|
||||
g.GET("/api/v1/automation/rules", perm(handleGetAutomationRules, "automations:manage"))
|
||||
g.GET("/api/v1/automation/rules/{id}", perm(handleGetAutomationRule, "automations:manage"))
|
||||
g.POST("/api/v1/automation/rules", perm(handleCreateAutomationRule, "automations:manage"))
|
||||
g.PUT("/api/v1/automation/rules/{id}/toggle", perm(handleToggleAutomationRule, "automations:manage"))
|
||||
g.PUT("/api/v1/automation/rules/{id}", perm(handleUpdateAutomationRule, "automations:manage"))
|
||||
g.PUT("/api/v1/automation/rules/weights", perm(handleUpdateAutomationRuleWeights, "automations:manage"))
|
||||
g.PUT("/api/v1/automation/rules/execution-mode", perm(handleUpdateAutomationRuleExecutionMode, "automations:manage"))
|
||||
g.DELETE("/api/v1/automation/rules/{id}", perm(handleDeleteAutomationRule, "automations:manage"))
|
||||
// Automations.
|
||||
g.GET("/api/v1/automations/rules", perm(handleGetAutomationRules, "automations:manage"))
|
||||
g.GET("/api/v1/automations/rules/{id}", perm(handleGetAutomationRule, "automations:manage"))
|
||||
g.POST("/api/v1/automations/rules", perm(handleCreateAutomationRule, "automations:manage"))
|
||||
g.PUT("/api/v1/automations/rules/{id}/toggle", perm(handleToggleAutomationRule, "automations:manage"))
|
||||
g.PUT("/api/v1/automations/rules/{id}", perm(handleUpdateAutomationRule, "automations:manage"))
|
||||
g.PUT("/api/v1/automations/rules/weights", perm(handleUpdateAutomationRuleWeights, "automations:manage"))
|
||||
g.PUT("/api/v1/automations/rules/execution-mode", perm(handleUpdateAutomationRuleExecutionMode, "automations:manage"))
|
||||
g.DELETE("/api/v1/automations/rules/{id}", perm(handleDeleteAutomationRule, "automations:manage"))
|
||||
|
||||
// Inbox.
|
||||
// Inboxes.
|
||||
g.GET("/api/v1/inboxes", auth(handleGetInboxes))
|
||||
g.GET("/api/v1/inboxes/{id}", perm(handleGetInbox, "inboxes:manage"))
|
||||
g.POST("/api/v1/inboxes", perm(handleCreateInbox, "inboxes:manage"))
|
||||
@@ -137,18 +145,18 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
||||
g.PUT("/api/v1/inboxes/{id}", perm(handleUpdateInbox, "inboxes:manage"))
|
||||
g.DELETE("/api/v1/inboxes/{id}", perm(handleDeleteInbox, "inboxes:manage"))
|
||||
|
||||
// Role.
|
||||
// Roles.
|
||||
g.GET("/api/v1/roles", perm(handleGetRoles, "roles:manage"))
|
||||
g.GET("/api/v1/roles/{id}", perm(handleGetRole, "roles:manage"))
|
||||
g.POST("/api/v1/roles", perm(handleCreateRole, "roles:manage"))
|
||||
g.PUT("/api/v1/roles/{id}", perm(handleUpdateRole, "roles:manage"))
|
||||
g.DELETE("/api/v1/roles/{id}", perm(handleDeleteRole, "roles:manage"))
|
||||
|
||||
// Dashboard.
|
||||
// Reports.
|
||||
g.GET("/api/v1/reports/overview/counts", perm(handleDashboardCounts, "reports:manage"))
|
||||
g.GET("/api/v1/reports/overview/charts", perm(handleDashboardCharts, "reports:manage"))
|
||||
|
||||
// Template.
|
||||
// Templates.
|
||||
g.GET("/api/v1/templates", perm(handleGetTemplates, "templates:manage"))
|
||||
g.GET("/api/v1/templates/{id}", perm(handleGetTemplate, "templates:manage"))
|
||||
g.POST("/api/v1/templates", perm(handleCreateTemplate, "templates:manage"))
|
||||
@@ -162,14 +170,14 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
||||
g.PUT("/api/v1/business-hours/{id}", perm(handleUpdateBusinessHours, "business_hours:manage"))
|
||||
g.DELETE("/api/v1/business-hours/{id}", perm(handleDeleteBusinessHour, "business_hours:manage"))
|
||||
|
||||
// SLA.
|
||||
// SLAs.
|
||||
g.GET("/api/v1/sla", perm(handleGetSLAs, "sla:manage"))
|
||||
g.GET("/api/v1/sla/{id}", perm(handleGetSLA, "sla:manage"))
|
||||
g.POST("/api/v1/sla", perm(handleCreateSLA, "sla:manage"))
|
||||
g.PUT("/api/v1/sla/{id}", perm(handleUpdateSLA, "sla:manage"))
|
||||
g.DELETE("/api/v1/sla/{id}", perm(handleDeleteSLA, "sla:manage"))
|
||||
|
||||
// AI completion.
|
||||
// AI completions.
|
||||
g.GET("/api/v1/ai/prompts", auth(handleGetAIPrompts))
|
||||
g.POST("/api/v1/ai/completion", auth(handleAICompletion))
|
||||
g.PUT("/api/v1/ai/provider", perm(handleUpdateAIProvider, "ai:manage"))
|
||||
|
13
cmd/init.go
13
cmd/init.go
@@ -526,7 +526,7 @@ func initNotifier() *notifier.Service {
|
||||
}
|
||||
|
||||
// initEmailInbox initializes the email inbox.
|
||||
func initEmailInbox(inboxRecord imodels.Inbox, store inbox.MessageStore) (inbox.Inbox, error) {
|
||||
func initEmailInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore) (inbox.Inbox, error) {
|
||||
var config email.Config
|
||||
|
||||
// Load JSON data into Koanf.
|
||||
@@ -552,7 +552,7 @@ func initEmailInbox(inboxRecord imodels.Inbox, store inbox.MessageStore) (inbox.
|
||||
log.Printf("WARNING: No `from` email address set for `%s` inbox: Name: `%s`", inboxRecord.Channel, inboxRecord.Name)
|
||||
}
|
||||
|
||||
inbox, err := email.New(store, email.Opts{
|
||||
inbox, err := email.New(msgStore, usrStore, email.Opts{
|
||||
ID: inboxRecord.ID,
|
||||
Config: config,
|
||||
Lo: initLogger("email_inbox"),
|
||||
@@ -568,10 +568,10 @@ func initEmailInbox(inboxRecord imodels.Inbox, store inbox.MessageStore) (inbox.
|
||||
}
|
||||
|
||||
// initializeInboxes handles inbox initialization.
|
||||
func initializeInboxes(inboxR imodels.Inbox, store inbox.MessageStore) (inbox.Inbox, error) {
|
||||
func initializeInboxes(inboxR imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore) (inbox.Inbox, error) {
|
||||
switch inboxR.Channel {
|
||||
case "email":
|
||||
return initEmailInbox(inboxR, store)
|
||||
return initEmailInbox(inboxR, msgStore, usrStore)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown inbox channel: %s", inboxR.Channel)
|
||||
}
|
||||
@@ -584,8 +584,9 @@ func reloadInboxes(app *App) error {
|
||||
}
|
||||
|
||||
// startInboxes registers the active inboxes and starts receiver for each.
|
||||
func startInboxes(ctx context.Context, mgr *inbox.Manager, store inbox.MessageStore) {
|
||||
mgr.SetMessageStore(store)
|
||||
func startInboxes(ctx context.Context, mgr *inbox.Manager, msgStore inbox.MessageStore, usrStore inbox.UserStore) {
|
||||
mgr.SetMessageStore(msgStore)
|
||||
mgr.SetUserStore(usrStore)
|
||||
|
||||
if err := mgr.InitInboxes(initializeInboxes); err != nil {
|
||||
log.Fatalf("error initializing inboxes: %v", err)
|
||||
|
@@ -139,7 +139,7 @@ func handleApplyMacro(r *fastglue.Request) error {
|
||||
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
incomingActions = []autoModels.RuleAction{}
|
||||
)
|
||||
user, err := app.user.GetAgent(auser.ID)
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -233,7 +233,7 @@ func setDisplayValues(app *App, actions []autoModels.RuleAction) error {
|
||||
return t.Name, nil
|
||||
},
|
||||
autoModels.ActionAssignUser: func(id int) (string, error) {
|
||||
u, err := app.user.GetAgent(id)
|
||||
u, err := app.user.GetAgent(id, "")
|
||||
if err != nil {
|
||||
app.lo.Warn("user not found for macro action", "user_id", id)
|
||||
return "", err
|
||||
|
@@ -185,7 +185,7 @@ func main() {
|
||||
)
|
||||
automation.SetConversationStore(conversation)
|
||||
|
||||
startInboxes(ctx, inbox, conversation)
|
||||
startInboxes(ctx, inbox, conversation, user)
|
||||
go automation.Run(ctx, automationWorkers)
|
||||
go autoassigner.Run(ctx, autoAssignInterval)
|
||||
go conversation.Run(ctx, messageIncomingQWorkers, messageOutgoingQWorkers, messageOutgoingScanInterval)
|
||||
|
@@ -150,7 +150,7 @@ func handleServeMedia(r *fastglue.Request) error {
|
||||
uuid = r.RequestCtx.UserValue("uuid").(string)
|
||||
)
|
||||
|
||||
user, err := app.user.GetAgent(auser.ID)
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
@@ -30,7 +30,7 @@ func handleGetMessages(r *fastglue.Request) error {
|
||||
total = 0
|
||||
)
|
||||
|
||||
user, err := app.user.GetAgent(auser.ID)
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -73,7 +73,7 @@ func handleGetMessage(r *fastglue.Request) error {
|
||||
cuuid = r.RequestCtx.UserValue("cuuid").(string)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
user, err := app.user.GetAgent(auser.ID)
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -108,7 +108,7 @@ func handleRetryMessage(r *fastglue.Request) error {
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
|
||||
user, err := app.user.GetAgent(auser.ID)
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -135,7 +135,7 @@ func handleSendMessage(r *fastglue.Request) error {
|
||||
req = messageReq{}
|
||||
)
|
||||
|
||||
user, err := app.user.GetAgent(auser.ID)
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
@@ -24,7 +24,7 @@ func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
||||
}
|
||||
|
||||
// Try to get user.
|
||||
user, err := app.user.GetAgent(userSession.ID)
|
||||
user, err := app.user.GetAgent(userSession.ID, "")
|
||||
if err != nil {
|
||||
return handler(r)
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
||||
}
|
||||
|
||||
// Set user in the request context.
|
||||
user, err := app.user.GetAgent(userSession.ID)
|
||||
user, err := app.user.GetAgent(userSession.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -92,7 +92,7 @@ func perm(handler fastglue.FastRequestHandler, perm string) fastglue.FastRequest
|
||||
}
|
||||
|
||||
// Get user from DB.
|
||||
user, err := app.user.GetAgent(sessUser.ID)
|
||||
user, err := app.user.GetAgent(sessUser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
15
cmd/tags.go
15
cmd/tags.go
@@ -9,17 +9,19 @@ import (
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
// handleGetTags returns all tags from the database.
|
||||
func handleGetTags(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
)
|
||||
t, err := app.tag.GetAll()
|
||||
if err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(t)
|
||||
}
|
||||
|
||||
// handleCreateTag creates a new tag in the database.
|
||||
func handleCreateTag(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -33,14 +35,14 @@ func handleCreateTag(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
err := app.tag.Create(tag.Name)
|
||||
if err != nil {
|
||||
if err := app.tag.Create(tag.Name); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
// handleDeleteTag deletes a tag from the database.
|
||||
func handleDeleteTag(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -50,14 +52,14 @@ func handleDeleteTag(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
err = app.tag.Delete(id)
|
||||
if err != nil {
|
||||
if err = app.tag.Delete(id); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
// handleUpdateTag updates an existing tag in the database.
|
||||
func handleUpdateTag(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -76,8 +78,7 @@ func handleUpdateTag(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
err = app.tag.Update(id, tag.Name)
|
||||
if err != nil {
|
||||
if err = app.tag.Update(id, tag.Name); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
|
226
cmd/users.go
226
cmd/users.go
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
@@ -22,33 +23,33 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
maxAvatarSizeMB = 20
|
||||
maxAvatarSizeMB = 5
|
||||
)
|
||||
|
||||
// handleGetUsers returns all users.
|
||||
func handleGetUsers(r *fastglue.Request) error {
|
||||
// handleGetAgents returns all agents.
|
||||
func handleGetAgents(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
)
|
||||
agents, err := app.user.GetAll()
|
||||
agents, err := app.user.GetAgents()
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(agents)
|
||||
}
|
||||
|
||||
// handleGetUsersCompact returns all users in a compact format.
|
||||
func handleGetUsersCompact(r *fastglue.Request) error {
|
||||
// handleGetAgentsCompact returns all agents in a compact format.
|
||||
func handleGetAgentsCompact(r *fastglue.Request) error {
|
||||
var app = r.Context.(*App)
|
||||
agents, err := app.user.GetAllCompact()
|
||||
agents, err := app.user.GetAgentsCompact()
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(agents)
|
||||
}
|
||||
|
||||
// handleGetUser returns a user.
|
||||
func handleGetUser(r *fastglue.Request) error {
|
||||
// handleGetAgent returns an agent.
|
||||
func handleGetAgent(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
)
|
||||
@@ -56,15 +57,15 @@ func handleGetUser(r *fastglue.Request) error {
|
||||
if err != nil || id <= 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
|
||||
}
|
||||
user, err := app.user.GetAgent(id)
|
||||
agent, err := app.user.GetAgent(id, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(user)
|
||||
return r.SendEnvelope(agent)
|
||||
}
|
||||
|
||||
// handleUpdateUserAvailability updates the current user availability.
|
||||
func handleUpdateUserAvailability(r *fastglue.Request) error {
|
||||
// handleUpdateAgentAvailability updates the current agent availability.
|
||||
func handleUpdateAgentAvailability(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
@@ -76,31 +77,31 @@ func handleUpdateUserAvailability(r *fastglue.Request) error {
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
// handleGetCurrentUserTeams returns the teams of a user.
|
||||
func handleGetCurrentUserTeams(r *fastglue.Request) error {
|
||||
// handleGetCurrentAgentTeams returns the teams of an agent.
|
||||
func handleGetCurrentAgentTeams(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
user, err := app.user.GetAgent(auser.ID)
|
||||
agent, err := app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
teams, err := app.team.GetUserTeams(user.ID)
|
||||
teams, err := app.team.GetUserTeams(agent.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(teams)
|
||||
}
|
||||
|
||||
// handleUpdateCurrentUser updates the current user.
|
||||
func handleUpdateCurrentUser(r *fastglue.Request) error {
|
||||
// handleUpdateCurrentAgent updates the current agent.
|
||||
func handleUpdateCurrentAgent(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
user, err := app.user.GetAgent(auser.ID)
|
||||
agent, err := app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -115,69 +116,15 @@ func handleUpdateCurrentUser(r *fastglue.Request) error {
|
||||
|
||||
// Upload avatar?
|
||||
if ok && len(files) > 0 {
|
||||
fileHeader := files[0]
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
app.lo.Error("error reading uploaded", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorReading", "name", "{globals.terms.file}"), nil, envelope.GeneralError)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Sanitize filename.
|
||||
srcFileName := stringutil.SanitizeFilename(fileHeader.Filename)
|
||||
srcContentType := fileHeader.Header.Get("Content-Type")
|
||||
srcFileSize := fileHeader.Size
|
||||
srcExt := strings.TrimPrefix(strings.ToLower(filepath.Ext(srcFileName)), ".")
|
||||
|
||||
if !slices.Contains(image.Exts, srcExt) {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.fileTypeisNotAnImage"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
// Check file size
|
||||
if bytesToMegabytes(srcFileSize) > maxAvatarSizeMB {
|
||||
app.lo.Error("error uploaded file size is larger than max allowed", "size", bytesToMegabytes(srcFileSize), "max_allowed", maxAvatarSizeMB)
|
||||
return r.SendErrorEnvelope(
|
||||
http.StatusRequestEntityTooLarge,
|
||||
app.i18n.Ts("media.fileSizeTooLarge", "size", fmt.Sprintf("%dMB", maxAvatarSizeMB)),
|
||||
nil,
|
||||
envelope.GeneralError,
|
||||
)
|
||||
}
|
||||
|
||||
// Reset ptr.
|
||||
file.Seek(0, 0)
|
||||
linkedModel := null.StringFrom(mmodels.ModelUser)
|
||||
linkedID := null.IntFrom(user.ID)
|
||||
disposition := null.NewString("", false)
|
||||
contentID := ""
|
||||
meta := []byte("{}")
|
||||
media, err := app.media.UploadAndInsert(srcFileName, srcContentType, contentID, linkedModel, linkedID, file, int(srcFileSize), disposition, meta)
|
||||
if err != nil {
|
||||
app.lo.Error("error uploading file", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.file}"), nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
// Delete current avatar.
|
||||
if user.AvatarURL.Valid {
|
||||
fileName := filepath.Base(user.AvatarURL.String)
|
||||
app.media.Delete(fileName)
|
||||
}
|
||||
|
||||
// Save file path.
|
||||
path, err := stringutil.GetPathFromURL(media.URL)
|
||||
if err != nil {
|
||||
app.lo.Debug("error getting path from URL", "url", media.URL, "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.file}"), nil, envelope.GeneralError)
|
||||
}
|
||||
if err := app.user.UpdateAvatar(user.ID, path); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
if err := uploadUserAvatar(r, &agent, files); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
// handleCreateUser creates a new user.
|
||||
func handleCreateUser(r *fastglue.Request) error {
|
||||
// handleCreateAgent creates a new agent.
|
||||
func handleCreateAgent(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
user = models.User{}
|
||||
@@ -240,15 +187,15 @@ func handleCreateUser(r *fastglue.Request) error {
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
// handleUpdateUser updates a user.
|
||||
func handleUpdateUser(r *fastglue.Request) error {
|
||||
// handleUpdateAgent updates an agent.
|
||||
func handleUpdateAgent(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
user = models.User{}
|
||||
)
|
||||
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
if err != nil || id == 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "{globals.terms.user} `id`"), nil, envelope.InputError)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if err := r.Decode(&user, "json"); err != nil {
|
||||
@@ -267,12 +214,12 @@ func handleUpdateUser(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
// Update user.
|
||||
if err = app.user.Update(id, user); err != nil {
|
||||
// Update agent.
|
||||
if err = app.user.UpdateAgent(id, user); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Upsert user teams.
|
||||
// Upsert agent teams.
|
||||
if err := app.team.UpsertUserTeams(id, user.Teams.Names()); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -280,18 +227,24 @@ func handleUpdateUser(r *fastglue.Request) error {
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
// handleDeleteUser soft deletes a user.
|
||||
func handleDeleteUser(r *fastglue.Request) error {
|
||||
// handleDeleteAgent soft deletes an agent.
|
||||
func handleDeleteAgent(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
id, err = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
if err != nil || id == 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "{globals.terms.user} `id`"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
// Disallow if self-deleting.
|
||||
if id == auser.ID {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("user.userCannotDeleteSelf"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
// Soft delete user.
|
||||
if err = app.user.SoftDelete(id); err != nil {
|
||||
if err = app.user.SoftDeleteAgent(id); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
@@ -300,54 +253,54 @@ func handleDeleteUser(r *fastglue.Request) error {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
return r.SendEnvelope("User deleted successfully.")
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
// handleGetCurrentUser returns the current logged in user.
|
||||
func handleGetCurrentUser(r *fastglue.Request) error {
|
||||
// handleGetCurrentAgent returns the current logged in agent.
|
||||
func handleGetCurrentAgent(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
u, err := app.user.GetAgent(auser.ID)
|
||||
u, err := app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(u)
|
||||
}
|
||||
|
||||
// handleDeleteAvatar deletes a user avatar.
|
||||
func handleDeleteAvatar(r *fastglue.Request) error {
|
||||
// handleDeleteCurrentAgentAvatar deletes the current agent's avatar.
|
||||
func handleDeleteCurrentAgentAvatar(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
|
||||
// Get user
|
||||
user, err := app.user.GetAgent(auser.ID)
|
||||
agent, err := app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Valid str?
|
||||
if user.AvatarURL.String == "" {
|
||||
return r.SendEnvelope("Avatar deleted successfully.")
|
||||
if agent.AvatarURL.String == "" {
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
fileName := filepath.Base(user.AvatarURL.String)
|
||||
fileName := filepath.Base(agent.AvatarURL.String)
|
||||
|
||||
// Delete file from the store.
|
||||
if err := app.media.Delete(fileName); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
if err = app.user.UpdateAvatar(user.ID, ""); err != nil {
|
||||
if err = app.user.UpdateAvatar(agent.ID, ""); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
// handleResetPassword generates a reset password token and sends an email to the user.
|
||||
// handleResetPassword generates a reset password token and sends an email to the agent.
|
||||
func handleResetPassword(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
@@ -363,13 +316,13 @@ func handleResetPassword(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
user, err := app.user.GetAgentByEmail(email)
|
||||
agent, err := app.user.GetAgent(0, email)
|
||||
if err != nil {
|
||||
// Send 200 even if user not found, to prevent email enumeration.
|
||||
return r.SendEnvelope("Reset password email sent successfully.")
|
||||
}
|
||||
|
||||
token, err := app.user.SetResetPasswordToken(user.ID)
|
||||
token, err := app.user.SetResetPasswordToken(agent.ID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -384,7 +337,7 @@ func handleResetPassword(r *fastglue.Request) error {
|
||||
}
|
||||
|
||||
if err := app.notifier.Send(notifier.Message{
|
||||
RecipientEmails: []string{user.Email.String},
|
||||
RecipientEmails: []string{agent.Email.String},
|
||||
Subject: "Reset Password",
|
||||
Content: content,
|
||||
Provider: notifier.ProviderEmail,
|
||||
@@ -400,13 +353,13 @@ func handleResetPassword(r *fastglue.Request) error {
|
||||
func handleSetPassword(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
user, ok = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
agent, ok = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
p = r.RequestCtx.PostArgs()
|
||||
password = string(p.Peek("password"))
|
||||
token = string(p.Peek("token"))
|
||||
)
|
||||
|
||||
if ok && user.ID > 0 {
|
||||
if ok && agent.ID > 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("user.userAlreadyLoggedIn"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
@@ -420,3 +373,68 @@ func handleSetPassword(r *fastglue.Request) error {
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
// uploadUserAvatar uploads the user avatar.
|
||||
func uploadUserAvatar(r *fastglue.Request, user *models.User, files []*multipart.FileHeader) error {
|
||||
var app = r.Context.(*App)
|
||||
|
||||
fileHeader := files[0]
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
app.lo.Error("error opening uploaded file", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorReading", "name", "{globals.terms.file}"), nil, envelope.GeneralError)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Sanitize filename.
|
||||
srcFileName := stringutil.SanitizeFilename(fileHeader.Filename)
|
||||
srcContentType := fileHeader.Header.Get("Content-Type")
|
||||
srcFileSize := fileHeader.Size
|
||||
srcExt := strings.TrimPrefix(strings.ToLower(filepath.Ext(srcFileName)), ".")
|
||||
|
||||
if !slices.Contains(image.Exts, srcExt) {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.fileTypeisNotAnImage"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
// Check file size
|
||||
if bytesToMegabytes(srcFileSize) > maxAvatarSizeMB {
|
||||
app.lo.Error("error uploaded file size is larger than max allowed", "size", bytesToMegabytes(srcFileSize), "max_allowed", maxAvatarSizeMB)
|
||||
return r.SendErrorEnvelope(
|
||||
http.StatusRequestEntityTooLarge,
|
||||
app.i18n.Ts("media.fileSizeTooLarge", "size", fmt.Sprintf("%dMB", maxAvatarSizeMB)),
|
||||
nil,
|
||||
envelope.GeneralError,
|
||||
)
|
||||
}
|
||||
|
||||
// Reset ptr.
|
||||
file.Seek(0, 0)
|
||||
linkedModel := null.StringFrom(mmodels.ModelUser)
|
||||
linkedID := null.IntFrom(user.ID)
|
||||
disposition := null.NewString("", false)
|
||||
contentID := ""
|
||||
meta := []byte("{}")
|
||||
media, err := app.media.UploadAndInsert(srcFileName, srcContentType, contentID, linkedModel, linkedID, file, int(srcFileSize), disposition, meta)
|
||||
if err != nil {
|
||||
app.lo.Error("error uploading file", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.file}"), nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
// Delete current avatar.
|
||||
if user.AvatarURL.Valid {
|
||||
fileName := filepath.Base(user.AvatarURL.String)
|
||||
app.media.Delete(fileName)
|
||||
}
|
||||
|
||||
// Save file path.
|
||||
path, err := stringutil.GetPathFromURL(media.URL)
|
||||
if err != nil {
|
||||
app.lo.Debug("error getting path from URL", "url", media.URL, "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.file}"), nil, envelope.GeneralError)
|
||||
}
|
||||
fmt.Println("path", path)
|
||||
if err := app.user.UpdateAvatar(user.ID, path); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
@@ -16,7 +16,7 @@ func handleGetUserViews(r *fastglue.Request) error {
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
)
|
||||
user, err := app.user.GetAgent(auser.ID)
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -37,7 +37,7 @@ func handleCreateUserView(r *fastglue.Request) error {
|
||||
if err := r.Decode(&view, "json"); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
|
||||
}
|
||||
user, err := app.user.GetAgent(auser.ID)
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -63,7 +63,7 @@ func handleDeleteUserView(r *fastglue.Request) error {
|
||||
if err != nil || id <= 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
|
||||
}
|
||||
user, err := app.user.GetAgent(auser.ID)
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func handleUpdateUserView(r *fastglue.Request) error {
|
||||
if err := r.Decode(&view, "json"); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
|
||||
}
|
||||
user, err := app.user.GetAgent(auser.ID)
|
||||
user, err := app.user.GetAgent(auser.ID, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
@@ -8,7 +8,6 @@
|
||||
"baseColor": "gray",
|
||||
"cssVariables": true
|
||||
},
|
||||
"framework": "vite",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
|
@@ -45,6 +45,7 @@
|
||||
"pinia": "^2.1.7",
|
||||
"qs": "^6.12.1",
|
||||
"radix-vue": "^1.9.17",
|
||||
"reka-ui": "^2.2.0",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"vee-validate": "^4.13.2",
|
||||
"vue": "^3.4.37",
|
||||
|
76
frontend/pnpm-lock.yaml
generated
76
frontend/pnpm-lock.yaml
generated
@@ -95,6 +95,9 @@ importers:
|
||||
radix-vue:
|
||||
specifier: ^1.9.17
|
||||
version: 1.9.17(vue@3.5.13(typescript@5.7.3))
|
||||
reka-ui:
|
||||
specifier: ^2.2.0
|
||||
version: 2.2.0(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3))
|
||||
tailwind-merge:
|
||||
specifier: ^2.3.0
|
||||
version: 2.6.0
|
||||
@@ -764,6 +767,9 @@ packages:
|
||||
'@tanstack/virtual-core@3.11.2':
|
||||
resolution: {integrity: sha512-vTtpNt7mKCiZ1pwU9hfKPhpdVO2sVzFQsxoVBGtOSHxlrRRzYr8iQ2TlwbAcRYCcEiZ9ECAM8kBzH0v2+VzfKw==}
|
||||
|
||||
'@tanstack/virtual-core@3.13.6':
|
||||
resolution: {integrity: sha512-cnQUeWnhNP8tJ4WsGcYiX24Gjkc9ALstLbHcBj1t3E7EimN6n6kHH+DPV4PpDnuw00NApQp+ViojMj1GRdwYQg==}
|
||||
|
||||
'@tanstack/vue-table@8.20.5':
|
||||
resolution: {integrity: sha512-2xixT3BEgSDw+jOSqPt6ylO/eutDI107t2WdFMVYIZZ45UmTHLySqNriNs0+dMaKR56K5z3t+97P6VuVnI2L+Q==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -775,6 +781,11 @@ packages:
|
||||
peerDependencies:
|
||||
vue: ^2.7.0 || ^3.0.0
|
||||
|
||||
'@tanstack/vue-virtual@3.13.6':
|
||||
resolution: {integrity: sha512-GYdZ3SJBQPzgxhuCE2fvpiH46qzHiVx5XzBSdtESgiqh4poj8UgckjGWYEhxaBbcVt1oLzh1m3Ql4TyH32TOzQ==}
|
||||
peerDependencies:
|
||||
vue: ^2.7.0 || ^3.0.0
|
||||
|
||||
'@tiptap/core@2.11.2':
|
||||
resolution: {integrity: sha512-Z437c/sQg31yrRVgLJVkQuih+7Og5tjRx6FE/zE47QgEayqQ9yXH0LrTAbPiY6IfY1X+f2A0h3e5Y/WGD6rC3Q==}
|
||||
peerDependencies:
|
||||
@@ -1123,6 +1134,9 @@ packages:
|
||||
'@types/web-bluetooth@0.0.20':
|
||||
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
|
||||
|
||||
'@types/web-bluetooth@0.0.21':
|
||||
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
|
||||
|
||||
@@ -1209,18 +1223,27 @@ packages:
|
||||
'@vueuse/core@12.4.0':
|
||||
resolution: {integrity: sha512-XnjQYcJwCsyXyIafyA6SvyN/OBtfPnjvJmbxNxQjCcyWD198urwm5TYvIUUyAxEAN0K7HJggOgT15cOlWFyLeA==}
|
||||
|
||||
'@vueuse/core@12.8.2':
|
||||
resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==}
|
||||
|
||||
'@vueuse/metadata@10.11.1':
|
||||
resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==}
|
||||
|
||||
'@vueuse/metadata@12.4.0':
|
||||
resolution: {integrity: sha512-AhPuHs/qtYrKHUlEoNO6zCXufu8OgbR8S/n2oMw1OQuBQJ3+HOLQ+EpvXs+feOlZMa0p8QVvDWNlmcJJY8rW2g==}
|
||||
|
||||
'@vueuse/metadata@12.8.2':
|
||||
resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==}
|
||||
|
||||
'@vueuse/shared@10.11.1':
|
||||
resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==}
|
||||
|
||||
'@vueuse/shared@12.4.0':
|
||||
resolution: {integrity: sha512-9yLgbHVIF12OSCojnjTIoZL1+UA10+O4E1aD6Hpfo/DKVm5o3SZIwz6CupqGy3+IcKI8d6Jnl26EQj/YucnW0Q==}
|
||||
|
||||
'@vueuse/shared@12.8.2':
|
||||
resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==}
|
||||
|
||||
'@withtypes/mime@0.1.2':
|
||||
resolution: {integrity: sha512-PB9BfZGzwblUONJY0LiOwsHCA6uV3DIPj/w9ReekdHxPOl0VdUFgI5s4avKycuuq9Gf5Nz2ZPA2O36GAUzlMPA==}
|
||||
|
||||
@@ -2479,6 +2502,9 @@ packages:
|
||||
resolution: {integrity: sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
ohash@2.0.11:
|
||||
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
|
||||
|
||||
once@1.4.0:
|
||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||
|
||||
@@ -2770,6 +2796,11 @@ packages:
|
||||
regenerator-runtime@0.14.1:
|
||||
resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==}
|
||||
|
||||
reka-ui@2.2.0:
|
||||
resolution: {integrity: sha512-eeRrLI4LwJ6dkdwks6KFNKGs0+beqZlHO3JMHen7THDTh+yJ5Z0KNwONmOhhV/0hZC2uJCEExgG60QPzGstkQg==}
|
||||
peerDependencies:
|
||||
vue: '>= 3.2.0'
|
||||
|
||||
request-progress@3.0.0:
|
||||
resolution: {integrity: sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==}
|
||||
|
||||
@@ -3801,6 +3832,8 @@ snapshots:
|
||||
|
||||
'@tanstack/virtual-core@3.11.2': {}
|
||||
|
||||
'@tanstack/virtual-core@3.13.6': {}
|
||||
|
||||
'@tanstack/vue-table@8.20.5(vue@3.5.13(typescript@5.7.3))':
|
||||
dependencies:
|
||||
'@tanstack/table-core': 8.20.5
|
||||
@@ -3811,6 +3844,11 @@ snapshots:
|
||||
'@tanstack/virtual-core': 3.11.2
|
||||
vue: 3.5.13(typescript@5.7.3)
|
||||
|
||||
'@tanstack/vue-virtual@3.13.6(vue@3.5.13(typescript@5.7.3))':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.13.6
|
||||
vue: 3.5.13(typescript@5.7.3)
|
||||
|
||||
'@tiptap/core@2.11.2(@tiptap/pm@2.11.2)':
|
||||
dependencies:
|
||||
'@tiptap/pm': 2.11.2
|
||||
@@ -4204,6 +4242,8 @@ snapshots:
|
||||
|
||||
'@types/web-bluetooth@0.0.20': {}
|
||||
|
||||
'@types/web-bluetooth@0.0.21': {}
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
dependencies:
|
||||
'@types/node': 22.10.5
|
||||
@@ -4377,10 +4417,21 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
'@vueuse/core@12.8.2(typescript@5.7.3)':
|
||||
dependencies:
|
||||
'@types/web-bluetooth': 0.0.21
|
||||
'@vueuse/metadata': 12.8.2
|
||||
'@vueuse/shared': 12.8.2(typescript@5.7.3)
|
||||
vue: 3.5.13(typescript@5.7.3)
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
'@vueuse/metadata@10.11.1': {}
|
||||
|
||||
'@vueuse/metadata@12.4.0': {}
|
||||
|
||||
'@vueuse/metadata@12.8.2': {}
|
||||
|
||||
'@vueuse/shared@10.11.1(vue@3.5.13(typescript@5.7.3))':
|
||||
dependencies:
|
||||
vue-demi: 0.14.10(vue@3.5.13(typescript@5.7.3))
|
||||
@@ -4394,6 +4445,12 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
'@vueuse/shared@12.8.2(typescript@5.7.3)':
|
||||
dependencies:
|
||||
vue: 3.5.13(typescript@5.7.3)
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
'@withtypes/mime@0.1.2':
|
||||
dependencies:
|
||||
mime: 3.0.0
|
||||
@@ -5728,6 +5785,8 @@ snapshots:
|
||||
|
||||
object-inspect@1.13.3: {}
|
||||
|
||||
ohash@2.0.11: {}
|
||||
|
||||
once@1.4.0:
|
||||
dependencies:
|
||||
wrappy: 1.0.2
|
||||
@@ -6041,6 +6100,23 @@ snapshots:
|
||||
|
||||
regenerator-runtime@0.14.1: {}
|
||||
|
||||
reka-ui@2.2.0(typescript@5.7.3)(vue@3.5.13(typescript@5.7.3)):
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.6.13
|
||||
'@floating-ui/vue': 1.1.6(vue@3.5.13(typescript@5.7.3))
|
||||
'@internationalized/date': 3.6.0
|
||||
'@internationalized/number': 3.6.0
|
||||
'@tanstack/vue-virtual': 3.13.6(vue@3.5.13(typescript@5.7.3))
|
||||
'@vueuse/core': 12.8.2(typescript@5.7.3)
|
||||
'@vueuse/shared': 12.8.2(typescript@5.7.3)
|
||||
aria-hidden: 1.2.4
|
||||
defu: 6.1.4
|
||||
ohash: 2.0.11
|
||||
vue: 3.5.13(typescript@5.7.3)
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
- typescript
|
||||
|
||||
request-progress@3.0.0:
|
||||
dependencies:
|
||||
throttleit: 1.0.1
|
||||
|
@@ -14,12 +14,10 @@
|
||||
</router-link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem v-if="userStore.hasAdminTabPermissions">
|
||||
<SidebarMenuButton asChild :isActive="route.path.startsWith('/admin')">
|
||||
<router-link
|
||||
:to="{ name: userStore.can('general_settings:manage') ? 'general' : 'admin' }"
|
||||
>
|
||||
<Shield />
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild :isActive="route.path.startsWith('/contacts')">
|
||||
<router-link :to="{ name: 'contacts' }">
|
||||
<BookUser />
|
||||
</router-link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
@@ -30,6 +28,15 @@
|
||||
</router-link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem v-if="userStore.hasAdminTabPermissions">
|
||||
<SidebarMenuButton asChild :isActive="route.path.startsWith('/admin')">
|
||||
<router-link
|
||||
:to="{ name: userStore.can('general_settings:manage') ? 'general' : 'admin' }"
|
||||
>
|
||||
<Shield />
|
||||
</router-link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
@@ -96,7 +103,7 @@ import { toast as sooner } from 'vue-sonner'
|
||||
import Sidebar from '@/components/sidebar/Sidebar.vue'
|
||||
import Command from '@/features/command/CommandBox.vue'
|
||||
import CreateConversation from '@/features/conversation/CreateConversation.vue'
|
||||
import { Inbox, Shield, FileLineChart } from 'lucide-vue-next'
|
||||
import { Inbox, Shield, FileLineChart, BookUser } from 'lucide-vue-next'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
import {
|
||||
|
@@ -36,9 +36,6 @@ http.interceptors.request.use((request) => {
|
||||
const searchConversations = (params) => http.get('/api/v1/conversations/search', { params })
|
||||
const searchMessages = (params) => http.get('/api/v1/messages/search', { params })
|
||||
const searchContacts = (params) => http.get('/api/v1/contacts/search', { params })
|
||||
const resetPassword = (data) => http.post('/api/v1/users/reset-password', data)
|
||||
const setPassword = (data) => http.post('/api/v1/users/set-password', data)
|
||||
const deleteUser = (id) => http.delete(`/api/v1/users/${id}`)
|
||||
const getEmailNotificationSettings = () => http.get('/api/v1/settings/notifications/email')
|
||||
const updateEmailNotificationSettings = (data) => http.put('/api/v1/settings/notifications/email', data)
|
||||
const getPriorities = () => http.get('/api/v1/priorities')
|
||||
@@ -119,31 +116,31 @@ const updateSettings = (key, data) =>
|
||||
const getSettings = (key) => http.get(`/api/v1/settings/${key}`)
|
||||
const login = (data) => http.post(`/api/v1/login`, data)
|
||||
const getAutomationRules = (type) =>
|
||||
http.get(`/api/v1/automation/rules`, {
|
||||
http.get(`/api/v1/automations/rules`, {
|
||||
params: { type: type }
|
||||
})
|
||||
const toggleAutomationRule = (id) => http.put(`/api/v1/automation/rules/${id}/toggle`)
|
||||
const getAutomationRule = (id) => http.get(`/api/v1/automation/rules/${id}`)
|
||||
const toggleAutomationRule = (id) => http.put(`/api/v1/automations/rules/${id}/toggle`)
|
||||
const getAutomationRule = (id) => http.get(`/api/v1/automations/rules/${id}`)
|
||||
const updateAutomationRule = (id, data) =>
|
||||
http.put(`/api/v1/automation/rules/${id}`, data, {
|
||||
http.put(`/api/v1/automations/rules/${id}`, data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const createAutomationRule = (data) =>
|
||||
http.post(`/api/v1/automation/rules`, data, {
|
||||
http.post(`/api/v1/automations/rules`, data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const deleteAutomationRule = (id) => http.delete(`/api/v1/automation/rules/${id}`)
|
||||
const deleteAutomationRule = (id) => http.delete(`/api/v1/automations/rules/${id}`)
|
||||
const updateAutomationRuleWeights = (data) =>
|
||||
http.put(`/api/v1/automation/rules/weights`, data, {
|
||||
http.put(`/api/v1/automations/rules/weights`, data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const updateAutomationRulesExecutionMode = (data) => http.put(`/api/v1/automation/rules/execution-mode`, data)
|
||||
const updateAutomationRulesExecutionMode = (data) => http.put(`/api/v1/automations/rules/execution-mode`, data)
|
||||
const getRoles = () => http.get('/api/v1/roles')
|
||||
const getRole = (id) => http.get(`/api/v1/roles/${id}`)
|
||||
const createRole = (data) =>
|
||||
@@ -159,26 +156,47 @@ const updateRole = (id, data) =>
|
||||
}
|
||||
})
|
||||
const deleteRole = (id) => http.delete(`/api/v1/roles/${id}`)
|
||||
const getUser = (id) => http.get(`/api/v1/users/${id}`)
|
||||
const getContacts = (params) => http.get('/api/v1/contacts', { params })
|
||||
const getContact = (id) => http.get(`/api/v1/contacts/${id}`)
|
||||
const updateContact = (id, data) => http.put(`/api/v1/contacts/${id}`, data, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
const getTeam = (id) => http.get(`/api/v1/teams/${id}`)
|
||||
const getTeams = () => http.get('/api/v1/teams')
|
||||
const updateTeam = (id, data) => http.put(`/api/v1/teams/${id}`, data)
|
||||
const createTeam = (data) => http.post('/api/v1/teams', data)
|
||||
const getTeamsCompact = () => http.get('/api/v1/teams/compact')
|
||||
const deleteTeam = (id) => http.delete(`/api/v1/teams/${id}`)
|
||||
|
||||
const getUsers = () => http.get('/api/v1/users')
|
||||
const getUsersCompact = () => http.get('/api/v1/users/compact')
|
||||
const updateUser = (id, data) =>
|
||||
http.put(`/api/v1/agents/${id}`, data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const getUsers = () => http.get('/api/v1/agents')
|
||||
const getUsersCompact = () => http.get('/api/v1/agents/compact')
|
||||
const updateCurrentUser = (data) =>
|
||||
http.put('/api/v1/users/me', data, {
|
||||
http.put('/api/v1/agents/me', data, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
const deleteUserAvatar = () => http.delete('/api/v1/users/me/avatar')
|
||||
const getCurrentUser = () => http.get('/api/v1/users/me')
|
||||
const getCurrentUserTeams = () => http.get('/api/v1/users/me/teams')
|
||||
const updateCurrentUserAvailability = (data) => http.put('/api/v1/users/me/availability', data)
|
||||
const getUser = (id) => http.get(`/api/v1/agents/${id}`)
|
||||
const deleteUserAvatar = () => http.delete('/api/v1/agents/me/avatar')
|
||||
const getCurrentUser = () => http.get('/api/v1/agents/me')
|
||||
const getCurrentUserTeams = () => http.get('/api/v1/agents/me/teams')
|
||||
const updateCurrentUserAvailability = (data) => http.put('/api/v1/agents/me/availability', data)
|
||||
const resetPassword = (data) => http.post('/api/v1/agents/reset-password', data)
|
||||
const setPassword = (data) => http.post('/api/v1/agents/set-password', data)
|
||||
const deleteUser = (id) => http.delete(`/api/v1/agents/${id}`)
|
||||
const createUser = (data) =>
|
||||
http.post('/api/v1/agents', data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const getTags = () => http.get('/api/v1/tags')
|
||||
const upsertTags = (uuid, data) => http.post(`/api/v1/conversations/${uuid}/tags`, data)
|
||||
const updateAssignee = (uuid, assignee_type, data) => http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}`, data)
|
||||
@@ -231,18 +249,6 @@ const uploadMedia = (data) =>
|
||||
const getOverviewCounts = () => http.get('/api/v1/reports/overview/counts')
|
||||
const getOverviewCharts = () => http.get('/api/v1/reports/overview/charts')
|
||||
const getLanguage = (lang) => http.get(`/api/v1/lang/${lang}`)
|
||||
const createUser = (data) =>
|
||||
http.post('/api/v1/users', data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const updateUser = (id, data) =>
|
||||
http.put(`/api/v1/users/${id}`, data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const createInbox = (data) =>
|
||||
http.post('/api/v1/inboxes', data, {
|
||||
headers: {
|
||||
@@ -390,4 +396,7 @@ export default {
|
||||
searchMessages,
|
||||
searchContacts,
|
||||
removeAssignee,
|
||||
getContacts,
|
||||
getContact,
|
||||
updateContact,
|
||||
}
|
||||
|
@@ -1,5 +1,10 @@
|
||||
<script setup>
|
||||
import { adminNavItems, reportsNavItems, accountNavItems } from '@/constants/navigation'
|
||||
import {
|
||||
adminNavItems,
|
||||
reportsNavItems,
|
||||
accountNavItems,
|
||||
contactNavItems
|
||||
} from '@/constants/navigation'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import {
|
||||
@@ -64,6 +69,7 @@ const deleteView = (view) => {
|
||||
|
||||
const filteredAdminNavItems = computed(() => filterNavItems(adminNavItems, userStore.can))
|
||||
const filteredReportsNavItems = computed(() => filterNavItems(reportsNavItems, userStore.can))
|
||||
const filteredContactsNavItems = computed(() => filterNavItems(contactNavItems, userStore.can))
|
||||
|
||||
const isActiveParent = (parentHref) => {
|
||||
return route.path.startsWith(parentHref)
|
||||
@@ -84,6 +90,44 @@ const viewInboxOpen = useStorage('viewInboxOpen', true)
|
||||
:default-open="sidebarOpen"
|
||||
v-on:update:open="sidebarOpen = $event"
|
||||
>
|
||||
<!-- Contacts sidebar -->
|
||||
<template
|
||||
v-if="
|
||||
route.matched.some((record) => record.name && record.name.startsWith('contact'))
|
||||
"
|
||||
>
|
||||
<Sidebar collapsible="offcanvas" class="border-r ml-12">
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton :isActive="isActiveParent('/contacts')" asChild>
|
||||
<div>
|
||||
<span class="font-semibold text-xl">
|
||||
{{ t('globals.terms.contact', 2) }}
|
||||
</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarSeparator />
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem v-for="item in filteredContactsNavItems" :key="item.titleKey">
|
||||
<SidebarMenuButton :isActive="isActiveParent(item.href)" asChild>
|
||||
<router-link :to="item.href">
|
||||
<span>{{ t(item.titleKey) }}</span>
|
||||
</router-link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
</template>
|
||||
|
||||
<!-- Reports sidebar -->
|
||||
<template
|
||||
v-if="
|
||||
|
58
frontend/src/components/ui/avatar/AvatarUpload.vue
Normal file
58
frontend/src/components/ui/avatar/AvatarUpload.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="relative group w-28 h-28 cursor-pointer" @click="triggerFileInput">
|
||||
<Avatar class="size-28">
|
||||
<AvatarImage :src="src || ''" />
|
||||
<AvatarFallback>{{ initials }}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<!-- Hover Overlay -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer rounded-full"
|
||||
>
|
||||
<span class="text-white font-semibold">{{ label }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Delete Icon -->
|
||||
<X
|
||||
class="absolute top-1 right-1 bg-white rounded-full p-1 shadow-md z-10 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
size="20"
|
||||
@click.stop="emit('remove')"
|
||||
/>
|
||||
|
||||
<!-- File Input -->
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
class="hidden"
|
||||
accept="image/png,image/jpeg,image/jpg"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { X } from 'lucide-vue-next'
|
||||
|
||||
defineProps({
|
||||
src: String,
|
||||
initials: String,
|
||||
label: {
|
||||
type: String,
|
||||
default: 'Upload'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['upload', 'remove'])
|
||||
const fileInput = ref(null)
|
||||
|
||||
function triggerFileInput() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
function handleChange(e) {
|
||||
const file = e.target.files[0]
|
||||
if (file) emit('upload', file)
|
||||
}
|
||||
</script>
|
@@ -3,6 +3,7 @@ import { cva } from 'class-variance-authority'
|
||||
export { default as Avatar } from './Avatar.vue'
|
||||
export { default as AvatarImage } from './AvatarImage.vue'
|
||||
export { default as AvatarFallback } from './AvatarFallback.vue'
|
||||
export { default as AvatarUpload } from './AvatarUpload.vue'
|
||||
|
||||
export const avatarVariant = cva(
|
||||
'inline-flex items-center justify-center font-normal text-foreground select-none shrink-0 bg-secondary overflow-hidden',
|
||||
|
@@ -5,7 +5,7 @@
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
:aria-expanded="open"
|
||||
class="w-full justify-between"
|
||||
:class="['w-full justify-between', buttonClass]"
|
||||
>
|
||||
<slot name="selected" :selected="selectedItem">{{ selectedLabel }}</slot>
|
||||
<CaretSortIcon class="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
@@ -58,7 +58,11 @@ const props = defineProps({
|
||||
required: true
|
||||
},
|
||||
placeholder: String,
|
||||
defaultLabel: String
|
||||
defaultLabel: String,
|
||||
buttonClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select'])
|
||||
|
29
frontend/src/components/ui/pagination/PaginationEllipsis.vue
Normal file
29
frontend/src/components/ui/pagination/PaginationEllipsis.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
import { cn } from '@/lib/utils';
|
||||
import { DotsHorizontalIcon } from '@radix-icons/vue';
|
||||
import { PaginationEllipsis } from 'reka-ui';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props;
|
||||
|
||||
return delegated;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PaginationEllipsis
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('w-9 h-9 flex items-center justify-center', props.class)"
|
||||
>
|
||||
<slot>
|
||||
<DotsHorizontalIcon />
|
||||
</slot>
|
||||
</PaginationEllipsis>
|
||||
</template>
|
29
frontend/src/components/ui/pagination/PaginationFirst.vue
Normal file
29
frontend/src/components/ui/pagination/PaginationFirst.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronsLeft } from 'lucide-vue-next';
|
||||
import { PaginationFirst } from 'reka-ui';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
asChild: { type: Boolean, required: false, default: true },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props;
|
||||
|
||||
return delegated;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PaginationFirst v-bind="delegatedProps">
|
||||
<Button :class="cn('w-9 h-9 p-0', props.class)" variant="outline">
|
||||
<slot>
|
||||
<ChevronsLeft />
|
||||
</slot>
|
||||
</Button>
|
||||
</PaginationFirst>
|
||||
</template>
|
29
frontend/src/components/ui/pagination/PaginationLast.vue
Normal file
29
frontend/src/components/ui/pagination/PaginationLast.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronsRight } from 'lucide-vue-next';
|
||||
import { PaginationLast } from 'reka-ui';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
asChild: { type: Boolean, required: false, default: true },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props;
|
||||
|
||||
return delegated;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PaginationLast v-bind="delegatedProps">
|
||||
<Button :class="cn('w-9 h-9 p-0', props.class)" variant="outline">
|
||||
<slot>
|
||||
<ChevronsRight />
|
||||
</slot>
|
||||
</Button>
|
||||
</PaginationLast>
|
||||
</template>
|
29
frontend/src/components/ui/pagination/PaginationNext.vue
Normal file
29
frontend/src/components/ui/pagination/PaginationNext.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronRightIcon } from '@radix-icons/vue';
|
||||
import { PaginationNext } from 'reka-ui';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
asChild: { type: Boolean, required: false, default: true },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props;
|
||||
|
||||
return delegated;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PaginationNext v-bind="delegatedProps">
|
||||
<Button :class="cn('w-9 h-9 p-0', props.class)" variant="outline">
|
||||
<slot>
|
||||
<ChevronRightIcon />
|
||||
</slot>
|
||||
</Button>
|
||||
</PaginationNext>
|
||||
</template>
|
29
frontend/src/components/ui/pagination/PaginationPrev.vue
Normal file
29
frontend/src/components/ui/pagination/PaginationPrev.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronLeftIcon } from '@radix-icons/vue';
|
||||
import { PaginationPrev } from 'reka-ui';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
asChild: { type: Boolean, required: false, default: true },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props;
|
||||
|
||||
return delegated;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PaginationPrev v-bind="delegatedProps">
|
||||
<Button :class="cn('w-9 h-9 p-0', props.class)" variant="outline">
|
||||
<slot>
|
||||
<ChevronLeftIcon />
|
||||
</slot>
|
||||
</Button>
|
||||
</PaginationPrev>
|
||||
</template>
|
10
frontend/src/components/ui/pagination/index.js
Normal file
10
frontend/src/components/ui/pagination/index.js
Normal file
@@ -0,0 +1,10 @@
|
||||
export { default as PaginationEllipsis } from './PaginationEllipsis.vue';
|
||||
export { default as PaginationFirst } from './PaginationFirst.vue';
|
||||
export { default as PaginationLast } from './PaginationLast.vue';
|
||||
export { default as PaginationNext } from './PaginationNext.vue';
|
||||
export { default as PaginationPrev } from './PaginationPrev.vue';
|
||||
export {
|
||||
PaginationRoot as Pagination,
|
||||
PaginationList,
|
||||
PaginationListItem,
|
||||
} from 'reka-ui';
|
@@ -4,8 +4,8 @@ import { SelectRoot, useForwardPropsEmits } from 'radix-vue'
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, required: false },
|
||||
defaultOpen: { type: Boolean, required: false },
|
||||
defaultValue: { type: String, required: false },
|
||||
modelValue: { type: String, required: false },
|
||||
defaultValue: { type: [String, Number], required: false },
|
||||
modelValue: { type: [String, Number], required: false },
|
||||
dir: { type: String, required: false },
|
||||
name: { type: String, required: false },
|
||||
autocomplete: { type: String, required: false },
|
||||
|
@@ -2,7 +2,7 @@
|
||||
import { SelectValue } from 'radix-vue'
|
||||
|
||||
const props = defineProps({
|
||||
placeholder: { type: String, required: false },
|
||||
placeholder: { type: [String, Number], required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false }
|
||||
})
|
||||
|
242
frontend/src/constants/countries.js
Normal file
242
frontend/src/constants/countries.js
Normal file
@@ -0,0 +1,242 @@
|
||||
const countries = [
|
||||
{ calling_code: '+93', name: 'Afghanistan', emoji: '🇦🇫', iso_2: 'AF' },
|
||||
{ calling_code: '+355', name: 'Albania', emoji: '🇦🇱', iso_2: 'AL' },
|
||||
{ calling_code: '+213', name: 'Algeria', emoji: '🇩🇿', iso_2: 'DZ' },
|
||||
{ calling_code: '+1-684', name: 'American Samoa', emoji: '🇦🇸', iso_2: 'AS' },
|
||||
{ calling_code: '+376', name: 'Andorra', emoji: '🇦🇩', iso_2: 'AD' },
|
||||
{ calling_code: '+244', name: 'Angola', emoji: '🇦🇴', iso_2: 'AO' },
|
||||
{ calling_code: '+1-264', name: 'Anguilla', emoji: '🇦🇮', iso_2: 'AI' },
|
||||
{ calling_code: '+1-268', name: 'Antigua and Barbuda', emoji: '🇦🇬', iso_2: 'AG' },
|
||||
{ calling_code: '+54', name: 'Argentina', emoji: '🇦🇷', iso_2: 'AR' },
|
||||
{ calling_code: '+374', name: 'Armenia', emoji: '🇦🇲', iso_2: 'AM' },
|
||||
{ calling_code: '+297', name: 'Aruba', emoji: '🇦🇼', iso_2: 'AW' },
|
||||
{ calling_code: '+61', name: 'Australia', emoji: '🇦🇺', iso_2: 'AU' },
|
||||
{ calling_code: '+43', name: 'Austria', emoji: '🇦🇹', iso_2: 'AT' },
|
||||
{ calling_code: '+994', name: 'Azerbaijan', emoji: '🇦🇿', iso_2: 'AZ' },
|
||||
{ calling_code: '+1-242', name: 'Bahamas', emoji: '🇧🇸', iso_2: 'BS' },
|
||||
{ calling_code: '+973', name: 'Bahrain', emoji: '🇧🇭', iso_2: 'BH' },
|
||||
{ calling_code: '+880', name: 'Bangladesh', emoji: '🇧🇩', iso_2: 'BD' },
|
||||
{ calling_code: '+1-246', name: 'Barbados', emoji: '🇧🇧', iso_2: 'BB' },
|
||||
{ calling_code: '+375', name: 'Belarus', emoji: '🇧🇾', iso_2: 'BY' },
|
||||
{ calling_code: '+32', name: 'Belgium', emoji: '🇧🇪', iso_2: 'BE' },
|
||||
{ calling_code: '+501', name: 'Belize', emoji: '🇧🇿', iso_2: 'BZ' },
|
||||
{ calling_code: '+229', name: 'Benin', emoji: '🇧🇯', iso_2: 'BJ' },
|
||||
{ calling_code: '+1-441', name: 'Bermuda', emoji: '🇧🇲', iso_2: 'BM' },
|
||||
{ calling_code: '+975', name: 'Bhutan', emoji: '🇧🇹', iso_2: 'BT' },
|
||||
{ calling_code: '+591', name: 'Bolivia', emoji: '🇧🇴', iso_2: 'BO' },
|
||||
{ calling_code: '+387', name: 'Bosnia and Herzegovina', emoji: '🇧🇦', iso_2: 'BA' },
|
||||
{ calling_code: '+267', name: 'Botswana', emoji: '🇧🇼', iso_2: 'BW' },
|
||||
{ calling_code: '+55', name: 'Brazil', emoji: '🇧🇷', iso_2: 'BR' },
|
||||
{ calling_code: '+246', name: 'British Indian Ocean Territory', emoji: '🇮🇴', iso_2: 'IO' },
|
||||
{ calling_code: '+673', name: 'Brunei', emoji: '🇧🇳', iso_2: 'BN' },
|
||||
{ calling_code: '+359', name: 'Bulgaria', emoji: '🇧🇬', iso_2: 'BG' },
|
||||
{ calling_code: '+226', name: 'Burkina Faso', emoji: '🇧🇫', iso_2: 'BF' },
|
||||
{ calling_code: '+257', name: 'Burundi', emoji: '🇧🇮', iso_2: 'BI' },
|
||||
{ calling_code: '+855', name: 'Cambodia', emoji: '🇰🇭', iso_2: 'KH' },
|
||||
{ calling_code: '+237', name: 'Cameroon', emoji: '🇨🇲', iso_2: 'CM' },
|
||||
{ calling_code: '+1', name: 'Canada', emoji: '🇨🇦', iso_2: 'CA' },
|
||||
{ calling_code: '+238', name: 'Cape Verde', emoji: '🇨🇻', iso_2: 'CV' },
|
||||
{ calling_code: '+1-345', name: 'Cayman Islands', emoji: '🇰🇾', iso_2: 'KY' },
|
||||
{ calling_code: '+236', name: 'Central African Republic', emoji: '🇨🇫', iso_2: 'CF' },
|
||||
{ calling_code: '+235', name: 'Chad', emoji: '🇹🇩', iso_2: 'TD' },
|
||||
{ calling_code: '+56', name: 'Chile', emoji: '🇨🇱', iso_2: 'CL' },
|
||||
{ calling_code: '+86', name: 'China', emoji: '🇨🇳', iso_2: 'CN' },
|
||||
{ calling_code: '+61', name: 'Christmas Island', emoji: '🇨🇽', iso_2: 'CX' },
|
||||
{ calling_code: '+61', name: 'Cocos (Keeling) Islands', emoji: '🇨🇨', iso_2: 'CC' },
|
||||
{ calling_code: '+57', name: 'Colombia', emoji: '🇨🇴', iso_2: 'CO' },
|
||||
{ calling_code: '+269', name: 'Comoros', emoji: '🇰🇲', iso_2: 'KM' },
|
||||
{ calling_code: '+242', name: 'Congo', emoji: '🇨🇬', iso_2: 'CG' },
|
||||
{ calling_code: '+243', name: 'Congo, Democratic Republic of the', emoji: '🇨🇩', iso_2: 'CD' },
|
||||
{ calling_code: '+682', name: 'Cook Islands', emoji: '🇨🇰', iso_2: 'CK' },
|
||||
{ calling_code: '+506', name: 'Costa Rica', emoji: '🇨🇷', iso_2: 'CR' },
|
||||
{ calling_code: '+225', name: "Côte d'Ivoire", emoji: '🇨🇮', iso_2: 'CI' },
|
||||
{ calling_code: '+385', name: 'Croatia', emoji: '🇭🇷', iso_2: 'HR' },
|
||||
{ calling_code: '+53', name: 'Cuba', emoji: '🇨🇺', iso_2: 'CU' },
|
||||
{ calling_code: '+599', name: 'Curaçao', emoji: '🇨🇼', iso_2: 'CW' },
|
||||
{ calling_code: '+357', name: 'Cyprus', emoji: '🇨🇾', iso_2: 'CY' },
|
||||
{ calling_code: '+420', name: 'Czech Republic', emoji: '🇨🇿', iso_2: 'CZ' },
|
||||
{ calling_code: '+45', name: 'Denmark', emoji: '🇩🇰', iso_2: 'DK' },
|
||||
{ calling_code: '+253', name: 'Djibouti', emoji: '🇩🇯', iso_2: 'DJ' },
|
||||
{ calling_code: '+1-767', name: 'Dominica', emoji: '🇩🇲', iso_2: 'DM' },
|
||||
{ calling_code: '+1-809', name: 'Dominican Republic', emoji: '🇩🇴', iso_2: 'DO' },
|
||||
{ calling_code: '+593', name: 'Ecuador', emoji: '🇪🇨', iso_2: 'EC' },
|
||||
{ calling_code: '+20', name: 'Egypt', emoji: '🇪🇬', iso_2: 'EG' },
|
||||
{ calling_code: '+503', name: 'El Salvador', emoji: '🇸🇻', iso_2: 'SV' },
|
||||
{ calling_code: '+240', name: 'Equatorial Guinea', emoji: '🇬🇶', iso_2: 'GQ' },
|
||||
{ calling_code: '+291', name: 'Eritrea', emoji: '🇪🇷', iso_2: 'ER' },
|
||||
{ calling_code: '+372', name: 'Estonia', emoji: '🇪🇪', iso_2: 'EE' },
|
||||
{ calling_code: '+268', name: 'Eswatini', emoji: '🇸🇿', iso_2: 'SZ' },
|
||||
{ calling_code: '+251', name: 'Ethiopia', emoji: '🇪🇹', iso_2: 'ET' },
|
||||
{ calling_code: '+500', name: 'Falkland Islands', emoji: '🇫🇰', iso_2: 'FK' },
|
||||
{ calling_code: '+298', name: 'Faroe Islands', emoji: '🇫🇴', iso_2: 'FO' },
|
||||
{ calling_code: '+679', name: 'Fiji', emoji: '🇫🇯', iso_2: 'FJ' },
|
||||
{ calling_code: '+358', name: 'Finland', emoji: '🇫🇮', iso_2: 'FI' },
|
||||
{ calling_code: '+33', name: 'France', emoji: '🇫🇷', iso_2: 'FR' },
|
||||
{ calling_code: '+594', name: 'French Guiana', emoji: '🇬🇫', iso_2: 'GF' },
|
||||
{ calling_code: '+689', name: 'French Polynesia', emoji: '🇵🇫', iso_2: 'PF' },
|
||||
{ calling_code: '+241', name: 'Gabon', emoji: '🇬🇦', iso_2: 'GA' },
|
||||
{ calling_code: '+220', name: 'Gambia', emoji: '🇬🇲', iso_2: 'GM' },
|
||||
{ calling_code: '+995', name: 'Georgia', emoji: '🇬🇪', iso_2: 'GE' },
|
||||
{ calling_code: '+49', name: 'Germany', emoji: '🇩🇪', iso_2: 'DE' },
|
||||
{ calling_code: '+233', name: 'Ghana', emoji: '🇬🇭', iso_2: 'GH' },
|
||||
{ calling_code: '+350', name: 'Gibraltar', emoji: '🇬🇮', iso_2: 'GI' },
|
||||
{ calling_code: '+30', name: 'Greece', emoji: '🇬🇷', iso_2: 'GR' },
|
||||
{ calling_code: '+299', name: 'Greenland', emoji: '🇬🇱', iso_2: 'GL' },
|
||||
{ calling_code: '+1-473', name: 'Grenada', emoji: '🇬🇩', iso_2: 'GD' },
|
||||
{ calling_code: '+590', name: 'Guadeloupe', emoji: '🇬🇵', iso_2: 'GP' },
|
||||
{ calling_code: '+1-671', name: 'Guam', emoji: '🇬🇺', iso_2: 'GU' },
|
||||
{ calling_code: '+502', name: 'Guatemala', emoji: '🇬🇹', iso_2: 'GT' },
|
||||
{ calling_code: '+44-1481', name: 'Guernsey', emoji: '🇬🇬', iso_2: 'GG' },
|
||||
{ calling_code: '+224', name: 'Guinea', emoji: '🇬🇳', iso_2: 'GN' },
|
||||
{ calling_code: '+245', name: 'Guinea-Bissau', emoji: '🇬🇼', iso_2: 'GW' },
|
||||
{ calling_code: '+592', name: 'Guyana', emoji: '🇬🇾', iso_2: 'GY' },
|
||||
{ calling_code: '+509', name: 'Haiti', emoji: '🇭🇹', iso_2: 'HT' },
|
||||
{ calling_code: '+379', name: 'Vatican City', emoji: '🇻🇦', iso_2: 'VA' },
|
||||
{ calling_code: '+504', name: 'Honduras', emoji: '🇭🇳', iso_2: 'HN' },
|
||||
{ calling_code: '+852', name: 'Hong Kong', emoji: '🇭🇰', iso_2: 'HK' },
|
||||
{ calling_code: '+36', name: 'Hungary', emoji: '🇭🇺', iso_2: 'HU' },
|
||||
{ calling_code: '+354', name: 'Iceland', emoji: '🇮🇸', iso_2: 'IS' },
|
||||
{ calling_code: '+91', name: 'India', emoji: '🇮🇳', iso_2: 'IN' },
|
||||
{ calling_code: '+62', name: 'Indonesia', emoji: '🇮🇩', iso_2: 'ID' },
|
||||
{ calling_code: '+98', name: 'Iran', emoji: '🇮🇷', iso_2: 'IR' },
|
||||
{ calling_code: '+964', name: 'Iraq', emoji: '🇮🇶', iso_2: 'IQ' },
|
||||
{ calling_code: '+353', name: 'Ireland', emoji: '🇮🇪', iso_2: 'IE' },
|
||||
{ calling_code: '+44-1624', name: 'Isle of Man', emoji: '🇮🇲', iso_2: 'IM' },
|
||||
{ calling_code: '+972', name: 'Israel', emoji: '🇮🇱', iso_2: 'IL' },
|
||||
{ calling_code: '+39', name: 'Italy', emoji: '🇮🇹', iso_2: 'IT' },
|
||||
{ calling_code: '+1-876', name: 'Jamaica', emoji: '🇯🇲', iso_2: 'JM' },
|
||||
{ calling_code: '+81', name: 'Japan', emoji: '🇯🇵', iso_2: 'JP' },
|
||||
{ calling_code: '+44-1534', name: 'Jersey', emoji: '🇯🇪', iso_2: 'JE' },
|
||||
{ calling_code: '+962', name: 'Jordan', emoji: '🇯🇴', iso_2: 'JO' },
|
||||
{ calling_code: '+7', name: 'Kazakhstan', emoji: '🇰🇿', iso_2: 'KZ' },
|
||||
{ calling_code: '+254', name: 'Kenya', emoji: '🇰🇪', iso_2: 'KE' },
|
||||
{ calling_code: '+686', name: 'Kiribati', emoji: '🇰🇮', iso_2: 'KI' },
|
||||
{ calling_code: '+383', name: 'Kosovo', emoji: '🇽🇰', iso_2: 'XK' },
|
||||
{ calling_code: '+965', name: 'Kuwait', emoji: '🇰🇼', iso_2: 'KW' },
|
||||
{ calling_code: '+996', name: 'Kyrgyzstan', emoji: '🇰🇬', iso_2: 'KG' },
|
||||
{ calling_code: '+856', name: 'Laos', emoji: '🇱🇦', iso_2: 'LA' },
|
||||
{ calling_code: '+371', name: 'Latvia', emoji: '🇱🇻', iso_2: 'LV' },
|
||||
{ calling_code: '+961', name: 'Lebanon', emoji: '🇱🇧', iso_2: 'LB' },
|
||||
{ calling_code: '+266', name: 'Lesotho', emoji: '🇱🇸', iso_2: 'LS' },
|
||||
{ calling_code: '+231', name: 'Liberia', emoji: '🇱🇷', iso_2: 'LR' },
|
||||
{ calling_code: '+218', name: 'Libya', emoji: '🇱🇾', iso_2: 'LY' },
|
||||
{ calling_code: '+423', name: 'Liechtenstein', emoji: '🇱🇮', iso_2: 'LI' },
|
||||
{ calling_code: '+370', name: 'Lithuania', emoji: '🇱🇹', iso_2: 'LT' },
|
||||
{ calling_code: '+352', name: 'Luxembourg', emoji: '🇱🇺', iso_2: 'LU' },
|
||||
{ calling_code: '+853', name: 'Macao', emoji: '🇲🇴', iso_2: 'MO' },
|
||||
{ calling_code: '+389', name: 'North Macedonia', emoji: '🇲🇰', iso_2: 'MK' },
|
||||
{ calling_code: '+261', name: 'Madagascar', emoji: '🇲🇬', iso_2: 'MG' },
|
||||
{ calling_code: '+265', name: 'Malawi', emoji: '🇲🇼', iso_2: 'MW' },
|
||||
{ calling_code: '+60', name: 'Malaysia', emoji: '🇲🇾', iso_2: 'MY' },
|
||||
{ calling_code: '+960', name: 'Maldives', emoji: '🇲🇻', iso_2: 'MV' },
|
||||
{ calling_code: '+223', name: 'Mali', emoji: '🇲🇱', iso_2: 'ML' },
|
||||
{ calling_code: '+356', name: 'Malta', emoji: '🇲🇹', iso_2: 'MT' },
|
||||
{ calling_code: '+692', name: 'Marshall Islands', emoji: '🇲🇭', iso_2: 'MH' },
|
||||
{ calling_code: '+596', name: 'Martinique', emoji: '🇲🇶', iso_2: 'MQ' },
|
||||
{ calling_code: '+222', name: 'Mauritania', emoji: '🇲🇷', iso_2: 'MR' },
|
||||
{ calling_code: '+230', name: 'Mauritius', emoji: '🇲🇺', iso_2: 'MU' },
|
||||
{ calling_code: '+262', name: 'Mayotte', emoji: '🇾🇹', iso_2: 'YT' },
|
||||
{ calling_code: '+52', name: 'Mexico', emoji: '🇲🇽', iso_2: 'MX' },
|
||||
{ calling_code: '+691', name: 'Micronesia', emoji: '🇫🇲', iso_2: 'FM' },
|
||||
{ calling_code: '+373', name: 'Moldova', emoji: '🇲🇩', iso_2: 'MD' },
|
||||
{ calling_code: '+377', name: 'Monaco', emoji: '🇲🇨', iso_2: 'MC' },
|
||||
{ calling_code: '+976', name: 'Mongolia', emoji: '🇲🇳', iso_2: 'MN' },
|
||||
{ calling_code: '+382', name: 'Montenegro', emoji: '🇲🇪', iso_2: 'ME' },
|
||||
{ calling_code: '+1-664', name: 'Montserrat', emoji: '🇲🇸', iso_2: 'MS' },
|
||||
{ calling_code: '+212', name: 'Morocco', emoji: '🇲🇦', iso_2: 'MA' },
|
||||
{ calling_code: '+258', name: 'Mozambique', emoji: '🇲🇿', iso_2: 'MZ' },
|
||||
{ calling_code: '+95', name: 'Myanmar', emoji: '🇲🇲', iso_2: 'MM' },
|
||||
{ calling_code: '+264', name: 'Namibia', emoji: '🇳🇦', iso_2: 'NA' },
|
||||
{ calling_code: '+674', name: 'Nauru', emoji: '🇳🇷', iso_2: 'NR' },
|
||||
{ calling_code: '+977', name: 'Nepal', emoji: '🇳🇵', iso_2: 'NP' },
|
||||
{ calling_code: '+31', name: 'Netherlands', emoji: '🇳🇱', iso_2: 'NL' },
|
||||
{ calling_code: '+687', name: 'New Caledonia', emoji: '🇳🇨', iso_2: 'NC' },
|
||||
{ calling_code: '+64', name: 'New Zealand', emoji: '🇳🇿', iso_2: 'NZ' },
|
||||
{ calling_code: '+505', name: 'Nicaragua', emoji: '🇳🇮', iso_2: 'NI' },
|
||||
{ calling_code: '+227', name: 'Niger', emoji: '🇳🇪', iso_2: 'NE' },
|
||||
{ calling_code: '+234', name: 'Nigeria', emoji: '🇳🇬', iso_2: 'NG' },
|
||||
{ calling_code: '+683', name: 'Niue', emoji: '🇳🇺', iso_2: 'NU' },
|
||||
{ calling_code: '+672', name: 'Norfolk Island', emoji: '🇳🇫', iso_2: 'NF' },
|
||||
{ calling_code: '+850', name: 'North Korea', emoji: '🇰🇵', iso_2: 'KP' },
|
||||
{ calling_code: '+47', name: 'Norway', emoji: '🇳🇴', iso_2: 'NO' },
|
||||
{ calling_code: '+968', name: 'Oman', emoji: '🇴🇲', iso_2: 'OM' },
|
||||
{ calling_code: '+92', name: 'Pakistan', emoji: '🇵🇰', iso_2: 'PK' },
|
||||
{ calling_code: '+680', name: 'Palau', emoji: '🇵🇼', iso_2: 'PW' },
|
||||
{ calling_code: '+970', name: 'Palestine', emoji: '🇵🇸', iso_2: 'PS' },
|
||||
{ calling_code: '+507', name: 'Panama', emoji: '🇵🇦', iso_2: 'PA' },
|
||||
{ calling_code: '+675', name: 'Papua New Guinea', emoji: '🇵🇬', iso_2: 'PG' },
|
||||
{ calling_code: '+595', name: 'Paraguay', emoji: '🇵🇾', iso_2: 'PY' },
|
||||
{ calling_code: '+51', name: 'Peru', emoji: '🇵🇪', iso_2: 'PE' },
|
||||
{ calling_code: '+63', name: 'Philippines', emoji: '🇵🇭', iso_2: 'PH' },
|
||||
{ calling_code: '+64', name: 'Pitcairn Islands', emoji: '🇵🇳', iso_2: 'PN' },
|
||||
{ calling_code: '+48', name: 'Poland', emoji: '🇵🇱', iso_2: 'PL' },
|
||||
{ calling_code: '+351', name: 'Portugal', emoji: '🇵🇹', iso_2: 'PT' },
|
||||
{ calling_code: '+1-787', name: 'Puerto Rico', emoji: '🇵🇷', iso_2: 'PR' },
|
||||
{ calling_code: '+974', name: 'Qatar', emoji: '🇶🇦', iso_2: 'QA' },
|
||||
{ calling_code: '+40', name: 'Romania', emoji: '🇷🇴', iso_2: 'RO' },
|
||||
{ calling_code: '+7', name: 'Russia', emoji: '🇷🇺', iso_2: 'RU' },
|
||||
{ calling_code: '+250', name: 'Rwanda', emoji: '🇷🇼', iso_2: 'RW' },
|
||||
{ calling_code: '+590', name: 'Saint Barthélemy', emoji: '🇧🇱', iso_2: 'BL' },
|
||||
{ calling_code: '+290', name: 'Saint Helena, Ascension and Tristan da Cunha', emoji: '🇸🇭', iso_2: 'SH' },
|
||||
{ calling_code: '+1-869', name: 'Saint Kitts and Nevis', emoji: '🇰🇳', iso_2: 'KN' },
|
||||
{ calling_code: '+1-758', name: 'Saint Lucia', emoji: '🇱🇨', iso_2: 'LC' },
|
||||
{ calling_code: '+590', name: 'Saint Martin', emoji: '🇲🇫', iso_2: 'MF' },
|
||||
{ calling_code: '+508', name: 'Saint Pierre and Miquelon', emoji: '🇵🇲', iso_2: 'PM' },
|
||||
{ calling_code: '+1-784', name: 'Saint Vincent and the Grenadines', emoji: '🇻🇨', iso_2: 'VC' },
|
||||
{ calling_code: '+685', name: 'Samoa', emoji: '🇼🇸', iso_2: 'WS' },
|
||||
{ calling_code: '+378', name: 'San Marino', emoji: '🇸🇲', iso_2: 'SM' },
|
||||
{ calling_code: '+239', name: 'Sao Tome and Principe', emoji: '🇸🇹', iso_2: 'ST' },
|
||||
{ calling_code: '+966', name: 'Saudi Arabia', emoji: '🇸🇦', iso_2: 'SA' },
|
||||
{ calling_code: '+221', name: 'Senegal', emoji: '🇸🇳', iso_2: 'SN' },
|
||||
{ calling_code: '+381', name: 'Serbia', emoji: '🇷🇸', iso_2: 'RS' },
|
||||
{ calling_code: '+248', name: 'Seychelles', emoji: '🇸🇨', iso_2: 'SC' },
|
||||
{ calling_code: '+232', name: 'Sierra Leone', emoji: '🇸🇱', iso_2: 'SL' },
|
||||
{ calling_code: '+65', name: 'Singapore', emoji: '🇸🇬', iso_2: 'SG' },
|
||||
{ calling_code: '+1-721', name: 'Sint Maarten', emoji: '🇸🇽', iso_2: 'SX' },
|
||||
{ calling_code: '+421', name: 'Slovakia', emoji: '🇸🇰', iso_2: 'SK' },
|
||||
{ calling_code: '+386', name: 'Slovenia', emoji: '🇸🇮', iso_2: 'SI' },
|
||||
{ calling_code: '+677', name: 'Solomon Islands', emoji: '🇸🇧', iso_2: 'SB' },
|
||||
{ calling_code: '+252', name: 'Somalia', emoji: '🇸🇴', iso_2: 'SO' },
|
||||
{ calling_code: '+27', name: 'South Africa', emoji: '🇿🇦', iso_2: 'ZA' },
|
||||
{ calling_code: '+82', name: 'South Korea', emoji: '🇰🇷', iso_2: 'KR' },
|
||||
{ calling_code: '+211', name: 'South Sudan', emoji: '🇸🇸', iso_2: 'SS' },
|
||||
{ calling_code: '+34', name: 'Spain', emoji: '🇪🇸', iso_2: 'ES' },
|
||||
{ calling_code: '+94', name: 'Sri Lanka', emoji: '🇱🇰', iso_2: 'LK' },
|
||||
{ calling_code: '+249', name: 'Sudan', emoji: '🇸🇩', iso_2: 'SD' },
|
||||
{ calling_code: '+597', name: 'Suriname', emoji: '🇸🇷', iso_2: 'SR' },
|
||||
{ calling_code: '+47', name: 'Svalbard and Jan Mayen', emoji: '🇸🇯', iso_2: 'SJ' },
|
||||
{ calling_code: '+46', name: 'Sweden', emoji: '🇸🇪', iso_2: 'SE' },
|
||||
{ calling_code: '+41', name: 'Switzerland', emoji: '🇨🇭', iso_2: 'CH' },
|
||||
{ calling_code: '+963', name: 'Syria', emoji: '🇸🇾', iso_2: 'SY' },
|
||||
{ calling_code: '+886', name: 'Taiwan', emoji: '🇹🇼', iso_2: 'TW' },
|
||||
{ calling_code: '+992', name: 'Tajikistan', emoji: '🇹🇯', iso_2: 'TJ' },
|
||||
{ calling_code: '+255', name: 'Tanzania', emoji: '🇹🇿', iso_2: 'TZ' },
|
||||
{ calling_code: '+66', name: 'Thailand', emoji: '🇹🇭', iso_2: 'TH' },
|
||||
{ calling_code: '+670', name: 'Timor-Leste', emoji: '🇹🇱', iso_2: 'TL' },
|
||||
{ calling_code: '+228', name: 'Togo', emoji: '🇹🇬', iso_2: 'TG' },
|
||||
{ calling_code: '+690', name: 'Tokelau', emoji: '🇹🇰', iso_2: 'TK' },
|
||||
{ calling_code: '+676', name: 'Tonga', emoji: '🇹🇴', iso_2: 'TO' },
|
||||
{ calling_code: '+1-868', name: 'Trinidad and Tobago', emoji: '🇹🇹', iso_2: 'TT' },
|
||||
{ calling_code: '+216', name: 'Tunisia', emoji: '🇹🇳', iso_2: 'TN' },
|
||||
{ calling_code: '+90', name: 'Turkey', emoji: '🇹🇷', iso_2: 'TR' },
|
||||
{ calling_code: '+993', name: 'Turkmenistan', emoji: '🇹🇲', iso_2: 'TM' },
|
||||
{ calling_code: '+1-649', name: 'Turks and Caicos Islands', emoji: '🇹🇨', iso_2: 'TC' },
|
||||
{ calling_code: '+688', name: 'Tuvalu', emoji: '🇹🇻', iso_2: 'TV' },
|
||||
{ calling_code: '+256', name: 'Uganda', emoji: '🇺🇬', iso_2: 'UG' },
|
||||
{ calling_code: '+380', name: 'Ukraine', emoji: '🇺🇦', iso_2: 'UA' },
|
||||
{ calling_code: '+971', name: 'United Arab Emirates', emoji: '🇦🇪', iso_2: 'AE' },
|
||||
{ calling_code: '+44', name: 'United Kingdom', emoji: '🇬🇧', iso_2: 'GB' },
|
||||
{ calling_code: '+1', name: 'United States', emoji: '🇺🇸', iso_2: 'US' },
|
||||
{ calling_code: '+598', name: 'Uruguay', emoji: '🇺🇾', iso_2: 'UY' },
|
||||
{ calling_code: '+998', name: 'Uzbekistan', emoji: '🇺🇿', iso_2: 'UZ' },
|
||||
{ calling_code: '+678', name: 'Vanuatu', emoji: '🇻🇺', iso_2: 'VU' },
|
||||
{ calling_code: '+58', name: 'Venezuela', emoji: '🇻🇪', iso_2: 'VE' },
|
||||
{ calling_code: '+84', name: 'Vietnam', emoji: '🇻🇳', iso_2: 'VN' },
|
||||
{ calling_code: '+681', name: 'Wallis and Futuna', emoji: '🇼🇫', iso_2: 'WF' },
|
||||
{ calling_code: '+212', name: 'Western Sahara', emoji: '🇪🇭', iso_2: 'EH' },
|
||||
{ calling_code: '+967', name: 'Yemen', emoji: '🇾🇪', iso_2: 'YE' },
|
||||
{ calling_code: '+260', name: 'Zambia', emoji: '🇿🇲', iso_2: 'ZM' },
|
||||
{ calling_code: '+263', name: 'Zimbabwe', emoji: '🇿🇼', iso_2: 'ZW' }
|
||||
]
|
||||
|
||||
export default countries;
|
@@ -126,3 +126,10 @@ export const accountNavItems = [
|
||||
description: 'Update your profile'
|
||||
}
|
||||
]
|
||||
|
||||
export const contactNavItems = [
|
||||
{
|
||||
titleKey: 'navigation.allContacts',
|
||||
href: '/contacts',
|
||||
}
|
||||
]
|
273
frontend/src/features/contacts/ContactsList.vue
Normal file
273
frontend/src/features/contacts/ContactsList.vue
Normal file
@@ -0,0 +1,273 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<!-- Main Content Area -->
|
||||
<div class="flex flex-wrap gap-4 pb-4">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<!-- Search Input -->
|
||||
<Input
|
||||
type="text"
|
||||
v-model="searchTerm"
|
||||
placeholder="Search by email"
|
||||
@input="fetchContactsDebounced"
|
||||
/>
|
||||
|
||||
<!-- Order By Popover -->
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<ArrowDownUp size="18" class="text-muted-foreground cursor-pointer" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[200px] p-4 flex flex-col gap-4">
|
||||
<!-- order by field -->
|
||||
<Select v-model="orderByField" @update:model-value="fetchContactsDebounced">
|
||||
<SelectTrigger class="h-8 w-full">
|
||||
<SelectValue :placeholder="orderByField" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="'created_at'">Created at</SelectItem>
|
||||
<SelectItem :value="'email'">Email</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- order by direction -->
|
||||
<Select v-model="orderByDirection" @update:model-value="fetchContactsDebounced">
|
||||
<SelectTrigger class="h-8 w-full">
|
||||
<SelectValue :placeholder="orderByDirection" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="'asc'">Ascending</SelectItem>
|
||||
<SelectItem :value="'desc'">Descending</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex flex-col gap-4 w-full">
|
||||
<Card v-for="i in perPage" :key="i" class="p-4 flex-shrink-0">
|
||||
<div class="flex items-center gap-4">
|
||||
<Skeleton class="h-10 w-10 rounded-full" />
|
||||
<div class="space-y-2">
|
||||
<Skeleton class="h-3 w-[160px]" />
|
||||
<Skeleton class="h-3 w-[140px]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Loaded State -->
|
||||
<template v-else>
|
||||
<Card
|
||||
v-for="contact in contacts"
|
||||
:key="contact.id"
|
||||
class="p-4 w-full hover:bg-accent/50 cursor-pointer"
|
||||
@click="$router.push({ name: 'contact-detail', params: { id: contact.id } })"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<Avatar class="h-10 w-10 border">
|
||||
<AvatarImage :src="contact.avatar_url || ''" />
|
||||
<AvatarFallback class="text-sm font-medium">
|
||||
{{ getInitials(contact.first_name, contact.last_name) }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div class="space-y-1 overflow-hidden">
|
||||
<h4 class="text-sm font-semibold truncate">
|
||||
{{ contact.first_name }} {{ contact.last_name }}
|
||||
</h4>
|
||||
<p class="text-xs text-muted-foreground truncate">
|
||||
{{ contact.email }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<div v-if="contacts.length === 0" class="flex items-center justify-center w-full h-32">
|
||||
<p class="text-lg text-muted-foreground">No contacts found</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Sticky Pagination Controls -->
|
||||
<div class="sticky bottom-0 bg-background p-4 mt-auto">
|
||||
<div class="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-muted-foreground"> Page {{ page }} of {{ totalPages }} </span>
|
||||
<Select v-model="perPage" @update:model-value="handlePerPageChange">
|
||||
<SelectTrigger class="h-8 w-[70px]">
|
||||
<SelectValue :placeholder="perPage" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="15">15</SelectItem>
|
||||
<SelectItem :value="30">30</SelectItem>
|
||||
<SelectItem :value="50">50</SelectItem>
|
||||
<SelectItem :value="100">100</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Pagination>
|
||||
<PaginationList class="flex items-center gap-1">
|
||||
<PaginationListItem>
|
||||
<PaginationFirst
|
||||
:class="{ 'cursor-not-allowed opacity-50': page === 1 }"
|
||||
@click.prevent="page > 1 ? goToPage(1) : null"
|
||||
/>
|
||||
</PaginationListItem>
|
||||
|
||||
<PaginationListItem>
|
||||
<PaginationPrev
|
||||
:class="{ 'cursor-not-allowed opacity-50': page === 1 }"
|
||||
@click.prevent="page > 1 ? goToPage(page - 1) : null"
|
||||
/>
|
||||
</PaginationListItem>
|
||||
|
||||
<template v-for="pageNumber in visiblePages" :key="pageNumber">
|
||||
<PaginationListItem v-if="pageNumber === '...'">
|
||||
<PaginationEllipsis />
|
||||
</PaginationListItem>
|
||||
<PaginationListItem v-else>
|
||||
<Button
|
||||
:is-active="pageNumber === page"
|
||||
@click.prevent="goToPage(pageNumber)"
|
||||
:variant="pageNumber === page ? 'default' : 'outline'"
|
||||
>
|
||||
{{ pageNumber }}
|
||||
</Button>
|
||||
</PaginationListItem>
|
||||
</template>
|
||||
|
||||
<PaginationListItem>
|
||||
<PaginationNext
|
||||
:class="{ 'cursor-not-allowed opacity-50': page === totalPages }"
|
||||
@click.prevent="page < totalPages ? goToPage(page + 1) : null"
|
||||
/>
|
||||
</PaginationListItem>
|
||||
|
||||
<PaginationListItem>
|
||||
<PaginationLast
|
||||
:class="{ 'cursor-not-allowed opacity-50': page === totalPages }"
|
||||
@click.prevent="page < totalPages ? goToPage(totalPages) : null"
|
||||
/>
|
||||
</PaginationListItem>
|
||||
</PaginationList>
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||
import {
|
||||
Pagination,
|
||||
PaginationEllipsis,
|
||||
PaginationFirst,
|
||||
PaginationLast,
|
||||
PaginationList,
|
||||
PaginationListItem,
|
||||
PaginationNext,
|
||||
PaginationPrev
|
||||
} from '@/components/ui/pagination'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ArrowDownUp } from 'lucide-vue-next'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { debounce } from '@/utils/debounce'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import api from '@/api'
|
||||
|
||||
const contacts = ref([])
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const perPage = ref(15)
|
||||
const totalPages = ref(0)
|
||||
const searchTerm = ref('')
|
||||
const orderByField = ref('created_at')
|
||||
const orderByDirection = ref('desc')
|
||||
const emitter = useEmitter()
|
||||
|
||||
// Google-style pagination
|
||||
const visiblePages = computed(() => {
|
||||
const pages = []
|
||||
const maxVisible = 5
|
||||
let start = Math.max(1, page.value - Math.floor(maxVisible / 2))
|
||||
let end = Math.min(totalPages.value, start + maxVisible - 1)
|
||||
|
||||
if (end - start < maxVisible - 1) {
|
||||
start = Math.max(1, end - maxVisible + 1)
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
return pages
|
||||
})
|
||||
|
||||
const fetchContactsDebounced = debounce(() => {
|
||||
fetchContacts()
|
||||
}, 300)
|
||||
|
||||
const fetchContacts = async () => {
|
||||
loading.value = true
|
||||
let filterJSON = ''
|
||||
if (searchTerm.value && searchTerm.value.length > 3) {
|
||||
filterJSON = JSON.stringify([
|
||||
{
|
||||
model: 'users',
|
||||
field: 'email',
|
||||
operator: 'ilike',
|
||||
value: searchTerm.value
|
||||
}
|
||||
])
|
||||
}
|
||||
try {
|
||||
const response = await api.getContacts({
|
||||
page: page.value,
|
||||
page_size: perPage.value,
|
||||
filters: filterJSON,
|
||||
order: orderByDirection.value,
|
||||
order_by: orderByField.value
|
||||
})
|
||||
contacts.value = response.data.data.results
|
||||
totalPages.value = response.data.data.total_pages
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getInitials = (firstName, lastName) => {
|
||||
return `${firstName?.[0] || ''}${lastName?.[0] || ''}`.toUpperCase()
|
||||
}
|
||||
|
||||
const goToPage = (newPage) => {
|
||||
page.value = newPage
|
||||
fetchContactsDebounced()
|
||||
}
|
||||
|
||||
const handlePerPageChange = (newPerPage) => {
|
||||
page.value = 1
|
||||
perPage.value = newPerPage
|
||||
fetchContactsDebounced()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchContactsDebounced()
|
||||
})
|
||||
</script>
|
@@ -1,10 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-start">
|
||||
<Avatar class="size-20">
|
||||
<AvatarImage
|
||||
:src="conversation?.contact?.avatar_url || ''"
|
||||
/>
|
||||
<AvatarImage :src="conversation?.contact?.avatar_url || ''" />
|
||||
<AvatarFallback>
|
||||
{{ conversation?.contact?.first_name?.toUpperCase().substring(0, 2) }}
|
||||
</AvatarFallback>
|
||||
@@ -12,34 +10,40 @@
|
||||
<PanelLeft
|
||||
class="cursor-pointer"
|
||||
@click="emitter.emit(EMITTER_EVENTS.CONVERSATION_SIDEBAR_TOGGLE)"
|
||||
size="16"
|
||||
size="20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 h-6">
|
||||
<div class="h-6 flex items-center gap-2">
|
||||
<span v-if="conversationStore.conversation.loading">
|
||||
<Skeleton class="w-24 h-4" />
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ conversation?.contact?.first_name + ' ' + conversation?.contact?.last_name }}
|
||||
</span>
|
||||
<ExternalLink
|
||||
v-if="!conversationStore.conversation.loading"
|
||||
size="20"
|
||||
class="text-muted-foreground cursor-pointer flex-shrink-0"
|
||||
@click="$router.push({ name: 'contact-detail', params: { id: conversation?.contact_id } })"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground flex gap-2 h-4 mt-2">
|
||||
<Mail class="size-3 mt-1" />
|
||||
<div class="text-sm text-muted-foreground flex gap-2 items-center">
|
||||
<Mail size="18" class="flex-shrink-0" />
|
||||
<span v-if="conversationStore.conversation.loading">
|
||||
<Skeleton class="w-32 h-4" />
|
||||
</span>
|
||||
<span v-else>
|
||||
<span v-else class="break-all">
|
||||
{{ conversation?.contact?.email }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground flex gap-2 mt-2 h-4">
|
||||
<Phone class="size-3 mt-1" />
|
||||
<div class="text-sm text-muted-foreground flex gap-2 items-center">
|
||||
<Phone size="18" class="flex-shrink-0" />
|
||||
<span v-if="conversationStore.conversation.loading">
|
||||
<Skeleton class="w-32 h-4" />
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ conversation?.contact?.phone_number || t('conversation.sidebar.notAvailable') }}
|
||||
{{ phoneNumber }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,15 +53,21 @@
|
||||
import { computed } from 'vue'
|
||||
import { PanelLeft } from 'lucide-vue-next'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Mail, Phone } from 'lucide-vue-next'
|
||||
import { Mail, Phone, ExternalLink } from 'lucide-vue-next'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
const emitter = useEmitter()
|
||||
const conversation = computed(() => conversationStore.current)
|
||||
const { t } = useI18n()
|
||||
|
||||
const phoneNumber = computed(() => {
|
||||
const callingCode = conversation.value?.contact?.phone_number_calling_code || ''
|
||||
const number = conversation.value?.contact?.phone_number || t('conversation.sidebar.notAvailable')
|
||||
return callingCode ? `${callingCode} ${number}` : number
|
||||
})
|
||||
</script>
|
||||
|
5
frontend/src/layouts/contact/ContactDetail.vue
Normal file
5
frontend/src/layouts/contact/ContactDetail.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="w-full h-screen overflow-y-auto px-6 sm:px-6 md:px-12 lg:px-12 xl:px-72 pt-6">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
5
frontend/src/layouts/contact/ContactList.vue
Normal file
5
frontend/src/layouts/contact/ContactList.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="w-full h-screen overflow-y-auto px-6 sm:px-6 md:px-6 lg:px-24 xl:px-72 pt-6">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
@@ -41,6 +41,18 @@ const routes = [
|
||||
path: '/',
|
||||
component: App,
|
||||
children: [
|
||||
{
|
||||
path: 'contacts',
|
||||
name: 'contacts',
|
||||
component: () => import('@/views/contact/ContactsView.vue'),
|
||||
meta: { title: 'Contacts' }
|
||||
},
|
||||
{
|
||||
path: 'contacts/:id',
|
||||
name: 'contact-detail',
|
||||
component: () => import('@/views/contact/ContactDetailView.vue'),
|
||||
meta: { title: 'Contacts' },
|
||||
},
|
||||
{
|
||||
path: '/reports',
|
||||
name: 'reports',
|
||||
|
346
frontend/src/views/contact/ContactDetailView.vue
Normal file
346
frontend/src/views/contact/ContactDetailView.vue
Normal file
@@ -0,0 +1,346 @@
|
||||
<template>
|
||||
<ContactDetail>
|
||||
<div class="flex flex-col mx-auto items-start">
|
||||
<div class="mb-6">
|
||||
<CustomBreadcrumb :links="breadcrumbLinks" />
|
||||
</div>
|
||||
|
||||
<div v-if="contact" class="flex justify-center space-y-4 w-full">
|
||||
<!-- Card -->
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="h-16"></div>
|
||||
|
||||
<div class="flex flex-col space-y-2">
|
||||
<!-- Avatar with upload-->
|
||||
<AvatarUpload @upload="onUpload" @remove="onRemove" :src="contact.avatar_url" :initials="getInitials" />
|
||||
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-900">
|
||||
{{ contact.first_name }} {{ contact.last_name }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-500">
|
||||
Created on
|
||||
{{ contact.created_at ? format(new Date(contact.created_at), 'PPP') : 'N/A' }}
|
||||
</div>
|
||||
|
||||
<div class="w-30">
|
||||
<Button
|
||||
:variant="contact.enabled ? 'destructive' : 'outline'"
|
||||
@click="showBlockConfirmation = true"
|
||||
>
|
||||
<ShieldOffIcon v-if="contact.enabled" size="18" class="mr-2" />
|
||||
<ShieldCheckIcon v-else size="18" class="mr-2" />
|
||||
{{ t(contact.enabled ? 'globals.buttons.block' : 'globals.buttons.unblock') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-12">
|
||||
<form @submit.prevent="onSubmit" class="space-y-8">
|
||||
<div class="flex flex-wrap gap-6">
|
||||
<div class="flex-1">
|
||||
<FormField v-slot="{ componentField }" name="first_name">
|
||||
<FormItem class="flex flex-col">
|
||||
<FormLabel class="flex items-center">
|
||||
{{ t('form.field.firstName') }}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input v-bind="componentField" type="text" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<FormField v-slot="{ componentField }" name="last_name" class="flex-1">
|
||||
<FormItem class="flex flex-col">
|
||||
<FormLabel class="flex items-center">
|
||||
{{ t('form.field.lastName') }}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input v-bind="componentField" type="text" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-6">
|
||||
<div class="flex-1">
|
||||
<FormField v-slot="{ componentField }" name="email">
|
||||
<FormItem class="flex flex-col">
|
||||
<FormLabel class="flex items-center">
|
||||
{{ t('form.field.email') }}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input v-bind="componentField" type="email" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col flex-1">
|
||||
<div class="flex items-end">
|
||||
<FormField v-slot="{ componentField }" name="phone_number_calling_code">
|
||||
<FormItem class="w-20">
|
||||
<FormLabel class="flex items-center whitespace-nowrap">
|
||||
{{ t('form.field.phoneNumber') }}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<ComboBox
|
||||
v-bind="componentField"
|
||||
:items="allCountries"
|
||||
:placeholder="t('form.field.select')"
|
||||
:buttonClass="'rounded-r-none border-r-0'"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-7 h-7 flex items-center justify-center">
|
||||
<span v-if="item.emoji">{{ item.emoji }}</span>
|
||||
</div>
|
||||
<span class="text-sm">{{ item.label }} ( {{ item.value }})</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #selected="{ selected }">
|
||||
<div class="flex items-center">
|
||||
<div v-if="selected">
|
||||
{{ selected?.emoji }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ComboBox>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<div class="flex-1">
|
||||
<FormField v-slot="{ componentField }" name="phone_number" class="flex-1">
|
||||
<FormItem class="flex flex-col">
|
||||
<FormLabel class="sr-only">{{ t('form.field.phoneNumber') }}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" v-bind="componentField" class="rounded-l-none" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button type="submit" :isLoading="formLoading" :disabled="formLoading">
|
||||
{{
|
||||
$t('globals.buttons.update', {
|
||||
name: $t('globals.terms.contact').toLowerCase()
|
||||
})
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<Spinner v-else />
|
||||
|
||||
<!-- Block/Unblock confirmation dialog -->
|
||||
<Dialog :open="showBlockConfirmation" @update:open="showBlockConfirmation = $event">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{
|
||||
contact?.enabled
|
||||
? t('globals.buttons.block', {
|
||||
name: t('globals.terms.contact')
|
||||
})
|
||||
: t('globals.buttons.unblock', {
|
||||
name: t('globals.terms.contact')
|
||||
})
|
||||
}}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ contact?.enabled ? t('contact.blockConfirm') : t('contact.unblockConfirm') }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="flex justify-end space-x-2 pt-4">
|
||||
<Button variant="outline" @click="showBlockConfirmation = false">
|
||||
{{ t('globals.buttons.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
:variant="contact?.enabled ? 'destructive' : 'default'"
|
||||
@click="confirmToggleBlock"
|
||||
>
|
||||
{{ contact?.enabled ? t('globals.buttons.block') : t('globals.buttons.unblock') }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</ContactDetail>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { format } from 'date-fns'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { AvatarUpload } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription
|
||||
} from '@/components/ui/dialog'
|
||||
import { ShieldOffIcon, ShieldCheckIcon } from 'lucide-vue-next'
|
||||
import ContactDetail from '@/layouts/contact/ContactDetail.vue'
|
||||
import api from '@/api'
|
||||
import { createFormSchema } from './formSchema.js'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
|
||||
import countries from '@/constants/countries.js'
|
||||
|
||||
const { t } = useI18n()
|
||||
const emitter = useEmitter()
|
||||
const route = useRoute()
|
||||
const formLoading = ref(false)
|
||||
const contact = ref(null)
|
||||
const showBlockConfirmation = ref(false)
|
||||
const form = useForm({
|
||||
validationSchema: toTypedSchema(createFormSchema(t))
|
||||
})
|
||||
|
||||
const allCountries = countries.map((country) => ({
|
||||
label: country.name,
|
||||
value: country.calling_code,
|
||||
emoji: country.emoji
|
||||
}))
|
||||
|
||||
const breadcrumbLinks = [
|
||||
{ path: 'contacts', label: t('globals.terms.contact', 2) },
|
||||
{
|
||||
path: '',
|
||||
label: t('globals.messages.edit', {
|
||||
name: t('globals.terms.contact')
|
||||
})
|
||||
}
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
fetchContact()
|
||||
})
|
||||
|
||||
async function fetchContact() {
|
||||
try {
|
||||
const { data } = await api.getContact(route.params.id)
|
||||
contact.value = data.data
|
||||
form.setValues(data.data)
|
||||
} catch (err) {
|
||||
showError(err)
|
||||
}
|
||||
}
|
||||
|
||||
const getInitials = computed(() => {
|
||||
if (!contact.value) return ''
|
||||
const firstName = contact.value.first_name || ''
|
||||
const lastName = contact.value.last_name || ''
|
||||
return `${firstName.charAt(0).toUpperCase()}${lastName.charAt(0).toUpperCase()}`
|
||||
})
|
||||
|
||||
async function confirmToggleBlock() {
|
||||
showBlockConfirmation.value = false
|
||||
await toggleBlock()
|
||||
}
|
||||
|
||||
async function toggleBlock() {
|
||||
try {
|
||||
form.setFieldValue('enabled', !contact.value.enabled)
|
||||
await onSubmit(form.values)
|
||||
await fetchContact()
|
||||
const messageKey = contact.value.enabled
|
||||
? 'globals.messages.unblockedSuccessfully'
|
||||
: 'globals.messages.blockedSuccessfully'
|
||||
emitToast(t(messageKey, { name: t('globals.terms.contact') }))
|
||||
} catch (err) {
|
||||
showError(err)
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
try {
|
||||
formLoading.value = true
|
||||
await api.updateContact(contact.value.id, {
|
||||
...values
|
||||
})
|
||||
await fetchContact()
|
||||
emitToast(t('globals.messages.updatedSuccessfully', { name: t('globals.terms.contact') }))
|
||||
} catch (err) {
|
||||
showError(err)
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function onUpload(file) {
|
||||
try {
|
||||
formLoading.value = true
|
||||
const formData = new FormData()
|
||||
formData.append('files', file)
|
||||
formData.append('first_name', form.values.first_name)
|
||||
formData.append('last_name', form.values.last_name)
|
||||
formData.append('email', form.values.email)
|
||||
formData.append('phone_number', form.values.phone_number)
|
||||
formData.append('phone_number_calling_code', form.values.phone_number_calling_code)
|
||||
formData.append('enabled', form.values.enabled)
|
||||
|
||||
const { data } = await api.updateContact(contact.value.id, formData)
|
||||
contact.value.avatar_url = data.avatar_url
|
||||
form.setFieldValue('avatar_url', data.avatar_url)
|
||||
emitToast(t('globals.messages.updatedSuccessfully', { name: t('globals.terms.avatar') }))
|
||||
} catch (err) {
|
||||
showError(err)
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onRemove() {
|
||||
try {
|
||||
formLoading.value = true
|
||||
contact.value.avatar_url = null
|
||||
form.setFieldValue('avatar_url', null)
|
||||
emitToast(t('globals.messages.removedSuccessfully', { name: t('globals.terms.avatar') }))
|
||||
} catch (err) {
|
||||
showError(err)
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function emitToast(description) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, { description })
|
||||
}
|
||||
|
||||
function showError(err) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(err).message
|
||||
})
|
||||
}
|
||||
</script>
|
9
frontend/src/views/contact/ContactsView.vue
Normal file
9
frontend/src/views/contact/ContactsView.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<ContactList>
|
||||
<ContactsList />
|
||||
</ContactList>
|
||||
</template>
|
||||
<script setup>
|
||||
import ContactsList from '@/features/contacts/ContactsList.vue'
|
||||
import ContactList from '@/layouts/contact/ContactList.vue'
|
||||
</script>
|
31
frontend/src/views/contact/formSchema.js
Normal file
31
frontend/src/views/contact/formSchema.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as z from 'zod'
|
||||
|
||||
export const createFormSchema = (t) => z.object({
|
||||
first_name: z
|
||||
.string({
|
||||
required_error: t('globals.messages.required'),
|
||||
})
|
||||
.min(2, {
|
||||
message: t('form.error.minmax', {
|
||||
min: 2,
|
||||
max: 50,
|
||||
})
|
||||
})
|
||||
.max(50, {
|
||||
message: t('form.error.minmax', {
|
||||
min: 2,
|
||||
max: 50,
|
||||
})
|
||||
}),
|
||||
enabled: z.boolean().optional(),
|
||||
last_name: z.string().optional(),
|
||||
phone_number: z.string().optional().nullable(),
|
||||
phone_number_calling_code: z.string().optional().nullable(),
|
||||
email: z
|
||||
.string({
|
||||
required_error: t('globals.messages.required'),
|
||||
})
|
||||
.email({
|
||||
message: t('globals.messages.invalidEmailAddress'),
|
||||
}),
|
||||
})
|
27
i18n/en.json
27
i18n/en.json
@@ -104,6 +104,9 @@
|
||||
"globals.messages.updatedSuccessfully": "{name} updated successfully",
|
||||
"globals.messages.savedSuccessfully": "{name} saved successfully",
|
||||
"globals.messages.createdSuccessfully": "{name} created successfully",
|
||||
"globals.messages.blockedSuccessfully": "{name} blocked successfully",
|
||||
"globals.messages.unblockedSuccessfully": "{name} unblocked successfully",
|
||||
"globals.messages.pageTooLarge": "Page size is too large, should be at most {max}",
|
||||
"globals.messages.edit": "Edit {name}",
|
||||
"globals.messages.delete": "Delete {name}",
|
||||
"globals.messages.create": "Create {name}",
|
||||
@@ -149,6 +152,7 @@
|
||||
"globals.messages.goDuration": "Invalid duration. Please use a valid duration format (e.g. 30s, 30m, 1h30m, 48h, etc.)",
|
||||
"globals.messages.invalidFromAddress": "Invalid from email address format, make sure it's a valid email address in the format `Name <mail@example.com>`",
|
||||
"user.resetPasswordTokenExpired": "Token is invalid or expired, please try again by requesting a new password reset link",
|
||||
"user.userCannotDeleteSelf": "You cannot delete yourself",
|
||||
"media.fileSizeTooLarge": "File size too large, please upload a file less than {size} ",
|
||||
"media.fileTypeNotAllowed": "File type not allowed",
|
||||
"inbox.emptyIMAP": "Empty IMAP config",
|
||||
@@ -235,9 +239,8 @@
|
||||
"navigation.all": "All",
|
||||
"navigation.teamInboxes": "Team Inboxes",
|
||||
"navigation.views": "Views",
|
||||
"navigation.edit": "Edit",
|
||||
"navigation.delete": "Delete",
|
||||
"navigation.reassignReplies": "Reassign replies",
|
||||
"navigation.allContacts": "All Contacts",
|
||||
"form.field.name": "Name",
|
||||
"form.field.awayReassigning": "Away and reassigning",
|
||||
"form.field.select": "Select {name}",
|
||||
@@ -272,14 +275,15 @@
|
||||
"form.field.host": "Host",
|
||||
"form.field.smtpHost": "SMTP Host",
|
||||
"form.field.smtpPort": "SMTP Port",
|
||||
"form.field.firstName": "First Name",
|
||||
"form.field.lastName": "Last Name",
|
||||
"form.field.firstName": "First name",
|
||||
"form.field.lastName": "Last name",
|
||||
"form.field.teams": "Teams",
|
||||
"form.field.roles": "Roles",
|
||||
"form.field.setPassword": "Set Password",
|
||||
"form.field.sendWelcomeEmail": "Send Welcome Email?",
|
||||
"form.field.setPassword": "Set password",
|
||||
"form.field.sendWelcomeEmail": "Send welcome email?",
|
||||
"form.field.username": "Username",
|
||||
"form.field.password": "Password",
|
||||
"form.field.phoneNumber": "Phone number",
|
||||
"form.field.visibility": "Visibility",
|
||||
"form.field.usage": "Usage",
|
||||
"form.field.createdAt": "Created At",
|
||||
@@ -509,12 +513,15 @@
|
||||
"globals.buttons.cancel": "Cancel",
|
||||
"globals.buttons.submit": "Submit",
|
||||
"globals.buttons.send": "Send",
|
||||
"globals.buttons.update": "Update",
|
||||
"globals.buttons.update": "Update {name}",
|
||||
"globals.buttons.delete": "Delete",
|
||||
"globals.buttons.create": "Create",
|
||||
"globals.buttons.enable": "Enable",
|
||||
"globals.buttons.disable": "Disable",
|
||||
"globals.buttons.block": "Block {name}",
|
||||
"globals.buttons.unblock": "Unblock {name}",
|
||||
"globals.buttons.saving": "Saving...",
|
||||
"globals.buttons.upload": "Upload",
|
||||
"globals.buttons.back": "Back",
|
||||
"globals.buttons.edit": "Edit",
|
||||
"globals.buttons.close": "Close",
|
||||
@@ -567,7 +574,7 @@
|
||||
"conversation.hideQuotedText": "Hide quoted text",
|
||||
"conversation.sidebar.action": "Action | Actions",
|
||||
"conversation.sidebar.information": "Information",
|
||||
"conversation.sidebar.previousConvo": "Previous conversastions",
|
||||
"conversation.sidebar.previousConvo": "Previous conversations",
|
||||
"conversation.sidebar.noPreviousConvo": "No previous conversations",
|
||||
"conversation.sidebar.notAvailable": "Not available",
|
||||
"editor.placeholder": "Shift + Enter to add a new line",
|
||||
@@ -579,5 +586,7 @@
|
||||
"replyBox.emailAddresess": "Email addresses separated by comma",
|
||||
"replyBox.editor.placeholder": "Shift + Enter to add a new line. Cmd + Enter to send. Cmd + K to open command bar.",
|
||||
"replyBox.invalidEmailsIn": "Invalid email(s) in",
|
||||
"replyBox.correctEmailErrors": "Please correct the email errors before sending."
|
||||
"replyBox.correctEmailErrors": "Please correct the email errors before sending.",
|
||||
"contact.blockConfirm": "Are you sure you want to block this contact? They will no longer be able to interact with you.",
|
||||
"contact.unblockConfirm": "Are you sure you want to unblock this contact? They will be able to interact with you again."
|
||||
}
|
@@ -235,8 +235,6 @@
|
||||
"navigation.all": "सर्व",
|
||||
"navigation.teamInboxes": "संघ इनबॉक्स",
|
||||
"navigation.views": "दृश्ये",
|
||||
"navigation.edit": "संपादित करा",
|
||||
"navigation.delete": "हटवा",
|
||||
"navigation.reassignReplies": "प्रतिसाद पुन्हा नियुक्त करा",
|
||||
"form.field.name": "नाव",
|
||||
"form.field.awayReassigning": "दूर आणि पुन्हा नियुक्त करत आहे",
|
||||
|
@@ -97,7 +97,7 @@ type teamStore interface {
|
||||
}
|
||||
|
||||
type userStore interface {
|
||||
GetAgent(int) (umodels.User, error)
|
||||
GetAgent(int, string) (umodels.User, error)
|
||||
GetSystemUser() (umodels.User, error)
|
||||
CreateContact(user *umodels.User) error
|
||||
}
|
||||
@@ -706,7 +706,7 @@ func (m *Manager) GetMessageSourceIDs(conversationID, limit int) ([]string, erro
|
||||
|
||||
// SendAssignedConversationEmail sends a email for an assigned conversation to the passed user ids.
|
||||
func (m *Manager) SendAssignedConversationEmail(userIDs []int, conversation models.Conversation) error {
|
||||
agent, err := m.userStore.GetAgent(userIDs[0])
|
||||
agent, err := m.userStore.GetAgent(userIDs[0], "")
|
||||
if err != nil {
|
||||
m.lo.Error("error fetching agent", "user_id", userIDs[0], "error", err)
|
||||
return fmt.Errorf("fetching agent: %w", err)
|
||||
|
@@ -412,7 +412,7 @@ func (m *Manager) RecordAssigneeUserChange(conversationUUID string, assigneeID i
|
||||
}
|
||||
|
||||
// Assignment to another user.
|
||||
assignee, err := m.userStore.GetAgent(assigneeID)
|
||||
assignee, err := m.userStore.GetAgent(assigneeID, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@@ -132,6 +132,7 @@ SELECT
|
||||
ct.email as "contact.email",
|
||||
ct.avatar_url as "contact.avatar_url",
|
||||
ct.phone_number as "contact.phone_number",
|
||||
ct.phone_number_calling_code as "contact.phone_number_calling_code",
|
||||
COALESCE(lr.cc, '[]'::jsonb) as cc,
|
||||
COALESCE(lr.bcc, '[]'::jsonb) as bcc,
|
||||
as_latest.first_response_deadline_at,
|
||||
|
@@ -33,7 +33,7 @@ type Filter struct {
|
||||
type AllowedFields map[string][]string
|
||||
|
||||
// BuildPaginatedQuery builds a paginated query from the given base query, existing arguments, pagination options, filters JSON, and allowed fields.
|
||||
func BuildPaginatedQuery(baseQuery string, existingArgs []interface{}, opts PaginationOptions, filtersJSON string, allowedFields AllowedFields) (string, []interface{}, error) {
|
||||
func BuildPaginatedQuery(baseQuery string, existingArgs []any, opts PaginationOptions, filtersJSON string, allowedFields AllowedFields) (string, []any, error) {
|
||||
if opts.Page <= 0 {
|
||||
return "", nil, fmt.Errorf("invalid page number: %d", opts.Page)
|
||||
}
|
||||
@@ -126,6 +126,10 @@ func buildWhereClause(filters []Filter, existingArgs []interface{}, allowedField
|
||||
conditions = append(conditions, fmt.Sprintf("%s BETWEEN $%d AND $%d", field, paramCount, paramCount+1))
|
||||
args = append(args, strings.TrimSpace(values[0]), strings.TrimSpace(values[1]))
|
||||
paramCount += 2
|
||||
case "ilike":
|
||||
conditions = append(conditions, field+fmt.Sprintf(" ILIKE $%d", paramCount))
|
||||
args = append(args, "%"+f.Value+"%")
|
||||
paramCount++
|
||||
default:
|
||||
return "", nil, fmt.Errorf("invalid operator: %s", f.Operator)
|
||||
}
|
||||
|
@@ -54,6 +54,7 @@ type Email struct {
|
||||
lo *logf.Logger
|
||||
from string
|
||||
messageStore inbox.MessageStore
|
||||
userStore inbox.UserStore
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
@@ -66,7 +67,7 @@ type Opts struct {
|
||||
}
|
||||
|
||||
// New returns a new instance of the email inbox.
|
||||
func New(store inbox.MessageStore, opts Opts) (*Email, error) {
|
||||
func New(store inbox.MessageStore, userStore inbox.UserStore, opts Opts) (*Email, error) {
|
||||
pools, err := NewSmtpPool(opts.Config.SMTP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -79,6 +80,7 @@ func New(store inbox.MessageStore, opts Opts) (*Email, error) {
|
||||
lo: opts.Lo,
|
||||
smtpPools: pools,
|
||||
messageStore: store,
|
||||
userStore: userStore,
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
@@ -186,17 +186,28 @@ func (e *Email) processEnvelope(ctx context.Context, client *imapclient.Client,
|
||||
e.lo.Warn("no sender received for email", "message_id", env.MessageID)
|
||||
return nil
|
||||
}
|
||||
var fromAddr = env.From[0].Addr()
|
||||
|
||||
// Check if the message already exists in the database.
|
||||
// If it does, ignore it.
|
||||
exists, err := e.messageStore.MessageExists(env.MessageID)
|
||||
if err != nil {
|
||||
e.lo.Error("error checking if message exists", "message_id", env.MessageID)
|
||||
return fmt.Errorf("checking if message exists in DB: %w", err)
|
||||
}
|
||||
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if contact with this email is blocked / disabed, if so, ignore the message.
|
||||
if contact, err := e.userStore.GetContact(0, fromAddr); err != nil {
|
||||
e.lo.Error("error checking if user is blocked", "email", fromAddr, "error", err)
|
||||
return fmt.Errorf("checking if user is blocked: %w", err)
|
||||
} else if !contact.Enabled {
|
||||
e.lo.Debug("contact is blocked, ignoring message", "email", fromAddr)
|
||||
return nil
|
||||
}
|
||||
|
||||
e.lo.Debug("message does not exist", "message_id", env.MessageID)
|
||||
|
||||
// Make contact.
|
||||
@@ -206,8 +217,8 @@ func (e *Email) processEnvelope(ctx context.Context, client *imapclient.Client,
|
||||
FirstName: firstName,
|
||||
LastName: lastName,
|
||||
SourceChannel: null.NewString(e.Channel(), true),
|
||||
SourceChannelID: null.NewString(env.From[0].Addr(), true),
|
||||
Email: null.NewString(env.From[0].Addr(), true),
|
||||
SourceChannelID: null.NewString(fromAddr, true),
|
||||
Email: null.NewString(fromAddr, true),
|
||||
Type: umodels.UserTypeContact,
|
||||
}
|
||||
|
||||
|
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/abhinavxd/libredesk/internal/dbutil"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
|
||||
umodels "github.com/abhinavxd/libredesk/internal/user/models"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/go-i18n"
|
||||
"github.com/zerodha/logf"
|
||||
@@ -32,7 +33,7 @@ var (
|
||||
ErrInboxNotFound = errors.New("inbox not found")
|
||||
)
|
||||
|
||||
type initFn func(imodels.Inbox, MessageStore) (Inbox, error)
|
||||
type initFn func(imodels.Inbox, MessageStore, UserStore) (Inbox, error)
|
||||
|
||||
// Closer provides a function for closing an inbox.
|
||||
type Closer interface {
|
||||
@@ -65,6 +66,11 @@ type MessageStore interface {
|
||||
EnqueueIncoming(models.IncomingMessage) error
|
||||
}
|
||||
|
||||
// UserStore defines methods for fetching user information.
|
||||
type UserStore interface {
|
||||
GetContact(id int, email string) (umodels.User, error)
|
||||
}
|
||||
|
||||
// Opts contains the options for initializing the inbox manager.
|
||||
type Opts struct {
|
||||
QueueSize int
|
||||
@@ -79,7 +85,8 @@ type Manager struct {
|
||||
lo *logf.Logger
|
||||
i18n *i18n.I18n
|
||||
receivers map[int]context.CancelFunc
|
||||
store MessageStore
|
||||
msgStore MessageStore
|
||||
usrStore UserStore
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
@@ -113,7 +120,12 @@ func New(lo *logf.Logger, db *sqlx.DB, i18n *i18n.I18n) (*Manager, error) {
|
||||
|
||||
// SetMessageStore sets the message store for the manager.
|
||||
func (m *Manager) SetMessageStore(store MessageStore) {
|
||||
m.store = store
|
||||
m.msgStore = store
|
||||
}
|
||||
|
||||
// SetUserStore sets the user store for the manager.
|
||||
func (m *Manager) SetUserStore(store UserStore) {
|
||||
m.usrStore = store
|
||||
}
|
||||
|
||||
// Register registers the inbox with the manager.
|
||||
@@ -178,7 +190,7 @@ func (m *Manager) InitInboxes(initFn initFn) error {
|
||||
}
|
||||
|
||||
for _, inboxRecord := range inboxRecords {
|
||||
inbox, err := initFn(inboxRecord, m.store)
|
||||
inbox, err := initFn(inboxRecord, m.msgStore, m.usrStore)
|
||||
if err != nil {
|
||||
m.lo.Error("error initializing inbox",
|
||||
"name", inboxRecord.Name,
|
||||
@@ -216,7 +228,7 @@ func (m *Manager) Reload(ctx context.Context, initFn initFn) error {
|
||||
|
||||
// Initialize new inboxes.
|
||||
for _, inboxRecord := range inboxRecords {
|
||||
inbox, err := initFn(inboxRecord, m.store)
|
||||
inbox, err := initFn(inboxRecord, m.msgStore, m.usrStore)
|
||||
if err != nil {
|
||||
m.lo.Error("error initializing inbox during reload",
|
||||
"name", inboxRecord.Name,
|
||||
|
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
// V0_6_0 updates the database schema to v0.6.0.
|
||||
func V0_6_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
|
||||
// Add new column for last login timestamp
|
||||
_, err := db.Exec(`
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ NULL;
|
||||
`)
|
||||
@@ -15,6 +16,7 @@ func V0_6_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add new enum value for user availability status
|
||||
_, err = db.Exec(`
|
||||
DO $$
|
||||
BEGIN
|
||||
@@ -32,5 +34,35 @@ func V0_6_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add new column for phone number calling code
|
||||
_, err = db.Exec(`
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone_number_calling_code TEXT NULL;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add constraint for phone number calling code
|
||||
_, err = db.Exec(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.constraint_column_usage
|
||||
WHERE table_name = 'users'
|
||||
AND column_name = 'phone_number_calling_code'
|
||||
AND constraint_name = 'constraint_users_on_phone_number_calling_code'
|
||||
) THEN
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT constraint_users_on_phone_number_calling_code
|
||||
CHECK (LENGTH(phone_number_calling_code) <= 10);
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
@@ -85,7 +85,7 @@ type teamStore interface {
|
||||
}
|
||||
|
||||
type userStore interface {
|
||||
GetAgent(int) (umodels.User, error)
|
||||
GetAgent(int, string) (umodels.User, error)
|
||||
}
|
||||
|
||||
type appSettingsStore interface {
|
||||
@@ -349,7 +349,7 @@ func (m *Manager) SendNotification(scheduledNotification models.ScheduledSLANoti
|
||||
m.lo.Error("error parsing recipient ID", "error", err, "recipient_id", recipientS)
|
||||
continue
|
||||
}
|
||||
agent, err := m.userStore.GetAgent(recipientID)
|
||||
agent, err := m.userStore.GetAgent(recipientID, "")
|
||||
if err != nil {
|
||||
m.lo.Error("error fetching agent for SLA notification", "recipient_id", recipientID, "error", err)
|
||||
if _, err := m.q.MarkNotificationProcessed.Exec(scheduledNotification.ID); err != nil {
|
||||
|
@@ -32,6 +32,7 @@ type User struct {
|
||||
Email null.String `db:"email" json:"email"`
|
||||
Type string `db:"type" json:"type"`
|
||||
AvailabilityStatus string `db:"availability_status" json:"availability_status"`
|
||||
PhoneNumberCallingCode null.String `db:"phone_number_calling_code" json:"phone_number_calling_code"`
|
||||
PhoneNumber null.String `db:"phone_number" json:"phone_number"`
|
||||
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
|
||||
Enabled bool `db:"enabled" json:"enabled"`
|
||||
@@ -49,6 +50,8 @@ type User struct {
|
||||
InboxID int `json:"-"`
|
||||
SourceChannel null.String `json:"-"`
|
||||
SourceChannelID null.String `json:"-"`
|
||||
|
||||
Total int `json:"total,omitempty"`
|
||||
}
|
||||
|
||||
func (u *User) FullName() string {
|
||||
|
@@ -1,20 +1,30 @@
|
||||
-- name: get-users
|
||||
SELECT u.id, u.updated_at, u.first_name, u.last_name, u.email, u.enabled
|
||||
FROM users u
|
||||
WHERE u.email != 'System' AND u.deleted_at IS NULL AND u.type = 'agent'
|
||||
ORDER BY u.updated_at DESC;
|
||||
SELECT COUNT(*) OVER() as total, users.id, 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
|
||||
|
||||
-- name: soft-delete-user
|
||||
-- name: soft-delete-agent
|
||||
WITH soft_delete AS (
|
||||
UPDATE users
|
||||
SET deleted_at = now(), updated_at = now()
|
||||
WHERE id = $1 AND type = 'agent'
|
||||
RETURNING id
|
||||
),
|
||||
-- Delete from user_roles and teams
|
||||
delete_team_members AS (
|
||||
DELETE FROM team_members
|
||||
WHERE user_id IN (SELECT id FROM soft_delete)
|
||||
RETURNING 1
|
||||
),
|
||||
delete_user_roles AS (
|
||||
DELETE FROM user_roles
|
||||
WHERE user_id IN (SELECT id FROM soft_delete)
|
||||
RETURNING 1
|
||||
)
|
||||
DELETE FROM team_members WHERE user_id IN (SELECT id FROM soft_delete);
|
||||
SELECT 1;
|
||||
|
||||
-- name: get-users-compact
|
||||
SELECT u.id, u.first_name, u.last_name, u.enabled, u.avatar_url
|
||||
-- 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;
|
||||
@@ -22,6 +32,8 @@ ORDER BY u.updated_at DESC;
|
||||
-- name: get-user
|
||||
SELECT
|
||||
u.id,
|
||||
u.created_at,
|
||||
u.updated_at,
|
||||
u.email,
|
||||
u.password,
|
||||
u.type,
|
||||
@@ -34,7 +46,9 @@ SELECT
|
||||
u.availability_status,
|
||||
u.last_active_at,
|
||||
u.last_login_at,
|
||||
array_agg(DISTINCT r.name) as roles,
|
||||
u.phone_number_calling_code,
|
||||
u.phone_number,
|
||||
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))
|
||||
FROM team_members tm
|
||||
@@ -42,20 +56,20 @@ SELECT
|
||||
WHERE tm.user_id = u.id),
|
||||
'[]'
|
||||
) AS teams,
|
||||
array_agg(DISTINCT p) as permissions
|
||||
array_agg(DISTINCT p) FILTER (WHERE p IS NOT NULL) AS permissions
|
||||
FROM users u
|
||||
LEFT JOIN user_roles ur ON ur.user_id = u.id
|
||||
LEFT JOIN roles r ON r.id = ur.role_id,
|
||||
unnest(r.permissions) p
|
||||
LEFT JOIN roles r ON r.id = ur.role_id
|
||||
LEFT JOIN LATERAL unnest(r.permissions) AS p ON true
|
||||
WHERE (u.id = $1 OR u.email = $2) AND u.type = $3 AND u.deleted_at IS NULL
|
||||
GROUP BY u.id;
|
||||
|
||||
-- name: set-user-password
|
||||
UPDATE users
|
||||
SET password = $1, updated_at = now()
|
||||
WHERE id = $2 AND type = 'agent';
|
||||
WHERE id = $2;
|
||||
|
||||
-- name: update-user
|
||||
-- name: update-agent
|
||||
WITH not_removed_roles AS (
|
||||
SELECT r.id FROM unnest($5::text[]) role_name
|
||||
JOIN roles r ON r.name = role_name
|
||||
@@ -79,12 +93,12 @@ SET first_name = COALESCE($2, first_name),
|
||||
enabled = COALESCE($8, enabled),
|
||||
availability_status = COALESCE($9, availability_status),
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND type = 'agent';
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: update-avatar
|
||||
UPDATE users
|
||||
SET avatar_url = $2, updated_at = now()
|
||||
WHERE id = $1 AND type = 'agent';
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: update-availability
|
||||
UPDATE users
|
||||
@@ -145,3 +159,15 @@ UPDATE users
|
||||
SET last_login_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: update-contact
|
||||
UPDATE users
|
||||
SET first_name = COALESCE($2, first_name),
|
||||
last_name = COALESCE($3, last_name),
|
||||
email = COALESCE($4, email),
|
||||
avatar_url = COALESCE($5, avatar_url),
|
||||
phone_number = COALESCE($6, phone_number),
|
||||
phone_number_calling_code = COALESCE($7, phone_number_calling_code),
|
||||
enabled = COALESCE($8, enabled),
|
||||
updated_at = now()
|
||||
WHERE id = $1 and type = 'contact';
|
@@ -1,4 +1,4 @@
|
||||
// Package user handles user login, logout and provides functions to fetch user details.
|
||||
// Package user managers all users in libredesk - agents and contacts.
|
||||
package user
|
||||
|
||||
import (
|
||||
@@ -33,6 +33,7 @@ var (
|
||||
|
||||
minPassword = 10
|
||||
maxPassword = 72
|
||||
maxListPageSize = 100
|
||||
|
||||
// ErrPasswordTooLong is returned when the password passed to
|
||||
// GenerateFromPassword is too long (i.e. > 72 bytes).
|
||||
@@ -46,6 +47,7 @@ type Manager struct {
|
||||
lo *logf.Logger
|
||||
i18n *i18n.I18n
|
||||
q queries
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
// Opts contains options for initializing the Manager.
|
||||
@@ -56,16 +58,17 @@ type Opts struct {
|
||||
|
||||
// queries contains prepared SQL queries.
|
||||
type queries struct {
|
||||
GetUsers *sqlx.Stmt `query:"get-users"`
|
||||
GetUsersCompact *sqlx.Stmt `query:"get-users-compact"`
|
||||
GetUser *sqlx.Stmt `query:"get-user"`
|
||||
UpdateUser *sqlx.Stmt `query:"update-user"`
|
||||
GetUsers string `query:"get-users"`
|
||||
GetAgentsCompact *sqlx.Stmt `query:"get-agents-compact"`
|
||||
UpdateContact *sqlx.Stmt `query:"update-contact"`
|
||||
UpdateAgent *sqlx.Stmt `query:"update-agent"`
|
||||
UpdateAvatar *sqlx.Stmt `query:"update-avatar"`
|
||||
UpdateAvailability *sqlx.Stmt `query:"update-availability"`
|
||||
UpdateLastActiveAt *sqlx.Stmt `query:"update-last-active-at"`
|
||||
UpdateInactiveOffline *sqlx.Stmt `query:"update-inactive-offline"`
|
||||
UpdateLastLoginAt *sqlx.Stmt `query:"update-last-login-at"`
|
||||
SoftDeleteUser *sqlx.Stmt `query:"soft-delete-user"`
|
||||
SoftDeleteAgent *sqlx.Stmt `query:"soft-delete-agent"`
|
||||
SetUserPassword *sqlx.Stmt `query:"set-user-password"`
|
||||
SetResetPasswordToken *sqlx.Stmt `query:"set-reset-password-token"`
|
||||
ResetPassword *sqlx.Stmt `query:"reset-password"`
|
||||
@@ -83,10 +86,11 @@ func New(i18n *i18n.I18n, opts Opts) (*Manager, error) {
|
||||
q: q,
|
||||
lo: opts.Lo,
|
||||
i18n: i18n,
|
||||
db: opts.DB,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// VerifyPassword authenticates an user by email and password.
|
||||
// VerifyPassword authenticates an user by email and password, returning the user if successful.
|
||||
func (u *Manager) VerifyPassword(email string, password []byte) (models.User, error) {
|
||||
var user models.User
|
||||
if err := u.q.GetUser.Get(&user, 0, email, models.UserTypeAgent); err != nil {
|
||||
@@ -102,31 +106,57 @@ func (u *Manager) VerifyPassword(email string, password []byte) (models.User, er
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GetAll retrieves all users.
|
||||
func (u *Manager) GetAll() ([]models.User, error) {
|
||||
var users = make([]models.User, 0)
|
||||
if err := u.q.GetUsers.Select(&users); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return users, 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)
|
||||
}
|
||||
// GetAllAgents returns a list of all agents.
|
||||
func (u *Manager) GetAgents() ([]models.User, error) {
|
||||
return u.GetAllUsers(1, 999999999, models.UserTypeAgent, "updated_at", "desc", "")
|
||||
}
|
||||
|
||||
// GetAllContacts returns a list of all contacts.
|
||||
func (u *Manager) GetContacts(page, pageSize int, order, orderBy string, filtersJSON string) ([]models.User, error) {
|
||||
if pageSize > maxListPageSize {
|
||||
return nil, envelope.NewError(envelope.InputError, u.i18n.Ts("globals.messages.pageTooLarge", "max", fmt.Sprintf("%d", maxListPageSize)), nil)
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 10
|
||||
}
|
||||
return u.GetAllUsers(page, pageSize, models.UserTypeContact, order, orderBy, filtersJSON)
|
||||
}
|
||||
|
||||
// GetAllUsers returns a list of all users.
|
||||
func (u *Manager) GetAllUsers(page, pageSize int, userType, order, orderBy string, filtersJSON string) ([]models.User, 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)
|
||||
return nil, envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil)
|
||||
}
|
||||
var users = make([]models.User, 0)
|
||||
tx, err := u.db.BeginTxx(context.Background(), nil)
|
||||
defer tx.Rollback()
|
||||
|
||||
if err != nil {
|
||||
u.lo.Error("error preparing get users query", "error", err)
|
||||
return nil, envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil)
|
||||
}
|
||||
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)
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// GetAllCompact returns a compact list of users with limited fields.
|
||||
func (u *Manager) GetAllCompact() ([]models.User, error) {
|
||||
// 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.GetUsersCompact.Select(&users); err != nil {
|
||||
if err := u.q.GetAgentsCompact.Select(&users); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return users, 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)
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
@@ -149,24 +179,19 @@ func (u *Manager) CreateAgent(user *models.User) error {
|
||||
}
|
||||
|
||||
// GetAgent retrieves an agent by ID.
|
||||
func (u *Manager) GetAgent(id int) (models.User, error) {
|
||||
return u.Get(id, models.UserTypeAgent)
|
||||
}
|
||||
|
||||
// GetAgentByEmail retrieves an agent by email.
|
||||
func (u *Manager) GetAgentByEmail(email string) (models.User, error) {
|
||||
return u.GetByEmail(email, models.UserTypeAgent)
|
||||
func (u *Manager) GetAgent(id int, email string) (models.User, error) {
|
||||
return u.Get(id, email, models.UserTypeAgent)
|
||||
}
|
||||
|
||||
// GetContact retrieves a contact by ID.
|
||||
func (u *Manager) GetContact(id int) (models.User, error) {
|
||||
return u.Get(id, models.UserTypeContact)
|
||||
func (u *Manager) GetContact(id int, email string) (models.User, error) {
|
||||
return u.Get(id, email, models.UserTypeContact)
|
||||
}
|
||||
|
||||
// Get retrieves an user by ID.
|
||||
func (u *Manager) Get(id int, type_ string) (models.User, error) {
|
||||
// Get retrieves an user by ID or email.
|
||||
func (u *Manager) Get(id int, email, type_ string) (models.User, error) {
|
||||
var user models.User
|
||||
if err := u.q.GetUser.Get(&user, id, "", type_); err != nil {
|
||||
if err := u.q.GetUser.Get(&user, id, email, type_); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
u.lo.Error("user not found", "id", id, "error", err)
|
||||
return user, envelope.NewError(envelope.NotFoundError, u.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.user}"), nil)
|
||||
@@ -177,40 +202,28 @@ func (u *Manager) Get(id int, type_ string) (models.User, error) {
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GetByEmail retrieves an user by email
|
||||
func (u *Manager) GetByEmail(email, type_ string) (models.User, error) {
|
||||
var user models.User
|
||||
if err := u.q.GetUser.Get(&user, 0, email, type_); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return user, envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.user}"), nil)
|
||||
}
|
||||
u.lo.Error("error fetching user from db", "error", err)
|
||||
return user, envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GetSystemUser retrieves the system user.
|
||||
func (u *Manager) GetSystemUser() (models.User, error) {
|
||||
return u.GetByEmail(models.SystemUserEmail, models.UserTypeAgent)
|
||||
return u.Get(0, models.SystemUserEmail, models.UserTypeAgent)
|
||||
}
|
||||
|
||||
// UpdateAvatar updates the user avatar.
|
||||
func (u *Manager) UpdateAvatar(id int, avatar string) error {
|
||||
if _, err := u.q.UpdateAvatar.Exec(id, null.NewString(avatar, avatar != "")); err != nil {
|
||||
func (u *Manager) UpdateAvatar(id int, path string) error {
|
||||
if _, err := u.q.UpdateAvatar.Exec(id, null.NewString(path, path != "")); err != nil {
|
||||
u.lo.Error("error updating user avatar", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}"), nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update updates an user.
|
||||
func (u *Manager) Update(id int, user models.User) error {
|
||||
// UpdateAgent updates an agent in the database, including their password if provided.
|
||||
func (u *Manager) UpdateAgent(id int, user models.User) error {
|
||||
var (
|
||||
hashedPassword any
|
||||
err error
|
||||
)
|
||||
|
||||
// Set password?
|
||||
if user.NewPassword != "" {
|
||||
if IsStrongPassword(user.NewPassword) {
|
||||
return envelope.NewError(envelope.InputError, PasswordHint, nil)
|
||||
@@ -223,13 +236,23 @@ func (u *Manager) Update(id int, user models.User) error {
|
||||
u.lo.Debug("setting new password for user", "user_id", id)
|
||||
}
|
||||
|
||||
if _, err := u.q.UpdateUser.Exec(id, user.FirstName, user.LastName, user.Email, pq.Array(user.Roles), user.AvatarURL, hashedPassword, user.Enabled, user.AvailabilityStatus); err != nil {
|
||||
// Update user in the database.
|
||||
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 {
|
||||
u.lo.Error("error updating user", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}"), nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateContact updates a contact in the database.
|
||||
func (u *Manager) UpdateContact(id int, user models.User) error {
|
||||
if _, err := u.q.UpdateContact.Exec(id, user.FirstName, user.LastName, user.Email, user.AvatarURL, user.PhoneNumber, user.PhoneNumberCallingCode, user.Enabled); err != nil {
|
||||
u.lo.Error("error updating user", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.contact}"), nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateLastLoginAt updates the last login timestamp of an user.
|
||||
func (u *Manager) UpdateLastLoginAt(id int) error {
|
||||
if _, err := u.q.UpdateLastLoginAt.Exec(id); err != nil {
|
||||
@@ -239,8 +262,8 @@ func (u *Manager) UpdateLastLoginAt(id int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SoftDelete soft deletes an user.
|
||||
func (u *Manager) SoftDelete(id int) error {
|
||||
// SoftDeleteAgent soft deletes an agent by ID.
|
||||
func (u *Manager) SoftDeleteAgent(id int) error {
|
||||
// Disallow if user is system user.
|
||||
systemUser, err := u.GetSystemUser()
|
||||
if err != nil {
|
||||
@@ -249,8 +272,7 @@ func (u *Manager) SoftDelete(id int) error {
|
||||
if id == systemUser.ID {
|
||||
return envelope.NewError(envelope.InputError, u.i18n.T("user.cannotDeleteSystemUser"), nil)
|
||||
}
|
||||
|
||||
if _, err := u.q.SoftDeleteUser.Exec(id); err != nil {
|
||||
if _, err := u.q.SoftDeleteAgent.Exec(id); err != nil {
|
||||
u.lo.Error("error deleting user", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.user}"), nil)
|
||||
}
|
||||
@@ -325,6 +347,24 @@ func (u *Manager) MonitorAgentAvailability(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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"},
|
||||
})
|
||||
}
|
||||
|
||||
// markInactiveAgentsOffline sets agents offline if they have been inactive for more than 5 minutes.
|
||||
func (u *Manager) markInactiveAgentsOffline() {
|
||||
if res, err := u.q.UpdateInactiveOffline.Exec(); err != nil {
|
||||
|
@@ -116,6 +116,7 @@ CREATE TABLE users (
|
||||
email TEXT NULL,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NULL,
|
||||
phone_number_calling_code TEXT NULL,
|
||||
phone_number TEXT NULL,
|
||||
country TEXT NULL,
|
||||
"password" VARCHAR(150) NULL,
|
||||
@@ -128,6 +129,7 @@ CREATE TABLE users (
|
||||
last_login_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT constraint_users_on_country CHECK (LENGTH(country) <= 140),
|
||||
CONSTRAINT constraint_users_on_phone_number CHECK (LENGTH(phone_number) <= 20),
|
||||
CONSTRAINT constraint_users_on_phone_number_calling_code CHECK (LENGTH(phone_number_calling_code) <= 10),
|
||||
CONSTRAINT constraint_users_on_email_length CHECK (LENGTH(email) <= 320),
|
||||
CONSTRAINT constraint_users_on_first_name CHECK (LENGTH(first_name) <= 140),
|
||||
CONSTRAINT constraint_users_on_last_name CHECK (LENGTH(last_name) <= 140)
|
||||
|
Reference in New Issue
Block a user