Compare commits

...

48 Commits

Author SHA1 Message Date
Abhinav Raut
36d91de8f7 fix: remove email validation from SMTP username field in email notification form schema 2025-03-06 15:11:16 +05:30
Abhinav Raut
57c1948379 fix[OOM]: fix read buffer size configuration in server settings, the readbuffer was set to the max body size making the binary go OOM. 2025-03-06 15:10:23 +05:30
Abhinav Raut
772152c40c fix: filter out empty email message ids for setting email references headers.
chore: adds debug logs.
2025-03-06 12:24:18 +05:30
Abhinav Raut
8e15d733ea fix: regression in sso login caused due to attempting in hiding client secret in the API response. Resolves #21 2025-03-05 16:13:13 +05:30
Abhinav Raut
fc47e65fcb chore: update screenshot in README 2025-03-05 04:33:11 +05:30
Abhinav Raut
760be37eda chore: update libredesk screenshot in documentation 2025-03-05 04:32:38 +05:30
Abhinav Raut
d1f08ce035 fix: handle null user last active time when marking agents offline. 2025-03-05 04:24:06 +05:30
Abhinav Raut
8551b65a27 fix: set references header in all outgoing emails, set the last 20 messages.
feat: set conversation reference number in the subject of conversation for better thread matching.
fix: hide CSAT link from conversation last message.
2025-03-05 03:49:22 +05:30
Abhinav Raut
eb499f64d0 chore: adds v0.4.0 to migration list. 2025-03-05 02:33:03 +05:30
Abhinav Raut
494bc15b0a feat: Enable agents to create conversations from the UI
Before this feature the only way to create a conversation was by adding inbox and sending an email.

Agents first search contacts by email, see a dropdown select an existing contact or fill a new email for new contact.

The backend creates contact if it does not exist, creates a conversation, sends a reply to the conversation.
Optinally assigns conversation to a user / team.

fix: Replies to emails create a new conversation instead of attaching to the previous one.

Was not happening in gmail, as gmail was sending the references headers in all replies and I missed this completely. So when libredesk searches a conversation by references headers it worked!

Instead the right way is to generate the outgoing email message id and saving it in DB. This commit fixes that.

There could be more backup strategies like putting reference number in the subject but that can be explored later.

chore: new role `conversatons:write` that enables the create conversations feature for an agent.

chore: migrations for v0.4.0.
2025-03-05 01:17:42 +05:30
Abhinav Raut
360557c58f fix: remove client_id and client_secret from get-all-oidc query 2025-03-04 22:02:42 +05:30
Abhinav Raut
8d8f08e1d2 chore add comments to command box component 2025-03-02 20:58:03 +05:30
Abhinav Raut
10b4f9d08c feat: show app version in admin tab
fix: view form validations and issues with reactivity
feat: save team inbox and view inbox dropdown state in localstorage.
fix: view inbox dropdown icon alignment.
2025-03-02 20:49:19 +05:30
Abhinav Raut
79f74363da fix: hide status dropdown in conversation list as views are prefiltered. 2025-03-02 20:44:05 +05:30
Abhinav Raut
8f6295542e fix: destroy user session when user account is disabled. 2025-03-02 19:17:42 +05:30
Abhinav Raut
8e286e2273 fix: /account navigation from sidebar. 2025-03-02 18:37:05 +05:30
Abhinav Raut
3aad69fc52 fix: update sample database credentials in config file
Matched it with default docker compose password.
2025-03-02 16:31:35 +05:30
Abhinav Raut
58825c3de9 fix: handle invalid sessions by destroying them and redirecting to login 2025-03-02 16:31:00 +05:30
Abhinav Raut
03c68afc4c fix: max age not working for cookies
Switch from expires to max age for setting cookie expiry
Set default max age to 9 hours
2025-03-02 16:28:26 +05:30
Abhinav Raut
15b9caaaed fix: prevent zap logo shrinking and ensure text wraps correctly in command bar
chore: increase command bar size.
2025-03-02 03:31:34 +05:30
Abhinav Raut
b0d3dcb5dd fix: Reply box layout for fullscreen mode 2025-03-02 03:05:51 +05:30
Abhinav Raut
96ef62b509 fix: reduce pagination sizes for conversation and message lists 2025-03-02 03:03:33 +05:30
Abhinav Raut
79c3f5a60c fix: do not clear editor state on API errors.
fix: handle macro errors silently, clear editor state on macro errors as most likely they are permission errors.
2025-03-02 03:02:46 +05:30
Abhinav Raut
70bef7b3ab fix: use explicit v-model binding to match defineModel name for action builder. 2025-03-02 02:55:20 +05:30
Abhinav Raut
b1e1dff3eb feat: replace quill editor with tiptap editor, removes the stupid hack as both editors handle new lines and empty content differently.
Quill adds <p><br></p> for new lines, while Tiptap uses <br> for Shift + Enter and <p> for Enter.

This commit fixes this hack I had added, now all editors in Libredesk are tiptap editors.

fix: Typography for agent and contact message bubbles and macro preview, as tailwind removes browser defaults. Introduces new class `native-html` for this.

fix: removes hardcoded classes in tiptap starter kit configuration as the new class `native-html` takes care of it and has to be just applied.

fix: Form validation for automations and macro form.

fix: automation list padding between items.

feat: adds bullet list and ordered list menu options to tiptap editor.
2025-03-02 01:42:17 +05:30
Abhinav Raut
9b34c2737d feat: multi-tab sync for user availability status and last activity 2025-03-01 20:33:40 +05:30
Abhinav Raut
1b63f03bb1 feat: include recipient details in email templates
With this the admin can simply add
```
Dear {{.Recipient.FirstName}},
```

To the default outgoing template and all outgoing emails will have the receipient name.
2025-03-01 20:04:49 +05:30
Abhinav Raut
26d76c966f feat: allow setting OpenAI API KEY from the UI.
feat: new `ai:manage` permission for the same
Migrations for new role.
2025-03-01 19:40:18 +05:30
Abhinav Raut
1ff335f772 fix: improve welcome email template styling and content
fix: extra large app logo in base template.
refactor: standardize template variables, explicitly pass variables for rendering into template
2025-03-01 19:10:50 +05:30
Abhinav Raut
5836ee8d90 fix: annoying scroll bar when there's a single message in a conversation
adjusts padding around single message in a conversation.
2025-02-28 22:22:13 +05:30
Abhinav Raut
98534f3c5a fix: reduce update check interval and initial sleep duration
As Libredesk is Alpha I will be pushing quick updates and fixes
2025-02-28 22:12:01 +05:30
Abhinav Raut
59951f0829 fix: private message sent as reply 2025-02-28 21:44:02 +05:30
Abhinav Raut
461ae3cf22 fix: sla badge not visible in conversation info sidebar. 2025-02-28 21:32:08 +05:30
Abhinav Raut
da5dfdbcde fix: prevent email enumeration in reset password flow. 2025-02-28 20:57:47 +05:30
Abhinav Raut
9c67c02b08 fix: ensure navigation to SSO list only after creating SSO provider and not while updating SSO provider. 2025-02-27 23:46:31 +05:30
Abhinav Raut
15b200b0db fix: add descriptions for notification settings SMTP config for better clarity 2025-02-27 23:02:14 +05:30
Abhinav Raut
f4617c599c fix: correct Zod schema for email address validation 2025-02-27 23:01:49 +05:30
Abhinav Raut
341d0b7e47 Update README.md 2025-02-27 21:37:07 +05:30
Abhinav Raut
78b8c508d8 fix: message bubble styling for better text wrapping 2025-02-27 03:01:05 +05:30
Abhinav Raut
f17d96f96f rafactor: move full screen editor and non-fullscreen editor to a common component.
feat: add typography plugin and improve DOM purifying in conversation messages
fix: sooner not working in outer app.
fix: macro actions getting deleted when macro is remove from the text editor preview.
fix: square user avatar image in sidebar,made it rounded-lg
refactor: visual fixes and improvements to macro previews for consistency with attachment preview.
2025-02-27 02:47:23 +05:30
Abhinav Raut
c75c117a4d fix: improve password handling and error reporting during password reset 2025-02-27 01:58:08 +05:30
Abhinav Raut
873d26ccb2 fix: ensure deep copy of macros, as removing macro from editor was deleting the macro action from the macro store.
- fix: conversation macro cmds visible when conversation is not open.
2025-02-26 23:19:37 +05:30
Abhinav Raut
71601364ae fix: mark conversation as read when messages are already cached 2025-02-26 17:41:25 +05:30
Abhinav Raut
44723fb70d fix: update command to start backend dev server in documentation 2025-02-26 12:11:15 +05:30
Abhinav Raut
67e1230485 feat: agent availability status
New columns in users table to store user availability status.

Websocket pings sets the last active at timestamp, once user stops sending pings (on disconnect) after 5 minutes the user availalbility status changes to offline.

Detects auto away by checking for mouse, keyboard events and sets user status to away.

User can also set their status to away manually from the sidebar.

Migrations for v0.3.0

Minor visual fixes.

Bump version in package.json
2025-02-26 04:34:30 +05:30
Abhinav Raut
d58898c60f fix: update DockerHub image path and branch reference in installation documentation 2025-02-26 00:52:42 +05:30
Abhinav Raut
a8dc0a6242 fix: correct DockerHub image path in installation documentation 2025-02-26 00:50:30 +05:30
Abhinav Raut
3aa144f703 feat: display app update component only for admin routes. 2025-02-25 18:27:21 +05:30
102 changed files with 2746 additions and 1252 deletions

View File

@@ -7,7 +7,7 @@ Open source, self-hosted customer support desk. Single binary app.
Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live demo**](https://demo.libredesk.io/).
![Screenshot_20250220_231723](https://github.com/user-attachments/assets/55e0ec68-b624-4442-8387-6157742da253)
![Screenshot_20250220_231723](https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-HvmxvOkalQSLp4qVezdTXaCd3dB4Rm.png)
> **CAUTION:** This project is currently in **alpha**. Features and APIs may change and are not yet fully tested.
@@ -74,7 +74,7 @@ __________________
- Run `./libredesk --set-system-user-password` to set the password for the System user.
- Run `./libredesk` and visit `http://localhost:9000` and login with username `System` and the password you set using the --set-system-user-password command.
See [installation docs](https://libredesk.app/docs/installation)
See [installation docs](https://libredesk.io/docs/installation)
__________________

View File

@@ -1,6 +1,14 @@
package main
import "github.com/zerodha/fastglue"
import (
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/zerodha/fastglue"
)
type providerUpdateReq struct {
Provider string `json:"provider"`
APIKey string `json:"api_key"`
}
// handleAICompletion handles AI completion requests
func handleAICompletion(r *fastglue.Request) error {
@@ -27,3 +35,18 @@ func handleGetAIPrompts(r *fastglue.Request) error {
}
return r.SendEnvelope(resp)
}
// handleUpdateAIProvider updates the AI provider
func handleUpdateAIProvider(r *fastglue.Request) error {
var (
app = r.Context.(*App)
req providerUpdateReq
)
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, "Error unmarshalling request", nil))
}
if err := app.ai.UpdateProvider(req.Provider, req.APIKey); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Provider updated successfully")
}

View File

@@ -75,7 +75,7 @@ func handleOIDCCallback(r *fastglue.Request) error {
}
// Lookup the user by email and set the session.
user, err := app.user.GetByEmail(claims.Email)
user, err := app.user.GetAgentByEmail(claims.Email)
if err != nil {
return sendErrorEnvelope(r, err)
}

View File

@@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"strconv"
"strings"
"time"
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
@@ -42,7 +43,6 @@ func handleGetAllConversations(r *fastglue.Request) error {
if conversations[i].SLAPolicyID.Int != 0 {
setSLADeadlines(app, &conversations[i])
}
conversations[i].ID = 0
}
return r.SendEnvelope(envelope.PageResults{
@@ -79,7 +79,6 @@ func handleGetAssignedConversations(r *fastglue.Request) error {
if conversations[i].SLAPolicyID.Int != 0 {
setSLADeadlines(app, &conversations[i])
}
conversations[i].ID = 0
}
return r.SendEnvelope(envelope.PageResults{
@@ -116,7 +115,6 @@ func handleGetUnassignedConversations(r *fastglue.Request) error {
if conversations[i].SLAPolicyID.Int != 0 {
setSLADeadlines(app, &conversations[i])
}
conversations[i].ID = 0
}
return r.SendEnvelope(envelope.PageResults{
@@ -153,7 +151,7 @@ func handleGetViewConversations(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "You don't have access to this view.", nil, envelope.PermissionError)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -195,7 +193,6 @@ func handleGetViewConversations(r *fastglue.Request) error {
if conversations[i].SLAPolicyID.Int != 0 {
setSLADeadlines(app, &conversations[i])
}
conversations[i].ID = 0
}
return r.SendEnvelope(envelope.PageResults{
@@ -248,7 +245,6 @@ func handleGetTeamUnassignedConversations(r *fastglue.Request) error {
if conversations[i].SLAPolicyID.Int != 0 {
setSLADeadlines(app, &conversations[i])
}
conversations[i].ID = 0
}
return r.SendEnvelope(envelope.PageResults{
@@ -268,7 +264,7 @@ func handleGetConversation(r *fastglue.Request) error {
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -284,7 +280,6 @@ func handleGetConversation(r *fastglue.Request) error {
prev, _ := app.conversation.GetContactConversations(conv.ContactID)
conv.PreviousConversations = filterCurrentConv(prev, conv.UUID)
conv.ID = 0
return r.SendEnvelope(conv)
}
@@ -295,7 +290,7 @@ func handleUpdateConversationAssigneeLastSeen(r *fastglue.Request) error {
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -316,7 +311,7 @@ func handleGetConversationParticipants(r *fastglue.Request) error {
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -343,7 +338,7 @@ func handleUpdateUserAssignee(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid `assignee_id`", nil, envelope.InputError)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -375,7 +370,7 @@ func handleUpdateTeamAssignee(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid assignee `id`.", nil, envelope.InputError)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -426,7 +421,7 @@ func handleUpdateConversationPriority(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, err)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -471,7 +466,7 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
}
// Enforce conversation access.
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -528,7 +523,7 @@ func handleUpdateConversationtags(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -607,7 +602,7 @@ func handleRemoveUserAssignee(r *fastglue.Request) error {
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -628,7 +623,7 @@ func handleRemoveTeamAssignee(r *fastglue.Request) error {
uuid = r.RequestCtx.UserValue("uuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -651,3 +646,99 @@ func filterCurrentConv(convs []cmodels.Conversation, uuid string) []cmodels.Conv
}
return []cmodels.Conversation{}
}
// handleCreateConversation creates a new conversation and sends a message to it.
func handleCreateConversation(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
inboxID = r.RequestCtx.PostArgs().GetUintOrZero("inbox_id")
assignedAgentID = r.RequestCtx.PostArgs().GetUintOrZero("agent_id")
assignedTeamID = r.RequestCtx.PostArgs().GetUintOrZero("team_id")
email = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("contact_email")))
firstName = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("first_name")))
lastName = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("last_name")))
subject = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("subject")))
content = string(r.RequestCtx.PostArgs().Peek("content"))
)
// Validate required fields
if inboxID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "inbox_id is required", nil, envelope.InputError)
}
if subject == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "subject is required", nil, envelope.InputError)
}
if content == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "content is required", nil, envelope.InputError)
}
if email == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Contact email is required", nil, envelope.InputError)
}
if firstName == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "First name is required when creating a new contact", nil, envelope.InputError)
}
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Check if inbox exists and is enabled.
inbox, err := app.inbox.GetDBRecord(inboxID)
if err != nil {
return sendErrorEnvelope(r, err)
}
if !inbox.Enabled {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "The chosen inbox is disabled", nil, envelope.InputError)
}
// Find or create contact.
contact := umodels.User{
Email: null.StringFrom(email),
SourceChannelID: null.StringFrom(email),
FirstName: firstName,
LastName: lastName,
InboxID: inboxID,
}
if err := app.user.CreateContact(&contact); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Error creating contact", nil))
}
// Create conversation
conversationID, conversationUUID, err := app.conversation.CreateConversation(
contact.ID,
contact.ContactChannelID,
inboxID,
"", /** last_message **/
time.Now(),
subject,
true, /** append reference number to subject **/
)
if err != nil {
app.lo.Error("error creating conversation", "error", err)
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Error creating conversation", nil))
}
// Send reply to the created conversation.
if err := app.conversation.SendReply(nil /**media**/, inboxID, auser.ID, conversationUUID, content, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil {
if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
app.lo.Error("error deleting conversation", "error", err)
}
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Error sending message", nil))
}
// Assign the conversation to the agent or team.
if assignedAgentID > 0 {
app.conversation.UpdateConversationUserAssignee(conversationUUID, assignedAgentID, user)
}
if assignedTeamID > 0 {
app.conversation.UpdateConversationTeamAssignee(conversationUUID, assignedTeamID, user)
}
// Send the created conversation back to the client.
conversation, err := app.conversation.GetConversation(conversationID, "")
if err != nil {
app.lo.Error("error fetching created conversation", "error", err)
}
return r.SendEnvelope(conversation)
}

View File

@@ -63,10 +63,12 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/api/v1/conversations/{uuid}/messages", perm(handleGetMessages, "messages:read"))
g.POST("/api/v1/conversations/{cuuid}/messages", perm(handleSendMessage, "messages:write"))
g.PUT("/api/v1/conversations/{cuuid}/messages/{uuid}/retry", perm(handleRetryMessage, "messages:write"))
g.POST("/api/v1/conversations", perm(handleCreateConversation, "conversations:write"))
// Search.
g.GET("/api/v1/conversations/search", perm(handleSearchConversations, "conversations:read"))
g.GET("/api/v1/messages/search", perm(handleSearchMessages, "messages:read"))
g.GET("/api/v1/contacts/search", perm(handleSearchContacts, "conversations:write"))
// Views.
g.GET("/api/v1/views/me", perm(handleGetUserViews, "view:manage"))
@@ -99,6 +101,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
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"))
@@ -173,6 +176,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// AI completion.
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"))
// WebSocket.
g.GET("/ws", auth(func(r *fastglue.Request) error {

View File

@@ -308,6 +308,11 @@ func initCSAT(db *sqlx.DB) *csat.Manager {
return m
}
// initWS inits websocket hub.
func initWS(user *user.Manager) *ws.Hub {
return ws.NewHub(user)
}
// initTemplates inits template manager.
func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants) *tmpl.Manager {
var (

View File

@@ -24,7 +24,7 @@ func install(ctx context.Context, db *sqlx.DB, fs stuffbin.FileSystem, idempoten
}
// Make sure the system user password is strong enough.
password := strings.TrimSpace(os.Getenv("LIBREDESK_SYSTEM_USER_PASSWORD"))
password := os.Getenv("LIBREDESK_SYSTEM_USER_PASSWORD")
if password != "" && !user.IsStrongSystemUserPassword(password) && !schemaInstalled {
log.Fatalf("system user password is not strong, %s", user.SystemUserPasswordHint)
}

View File

@@ -3,6 +3,7 @@ package main
import (
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/envelope"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
@@ -11,14 +12,24 @@ import (
func handleLogin(r *fastglue.Request) error {
var (
app = r.Context.(*App)
p = r.RequestCtx.PostArgs()
email = string(p.Peek("email"))
password = p.Peek("password")
email = string(r.RequestCtx.PostArgs().Peek("email"))
password = r.RequestCtx.PostArgs().Peek("password")
)
user, err := app.user.VerifyPassword(email, password)
if err != nil {
return sendErrorEnvelope(r, err)
}
if !user.Enabled {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Your account is disabled, please contact administrator", nil))
}
// Set user availability status to online.
if err := app.user.UpdateAvailability(user.ID, umodels.Online); err != nil {
return sendErrorEnvelope(r, err)
}
user.AvailabilityStatus = umodels.Online
if err := app.auth.SaveSession(amodels.User{
ID: user.ID,
Email: user.Email.String,

View File

@@ -145,7 +145,7 @@ func handleApplyMacro(r *fastglue.Request) error {
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
incomingActions = []autoModels.RuleAction{}
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -239,7 +239,7 @@ func setDisplayValues(app *App, actions []autoModels.RuleAction) error {
return t.Name, nil
},
autoModels.ActionAssignUser: func(id int) (string, error) {
u, err := app.user.Get(id)
u, err := app.user.GetAgent(id)
if err != nil {
app.lo.Warn("user not found for macro action", "user_id", id)
return "", err

View File

@@ -36,7 +36,6 @@ import (
"github.com/abhinavxd/libredesk/internal/team"
"github.com/abhinavxd/libredesk/internal/template"
"github.com/abhinavxd/libredesk/internal/user"
"github.com/abhinavxd/libredesk/internal/ws"
"github.com/knadh/go-i18n"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
@@ -162,7 +161,6 @@ func main() {
messageOutgoingScanInterval = ko.MustDuration("message.message_outoing_scan_interval")
slaEvaluationInterval = ko.MustDuration("sla.evaluation_interval")
lo = initLogger(appName)
wsHub = ws.NewHub()
rdb = initRedis()
constants = initConstants()
i18n = initI18n(fs)
@@ -177,6 +175,7 @@ func main() {
team = initTeam(db)
businessHours = initBusinessHours(db)
user = initUser(i18n, db)
wsHub = initWS(user)
notifier = initNotifier(user)
automation = initAutomationEngine(db)
sla = initSLA(db, team, settings, businessHours)
@@ -193,6 +192,7 @@ func main() {
go notifier.Run(ctx)
go sla.Run(ctx, slaEvaluationInterval)
go media.DeleteUnlinkedMedia(ctx)
go user.MonitorAgentAvailability(ctx)
var app = &App{
lo: lo,
@@ -235,7 +235,7 @@ func main() {
WriteTimeout: ko.MustDuration("app.server.write_timeout"),
MaxRequestBodySize: ko.MustInt("app.server.max_body_size"),
MaxKeepaliveDuration: ko.MustDuration("app.server.keepalive_timeout"),
ReadBufferSize: ko.MustInt("app.server.max_body_size"),
ReadBufferSize: ko.Int("app.server.read_buffer_size"),
}
go func() {
@@ -250,7 +250,7 @@ func main() {
// Start the app update checker.
if ko.Bool("app.check_updates") {
go checkUpdates(versionString, time.Hour*24, app)
go checkUpdates(versionString, time.Hour*1, app)
}
// Wait for shutdown signal.

View File

@@ -150,7 +150,7 @@ func handleServeMedia(r *fastglue.Request) error {
uuid = r.RequestCtx.UserValue("uuid").(string)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}

View File

@@ -30,7 +30,7 @@ func handleGetMessages(r *fastglue.Request) error {
total = 0
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -70,7 +70,7 @@ func handleGetMessage(r *fastglue.Request) error {
cuuid = r.RequestCtx.UserValue("cuuid").(string)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -105,7 +105,7 @@ func handleRetryMessage(r *fastglue.Request) error {
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -133,13 +133,13 @@ func handleSendMessage(r *fastglue.Request) error {
req = messageReq{}
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Check permission
_, err = enforceConversationAccess(app, cuuid, user)
conv, err := enforceConversationAccess(app, cuuid, user)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -163,7 +163,7 @@ func handleSendMessage(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
} else {
if err := app.conversation.SendReply(media, user.ID, cuuid, req.Message, req.CC, req.BCC, map[string]interface{}{}); err != nil {
if err := app.conversation.SendReply(media, conv.InboxID, user.ID, cuuid, req.Message, req.CC, req.BCC, map[string]any{} /**meta**/); err != nil {
return sendErrorEnvelope(r, err)
}
// Evaluate automation rules.

View File

@@ -8,6 +8,7 @@ import (
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
"github.com/zerodha/simplesessions/v3"
)
// tryAuth is a middleware that attempts to authenticate the user and add them to the context
@@ -23,7 +24,7 @@ func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
}
// Try to get user.
user, err := app.user.Get(userSession.ID)
user, err := app.user.GetAgent(userSession.ID)
if err != nil {
return handler(r)
}
@@ -43,9 +44,7 @@ func tryAuth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
// auth makes sure the user is logged in.
func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
var app = r.Context.(*App)
// Validate session and fetch user.
userSession, err := app.auth.ValidateSession(r)
@@ -55,7 +54,7 @@ func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
}
// Set user in the request context.
user, err := app.user.Get(userSession.ID)
user, err := app.user.GetAgent(userSession.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -92,11 +91,19 @@ func perm(handler fastglue.FastRequestHandler, perm string) fastglue.FastRequest
}
// Get user from DB.
user, err := app.user.Get(sessUser.ID)
user, err := app.user.GetAgent(sessUser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Destroy session if user is disabled.
if !user.Enabled {
if err := app.auth.DestroySession(r); err != nil {
app.lo.Error("error destroying session", "error", err)
}
return r.SendErrorEnvelope(http.StatusUnauthorized, "User account disabled", nil, envelope.PermissionError)
}
// Split the permission string into object and action and enforce it.
parts := strings.Split(perm, ":")
if len(parts) != 2 {
@@ -131,9 +138,17 @@ func authPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
// Validate session.
user, err := app.auth.ValidateSession(r)
if err != nil {
app.lo.Error("error validating session", "error", err)
return r.SendErrorEnvelope(http.StatusUnauthorized, "Invalid or expired session", nil, envelope.PermissionError)
// Session is not valid, destroy it and redirect to login.
if err != simplesessions.ErrInvalidSession {
app.lo.Error("error validating session", "error", err)
return r.SendErrorEnvelope(http.StatusUnauthorized, "Error validating session", nil, envelope.PermissionError)
}
if err := app.auth.DestroySession(r); err != nil {
app.lo.Error("error destroying session", "error", err)
}
}
// User is authenticated.
if user.ID > 0 {
return handler(r)
}
@@ -142,7 +157,7 @@ func authPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
if len(nextURI) == 0 {
nextURI = r.RequestCtx.RequestURI()
}
return r.RedirectURI("/", fasthttp.StatusFound, map[string]interface{}{
return r.RedirectURI("/", fasthttp.StatusFound, map[string]any{
"next": string(nextURI),
}, "")
}

View File

@@ -2,9 +2,11 @@ package main
import (
"strconv"
"strings"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/oidc/models"
"github.com/abhinavxd/libredesk/internal/stringutil"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
@@ -26,6 +28,10 @@ func handleGetAllOIDC(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, err)
}
// Replace secrets with dummy values.
for i := range out {
out[i].ClientSecret = strings.Repeat(stringutil.PasswordDummy, 10)
}
return r.SendEnvelope(out)
}

View File

@@ -44,3 +44,19 @@ func handleSearchMessages(r *fastglue.Request) error {
}
return r.SendEnvelope(messages)
}
// handleSearchContacts searches contacts based on the query.
func handleSearchContacts(r *fastglue.Request) error {
var (
app = r.Context.(*App)
q = string(r.RequestCtx.QueryArgs().Peek("query"))
)
if len(q) < minSearchQueryLength {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, "Query length should be at least 3 characters", nil))
}
contacts, err := app.search.Contacts(q)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(contacts)
}

View File

@@ -2,6 +2,7 @@ package main
import (
"encoding/json"
"net/mail"
"strings"
"github.com/abhinavxd/libredesk/internal/envelope"
@@ -20,14 +21,16 @@ func handleGetGeneralSettings(r *fastglue.Request) error {
if err != nil {
return sendErrorEnvelope(r, err)
}
// Unmarshal to add the app.update to the settings.
// Unmarshal to set the app.update to the settings, so the frontend can show that an update is available.
var settings map[string]interface{}
if err := json.Unmarshal(out, &settings); err != nil {
app.lo.Error("error unmarshalling settings", "err", err)
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Error fetching settings", nil))
}
// Add the app.update to the settings, adding `app` prefix to the key to match the settings structure in db.
// Set the app.update to the settings, adding `app` prefix to the key to match the settings structure in db.
settings["app.update"] = app.update
// Set app version.
settings["app.version"] = versionString
return r.SendEnvelope(settings)
}
@@ -98,6 +101,11 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Error updating settings", nil))
}
// Make sure it's a valid from email address.
if _, err := mail.ParseAddress(req.EmailAddress); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid from email address format", nil, envelope.InputError)
}
if req.Password == "" {
req.Password = cur.Password
}
@@ -105,5 +113,7 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
if err := app.setting.Update(req); err != nil {
return sendErrorEnvelope(r, err)
}
// No reload implemented, so user has to restart the app.
return r.SendEnvelope("Settings updated successfully, Please restart the app for changes to take effect.")
}

View File

@@ -83,9 +83,9 @@ func checkUpdates(curVersion string, interval time.Duration, app *App) {
app.Unlock()
}
// Give a 15 minute buffer after app start in case the admin wants to disable
// Give a 5 minute buffer after app start in case the admin wants to disable
// update checks entirely and not make a request to upstream.
time.Sleep(time.Minute * 15)
time.Sleep(time.Minute * 5)
fnCheck()
// Thereafter, check every $interval.

View File

@@ -10,6 +10,7 @@ import (
"strings"
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/migrations"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
@@ -28,7 +29,10 @@ type migFunc struct {
// migList is the list of available migList ordered by the semver.
// Each migration is a Go file in internal/migrations named after the semver.
// The functions are named as: v0.7.0 => migrations.V0_7_0() and are idempotent.
var migList = []migFunc{}
var migList = []migFunc{
{"v0.3.0", migrations.V0_3_0},
{"v0.4.0", migrations.V0_4_0},
}
// upgrade upgrades the database to the current version by running SQL migration files
// for all version from the last known version to the current one.

View File

@@ -22,7 +22,7 @@ import (
)
const (
maxAvatarSizeMB = 5
maxAvatarSizeMB = 20
)
// handleGetUsers returns all users.
@@ -39,9 +39,7 @@ func handleGetUsers(r *fastglue.Request) error {
// handleGetUsersCompact returns all users in a compact format.
func handleGetUsersCompact(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
var app = r.Context.(*App)
agents, err := app.user.GetAllCompact()
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, err.Error(), nil, "")
@@ -59,20 +57,33 @@ func handleGetUser(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid user `id`.", nil, envelope.InputError)
}
user, err := app.user.Get(id)
user, err := app.user.GetAgent(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(user)
}
// handleUpdateUserAvailability updates the current user availability.
func handleUpdateUserAvailability(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
status = string(r.RequestCtx.PostArgs().Peek("status"))
)
if err := app.user.UpdateAvailability(auser.ID, status); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("User availability updated successfully.")
}
// handleGetCurrentUserTeams returns the teams of a user.
func handleGetCurrentUserTeams(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -90,13 +101,7 @@ func handleUpdateCurrentUser(r *fastglue.Request) error {
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
user, err := app.user.Get(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Get current user.
currentUser, err := app.user.Get(user.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -154,8 +159,8 @@ func handleUpdateCurrentUser(r *fastglue.Request) error {
}
// Delete current avatar.
if currentUser.AvatarURL.Valid {
fileName := filepath.Base(currentUser.AvatarURL.String)
if user.AvatarURL.Valid {
fileName := filepath.Base(user.AvatarURL.String)
app.media.Delete(fileName)
}
@@ -212,9 +217,9 @@ func handleCreateUser(r *fastglue.Request) error {
}
// Render template and send email.
content, err := app.tmpl.RenderTemplate(tmpl.TmplWelcome, map[string]interface{}{
content, err := app.tmpl.RenderInMemoryTemplate(tmpl.TmplWelcome, map[string]any{
"ResetToken": resetToken,
"Email": user.Email,
"Email": user.Email.String,
})
if err != nil {
app.lo.Error("error rendering template", "error", err)
@@ -228,7 +233,7 @@ func handleCreateUser(r *fastglue.Request) error {
Provider: notifier.ProviderEmail,
}); err != nil {
app.lo.Error("error sending notification message", "error", err)
return r.SendEnvelope("User created successfully, but error sending welcome email.")
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "User created successfully, but could not send welcome email.", nil))
}
}
return r.SendEnvelope("User created successfully.")
@@ -305,7 +310,7 @@ func handleGetCurrentUser(r *fastglue.Request) error {
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
)
u, err := app.user.Get(auser.ID)
u, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -320,14 +325,14 @@ func handleDeleteAvatar(r *fastglue.Request) error {
)
// Get user
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Valid str?
if user.AvatarURL.String == "" {
return r.SendEnvelope(true)
return r.SendEnvelope("Avatar deleted successfully.")
}
fileName := filepath.Base(user.AvatarURL.String)
@@ -336,8 +341,8 @@ func handleDeleteAvatar(r *fastglue.Request) error {
if err := app.media.Delete(fileName); err != nil {
return sendErrorEnvelope(r, err)
}
err = app.user.UpdateAvatar(user.ID, "")
if err != nil {
if err = app.user.UpdateAvatar(user.ID, ""); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Avatar deleted successfully.")
@@ -352,16 +357,17 @@ func handleResetPassword(r *fastglue.Request) error {
email = string(p.Peek("email"))
)
if ok && auser.ID > 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "User is already logged in", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "User is already logged in, Please logout to reset password.", nil, envelope.InputError)
}
if email == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty `email`", nil, envelope.InputError)
}
user, err := app.user.GetByEmail(email)
user, err := app.user.GetAgentByEmail(email)
if err != nil {
return sendErrorEnvelope(r, err)
// 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)
@@ -370,10 +376,9 @@ func handleResetPassword(r *fastglue.Request) error {
}
// Send email.
content, err := app.tmpl.RenderTemplate(tmpl.TmplResetPassword,
map[string]string{
"ResetToken": token,
})
content, err := app.tmpl.RenderInMemoryTemplate(tmpl.TmplResetPassword, map[string]string{
"ResetToken": token,
})
if err != nil {
app.lo.Error("error rendering template", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error rendering template", nil, envelope.GeneralError)
@@ -385,8 +390,8 @@ func handleResetPassword(r *fastglue.Request) error {
Content: content,
Provider: notifier.ProviderEmail,
}); err != nil {
app.lo.Error("error sending notification message", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error sending notification message", nil, envelope.GeneralError)
app.lo.Error("error sending password reset email", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error sending password reset email", nil, envelope.GeneralError)
}
return r.SendEnvelope("Reset password email sent successfully.")

View File

@@ -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.Get(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, "decode failed", err.Error(), envelope.InputError)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -46,7 +46,7 @@ func handleCreateUserView(r *fastglue.Request) error {
}
if string(view.Filters) == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty view `Filter`", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Please provide at least one filter", nil, envelope.InputError)
}
if err := app.view.Create(view.Name, view.Filters, user.ID); err != nil {
@@ -71,7 +71,7 @@ func handleDeleteUserView(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty view `ID`", nil, envelope.InputError)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -109,7 +109,7 @@ func handleUpdateUserView(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
}
user, err := app.user.Get(auser.ID)
user, err := app.user.GetAgent(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}

View File

@@ -11,6 +11,7 @@ socket = ""
read_timeout = "5s"
write_timeout = "5s"
max_body_size = 500000000
read_buffer_size = 4096
keepalive_timeout = "10s"
# File upload provider to use, either `fs` or `s3`.
@@ -36,8 +37,9 @@ expiry = "6h"
# If using docker compose, use the service name as the host. e.g. db
host = "127.0.0.1"
port = 5432
user = "postgres"
password = "postgres"
# Update the following values with your database credentials.
user = "libredesk"
password = "libredesk"
database = "libredesk"
ssl_mode = "disable"
max_open = 30
@@ -72,4 +74,4 @@ autoassign_interval = "5m"
unsnooze_interval = "5m"
[sla]
evaluation_interval = "5m"
evaluation_interval = "5m"

View File

@@ -21,7 +21,7 @@ git clone https://github.com/abhinavxd/libredesk.git
### Running the Dev Environment
1. Run `make run` to start the libredesk backend dev server on `:9000`.
1. Run `make run-backend` to start the libredesk backend dev server on `:9000`.
2. Run `make run-frontend` to start the Vue frontend in dev mode using pnpm on `:8000`. Requests are proxied to the backend running on `:9000` check `vite.config.js` for the proxy config.
---

View File

@@ -5,7 +5,7 @@ Libredesk is an open source, self-hosted customer support desk. Single binary ap
<div style="border: 1px solid #ccc; padding: 1px; border-radius:5px; box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.1); background-color: #fff;">
<a href="https://libredesk.io">
<img src="https://hebbkx1anhila5yf.public.blob.vercel-storage.com/Screenshot_20250220_231723-VxuEQgEiFfI9xhzJDOvgMK0yJ0TwR3.png" alt="libredesk screenshot" style="display: block; margin: 0 auto;">
<img src="https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-HvmxvOkalQSLp4qVezdTXaCd3dB4Rm.png" alt="libredesk screenshot" style="display: block; margin: 0 auto;">
</a>
</div>

View File

@@ -15,9 +15,9 @@ Libredesk is a single binary application that requires postgres and redis to run
## Docker
The latest image is available on DockerHub at `libredesk/llibredeskistmonk:latest`
The latest image is available on DockerHub at `libredesk/libredesk:latest`
The recommended method is to download the [docker-compose.yml](https://github.com/abhinavxd/libredesk/blob/master/docker-compose.yml) file, customize it for your environment and then to simply run `docker compose up -d`.
The recommended method is to download the [docker-compose.yml](https://github.com/abhinavxd/libredesk/blob/main/docker-compose.yml) file, customize it for your environment and then to simply run `docker compose up -d`.
```shell
# Download the compose file and the sample config file in the current directory.
@@ -41,7 +41,7 @@ Go to `http://localhost:9000` and login with the email `System` and the password
## Compiling from source
To compile the latest unreleased version (`master` branch):
To compile the latest unreleased version (`main` branch):
1. Make sure `go`, `nodejs`, and `pnpm` are installed on your system.
2. `git clone git@github.com:abhinavxd/libredesk.git`

View File

@@ -1,6 +1,6 @@
{
"name": "libredesk",
"version": "0.0.0",
"version": "0.3.0",
"private": true,
"type": "module",
"scripts": {
@@ -18,6 +18,7 @@
"@formkit/auto-animate": "^0.8.2",
"@internationalized/date": "^3.5.5",
"@radix-icons/vue": "^1.0.0",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/vue-table": "^8.19.2",
"@tiptap/extension-image": "^2.5.9",
"@tiptap/extension-link": "^2.9.1",
@@ -28,7 +29,6 @@
"@unovis/ts": "^1.4.4",
"@unovis/vue": "^1.4.4",
"@vee-validate/zod": "^4.13.2",
"@vueup/vue-quill": "^1.2.0",
"@vueuse/core": "^12.4.0",
"axios": "^1.7.9",
"class-variance-authority": "^0.7.0",
@@ -43,6 +43,7 @@
"tailwind-merge": "^2.3.0",
"vee-validate": "^4.13.2",
"vue": "^3.4.37",
"vue-dompurify-html": "^5.2.0",
"vue-i18n": "9",
"vue-letter": "^0.2.0",
"vue-picture-cropper": "^0.7.0",

287
frontend/pnpm-lock.yaml generated
View File

@@ -17,6 +17,9 @@ importers:
'@radix-icons/vue':
specifier: ^1.0.0
version: 1.0.0(vue@3.5.13(typescript@5.7.3))
'@tailwindcss/typography':
specifier: ^0.5.16
version: 0.5.16(tailwindcss@3.4.17)
'@tanstack/vue-table':
specifier: ^8.19.2
version: 8.20.5(vue@3.5.13(typescript@5.7.3))
@@ -47,9 +50,6 @@ importers:
'@vee-validate/zod':
specifier: ^4.13.2
version: 4.15.0(vue@3.5.13(typescript@5.7.3))(zod@3.24.1)
'@vueup/vue-quill':
specifier: ^1.2.0
version: 1.2.0(vue@3.5.13(typescript@5.7.3))
'@vueuse/core':
specifier: ^12.4.0
version: 12.4.0(typescript@5.7.3)
@@ -92,6 +92,9 @@ importers:
vue:
specifier: ^3.4.37
version: 3.5.13(typescript@5.7.3)
vue-dompurify-html:
specifier: ^5.2.0
version: 5.2.0(vue@3.5.13(typescript@5.7.3))
vue-i18n:
specifier: '9'
version: 9.14.2(vue@3.5.13(typescript@5.7.3))
@@ -737,6 +740,11 @@ packages:
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
'@tailwindcss/typography@0.5.16':
resolution: {integrity: sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==}
peerDependencies:
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
'@tanstack/table-core@8.20.5':
resolution: {integrity: sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==}
engines: {node: '>=12'}
@@ -815,8 +823,8 @@ packages:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/extension-hard-break@2.11.2':
resolution: {integrity: sha512-FNcXemfuwkiP4drZ9m90BC6GD4nyikfYHYEUyYuVd74Mm6w5vXpueWXus3mUcdT78xTs1XpQVibDorilLu7X8w==}
'@tiptap/extension-hard-break@2.11.5':
resolution: {integrity: sha512-q9doeN+Yg9F5QNTG8pZGYfNye3tmntOwch683v0CCVCI4ldKaLZ0jG3NbBTq+mosHYdgOH2rNbIORlRRsQ+iYQ==}
peerDependencies:
'@tiptap/core': ^2.7.0
@@ -1076,6 +1084,9 @@ packages:
'@types/topojson@3.2.6':
resolution: {integrity: sha512-ppfdlxjxofWJ66XdLgIlER/85RvpGyfOf8jrWf+3kVIjEatFxEZYD/Ea83jO672Xu1HRzd/ghwlbcZIUNHTskw==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/web-bluetooth@0.0.20':
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
@@ -1159,11 +1170,6 @@ packages:
'@vue/shared@3.5.13':
resolution: {integrity: sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==}
'@vueup/vue-quill@1.2.0':
resolution: {integrity: sha512-kd5QPSHMDpycklojPXno2Kw2JSiKMYduKYQckTm1RJoVDA557MnyUXgcuuDpry4HY/Rny9nGNcK+m3AHk94wag==}
peerDependencies:
vue: ^3.2.41
'@vueuse/core@10.11.1':
resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==}
@@ -1346,10 +1352,6 @@ packages:
resolution: {integrity: sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==}
engines: {node: '>= 0.4'}
call-bind@1.0.8:
resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==}
engines: {node: '>= 0.4'}
call-bound@1.0.3:
resolution: {integrity: sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==}
engines: {node: '>= 0.4'}
@@ -1407,10 +1409,6 @@ packages:
resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==}
engines: {node: '>=8'}
clone@2.1.2:
resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==}
engines: {node: '>=0.8'}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@@ -1678,21 +1676,9 @@ packages:
resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
engines: {node: '>=0.10'}
deep-equal@1.1.2:
resolution: {integrity: sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==}
engines: {node: '>= 0.4'}
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
define-data-property@1.1.4:
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
engines: {node: '>= 0.4'}
define-properties@1.2.1:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
defu@6.1.4:
resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
@@ -1718,6 +1704,9 @@ packages:
resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
engines: {node: '>=6.0.0'}
dompurify@3.2.4:
resolution: {integrity: sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@@ -1863,9 +1852,6 @@ packages:
eventemitter2@6.4.7:
resolution: {integrity: sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==}
eventemitter3@2.0.3:
resolution: {integrity: sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==}
execa@4.1.0:
resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==}
engines: {node: '>=10'}
@@ -1893,12 +1879,6 @@ packages:
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-diff@1.1.2:
resolution: {integrity: sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==}
fast-diff@1.2.0:
resolution: {integrity: sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==}
fast-diff@1.3.0:
resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==}
@@ -1985,9 +1965,6 @@ packages:
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
functions-have-names@1.2.3:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
geojson-vt@3.2.1:
resolution: {integrity: sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==}
@@ -2066,17 +2043,10 @@ packages:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
has-property-descriptors@1.0.2:
resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
has-tostringtag@1.0.2:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
@@ -2146,10 +2116,6 @@ packages:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
is-arguments@1.2.0:
resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==}
engines: {node: '>= 0.4'}
is-arrayish@0.2.1:
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
@@ -2161,10 +2127,6 @@ packages:
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
engines: {node: '>= 0.4'}
is-date-object@1.1.0:
resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
engines: {node: '>= 0.4'}
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@@ -2189,10 +2151,6 @@ packages:
resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==}
engines: {node: '>=8'}
is-regex@1.2.1:
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
engines: {node: '>= 0.4'}
is-stream@2.0.1:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'}
@@ -2317,11 +2275,11 @@ packages:
lodash-es@4.17.21:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
lodash.clonedeep@4.5.0:
resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==}
lodash.castarray@4.4.0:
resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==}
lodash.isequal@4.5.0:
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@@ -2468,14 +2426,6 @@ packages:
resolution: {integrity: sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==}
engines: {node: '>= 0.4'}
object-is@1.1.6:
resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==}
engines: {node: '>= 0.4'}
object-keys@1.1.1:
resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
engines: {node: '>= 0.4'}
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
@@ -2508,9 +2458,6 @@ packages:
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
parchment@1.1.4:
resolution: {integrity: sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -2615,6 +2562,10 @@ packages:
peerDependencies:
postcss: ^8.2.14
postcss-selector-parser@6.0.10:
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
engines: {node: '>=4'}
postcss-selector-parser@6.1.2:
resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
engines: {node: '>=4'}
@@ -2747,16 +2698,6 @@ packages:
quickselect@2.0.0:
resolution: {integrity: sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==}
quill-delta@3.6.3:
resolution: {integrity: sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==}
engines: {node: '>=0.10'}
quill-delta@4.2.2:
resolution: {integrity: sha512-qjbn82b/yJzOjstBgkhtBjN2TNK+ZHP/BgUQO+j6bRhWQQdmj2lH6hXG7+nwwLF41Xgn//7/83lxs9n2BkTtTg==}
quill@1.3.7:
resolution: {integrity: sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==}
radix-vue@1.9.12:
resolution: {integrity: sha512-zkr66Jqxbej4+oR6O/pZRzyM/VZi66ndbyIBZQjJKAXa1lIoYReZJse6W1EEDZKXknD7rXhpS+jM9Sr23lIqfg==}
peerDependencies:
@@ -2776,10 +2717,6 @@ packages:
regenerator-runtime@0.14.1:
resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==}
regexp.prototype.flags@1.5.4:
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
engines: {node: '>= 0.4'}
request-progress@3.0.0:
resolution: {integrity: sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==}
@@ -2850,14 +2787,6 @@ packages:
engines: {node: '>=10'}
hasBin: true
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
set-function-name@2.0.2:
resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==}
engines: {node: '>= 0.4'}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -3191,6 +3120,11 @@ packages:
'@vue/composition-api':
optional: true
vue-dompurify-html@5.2.0:
resolution: {integrity: sha512-GX+BStkKEJ8wu/+hU1EK2nu/gzXWhb4XzBu6aowpsuU/3nkvXvZ2jx4nZ9M3jtS/Vu7J7MtFXjc7x3cWQ+zbVQ==}
peerDependencies:
vue: ^3.0.0
vue-eslint-parser@9.4.3:
resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==}
engines: {node: ^14.17.0 || >=16.0.0}
@@ -3802,6 +3736,14 @@ snapshots:
dependencies:
tslib: 2.8.1
'@tailwindcss/typography@0.5.16(tailwindcss@3.4.17)':
dependencies:
lodash.castarray: 4.4.0
lodash.isplainobject: 4.0.6
lodash.merge: 4.6.2
postcss-selector-parser: 6.0.10
tailwindcss: 3.4.17
'@tanstack/table-core@8.20.5': {}
'@tanstack/virtual-core@3.11.2': {}
@@ -3867,7 +3809,7 @@ snapshots:
'@tiptap/core': 2.11.2(@tiptap/pm@2.11.2)
'@tiptap/pm': 2.11.2
'@tiptap/extension-hard-break@2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))':
'@tiptap/extension-hard-break@2.11.5(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))':
dependencies:
'@tiptap/core': 2.11.2(@tiptap/pm@2.11.2)
@@ -3960,7 +3902,7 @@ snapshots:
'@tiptap/extension-document': 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))
'@tiptap/extension-dropcursor': 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))(@tiptap/pm@2.11.2)
'@tiptap/extension-gapcursor': 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))(@tiptap/pm@2.11.2)
'@tiptap/extension-hard-break': 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))
'@tiptap/extension-hard-break': 2.11.5(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))
'@tiptap/extension-heading': 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))
'@tiptap/extension-history': 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))(@tiptap/pm@2.11.2)
'@tiptap/extension-horizontal-rule': 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))(@tiptap/pm@2.11.2)
@@ -4187,6 +4129,9 @@ snapshots:
'@types/topojson-simplify': 3.0.3
'@types/topojson-specification': 1.0.5
'@types/trusted-types@2.0.7':
optional: true
'@types/web-bluetooth@0.0.20': {}
'@types/yauzl@2.10.3':
@@ -4343,12 +4288,6 @@ snapshots:
'@vue/shared@3.5.13': {}
'@vueup/vue-quill@1.2.0(vue@3.5.13(typescript@5.7.3))':
dependencies:
quill: 1.3.7
quill-delta: 4.2.2
vue: 3.5.13(typescript@5.7.3)
'@vueuse/core@10.11.1(vue@3.5.13(typescript@5.7.3))':
dependencies:
'@types/web-bluetooth': 0.0.20
@@ -4537,13 +4476,6 @@ snapshots:
es-errors: 1.3.0
function-bind: 1.1.2
call-bind@1.0.8:
dependencies:
call-bind-apply-helpers: 1.0.1
es-define-property: 1.0.1
get-intrinsic: 1.2.7
set-function-length: 1.2.2
call-bound@1.0.3:
dependencies:
call-bind-apply-helpers: 1.0.1
@@ -4603,8 +4535,6 @@ snapshots:
slice-ansi: 3.0.0
string-width: 4.2.3
clone@2.1.2: {}
clsx@2.1.1: {}
codeflask@1.4.1:
@@ -4921,29 +4851,8 @@ snapshots:
decode-uri-component@0.2.2:
optional: true
deep-equal@1.1.2:
dependencies:
is-arguments: 1.2.0
is-date-object: 1.1.0
is-regex: 1.2.1
object-is: 1.1.6
object-keys: 1.1.1
regexp.prototype.flags: 1.5.4
deep-is@0.1.4: {}
define-data-property@1.1.4:
dependencies:
es-define-property: 1.0.1
es-errors: 1.3.0
gopd: 1.2.0
define-properties@1.2.1:
dependencies:
define-data-property: 1.1.4
has-property-descriptors: 1.0.2
object-keys: 1.1.1
defu@6.1.4: {}
delaunator@5.0.1:
@@ -4963,6 +4872,10 @@ snapshots:
dependencies:
esutils: 2.0.3
dompurify@3.2.4:
optionalDependencies:
'@types/trusted-types': 2.0.7
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.1
@@ -5157,8 +5070,6 @@ snapshots:
eventemitter2@6.4.7: {}
eventemitter3@2.0.3: {}
execa@4.1.0:
dependencies:
cross-spawn: 7.0.6
@@ -5203,10 +5114,6 @@ snapshots:
fast-deep-equal@3.1.3: {}
fast-diff@1.1.2: {}
fast-diff@1.2.0: {}
fast-diff@1.3.0: {}
fast-glob@3.3.3:
@@ -5291,8 +5198,6 @@ snapshots:
function-bind@1.1.2: {}
functions-have-names@1.2.3: {}
geojson-vt@3.2.1: {}
geojson@0.5.0: {}
@@ -5381,16 +5286,8 @@ snapshots:
has-flag@4.0.0: {}
has-property-descriptors@1.0.2:
dependencies:
es-define-property: 1.0.1
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.1.0
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
@@ -5443,11 +5340,6 @@ snapshots:
internmap@2.0.3: {}
is-arguments@1.2.0:
dependencies:
call-bound: 1.0.3
has-tostringtag: 1.0.2
is-arrayish@0.2.1: {}
is-binary-path@2.1.0:
@@ -5458,11 +5350,6 @@ snapshots:
dependencies:
hasown: 2.0.2
is-date-object@1.1.0:
dependencies:
call-bound: 1.0.3
has-tostringtag: 1.0.2
is-extglob@2.1.1: {}
is-fullwidth-code-point@3.0.0: {}
@@ -5480,13 +5367,6 @@ snapshots:
is-path-inside@3.0.3: {}
is-regex@1.2.1:
dependencies:
call-bound: 1.0.3
gopd: 1.2.0
has-tostringtag: 1.0.2
hasown: 2.0.2
is-stream@2.0.1: {}
is-typedarray@1.0.0: {}
@@ -5598,9 +5478,9 @@ snapshots:
lodash-es@4.17.21: {}
lodash.clonedeep@4.5.0: {}
lodash.castarray@4.4.0: {}
lodash.isequal@4.5.0: {}
lodash.isplainobject@4.0.6: {}
lodash.merge@4.6.2: {}
@@ -5744,13 +5624,6 @@ snapshots:
object-inspect@1.13.3: {}
object-is@1.1.6:
dependencies:
call-bind: 1.0.8
define-properties: 1.2.1
object-keys@1.1.1: {}
once@1.4.0:
dependencies:
wrappy: 1.0.2
@@ -5786,8 +5659,6 @@ snapshots:
package-json-from-dist@1.0.1: {}
parchment@1.1.4: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -5873,6 +5744,11 @@ snapshots:
postcss: 8.4.49
postcss-selector-parser: 6.1.2
postcss-selector-parser@6.0.10:
dependencies:
cssesc: 3.0.0
util-deprecate: 1.0.2
postcss-selector-parser@6.1.2:
dependencies:
cssesc: 3.0.0
@@ -6032,27 +5908,6 @@ snapshots:
quickselect@2.0.0: {}
quill-delta@3.6.3:
dependencies:
deep-equal: 1.1.2
extend: 3.0.2
fast-diff: 1.1.2
quill-delta@4.2.2:
dependencies:
fast-diff: 1.2.0
lodash.clonedeep: 4.5.0
lodash.isequal: 4.5.0
quill@1.3.7:
dependencies:
clone: 2.1.2
deep-equal: 1.1.2
eventemitter3: 2.0.3
extend: 3.0.2
parchment: 1.1.4
quill-delta: 3.6.3
radix-vue@1.9.12(vue@3.5.13(typescript@5.7.3)):
dependencies:
'@floating-ui/dom': 1.6.13
@@ -6082,15 +5937,6 @@ snapshots:
regenerator-runtime@0.14.1: {}
regexp.prototype.flags@1.5.4:
dependencies:
call-bind: 1.0.8
define-properties: 1.2.1
es-errors: 1.3.0
get-proto: 1.0.1
gopd: 1.2.0
set-function-name: 2.0.2
request-progress@3.0.0:
dependencies:
throttleit: 1.0.1
@@ -6176,22 +6022,6 @@ snapshots:
semver@7.6.3: {}
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
es-errors: 1.3.0
function-bind: 1.1.2
get-intrinsic: 1.2.7
gopd: 1.2.0
has-property-descriptors: 1.0.2
set-function-name@2.0.2:
dependencies:
define-data-property: 1.1.4
es-errors: 1.3.0
functions-have-names: 1.2.3
has-property-descriptors: 1.0.2
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
@@ -6530,6 +6360,11 @@ snapshots:
dependencies:
vue: 3.5.13(typescript@5.7.3)
vue-dompurify-html@5.2.0(vue@3.5.13(typescript@5.7.3)):
dependencies:
dompurify: 3.2.4
vue: 3.5.13(typescript@5.7.3)
vue-eslint-parser@9.4.3(eslint@8.57.1):
dependencies:
debug: 4.4.0(supports-color@8.1.1)

View File

@@ -46,10 +46,16 @@
@create-view="openCreateViewForm = true"
@edit-view="editView"
@delete-view="deleteView"
@create-conversation="() => openCreateConversationDialog = true"
>
<div class="flex flex-col h-screen">
<AppUpdate />
<!-- Show app update only in admin routes -->
<AppUpdate v-if="route.path.startsWith('/admin')" />
<!-- Common header for all pages -->
<PageHeader />
<!-- Main content -->
<RouterView class="flex-grow" />
</div>
<ViewForm v-model:openDialog="openCreateViewForm" v-model:view="view" />
@@ -59,6 +65,9 @@
<!-- Command box -->
<Command />
<!-- Create conversation dialog -->
<CreateConversation v-model="openCreateConversationDialog" />
</template>
<script setup>
@@ -76,6 +85,7 @@ import { useTeamStore } from '@/stores/team'
import { useSlaStore } from '@/stores/sla'
import { useMacroStore } from '@/stores/macro'
import { useTagStore } from '@/stores/tag'
import { useIdleDetection } from '@/composables/useIdleDetection'
import PageHeader from './components/layout/PageHeader.vue'
import ViewForm from '@/features/view/ViewForm.vue'
import AppUpdate from '@/components/update/AppUpdate.vue'
@@ -83,6 +93,7 @@ import api from '@/api'
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 { useRoute } from 'vue-router'
import {
@@ -111,8 +122,11 @@ const tagStore = useTagStore()
const userViews = ref([])
const view = ref({})
const openCreateViewForm = ref(false)
const openCreateConversationDialog = ref(false)
initWS()
useIdleDetection()
onMounted(() => {
initToaster()
listenViewRefresh()
@@ -121,8 +135,10 @@ onMounted(() => {
// initialize data stores
const initStores = async () => {
if (!userStore.userID) {
await userStore.getCurrentUser()
}
await Promise.allSettled([
userStore.getCurrentUser(),
getUserViews(),
conversationStore.fetchStatuses(),
conversationStore.fetchPriorities(),

View File

@@ -1,7 +1,27 @@
<template>
<RouterView />
<RouterView />
</template>
<script setup>
import { onMounted } from 'vue'
import { RouterView } from 'vue-router'
</script>
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { toast as sooner } from 'vue-sonner'
const emitter = useEmitter()
onMounted(() => {
initToaster()
})
const initToaster = () => {
emitter.on(EMITTER_EVENTS.SHOW_TOAST, (message) => {
if (message.variant === 'destructive') {
sooner.error(message.description)
} else {
sooner.success(message.description)
}
})
}
</script>

View File

@@ -35,6 +35,7 @@ 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}`)
@@ -169,10 +170,12 @@ const updateCurrentUser = (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 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)
const removeAssignee = (uuid, assignee_type) => http.put(`/api/v1/conversations/${uuid}/assignee/${assignee_type}/remove`)
const createConversation = (data) => http.post('/api/v1/conversations', data)
const updateConversationStatus = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/status`, data)
const updateConversationPriority = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/priority`, data)
const updateAssigneeLastSeen = (uuid) => http.put(`/api/v1/conversations/${uuid}/last-seen`)
@@ -264,6 +267,7 @@ const updateView = (id, data) =>
const deleteView = (id) => http.delete(`/api/v1/views/me/${id}`)
const getAiPrompts = () => http.get('/api/v1/ai/prompts')
const aiCompletion = (data) => http.post('/api/v1/ai/completion', data)
const updateAIProvider = (data) => http.put('/api/v1/ai/provider', data)
export default {
login,
@@ -323,12 +327,15 @@ export default {
uploadMedia,
updateAssigneeLastSeen,
updateUser,
updateCurrentUserAvailability,
updateAutomationRule,
updateAutomationRuleWeights,
updateAutomationRulesExecutionMode,
updateAIProvider,
createAutomationRule,
toggleAutomationRule,
deleteAutomationRule,
createConversation,
sendMessage,
retryMessage,
createUser,
@@ -373,5 +380,6 @@ export default {
aiCompletion,
searchConversations,
searchMessages,
searchContacts,
removeAssignee,
}

View File

@@ -18,6 +18,49 @@
overflow-x: auto;
}
}
.native-html {
p {
margin-bottom: 0.5rem;
}
ul {
list-style-type: disc;
margin-left: 1.5rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
ol {
list-style-type: decimal;
margin-left: 1.5rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
li {
padding-left: 0.25rem;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: 1.25rem;
font-weight: 700;
}
a {
color: #0066cc;
cursor: pointer;
&:hover {
color: #003d7a;
}
}
}
}
// Theme.

View File

@@ -18,6 +18,7 @@ import {
SidebarProvider,
SidebarRail
} from '@/components/ui/sidebar'
import { useAppSettingsStore } from '@/stores/appSettings'
import {
ChevronRight,
EllipsisVertical,
@@ -43,8 +44,9 @@ defineProps({
userViews: { type: Array, default: () => [] }
})
const userStore = useUserStore()
const settingsStore = useAppSettingsStore()
const route = useRoute()
const emit = defineEmits(['createView', 'editView', 'deleteView'])
const emit = defineEmits(['createView', 'editView', 'deleteView', 'createConversation'])
const openCreateViewDialog = () => {
emit('createView')
@@ -70,6 +72,8 @@ const isInboxRoute = (path) => {
}
const sidebarOpen = useStorage('mainSidebarOpen', true)
const teamInboxOpen = useStorage('teamInboxOpen', true)
const viewInboxOpen = useStorage('viewInboxOpen', true)
</script>
<template>
@@ -122,9 +126,13 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton :isActive="isActiveParent('/admin')" asChild>
<div>
<div class="flex items-center justify-between w-full">
<span class="font-semibold text-xl">Admin</span>
</div>
<!-- App version -->
<div class="text-xs text-muted-foreground ml-2">
({{ settingsStore.settings['app.version'] }})
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
@@ -222,15 +230,27 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
<div class="flex items-center justify-between w-full">
<div class="font-semibold text-xl">Inbox</div>
<div class="ml-auto">
<router-link :to="{ name: 'search' }">
<div class="flex items-center bg-accent p-2 rounded-full">
<Search
class="transition-transform duration-200 hover:scale-110 cursor-pointer"
<div class="flex items-center space-x-2">
<div
class="flex items-center bg-accent p-2 rounded-full cursor-pointer"
@click="emit('createConversation')"
>
<Plus
class="transition-transform duration-200 hover:scale-110"
size="15"
stroke-width="2.5"
/>
</div>
</router-link>
<router-link :to="{ name: 'search' }">
<div class="flex items-center bg-accent p-2 rounded-full">
<Search
class="transition-transform duration-200 hover:scale-110 cursor-pointer"
size="15"
stroke-width="2.5"
/>
</div>
</router-link>
</div>
</div>
</div>
</SidebarMenuButton>
@@ -269,7 +289,12 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
</SidebarMenuItem>
<!-- Team Inboxes -->
<Collapsible defaultOpen class="group/collapsible" v-if="userTeams.length">
<Collapsible
defaultOpen
class="group/collapsible"
v-if="userTeams.length"
v-model:open="teamInboxOpen"
>
<SidebarMenuItem>
<CollapsibleTrigger as-child>
<SidebarMenuButton asChild>
@@ -301,7 +326,7 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
</Collapsible>
<!-- Views -->
<Collapsible class="group/collapsible" defaultOpen>
<Collapsible class="group/collapsible" defaultOpen v-model:open="viewInboxOpen">
<SidebarMenuItem>
<CollapsibleTrigger as-child>
<SidebarMenuButton asChild>
@@ -315,17 +340,14 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
class="rounded-lg cursor-pointer opacity-0 transition-all duration-200 group-hover:opacity-100 hover:bg-gray-200 hover:shadow-sm text-gray-600 hover:text-gray-800 transform hover:scale-105 active:scale-100 p-1"
/>
</div>
<ChevronRight
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
v-if="userViews.length"
/>
</router-link>
</SidebarMenuButton>
</CollapsibleTrigger>
<SidebarMenuAction>
<ChevronRight
class="transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
v-if="userViews.length"
/>
</SidebarMenuAction>
<CollapsibleContent>
<SidebarMenuSub v-for="view in userViews" :key="view.id">
<SidebarMenuSubItem>
@@ -335,25 +357,24 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
asChild
>
<router-link :to="{ name: 'view-inbox', params: { viewID: view.id } }">
<span class="break-all w-24">{{ view.name }}</span>
<span class="break-words w-32 truncate">{{ view.name }}</span>
<SidebarMenuAction :showOnHover="true" class="mr-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<EllipsisVertical />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="() => editView(view)">
<span>Edit</span>
</DropdownMenuItem>
<DropdownMenuItem @click="() => deleteView(view)">
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuAction>
</router-link>
</SidebarMenuButton>
<SidebarMenuAction>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<EllipsisVertical />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="() => editView(view)">
<span>Edit</span>
</DropdownMenuItem>
<DropdownMenuItem @click="() => deleteView(view)">
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuAction>
</SidebarMenuSubItem>
</SidebarMenuSub>
</CollapsibleContent>

View File

@@ -1,82 +1,99 @@
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground p-0">
<Avatar class="h-8 w-8 rounded-lg">
<AvatarImage :src="userStore.avatar" alt="Abhinav" />
<AvatarFallback class="rounded-lg">
{{ userStore.getInitials }}
</AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{{ userStore.getFullName }}</span>
<span class="truncate text-xs">{{ userStore.email }}</span>
</div>
<ChevronsUpDown class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg" side="bottom"
:side-offset="4">
<DropdownMenuLabel class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar class="h-8 w-8 rounded-lg">
<AvatarImage :src="userStore.avatar" alt="Abhinav" />
<AvatarFallback class="rounded-lg">
{{ userStore.getInitials }}
</AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{{ userStore.getFullName }}</span>
<span class="truncate text-xs">{{ userStore.email }}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<router-link to="/account" class="flex items-center">
<CircleUserRound size="18" class="mr-2" />
Account
</router-link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem @click="logout">
<LogOut size="18" class="mr-2" />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton
size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground p-0"
>
<Avatar class="h-8 w-8 rounded-lg relative overflow-visible">
<AvatarImage :src="userStore.avatar" alt="" class="rounded-lg" />
<AvatarFallback class="rounded-lg">
{{ userStore.getInitials }}
</AvatarFallback>
<div
class="absolute bottom-0 right-0 h-2.5 w-2.5 rounded-full border border-background"
:class="{
'bg-green-500': userStore.user.availability_status === 'online',
'bg-amber-500':
userStore.user.availability_status === 'away' ||
userStore.user.availability_status === 'away_manual',
'bg-gray-400': userStore.user.availability_status === 'offline'
}"
></div>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{{ userStore.getFullName }}</span>
<span class="truncate text-xs">{{ userStore.email }}</span>
</div>
<ChevronsUpDown class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
class="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
side="bottom"
:side-offset="4"
>
<DropdownMenuLabel class="p-0 font-normal space-y-1">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar class="h-8 w-8 rounded-lg">
<AvatarImage :src="userStore.avatar" alt="Abhinav" />
<AvatarFallback class="rounded-lg">
{{ userStore.getInitials }}
</AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{{ userStore.getFullName }}</span>
<span class="truncate text-xs">{{ userStore.email }}</span>
</div>
</div>
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm justify-between">
<span class="text-muted-foreground">Away</span>
<Switch
:checked="
userStore.user.availability_status === 'away' ||
userStore.user.availability_status === 'away_manual'
"
@update:checked="(val) => userStore.updateUserAvailability(val ? 'away' : 'online')"
/>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem @click.prevent="router.push({ name: 'account' })">
<CircleUserRound size="18" class="mr-2" />
Account
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem @click="logout">
<LogOut size="18" class="mr-2" />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
<script setup>
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import {
SidebarMenuButton,
} from '@/components/ui/sidebar'
import {
Avatar,
AvatarFallback,
AvatarImage,
} from '@/components/ui/avatar'
import {
ChevronsUpDown,
CircleUserRound,
LogOut,
} from 'lucide-vue-next'
import { SidebarMenuButton } from '@/components/ui/sidebar'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Switch } from '@/components/ui/switch'
import { ChevronsUpDown, CircleUserRound, LogOut } from 'lucide-vue-next'
import { useUserStore } from '@/stores/user'
import { useRouter } from 'vue-router'
const userStore = useUserStore()
const router = useRouter()
const logout = () => {
window.location.href = '/logout'
window.location.href = '/logout'
}
</script>
</script>

View File

@@ -0,0 +1,59 @@
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { useUserStore } from '@/stores/user'
import { debounce } from '@/utils/debounce'
import { useStorage } from '@vueuse/core'
export function useIdleDetection () {
const userStore = useUserStore()
// 4 minutes
const AWAY_THRESHOLD = 4 * 60 * 1000
// 1 minute
const CHECK_INTERVAL = 60 * 1000
// Store last activity time in localStorage to sync across tabs
const lastActivity = useStorage('last_active', Date.now())
const timer = ref(null)
function resetTimer () {
if (userStore.user.availability_status === 'away' || userStore.user.availability_status === 'offline') {
userStore.updateUserAvailability('online', false)
}
const now = Date.now()
if (lastActivity.value < now) {
lastActivity.value = now
}
}
const debouncedResetTimer = debounce(resetTimer, 200)
function checkIdle () {
if (Date.now() - lastActivity.value > AWAY_THRESHOLD &&
userStore.user.availability_status === 'online') {
userStore.updateUserAvailability('away', false)
}
}
onMounted(() => {
window.addEventListener('mousemove', debouncedResetTimer)
window.addEventListener('keypress', debouncedResetTimer)
window.addEventListener('click', debouncedResetTimer)
timer.value = setInterval(checkIdle, CHECK_INTERVAL)
})
onBeforeUnmount(() => {
window.removeEventListener('mousemove', debouncedResetTimer)
window.removeEventListener('keypress', debouncedResetTimer)
window.removeEventListener('click', debouncedResetTimer)
if (timer.value) {
clearInterval(timer.value)
timer.value = null
}
})
// Watch for lastActivity changes in localStorage to handle multi-tab sync
watch(lastActivity, (newVal, oldVal) => {
if (newVal > oldVal) {
resetTimer()
}
})
}

View File

@@ -18,5 +18,5 @@ export function useSla (dueAt, actualAt) {
clearInterval(intervalId)
})
})
return { sla, updateSla }
return sla
}

View File

@@ -10,7 +10,6 @@
<div class="flex items-center justify-between">
<div class="flex gap-5">
<div class="w-48">
<!-- Type -->
<Select
v-model="action.type"
@@ -109,15 +108,13 @@
</div>
<div
class="box p-2 h-96 min-h-96"
v-if="action.type && conversationActions[action.type]?.type === 'richtext'"
class="pl-0 shadow"
>
<QuillEditor
theme="snow"
v-model:content="action.value[0]"
contentType="html"
@update:content="(value) => handleValueChange(value, index)"
class="h-32 mb-12"
<Editor
v-model:htmlContent="action.value[0]"
@update:htmlContent="(value) => handleEditorChange(value, index)"
:placeholder="'Shift + Enter to add new line'"
/>
</div>
</div>
@@ -142,12 +139,12 @@ import {
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { SelectTag } from '@/components/ui/select'
import { useConversationFilters } from '@/composables/useConversationFilters'
import { getTextFromHTML } from '@/utils/strings.js'
import Editor from '@/features/conversation/ConversationTextEditor.vue'
const props = defineProps({
actions: {
@@ -175,6 +172,16 @@ const handleValueChange = (value, index) => {
emitUpdate(index)
}
const handleEditorChange = (value, index) => {
// If text is empty, set HTML to empty string
const textContent = getTextFromHTML(value)
if (textContent.length === 0) {
value = ''
}
actions.value[index].value = [value]
emitUpdate(index)
}
const removeAction = (index) => {
emit('remove-action', index)
}

View File

@@ -31,7 +31,7 @@
</template>
</draggable>
</div>
<div v-else>
<div v-else class="space-y-5">
<RuleList
v-for="rule in rules"
:key="rule.id"

View File

@@ -108,19 +108,6 @@
placeholder="Select tag"
/>
</div>
<div
v-if="action.type && config.actions[action.type]?.type === 'richtext'"
class="pl-0 shadow"
>
<QuillEditor
v-model:content="action.value[0]"
theme="snow"
contentType="html"
@update:content="(value) => updateValue(value, index)"
class="h-32 mb-12"
/>
</div>
</div>
</div>
</div>
@@ -139,14 +126,12 @@ import {
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { SelectTag } from '@/components/ui/select'
import { useTagStore } from '@/stores/tag'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
const model = defineModel({
const model = defineModel("actions", {
type: Array,
required: true,
default: () => []

View File

@@ -13,16 +13,25 @@
<FormField v-slot="{ componentField }" name="message_content">
<FormItem>
<FormLabel>Response to be sent when macro is used</FormLabel>
<FormLabel>Response to be sent when macro is used (optional)</FormLabel>
<FormControl>
<QuillEditor
v-model:content="componentField.modelValue"
placeholder="Add a response (optional)"
theme="snow"
contentType="html"
class="h-32 mb-12"
@update:content="(value) => componentField.onChange(value)"
/>
<div class="box p-2 h-96 min-h-96">
<Editor
v-model:htmlContent="componentField.modelValue"
@update:htmlContent="(value) => componentField.onChange(value)"
:placeholder="'Shift + Enter to add new line'"
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="actions">
<FormItem>
<FormLabel> Actions (optional)</FormLabel>
<FormControl>
<ActionBuilder v-model:actions="componentField.modelValue" :config="actionConfig" @update:actions="(value) => componentField.onChange(value)" />
</FormControl>
<FormMessage />
</FormItem>
@@ -106,16 +115,6 @@
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="actions">
<FormItem>
<FormLabel> Actions </FormLabel>
<FormControl>
<ActionBuilder v-bind="componentField" :config="actionConfig" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<Button type="submit" :isLoading="isLoading">{{ submitLabel }}</Button>
</form>
</template>
@@ -133,9 +132,8 @@ import ActionBuilder from '@/features/admin/macros/ActionBuilder.vue'
import { useConversationFilters } from '@/composables/useConversationFilters'
import { useUsersStore } from '@/stores/users'
import { useTeamStore } from '@/stores/team'
import { getTextFromHTML } from '@/utils/strings.js'
import { formSchema } from './formSchema.js'
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
import {
Select,
SelectContent,
@@ -145,6 +143,7 @@ import {
SelectValue
} from '@/components/ui/select'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
import Editor from '@/features/conversation/ConversationTextEditor.vue'
const { macroActions } = useConversationFilters()
const formLoading = ref(false)
@@ -181,6 +180,11 @@ const actionConfig = ref({
})
const onSubmit = form.handleSubmit(async (values) => {
// If the text of HTML is empty then set the HTML to empty string
const textContent = getTextFromHTML(values.message_content)
if (textContent.length === 0) {
values.message_content = ''
}
props.submitForm(values)
})

View File

@@ -1,4 +1,5 @@
import * as z from 'zod'
import { getTextFromHTML } from '@/utils/strings.js'
const actionSchema = z.array(
z.object({
@@ -10,8 +11,42 @@ const actionSchema = z.array(
export const formSchema = z.object({
name: z.string().min(1, 'Macro name is required'),
message_content: z.string().optional(),
actions: actionSchema,
actions: actionSchema.optional().default([]), // Default to empty array if not provided
visibility: z.enum(['all', 'team', 'user']),
team_id: z.string().nullable().optional(),
user_id: z.string().nullable().optional(),
})
})
.refine(
(data) => {
// Check if message_content has non-empty text after stripping HTML
const hasMessageContent = getTextFromHTML(data.message_content || '').trim().length > 0
// Check if actions has at least one valid action
const hasValidActions = data.actions && data.actions.length > 0
// Either message content or actions must be valid
return hasMessageContent || hasValidActions
},
{
message: 'Either message content or actions are required',
// Field path to highlight
path: ['message_content'],
}
)
.refine(
(data) => {
// If visibility is 'team', team_id is required
if (data.visibility === 'team' && !data.team_id) {
return false
}
// If visibility is 'user', user_id is required
if (data.visibility === 'user' && !data.user_id) {
return false
}
// Otherwise, validation passes
return true
},
{
message: 'team is required when visibility is "team", and user is required when visibility is "user"',
// Field path to highlight
path: ['visibility'],
}
)

View File

@@ -65,6 +65,7 @@
<Input type="number" placeholder="2" v-bind="componentField" />
</FormControl>
<FormMessage />
<FormDescription> Maximum concurrent connections to the server. </FormDescription>
</FormItem>
</FormField>
@@ -76,6 +77,10 @@
<Input type="text" placeholder="15s" v-bind="componentField" />
</FormControl>
<FormMessage />
<FormDescription>
Time to wait for new activity on a connection before closing it and removing it from the
pool (s for second, m for minute)
</FormDescription>
</FormItem>
</FormField>
@@ -87,6 +92,10 @@
<Input type="text" placeholder="5s" v-bind="componentField" />
</FormControl>
<FormMessage />
<FormDescription>
Time to wait for new activity on a connection before closing it and removing it from the
pool (s for second, m for minute, h for hour).
</FormDescription>
</FormItem>
</FormField>
@@ -139,6 +148,7 @@
<Input type="number" placeholder="2" v-bind="componentField" />
</FormControl>
<FormMessage />
<FormDescription> Number of times to retry when a message fails. </FormDescription>
</FormItem>
</FormField>

View File

@@ -3,7 +3,7 @@ import { isGoDuration } from '@/utils/strings';
export const smtpConfigSchema = z.object({
enabled: z.boolean().describe('Enabled status').default(false),
username: z.string().describe('SMTP username').email().nonempty({
username: z.string().describe('SMTP username').nonempty({
message: "SMTP username is required"
}),
host: z.string().describe('SMTP host').nonempty({
@@ -51,8 +51,8 @@ export const smtpConfigSchema = z.object({
auth_protocol: z
.enum(['plain', 'login', 'cram', 'none'])
.describe('Authentication protocol'),
email_address: z.string().describe('Email address').email().nonempty({
message: "Email address is required"
email_address: z.string().describe('From email address with name (e.g., "Name <email@example.com>")').nonempty({
message: "From email address is required"
}),
max_msg_retries: z
.number({

View File

@@ -13,7 +13,11 @@
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input type="text" placeholder="This role is for all support agents" v-bind="componentField" />
<Input
type="text"
placeholder="This role is for all support agents"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -24,13 +28,19 @@
<div v-for="entity in permissions" :key="entity.name" class="box p-4">
<p class="text-lg mb-5">{{ entity.name }}</p>
<div class="space-y-4">
<FormField v-for="permission in entity.permissions" :key="permission.name" type="checkbox"
:name="permission.name">
<FormField
v-for="permission in entity.permissions"
:key="permission.name"
type="checkbox"
:name="permission.name"
>
<FormItem class="flex flex-col gap-y-5 space-y-0 rounded-lg">
<div class="flex space-x-3">
<FormControl>
<Checkbox :checked="selectedPermissions.includes(permission.name)"
@update:checked="(newValue) => handleChange(newValue, permission.name)" />
<Checkbox
:checked="selectedPermissions.includes(permission.name)"
@update:checked="(newValue) => handleChange(newValue, permission.name)"
/>
<FormLabel>{{ permission.label }}</FormLabel>
</FormControl>
</div>
@@ -69,7 +79,7 @@ const props = defineProps({
},
isLoading: {
type: Boolean,
required: false,
required: false
}
})
@@ -77,7 +87,8 @@ const permissions = ref([
{
name: 'Conversation',
permissions: [
{ name: 'conversations:read', label: 'View conversations' },
{ name: 'conversations:read', label: 'View conversation' },
{ name: 'conversations:write', label: 'Create conversation' },
{ name: 'conversations:read_assigned', label: 'View conversations assigned to me' },
{ name: 'conversations:read_all', label: 'View all conversations' },
{ name: 'conversations:read_unassigned', label: 'View all unassigned conversations' },
@@ -89,7 +100,7 @@ const permissions = ref([
{ name: 'conversations:update_tags', label: 'Add or remove conversation tags' },
{ name: 'messages:read', label: 'View conversation messages' },
{ name: 'messages:write', label: 'Send messages in conversations' },
{ name: 'view:manage', label: 'Create and manage conversation views' },
{ name: 'view:manage', label: 'Create and manage conversation views' }
]
},
{
@@ -110,8 +121,9 @@ const permissions = ref([
{ name: 'reports:manage', label: 'Manage Reports' },
{ name: 'business_hours:manage', label: 'Manage Business Hours' },
{ name: 'sla:manage', label: 'Manage SLA Policies' },
{ name: 'ai:manage', label: 'Manage AI Features' }
]
},
}
])
const selectedPermissions = ref([])

View File

@@ -1,7 +1,11 @@
<template>
<CommandDialog :open="open" @update:open="handleOpenChange" class="z-[51]">
<CommandDialog
:open="open"
@update:open="handleOpenChange"
class="z-[51] !min-w-[50vw] !min-h-[60vh]"
>
<CommandInput placeholder="Type a command or search..." @keydown="onInputKeydown" />
<CommandList class="!min-h-[400px]">
<CommandList class="!min-h-[60vh] !min-w-[50vw]">
<CommandEmpty>
<p class="text-muted-foreground">No command available</p>
</CommandEmpty>
@@ -10,7 +14,7 @@
<CommandGroup
heading="Conversations"
value="conversations"
v-if="nestedCommand === null && conversationStore.current"
v-if="nestedCommand === null && conversationStore.hasConversationOpen"
>
<CommandItem value="conv-snooze" @select="setNestedCommand('snooze')"> Snooze </CommandItem>
<CommandItem value="conv-resolve" @select="resolveConversation"> Resolve </CommandItem>
@@ -32,12 +36,12 @@
</CommandGroup>
<!-- Macros -->
<!-- TODO move to a separate component -->
<div v-if="nestedCommand === 'apply-macro'" class="bg-background">
<CommandGroup heading="Apply macro" class="pb-2">
<div class="min-h-[400px] overflow-auto">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-4 border-r border-border/30 pr-2">
<!-- Left Column: Macro List (30%) -->
<div class="col-span-4 pr-2 border-r">
<CommandItem
v-for="(macro, index) in macroStore.macroOptions"
:key="macro.value"
@@ -45,25 +49,29 @@
:data-index="index"
@select="handleApplyMacro(macro)"
class="px-3 py-2 rounded-md cursor-pointer transition-all duration-200 hover:bg-primary/10 hover:text-primary"
:class="{ 'bg-primary/5 text-primary': selectedMacroIndex === index }"
>
<div class="flex items-center space-x-2 justify-start">
<Zap :size="14" class="text-primary" />
<span class="text-sm overflow">{{ macro.label }}</span>
<div class="flex items-center gap-2">
<Zap size="14" class="text-primary shrink-0" />
<span class="text-sm truncate w-full break-words whitespace-normal">{{
macro.label
}}</span>
</div>
</CommandItem>
</div>
<!-- Right Column: Macro Details (70%) -->
<div class="col-span-8 pl-2">
<div class="space-y-3 text-xs">
<!-- Reply Preview -->
<div v-if="replyContent" class="space-y-1">
<p class="text-xs font-semibold text-primary">Reply Preview</p>
<div
class="w-full min-h-200 p-2 bg-muted/50 rounded-md overflow-auto shadow-sm"
v-html="replyContent"
class="w-full min-h-200 p-2 bg-muted/50 rounded-md overflow-auto shadow-sm native-html"
v-dompurify-html="replyContent"
/>
</div>
<!-- Actions -->
<div v-if="otherActions.length > 0" class="space-y-1">
<p class="text-xs font-semibold text-primary">Actions</p>
<div class="space-y-1.5 max-w-sm">
@@ -105,6 +113,8 @@
</div>
</div>
</div>
<!-- Empty State -->
<div
v-if="!replyContent && otherActions.length === 0"
class="flex items-center justify-center h-20"
@@ -122,7 +132,6 @@
</CommandList>
<!-- Navigation -->
<!-- TODO: Move to a separate component -->
<div class="mt-2 px-4 py-2 text-xs text-gray-500 flex space-x-4">
<span><kbd>Enter</kbd> select</span>
<span><kbd></kbd>/<kbd></kbd> navigate</span>
@@ -132,7 +141,6 @@
</CommandDialog>
<!-- Date Picker for Custom Snooze -->
<!-- TODO: Move to a separate component -->
<Dialog :open="showDatePicker" @update:open="closeDatePicker">
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
@@ -219,7 +227,9 @@ watch([Meta_K, Ctrl_K], ([mac, win]) => {
const highlightedMacro = ref(null)
function handleApplyMacro(macro) {
conversationStore.setMacro(macro)
// Create a deep copy.
const plainMacro = JSON.parse(JSON.stringify(macro))
conversationStore.setMacro(plainMacro)
handleOpenChange()
}

View File

@@ -38,7 +38,7 @@
<div class="flex flex-col flex-grow overflow-hidden">
<MessageList class="flex-1 overflow-y-auto" />
<div class="sticky bottom-0">
<ReplyBox class="h-max" />
<ReplyBox />
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<template>
<div class="max-h-[600px] overflow-y-auto">
<div class="editor-wrapper h-full overflow-y-auto">
<BubbleMenu
:editor="editor"
:tippy-options="{ duration: 100 }"
@@ -7,7 +7,7 @@
class="bg-white p-1 box will-change-transform"
>
<div class="flex space-x-1 items-center">
<DropdownMenu>
<DropdownMenu v-if="aiPrompts.length > 0">
<DropdownMenuTrigger>
<Button size="sm" variant="ghost" class="flex items-center justify-center">
<span class="flex items-center">
@@ -30,7 +30,7 @@
<Button
size="sm"
variant="ghost"
@click="isBold = !isBold"
@click.prevent="isBold = !isBold"
:active="isBold"
:class="{ 'bg-gray-200': isBold }"
>
@@ -39,22 +39,39 @@
<Button
size="sm"
variant="ghost"
@click="isItalic = !isItalic"
@click.prevent="isItalic = !isItalic"
:active="isItalic"
:class="{ 'bg-gray-200': isItalic }"
>
<Italic size="14" />
</Button>
<Button
size="sm"
variant="ghost"
@click.prevent="toggleBulletList"
:class="{ 'bg-gray-200': editor?.isActive('bulletList') }"
>
<List size="14" />
</Button>
<Button
size="sm"
variant="ghost"
@click.prevent="toggleOrderedList"
:class="{ 'bg-gray-200': editor?.isActive('orderedList') }"
>
<ListOrdered size="14" />
</Button>
</div>
</BubbleMenu>
<EditorContent :editor="editor" />
<EditorContent :editor="editor" class="native-html" />
</div>
</template>
<script setup>
import { ref, watch, watchEffect, onUnmounted } from 'vue'
import { useEditor, EditorContent, BubbleMenu } from '@tiptap/vue-3'
import { ChevronDown, Bold, Italic, Bot } from 'lucide-vue-next'
import { ChevronDown, Bold, Italic, Bot, List, ListOrdered } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
@@ -95,28 +112,7 @@ const getSelectionText = (from, to, doc) => doc.textBetween(from, to)
const editorConfig = {
extensions: [
// Lists are unstyled in tailwind, so need to add classes to them.
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: 'list-disc ml-6 my-2'
}
},
orderedList: {
HTMLAttributes: {
class: 'list-decimal ml-6 my-2'
}
},
listItem: {
HTMLAttributes: {
class: 'pl-1'
}
},
heading: {
HTMLAttributes: {
class: 'text-xl font-bold mt-4 mb-2'
}
}
}),
StarterKit.configure(),
Image.configure({ HTMLAttributes: { class: 'inline-image' } }),
Placeholder.configure({ placeholder: () => props.placeholder }),
Link
@@ -179,13 +175,20 @@ watchEffect(() => {
watch(
() => props.contentToSet,
(newContent) => {
if (newContent === '') {
editor.value?.commands.clearContent()
} else {
editor.value?.commands.setContent(newContent, true)
(newContentData) => {
if (!newContentData) return
try {
const parsedData = JSON.parse(newContentData)
const content = parsedData.content
if (content === '') {
editor.value?.commands.clearContent()
} else {
editor.value?.commands.setContent(content, true)
}
editor.value?.commands.focus()
} catch (e) {
console.error('Error parsing content data', e)
}
editor.value?.commands.focus()
}
)
@@ -231,6 +234,18 @@ watch(
onUnmounted(() => {
editor.value?.destroy()
})
const toggleBulletList = () => {
if (editor.value) {
editor.value.chain().focus().toggleBulletList().run()
}
}
const toggleOrderedList = () => {
if (editor.value) {
editor.value.chain().focus().toggleOrderedList().run()
}
}
</script>
<style lang="scss">
@@ -243,22 +258,26 @@ onUnmounted(() => {
height: 0;
}
// Editor height
.ProseMirror {
min-height: 80px !important;
max-height: 60% !important;
overflow-y: scroll !important;
// Ensure the parent div has a proper height
.editor-wrapper div[aria-expanded='false'] {
display: flex;
flex-direction: column;
height: 100%;
}
.fullscreen-tiptap-editor {
@apply p-0;
.ProseMirror {
min-height: 600px !important;
width: 90%;
scrollbar-width: none;
}
// Ensure the editor content has a proper height and breaks words
.tiptap.ProseMirror {
flex: 1;
min-height: 70px;
overflow-y: auto;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
word-break: break-word;
white-space: pre-wrap;
max-width: 100%;
}
// Anchor tag styling
.tiptap {
a {
color: #0066cc;

View File

@@ -0,0 +1,345 @@
<template>
<Dialog :open="dialogOpen" @update:open="dialogOpen = false">
<DialogContent class="max-w-5xl w-full h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>New Conversation</DialogTitle>
</DialogHeader>
<form @submit="createConversation" class="flex flex-col flex-1 overflow-hidden">
<div class="flex-1 space-y-4 pr-1 overflow-y-auto pb-2">
<FormField name="contact_email">
<FormItem class="relative">
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="Search contact by email or type new email"
v-model="emailQuery"
@input="handleSearchContacts"
autocomplete="off"
/>
</FormControl>
<FormMessage />
<ul
v-if="searchResults.length"
class="border rounded p-2 max-h-60 overflow-y-auto absolute bg-white w-full z-50 shadow-lg"
>
<li
v-for="contact in searchResults"
:key="contact.email"
@click="selectContact(contact)"
class="cursor-pointer p-2 hover:bg-gray-100 rounded"
>
{{ contact.first_name }} {{ contact.last_name }} ({{ contact.email }})
</li>
</ul>
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="first_name">
<FormItem>
<FormLabel>First Name</FormLabel>
<FormControl>
<Input type="text" placeholder="First Name" v-bind="componentField" required />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="last_name">
<FormItem>
<FormLabel>Last Name</FormLabel>
<FormControl>
<Input type="text" placeholder="Last Name" v-bind="componentField" required />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="subject">
<FormItem>
<FormLabel>Subject</FormLabel>
<FormControl>
<Input type="text" placeholder="Subject" v-bind="componentField" required />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="inbox_id">
<FormItem>
<FormLabel>Inbox</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Select an inbox" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem
v-for="option in inboxStore.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Set assigned team -->
<FormField v-slot="{ componentField }" name="team_id">
<FormItem>
<FormLabel>Assign team (optional)</FormLabel>
<FormControl>
<ComboBox
v-bind="componentField"
:items="[{ value: 'none', label: 'None' }, ...teamStore.options]"
placeholder="Search team"
defaultLabel="Assign team"
>
<template #item="{ item }">
<div class="flex items-center gap-3 py-2">
<div class="w-7 h-7 flex items-center justify-center">
<span v-if="item.emoji">{{ item.emoji }}</span>
<div
v-else
class="text-primary bg-muted rounded-full w-7 h-7 flex items-center justify-center"
>
<Users size="14" />
</div>
</div>
<span class="text-sm">{{ item.label }}</span>
</div>
</template>
<template #selected="{ selected }">
<div class="flex items-center gap-3" v-if="selected">
<div class="w-7 h-7 flex items-center justify-center">
{{ selected?.emoji }}
</div>
<span class="text-sm">{{ selected?.label || 'Select team' }}</span>
</div>
</template>
</ComboBox>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Set assigned agent -->
<FormField v-slot="{ componentField }" name="agent_id">
<FormItem>
<FormLabel>Assign agent (optional)</FormLabel>
<FormControl>
<ComboBox
v-bind="componentField"
:items="[{ value: 'none', label: 'None' }, ...uStore.options]"
placeholder="Search agent"
defaultLabel="Assign agent"
>
<template #item="{ item }">
<div class="flex items-center gap-3 py-2">
<Avatar class="w-8 h-8">
<AvatarImage
:src="item.value === 'none' ? '/default-avatar.png' : item.avatar_url"
:alt="item.value === 'none' ? 'N' : item.label.slice(0, 2)"
/>
<AvatarFallback>
{{ item.value === 'none' ? 'N' : item.label.slice(0, 2).toUpperCase() }}
</AvatarFallback>
</Avatar>
<span class="text-sm">{{ item.label }}</span>
</div>
</template>
<template #selected="{ selected }">
<div class="flex items-center gap-3">
<Avatar class="w-7 h-7" v-if="selected">
<AvatarImage
:src="
selected?.value === 'none'
? '/default-avatar.png'
: selected?.avatar_url
"
:alt="selected?.value === 'none' ? 'N' : selected?.label?.slice(0, 2)"
/>
<AvatarFallback>
{{
selected?.value === 'none'
? 'N'
: selected?.label?.slice(0, 2)?.toUpperCase()
}}
</AvatarFallback>
</Avatar>
<span class="text-sm">{{ selected?.label || 'Assign agent' }}</span>
</div>
</template>
</ComboBox>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField
v-slot="{ componentField }"
name="content"
class="flex-1 min-h-0 flex flex-col"
>
<FormItem class="flex flex-col flex-1">
<FormLabel>Message</FormLabel>
<FormControl class="flex-1 min-h-0 flex flex-col">
<div class="flex-1 min-h-0 flex flex-col">
<Editor
v-model:htmlContent="componentField.modelValue"
@update:htmlContent="(value) => componentField.onChange(value)"
:placeholder="'Shift + Enter to add new line'"
class="w-full flex-1 overflow-y-auto p-2 min-h-[200px] box"
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<DialogFooter class="mt-4 pt-2 border-t shrink-0">
<Button type="submit" :disabled="loading" :isLoading="loading"> Submit </Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>
<script setup>
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { z } from 'zod'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { ref, defineModel, watch } from 'vue'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
import { handleHTTPError } from '@/utils/http'
import { useInboxStore } from '@/stores/inbox'
import { useUsersStore } from '@/stores/users'
import { useTeamStore } from '@/stores/team'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import Editor from '@/features/conversation/ConversationTextEditor.vue'
import api from '@/api'
const dialogOpen = defineModel({
required: false,
default: () => false
})
const inboxStore = useInboxStore()
const uStore = useUsersStore()
const teamStore = useTeamStore()
const emitter = useEmitter()
const loading = ref(false)
const searchResults = ref([])
const emailQuery = ref('')
let timeoutId = null
const formSchema = z.object({
subject: z.string().min(3, 'Subject must be at least 3 characters'),
content: z.string().min(1, 'Message cannot be empty'),
inbox_id: z.any().refine((val) => inboxStore.options.some((option) => option.value === val), {
message: 'Inbox is required'
}),
team_id: z.any().optional(),
agent_id: z.any().optional(),
contact_email: z.string().email('Invalid email address'),
first_name: z.string().min(1, 'First name is required'),
last_name: z.string().min(1, 'Last name is required')
})
const form = useForm({
validationSchema: toTypedSchema(formSchema),
initialValues: {
inbox_id: null,
team_id: null,
agent_id: null,
subject: '',
content: '',
contact_email: '',
first_name: '',
last_name: ''
}
})
watch(emailQuery, (newVal) => {
form.setFieldValue('contact_email', newVal)
})
const handleSearchContacts = async () => {
clearTimeout(timeoutId)
timeoutId = setTimeout(async () => {
const query = emailQuery.value.trim()
if (query.length < 3) {
searchResults.value.splice(0)
return
}
try {
const resp = await api.searchContacts({ query })
searchResults.value = [...resp.data.data]
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
searchResults.value.splice(0)
}
}, 300)
}
const selectContact = (contact) => {
emailQuery.value = contact.email
form.setFieldValue('first_name', contact.first_name)
form.setFieldValue('last_name', contact.last_name || '')
searchResults.value.splice(0)
}
const createConversation = form.handleSubmit(async (values) => {
loading.value = true
try {
await api.createConversation(values)
dialogOpen.value = false
form.resetForm()
emailQuery.value = ''
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
loading.value = false
}
})
</script>

View File

@@ -1,16 +1,16 @@
<template>
<div class="flex flex-wrap px-2 py-1">
<div class="flex flex-wrap">
<div class="flex flex-wrap gap-2">
<div
v-for="action in actions"
:key="action.type"
class="flex items-center bg-white border border-gray-200 rounded shadow-sm transition-all duration-300 ease-in-out hover:shadow-md group"
class="flex items-center bg-white border border-gray-200 rounded shadow-sm transition-all duration-300 ease-in-out hover:shadow-md group gap-2 py-1"
>
<div class="flex items-center space-x-2 px-3 py-2">
<div class="flex items-center space-x-2 px-2">
<component
:is="getIcon(action.type)"
size="16"
class="text-primary group-hover:text-primary"
class="text-gray-500 text-primary group-hover:text-primary"
/>
<Tooltip>
<TooltipTrigger as-child>
@@ -27,7 +27,7 @@
</div>
<button
@click.stop="onRemove(action)"
class="p-2 text-gray-400 hover:text-red-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 rounded transition-colors duration-300 ease-in-out"
class="pr-2 text-gray-400 hover:text-red-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 rounded transition-colors duration-300 ease-in-out"
title="Remove action"
>
<X size="14" />

View File

@@ -1,330 +1,202 @@
<template>
<Dialog :open="openAIKeyPrompt" @update:open="openAIKeyPrompt = false">
<DialogContent class="sm:max-w-lg">
<DialogHeader class="space-y-2">
<DialogTitle>Enter OpenAI API Key</DialogTitle>
<DialogDescription>
OpenAI API key is not set or invalid. Please enter a valid API key to use AI features.
</DialogDescription>
</DialogHeader>
<Form v-slot="{ handleSubmit }" as="" keep-values :validation-schema="formSchema">
<form id="apiKeyForm" @submit="handleSubmit($event, updateProvider)">
<FormField v-slot="{ componentField }" name="apiKey">
<FormItem>
<FormLabel>API Key</FormLabel>
<FormControl>
<Input type="text" placeholder="Enter your API key" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</form>
<DialogFooter>
<Button
type="submit"
form="apiKeyForm"
:is-loading="isOpenAIKeyUpdating"
:disabled="isOpenAIKeyUpdating"
>
Save
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
<div class="text-foreground bg-background">
<!-- Fullscreen editor -->
<Dialog :open="isEditorFullscreen" @update:open="isEditorFullscreen = false">
<DialogContent
class="max-w-[70%] max-h-[70%] h-[90%] w-full bg-card text-card-foreground px-4 py-4"
class="max-w-[70%] max-h-[70%] h-[70%] bg-card text-card-foreground p-4 flex flex-col"
@escapeKeyDown="isEditorFullscreen = false"
hide-close-button="true"
:hide-close-button="true"
>
<div v-if="isEditorFullscreen" class="h-full flex flex-col">
<!-- Message type toggle -->
<div class="flex justify-between items-center border-b border-border pb-4">
<Tabs v-model="messageType" class="rounded-lg">
<TabsList class="bg-muted p-1 rounded-lg">
<TabsTrigger
value="reply"
class="px-3 py-1 rounded-lg transition-colors duration-200"
:class="{ 'bg-background text-foreground': messageType === 'reply' }"
>
Reply
</TabsTrigger>
<TabsTrigger
value="private_note"
class="px-3 py-1 rounded-lg transition-colors duration-200"
:class="{ 'bg-background text-foreground': messageType === 'private_note' }"
>
Private note
</TabsTrigger>
</TabsList>
</Tabs>
<span
class="text-muted-foreground hover:text-foreground transition-colors duration-200 cursor-pointer"
variant="ghost"
@click="isEditorFullscreen = false"
>
<Minimize2 size="18" />
</span>
</div>
<!-- CC and BCC fields -->
<div class="space-y-3 p-4 border-b border-border" v-if="messageType === 'reply'">
<div class="flex items-center space-x-2">
<label class="w-12 text-sm font-medium text-muted-foreground">CC:</label>
<Input
type="text"
placeholder="Email addresses separated by comma"
v-model="cc"
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
@blur="validateEmails('cc')"
/>
<Button
size="sm"
@click="hideBcc"
class="text-sm bg-secondary text-secondary-foreground hover:bg-secondary/80"
>
{{ showBcc ? 'Remove BCC' : 'BCC' }}
</Button>
</div>
<div v-if="showBcc" class="flex items-center space-x-2">
<label class="w-12 text-sm font-medium text-muted-foreground">BCC:</label>
<Input
type="text"
placeholder="Email addresses separated by comma"
v-model="bcc"
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
@blur="validateEmails('bcc')"
/>
</div>
</div>
<div
v-if="emailErrors.length > 0"
class="mb-4 px-2 py-1 bg-destructive/10 border border-destructive text-destructive rounded"
>
<p v-for="error in emailErrors" :key="error" class="text-sm">{{ error }}</p>
</div>
<!-- Main Editor -->
<div class="flex-grow overflow-y-auto p-2">
<Editor
v-model:selectedText="selectedText"
v-model:isBold="isBold"
v-model:isItalic="isItalic"
v-model:htmlContent="htmlContent"
v-model:textContent="textContent"
:placeholder="editorPlaceholder"
:aiPrompts="aiPrompts"
@aiPromptSelected="handleAiPromptSelected"
:contentToSet="contentToSet"
@send="handleSend"
v-model:cursorPosition="cursorPosition"
:clearContent="clearEditorContent"
:setInlineImage="setInlineImage"
:insertContent="insertContent"
class="h-full"
/>
</div>
<!-- Macro preview -->
<MacroActionsPreview
v-if="conversationStore.conversation?.macro?.actions?.length > 0"
:actions="conversationStore.conversation.macro.actions"
:onRemove="conversationStore.removeMacroAction"
class="mt-4"
/>
<!-- Attachments preview -->
<AttachmentsPreview
:attachments="attachments"
:uploadingFiles="uploadingFiles"
:onDelete="handleOnFileDelete"
v-if="attachments.length > 0 || uploadingFiles.length > 0"
class="mt-4"
/>
<!-- Bottom menu bar -->
<ReplyBoxBottomMenuBar
class="mt-4 pt-4"
:handleFileUpload="handleFileUpload"
:handleInlineImageUpload="handleInlineImageUpload"
:isBold="isBold"
:isItalic="isItalic"
:isSending="isSending"
@toggleBold="toggleBold"
@toggleItalic="toggleItalic"
:enableSend="enableSend"
:handleSend="handleSend"
@emojiSelect="handleEmojiSelect"
/>
</div>
<ReplyBoxContent
v-if="isEditorFullscreen"
:isFullscreen="true"
:aiPrompts="aiPrompts"
:isSending="isSending"
:uploadingFiles="uploadingFiles"
:clearEditorContent="clearEditorContent"
:htmlContent="htmlContent"
:textContent="textContent"
:selectedText="selectedText"
:isBold="isBold"
:isItalic="isItalic"
:cursorPosition="cursorPosition"
:contentToSet="contentToSet"
:cc="cc"
:bcc="bcc"
:emailErrors="emailErrors"
:messageType="messageType"
:showBcc="showBcc"
@update:htmlContent="htmlContent = $event"
@update:textContent="textContent = $event"
@update:selectedText="selectedText = $event"
@update:isBold="isBold = $event"
@update:isItalic="isItalic = $event"
@update:cursorPosition="cursorPosition = $event"
@toggleFullscreen="isEditorFullscreen = false"
@update:messageType="messageType = $event"
@update:cc="cc = $event"
@update:bcc="bcc = $event"
@update:showBcc="showBcc = $event"
@updateEmailErrors="emailErrors = $event"
@send="processSend"
@fileUpload="handleFileUpload"
@inlineImageUpload="handleInlineImageUpload"
@fileDelete="handleOnFileDelete"
@aiPromptSelected="handleAiPromptSelected"
class="h-full flex-grow"
/>
</DialogContent>
</Dialog>
<!-- Main Editor non-fullscreen -->
<div class="bg-card text-card-foreground box px-2 pt-2 m-2">
<div v-if="!isEditorFullscreen" class="">
<!-- Message type toggle -->
<div class="flex justify-between items-center mb-4">
<Tabs v-model="messageType" class="rounded-lg">
<TabsList class="bg-muted p-1 rounded-lg">
<TabsTrigger
value="reply"
class="px-3 py-1 rounded-lg transition-colors duration-200"
:class="{ 'bg-background text-foreground': messageType === 'reply' }"
>
Reply
</TabsTrigger>
<TabsTrigger
value="private_note"
class="px-3 py-1 rounded-lg transition-colors duration-200"
:class="{ 'bg-background text-foreground': messageType === 'private_note' }"
>
Private note
</TabsTrigger>
</TabsList>
</Tabs>
<span
class="text-muted-foreground hover:text-foreground transition-colors duration-200 cursor-pointer mr-2"
variant="ghost"
@click="isEditorFullscreen = true"
>
<Maximize2 size="15" />
</span>
</div>
<div class="space-y-3 mb-4" v-if="messageType === 'reply'">
<div class="flex items-center space-x-2">
<label class="w-12 text-sm font-medium text-muted-foreground">CC:</label>
<Input
type="text"
placeholder="Email addresses separated by comma"
v-model="cc"
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
@blur="validateEmails('cc')"
/>
<Button
size="sm"
@click="hideBcc"
class="text-sm bg-secondary text-secondary-foreground hover:bg-secondary/80"
>
{{ showBcc ? 'Remove BCC' : 'BCC' }}
</Button>
</div>
<div v-if="showBcc" class="flex items-center space-x-2">
<label class="w-12 text-sm font-medium text-muted-foreground">BCC:</label>
<Input
type="text"
placeholder="Email addresses separated by comma"
v-model="bcc"
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
@blur="validateEmails('bcc')"
/>
</div>
</div>
<div
v-if="emailErrors.length > 0"
class="mb-4 px-2 py-1 bg-destructive/10 border border-destructive text-destructive rounded"
>
<p v-for="error in emailErrors" :key="error" class="text-sm">{{ error }}</p>
</div>
<!-- Main Editor -->
<Editor
v-model:selectedText="selectedText"
v-model:isBold="isBold"
v-model:isItalic="isItalic"
v-model:htmlContent="htmlContent"
v-model:textContent="textContent"
:placeholder="editorPlaceholder"
:aiPrompts="aiPrompts"
@aiPromptSelected="handleAiPromptSelected"
:contentToSet="contentToSet"
@send="handleSend"
v-model:cursorPosition="cursorPosition"
:clearContent="clearEditorContent"
:setInlineImage="setInlineImage"
:insertContent="insertContent"
/>
<!-- Macro preview -->
<MacroActionsPreview
v-if="conversationStore.conversation?.macro?.actions?.length > 0"
:actions="conversationStore.conversation.macro.actions"
:onRemove="conversationStore.removeMacroAction"
/>
<!-- Attachments preview -->
<AttachmentsPreview
:attachments="attachments"
:uploadingFiles="uploadingFiles"
:onDelete="handleOnFileDelete"
v-if="attachments.length > 0 || uploadingFiles.length > 0"
class="mt-4"
/>
<!-- Bottom menu bar -->
<ReplyBoxBottomMenuBar
class="mt-1"
:handleFileUpload="handleFileUpload"
:handleInlineImageUpload="handleInlineImageUpload"
:isBold="isBold"
:isItalic="isItalic"
:isSending="isSending"
@toggleBold="toggleBold"
@toggleItalic="toggleItalic"
:enableSend="enableSend"
:handleSend="handleSend"
@emojiSelect="handleEmojiSelect"
/>
</div>
<div
class="bg-card text-card-foreground box m-2 px-2 pt-2 flex flex-col"
v-if="!isEditorFullscreen"
>
<ReplyBoxContent
:isFullscreen="false"
:aiPrompts="aiPrompts"
:isSending="isSending"
:uploadingFiles="uploadingFiles"
:clearEditorContent="clearEditorContent"
:htmlContent="htmlContent"
:textContent="textContent"
:selectedText="selectedText"
:isBold="isBold"
:isItalic="isItalic"
:cursorPosition="cursorPosition"
:contentToSet="contentToSet"
:cc="cc"
:bcc="bcc"
:emailErrors="emailErrors"
:messageType="messageType"
:showBcc="showBcc"
@update:htmlContent="htmlContent = $event"
@update:textContent="textContent = $event"
@update:selectedText="selectedText = $event"
@update:isBold="isBold = $event"
@update:isItalic="isItalic = $event"
@update:cursorPosition="cursorPosition = $event"
@toggleFullscreen="isEditorFullscreen = true"
@update:messageType="messageType = $event"
@update:cc="cc = $event"
@update:bcc="bcc = $event"
@update:showBcc="showBcc = $event"
@updateEmailErrors="emailErrors = $event"
@send="processSend"
@fileUpload="handleFileUpload"
@inlineImageUpload="handleInlineImageUpload"
@fileDelete="handleOnFileDelete"
@aiPromptSelected="handleAiPromptSelected"
/>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed, nextTick, watch } from 'vue'
import { ref, onMounted, nextTick, watch, computed } from 'vue'
import { transformImageSrcToCID } from '@/utils/strings'
import { handleHTTPError } from '@/utils/http'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { Maximize2, Minimize2 } from 'lucide-vue-next'
import { useUserStore } from '@/stores/user'
import api from '@/api'
import Editor from './ConversationTextEditor.vue'
import { useConversationStore } from '@/stores/conversation'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { useEmitter } from '@/composables/useEmitter'
import AttachmentsPreview from '@/features/conversation/message/attachment/AttachmentsPreview.vue'
import MacroActionsPreview from '@/features/conversation/MacroActionsPreview.vue'
import ReplyBoxBottomMenuBar from '@/features/conversation/ReplyBoxMenuBar.vue'
import ReplyBoxContent from '@/features/conversation/ReplyBoxContent.vue'
import {
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage
} from '@/components/ui/form'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
const formSchema = toTypedSchema(
z.object({
apiKey: z.string().min(1, 'API key is required')
})
)
const conversationStore = useConversationStore()
const emitter = useEmitter()
const insertContent = ref(null)
const setInlineImage = ref(null)
const userStore = useUserStore()
const openAIKeyPrompt = ref(false)
const isOpenAIKeyUpdating = ref(false)
// Shared state between the two editor components.
const clearEditorContent = ref(false)
const isEditorFullscreen = ref(false)
const isSending = ref(false)
const cursorPosition = ref(0)
const selectedText = ref('')
const htmlContent = ref('')
const textContent = ref('')
const contentToSet = ref('')
const isBold = ref(false)
const isItalic = ref(false)
const messageType = ref('reply')
const showBcc = ref(false)
const cc = ref('')
const bcc = ref('')
const showBcc = ref(false)
const emailErrors = ref([])
const aiPrompts = ref([])
const uploadingFiles = ref([])
const editorPlaceholder = 'Press Enter to add a new line; Press Ctrl + Enter to send.'
const htmlContent = ref('')
const textContent = ref('')
const selectedText = ref('')
const isBold = ref(false)
const isItalic = ref(false)
const cursorPosition = ref(0)
const contentToSet = ref('')
onMounted(async () => {
await fetchAiPrompts()
})
const hideBcc = () => {
showBcc.value = !showBcc.value
}
watch(
() => conversationStore.currentCC,
(newVal) => {
cc.value = newVal?.join(', ') || ''
},
{ deep: true, immediate: true }
)
watch(
() => conversationStore.currentBCC,
(newVal) => {
const newBcc = newVal?.join(', ') || ''
bcc.value = newBcc
if (newBcc.length == 0) {
showBcc.value = false
} else {
showBcc.value = true
}
},
{ deep: true, immediate: true }
)
/**
* Fetches AI prompts from the server.
*/
const fetchAiPrompts = async () => {
try {
const resp = await api.getAiPrompts()
@@ -338,14 +210,27 @@ const fetchAiPrompts = async () => {
}
}
/**
* Handles the AI prompt selection event.
* Sends the selected prompt key and the current text content to the server for completion.
* Sets the response as the new content in the editor.
* @param {String} key - The key of the selected AI prompt
*/
const handleAiPromptSelected = async (key) => {
try {
const resp = await api.aiCompletion({
prompt_key: key,
content: selectedText.value
content: textContent.value
})
contentToSet.value = JSON.stringify({
content: resp.data.data.replace(/\n/g, '<br>'),
timestamp: Date.now()
})
contentToSet.value = resp.data.data.replace(/\n/g, '<br>')
} catch (error) {
// Check if user needs to enter OpenAI API key and has permission to do so.
if (error.response?.status === 400 && userStore.can('ai:manage')) {
openAIKeyPrompt.value = true
}
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
@@ -354,33 +239,35 @@ const handleAiPromptSelected = async (key) => {
}
}
const toggleBold = () => {
isBold.value = !isBold.value
/**
* updateProvider updates the OpenAI API key.
* @param {Object} values - The form values containing the API key
*/
const updateProvider = async (values) => {
try {
isOpenAIKeyUpdating.value = true
await api.updateAIProvider({ api_key: values.apiKey, provider: 'openai' })
openAIKeyPrompt.value = false
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Success',
description: 'API key saved successfully.'
})
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
isOpenAIKeyUpdating.value = false
}
}
const toggleItalic = () => {
isItalic.value = !isItalic.value
}
const attachments = computed(() => {
return conversationStore.conversation.mediaFiles.filter(
(upload) => upload.disposition === 'attachment'
)
})
const enableSend = computed(() => {
return (
(textContent.value.trim().length > 0 ||
conversationStore.conversation?.macro?.actions?.length > 0) &&
emailErrors.value.length === 0 &&
!uploadingFiles.value.length
)
})
const hasTextContent = computed(() => {
return textContent.value.trim().length > 0
})
/**
* Handles the file upload process when files are selected.
* Uploads each file to the server and adds them to the conversation's mediaFiles.
* @param {Event} event - The file input change event containing selected files
*/
const handleFileUpload = (event) => {
const files = Array.from(event.target.files)
uploadingFiles.value = files
@@ -407,6 +294,7 @@ const handleFileUpload = (event) => {
}
}
// Inline image upload is not supported yet.
const handleInlineImageUpload = (event) => {
for (const file of event.target.files) {
api
@@ -416,12 +304,13 @@ const handleInlineImageUpload = (event) => {
linked_model: 'messages'
})
.then((resp) => {
setInlineImage.value = {
const imageData = {
src: resp.data.data.url,
alt: resp.data.data.filename,
title: resp.data.data.uuid
}
conversationStore.conversation.mediaFiles.push(resp.data.data)
return imageData
})
.catch((error) => {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
@@ -433,44 +322,24 @@ const handleInlineImageUpload = (event) => {
}
}
const validateEmails = (field) => {
const emails = field === 'cc' ? cc.value : bcc.value
const emailList = emails
.split(',')
.map((e) => e.trim())
.filter((e) => e !== '')
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const invalidEmails = emailList.filter((email) => !emailRegex.test(email))
// Remove any existing errors for this field
emailErrors.value = emailErrors.value.filter(
(error) => !error.startsWith(`Invalid email(s) in ${field.toUpperCase()}`)
)
// Add new error if there are invalid emails
if (invalidEmails.length > 0) {
emailErrors.value.push(
`Invalid email(s) in ${field.toUpperCase()}: ${invalidEmails.join(', ')}`
)
}
}
const handleSend = async () => {
if (emailErrors.value.length > 0) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: 'Please correct the email errors before sending.'
})
return
}
/**
* Returns true if the editor has text content.
*/
const hasTextContent = computed(() => {
return textContent.value.trim().length > 0
})
/**
* Processes the send action.
*/
const processSend = async () => {
let hasAPIErrored = false
isEditorFullscreen.value = false
try {
isSending.value = true
// Send message if there is text content in the editor.
if (hasTextContent.value) {
if (hasTextContent.value > 0) {
// Replace inline image url with cid.
const message = transformImageSrcToCID(htmlContent.value)
@@ -498,7 +367,7 @@ const handleSend = async () => {
.split(',')
.map((email) => email.trim())
.filter((email) => email),
bcc: showBcc.value
bcc: bcc.value
? bcc.value
.split(',')
.map((email) => email.trim())
@@ -507,57 +376,101 @@ const handleSend = async () => {
})
}
// Apply macro if it exists.
// Apply macro actions if any.
// For macros errors just show toast and clear the editor, as most likely it's the permission error.
if (conversationStore.conversation?.macro?.actions?.length > 0) {
await api.applyMacro(
conversationStore.current.uuid,
conversationStore.conversation.macro.id,
conversationStore.conversation.macro.actions
)
try {
await api.applyMacro(
conversationStore.current.uuid,
conversationStore.conversation.macro.id,
conversationStore.conversation.macro.actions
)
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
} catch (error) {
hasAPIErrored = true
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
// If API has NOT errored clear state.
if (hasAPIErrored === false) {
// Clear editor.
clearEditorContent.value = true
// Clear macro.
conversationStore.resetMacro()
// Clear media files.
conversationStore.resetMediaFiles()
// Clear any email errors.
emailErrors.value = []
nextTick(() => {
clearEditorContent.value = false
})
}
isSending.value = false
clearEditorContent.value = true
conversationStore.resetMacro()
conversationStore.resetMediaFiles()
emailErrors.value = []
nextTick(() => {
clearEditorContent.value = false
})
}
// Update assignee last seen timestamp.
api.updateAssigneeLastSeen(conversationStore.current.uuid)
}
/**
* Handles the file delete event.
* Removes the file from the conversation's mediaFiles.
* @param {String} uuid - The UUID of the file to delete
*/
const handleOnFileDelete = (uuid) => {
conversationStore.conversation.mediaFiles = conversationStore.conversation.mediaFiles.filter(
(item) => item.uuid !== uuid
)
}
const handleEmojiSelect = (emoji) => {
insertContent.value = undefined
// Force reactivity so the user can select the same emoji multiple times
nextTick(() => (insertContent.value = emoji))
}
// Watch for changes in macro content and update editor content.
/**
* Watches for changes in the conversation's macro id and update message content.
*/
watch(
() => conversationStore.conversation.macro,
() => conversationStore.conversation.macro.id,
() => {
// hack: Quill editor adds <p><br></p> replace with <p></p>
if (conversationStore.conversation?.macro?.message_content) {
contentToSet.value = conversationStore.conversation.macro.message_content.replace(
/<p><br><\/p>/g,
'<p></p>'
)
}
// Setting timestamp, so the same macro can be set again.
contentToSet.value = JSON.stringify({
content: conversationStore.conversation.macro.message_content,
timestamp: Date.now()
})
},
{ deep: true }
)
// Initialize cc and bcc from conversation store
watch(
() => conversationStore.currentCC,
(newVal) => {
cc.value = newVal?.join(', ') || ''
},
{ deep: true, immediate: true }
)
watch(
() => conversationStore.currentBCC,
(newVal) => {
const newBcc = newVal?.join(', ') || ''
bcc.value = newBcc
// Only show BCC field if it has content
if (newBcc.length > 0) {
showBcc.value = true
}
},
{ deep: true, immediate: true }
)
</script>

View File

@@ -0,0 +1,307 @@
<template>
<!-- Set fixed width only when not in fullscreen. -->
<div class="flex flex-col h-full" :class="{ 'max-h-[600px]': !isFullscreen }">
<!-- Message type toggle -->
<div
class="flex justify-between items-center"
:class="{ 'mb-4': !isFullscreen, 'border-b border-border pb-4': isFullscreen }"
>
<Tabs v-model="messageType" class="rounded-lg">
<TabsList class="bg-muted p-1 rounded-lg">
<TabsTrigger
value="reply"
class="px-3 py-1 rounded-lg transition-colors duration-200"
:class="{ 'bg-background text-foreground': messageType === 'reply' }"
>
Reply
</TabsTrigger>
<TabsTrigger
value="private_note"
class="px-3 py-1 rounded-lg transition-colors duration-200"
:class="{ 'bg-background text-foreground': messageType === 'private_note' }"
>
Private note
</TabsTrigger>
</TabsList>
</Tabs>
<span
class="text-muted-foreground hover:text-foreground transition-colors duration-200 cursor-pointer"
variant="ghost"
@click="toggleFullscreen"
>
<component
:is="isFullscreen ? Minimize2 : Maximize2"
:size="isFullscreen ? '18' : '15'"
:class="{ 'mr-2': !isFullscreen }"
/>
</span>
</div>
<!-- CC and BCC fields -->
<div
:class="['space-y-3', isFullscreen ? 'p-4 border-b border-border' : 'mb-4']"
v-if="messageType === 'reply'"
>
<div class="flex items-center space-x-2">
<label class="w-12 text-sm font-medium text-muted-foreground">CC:</label>
<Input
type="text"
placeholder="Email addresses separated by comma"
v-model="cc"
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
@blur="validateEmails('cc')"
/>
<Button
size="sm"
@click="toggleBcc"
class="text-sm bg-secondary text-secondary-foreground hover:bg-secondary/80"
>
{{ showBcc ? 'Remove BCC' : 'BCC' }}
</Button>
</div>
<div v-if="showBcc" class="flex items-center space-x-2">
<label class="w-12 text-sm font-medium text-muted-foreground">BCC:</label>
<Input
type="text"
placeholder="Email addresses separated by comma"
v-model="bcc"
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
@blur="validateEmails('bcc')"
/>
</div>
</div>
<!-- CC and BCC field validation errors -->
<div
v-if="emailErrors.length > 0"
class="mb-4 px-2 py-1 bg-destructive/10 border border-destructive text-destructive rounded"
>
<p v-for="error in emailErrors" :key="error" class="text-sm">{{ error }}</p>
</div>
<!-- Main tiptap editor -->
<div class="flex-grow flex flex-col overflow-hidden">
<Editor
v-model:selectedText="selectedText"
v-model:isBold="isBold"
v-model:isItalic="isItalic"
v-model:htmlContent="htmlContent"
v-model:textContent="textContent"
v-model:cursorPosition="cursorPosition"
:placeholder="editorPlaceholder"
:aiPrompts="aiPrompts"
@aiPromptSelected="handleAiPromptSelected"
:contentToSet="contentToSet"
@send="handleSend"
:clearContent="clearEditorContent"
:setInlineImage="setInlineImage"
:insertContent="insertContent"
/>
</div>
<!-- Macro preview -->
<MacroActionsPreview
v-if="conversationStore.conversation?.macro?.actions?.length > 0"
:actions="conversationStore.conversation.macro.actions"
:onRemove="conversationStore.removeMacroAction"
class="mt-2"
/>
<!-- Attachments preview -->
<AttachmentsPreview
:attachments="attachments"
:uploadingFiles="uploadingFiles"
:onDelete="handleOnFileDelete"
v-if="attachments.length > 0 || uploadingFiles.length > 0"
class="mt-2"
/>
<!-- Editor menu bar with send button -->
<ReplyBoxMenuBar
class="mt-1 shrink-0"
:handleFileUpload="handleFileUpload"
:handleInlineImageUpload="handleInlineImageUpload"
:isBold="isBold"
:isItalic="isItalic"
:isSending="isSending"
@toggleBold="toggleBold"
@toggleItalic="toggleItalic"
:enableSend="enableSend"
:handleSend="handleSend"
@emojiSelect="handleEmojiSelect"
/>
</div>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { Maximize2, Minimize2 } from 'lucide-vue-next'
import Editor from './ConversationTextEditor.vue'
import { useConversationStore } from '@/stores/conversation'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useEmitter } from '@/composables/useEmitter'
import AttachmentsPreview from '@/features/conversation/message/attachment/AttachmentsPreview.vue'
import MacroActionsPreview from '@/features/conversation/MacroActionsPreview.vue'
import ReplyBoxMenuBar from '@/features/conversation/ReplyBoxMenuBar.vue'
// Define models for two-way binding
const messageType = defineModel('messageType', { default: 'reply' })
const cc = defineModel('cc', { default: '' })
const bcc = defineModel('bcc', { default: '' })
const showBcc = defineModel('showBcc', { default: false })
const emailErrors = defineModel('emailErrors', { default: () => [] })
const htmlContent = defineModel('htmlContent', { default: '' })
const textContent = defineModel('textContent', { default: '' })
const selectedText = defineModel('selectedText', { default: '' })
const isBold = defineModel('isBold', { default: false })
const isItalic = defineModel('isItalic', { default: false })
const cursorPosition = defineModel('cursorPosition', { default: 0 })
const props = defineProps({
isFullscreen: {
type: Boolean,
default: false
},
aiPrompts: {
type: Array,
required: true
},
isSending: {
type: Boolean,
required: true
},
uploadingFiles: {
type: Array,
required: true
},
clearEditorContent: {
type: Boolean,
required: true
},
contentToSet: {
type: String,
default: null
}
})
const emit = defineEmits([
'toggleFullscreen',
'send',
'fileUpload',
'inlineImageUpload',
'fileDelete',
'aiPromptSelected'
])
const conversationStore = useConversationStore()
const emitter = useEmitter()
const insertContent = ref(null)
const setInlineImage = ref(null)
const editorPlaceholder =
'Shift + Enter to add a new line. Cmd + Enter to send. Cmd + K to open command bar.'
const toggleBcc = async () => {
showBcc.value = !showBcc.value
await nextTick()
// If hiding BCC field, clear the content
if (!showBcc.value) {
bcc.value = ''
}
}
const toggleFullscreen = () => {
emit('toggleFullscreen')
}
const toggleBold = () => {
isBold.value = !isBold.value
}
const toggleItalic = () => {
isItalic.value = !isItalic.value
}
const attachments = computed(() => {
return conversationStore.conversation.mediaFiles.filter(
(upload) => upload.disposition === 'attachment'
)
})
const enableSend = computed(() => {
return (
(textContent.value.trim().length > 0 ||
conversationStore.conversation?.macro?.actions?.length > 0) &&
emailErrors.value.length === 0 &&
!props.uploadingFiles.length
)
})
/**
* Validate email addresses in the CC and BCC fields
* @param {string} field - 'cc' or 'bcc'
*/
const validateEmails = (field) => {
const emails = field === 'cc' ? cc.value : bcc.value
const emailList = emails
.split(',')
.map((e) => e.trim())
.filter((e) => e !== '')
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const invalidEmails = emailList.filter((email) => !emailRegex.test(email))
// Remove any existing errors for this field
emailErrors.value = emailErrors.value.filter(
(error) => !error.startsWith(`Invalid email(s) in ${field.toUpperCase()}`)
)
// Add new error if there are invalid emails
if (invalidEmails.length > 0) {
emailErrors.value.push(
`Invalid email(s) in ${field.toUpperCase()}: ${invalidEmails.join(', ')}`
)
}
}
/**
* Send the reply or private note
*/
const handleSend = async () => {
validateEmails('cc')
validateEmails('bcc')
if (emailErrors.value.length > 0) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: 'Please correct the email errors before sending.'
})
return
}
emit('send')
}
const handleFileUpload = (event) => {
emit('fileUpload', event)
}
const handleInlineImageUpload = (event) => {
emit('inlineImageUpload', event)
}
const handleOnFileDelete = (uuid) => {
emit('fileDelete', uuid)
}
const handleEmojiSelect = (emoji) => {
insertContent.value = undefined
// Force reactivity so the user can select the same emoji multiple times
nextTick(() => (insertContent.value = emoji))
}
const handleAiPromptSelected = (key) => {
emit('aiPromptSelected', key)
}
</script>

View File

@@ -35,7 +35,9 @@
<Smile class="h-4 w-4" />
</Toggle>
</div>
<Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!enableSend" :isLoading="isSending">Send</Button>
<Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!enableSend" :isLoading="isSending"
>Send</Button
>
</div>
</template>
@@ -52,11 +54,10 @@ const attachmentInput = ref(null)
const inlineImageInput = ref(null)
const isEmojiPickerVisible = ref(false)
const emojiPickerRef = ref(null)
const emit = defineEmits(['toggleBold', 'toggleItalic', 'emojiSelect'])
const emit = defineEmits(['emojiSelect'])
// Using defineProps for props that don't need two-way binding
defineProps({
isBold: Boolean,
isItalic: Boolean,
isSending: Boolean,
enableSend: Boolean,
handleSend: Function,
@@ -69,7 +70,11 @@ onClickOutside(emojiPickerRef, () => {
})
const triggerFileUpload = () => {
attachmentInput.value.click()
if (attachmentInput.value) {
// Clear the value to allow the same file to be uploaded again.
attachmentInput.value.value = ''
attachmentInput.value.click()
}
}
const toggleEmojiPicker = () => {

View File

@@ -8,7 +8,8 @@
<!-- Filters -->
<div class="bg-white p-2 flex justify-between items-center">
<DropdownMenu>
<!-- Status dropdown-menu, hidden when a view is selected as views are pre-filtered -->
<DropdownMenu v-if="!route.params.viewID">
<DropdownMenuTrigger asChild>
<Button variant="ghost" class="w-30">
<div>
@@ -28,6 +29,9 @@
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div v-else></div>
<!-- Sort dropdown-menu -->
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" class="w-30">
@@ -124,7 +128,10 @@
<Loader2 v-if="isLoading" class="mr-2 h-4 w-4 animate-spin" />
{{ isLoading ? 'Loading...' : 'Load more' }}
</Button>
<p class="text-sm text-gray-500" v-else-if="conversationStore.conversationsList.length > 10">
<p
class="text-sm text-gray-500"
v-else-if="conversationStore.conversationsList.length > 10"
>
All conversations loaded
</p>
</div>

View File

@@ -57,16 +57,18 @@
<div class="flex items-center mt-2 space-x-2">
<SlaBadge
v-if="conversation.first_response_due_at"
:dueAt="conversation.first_response_due_at"
:actualAt="conversation.first_reply_at"
:label="'FRD'"
:showSLAMet="false"
:showExtra="false"
/>
<SlaBadge
v-if="conversation.resolution_due_at"
:dueAt="conversation.resolution_due_at"
:actualAt="conversation.resolved_at"
:label="'RD'"
:showSLAMet="false"
:showExtra="false"
/>
</div>
</div>

View File

@@ -19,7 +19,11 @@
}"
>
<!-- Message Content -->
<div v-html="messageContent" :class="{ 'mb-3': message.attachments.length > 0 }"></div>
<div
v-dompurify-html="messageContent"
class="whitespace-pre-wrap break-words overflow-wrap-anywhere native-html"
:class="{ 'mb-3': message.attachments.length > 0 }"
/>
<!-- Attachments -->
<MessageAttachmentPreview :attachments="nonInlineAttachments" />
@@ -125,3 +129,9 @@ const retryMessage = (msg) => {
api.retryMessage(convStore.current.uuid, msg.uuid)
}
</script>
<style scoped>
.overflow-wrap-anywhere {
overflow-wrap: anywhere;
}
</style>

View File

@@ -29,7 +29,7 @@
<Letter
:html="sanitizedMessageContent"
:allowedSchemas="['cid', 'https', 'http']"
class="mb-1"
class="mb-1 native-html"
:class="{ 'mb-3': message.attachments.length > 0 }"
/>

View File

@@ -22,16 +22,14 @@
<MessagesSkeleton :count="10" v-if="conversationStore.messages.loading" />
<TransitionGroup
v-else
enter-active-class="animate-slide-in"
tag="div"
class="space-y-4"
>
<TransitionGroup v-else enter-active-class="animate-slide-in" tag="div" class="space-y-4">
<div
v-for="message in conversationStore.conversationMessages"
v-for="(message, index) in conversationStore.conversationMessages"
:key="message.uuid"
:class="message.type === 'activity' ? 'my-2' : 'my-4'"
:class="{
'my-2': message.type === 'activity',
'pt-4': index === 0
}"
>
<div v-if="!message.private">
<ContactMessageBubble :message="message" v-if="message.type === 'incoming'" />
@@ -57,7 +55,7 @@
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-1"
>
<div v-show="!isAtBottom" class="absolute bottom-12 right-6 z-10">
<div v-show="!isAtBottom" class="absolute bottom-5 right-6 z-10">
<button
@click="handleScrollToBottom"
class="w-10 h-10 rounded-full flex items-center justify-center shadow-lg border bg-white text-primary transition-colors duration-200 hover:bg-gray-100"

View File

@@ -27,8 +27,10 @@
<div class="flex justify-start items-center space-x-2">
<p class="font-medium">First reply at</p>
<SlaBadge
v-if="conversation.first_response_due_at"
:dueAt="conversation.first_response_due_at"
:actualAt="conversation.first_reply_at"
:key="conversation.uuid"
/>
</div>
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
@@ -43,7 +45,12 @@
<div class="flex flex-col gap-1 mb-5">
<div class="flex justify-start items-center space-x-2">
<p class="font-medium">Resolved at</p>
<SlaBadge :dueAt="conversation.resolution_due_at" :actualAt="conversation.resolved_at" />
<SlaBadge
v-if="conversation.resolution_due_at"
:dueAt="conversation.resolution_due_at"
:actualAt="conversation.resolved_at"
:key="conversation.uuid"
/>
</div>
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
<div v-else>

View File

@@ -1,25 +1,36 @@
<template>
<div class="flex flex-1 flex-col gap-x-5 box p-5 space-y-5 bg-white">
<div class="flex items-center space-x-2">
<p class="text-2xl">{{ title }}</p>
<p class="text-2xl flex items-center">{{ title }}</p>
<div class="bg-green-100/70 flex items-center space-x-2 px-1 rounded">
<span class="blinking-dot"></span>
<p class="uppercase text-xs">Live</p>
</div>
</div>
<div class="flex justify-between pr-32">
<div v-for="(value, key) in counts" :key="key" class="flex flex-col items-center gap-y-2">
<div
v-for="(item, key) in filteredCounts"
:key="key"
class="flex flex-col items-center gap-y-2"
>
<span class="text-muted-foreground">{{ labels[key] }}</span>
<span class="text-2xl font-medium">{{ value }}</span>
<span class="text-2xl font-medium">{{ item }}</span>
</div>
</div>
</div>
</template>
<script setup>
defineProps({
import { computed } from 'vue'
const props = defineProps({
counts: { type: Object, required: true },
labels: { type: Object, required: true },
title: { type: String, required: true }
})
// Filter out counts that don't have a label
const filteredCounts = computed(() => {
return Object.fromEntries(Object.entries(props.counts).filter(([key]) => props.labels[key]))
})
</script>

View File

@@ -1,32 +1,33 @@
<template>
<div v-if="dueAt" class="flex justify-start items-center space-x-2">
<TransitionGroup name="fade">
<!-- Overdue-->
<span v-if="sla?.status === 'overdue'" key="overdue" class="sla-badge box sla-overdue">
<AlertCircle size="10" class="text-red-800" />
<span class="text-xs text-red-800">{{ label }} Overdue</span>
<!-- Overdue-->
<span v-if="sla?.status === 'overdue'" key="overdue" class="sla-badge box sla-overdue">
<AlertCircle size="12" class="text-red-800" />
<span class="sla-text text-red-800"
>{{ label }} Overdue
<span v-if="showExtra">by {{ sla.value }}</span>
</span>
</span>
<!-- SLA Hit -->
<span
v-else-if="sla?.status === 'hit' && showSLAMet"
key="sla-hit"
class="sla-badge box sla-hit"
>
<CheckCircle size="10" />
<span class="sla-text">{{ label }} SLA met</span>
</span>
<!-- SLA Hit -->
<span
v-else-if="sla?.status === 'hit' && showExtra"
key="sla-hit"
class="sla-badge box sla-hit"
>
<CheckCircle size="12" />
<span class="sla-text">{{ label }} SLA met</span>
</span>
<!-- Remaining -->
<span
v-else-if="sla?.status === 'remaining'"
key="remaining"
class="sla-badge box sla-remaining"
>
<Clock size="10" />
<span class="sla-text">{{ label }} {{ sla.value }}</span>
</span>
</TransitionGroup>
<!-- Remaining -->
<span
v-else-if="sla?.status === 'remaining'"
key="remaining"
class="sla-badge box sla-remaining"
>
<Clock size="12" />
<span class="sla-text">{{ label }} {{ sla.value }}</span>
</span>
</div>
</template>
@@ -38,12 +39,16 @@ const props = defineProps({
dueAt: String,
actualAt: String,
label: String,
showSLAMet: {
showExtra: {
type: Boolean,
default: true
}
})
const { sla } = useSla(ref(props.dueAt), ref(props.actualAt))
let sla = null
if (props.dueAt) {
sla = useSla(ref(props.dueAt), ref(props.actualAt))
}
</script>
<style scoped>
@@ -62,4 +67,8 @@ const { sla } = useSla(ref(props.dueAt), ref(props.actualAt))
.sla-remaining {
@apply bg-yellow-100 text-yellow-800;
}
.sla-text {
@apply text-[0.65rem];
}
</style>

View File

@@ -65,6 +65,7 @@
</template>
<template #selected="{ selected }">
<div v-if="!selected">Select value</div>
<div v-if="modelFilter.field === 'assigned_user_id'">
<div class="flex items-center gap-2">
<div v-if="selected" class="flex items-center gap-1">
@@ -76,7 +77,6 @@
</Avatar>
<span>{{ selected.label }}</span>
</div>
<span v-else>Select user</span>
</div>
</div>
<div v-else-if="modelFilter.field === 'assigned_team_id'">
@@ -85,7 +85,6 @@
{{ selected.emoji }}
<span>{{ selected.label }}</span>
</span>
<span v-else>Select team</span>
</div>
</div>
<div v-else-if="selected">
@@ -114,7 +113,7 @@
</div>
<div class="flex items-center justify-between pt-3">
<Button variant="ghost" size="sm" @click.prevent="addFilter" class="text-slate-600">
<Button variant="ghost" size="sm" @click="addFilter" class="text-slate-600">
<Plus class="w-3 h-3 mr-1" /> Add filter
</Button>
<div class="flex gap-2" v-if="showButtons">
@@ -159,7 +158,7 @@ const createFilter = () => ({ field: '', operator: '', value: '' })
onMounted(() => {
if (modelValue.value.length === 0) {
modelValue.value.push(createFilter())
modelValue.value = [createFilter()]
}
})
@@ -171,6 +170,8 @@ const getModel = (field) => {
const fieldConfig = props.fields.find((f) => f.field === field)
return fieldConfig?.model || ''
}
// Set model for each filter
watch(
() => modelValue.value,
(filters) => {
@@ -183,8 +184,25 @@ watch(
{ deep: true }
)
const addFilter = () => modelValue.value.push(createFilter())
const removeFilter = (index) => modelValue.value.splice(index, 1)
// Reset operator and value when field changes for a filter at a given index
watch(
() => modelValue.value.map((f) => f.field),
(newFields, oldFields) => {
newFields.forEach((field, index) => {
if (field !== oldFields[index]) {
modelValue.value[index].operator = ''
modelValue.value[index].value = ''
}
})
}
)
const addFilter = () => {
modelValue.value = [...modelValue.value, createFilter()]
}
const removeFilter = (index) => {
modelValue.value = modelValue.value.filter((_, i) => i !== index)
}
const applyFilters = () => emit('apply', validFilters.value)
const clearFilters = () => {
modelValue.value = []

View File

@@ -1,9 +1,11 @@
<template>
<Dialog :open="openDialog" @update:open="openDialog = false">
<DialogContent>
<DialogContent class="min-w-[40%] min-h-[30%]">
<DialogHeader class="space-y-1">
<DialogTitle>{{ view?.id ? 'Edit' : 'Create' }} view</DialogTitle>
<DialogDescription> Views let you create filters and save them. </DialogDescription>
<DialogDescription>
Create and save custom filter views for quick access to your conversations.
</DialogDescription>
</DialogHeader>
<form @submit.prevent="onSubmit">
<div class="grid gap-4 py-4">
@@ -11,7 +13,13 @@
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input id="name" class="col-span-3" placeholder="Name" v-bind="componentField" />
<Input
id="name"
class="col-span-3"
placeholder="Name"
v-bind="componentField"
@keydown.enter.prevent="onSubmit"
/>
</FormControl>
<FormDescription>Enter an unique name for your view.</FormDescription>
<FormMessage />
@@ -21,9 +29,13 @@
<FormItem>
<FormLabel>Filters</FormLabel>
<FormControl>
<FilterBuilder :fields="filterFields" :showButtons="false" v-bind="componentField" />
<FilterBuilder
:fields="filterFields"
:showButtons="false"
v-bind="componentField"
/>
</FormControl>
<FormDescription>Add multiple filters to customize view.</FormDescription>
<FormDescription> Set one or more filters to customize view.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
@@ -65,6 +77,7 @@ import { toTypedSchema } from '@vee-validate/zod'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { handleHTTPError } from '@/utils/http'
import { OPERATOR } from '@/constants/filterConfig.js'
import { z } from 'zod'
import api from '@/api'
@@ -91,27 +104,53 @@ const formSchema = toTypedSchema(
name: z
.string()
.min(2, { message: 'Name must be at least 2 characters.' })
.max(250, { message: 'Name cannot exceed 250 characters.' }),
.max(30, { message: 'Name cannot exceed 30 characters.' }),
filters: z
.array(
z.object({
model: z.string({ required_error: 'Filter required' }),
field: z.string({ required_error: 'Filter required' }),
operator: z.string({ required_error: 'Filter required' }),
value: z.union([z.string(), z.number(), z.boolean()])
value: z.union([z.string(), z.number(), z.boolean()]).optional()
})
)
.default([])
.refine(
(filters) => filters.length > 0,
{ message: 'Please add at least one filter.' }
)
.refine(
(filters) =>
filters.every(
(f) =>
f.model &&
f.field &&
f.operator &&
([OPERATOR.SET, OPERATOR.NOT_SET].includes(f.operator) || f.value)
),
{
message: "Please make sure you've filled the filter fields correctly."
}
)
})
)
const form = useForm({ validationSchema: formSchema })
const form = useForm({
validationSchema: formSchema,
validateOnMount: false,
validateOnInput: false,
validateOnBlur: false
})
const onSubmit = async () => {
const validationResult = await form.validate()
if (!validationResult.valid) return
const onSubmit = form.handleSubmit(async (values) => {
if (isSubmitting.value) return
isSubmitting.value = true
try {
const values = form.values
if (values.id) {
await api.updateView(values.id, values)
} else {
@@ -129,8 +168,9 @@ const onSubmit = form.handleSubmit(async (values) => {
} finally {
isSubmitting.value = false
}
})
}
// Set form values when view prop changes
watch(
() => view.value,
(newVal) => {

View File

@@ -7,6 +7,7 @@ import mitt from 'mitt'
import api from './api'
import './assets/styles/main.scss'
import './utils/strings.js'
import VueDOMPurifyHTML from 'vue-dompurify-html'
import Root from './Root.vue'
const setFavicon = (url) => {
@@ -50,6 +51,7 @@ async function initApp () {
app.use(router)
app.use(i18n)
app.use(VueDOMPurifyHTML)
app.mount('#app')
}

View File

@@ -65,7 +65,6 @@ const routes = [
path: '',
name: 'team-inbox',
component: InboxView,
props: true,
meta: { title: 'Team inbox' }
},
{
@@ -88,7 +87,6 @@ const routes = [
path: '',
name: 'view-inbox',
component: InboxView,
props: true,
meta: { title: 'View inbox' }
},
{
@@ -118,7 +116,6 @@ const routes = [
path: '',
name: 'inbox',
component: InboxView,
props: true,
meta: {
title: 'Inbox',
type: route => route.params.type === 'assigned' ? 'My inbox' : route.params.type

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { computed, reactive, ref } from 'vue'
import { computed, reactive, ref, nextTick } from 'vue'
import { CONVERSATION_LIST_TYPE, CONVERSATION_DEFAULT_STATUSES } from '@/constants/conversation'
import { handleHTTPError } from '@/utils/http'
import { useEmitter } from '@/composables/useEmitter'
@@ -8,8 +8,8 @@ import MessageCache from '@/utils/conversation-message-cache'
import api from '@/api'
export const useConversationStore = defineStore('conversation', () => {
const CONV_LIST_PAGE_SIZE = 100
const MESSAGE_LIST_PAGE_SIZE = 100
const CONV_LIST_PAGE_SIZE = 50
const MESSAGE_LIST_PAGE_SIZE = 30
const priorities = ref([])
const statuses = ref([])
@@ -110,8 +110,11 @@ export const useConversationStore = defineStore('conversation', () => {
clearInterval(reRenderInterval)
}
function setMacro (macros) {
conversation.macro = macros
async function setMacro (macro) {
// Clear existing macro.
conversation.macro = {}
await nextTick()
conversation.macro = macro
}
function removeMacroAction (action) {
@@ -231,6 +234,10 @@ export const useConversationStore = defineStore('conversation', () => {
return conversation.data || {}
})
const hasConversationOpen = computed(() => {
return Object.keys(conversation.data || {}).length > 0
})
const currentBCC = computed(() => {
return conversation.data?.bcc || []
})
@@ -282,8 +289,10 @@ export const useConversationStore = defineStore('conversation', () => {
async function fetchMessages (uuid, fetchNextPage = false) {
// Messages are already cached?
let hasMessages = messages.data.getAllPagesMessages(uuid)
if (hasMessages.length > 0 && !fetchNextPage)
if (hasMessages.length > 0 && !fetchNextPage) {
markConversationAsRead(uuid)
return
}
// Fetch messages from server.
messages.loading = true
@@ -293,7 +302,6 @@ export const useConversationStore = defineStore('conversation', () => {
const response = await api.getConversationMessages(uuid, { page: page, page_size: MESSAGE_LIST_PAGE_SIZE })
const result = response.data?.data || {}
const newMessages = result.results || []
// Mark conversation as read
markConversationAsRead(uuid)
// Cache messages
messages.data.addMessages(uuid, newMessages, result.page, result.total_pages)
@@ -608,8 +616,8 @@ export const useConversationStore = defineStore('conversation', () => {
Object.assign(conversation, {
data: null,
participants: {},
macro: {},
mediaFiles: [],
macro: {},
loading: false,
errorMessage: ''
})
@@ -629,6 +637,7 @@ export const useConversationStore = defineStore('conversation', () => {
conversationsList,
conversationMessages,
currentConversationHasMoreMessages,
hasConversationOpen,
current,
currentContactName,
currentBCC,

View File

@@ -1,4 +1,4 @@
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import { defineStore } from 'pinia'
import { handleHTTPError } from '@/utils/http'
import { useEmitter } from '@/composables/useEmitter'
@@ -6,6 +6,7 @@ import { EMITTER_EVENTS } from '@/constants/emitterEvents'
import { adminNavItems, reportsNavItems } from '@/constants/navigation'
import { filterNavItems } from '@/utils/nav-permissions'
import api from '@/api'
import { useStorage } from '@vueuse/core'
export const useUserStore = defineStore('user', () => {
const user = ref({
@@ -15,14 +16,15 @@ export const useUserStore = defineStore('user', () => {
avatar_url: '',
email: '',
teams: [],
permissions: []
permissions: [],
availability_status: 'offline'
})
const emitter = useEmitter()
const userID = computed(() => user.value.id)
const firstName = computed(() => user.value.first_name)
const lastName = computed(() => user.value.last_name)
const avatar = computed(() => user.value.avatar_url)
const firstName = computed(() => user.value.first_name || '')
const lastName = computed(() => user.value.last_name || '')
const avatar = computed(() => user.value.avatar_url || '')
const permissions = computed(() => user.value.permissions || [])
const email = computed(() => user.value.email)
const teams = computed(() => user.value.teams || [])
@@ -71,6 +73,10 @@ export const useUserStore = defineStore('user', () => {
}
}
const setCurrentUser = (userData) => {
user.value = userData
}
const setAvatar = (avatarURL) => {
if (typeof avatarURL !== 'string') {
console.warn('Avatar URL must be a string')
@@ -83,6 +89,23 @@ export const useUserStore = defineStore('user', () => {
user.value.avatar_url = ''
}
// Set and watch user availability status in localStorage to sync across tabs
const availabilityStatusStorage = useStorage('user_availability_status', user.value.availability_status)
watch(availabilityStatusStorage, (newVal) => {
user.value.availability_status = newVal
})
const updateUserAvailability = async (status, isManual = true) => {
try {
const apiStatus = status === 'away' && isManual ? 'away_manual' : status
await api.updateCurrentUserAvailability({ status: apiStatus })
user.value.availability_status = apiStatus
availabilityStatusStorage.value = apiStatus
} catch (error) {
if (error?.response?.status === 401) window.location.href = '/'
}
}
return {
user,
userID,
@@ -96,9 +119,11 @@ export const useUserStore = defineStore('user', () => {
getInitials,
hasAdminTabPermissions,
hasReportTabPermissions,
setCurrentUser,
getCurrentUser,
clearAvatar,
setAvatar,
updateUserAvailability,
can
}
})
})

View File

@@ -0,0 +1,7 @@
export function debounce (fn, delay) {
let timeout
return function (...args) {
clearTimeout(timeout)
timeout = setTimeout(() => fn(...args), delay)
}
}

View File

@@ -48,8 +48,13 @@ export const isGoHourMinuteDuration = (value) => {
const template = document.createElement('template')
export function getTextFromHTML(htmlString) {
template.innerHTML = htmlString
const text = template.content.textContent || template.content.innerText || ''
template.innerHTML = ''
return text;
try {
template.innerHTML = htmlString
const text = template.content.textContent || template.content.innerText || ''
template.innerHTML = ''
return text.trim()
} catch (error) {
console.error('Error converting HTML to text:', error)
return ''
}
}

View File

@@ -155,6 +155,7 @@ import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { handleHTTPError } from '@/utils/http'
import { SelectTag } from '@/components/ui/select'
import { OPERATOR } from '@/constants/filterConfig'
import {
Select,
SelectContent,
@@ -315,7 +316,8 @@ const handleSave = async (values) => {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Invalid rules',
variant: 'destructive',
description: 'Make sure you have atleast one action and one rule.'
description:
'Make sure you have atleast one action and one rule and their values are not empty.'
})
return
}
@@ -347,27 +349,53 @@ const handleSave = async (values) => {
}
}
// TODO: Add some vee-validate validations.
// TODO: Maybe we can do some vee validate magic here.
const areRulesValid = () => {
// Must have groups.
if (rule.value.rules[0].groups.length == 0) {
return false
}
// At least one group should have at least one rule
const group1HasRules = rule.value.rules[0].groups[0].rules.length > 0
const group2HasRules = rule.value.rules[0].groups[1].rules.length > 0
if (!group1HasRules && !group2HasRules) {
return false
}
// For both groups, each rule should have value, operator and field.
for (const group of rule.value.rules[0].groups) {
for (const rule of group.rules) {
if (!rule.field || !rule.operator) {
return false
}
// For 'set' and `not set` operator, value is not required.
if (rule.operator !== OPERATOR.SET && rule.operator !== OPERATOR.NOT_SET && !rule.value) {
return false
}
}
}
// Must have atleast one action.
if (rule.value.rules[0].actions.length == 0) {
return false
}
// Must have atleast 1 group.
if (rule.value.rules[0].groups.length == 0) {
return false
}
// Make sure each action has value.
for (const action of rule.value.rules[0].actions) {
// CSAT action does not require value, set dummy value.
if (action.type === 'send_csat') {
action.value = ['0']
}
// Group should have atleast one rule.
if (rule.value.rules[0].groups[0].rules.length == 0) {
return false
}
// Empty array, no value selected.
if (action.value.length === 0) {
return false
}
// Make sure each rule has all the required fields.
for (const group of rule.value.rules[0].groups) {
for (const rule of group.rules) {
if (!rule.value || !rule.operator || !rule.field) {
// Check if all values are present.
for (const key in action.value) {
if (!action.value[key]) {
return false
}
}

View File

@@ -62,9 +62,9 @@ const submitForm = async (values) => {
}
await api.updateOIDC(props.id, values)
toastDescription = 'Provider updated successfully'
router.push({ name: 'sso-list' })
} else {
await api.createOIDC(values)
router.push({ name: 'sso-list' })
toastDescription = 'Provider created successfully'
}
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {

View File

@@ -12,7 +12,7 @@
</template>
<script setup>
import { watch, onMounted } from 'vue'
import { watch, onMounted, onUnmounted } from 'vue'
import { useConversationStore } from '@/stores/conversation'
import Conversation from '@/features/conversation/Conversation.vue'
import ConversationSideBarWrapper from '@/features/conversation/sidebar/ConversationSideBarWrapper.vue'
@@ -37,6 +37,10 @@ onMounted(() => {
if (props.uuid) fetchConversation(props.uuid)
})
onUnmounted(() => {
conversationStore.resetCurrentConversation()
})
// Watcher for UUID changes
watch(
() => props.uuid,

View File

@@ -1,5 +1,5 @@
<template>
<ConversationPlaceholder v-if="route.name === 'inbox'" />
<ConversationPlaceholder v-if="['inbox', 'team-inbox', 'view-inbox'].includes(route.name)" />
<router-view />
</template>

View File

@@ -138,12 +138,14 @@ import { Card, CardContent, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useEmitter } from '@/composables/useEmitter'
import { useUserStore } from '@/stores/user'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
const emitter = useEmitter()
const errorMessage = ref('')
const isLoading = ref(false)
const router = useRouter()
const userStore = useUserStore()
const loginForm = ref({
email: '',
password: ''
@@ -207,7 +209,10 @@ const loginAction = () => {
email: loginForm.value.email,
password: loginForm.value.password
})
.then(() => {
.then((resp) => {
if (resp?.data?.data) {
userStore.setCurrentUser(resp.data.data)
}
router.push({ name: 'inboxes' })
})
.catch((error) => {

View File

@@ -76,8 +76,7 @@
</main>
<footer class="p-6 text-center">
<div class="text-sm text-muted-foreground space-x-4">
</div>
<div class="text-sm text-muted-foreground space-x-4"></div>
</footer>
</div>
</template>
@@ -93,10 +92,13 @@ import { Button } from '@/components/ui/button'
import { Error } from '@/components/ui/error'
import { Card, CardContent, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { Label } from '@/components/ui/label'
const errorMessage = ref('')
const isLoading = ref(false)
const emitter = useEmitter()
const router = useRouter()
const resetForm = ref({
email: ''
@@ -121,16 +123,16 @@ const requestResetAction = async () => {
await api.resetPassword({
email: resetForm.value.email
})
toast({
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Reset link sent',
description: 'Please check your email for the reset link.'
})
router.push({ name: 'login' })
} catch (err) {
toast({
title: 'Error',
description: err.response.data.message,
variant: 'destructive'
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Reset link sent',
variant: 'destructive',
description: handleHTTPError(err).message
})
errorMessage.value = handleHTTPError(err).message
useTemporaryClass('reset-password-container', 'animate-shake')

View File

@@ -125,18 +125,16 @@ onMounted(() => {
})
const validateForm = () => {
if (!passwordForm.value.password || passwordForm.value.password.length < 8) {
errorMessage.value = 'Password must be at least 8 characters long.'
if (!passwordForm.value.password) {
errorMessage.value = 'Password is required.'
useTemporaryClass('set-password-container', 'animate-shake')
return false
}
if (passwordForm.value.password !== passwordForm.value.confirmPassword) {
errorMessage.value = 'Passwords do not match.'
useTemporaryClass('set-password-container', 'animate-shake')
return false
}
return true
}
@@ -156,11 +154,6 @@ const setPasswordAction = async () => {
})
router.push({ name: 'login' })
} catch (err) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(err).message
})
errorMessage.value = handleHTTPError(err).message
useTemporaryClass('set-password-container', 'animate-shake')
} finally {

View File

@@ -1,27 +1,29 @@
<template>
<div
class="overflow-y-auto p-4 pr-36"
:class="{ 'opacity-50 transition-opacity duration-300': isLoading }"
>
<Spinner v-if="isLoading" />
<div class="space-y-4">
<div class="text-sm text-gray-500 text-right">
Last updated: {{ new Date(lastUpdate).toLocaleTimeString() }}
</div>
<div class="mt-7 flex w-full space-x-4">
<Card title="Open conversations" :counts="cardCounts" :labels="agentCountCardsLabels" />
<Card
class="w-8/12"
title="Agent status"
:counts="sampleAgentStatusCounts"
:labels="sampleAgentStatusLabels"
/>
</div>
<div class="rounded-lg box w-full p-5 bg-white">
<LineChart :data="chartData.processedData"></LineChart>
</div>
<div class="rounded-lg box w-full p-5 bg-white">
<BarChart :data="chartData.status_summary"></BarChart>
<div class="overflow-y-auto">
<div
class="p-4 w-[calc(100%-3rem)]"
:class="{ 'opacity-50 transition-opacity duration-300': isLoading }"
>
<Spinner v-if="isLoading" />
<div class="space-y-4">
<div class="text-sm text-gray-500 text-right">
Last updated: {{ new Date(lastUpdate).toLocaleTimeString() }}
</div>
<div class="mt-7 flex w-full space-x-4">
<Card title="Open conversations" :counts="cardCounts" :labels="agentCountCardsLabels" />
<Card
class="w-8/12"
title="Agent status"
:counts="agentStatusCounts"
:labels="agentStatusLabels"
/>
</div>
<div class="rounded-lg box w-full p-5 bg-white">
<LineChart :data="chartData.processedData"></LineChart>
</div>
<div class="rounded-lg box w-full p-5 bg-white">
<BarChart :data="chartData.status_summary"></BarChart>
</div>
</div>
</div>
</div>
@@ -52,18 +54,18 @@ const agentCountCardsLabels = {
pending: 'Pending'
}
// TODO: Build agent status feature.
const sampleAgentStatusLabels = {
online: 'Online',
offline: 'Offline',
away: 'Away'
}
const sampleAgentStatusCounts = {
online: 5,
offline: 2,
away: 1
const agentStatusLabels = {
agents_online: 'Online',
agents_offline: 'Offline',
agents_away: 'Away'
}
const agentStatusCounts = ref({
agents_online: 0,
agents_offline: 0,
agents_away: 0
})
onMounted(() => {
getDashboardData()
startRealtimeUpdates()
@@ -96,6 +98,11 @@ const getCardStats = async () => {
.getOverviewCounts()
.then((resp) => {
cardCounts.value = resp.data.data
agentStatusCounts.value = {
agents_online: cardCounts.value.agents_online,
agents_offline: cardCounts.value.agents_offline,
agents_away: cardCounts.value.agents_away
}
})
.catch((error) => {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {

View File

@@ -1,4 +1,5 @@
const animate = require("tailwindcss-animate")
const typography = require("@tailwindcss/typography")
/** @type {import('tailwindcss').Config} */
module.exports = {
@@ -140,5 +141,5 @@ module.exports = {
},
},
},
plugins: [animate],
}
plugins: [animate, typography],
}

View File

@@ -5,6 +5,7 @@ import (
"database/sql"
"embed"
"encoding/json"
"errors"
"github.com/abhinavxd/libredesk/internal/ai/models"
"github.com/abhinavxd/libredesk/internal/dbutil"
@@ -16,6 +17,9 @@ import (
var (
//go:embed queries.sql
efs embed.FS
ErrInvalidAPIKey = errors.New("invalid API Key")
ErrApiKeyNotSet = errors.New("api Key not set")
)
// Manager manages LLM providers.
@@ -35,6 +39,7 @@ type queries struct {
GetDefaultProvider *sqlx.Stmt `query:"get-default-provider"`
GetPrompt *sqlx.Stmt `query:"get-prompt"`
GetPrompts *sqlx.Stmt `query:"get-prompts"`
SetOpenAIKey *sqlx.Stmt `query:"set-openai-key"`
}
// New creates and returns a new instance of the Manager.
@@ -69,6 +74,14 @@ func (m *Manager) Completion(k string, prompt string) (string, error) {
response, err := client.SendPrompt(payload)
if err != nil {
if errors.Is(err, ErrInvalidAPIKey) {
m.lo.Error("error invalid API key", "error", err)
return "", envelope.NewError(envelope.InputError, "OpenAI API Key is invalid, Please ask your administrator to set it up", nil)
}
if errors.Is(err, ErrApiKeyNotSet) {
m.lo.Error("error API key not set", "error", err)
return "", envelope.NewError(envelope.InputError, "OpenAI API Key is not set, Please ask your administrator to set it up", nil)
}
m.lo.Error("error sending prompt to provider", "error", err)
return "", envelope.NewError(envelope.GeneralError, err.Error(), nil)
}
@@ -86,6 +99,26 @@ func (m *Manager) GetPrompts() ([]models.Prompt, error) {
return prompts, nil
}
// UpdateProvider updates a provider.
func (m *Manager) UpdateProvider(provider, apiKey string) error {
switch ProviderType(provider) {
case ProviderOpenAI:
return m.setOpenAIAPIKey(apiKey)
default:
m.lo.Error("unsupported provider type", "provider", provider)
return envelope.NewError(envelope.GeneralError, "Unsupported provider type", nil)
}
}
// setOpenAIAPIKey sets the OpenAI API key in the database.
func (m *Manager) setOpenAIAPIKey(apiKey string) error {
if _, err := m.q.SetOpenAIKey.Exec(apiKey); err != nil {
m.lo.Error("error setting OpenAI API key", "error", err)
return envelope.NewError(envelope.GeneralError, "Error setting OpenAI API key", nil)
}
return nil
}
// getPrompt returns a prompt from the database.
func (m *Manager) getPrompt(k string) (string, error) {
var p models.Prompt

View File

@@ -8,6 +8,7 @@ import (
"net/http"
"time"
"github.com/valyala/fasthttp"
"github.com/zerodha/logf"
)
@@ -28,7 +29,7 @@ func NewOpenAIClient(apiKey string, lo *logf.Logger) *OpenAIClient {
// SendPrompt sends a prompt to the OpenAI API and returns the response text.
func (o *OpenAIClient) SendPrompt(payload PromptPayload) (string, error) {
if o.apikey == "" {
return "", fmt.Errorf("OpenAI API key is not set, Please ask your administrator to set the key")
return "", ErrApiKeyNotSet
}
apiURL := "https://api.openai.com/v1/chat/completions"
@@ -48,7 +49,7 @@ func (o *OpenAIClient) SendPrompt(payload PromptPayload) (string, error) {
return "", fmt.Errorf("marshalling request body: %w", err)
}
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(bodyBytes))
req, err := http.NewRequest(fasthttp.MethodPost, apiURL, bytes.NewBuffer(bodyBytes))
if err != nil {
o.lo.Error("error creating request", "error", err)
return "", fmt.Errorf("error creating request: %w", err)
@@ -65,11 +66,12 @@ func (o *OpenAIClient) SendPrompt(payload PromptPayload) (string, error) {
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return "", fmt.Errorf("OpenAI API key is invalid, Please ask your administrator to update the key")
return "", ErrInvalidAPIKey
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
o.lo.Error("non-ok response received from openai API", "status", resp.Status, "code", resp.StatusCode, "response_text", body)
return "", fmt.Errorf("API error: %s, body: %s", resp.Status, body)
}

View File

@@ -5,4 +5,13 @@ SELECT id, name, provider, config, is_default FROM ai_providers where is_default
SELECT id, key, title, content FROM ai_prompts where key = $1;
-- name: get-prompts
SELECT id, key, title FROM ai_prompts order by title;
SELECT id, key, title FROM ai_prompts order by title;
-- name: set-openai-key
UPDATE ai_providers
SET config = jsonb_set(
COALESCE(config, '{}'::jsonb),
'{api_key}',
to_jsonb($1::text)
)
WHERE provider = 'openai';

View File

@@ -90,9 +90,10 @@ func New(cfg Config, rd *redis.Client, logger *logf.Logger) (*Auth, error) {
EnableAutoCreate: true,
SessionIDLength: 64,
Cookie: simplesessions.CookieOptions{
Name: "libredesk_session",
IsHTTPOnly: true,
IsSecure: true,
Expires: time.Now().Add(time.Hour * 48),
MaxAge: time.Hour * 9,
},
})
@@ -388,6 +389,7 @@ func simpleSessGetCookieCB(name string, r interface{}) (*http.Cookie, error) {
Path: string(c.Path()),
Domain: string(c.Domain()),
Expires: c.Expire(),
MaxAge: c.MaxAge(),
Secure: c.Secure(),
HttpOnly: c.HTTPOnly(),
SameSite: http.SameSite(c.SameSite()),
@@ -410,6 +412,7 @@ func simpleSessSetCookieCB(c *http.Cookie, w interface{}) error {
fc.SetPath(c.Path)
fc.SetDomain(c.Domain)
fc.SetExpire(c.Expires)
fc.SetMaxAge(int(c.MaxAge))
fc.SetSecure(c.Secure)
fc.SetHTTPOnly(c.HttpOnly)
fc.SetSameSite(fasthttp.CookieSameSite(c.SameSite))

View File

@@ -12,6 +12,7 @@ const (
PermConversationsUpdatePriority = "conversations:update_priority"
PermConversationsUpdateStatus = "conversations:update_status"
PermConversationsUpdateTags = "conversations:update_tags"
PermConversationWrite = "conversations:write"
PermMessagesRead = "messages:read"
PermMessagesWrite = "messages:write"
@@ -62,6 +63,9 @@ const (
// OpenID Connect SSO
PermOIDCManage = "oidc:manage"
// AI
PermAIManage = "ai:manage"
)
var validPermissions = map[string]struct{}{
@@ -75,6 +79,7 @@ var validPermissions = map[string]struct{}{
PermConversationsUpdatePriority: {},
PermConversationsUpdateStatus: {},
PermConversationsUpdateTags: {},
PermConversationWrite: {},
PermMessagesRead: {},
PermMessagesWrite: {},
PermViewManage: {},
@@ -93,6 +98,7 @@ var validPermissions = map[string]struct{}{
PermGeneralSettingsManage: {},
PermNotificationSettingsManage: {},
PermOIDCManage: {},
PermAIManage: {},
}
// IsValidPermission returns true if it's a valid permission.

View File

@@ -24,6 +24,7 @@ import (
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/inbox"
imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
mmodels "github.com/abhinavxd/libredesk/internal/media/models"
notifier "github.com/abhinavxd/libredesk/internal/notification"
slaModels "github.com/abhinavxd/libredesk/internal/sla/models"
@@ -96,7 +97,7 @@ type teamStore interface {
}
type userStore interface {
Get(int) (umodels.User, error)
GetAgent(int) (umodels.User, error)
GetSystemUser() (umodels.User, error)
CreateContact(user *umodels.User) error
}
@@ -112,6 +113,7 @@ type mediaStore interface {
type inboxStore interface {
Get(int) (inbox.Inbox, error)
GetDBRecord(int) (imodels.Inbox, error)
}
type settingsStore interface {
@@ -182,7 +184,6 @@ func New(
type queries struct {
// Conversation queries.
GetLatestReceivedMessageSourceID *sqlx.Stmt `query:"get-latest-received-message-source-id"`
GetToAddress *sqlx.Stmt `query:"get-to-address"`
GetConversationUUID *sqlx.Stmt `query:"get-conversation-uuid"`
GetConversation *sqlx.Stmt `query:"get-conversation"`
@@ -207,6 +208,7 @@ type queries struct {
UnassignOpenConversations *sqlx.Stmt `query:"unassign-open-conversations"`
ReOpenConversation *sqlx.Stmt `query:"re-open-conversation"`
UnsnoozeAll *sqlx.Stmt `query:"unsnooze-all"`
DeleteConversation *sqlx.Stmt `query:"delete-conversation"`
// Dashboard queries.
GetDashboardCharts string `query:"get-dashboard-charts"`
@@ -216,6 +218,7 @@ type queries struct {
GetMessage *sqlx.Stmt `query:"get-message"`
GetMessages string `query:"get-messages"`
GetPendingMessages *sqlx.Stmt `query:"get-pending-messages"`
GetMessageSourceIDs *sqlx.Stmt `query:"get-message-source-ids"`
GetConversationUUIDFromMessageUUID *sqlx.Stmt `query:"get-conversation-uuid-from-message-uuid"`
InsertMessage *sqlx.Stmt `query:"insert-message"`
UpdateMessageStatus *sqlx.Stmt `query:"update-message-status"`
@@ -224,13 +227,13 @@ type queries struct {
}
// CreateConversation creates a new conversation and returns its ID and UUID.
func (c *Manager) CreateConversation(contactID, contactChannelID, inboxID int, lastMessage string, lastMessageAt time.Time, subject string) (int, string, error) {
func (c *Manager) CreateConversation(contactID, contactChannelID, inboxID int, lastMessage string, lastMessageAt time.Time, subject string, appendRefNumToSubject bool) (int, string, error) {
var (
id int
uuid string
prefix string
)
if err := c.q.InsertConversation.QueryRow(contactID, contactChannelID, models.StatusOpen, inboxID, lastMessage, lastMessageAt, subject, prefix).Scan(&id, &uuid); err != nil {
if err := c.q.InsertConversation.QueryRow(contactID, contactChannelID, models.StatusOpen, inboxID, lastMessage, lastMessageAt, subject, prefix, appendRefNumToSubject).Scan(&id, &uuid); err != nil {
c.lo.Error("error inserting new conversation into the DB", "error", err)
return id, uuid, err
}
@@ -738,26 +741,28 @@ func (m *Manager) GetToAddress(conversationID int) ([]string, error) {
return addr, nil
}
// GetLatestReceivedMessageSourceID returns the last received message source ID.
func (m *Manager) GetLatestReceivedMessageSourceID(conversationID int) (string, error) {
var out string
if err := m.q.GetLatestReceivedMessageSourceID.Get(&out, conversationID); err != nil {
m.lo.Error("error fetching message source id", "error", err, "conversation_id", conversationID)
return out, err
// GetMessageSourceIDs retrieves source IDs for messages in a conversation in descending order.
// So the oldest message will be the last in the list.
func (m *Manager) GetMessageSourceIDs(conversationID, limit int) ([]string, error) {
var refs []string
if err := m.q.GetMessageSourceIDs.Select(&refs, conversationID, limit); err != nil {
m.lo.Error("error fetching message source IDs", "conversation_id", conversationID, "error", err)
return refs, err
}
return out, nil
return refs, nil
}
// 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.Get(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)
}
content, subject, err := m.template.RenderNamedTemplate(template.TmplConversationAssigned,
map[string]interface{}{
content, subject, err := m.template.RenderStoredEmailTemplate(template.TmplConversationAssigned,
map[string]any{
// Kept these lower case keys for backward compatibility.
"conversation": map[string]string{
"subject": conversation.Subject.String,
"uuid": conversation.UUID,
@@ -767,6 +772,31 @@ func (m *Manager) SendAssignedConversationEmail(userIDs []int, conversation mode
"agent": map[string]string{
"full_name": agent.FullName(),
},
// Following the new structure.
"Conversation": map[string]any{
"ReferenceNumber": conversation.ReferenceNumber,
"Subject": conversation.Subject.String,
"Priority": conversation.Priority.String,
"UUID": conversation.UUID,
},
"Agent": map[string]any{
"FirstName": agent.FirstName,
"LastName": agent.LastName,
"FullName": agent.FullName(),
"Email": agent.Email,
},
"Contact": map[string]any{
"FirstName": conversation.Contact.FirstName,
"LastName": conversation.Contact.LastName,
"FullName": conversation.Contact.FullName(),
"Email": conversation.Contact.Email,
},
"Recipient": map[string]any{
"FirstName": agent.FirstName,
"LastName": agent.LastName,
"FullName": agent.FullName(),
"Email": agent.Email,
},
})
if err != nil {
m.lo.Error("error rendering template", "template", template.TmplConversationAssigned, "conversation_uuid", conversation.UUID, "error", err)
@@ -849,7 +879,7 @@ func (m *Manager) ApplyAction(action amodels.RuleAction, conv models.Conversatio
case amodels.ActionSendPrivateNote:
return m.SendPrivateNote([]mmodels.Media{}, user.ID, conv.UUID, action.Value[0])
case amodels.ActionReply:
return m.SendReply([]mmodels.Media{}, user.ID, conv.UUID, action.Value[0], nil, nil, nil)
return m.SendReply([]mmodels.Media{}, conv.InboxID, user.ID, conv.UUID, action.Value[0], nil, nil, nil)
case amodels.ActionSetSLA:
slaID, _ := strconv.Atoi(action.Value[0])
return m.ApplySLA(conv, slaID, user)
@@ -887,7 +917,16 @@ func (m *Manager) SendCSATReply(actorUserID int, conversation models.Conversatio
meta := map[string]interface{}{
"is_csat": true,
}
return m.SendReply([]mmodels.Media{}, actorUserID, conversation.UUID, message, nil, nil, meta)
return m.SendReply([]mmodels.Media{}, conversation.InboxID, actorUserID, conversation.UUID, message, nil, nil, meta)
}
// DeleteConversation deletes a conversation.
func (m *Manager) DeleteConversation(uuid string) error {
if _, err := m.q.DeleteConversation.Exec(uuid); err != nil {
m.lo.Error("error deleting conversation", "error", err)
return envelope.NewError(envelope.GeneralError, "Error deleting conversation", nil)
}
return nil
}
// addConversationParticipant adds a user as participant to a conversation.

View File

@@ -50,7 +50,7 @@ const (
ContentTypeHTML = "html"
maxLastMessageLen = 45
maxMessagesPerPage = 30
maxMessagesPerPage = 100
)
// Run starts a pool of worker goroutines to handle message dispatching via inbox's channel and processes incoming messages. It scans for
@@ -178,10 +178,29 @@ func (m *Manager) sendOutgoingMessage(message models.Message) {
return
}
// Set message sender and receiver
// Set from and to addresses
message.From = inbox.FromAddress()
message.To, _ = m.GetToAddress(message.ConversationID)
message.InReplyTo, _ = m.GetLatestReceivedMessageSourceID(message.ConversationID)
message.To, err = m.GetToAddress(message.ConversationID)
if handleError(err, "error fetching `to` address") {
return
}
// Set "In-Reply-To" and "References" headers, logging any errors but continuing to send the message.
// Include only the last 20 messages as references to avoid exceeding header size limits.
message.References, err = m.GetMessageSourceIDs(message.ConversationID, 20)
if err != nil {
m.lo.Error("Error fetching conversation source IDs", "error", err)
}
// References is sorted in DESC i.e newest message first, so reverse it to keep the references in order.
stringutil.ReverseSlice(message.References)
// Remove the current message ID from the references.
message.References = stringutil.RemoveItemByValue(message.References, message.SourceID.String)
if len(message.References) > 0 {
message.InReplyTo = message.References[len(message.References)-1]
}
// Send message
err = inbox.Send(message)
@@ -203,7 +222,27 @@ func (m *Manager) RenderContentInTemplate(channel string, message *models.Messag
m.lo.Error("error fetching conversation", "uuid", message.ConversationUUID, "error", err)
return fmt.Errorf("fetching conversation: %w", err)
}
message.Content, err = m.template.RenderWithBaseTemplate(conversation, message.Content)
// Pass conversation and contact data to the template for rendering any placeholders.
message.Content, err = m.template.RenderEmailWithTemplate(map[string]any{
"Conversation": map[string]any{
"ReferenceNumber": conversation.ReferenceNumber,
"Subject": conversation.Subject.String,
"Priority": conversation.Priority.String,
"UUID": conversation.UUID,
},
"Contact": map[string]any{
"FirstName": conversation.Contact.FirstName,
"LastName": conversation.Contact.LastName,
"FullName": conversation.Contact.FullName(),
"Email": conversation.Contact.Email,
},
"Recipient": map[string]any{
"FirstName": conversation.Contact.FirstName,
"LastName": conversation.Contact.LastName,
"FullName": conversation.Contact.FullName(),
"Email": conversation.Contact.Email,
},
}, message.Content)
if err != nil {
m.lo.Error("could not render email content using template", "id", message.ID, "error", err)
return fmt.Errorf("could not render email content using template: %w", err)
@@ -293,11 +332,10 @@ func (m *Manager) SendPrivateNote(media []mmodels.Media, senderID int, conversat
}
// SendReply inserts a reply message in a conversation.
func (m *Manager) SendReply(media []mmodels.Media, senderID int, conversationUUID, content string, cc, bcc []string, meta map[string]interface{}) error {
func (m *Manager) SendReply(media []mmodels.Media, inboxID, senderID int, conversationUUID, content string, cc, bcc []string, meta map[string]interface{}) error {
// Save cc and bcc as JSON in meta.
cc = stringutil.RemoveEmpty(cc)
bcc = stringutil.RemoveEmpty(bcc)
// Save cc and bcc as JSON in meta.
if len(cc) > 0 {
meta["cc"] = cc
}
@@ -308,6 +346,19 @@ func (m *Manager) SendReply(media []mmodels.Media, senderID int, conversationUUI
if err != nil {
return envelope.NewError(envelope.GeneralError, "Error marshalling message meta", nil)
}
// Generage unique source ID i.e. message-id for email.
inbox, err := m.inboxStore.GetDBRecord(inboxID)
if err != nil {
return err
}
sourceID, err := stringutil.GenerateEmailMessageID(conversationUUID, inbox.From)
if err != nil {
m.lo.Error("error generating source message id", "error", err)
return envelope.NewError(envelope.GeneralError, "Error generating source message id", nil)
}
// Insert Message.
message := models.Message{
ConversationUUID: conversationUUID,
SenderID: senderID,
@@ -319,6 +370,7 @@ func (m *Manager) SendReply(media []mmodels.Media, senderID int, conversationUUI
Private: false,
Media: media,
Meta: string(metaJSON),
SourceID: null.StringFrom(sourceID),
}
return m.InsertMessage(&message)
}
@@ -355,8 +407,14 @@ func (m *Manager) InsertMessage(message *models.Message) error {
return err
}
// Update conversation last message details in conversation metadata.
m.UpdateConversationLastMessage(message.ConversationID, message.ConversationUUID, message.TextContent, message.SenderType, message.CreatedAt)
// Hide CSAT message content as it contains a public link to the survey.
lastMessage := message.TextContent
if message.HasCSAT() {
lastMessage = "Please rate your experience with us"
}
// Update conversation last message details in conversation.
m.UpdateConversationLastMessage(message.ConversationID, message.ConversationUUID, lastMessage, message.SenderType, message.CreatedAt)
// Broadcast new message.
m.BroadcastNewMessage(message)
@@ -371,7 +429,7 @@ func (m *Manager) RecordAssigneeUserChange(conversationUUID string, assigneeID i
}
// Assignment to another user.
assignee, err := m.userStore.Get(assigneeID)
assignee, err := m.userStore.GetAgent(assigneeID)
if err != nil {
return err
}
@@ -655,11 +713,8 @@ func (m *Manager) findOrCreateConversation(in *models.Message, inboxID, contactC
conversationUUID string
)
// Search for existing conversation.
sourceIDs := in.References
if in.InReplyTo != "" {
sourceIDs = append(sourceIDs, in.InReplyTo)
}
// Search for existing conversation using the in-reply-to and references.
sourceIDs := append([]string{in.InReplyTo}, in.References...)
conversationID, err = m.findConversationID(sourceIDs)
if err != nil && err != errConversationNotFound {
return new, err
@@ -670,7 +725,7 @@ func (m *Manager) findOrCreateConversation(in *models.Message, inboxID, contactC
new = true
lastMessage := stringutil.HTML2Text(in.Content)
lastMessageAt := time.Now()
conversationID, conversationUUID, err = m.CreateConversation(contactID, contactChannelID, inboxID, lastMessage, lastMessageAt, in.Subject)
conversationID, conversationUUID, err = m.CreateConversation(contactID, contactChannelID, inboxID, lastMessage, lastMessageAt, in.Subject, false /**append reference number to subject**/)
if err != nil || conversationID == 0 {
return new, err
}

View File

@@ -119,6 +119,7 @@ type Message struct {
InReplyTo string `json:"-"`
Headers textproto.MIMEHeader `json:"-"`
Media []mmodels.Media `db:"-" json:"-"`
IsCSAT bool `db:"-" json:"-"`
Total int `db:"total" json:"-"`
}
@@ -134,6 +135,16 @@ func (m *Message) CensorCSATContent() {
}
}
// HasCSAT returns true if the message is a CSAT message.
func (m *Message) HasCSAT() bool {
var meta map[string]interface{}
if err := json.Unmarshal([]byte(m.Meta), &meta); err != nil {
return false
}
isCsat, _ := meta["is_csat"].(bool)
return isCsat
}
// IncomingMessage links a message with the contact information and inbox id.
type IncomingMessage struct {
Message Message

View File

@@ -9,7 +9,7 @@ status_id AS (
SELECT id FROM conversation_statuses WHERE name = $3
),
reference_number AS (
SELECT generate_reference_number($8) as reference_number
SELECT generate_reference_number($8) AS reference_number
)
INSERT INTO conversations
(contact_id, contact_channel_id, status_id, inbox_id, last_message, last_message_at, subject, reference_number)
@@ -20,7 +20,10 @@ VALUES(
$4,
$5,
$6,
$7,
CASE
WHEN $9 = TRUE THEN CONCAT($7::text, ' [', (SELECT reference_number FROM reference_number), ']')
ELSE $7::text
END,
(SELECT reference_number FROM reference_number)
)
RETURNING id, uuid;
@@ -234,7 +237,10 @@ SELECT json_build_object(
'open', COUNT(*),
'awaiting_response', COUNT(CASE WHEN c.waiting_since IS NOT NULL THEN 1 END),
'unassigned', COUNT(CASE WHEN c.assigned_user_id IS NULL THEN 1 END),
'pending', COUNT(CASE WHEN c.first_reply_at IS NOT NULL THEN 1 END)
'pending', COUNT(CASE WHEN c.first_reply_at IS NOT NULL THEN 1 END),
'agents_online', (SELECT COUNT(*) FROM users WHERE availability_status = 'online' AND type = 'agent' AND deleted_at is null),
'agents_away', (SELECT COUNT(*) FROM users WHERE availability_status in ('away', 'away_manual') AND type = 'agent' AND deleted_at is null),
'agents_offline', (SELECT COUNT(*) FROM users WHERE availability_status = 'offline' AND type = 'agent' AND deleted_at is null)
)
FROM conversations c
INNER JOIN conversation_statuses s ON c.status_id = s.id
@@ -359,13 +365,17 @@ SET assigned_user_id = NULL,
updated_at = now()
WHERE assigned_user_id = $1 AND status_id in (SELECT id FROM conversation_statuses WHERE name NOT IN ('Resolved', 'Closed'));
-- MESSAGE queries.
-- name: get-latest-received-message-source-id
SELECT source_id
-- name: get-message-source-ids
SELECT
source_id
FROM conversation_messages
WHERE conversation_id = $1 and status = 'received'
WHERE conversation_id = $1
AND type in ('incoming', 'outgoing') and private = false
and source_id > ''
ORDER BY id DESC
LIMIT 1;
LIMIT $2;
-- name: get-pending-messages
SELECT
@@ -517,4 +527,7 @@ SET status_id = (SELECT id FROM conversation_statuses WHERE name = 'Open'), snoo
updated_at = now()
WHERE uuid = $1 and status_id in (
SELECT id FROM conversation_statuses WHERE name IN ('Snoozed', 'Closed', 'Resolved')
)
)
-- name: delete-conversation
DELETE FROM conversations WHERE uuid = $1;

View File

@@ -112,16 +112,24 @@ func (e *Email) Send(m models.Message) error {
email.Headers.Set(key, value[0])
}
// Set In-Reply-To and References headers
// Set In-Reply-To header
if m.InReplyTo != "" {
email.Headers.Set(headerInReplyTo, "<"+m.InReplyTo+">")
e.lo.Debug("In-Reply-To header set", "message_id", m.InReplyTo)
}
// Set references message ids
// Set message id header
if m.SourceID.String != "" {
email.Headers.Set(headerMessageID, fmt.Sprintf("<%s>", m.SourceID.String))
e.lo.Debug("Message-ID header set", "message_id", m.SourceID.String)
}
// Set references header
var references string
for _, ref := range m.References {
references += "<" + ref + "> "
}
e.lo.Debug("References header set", "references", references)
email.Headers.Set(headerReferences, references)
// Set email content

View File

@@ -0,0 +1,22 @@
package migrations
import (
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
// V0_3_0 updates the database schema to v0.3.0.
func V0_3_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
_, err := db.Exec(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_availability_status') THEN
CREATE TYPE user_availability_status AS ENUM ('online', 'away', 'away_manual', 'offline');
END IF;
END$$;
ALTER TABLE users ADD COLUMN IF NOT EXISTS availability_status user_availability_status DEFAULT 'offline' NOT NULL;
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_active_at TIMESTAMPTZ NULL;
`)
return err
}

View File

@@ -0,0 +1,36 @@
package migrations
import (
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)
// V0_4_0 updates the database schema to v0.4.0.
func V0_4_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
// Admin role gets new permissions.
_, err := db.Exec(`
UPDATE roles
SET permissions = array_append(permissions, 'ai:manage')
WHERE name = 'Admin' AND NOT ('ai:manage' = ANY(permissions));
`)
if err != nil {
return err
}
_, err = db.Exec(`
UPDATE roles
SET permissions = array_append(permissions, 'conversations:write')
WHERE name = 'Admin' AND NOT ('conversations:write' = ANY(permissions));
`)
if err != nil {
return err
}
// Create trigram index on users.email if it doesn't exist.
_, err = db.Exec(`
CREATE INDEX IF NOT EXISTS index_tgrm_users_on_email
ON users USING GIN (email gin_trgm_ops);
`)
return err
}

View File

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

View File

@@ -16,3 +16,10 @@ type Message struct {
ConversationUUID string `db:"conversation_uuid" json:"conversation_uuid"`
ConversationReferenceNumber string `db:"conversation_reference_number" json:"conversation_reference_number"`
}
type Contact struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
FirstName string `db:"first_name" json:"first_name"`
LastName string `db:"last_name" json:"last_name"`
Email string `db:"email" json:"email"`
}

View File

@@ -15,4 +15,17 @@ SELECT
m.text_content
FROM conversation_messages m
JOIN conversations c ON m.conversation_id = c.id
WHERE m.type != 'activity' and m.text_content ILIKE '%' || $1 || '%';
WHERE m.type != 'activity' and m.text_content ILIKE '%' || $1 || '%';
-- name: search-contacts
SELECT
id,
created_at,
first_name,
last_name,
email
FROM users
WHERE type = 'contact'
AND deleted_at IS NULL
AND email ILIKE '%' || $1 || '%'
LIMIT 15;

View File

@@ -32,6 +32,7 @@ type Opts struct {
type queries struct {
SearchConversations *sqlx.Stmt `query:"search-conversations"`
SearchMessages *sqlx.Stmt `query:"search-messages"`
SearchContacts *sqlx.Stmt `query:"search-contacts"`
}
// New creates a new search manager
@@ -62,3 +63,13 @@ func (s *Manager) Messages(query string) ([]models.Message, error) {
}
return results, nil
}
// Contacts searches contacts based on the query
func (s *Manager) Contacts(query string) ([]models.Contact, error) {
var results = make([]models.Contact, 0)
if err := s.q.SearchContacts.Select(&results, query); err != nil {
s.lo.Error("error searching contacts", "error", err)
return nil, envelope.NewError(envelope.GeneralError, "Error searching contacts", nil)
}
return results, nil
}

View File

@@ -75,6 +75,7 @@ func (m *Manager) GetAll() (models.Settings, error) {
func (m *Manager) GetAllJSON() (types.JSONText, error) {
var b types.JSONText
if err := m.q.GetAll.Get(&b); err != nil {
m.lo.Error("error fetching settings", "error", err)
return b, err
}
return b, nil
@@ -85,10 +86,12 @@ func (m *Manager) Update(s interface{}) error {
// Marshal settings.
b, err := json.Marshal(s)
if err != nil {
m.lo.Error("error marshalling settings", "error", err)
return envelope.NewError(envelope.GeneralError, "Error updating settings", nil)
}
// Update the settings in the DB.
if _, err := m.q.Update.Exec(b); err != nil {
m.lo.Error("error updating settings", "error", err)
return envelope.NewError(envelope.GeneralError, "Error updating settings", nil)
}
return nil

View File

@@ -3,10 +3,14 @@ package stringutil
import (
"crypto/rand"
"encoding/base64"
"fmt"
"net/mail"
"net/url"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/k3a/html2text"
)
@@ -94,3 +98,65 @@ func RemoveEmpty(s []string) []string {
}
return r
}
// GenerateEmailMessageID generates a RFC-compliant Message-ID for an email, does not include the angle brackets.
// The client is expected to wrap the returned string in angle brackets.
func GenerateEmailMessageID(messageID string, fromAddress string) (string, error) {
if messageID == "" {
return "", fmt.Errorf("messageID cannot be empty")
}
// Parse from address
addr, err := mail.ParseAddress(fromAddress)
if err != nil {
return "", fmt.Errorf("invalid from address: %w", err)
}
// Extract domain with validation
parts := strings.Split(addr.Address, "@")
if len(parts) != 2 || parts[1] == "" {
return "", fmt.Errorf("invalid domain in from address")
}
domain := parts[1]
// Generate cryptographic random component
random := make([]byte, 8)
if _, err := rand.Read(random); err != nil {
return "", fmt.Errorf("failed to generate random bytes: %w", err)
}
// Sanitize messageID for email Message-ID
cleaner := regexp.MustCompile(`[^\w.-]`) // Allow only alphanum, ., -, _
cleanmessageID := cleaner.ReplaceAllString(messageID, "_")
// Ensure cleaned messageID isn't empty
if cleanmessageID == "" {
return "", fmt.Errorf("messageID became empty after sanitization")
}
// Build RFC-compliant Message-ID
return fmt.Sprintf("%s-%d-%s@%s",
cleanmessageID,
time.Now().UnixNano(), // Nanosecond precision
strings.TrimRight(base64.URLEncoding.EncodeToString(random), "="), // URL-safe base64 without padding
domain,
), nil
}
// ReverseSlice reverses a slice of strings in place.
func ReverseSlice(source []string) {
for i, j := 0, len(source)-1; i < j; i, j = i+1, j-1 {
source[i], source[j] = source[j], source[i]
}
}
// RemoveItemByValue removes all instances of a value from a slice of strings.
func RemoveItemByValue(slice []string, value string) []string {
result := []string{}
for _, v := range slice {
if v != value {
result = append(result, v)
}
}
return result
}

View File

@@ -22,17 +22,19 @@ const (
TmplContent = "content"
)
// RenderWithBaseTemplate merges the given content with the default outgoing email template, if available.
func (m *Manager) RenderWithBaseTemplate(data any, content string) (string, error) {
// RenderEmailWithTemplate renders content inside the default outgoing email template.
func (m *Manager) RenderEmailWithTemplate(data any, content string) (string, error) {
m.mutex.RLock()
defer m.mutex.RUnlock()
defaultTmpl, err := m.getDefaultOutgoingEmailTemplate()
if err != nil {
if err == ErrTemplateNotFound {
m.lo.Warn("default outgoing email template not found, rendering content any template")
m.lo.Warn("default outgoing email template not found, rendering content without any template")
return content, nil
}
return "", err
m.lo.Error("error fetching default outgoing email template", "error", err)
return "", fmt.Errorf("fetching default outgoing email template: %w", err)
}
baseTemplate, err := template.New(TmplBase).Funcs(m.funcMap).Parse(defaultTmpl.Body)
@@ -58,8 +60,8 @@ func (m *Manager) RenderWithBaseTemplate(data any, content string) (string, erro
return rendered.String(), nil
}
// RenderNamedTemplate fetches a named template from DB and merges it with the default base template, if available.
func (m *Manager) RenderNamedTemplate(name string, data any) (string, string, error) {
// RenderStoredEmailTemplate fetches and renders an email template from the database, including subject and body and returns the rendered content.
func (m *Manager) RenderStoredEmailTemplate(name string, data any) (string, string, error) {
tmpl, err := m.getByName(name)
if err != nil {
if err == ErrTemplateNotFound {
@@ -137,8 +139,9 @@ func (m *Manager) RenderNamedTemplate(name string, data any) (string, string, er
return rendered.String(), subject, nil
}
// RenderTemplate executes a named in-memory template with the provided data.
func (m *Manager) RenderTemplate(name string, data interface{}) (string, error) {
// RenderInMemoryTemplate executes an in-memory template with data and returns the rendered content.
// This is for system emails like reset password and welcome email etc.
func (m *Manager) RenderInMemoryTemplate(name string, data interface{}) (string, error) {
m.mutex.RLock()
defer m.mutex.RUnlock()
var buf bytes.Buffer

View File

@@ -8,29 +8,37 @@ import (
"github.com/volatiletech/null/v9"
)
var (
Online = "online"
Offline = "offline"
Away = "away"
AwayManual = "away_manual"
)
type User struct {
ID int `db:"id" json:"id,omitempty"`
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,omitempty"`
Type string `db:"type" json:"type"`
PhoneNumber null.String `db:"phone_number" json:"phone_number,omitempty"`
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
Enabled bool `db:"enabled" json:"enabled"`
Password string `db:"password" json:"-"`
Roles pq.StringArray `db:"roles" json:"roles,omitempty"`
Permissions pq.StringArray `db:"permissions" json:"permissions,omitempty"`
Meta pq.StringArray `db:"meta" json:"meta,omitempty"`
CustomAttributes pq.StringArray `db:"custom_attributes" json:"custom_attributes,omitempty"`
Teams tmodels.Teams `db:"teams" json:"teams,omitempty"`
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,omitempty"`
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,omitempty"`
Type string `db:"type" json:"type"`
AvailabilityStatus string `db:"availability_status" json:"availability_status"`
PhoneNumber null.String `db:"phone_number" json:"phone_number,omitempty"`
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
Enabled bool `db:"enabled" json:"enabled"`
Password string `db:"password" json:"-"`
Roles pq.StringArray `db:"roles" json:"roles,omitempty"`
Permissions pq.StringArray `db:"permissions" json:"permissions,omitempty"`
Meta pq.StringArray `db:"meta" json:"meta,omitempty"`
CustomAttributes pq.StringArray `db:"custom_attributes" json:"custom_attributes,omitempty"`
Teams tmodels.Teams `db:"teams" json:"teams,omitempty"`
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:"-"`
}
func (u *User) FullName() string {

View File

@@ -20,41 +20,32 @@ SELECT email
FROM users
WHERE id = $1 AND deleted_at IS NULL AND type = 'agent';
-- name: get-user-by-email
SELECT u.id, u.email, u.password, u.avatar_url, u.first_name, u.last_name, u.enabled,
array_agg(DISTINCT r.name) as roles,
array_agg(DISTINCT p) as permissions
FROM users u
JOIN user_roles ur ON ur.user_id = u.id
JOIN roles r ON r.id = ur.role_id,
unnest(r.permissions) p
WHERE u.email = $1 AND u.deleted_at IS NULL AND u.type = 'agent'
GROUP BY u.id;
-- name: get-user
SELECT
u.id,
u.created_at,
u.updated_at,
u.enabled,
u.email,
u.avatar_url,
u.first_name,
u.last_name,
array_agg(DISTINCT r.name) 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),
'[]'
) AS teams,
array_agg(DISTINCT p) as permissions
u.id,
u.email,
u.password,
u.created_at,
u.updated_at,
u.enabled,
u.avatar_url,
u.first_name,
u.last_name,
u.availability_status,
array_agg(DISTINCT r.name) 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),
'[]'
) AS teams,
array_agg(DISTINCT p) 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
WHERE u.id = $1 AND u.deleted_at IS NULL AND u.type = 'agent'
unnest(r.permissions) p
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
@@ -92,6 +83,25 @@ UPDATE users
SET avatar_url = $2, updated_at = now()
WHERE id = $1 AND type = 'agent';
-- name: update-availability
UPDATE users
SET availability_status = $2
WHERE id = $1;
-- name: update-last-active-at
UPDATE users
SET last_active_at = now(),
availability_status = CASE WHEN availability_status = 'offline' THEN 'online' ELSE availability_status END
WHERE id = $1;
-- name: update-inactive-offline
UPDATE users
SET availability_status = 'offline'
WHERE
type = 'agent'
AND (last_active_at IS NULL OR last_active_at < NOW() - INTERVAL '5 minutes')
AND availability_status != 'offline';
-- name: get-permissions
SELECT DISTINCT unnest(r.permissions)
FROM users u

View File

@@ -10,6 +10,7 @@ import (
"os"
"regexp"
"strings"
"time"
"log"
@@ -61,13 +62,15 @@ type Opts struct {
// queries contains prepared SQL queries.
type queries struct {
GetUsers *sqlx.Stmt `query:"get-users"`
GetUserCompact *sqlx.Stmt `query:"get-users-compact"`
GetUsersCompact *sqlx.Stmt `query:"get-users-compact"`
GetUser *sqlx.Stmt `query:"get-user"`
GetEmail *sqlx.Stmt `query:"get-email"`
GetPermissions *sqlx.Stmt `query:"get-permissions"`
GetUserByEmail *sqlx.Stmt `query:"get-user-by-email"`
UpdateUser *sqlx.Stmt `query:"update-user"`
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"`
SoftDeleteUser *sqlx.Stmt `query:"soft-delete-user"`
SetUserPassword *sqlx.Stmt `query:"set-user-password"`
SetResetPasswordToken *sqlx.Stmt `query:"set-reset-password-token"`
@@ -89,22 +92,19 @@ func New(i18n *i18n.I18n, opts Opts) (*Manager, error) {
}, nil
}
// VerifyPassword authenticates a user by email and password.
// VerifyPassword authenticates an user by email and password.
func (u *Manager) VerifyPassword(email string, password []byte) (models.User, error) {
var user models.User
if err := u.q.GetUserByEmail.Get(&user, email); err != nil {
if err := u.q.GetUser.Get(&user, 0, email, UserTypeAgent); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return user, envelope.NewError(envelope.InputError, u.i18n.T("user.invalidEmailPassword"), 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.entities.user}"), nil)
}
if err := u.verifyPassword(password, user.Password); err != nil {
return user, envelope.NewError(envelope.InputError, u.i18n.T("user.invalidEmailPassword"), nil)
}
return user, nil
}
@@ -125,7 +125,7 @@ func (u *Manager) GetAll() ([]models.User, error) {
// GetAllCompact returns a compact list of users with limited fields.
func (u *Manager) GetAllCompact() ([]models.User, error) {
var users = make([]models.User, 0)
if err := u.q.GetUserCompact.Select(&users); err != nil {
if err := u.q.GetUsersCompact.Select(&users); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return users, nil
}
@@ -154,10 +154,25 @@ func (u *Manager) CreateAgent(user *models.User) error {
return nil
}
// Get retrieves a user by ID.
func (u *Manager) Get(id int) (models.User, error) {
// GetAgent retrieves an agent by ID.
func (u *Manager) GetAgent(id int) (models.User, error) {
return u.Get(id, UserTypeAgent)
}
// GetAgentByEmail retrieves an agent by email.
func (u *Manager) GetAgentByEmail(email string) (models.User, error) {
return u.GetByEmail(email, UserTypeAgent)
}
// GetContact retrieves a contact by ID.
func (u *Manager) GetContact(id int) (models.User, error) {
return u.Get(id, UserTypeContact)
}
// Get retrieves an user by ID.
func (u *Manager) Get(id int, type_ string) (models.User, error) {
var user models.User
if err := u.q.GetUser.Get(&user, id); err != nil {
if err := u.q.GetUser.Get(&user, id, "", type_); err != nil {
if errors.Is(err, sql.ErrNoRows) {
u.lo.Error("user not found", "id", id, "error", err)
return user, envelope.NewError(envelope.GeneralError, "User not found", nil)
@@ -168,10 +183,10 @@ func (u *Manager) Get(id int) (models.User, error) {
return user, nil
}
// GetByEmail retrieves a user by email
func (u *Manager) GetByEmail(email string) (models.User, error) {
// GetByEmail retrieves an user by email
func (u *Manager) GetByEmail(email, type_ string) (models.User, error) {
var user models.User
if err := u.q.GetUserByEmail.Get(&user, email); err != nil {
if err := u.q.GetUser.Get(&user, 0, email, type_); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return user, envelope.NewError(envelope.GeneralError, "User not found", nil)
}
@@ -183,7 +198,7 @@ func (u *Manager) GetByEmail(email string) (models.User, error) {
// GetSystemUser retrieves the system user.
func (u *Manager) GetSystemUser() (models.User, error) {
return u.GetByEmail(systemUserEmail)
return u.GetByEmail(systemUserEmail, UserTypeAgent)
}
// UpdateAvatar updates the user avatar.
@@ -195,10 +210,10 @@ func (u *Manager) UpdateAvatar(id int, avatar string) error {
return nil
}
// Update updates a user.
// Update updates an user.
func (u *Manager) Update(id int, user models.User) error {
var (
hashedPassword interface{}
hashedPassword any
err error
)
@@ -221,7 +236,7 @@ func (u *Manager) Update(id int, user models.User) error {
return nil
}
// SoftDelete soft deletes a user.
// SoftDelete soft deletes an user.
func (u *Manager) SoftDelete(id int) error {
// Disallow if user is system user.
systemUser, err := u.GetSystemUser()
@@ -239,7 +254,7 @@ func (u *Manager) SoftDelete(id int) error {
return nil
}
// GetEmail retrieves the email of a user by ID.
// GetEmail retrieves the email of an user by ID.
func (u *Manager) GetEmail(id int) (string, error) {
var email string
if err := u.q.GetEmail.Get(&email, id); err != nil {
@@ -252,7 +267,7 @@ func (u *Manager) GetEmail(id int) (string, error) {
return email, nil
}
// SetResetPasswordToken sets a reset password token for a user and returns the token.
// SetResetPasswordToken sets a reset password token for an user and returns the token.
func (u *Manager) SetResetPasswordToken(id int) (string, error) {
token, err := stringutil.RandomAlphanumeric(32)
if err != nil {
@@ -266,7 +281,7 @@ func (u *Manager) SetResetPasswordToken(id int) (string, error) {
return token, nil
}
// ResetPassword sets a new password for a user.
// ResetPassword sets a new password for an user.
func (u *Manager) ResetPassword(token, password string) error {
if !u.isStrongPassword(password) {
return envelope.NewError(envelope.InputError, "Password is not strong enough, "+SystemUserPasswordHint, nil)
@@ -277,14 +292,18 @@ func (u *Manager) ResetPassword(token, password string) error {
u.lo.Error("error generating bcrypt password", "error", err)
return envelope.NewError(envelope.GeneralError, "Error setting new password", nil)
}
if _, err := u.q.ResetPassword.Exec(passwordHash, token); err != nil {
rows, err := u.q.ResetPassword.Exec(passwordHash, token)
if err != nil {
u.lo.Error("error setting new password", "error", err)
return envelope.NewError(envelope.GeneralError, "Error setting new password", nil)
}
if count, _ := rows.RowsAffected(); count == 0 {
return envelope.NewError(envelope.InputError, "Token is invalid or expired, please try again by requesting a new password reset link", nil)
}
return nil
}
// GetPermissions retrieves the permissions of a user by ID.
// GetPermissions retrieves the permissions of an user by ID.
func (u *Manager) GetPermissions(id int) ([]string, error) {
var permissions []string
if err := u.q.GetPermissions.Select(&permissions, id); err != nil {
@@ -294,6 +313,50 @@ func (u *Manager) GetPermissions(id int) ([]string, error) {
return permissions, nil
}
// UpdateAvailability updates the availability status of an user.
func (u *Manager) UpdateAvailability(id int, status string) error {
if _, err := u.q.UpdateAvailability.Exec(id, status); err != nil {
u.lo.Error("error updating user availability", "error", err)
return envelope.NewError(envelope.GeneralError, "Error updating user availability", nil)
}
return nil
}
// UpdateLastActive updates the last active timestamp of an user.
func (u *Manager) UpdateLastActive(id int) error {
if _, err := u.q.UpdateLastActiveAt.Exec(id); err != nil {
u.lo.Error("error updating user last active at", "error", err)
return envelope.NewError(envelope.GeneralError, "Error updating user last active at", nil)
}
return nil
}
// MonitorAgentAvailability continuously checks for user activity and sets them offline if inactive for more than 5 minutes.
func (u *Manager) MonitorAgentAvailability(ctx context.Context) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
u.markInactiveAgentsOffline()
case <-ctx.Done():
return
}
}
}
// 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 {
u.lo.Error("error setting users offline", "error", err)
} else {
rows, _ := res.RowsAffected()
if rows > 0 {
u.lo.Info("set inactive users offline", "count", rows)
}
}
}
// verifyPassword compares the provided password with the stored password hash.
func (u *Manager) verifyPassword(pwd []byte, pwdHash string) error {
if err := bcrypt.CompareHashAndPassword([]byte(pwdHash), pwd); err != nil {

View File

@@ -94,7 +94,9 @@ func (c *Client) Listen() {
// processIncomingMessage processes incoming messages from the client.
func (c *Client) processIncomingMessage(data []byte) {
// Handle ping messages, and update last active time for user.
if string(data) == "ping" {
c.Hub.userStore.UpdateLastActive(c.ID)
c.SendMessage([]byte("pong"), websocket.TextMessage)
return
}

View File

@@ -13,13 +13,20 @@ type Hub struct {
// Client ID to WS Client map, user can connect from multiple devices and each device will have a separate client.
clients map[int][]*Client
clientsMutex sync.Mutex
userStore userStore
}
type userStore interface {
UpdateLastActive(userID int) error
}
// NewHub creates a new websocket hub.
func NewHub() *Hub {
func NewHub(userStore userStore) *Hub {
return &Hub{
clients: make(map[int][]*Client, 10000),
clientsMutex: sync.Mutex{},
userStore: userStore,
}
}

View File

@@ -13,6 +13,7 @@ DROP TYPE IF EXISTS "automation_execution_mode" CASCADE; CREATE TYPE "automation
DROP TYPE IF EXISTS "macro_visibility" CASCADE; CREATE TYPE "macro_visibility" AS ENUM ('all', 'team', 'user');
DROP TYPE IF EXISTS "media_disposition" CASCADE; CREATE TYPE "media_disposition" AS ENUM ('inline', 'attachment');
DROP TYPE IF EXISTS "media_store" CASCADE; CREATE TYPE "media_store" AS ENUM ('s3', 'fs');
DROP TYPE IF EXISTS "user_availability_status" CASCADE; CREATE TYPE "user_availability_status" AS ENUM ('online', 'away', 'away_manual', 'offline');
-- Sequence to generate reference number for conversations.
DROP SEQUENCE IF EXISTS conversation_reference_number_sequence; CREATE SEQUENCE conversation_reference_number_sequence START 100;
@@ -118,6 +119,8 @@ CREATE TABLE users (
custom_attributes JSONB DEFAULT '{}'::jsonb NOT NULL,
reset_password_token TEXT NULL,
reset_password_token_expiry TIMESTAMPTZ NULL,
availability_status user_availability_status DEFAULT 'offline' NOT NULL,
last_active_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_email_length CHECK (LENGTH(email) <= 320),
@@ -126,6 +129,7 @@ CREATE TABLE users (
);
CREATE UNIQUE INDEX index_unique_users_on_email_and_type_when_deleted_at_is_null ON users (email, type)
WHERE deleted_at IS NULL;
CREATE INDEX index_tgrm_users_on_email ON users USING GIN (email gin_trgm_ops);
DROP TABLE IF EXISTS user_roles CASCADE;
CREATE TABLE user_roles (
@@ -533,28 +537,30 @@ VALUES
(
'Admin',
'Role for users who have complete access to everything.',
'{general_settings:manage,notification_settings:manage,oidc:manage,conversations:read_all,conversations:read_unassigned,conversations:read_assigned,conversations:read_team_inbox,conversations:read,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write,view:manage,status:manage,tags:manage,macros:manage,users:manage,teams:manage,automations:manage,inboxes:manage,roles:manage,reports:manage,templates:manage,business_hours:manage,sla:manage}'
'{conversations:write,ai:manage,general_settings:manage,notification_settings:manage,oidc:manage,conversations:read_all,conversations:read_unassigned,conversations:read_assigned,conversations:read_team_inbox,conversations:read,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write,view:manage,status:manage,tags:manage,macros:manage,users:manage,teams:manage,automations:manage,inboxes:manage,roles:manage,reports:manage,templates:manage,business_hours:manage,sla:manage}'
);
-- Email notification templates
INSERT INTO public.templates
INSERT INTO templates
("type", body, is_default, "name", subject, is_builtin)
VALUES('email_notification'::public."template_type", '<p>Hello {{ .agent.full_name }},</p>
VALUES('email_notification'::template_type, '
<p>Hi {{ .Agent.FirstName }},</p>
<p>A new conversation has been assigned to you:</p>
<div>
Reference number: {{.conversation.reference_number }} <br>
Priority: {{.conversation.priority }}<br>
Subject: {{.conversation.subject }}
Reference number: {{ .Conversation.ReferenceNumber }} <br>
Subject: {{ .Conversation.Subject }}
</div>
<p>
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .conversation.uuid }}">View Conversation</a>
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
</p>
<div >
<div>
Best regards,<br>
Libredesk
</div>', false, 'Conversation assigned', 'New conversation assigned to you', true);
</div>
', false, 'Conversation assigned', 'New conversation assigned to you', true);

Some files were not shown because too many files have changed in this diff Show More