-
+
+
-
+
{{ conversation?.contact?.email }}
-
-
+
+
- {{ conversation?.contact?.phone_number || t('conversation.sidebar.notAvailable') }}
+ {{ phoneNumber }}
@@ -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
+})
diff --git a/frontend/src/layouts/contact/ContactDetail.vue b/frontend/src/layouts/contact/ContactDetail.vue
new file mode 100644
index 0000000..159985a
--- /dev/null
+++ b/frontend/src/layouts/contact/ContactDetail.vue
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/frontend/src/layouts/contact/ContactList.vue b/frontend/src/layouts/contact/ContactList.vue
new file mode 100644
index 0000000..d6548d4
--- /dev/null
+++ b/frontend/src/layouts/contact/ContactList.vue
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js
index c217006..380471f 100644
--- a/frontend/src/router/index.js
+++ b/frontend/src/router/index.js
@@ -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',
diff --git a/frontend/src/views/contact/ContactDetailView.vue b/frontend/src/views/contact/ContactDetailView.vue
new file mode 100644
index 0000000..36cacbc
--- /dev/null
+++ b/frontend/src/views/contact/ContactDetailView.vue
@@ -0,0 +1,346 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ contact.first_name }} {{ contact.last_name }}
+
+
+
+
+ Created on
+ {{ contact.created_at ? format(new Date(contact.created_at), 'PPP') : 'N/A' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/contact/ContactsView.vue b/frontend/src/views/contact/ContactsView.vue
new file mode 100644
index 0000000..b178c82
--- /dev/null
+++ b/frontend/src/views/contact/ContactsView.vue
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/frontend/src/views/contact/formSchema.js b/frontend/src/views/contact/formSchema.js
new file mode 100644
index 0000000..e58b2ce
--- /dev/null
+++ b/frontend/src/views/contact/formSchema.js
@@ -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'),
+ }),
+})
diff --git a/i18n/en.json b/i18n/en.json
index 93c8d39..b127ba0 100644
--- a/i18n/en.json
+++ b/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
`",
"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."
}
\ No newline at end of file
diff --git a/i18n/mr.json b/i18n/mr.json
index 8079630..19a49ce 100644
--- a/i18n/mr.json
+++ b/i18n/mr.json
@@ -235,8 +235,6 @@
"navigation.all": "सर्व",
"navigation.teamInboxes": "संघ इनबॉक्स",
"navigation.views": "दृश्ये",
- "navigation.edit": "संपादित करा",
- "navigation.delete": "हटवा",
"navigation.reassignReplies": "प्रतिसाद पुन्हा नियुक्त करा",
"form.field.name": "नाव",
"form.field.awayReassigning": "दूर आणि पुन्हा नियुक्त करत आहे",
diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go
index 698f460..f0d1297 100644
--- a/internal/conversation/conversation.go
+++ b/internal/conversation/conversation.go
@@ -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)
diff --git a/internal/conversation/message.go b/internal/conversation/message.go
index 0e45452..00aa8c2 100644
--- a/internal/conversation/message.go
+++ b/internal/conversation/message.go
@@ -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
}
diff --git a/internal/conversation/queries.sql b/internal/conversation/queries.sql
index 18ac45d..41249e6 100644
--- a/internal/conversation/queries.sql
+++ b/internal/conversation/queries.sql
@@ -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,
diff --git a/internal/dbutil/builder.go b/internal/dbutil/builder.go
index a425b8f..ce12fb1 100644
--- a/internal/dbutil/builder.go
+++ b/internal/dbutil/builder.go
@@ -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)
}
diff --git a/internal/inbox/channel/email/email.go b/internal/inbox/channel/email/email.go
index fdace71..0245787 100644
--- a/internal/inbox/channel/email/email.go
+++ b/internal/inbox/channel/email/email.go
@@ -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
}
diff --git a/internal/inbox/channel/email/imap.go b/internal/inbox/channel/email/imap.go
index 9bd176c..80f0186 100644
--- a/internal/inbox/channel/email/imap.go
+++ b/internal/inbox/channel/email/imap.go
@@ -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,
}
diff --git a/internal/inbox/inbox.go b/internal/inbox/inbox.go
index 26921ca..0a176ef 100644
--- a/internal/inbox/inbox.go
+++ b/internal/inbox/inbox.go
@@ -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,
diff --git a/internal/migrations/v0.6.0.go b/internal/migrations/v0.6.0.go
index 821f338..278c785 100644
--- a/internal/migrations/v0.6.0.go
+++ b/internal/migrations/v0.6.0.go
@@ -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
}
diff --git a/internal/sla/sla.go b/internal/sla/sla.go
index 57f7dd2..8cc622c 100644
--- a/internal/sla/sla.go
+++ b/internal/sla/sla.go
@@ -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 {
diff --git a/internal/user/models/models.go b/internal/user/models/models.go
index 3cdee8b..b735a76 100644
--- a/internal/user/models/models.go
+++ b/internal/user/models/models.go
@@ -24,31 +24,34 @@ const (
)
type User struct {
- ID int `db:"id" json:"id"`
- CreatedAt time.Time `db:"created_at" json:"created_at"`
- UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
- FirstName string `db:"first_name" json:"first_name"`
- LastName string `db:"last_name" json:"last_name"`
- Email null.String `db:"email" json:"email"`
- Type string `db:"type" json:"type"`
- AvailabilityStatus string `db:"availability_status" json:"availability_status"`
- PhoneNumber null.String `db:"phone_number" json:"phone_number"`
- AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
- Enabled bool `db:"enabled" json:"enabled"`
- Password string `db:"password" json:"-"`
- LastActiveAt null.Time `db:"last_active_at" json:"last_active_at"`
- LastLoginAt null.Time `db:"last_login_at" json:"last_login_at"`
- Roles pq.StringArray `db:"roles" json:"roles"`
- Permissions pq.StringArray `db:"permissions" json:"permissions"`
- Meta pq.StringArray `db:"meta" json:"meta"`
- CustomAttributes pq.StringArray `db:"custom_attributes" json:"custom_attributes"`
- Teams tmodels.Teams `db:"teams" json:"teams"`
- ContactChannelID int `db:"contact_channel_id" json:"contact_channel_id,omitempty"`
- NewPassword string `db:"-" json:"new_password,omitempty"`
- SendWelcomeEmail bool `db:"-" json:"send_welcome_email,omitempty"`
- InboxID int `json:"-"`
- SourceChannel null.String `json:"-"`
- SourceChannelID null.String `json:"-"`
+ ID int `db:"id" json:"id"`
+ CreatedAt time.Time `db:"created_at" json:"created_at"`
+ UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
+ FirstName string `db:"first_name" json:"first_name"`
+ LastName string `db:"last_name" json:"last_name"`
+ Email null.String `db:"email" json:"email"`
+ Type string `db:"type" json:"type"`
+ AvailabilityStatus string `db:"availability_status" json:"availability_status"`
+ PhoneNumberCallingCode null.String `db:"phone_number_calling_code" json:"phone_number_calling_code"`
+ PhoneNumber null.String `db:"phone_number" json:"phone_number"`
+ AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
+ Enabled bool `db:"enabled" json:"enabled"`
+ Password string `db:"password" json:"-"`
+ LastActiveAt null.Time `db:"last_active_at" json:"last_active_at"`
+ LastLoginAt null.Time `db:"last_login_at" json:"last_login_at"`
+ Roles pq.StringArray `db:"roles" json:"roles"`
+ Permissions pq.StringArray `db:"permissions" json:"permissions"`
+ Meta pq.StringArray `db:"meta" json:"meta"`
+ CustomAttributes pq.StringArray `db:"custom_attributes" json:"custom_attributes"`
+ Teams tmodels.Teams `db:"teams" json:"teams"`
+ ContactChannelID int `db:"contact_channel_id" json:"contact_channel_id,omitempty"`
+ NewPassword string `db:"-" json:"new_password,omitempty"`
+ SendWelcomeEmail bool `db:"-" json:"send_welcome_email,omitempty"`
+ InboxID int `json:"-"`
+ SourceChannel null.String `json:"-"`
+ SourceChannelID null.String `json:"-"`
+
+ Total int `json:"total,omitempty"`
}
func (u *User) FullName() string {
diff --git a/internal/user/queries.sql b/internal/user/queries.sql
index f48b1ad..7ec7570 100644
--- a/internal/user/queries.sql
+++ b/internal/user/queries.sql
@@ -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,28 +46,30 @@ 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
- JOIN teams t ON tm.team_id = t.id
- WHERE tm.user_id = u.id),
- '[]'
+ (SELECT json_agg(json_build_object('id', t.id, 'name', t.name, 'emoji', t.emoji))
+ FROM team_members tm
+ JOIN teams t ON tm.team_id = t.id
+ 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
@@ -144,4 +158,16 @@ RETURNING contact_id, id;
UPDATE users
SET last_login_at = now(),
updated_at = now()
-WHERE id = $1;
\ No newline at end of file
+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';
\ No newline at end of file
diff --git a/internal/user/user.go b/internal/user/user.go
index bcb8741..2a525d6 100644
--- a/internal/user/user.go
+++ b/internal/user/user.go
@@ -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 (
@@ -31,8 +31,9 @@ var (
//go:embed queries.sql
efs embed.FS
- minPassword = 10
- maxPassword = 72
+ 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 {
diff --git a/schema.sql b/schema.sql
index 53c5219..e03006e 100644
--- a/schema.sql
+++ b/schema.sql
@@ -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)