mirror of
				https://github.com/abhinavxd/libredesk.git
				synced 2025-11-04 05:53:30 +00:00 
			
		
		
		
	Compare commits
	
		
			41 Commits
		
	
	
		
			v0.3.0-alp
			...
			v0.4.1-alp
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					8e15d733ea | ||
| 
						 | 
					fc47e65fcb | ||
| 
						 | 
					760be37eda | ||
| 
						 | 
					d1f08ce035 | ||
| 
						 | 
					8551b65a27 | ||
| 
						 | 
					eb499f64d0 | ||
| 
						 | 
					494bc15b0a | ||
| 
						 | 
					360557c58f | ||
| 
						 | 
					8d8f08e1d2 | ||
| 
						 | 
					10b4f9d08c | ||
| 
						 | 
					79f74363da | ||
| 
						 | 
					8f6295542e | ||
| 
						 | 
					8e286e2273 | ||
| 
						 | 
					3aad69fc52 | ||
| 
						 | 
					58825c3de9 | ||
| 
						 | 
					03c68afc4c | ||
| 
						 | 
					15b9caaaed | ||
| 
						 | 
					b0d3dcb5dd | ||
| 
						 | 
					96ef62b509 | ||
| 
						 | 
					79c3f5a60c | ||
| 
						 | 
					70bef7b3ab | ||
| 
						 | 
					b1e1dff3eb | ||
| 
						 | 
					9b34c2737d | ||
| 
						 | 
					1b63f03bb1 | ||
| 
						 | 
					26d76c966f | ||
| 
						 | 
					1ff335f772 | ||
| 
						 | 
					5836ee8d90 | ||
| 
						 | 
					98534f3c5a | ||
| 
						 | 
					59951f0829 | ||
| 
						 | 
					461ae3cf22 | ||
| 
						 | 
					da5dfdbcde | ||
| 
						 | 
					9c67c02b08 | ||
| 
						 | 
					15b200b0db | ||
| 
						 | 
					f4617c599c | ||
| 
						 | 
					341d0b7e47 | ||
| 
						 | 
					78b8c508d8 | ||
| 
						 | 
					f17d96f96f | ||
| 
						 | 
					c75c117a4d | ||
| 
						 | 
					873d26ccb2 | ||
| 
						 | 
					71601364ae | ||
| 
						 | 
					44723fb70d | 
@@ -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/).
 | 
					Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**Live demo**](https://demo.libredesk.io/).
 | 
				
			||||||
 | 
					
 | 
				
			||||||

 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
> **CAUTION:** This project is currently in **alpha**. Features and APIs may change and are not yet fully tested.
 | 
					> **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 --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.
 | 
					- 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)
 | 
				
			||||||
__________________
 | 
					__________________
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										25
									
								
								cmd/ai.go
									
									
									
									
									
								
							
							
						
						
									
										25
									
								
								cmd/ai.go
									
									
									
									
									
								
							@@ -1,6 +1,14 @@
 | 
				
			|||||||
package main
 | 
					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
 | 
					// handleAICompletion handles AI completion requests
 | 
				
			||||||
func handleAICompletion(r *fastglue.Request) error {
 | 
					func handleAICompletion(r *fastglue.Request) error {
 | 
				
			||||||
@@ -27,3 +35,18 @@ func handleGetAIPrompts(r *fastglue.Request) error {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(resp)
 | 
						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")
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -75,7 +75,7 @@ func handleOIDCCallback(r *fastglue.Request) error {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Lookup the user by email and set the session.
 | 
						// 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 {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,6 +3,7 @@ package main
 | 
				
			|||||||
import (
 | 
					import (
 | 
				
			||||||
	"encoding/json"
 | 
						"encoding/json"
 | 
				
			||||||
	"strconv"
 | 
						"strconv"
 | 
				
			||||||
 | 
						"strings"
 | 
				
			||||||
	"time"
 | 
						"time"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	amodels "github.com/abhinavxd/libredesk/internal/auth/models"
 | 
						amodels "github.com/abhinavxd/libredesk/internal/auth/models"
 | 
				
			||||||
@@ -42,7 +43,6 @@ func handleGetAllConversations(r *fastglue.Request) error {
 | 
				
			|||||||
		if conversations[i].SLAPolicyID.Int != 0 {
 | 
							if conversations[i].SLAPolicyID.Int != 0 {
 | 
				
			||||||
			setSLADeadlines(app, &conversations[i])
 | 
								setSLADeadlines(app, &conversations[i])
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		conversations[i].ID = 0
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return r.SendEnvelope(envelope.PageResults{
 | 
						return r.SendEnvelope(envelope.PageResults{
 | 
				
			||||||
@@ -79,7 +79,6 @@ func handleGetAssignedConversations(r *fastglue.Request) error {
 | 
				
			|||||||
		if conversations[i].SLAPolicyID.Int != 0 {
 | 
							if conversations[i].SLAPolicyID.Int != 0 {
 | 
				
			||||||
			setSLADeadlines(app, &conversations[i])
 | 
								setSLADeadlines(app, &conversations[i])
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		conversations[i].ID = 0
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return r.SendEnvelope(envelope.PageResults{
 | 
						return r.SendEnvelope(envelope.PageResults{
 | 
				
			||||||
@@ -116,7 +115,6 @@ func handleGetUnassignedConversations(r *fastglue.Request) error {
 | 
				
			|||||||
		if conversations[i].SLAPolicyID.Int != 0 {
 | 
							if conversations[i].SLAPolicyID.Int != 0 {
 | 
				
			||||||
			setSLADeadlines(app, &conversations[i])
 | 
								setSLADeadlines(app, &conversations[i])
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		conversations[i].ID = 0
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return r.SendEnvelope(envelope.PageResults{
 | 
						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)
 | 
							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 {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -195,7 +193,6 @@ func handleGetViewConversations(r *fastglue.Request) error {
 | 
				
			|||||||
		if conversations[i].SLAPolicyID.Int != 0 {
 | 
							if conversations[i].SLAPolicyID.Int != 0 {
 | 
				
			||||||
			setSLADeadlines(app, &conversations[i])
 | 
								setSLADeadlines(app, &conversations[i])
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		conversations[i].ID = 0
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return r.SendEnvelope(envelope.PageResults{
 | 
						return r.SendEnvelope(envelope.PageResults{
 | 
				
			||||||
@@ -248,7 +245,6 @@ func handleGetTeamUnassignedConversations(r *fastglue.Request) error {
 | 
				
			|||||||
		if conversations[i].SLAPolicyID.Int != 0 {
 | 
							if conversations[i].SLAPolicyID.Int != 0 {
 | 
				
			||||||
			setSLADeadlines(app, &conversations[i])
 | 
								setSLADeadlines(app, &conversations[i])
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		conversations[i].ID = 0
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return r.SendEnvelope(envelope.PageResults{
 | 
						return r.SendEnvelope(envelope.PageResults{
 | 
				
			||||||
@@ -268,7 +264,7 @@ func handleGetConversation(r *fastglue.Request) error {
 | 
				
			|||||||
		auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	user, err := app.user.Get(auser.ID)
 | 
						user, err := app.user.GetAgent(auser.ID)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -284,7 +280,6 @@ func handleGetConversation(r *fastglue.Request) error {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	prev, _ := app.conversation.GetContactConversations(conv.ContactID)
 | 
						prev, _ := app.conversation.GetContactConversations(conv.ContactID)
 | 
				
			||||||
	conv.PreviousConversations = filterCurrentConv(prev, conv.UUID)
 | 
						conv.PreviousConversations = filterCurrentConv(prev, conv.UUID)
 | 
				
			||||||
	conv.ID = 0
 | 
					 | 
				
			||||||
	return r.SendEnvelope(conv)
 | 
						return r.SendEnvelope(conv)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -295,7 +290,7 @@ func handleUpdateConversationAssigneeLastSeen(r *fastglue.Request) error {
 | 
				
			|||||||
		uuid  = r.RequestCtx.UserValue("uuid").(string)
 | 
							uuid  = r.RequestCtx.UserValue("uuid").(string)
 | 
				
			||||||
		auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	user, err := app.user.Get(auser.ID)
 | 
						user, err := app.user.GetAgent(auser.ID)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -316,7 +311,7 @@ func handleGetConversationParticipants(r *fastglue.Request) error {
 | 
				
			|||||||
		uuid  = r.RequestCtx.UserValue("uuid").(string)
 | 
							uuid  = r.RequestCtx.UserValue("uuid").(string)
 | 
				
			||||||
		auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	user, err := app.user.Get(auser.ID)
 | 
						user, err := app.user.GetAgent(auser.ID)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							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)
 | 
							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 {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							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)
 | 
							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 {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -426,7 +421,7 @@ func handleUpdateConversationPriority(r *fastglue.Request) error {
 | 
				
			|||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	user, err := app.user.Get(auser.ID)
 | 
						user, err := app.user.GetAgent(auser.ID)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -471,7 +466,7 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Enforce conversation access.
 | 
						// Enforce conversation access.
 | 
				
			||||||
	user, err := app.user.Get(auser.ID)
 | 
						user, err := app.user.GetAgent(auser.ID)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -528,7 +523,7 @@ func handleUpdateConversationtags(r *fastglue.Request) error {
 | 
				
			|||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	user, err := app.user.Get(auser.ID)
 | 
						user, err := app.user.GetAgent(auser.ID)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -607,7 +602,7 @@ func handleRemoveUserAssignee(r *fastglue.Request) error {
 | 
				
			|||||||
		uuid  = r.RequestCtx.UserValue("uuid").(string)
 | 
							uuid  = r.RequestCtx.UserValue("uuid").(string)
 | 
				
			||||||
		auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	user, err := app.user.Get(auser.ID)
 | 
						user, err := app.user.GetAgent(auser.ID)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -628,7 +623,7 @@ func handleRemoveTeamAssignee(r *fastglue.Request) error {
 | 
				
			|||||||
		uuid  = r.RequestCtx.UserValue("uuid").(string)
 | 
							uuid  = r.RequestCtx.UserValue("uuid").(string)
 | 
				
			||||||
		auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	user, err := app.user.Get(auser.ID)
 | 
						user, err := app.user.GetAgent(auser.ID)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -651,3 +646,99 @@ func filterCurrentConv(convs []cmodels.Conversation, uuid string) []cmodels.Conv
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
	return []cmodels.Conversation{}
 | 
						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)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -63,10 +63,12 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
 | 
				
			|||||||
	g.GET("/api/v1/conversations/{uuid}/messages", perm(handleGetMessages, "messages:read"))
 | 
						g.GET("/api/v1/conversations/{uuid}/messages", perm(handleGetMessages, "messages:read"))
 | 
				
			||||||
	g.POST("/api/v1/conversations/{cuuid}/messages", perm(handleSendMessage, "messages:write"))
 | 
						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.PUT("/api/v1/conversations/{cuuid}/messages/{uuid}/retry", perm(handleRetryMessage, "messages:write"))
 | 
				
			||||||
 | 
						g.POST("/api/v1/conversations", perm(handleCreateConversation, "conversations:write"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Search.
 | 
						// Search.
 | 
				
			||||||
	g.GET("/api/v1/conversations/search", perm(handleSearchConversations, "conversations:read"))
 | 
						g.GET("/api/v1/conversations/search", perm(handleSearchConversations, "conversations:read"))
 | 
				
			||||||
	g.GET("/api/v1/messages/search", perm(handleSearchMessages, "messages:read"))
 | 
						g.GET("/api/v1/messages/search", perm(handleSearchMessages, "messages:read"))
 | 
				
			||||||
 | 
						g.GET("/api/v1/contacts/search", perm(handleSearchContacts, "conversations:write"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Views.
 | 
						// Views.
 | 
				
			||||||
	g.GET("/api/v1/views/me", perm(handleGetUserViews, "view:manage"))
 | 
						g.GET("/api/v1/views/me", perm(handleGetUserViews, "view:manage"))
 | 
				
			||||||
@@ -174,6 +176,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
 | 
				
			|||||||
	// AI completion.
 | 
						// AI completion.
 | 
				
			||||||
	g.GET("/api/v1/ai/prompts", auth(handleGetAIPrompts))
 | 
						g.GET("/api/v1/ai/prompts", auth(handleGetAIPrompts))
 | 
				
			||||||
	g.POST("/api/v1/ai/completion", auth(handleAICompletion))
 | 
						g.POST("/api/v1/ai/completion", auth(handleAICompletion))
 | 
				
			||||||
 | 
						g.PUT("/api/v1/ai/provider", perm(handleUpdateAIProvider, "ai:manage"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// WebSocket.
 | 
						// WebSocket.
 | 
				
			||||||
	g.GET("/ws", auth(func(r *fastglue.Request) error {
 | 
						g.GET("/ws", auth(func(r *fastglue.Request) error {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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.
 | 
						// 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 {
 | 
						if password != "" && !user.IsStrongSystemUserPassword(password) && !schemaInstalled {
 | 
				
			||||||
		log.Fatalf("system user password is not strong, %s", user.SystemUserPasswordHint)
 | 
							log.Fatalf("system user password is not strong, %s", user.SystemUserPasswordHint)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -20,6 +20,10 @@ func handleLogin(r *fastglue.Request) error {
 | 
				
			|||||||
		return sendErrorEnvelope(r, err)
 | 
							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.
 | 
						// Set user availability status to online.
 | 
				
			||||||
	if err := app.user.UpdateAvailability(user.ID, umodels.Online); err != nil {
 | 
						if err := app.user.UpdateAvailability(user.ID, umodels.Online); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -145,7 +145,7 @@ func handleApplyMacro(r *fastglue.Request) error {
 | 
				
			|||||||
		id, _            = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
							id, _            = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
 | 
				
			||||||
		incomingActions  = []autoModels.RuleAction{}
 | 
							incomingActions  = []autoModels.RuleAction{}
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	user, err := app.user.Get(auser.ID)
 | 
						user, err := app.user.GetAgent(auser.ID)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -239,7 +239,7 @@ func setDisplayValues(app *App, actions []autoModels.RuleAction) error {
 | 
				
			|||||||
			return t.Name, nil
 | 
								return t.Name, nil
 | 
				
			||||||
		},
 | 
							},
 | 
				
			||||||
		autoModels.ActionAssignUser: func(id int) (string, error) {
 | 
							autoModels.ActionAssignUser: func(id int) (string, error) {
 | 
				
			||||||
			u, err := app.user.Get(id)
 | 
								u, err := app.user.GetAgent(id)
 | 
				
			||||||
			if err != nil {
 | 
								if err != nil {
 | 
				
			||||||
				app.lo.Warn("user not found for macro action", "user_id", id)
 | 
									app.lo.Warn("user not found for macro action", "user_id", id)
 | 
				
			||||||
				return "", err
 | 
									return "", err
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -250,7 +250,7 @@ func main() {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	// Start the app update checker.
 | 
						// Start the app update checker.
 | 
				
			||||||
	if ko.Bool("app.check_updates") {
 | 
						if ko.Bool("app.check_updates") {
 | 
				
			||||||
		go checkUpdates(versionString, time.Hour*24, app)
 | 
							go checkUpdates(versionString, time.Hour*1, app)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Wait for shutdown signal.
 | 
						// Wait for shutdown signal.
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -150,7 +150,7 @@ func handleServeMedia(r *fastglue.Request) error {
 | 
				
			|||||||
		uuid  = r.RequestCtx.UserValue("uuid").(string)
 | 
							uuid  = r.RequestCtx.UserValue("uuid").(string)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	user, err := app.user.Get(auser.ID)
 | 
						user, err := app.user.GetAgent(auser.ID)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -30,7 +30,7 @@ func handleGetMessages(r *fastglue.Request) error {
 | 
				
			|||||||
		total       = 0
 | 
							total       = 0
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	user, err := app.user.Get(auser.ID)
 | 
						user, err := app.user.GetAgent(auser.ID)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -70,7 +70,7 @@ func handleGetMessage(r *fastglue.Request) error {
 | 
				
			|||||||
		cuuid = r.RequestCtx.UserValue("cuuid").(string)
 | 
							cuuid = r.RequestCtx.UserValue("cuuid").(string)
 | 
				
			||||||
		auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	user, err := app.user.Get(auser.ID)
 | 
						user, err := app.user.GetAgent(auser.ID)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -105,7 +105,7 @@ func handleRetryMessage(r *fastglue.Request) error {
 | 
				
			|||||||
		auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	user, err := app.user.Get(auser.ID)
 | 
						user, err := app.user.GetAgent(auser.ID)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -133,13 +133,13 @@ func handleSendMessage(r *fastglue.Request) error {
 | 
				
			|||||||
		req   = messageReq{}
 | 
							req   = messageReq{}
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	user, err := app.user.Get(auser.ID)
 | 
						user, err := app.user.GetAgent(auser.ID)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Check permission
 | 
						// Check permission
 | 
				
			||||||
	_, err = enforceConversationAccess(app, cuuid, user)
 | 
						conv, err := enforceConversationAccess(app, cuuid, user)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -163,7 +163,7 @@ func handleSendMessage(r *fastglue.Request) error {
 | 
				
			|||||||
			return sendErrorEnvelope(r, err)
 | 
								return sendErrorEnvelope(r, err)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	} else {
 | 
						} 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)
 | 
								return sendErrorEnvelope(r, err)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		// Evaluate automation rules.
 | 
							// Evaluate automation rules.
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -8,6 +8,7 @@ import (
 | 
				
			|||||||
	"github.com/abhinavxd/libredesk/internal/envelope"
 | 
						"github.com/abhinavxd/libredesk/internal/envelope"
 | 
				
			||||||
	"github.com/valyala/fasthttp"
 | 
						"github.com/valyala/fasthttp"
 | 
				
			||||||
	"github.com/zerodha/fastglue"
 | 
						"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
 | 
					// 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.
 | 
							// Try to get user.
 | 
				
			||||||
		user, err := app.user.Get(userSession.ID)
 | 
							user, err := app.user.GetAgent(userSession.ID)
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			return handler(r)
 | 
								return handler(r)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
@@ -53,7 +54,7 @@ func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
 | 
				
			|||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		// Set user in the request context.
 | 
							// Set user in the request context.
 | 
				
			||||||
		user, err := app.user.Get(userSession.ID)
 | 
							user, err := app.user.GetAgent(userSession.ID)
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			return sendErrorEnvelope(r, err)
 | 
								return sendErrorEnvelope(r, err)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
@@ -90,11 +91,19 @@ func perm(handler fastglue.FastRequestHandler, perm string) fastglue.FastRequest
 | 
				
			|||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		// Get user from DB.
 | 
							// Get user from DB.
 | 
				
			||||||
		user, err := app.user.Get(sessUser.ID)
 | 
							user, err := app.user.GetAgent(sessUser.ID)
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			return sendErrorEnvelope(r, err)
 | 
								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.
 | 
							// Split the permission string into object and action and enforce it.
 | 
				
			||||||
		parts := strings.Split(perm, ":")
 | 
							parts := strings.Split(perm, ":")
 | 
				
			||||||
		if len(parts) != 2 {
 | 
							if len(parts) != 2 {
 | 
				
			||||||
@@ -129,9 +138,17 @@ func authPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
 | 
				
			|||||||
		// Validate session.
 | 
							// Validate session.
 | 
				
			||||||
		user, err := app.auth.ValidateSession(r)
 | 
							user, err := app.auth.ValidateSession(r)
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
 | 
								// Session is not valid, destroy it and redirect to login.
 | 
				
			||||||
 | 
								if err != simplesessions.ErrInvalidSession {
 | 
				
			||||||
				app.lo.Error("error validating session", "error", err)
 | 
									app.lo.Error("error validating session", "error", err)
 | 
				
			||||||
			return r.SendErrorEnvelope(http.StatusUnauthorized, "Invalid or expired session", nil, envelope.PermissionError)
 | 
									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 {
 | 
							if user.ID > 0 {
 | 
				
			||||||
			return handler(r)
 | 
								return handler(r)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
@@ -140,7 +157,7 @@ func authPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
 | 
				
			|||||||
		if len(nextURI) == 0 {
 | 
							if len(nextURI) == 0 {
 | 
				
			||||||
			nextURI = r.RequestCtx.RequestURI()
 | 
								nextURI = r.RequestCtx.RequestURI()
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		return r.RedirectURI("/", fasthttp.StatusFound, map[string]interface{}{
 | 
							return r.RedirectURI("/", fasthttp.StatusFound, map[string]any{
 | 
				
			||||||
			"next": string(nextURI),
 | 
								"next": string(nextURI),
 | 
				
			||||||
		}, "")
 | 
							}, "")
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,9 +2,11 @@ package main
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
	"strconv"
 | 
						"strconv"
 | 
				
			||||||
 | 
						"strings"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/envelope"
 | 
						"github.com/abhinavxd/libredesk/internal/envelope"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/oidc/models"
 | 
						"github.com/abhinavxd/libredesk/internal/oidc/models"
 | 
				
			||||||
 | 
						"github.com/abhinavxd/libredesk/internal/stringutil"
 | 
				
			||||||
	"github.com/valyala/fasthttp"
 | 
						"github.com/valyala/fasthttp"
 | 
				
			||||||
	"github.com/zerodha/fastglue"
 | 
						"github.com/zerodha/fastglue"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
@@ -26,6 +28,10 @@ func handleGetAllOIDC(r *fastglue.Request) error {
 | 
				
			|||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							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)
 | 
						return r.SendEnvelope(out)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -44,3 +44,19 @@ func handleSearchMessages(r *fastglue.Request) error {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(messages)
 | 
						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)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -2,6 +2,7 @@ package main
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
	"encoding/json"
 | 
						"encoding/json"
 | 
				
			||||||
 | 
						"net/mail"
 | 
				
			||||||
	"strings"
 | 
						"strings"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/envelope"
 | 
						"github.com/abhinavxd/libredesk/internal/envelope"
 | 
				
			||||||
@@ -20,14 +21,16 @@ func handleGetGeneralSettings(r *fastglue.Request) error {
 | 
				
			|||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							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{}
 | 
						var settings map[string]interface{}
 | 
				
			||||||
	if err := json.Unmarshal(out, &settings); err != nil {
 | 
						if err := json.Unmarshal(out, &settings); err != nil {
 | 
				
			||||||
		app.lo.Error("error unmarshalling settings", "err", err)
 | 
							app.lo.Error("error unmarshalling settings", "err", err)
 | 
				
			||||||
		return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, "Error fetching settings", nil))
 | 
							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
 | 
						settings["app.update"] = app.update
 | 
				
			||||||
 | 
						// Set app version.
 | 
				
			||||||
 | 
						settings["app.version"] = versionString
 | 
				
			||||||
	return r.SendEnvelope(settings)
 | 
						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))
 | 
							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 == "" {
 | 
						if req.Password == "" {
 | 
				
			||||||
		req.Password = cur.Password
 | 
							req.Password = cur.Password
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -105,5 +113,7 @@ func handleUpdateEmailNotificationSettings(r *fastglue.Request) error {
 | 
				
			|||||||
	if err := app.setting.Update(req); err != nil {
 | 
						if err := app.setting.Update(req); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							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.")
 | 
						return r.SendEnvelope("Settings updated successfully, Please restart the app for changes to take effect.")
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -83,9 +83,9 @@ func checkUpdates(curVersion string, interval time.Duration, app *App) {
 | 
				
			|||||||
		app.Unlock()
 | 
							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.
 | 
						// update checks entirely and not make a request to upstream.
 | 
				
			||||||
	time.Sleep(time.Minute * 15)
 | 
						time.Sleep(time.Minute * 5)
 | 
				
			||||||
	fnCheck()
 | 
						fnCheck()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Thereafter, check every $interval.
 | 
						// Thereafter, check every $interval.
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -31,6 +31,7 @@ type migFunc struct {
 | 
				
			|||||||
// The functions are named as: v0.7.0 => migrations.V0_7_0() and are idempotent.
 | 
					// 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.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
 | 
					// upgrade upgrades the database to the current version by running SQL migration files
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										44
									
								
								cmd/users.go
									
									
									
									
									
								
							
							
						
						
									
										44
									
								
								cmd/users.go
									
									
									
									
									
								
							@@ -57,7 +57,7 @@ func handleGetUser(r *fastglue.Request) error {
 | 
				
			|||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
 | 
							return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
 | 
				
			||||||
			"Invalid user `id`.", nil, envelope.InputError)
 | 
								"Invalid user `id`.", nil, envelope.InputError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	user, err := app.user.Get(id)
 | 
						user, err := app.user.GetAgent(id)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -83,7 +83,7 @@ func handleGetCurrentUserTeams(r *fastglue.Request) error {
 | 
				
			|||||||
		app   = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
		auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	user, err := app.user.Get(auser.ID)
 | 
						user, err := app.user.GetAgent(auser.ID)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -101,13 +101,7 @@ func handleUpdateCurrentUser(r *fastglue.Request) error {
 | 
				
			|||||||
		app   = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
		auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
							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)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	// Get current user.
 | 
					 | 
				
			||||||
	currentUser, err := app.user.Get(user.ID)
 | 
					 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -165,8 +159,8 @@ func handleUpdateCurrentUser(r *fastglue.Request) error {
 | 
				
			|||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		// Delete current avatar.
 | 
							// Delete current avatar.
 | 
				
			||||||
		if currentUser.AvatarURL.Valid {
 | 
							if user.AvatarURL.Valid {
 | 
				
			||||||
			fileName := filepath.Base(currentUser.AvatarURL.String)
 | 
								fileName := filepath.Base(user.AvatarURL.String)
 | 
				
			||||||
			app.media.Delete(fileName)
 | 
								app.media.Delete(fileName)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -223,9 +217,9 @@ func handleCreateUser(r *fastglue.Request) error {
 | 
				
			|||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		// Render template and send email.
 | 
							// 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,
 | 
								"ResetToken": resetToken,
 | 
				
			||||||
			"Email":      user.Email,
 | 
								"Email":      user.Email.String,
 | 
				
			||||||
		})
 | 
							})
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			app.lo.Error("error rendering template", "error", err)
 | 
								app.lo.Error("error rendering template", "error", err)
 | 
				
			||||||
@@ -316,7 +310,7 @@ func handleGetCurrentUser(r *fastglue.Request) error {
 | 
				
			|||||||
		app   = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
		auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	u, err := app.user.Get(auser.ID)
 | 
						u, err := app.user.GetAgent(auser.ID)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -331,14 +325,14 @@ func handleDeleteAvatar(r *fastglue.Request) error {
 | 
				
			|||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Get user
 | 
						// Get user
 | 
				
			||||||
	user, err := app.user.Get(auser.ID)
 | 
						user, err := app.user.GetAgent(auser.ID)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Valid str?
 | 
						// Valid str?
 | 
				
			||||||
	if user.AvatarURL.String == "" {
 | 
						if user.AvatarURL.String == "" {
 | 
				
			||||||
		return r.SendEnvelope(true)
 | 
							return r.SendEnvelope("Avatar deleted successfully.")
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	fileName := filepath.Base(user.AvatarURL.String)
 | 
						fileName := filepath.Base(user.AvatarURL.String)
 | 
				
			||||||
@@ -347,8 +341,8 @@ func handleDeleteAvatar(r *fastglue.Request) error {
 | 
				
			|||||||
	if err := app.media.Delete(fileName); err != nil {
 | 
						if err := app.media.Delete(fileName); err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							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 sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope("Avatar deleted successfully.")
 | 
						return r.SendEnvelope("Avatar deleted successfully.")
 | 
				
			||||||
@@ -363,16 +357,17 @@ func handleResetPassword(r *fastglue.Request) error {
 | 
				
			|||||||
		email     = string(p.Peek("email"))
 | 
							email     = string(p.Peek("email"))
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	if ok && auser.ID > 0 {
 | 
						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 == "" {
 | 
						if email == "" {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty `email`", nil, envelope.InputError)
 | 
							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 {
 | 
						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)
 | 
						token, err := app.user.SetResetPasswordToken(user.ID)
 | 
				
			||||||
@@ -381,8 +376,7 @@ func handleResetPassword(r *fastglue.Request) error {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Send email.
 | 
						// Send email.
 | 
				
			||||||
	content, err := app.tmpl.RenderTemplate(tmpl.TmplResetPassword,
 | 
						content, err := app.tmpl.RenderInMemoryTemplate(tmpl.TmplResetPassword, map[string]string{
 | 
				
			||||||
		map[string]string{
 | 
					 | 
				
			||||||
		"ResetToken": token,
 | 
							"ResetToken": token,
 | 
				
			||||||
	})
 | 
						})
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
@@ -396,8 +390,8 @@ func handleResetPassword(r *fastglue.Request) error {
 | 
				
			|||||||
		Content:  content,
 | 
							Content:  content,
 | 
				
			||||||
		Provider: notifier.ProviderEmail,
 | 
							Provider: notifier.ProviderEmail,
 | 
				
			||||||
	}); err != nil {
 | 
						}); err != nil {
 | 
				
			||||||
		app.lo.Error("error sending notification message", "error", err)
 | 
							app.lo.Error("error sending password reset email", "error", err)
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error sending notification message", nil, envelope.GeneralError)
 | 
							return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error sending password reset email", nil, envelope.GeneralError)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return r.SendEnvelope("Reset password email sent successfully.")
 | 
						return r.SendEnvelope("Reset password email sent successfully.")
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										10
									
								
								cmd/views.go
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								cmd/views.go
									
									
									
									
									
								
							@@ -16,7 +16,7 @@ func handleGetUserViews(r *fastglue.Request) error {
 | 
				
			|||||||
		app   = r.Context.(*App)
 | 
							app   = r.Context.(*App)
 | 
				
			||||||
		auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
							auser = r.RequestCtx.UserValue("user").(amodels.User)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	user, err := app.user.Get(auser.ID)
 | 
						user, err := app.user.GetAgent(auser.ID)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -37,7 +37,7 @@ func handleCreateUserView(r *fastglue.Request) error {
 | 
				
			|||||||
	if err := r.Decode(&view, "json"); err != nil {
 | 
						if err := r.Decode(&view, "json"); err != nil {
 | 
				
			||||||
		return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
 | 
							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 {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -46,7 +46,7 @@ func handleCreateUserView(r *fastglue.Request) error {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if string(view.Filters) == "" {
 | 
						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 {
 | 
						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)
 | 
							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 {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							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)
 | 
							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 {
 | 
						if err != nil {
 | 
				
			||||||
		return sendErrorEnvelope(r, err)
 | 
							return sendErrorEnvelope(r, err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -36,8 +36,9 @@ expiry = "6h"
 | 
				
			|||||||
# If using docker compose, use the service name as the host. e.g. db
 | 
					# If using docker compose, use the service name as the host. e.g. db
 | 
				
			||||||
host = "127.0.0.1"
 | 
					host = "127.0.0.1"
 | 
				
			||||||
port = 5432
 | 
					port = 5432
 | 
				
			||||||
user = "postgres"
 | 
					# Update the following values with your database credentials.
 | 
				
			||||||
password = "postgres"
 | 
					user = "libredesk"
 | 
				
			||||||
 | 
					password = "libredesk"
 | 
				
			||||||
database = "libredesk"
 | 
					database = "libredesk"
 | 
				
			||||||
ssl_mode = "disable"
 | 
					ssl_mode = "disable"
 | 
				
			||||||
max_open = 30
 | 
					max_open = 30
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -21,7 +21,7 @@ git clone https://github.com/abhinavxd/libredesk.git
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
### Running the Dev Environment
 | 
					### 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.
 | 
					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.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
---
 | 
					---
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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;">
 | 
					<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">
 | 
					    <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>
 | 
					    </a>
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -18,6 +18,7 @@
 | 
				
			|||||||
    "@formkit/auto-animate": "^0.8.2",
 | 
					    "@formkit/auto-animate": "^0.8.2",
 | 
				
			||||||
    "@internationalized/date": "^3.5.5",
 | 
					    "@internationalized/date": "^3.5.5",
 | 
				
			||||||
    "@radix-icons/vue": "^1.0.0",
 | 
					    "@radix-icons/vue": "^1.0.0",
 | 
				
			||||||
 | 
					    "@tailwindcss/typography": "^0.5.16",
 | 
				
			||||||
    "@tanstack/vue-table": "^8.19.2",
 | 
					    "@tanstack/vue-table": "^8.19.2",
 | 
				
			||||||
    "@tiptap/extension-image": "^2.5.9",
 | 
					    "@tiptap/extension-image": "^2.5.9",
 | 
				
			||||||
    "@tiptap/extension-link": "^2.9.1",
 | 
					    "@tiptap/extension-link": "^2.9.1",
 | 
				
			||||||
@@ -28,7 +29,6 @@
 | 
				
			|||||||
    "@unovis/ts": "^1.4.4",
 | 
					    "@unovis/ts": "^1.4.4",
 | 
				
			||||||
    "@unovis/vue": "^1.4.4",
 | 
					    "@unovis/vue": "^1.4.4",
 | 
				
			||||||
    "@vee-validate/zod": "^4.13.2",
 | 
					    "@vee-validate/zod": "^4.13.2",
 | 
				
			||||||
    "@vueup/vue-quill": "^1.2.0",
 | 
					 | 
				
			||||||
    "@vueuse/core": "^12.4.0",
 | 
					    "@vueuse/core": "^12.4.0",
 | 
				
			||||||
    "axios": "^1.7.9",
 | 
					    "axios": "^1.7.9",
 | 
				
			||||||
    "class-variance-authority": "^0.7.0",
 | 
					    "class-variance-authority": "^0.7.0",
 | 
				
			||||||
@@ -43,6 +43,7 @@
 | 
				
			|||||||
    "tailwind-merge": "^2.3.0",
 | 
					    "tailwind-merge": "^2.3.0",
 | 
				
			||||||
    "vee-validate": "^4.13.2",
 | 
					    "vee-validate": "^4.13.2",
 | 
				
			||||||
    "vue": "^3.4.37",
 | 
					    "vue": "^3.4.37",
 | 
				
			||||||
 | 
					    "vue-dompurify-html": "^5.2.0",
 | 
				
			||||||
    "vue-i18n": "9",
 | 
					    "vue-i18n": "9",
 | 
				
			||||||
    "vue-letter": "^0.2.0",
 | 
					    "vue-letter": "^0.2.0",
 | 
				
			||||||
    "vue-picture-cropper": "^0.7.0",
 | 
					    "vue-picture-cropper": "^0.7.0",
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										287
									
								
								frontend/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										287
									
								
								frontend/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							@@ -17,6 +17,9 @@ importers:
 | 
				
			|||||||
      '@radix-icons/vue':
 | 
					      '@radix-icons/vue':
 | 
				
			||||||
        specifier: ^1.0.0
 | 
					        specifier: ^1.0.0
 | 
				
			||||||
        version: 1.0.0(vue@3.5.13(typescript@5.7.3))
 | 
					        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':
 | 
					      '@tanstack/vue-table':
 | 
				
			||||||
        specifier: ^8.19.2
 | 
					        specifier: ^8.19.2
 | 
				
			||||||
        version: 8.20.5(vue@3.5.13(typescript@5.7.3))
 | 
					        version: 8.20.5(vue@3.5.13(typescript@5.7.3))
 | 
				
			||||||
@@ -47,9 +50,6 @@ importers:
 | 
				
			|||||||
      '@vee-validate/zod':
 | 
					      '@vee-validate/zod':
 | 
				
			||||||
        specifier: ^4.13.2
 | 
					        specifier: ^4.13.2
 | 
				
			||||||
        version: 4.15.0(vue@3.5.13(typescript@5.7.3))(zod@3.24.1)
 | 
					        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':
 | 
					      '@vueuse/core':
 | 
				
			||||||
        specifier: ^12.4.0
 | 
					        specifier: ^12.4.0
 | 
				
			||||||
        version: 12.4.0(typescript@5.7.3)
 | 
					        version: 12.4.0(typescript@5.7.3)
 | 
				
			||||||
@@ -92,6 +92,9 @@ importers:
 | 
				
			|||||||
      vue:
 | 
					      vue:
 | 
				
			||||||
        specifier: ^3.4.37
 | 
					        specifier: ^3.4.37
 | 
				
			||||||
        version: 3.5.13(typescript@5.7.3)
 | 
					        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:
 | 
					      vue-i18n:
 | 
				
			||||||
        specifier: '9'
 | 
					        specifier: '9'
 | 
				
			||||||
        version: 9.14.2(vue@3.5.13(typescript@5.7.3))
 | 
					        version: 9.14.2(vue@3.5.13(typescript@5.7.3))
 | 
				
			||||||
@@ -737,6 +740,11 @@ packages:
 | 
				
			|||||||
  '@swc/helpers@0.5.15':
 | 
					  '@swc/helpers@0.5.15':
 | 
				
			||||||
    resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
 | 
					    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':
 | 
					  '@tanstack/table-core@8.20.5':
 | 
				
			||||||
    resolution: {integrity: sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==}
 | 
					    resolution: {integrity: sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==}
 | 
				
			||||||
    engines: {node: '>=12'}
 | 
					    engines: {node: '>=12'}
 | 
				
			||||||
@@ -815,8 +823,8 @@ packages:
 | 
				
			|||||||
      '@tiptap/core': ^2.7.0
 | 
					      '@tiptap/core': ^2.7.0
 | 
				
			||||||
      '@tiptap/pm': ^2.7.0
 | 
					      '@tiptap/pm': ^2.7.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  '@tiptap/extension-hard-break@2.11.2':
 | 
					  '@tiptap/extension-hard-break@2.11.5':
 | 
				
			||||||
    resolution: {integrity: sha512-FNcXemfuwkiP4drZ9m90BC6GD4nyikfYHYEUyYuVd74Mm6w5vXpueWXus3mUcdT78xTs1XpQVibDorilLu7X8w==}
 | 
					    resolution: {integrity: sha512-q9doeN+Yg9F5QNTG8pZGYfNye3tmntOwch683v0CCVCI4ldKaLZ0jG3NbBTq+mosHYdgOH2rNbIORlRRsQ+iYQ==}
 | 
				
			||||||
    peerDependencies:
 | 
					    peerDependencies:
 | 
				
			||||||
      '@tiptap/core': ^2.7.0
 | 
					      '@tiptap/core': ^2.7.0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -1076,6 +1084,9 @@ packages:
 | 
				
			|||||||
  '@types/topojson@3.2.6':
 | 
					  '@types/topojson@3.2.6':
 | 
				
			||||||
    resolution: {integrity: sha512-ppfdlxjxofWJ66XdLgIlER/85RvpGyfOf8jrWf+3kVIjEatFxEZYD/Ea83jO672Xu1HRzd/ghwlbcZIUNHTskw==}
 | 
					    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':
 | 
					  '@types/web-bluetooth@0.0.20':
 | 
				
			||||||
    resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
 | 
					    resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -1159,11 +1170,6 @@ packages:
 | 
				
			|||||||
  '@vue/shared@3.5.13':
 | 
					  '@vue/shared@3.5.13':
 | 
				
			||||||
    resolution: {integrity: sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==}
 | 
					    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':
 | 
					  '@vueuse/core@10.11.1':
 | 
				
			||||||
    resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==}
 | 
					    resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -1346,10 +1352,6 @@ packages:
 | 
				
			|||||||
    resolution: {integrity: sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==}
 | 
					    resolution: {integrity: sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==}
 | 
				
			||||||
    engines: {node: '>= 0.4'}
 | 
					    engines: {node: '>= 0.4'}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  call-bind@1.0.8:
 | 
					 | 
				
			||||||
    resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==}
 | 
					 | 
				
			||||||
    engines: {node: '>= 0.4'}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  call-bound@1.0.3:
 | 
					  call-bound@1.0.3:
 | 
				
			||||||
    resolution: {integrity: sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==}
 | 
					    resolution: {integrity: sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==}
 | 
				
			||||||
    engines: {node: '>= 0.4'}
 | 
					    engines: {node: '>= 0.4'}
 | 
				
			||||||
@@ -1407,10 +1409,6 @@ packages:
 | 
				
			|||||||
    resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==}
 | 
					    resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==}
 | 
				
			||||||
    engines: {node: '>=8'}
 | 
					    engines: {node: '>=8'}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  clone@2.1.2:
 | 
					 | 
				
			||||||
    resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==}
 | 
					 | 
				
			||||||
    engines: {node: '>=0.8'}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  clsx@2.1.1:
 | 
					  clsx@2.1.1:
 | 
				
			||||||
    resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
 | 
					    resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
 | 
				
			||||||
    engines: {node: '>=6'}
 | 
					    engines: {node: '>=6'}
 | 
				
			||||||
@@ -1678,21 +1676,9 @@ packages:
 | 
				
			|||||||
    resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
 | 
					    resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
 | 
				
			||||||
    engines: {node: '>=0.10'}
 | 
					    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:
 | 
					  deep-is@0.1.4:
 | 
				
			||||||
    resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
 | 
					    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:
 | 
					  defu@6.1.4:
 | 
				
			||||||
    resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
 | 
					    resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -1718,6 +1704,9 @@ packages:
 | 
				
			|||||||
    resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
 | 
					    resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
 | 
				
			||||||
    engines: {node: '>=6.0.0'}
 | 
					    engines: {node: '>=6.0.0'}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  dompurify@3.2.4:
 | 
				
			||||||
 | 
					    resolution: {integrity: sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  dunder-proto@1.0.1:
 | 
					  dunder-proto@1.0.1:
 | 
				
			||||||
    resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
 | 
					    resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
 | 
				
			||||||
    engines: {node: '>= 0.4'}
 | 
					    engines: {node: '>= 0.4'}
 | 
				
			||||||
@@ -1863,9 +1852,6 @@ packages:
 | 
				
			|||||||
  eventemitter2@6.4.7:
 | 
					  eventemitter2@6.4.7:
 | 
				
			||||||
    resolution: {integrity: sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==}
 | 
					    resolution: {integrity: sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  eventemitter3@2.0.3:
 | 
					 | 
				
			||||||
    resolution: {integrity: sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  execa@4.1.0:
 | 
					  execa@4.1.0:
 | 
				
			||||||
    resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==}
 | 
					    resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==}
 | 
				
			||||||
    engines: {node: '>=10'}
 | 
					    engines: {node: '>=10'}
 | 
				
			||||||
@@ -1893,12 +1879,6 @@ packages:
 | 
				
			|||||||
  fast-deep-equal@3.1.3:
 | 
					  fast-deep-equal@3.1.3:
 | 
				
			||||||
    resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
 | 
					    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:
 | 
					  fast-diff@1.3.0:
 | 
				
			||||||
    resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==}
 | 
					    resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -1985,9 +1965,6 @@ packages:
 | 
				
			|||||||
  function-bind@1.1.2:
 | 
					  function-bind@1.1.2:
 | 
				
			||||||
    resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
 | 
					    resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  functions-have-names@1.2.3:
 | 
					 | 
				
			||||||
    resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  geojson-vt@3.2.1:
 | 
					  geojson-vt@3.2.1:
 | 
				
			||||||
    resolution: {integrity: sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==}
 | 
					    resolution: {integrity: sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -2066,17 +2043,10 @@ packages:
 | 
				
			|||||||
    resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
 | 
					    resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
 | 
				
			||||||
    engines: {node: '>=8'}
 | 
					    engines: {node: '>=8'}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  has-property-descriptors@1.0.2:
 | 
					 | 
				
			||||||
    resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  has-symbols@1.1.0:
 | 
					  has-symbols@1.1.0:
 | 
				
			||||||
    resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
 | 
					    resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
 | 
				
			||||||
    engines: {node: '>= 0.4'}
 | 
					    engines: {node: '>= 0.4'}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  has-tostringtag@1.0.2:
 | 
					 | 
				
			||||||
    resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
 | 
					 | 
				
			||||||
    engines: {node: '>= 0.4'}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  hasown@2.0.2:
 | 
					  hasown@2.0.2:
 | 
				
			||||||
    resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
 | 
					    resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
 | 
				
			||||||
    engines: {node: '>= 0.4'}
 | 
					    engines: {node: '>= 0.4'}
 | 
				
			||||||
@@ -2146,10 +2116,6 @@ packages:
 | 
				
			|||||||
    resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
 | 
					    resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
 | 
				
			||||||
    engines: {node: '>=12'}
 | 
					    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:
 | 
					  is-arrayish@0.2.1:
 | 
				
			||||||
    resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
 | 
					    resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -2161,10 +2127,6 @@ packages:
 | 
				
			|||||||
    resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
 | 
					    resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
 | 
				
			||||||
    engines: {node: '>= 0.4'}
 | 
					    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:
 | 
					  is-extglob@2.1.1:
 | 
				
			||||||
    resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
 | 
					    resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
 | 
				
			||||||
    engines: {node: '>=0.10.0'}
 | 
					    engines: {node: '>=0.10.0'}
 | 
				
			||||||
@@ -2189,10 +2151,6 @@ packages:
 | 
				
			|||||||
    resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==}
 | 
					    resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==}
 | 
				
			||||||
    engines: {node: '>=8'}
 | 
					    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:
 | 
					  is-stream@2.0.1:
 | 
				
			||||||
    resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
 | 
					    resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
 | 
				
			||||||
    engines: {node: '>=8'}
 | 
					    engines: {node: '>=8'}
 | 
				
			||||||
@@ -2317,11 +2275,11 @@ packages:
 | 
				
			|||||||
  lodash-es@4.17.21:
 | 
					  lodash-es@4.17.21:
 | 
				
			||||||
    resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
 | 
					    resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  lodash.clonedeep@4.5.0:
 | 
					  lodash.castarray@4.4.0:
 | 
				
			||||||
    resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==}
 | 
					    resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  lodash.isequal@4.5.0:
 | 
					  lodash.isplainobject@4.0.6:
 | 
				
			||||||
    resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
 | 
					    resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  lodash.merge@4.6.2:
 | 
					  lodash.merge@4.6.2:
 | 
				
			||||||
    resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
 | 
					    resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
 | 
				
			||||||
@@ -2468,14 +2426,6 @@ packages:
 | 
				
			|||||||
    resolution: {integrity: sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==}
 | 
					    resolution: {integrity: sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==}
 | 
				
			||||||
    engines: {node: '>= 0.4'}
 | 
					    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:
 | 
					  once@1.4.0:
 | 
				
			||||||
    resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
 | 
					    resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -2508,9 +2458,6 @@ packages:
 | 
				
			|||||||
  package-json-from-dist@1.0.1:
 | 
					  package-json-from-dist@1.0.1:
 | 
				
			||||||
    resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
 | 
					    resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  parchment@1.1.4:
 | 
					 | 
				
			||||||
    resolution: {integrity: sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  parent-module@1.0.1:
 | 
					  parent-module@1.0.1:
 | 
				
			||||||
    resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
 | 
					    resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
 | 
				
			||||||
    engines: {node: '>=6'}
 | 
					    engines: {node: '>=6'}
 | 
				
			||||||
@@ -2615,6 +2562,10 @@ packages:
 | 
				
			|||||||
    peerDependencies:
 | 
					    peerDependencies:
 | 
				
			||||||
      postcss: ^8.2.14
 | 
					      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:
 | 
					  postcss-selector-parser@6.1.2:
 | 
				
			||||||
    resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
 | 
					    resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
 | 
				
			||||||
    engines: {node: '>=4'}
 | 
					    engines: {node: '>=4'}
 | 
				
			||||||
@@ -2747,16 +2698,6 @@ packages:
 | 
				
			|||||||
  quickselect@2.0.0:
 | 
					  quickselect@2.0.0:
 | 
				
			||||||
    resolution: {integrity: sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==}
 | 
					    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:
 | 
					  radix-vue@1.9.12:
 | 
				
			||||||
    resolution: {integrity: sha512-zkr66Jqxbej4+oR6O/pZRzyM/VZi66ndbyIBZQjJKAXa1lIoYReZJse6W1EEDZKXknD7rXhpS+jM9Sr23lIqfg==}
 | 
					    resolution: {integrity: sha512-zkr66Jqxbej4+oR6O/pZRzyM/VZi66ndbyIBZQjJKAXa1lIoYReZJse6W1EEDZKXknD7rXhpS+jM9Sr23lIqfg==}
 | 
				
			||||||
    peerDependencies:
 | 
					    peerDependencies:
 | 
				
			||||||
@@ -2776,10 +2717,6 @@ packages:
 | 
				
			|||||||
  regenerator-runtime@0.14.1:
 | 
					  regenerator-runtime@0.14.1:
 | 
				
			||||||
    resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==}
 | 
					    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:
 | 
					  request-progress@3.0.0:
 | 
				
			||||||
    resolution: {integrity: sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==}
 | 
					    resolution: {integrity: sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -2850,14 +2787,6 @@ packages:
 | 
				
			|||||||
    engines: {node: '>=10'}
 | 
					    engines: {node: '>=10'}
 | 
				
			||||||
    hasBin: true
 | 
					    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:
 | 
					  shebang-command@2.0.0:
 | 
				
			||||||
    resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
 | 
					    resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
 | 
				
			||||||
    engines: {node: '>=8'}
 | 
					    engines: {node: '>=8'}
 | 
				
			||||||
@@ -3191,6 +3120,11 @@ packages:
 | 
				
			|||||||
      '@vue/composition-api':
 | 
					      '@vue/composition-api':
 | 
				
			||||||
        optional: true
 | 
					        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:
 | 
					  vue-eslint-parser@9.4.3:
 | 
				
			||||||
    resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==}
 | 
					    resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==}
 | 
				
			||||||
    engines: {node: ^14.17.0 || >=16.0.0}
 | 
					    engines: {node: ^14.17.0 || >=16.0.0}
 | 
				
			||||||
@@ -3802,6 +3736,14 @@ snapshots:
 | 
				
			|||||||
    dependencies:
 | 
					    dependencies:
 | 
				
			||||||
      tslib: 2.8.1
 | 
					      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/table-core@8.20.5': {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  '@tanstack/virtual-core@3.11.2': {}
 | 
					  '@tanstack/virtual-core@3.11.2': {}
 | 
				
			||||||
@@ -3867,7 +3809,7 @@ snapshots:
 | 
				
			|||||||
      '@tiptap/core': 2.11.2(@tiptap/pm@2.11.2)
 | 
					      '@tiptap/core': 2.11.2(@tiptap/pm@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:
 | 
					    dependencies:
 | 
				
			||||||
      '@tiptap/core': 2.11.2(@tiptap/pm@2.11.2)
 | 
					      '@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-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-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-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-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-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)
 | 
					      '@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-simplify': 3.0.3
 | 
				
			||||||
      '@types/topojson-specification': 1.0.5
 | 
					      '@types/topojson-specification': 1.0.5
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  '@types/trusted-types@2.0.7':
 | 
				
			||||||
 | 
					    optional: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  '@types/web-bluetooth@0.0.20': {}
 | 
					  '@types/web-bluetooth@0.0.20': {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  '@types/yauzl@2.10.3':
 | 
					  '@types/yauzl@2.10.3':
 | 
				
			||||||
@@ -4343,12 +4288,6 @@ snapshots:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  '@vue/shared@3.5.13': {}
 | 
					  '@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))':
 | 
					  '@vueuse/core@10.11.1(vue@3.5.13(typescript@5.7.3))':
 | 
				
			||||||
    dependencies:
 | 
					    dependencies:
 | 
				
			||||||
      '@types/web-bluetooth': 0.0.20
 | 
					      '@types/web-bluetooth': 0.0.20
 | 
				
			||||||
@@ -4537,13 +4476,6 @@ snapshots:
 | 
				
			|||||||
      es-errors: 1.3.0
 | 
					      es-errors: 1.3.0
 | 
				
			||||||
      function-bind: 1.1.2
 | 
					      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:
 | 
					  call-bound@1.0.3:
 | 
				
			||||||
    dependencies:
 | 
					    dependencies:
 | 
				
			||||||
      call-bind-apply-helpers: 1.0.1
 | 
					      call-bind-apply-helpers: 1.0.1
 | 
				
			||||||
@@ -4603,8 +4535,6 @@ snapshots:
 | 
				
			|||||||
      slice-ansi: 3.0.0
 | 
					      slice-ansi: 3.0.0
 | 
				
			||||||
      string-width: 4.2.3
 | 
					      string-width: 4.2.3
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  clone@2.1.2: {}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  clsx@2.1.1: {}
 | 
					  clsx@2.1.1: {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  codeflask@1.4.1:
 | 
					  codeflask@1.4.1:
 | 
				
			||||||
@@ -4921,29 +4851,8 @@ snapshots:
 | 
				
			|||||||
  decode-uri-component@0.2.2:
 | 
					  decode-uri-component@0.2.2:
 | 
				
			||||||
    optional: true
 | 
					    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: {}
 | 
					  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: {}
 | 
					  defu@6.1.4: {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  delaunator@5.0.1:
 | 
					  delaunator@5.0.1:
 | 
				
			||||||
@@ -4963,6 +4872,10 @@ snapshots:
 | 
				
			|||||||
    dependencies:
 | 
					    dependencies:
 | 
				
			||||||
      esutils: 2.0.3
 | 
					      esutils: 2.0.3
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  dompurify@3.2.4:
 | 
				
			||||||
 | 
					    optionalDependencies:
 | 
				
			||||||
 | 
					      '@types/trusted-types': 2.0.7
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  dunder-proto@1.0.1:
 | 
					  dunder-proto@1.0.1:
 | 
				
			||||||
    dependencies:
 | 
					    dependencies:
 | 
				
			||||||
      call-bind-apply-helpers: 1.0.1
 | 
					      call-bind-apply-helpers: 1.0.1
 | 
				
			||||||
@@ -5157,8 +5070,6 @@ snapshots:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  eventemitter2@6.4.7: {}
 | 
					  eventemitter2@6.4.7: {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  eventemitter3@2.0.3: {}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  execa@4.1.0:
 | 
					  execa@4.1.0:
 | 
				
			||||||
    dependencies:
 | 
					    dependencies:
 | 
				
			||||||
      cross-spawn: 7.0.6
 | 
					      cross-spawn: 7.0.6
 | 
				
			||||||
@@ -5203,10 +5114,6 @@ snapshots:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  fast-deep-equal@3.1.3: {}
 | 
					  fast-deep-equal@3.1.3: {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  fast-diff@1.1.2: {}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  fast-diff@1.2.0: {}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  fast-diff@1.3.0: {}
 | 
					  fast-diff@1.3.0: {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  fast-glob@3.3.3:
 | 
					  fast-glob@3.3.3:
 | 
				
			||||||
@@ -5291,8 +5198,6 @@ snapshots:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  function-bind@1.1.2: {}
 | 
					  function-bind@1.1.2: {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  functions-have-names@1.2.3: {}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  geojson-vt@3.2.1: {}
 | 
					  geojson-vt@3.2.1: {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  geojson@0.5.0: {}
 | 
					  geojson@0.5.0: {}
 | 
				
			||||||
@@ -5381,16 +5286,8 @@ snapshots:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  has-flag@4.0.0: {}
 | 
					  has-flag@4.0.0: {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  has-property-descriptors@1.0.2:
 | 
					 | 
				
			||||||
    dependencies:
 | 
					 | 
				
			||||||
      es-define-property: 1.0.1
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  has-symbols@1.1.0: {}
 | 
					  has-symbols@1.1.0: {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  has-tostringtag@1.0.2:
 | 
					 | 
				
			||||||
    dependencies:
 | 
					 | 
				
			||||||
      has-symbols: 1.1.0
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  hasown@2.0.2:
 | 
					  hasown@2.0.2:
 | 
				
			||||||
    dependencies:
 | 
					    dependencies:
 | 
				
			||||||
      function-bind: 1.1.2
 | 
					      function-bind: 1.1.2
 | 
				
			||||||
@@ -5443,11 +5340,6 @@ snapshots:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  internmap@2.0.3: {}
 | 
					  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-arrayish@0.2.1: {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  is-binary-path@2.1.0:
 | 
					  is-binary-path@2.1.0:
 | 
				
			||||||
@@ -5458,11 +5350,6 @@ snapshots:
 | 
				
			|||||||
    dependencies:
 | 
					    dependencies:
 | 
				
			||||||
      hasown: 2.0.2
 | 
					      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-extglob@2.1.1: {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  is-fullwidth-code-point@3.0.0: {}
 | 
					  is-fullwidth-code-point@3.0.0: {}
 | 
				
			||||||
@@ -5480,13 +5367,6 @@ snapshots:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  is-path-inside@3.0.3: {}
 | 
					  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-stream@2.0.1: {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  is-typedarray@1.0.0: {}
 | 
					  is-typedarray@1.0.0: {}
 | 
				
			||||||
@@ -5598,9 +5478,9 @@ snapshots:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  lodash-es@4.17.21: {}
 | 
					  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: {}
 | 
					  lodash.merge@4.6.2: {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -5744,13 +5624,6 @@ snapshots:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  object-inspect@1.13.3: {}
 | 
					  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:
 | 
					  once@1.4.0:
 | 
				
			||||||
    dependencies:
 | 
					    dependencies:
 | 
				
			||||||
      wrappy: 1.0.2
 | 
					      wrappy: 1.0.2
 | 
				
			||||||
@@ -5786,8 +5659,6 @@ snapshots:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  package-json-from-dist@1.0.1: {}
 | 
					  package-json-from-dist@1.0.1: {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  parchment@1.1.4: {}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  parent-module@1.0.1:
 | 
					  parent-module@1.0.1:
 | 
				
			||||||
    dependencies:
 | 
					    dependencies:
 | 
				
			||||||
      callsites: 3.1.0
 | 
					      callsites: 3.1.0
 | 
				
			||||||
@@ -5873,6 +5744,11 @@ snapshots:
 | 
				
			|||||||
      postcss: 8.4.49
 | 
					      postcss: 8.4.49
 | 
				
			||||||
      postcss-selector-parser: 6.1.2
 | 
					      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:
 | 
					  postcss-selector-parser@6.1.2:
 | 
				
			||||||
    dependencies:
 | 
					    dependencies:
 | 
				
			||||||
      cssesc: 3.0.0
 | 
					      cssesc: 3.0.0
 | 
				
			||||||
@@ -6032,27 +5908,6 @@ snapshots:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  quickselect@2.0.0: {}
 | 
					  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)):
 | 
					  radix-vue@1.9.12(vue@3.5.13(typescript@5.7.3)):
 | 
				
			||||||
    dependencies:
 | 
					    dependencies:
 | 
				
			||||||
      '@floating-ui/dom': 1.6.13
 | 
					      '@floating-ui/dom': 1.6.13
 | 
				
			||||||
@@ -6082,15 +5937,6 @@ snapshots:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  regenerator-runtime@0.14.1: {}
 | 
					  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:
 | 
					  request-progress@3.0.0:
 | 
				
			||||||
    dependencies:
 | 
					    dependencies:
 | 
				
			||||||
      throttleit: 1.0.1
 | 
					      throttleit: 1.0.1
 | 
				
			||||||
@@ -6176,22 +6022,6 @@ snapshots:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  semver@7.6.3: {}
 | 
					  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:
 | 
					  shebang-command@2.0.0:
 | 
				
			||||||
    dependencies:
 | 
					    dependencies:
 | 
				
			||||||
      shebang-regex: 3.0.0
 | 
					      shebang-regex: 3.0.0
 | 
				
			||||||
@@ -6530,6 +6360,11 @@ snapshots:
 | 
				
			|||||||
    dependencies:
 | 
					    dependencies:
 | 
				
			||||||
      vue: 3.5.13(typescript@5.7.3)
 | 
					      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):
 | 
					  vue-eslint-parser@9.4.3(eslint@8.57.1):
 | 
				
			||||||
    dependencies:
 | 
					    dependencies:
 | 
				
			||||||
      debug: 4.4.0(supports-color@8.1.1)
 | 
					      debug: 4.4.0(supports-color@8.1.1)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -46,6 +46,7 @@
 | 
				
			|||||||
        @create-view="openCreateViewForm = true"
 | 
					        @create-view="openCreateViewForm = true"
 | 
				
			||||||
        @edit-view="editView"
 | 
					        @edit-view="editView"
 | 
				
			||||||
        @delete-view="deleteView"
 | 
					        @delete-view="deleteView"
 | 
				
			||||||
 | 
					        @create-conversation="() => openCreateConversationDialog = true"
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        <div class="flex flex-col h-screen">
 | 
					        <div class="flex flex-col h-screen">
 | 
				
			||||||
          <!-- Show app update only in admin routes -->
 | 
					          <!-- Show app update only in admin routes -->
 | 
				
			||||||
@@ -64,6 +65,9 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  <!-- Command box -->
 | 
					  <!-- Command box -->
 | 
				
			||||||
  <Command />
 | 
					  <Command />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <!-- Create conversation dialog -->
 | 
				
			||||||
 | 
					  <CreateConversation v-model="openCreateConversationDialog" />
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
@@ -89,6 +93,7 @@ import api from '@/api'
 | 
				
			|||||||
import { toast as sooner } from 'vue-sonner'
 | 
					import { toast as sooner } from 'vue-sonner'
 | 
				
			||||||
import Sidebar from '@/components/sidebar/Sidebar.vue'
 | 
					import Sidebar from '@/components/sidebar/Sidebar.vue'
 | 
				
			||||||
import Command from '@/features/command/CommandBox.vue'
 | 
					import Command from '@/features/command/CommandBox.vue'
 | 
				
			||||||
 | 
					import CreateConversation from '@/features/conversation/CreateConversation.vue'
 | 
				
			||||||
import { Inbox, Shield, FileLineChart } from 'lucide-vue-next'
 | 
					import { Inbox, Shield, FileLineChart } from 'lucide-vue-next'
 | 
				
			||||||
import { useRoute } from 'vue-router'
 | 
					import { useRoute } from 'vue-router'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
@@ -117,6 +122,7 @@ const tagStore = useTagStore()
 | 
				
			|||||||
const userViews = ref([])
 | 
					const userViews = ref([])
 | 
				
			||||||
const view = ref({})
 | 
					const view = ref({})
 | 
				
			||||||
const openCreateViewForm = ref(false)
 | 
					const openCreateViewForm = ref(false)
 | 
				
			||||||
 | 
					const openCreateConversationDialog = ref(false)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
initWS()
 | 
					initWS()
 | 
				
			||||||
useIdleDetection()
 | 
					useIdleDetection()
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,5 +3,25 @@
 | 
				
			|||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
 | 
					import { onMounted } from 'vue'
 | 
				
			||||||
import { RouterView } from 'vue-router'
 | 
					import { RouterView } from 'vue-router'
 | 
				
			||||||
 | 
					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>
 | 
					</script>
 | 
				
			||||||
@@ -35,6 +35,7 @@ http.interceptors.request.use((request) => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
const searchConversations = (params) => http.get('/api/v1/conversations/search', { params })
 | 
					const searchConversations = (params) => http.get('/api/v1/conversations/search', { params })
 | 
				
			||||||
const searchMessages = (params) => http.get('/api/v1/messages/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 resetPassword = (data) => http.post('/api/v1/users/reset-password', data)
 | 
				
			||||||
const setPassword = (data) => http.post('/api/v1/users/set-password', data)
 | 
					const setPassword = (data) => http.post('/api/v1/users/set-password', data)
 | 
				
			||||||
const deleteUser = (id) => http.delete(`/api/v1/users/${id}`)
 | 
					const deleteUser = (id) => http.delete(`/api/v1/users/${id}`)
 | 
				
			||||||
@@ -174,6 +175,7 @@ const getTags = () => http.get('/api/v1/tags')
 | 
				
			|||||||
const upsertTags = (uuid, data) => http.post(`/api/v1/conversations/${uuid}/tags`, data)
 | 
					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 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 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 updateConversationStatus = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/status`, data)
 | 
				
			||||||
const updateConversationPriority = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/priority`, data)
 | 
					const updateConversationPriority = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/priority`, data)
 | 
				
			||||||
const updateAssigneeLastSeen = (uuid) => http.put(`/api/v1/conversations/${uuid}/last-seen`)
 | 
					const updateAssigneeLastSeen = (uuid) => http.put(`/api/v1/conversations/${uuid}/last-seen`)
 | 
				
			||||||
@@ -265,6 +267,7 @@ const updateView = (id, data) =>
 | 
				
			|||||||
const deleteView = (id) => http.delete(`/api/v1/views/me/${id}`)
 | 
					const deleteView = (id) => http.delete(`/api/v1/views/me/${id}`)
 | 
				
			||||||
const getAiPrompts = () => http.get('/api/v1/ai/prompts')
 | 
					const getAiPrompts = () => http.get('/api/v1/ai/prompts')
 | 
				
			||||||
const aiCompletion = (data) => http.post('/api/v1/ai/completion', data)
 | 
					const aiCompletion = (data) => http.post('/api/v1/ai/completion', data)
 | 
				
			||||||
 | 
					const updateAIProvider = (data) => http.put('/api/v1/ai/provider', data)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default {
 | 
					export default {
 | 
				
			||||||
  login,
 | 
					  login,
 | 
				
			||||||
@@ -328,9 +331,11 @@ export default {
 | 
				
			|||||||
  updateAutomationRule,
 | 
					  updateAutomationRule,
 | 
				
			||||||
  updateAutomationRuleWeights,
 | 
					  updateAutomationRuleWeights,
 | 
				
			||||||
  updateAutomationRulesExecutionMode,
 | 
					  updateAutomationRulesExecutionMode,
 | 
				
			||||||
 | 
					  updateAIProvider,
 | 
				
			||||||
  createAutomationRule,
 | 
					  createAutomationRule,
 | 
				
			||||||
  toggleAutomationRule,
 | 
					  toggleAutomationRule,
 | 
				
			||||||
  deleteAutomationRule,
 | 
					  deleteAutomationRule,
 | 
				
			||||||
 | 
					  createConversation,
 | 
				
			||||||
  sendMessage,
 | 
					  sendMessage,
 | 
				
			||||||
  retryMessage,
 | 
					  retryMessage,
 | 
				
			||||||
  createUser,
 | 
					  createUser,
 | 
				
			||||||
@@ -375,5 +380,6 @@ export default {
 | 
				
			|||||||
  aiCompletion,
 | 
					  aiCompletion,
 | 
				
			||||||
  searchConversations,
 | 
					  searchConversations,
 | 
				
			||||||
  searchMessages,
 | 
					  searchMessages,
 | 
				
			||||||
 | 
					  searchContacts,
 | 
				
			||||||
  removeAssignee,
 | 
					  removeAssignee,
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -18,6 +18,49 @@
 | 
				
			|||||||
      overflow-x: auto;
 | 
					      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.
 | 
					// Theme.
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -18,6 +18,7 @@ import {
 | 
				
			|||||||
  SidebarProvider,
 | 
					  SidebarProvider,
 | 
				
			||||||
  SidebarRail
 | 
					  SidebarRail
 | 
				
			||||||
} from '@/components/ui/sidebar'
 | 
					} from '@/components/ui/sidebar'
 | 
				
			||||||
 | 
					import { useAppSettingsStore } from '@/stores/appSettings'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  ChevronRight,
 | 
					  ChevronRight,
 | 
				
			||||||
  EllipsisVertical,
 | 
					  EllipsisVertical,
 | 
				
			||||||
@@ -43,8 +44,9 @@ defineProps({
 | 
				
			|||||||
  userViews: { type: Array, default: () => [] }
 | 
					  userViews: { type: Array, default: () => [] }
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
const userStore = useUserStore()
 | 
					const userStore = useUserStore()
 | 
				
			||||||
 | 
					const settingsStore = useAppSettingsStore()
 | 
				
			||||||
const route = useRoute()
 | 
					const route = useRoute()
 | 
				
			||||||
const emit = defineEmits(['createView', 'editView', 'deleteView'])
 | 
					const emit = defineEmits(['createView', 'editView', 'deleteView', 'createConversation'])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const openCreateViewDialog = () => {
 | 
					const openCreateViewDialog = () => {
 | 
				
			||||||
  emit('createView')
 | 
					  emit('createView')
 | 
				
			||||||
@@ -70,6 +72,8 @@ const isInboxRoute = (path) => {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const sidebarOpen = useStorage('mainSidebarOpen', true)
 | 
					const sidebarOpen = useStorage('mainSidebarOpen', true)
 | 
				
			||||||
 | 
					const teamInboxOpen = useStorage('teamInboxOpen', true)
 | 
				
			||||||
 | 
					const viewInboxOpen = useStorage('viewInboxOpen', true)
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
@@ -122,9 +126,13 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
 | 
				
			|||||||
          <SidebarMenu>
 | 
					          <SidebarMenu>
 | 
				
			||||||
            <SidebarMenuItem>
 | 
					            <SidebarMenuItem>
 | 
				
			||||||
              <SidebarMenuButton :isActive="isActiveParent('/admin')" asChild>
 | 
					              <SidebarMenuButton :isActive="isActiveParent('/admin')" asChild>
 | 
				
			||||||
                <div>
 | 
					                <div class="flex items-center justify-between w-full">
 | 
				
			||||||
                  <span class="font-semibold text-xl">Admin</span>
 | 
					                  <span class="font-semibold text-xl">Admin</span>
 | 
				
			||||||
                </div>
 | 
					                </div>
 | 
				
			||||||
 | 
					                <!-- App version -->
 | 
				
			||||||
 | 
					                <div class="text-xs text-muted-foreground ml-2">
 | 
				
			||||||
 | 
					                  ({{ settingsStore.settings['app.version'] }})
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
              </SidebarMenuButton>
 | 
					              </SidebarMenuButton>
 | 
				
			||||||
            </SidebarMenuItem>
 | 
					            </SidebarMenuItem>
 | 
				
			||||||
          </SidebarMenu>
 | 
					          </SidebarMenu>
 | 
				
			||||||
@@ -222,6 +230,17 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
 | 
				
			|||||||
                <div class="flex items-center justify-between w-full">
 | 
					                <div class="flex items-center justify-between w-full">
 | 
				
			||||||
                  <div class="font-semibold text-xl">Inbox</div>
 | 
					                  <div class="font-semibold text-xl">Inbox</div>
 | 
				
			||||||
                  <div class="ml-auto">
 | 
					                  <div class="ml-auto">
 | 
				
			||||||
 | 
					                    <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 :to="{ name: 'search' }">
 | 
					                      <router-link :to="{ name: 'search' }">
 | 
				
			||||||
                        <div class="flex items-center bg-accent p-2 rounded-full">
 | 
					                        <div class="flex items-center bg-accent p-2 rounded-full">
 | 
				
			||||||
                          <Search
 | 
					                          <Search
 | 
				
			||||||
@@ -233,6 +252,7 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
 | 
				
			|||||||
                      </router-link>
 | 
					                      </router-link>
 | 
				
			||||||
                    </div>
 | 
					                    </div>
 | 
				
			||||||
                  </div>
 | 
					                  </div>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
              </SidebarMenuButton>
 | 
					              </SidebarMenuButton>
 | 
				
			||||||
            </SidebarMenuItem>
 | 
					            </SidebarMenuItem>
 | 
				
			||||||
          </SidebarMenu>
 | 
					          </SidebarMenu>
 | 
				
			||||||
@@ -269,7 +289,12 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
 | 
				
			|||||||
              </SidebarMenuItem>
 | 
					              </SidebarMenuItem>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
              <!-- Team Inboxes -->
 | 
					              <!-- 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>
 | 
					                <SidebarMenuItem>
 | 
				
			||||||
                  <CollapsibleTrigger as-child>
 | 
					                  <CollapsibleTrigger as-child>
 | 
				
			||||||
                    <SidebarMenuButton asChild>
 | 
					                    <SidebarMenuButton asChild>
 | 
				
			||||||
@@ -301,7 +326,7 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
 | 
				
			|||||||
              </Collapsible>
 | 
					              </Collapsible>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
              <!-- Views -->
 | 
					              <!-- Views -->
 | 
				
			||||||
              <Collapsible class="group/collapsible" defaultOpen>
 | 
					              <Collapsible class="group/collapsible" defaultOpen v-model:open="viewInboxOpen">
 | 
				
			||||||
                <SidebarMenuItem>
 | 
					                <SidebarMenuItem>
 | 
				
			||||||
                  <CollapsibleTrigger as-child>
 | 
					                  <CollapsibleTrigger as-child>
 | 
				
			||||||
                    <SidebarMenuButton asChild>
 | 
					                    <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"
 | 
					                            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>
 | 
					                        </div>
 | 
				
			||||||
 | 
					                        <ChevronRight
 | 
				
			||||||
 | 
					                          class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
 | 
				
			||||||
 | 
					                          v-if="userViews.length"
 | 
				
			||||||
 | 
					                        />
 | 
				
			||||||
                      </router-link>
 | 
					                      </router-link>
 | 
				
			||||||
                    </SidebarMenuButton>
 | 
					                    </SidebarMenuButton>
 | 
				
			||||||
                  </CollapsibleTrigger>
 | 
					                  </CollapsibleTrigger>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                  <SidebarMenuAction>
 | 
					 | 
				
			||||||
                    <ChevronRight
 | 
					 | 
				
			||||||
                      class="transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
 | 
					 | 
				
			||||||
                      v-if="userViews.length"
 | 
					 | 
				
			||||||
                    />
 | 
					 | 
				
			||||||
                  </SidebarMenuAction>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                  <CollapsibleContent>
 | 
					                  <CollapsibleContent>
 | 
				
			||||||
                    <SidebarMenuSub v-for="view in userViews" :key="view.id">
 | 
					                    <SidebarMenuSub v-for="view in userViews" :key="view.id">
 | 
				
			||||||
                      <SidebarMenuSubItem>
 | 
					                      <SidebarMenuSubItem>
 | 
				
			||||||
@@ -335,11 +357,8 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
 | 
				
			|||||||
                          asChild
 | 
					                          asChild
 | 
				
			||||||
                        >
 | 
					                        >
 | 
				
			||||||
                          <router-link :to="{ name: 'view-inbox', params: { viewID: view.id } }">
 | 
					                          <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>
 | 
				
			||||||
                          </router-link>
 | 
					                            <SidebarMenuAction :showOnHover="true" class="mr-3">
 | 
				
			||||||
                        </SidebarMenuButton>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                        <SidebarMenuAction>
 | 
					 | 
				
			||||||
                              <DropdownMenu>
 | 
					                              <DropdownMenu>
 | 
				
			||||||
                                <DropdownMenuTrigger asChild>
 | 
					                                <DropdownMenuTrigger asChild>
 | 
				
			||||||
                                  <EllipsisVertical />
 | 
					                                  <EllipsisVertical />
 | 
				
			||||||
@@ -354,6 +373,8 @@ const sidebarOpen = useStorage('mainSidebarOpen', true)
 | 
				
			|||||||
                                </DropdownMenuContent>
 | 
					                                </DropdownMenuContent>
 | 
				
			||||||
                              </DropdownMenu>
 | 
					                              </DropdownMenu>
 | 
				
			||||||
                            </SidebarMenuAction>
 | 
					                            </SidebarMenuAction>
 | 
				
			||||||
 | 
					                          </router-link>
 | 
				
			||||||
 | 
					                        </SidebarMenuButton>
 | 
				
			||||||
                      </SidebarMenuSubItem>
 | 
					                      </SidebarMenuSubItem>
 | 
				
			||||||
                    </SidebarMenuSub>
 | 
					                    </SidebarMenuSub>
 | 
				
			||||||
                  </CollapsibleContent>
 | 
					                  </CollapsibleContent>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,7 +6,7 @@
 | 
				
			|||||||
        class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground p-0"
 | 
					        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">
 | 
					        <Avatar class="h-8 w-8 rounded-lg relative overflow-visible">
 | 
				
			||||||
          <AvatarImage :src="userStore.avatar" alt="Abhinav" />
 | 
					          <AvatarImage :src="userStore.avatar" alt="" class="rounded-lg" />
 | 
				
			||||||
          <AvatarFallback class="rounded-lg">
 | 
					          <AvatarFallback class="rounded-lg">
 | 
				
			||||||
            {{ userStore.getInitials }}
 | 
					            {{ userStore.getInitials }}
 | 
				
			||||||
          </AvatarFallback>
 | 
					          </AvatarFallback>
 | 
				
			||||||
@@ -14,7 +14,9 @@
 | 
				
			|||||||
            class="absolute bottom-0 right-0 h-2.5 w-2.5 rounded-full border border-background"
 | 
					            class="absolute bottom-0 right-0 h-2.5 w-2.5 rounded-full border border-background"
 | 
				
			||||||
            :class="{
 | 
					            :class="{
 | 
				
			||||||
              'bg-green-500': userStore.user.availability_status === 'online',
 | 
					              'bg-green-500': userStore.user.availability_status === 'online',
 | 
				
			||||||
              'bg-amber-500': userStore.user.availability_status === 'away' || userStore.user.availability_status === 'away_manual',
 | 
					              'bg-amber-500':
 | 
				
			||||||
 | 
					                userStore.user.availability_status === 'away' ||
 | 
				
			||||||
 | 
					                userStore.user.availability_status === 'away_manual',
 | 
				
			||||||
              'bg-gray-400': userStore.user.availability_status === 'offline'
 | 
					              'bg-gray-400': userStore.user.availability_status === 'offline'
 | 
				
			||||||
            }"
 | 
					            }"
 | 
				
			||||||
          ></div>
 | 
					          ></div>
 | 
				
			||||||
@@ -47,18 +49,19 @@
 | 
				
			|||||||
        <div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm justify-between">
 | 
					        <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>
 | 
					          <span class="text-muted-foreground">Away</span>
 | 
				
			||||||
          <Switch
 | 
					          <Switch
 | 
				
			||||||
            :checked="userStore.user.availability_status === 'away' || userStore.user.availability_status === 'away_manual'"
 | 
					            :checked="
 | 
				
			||||||
 | 
					              userStore.user.availability_status === 'away' ||
 | 
				
			||||||
 | 
					              userStore.user.availability_status === 'away_manual'
 | 
				
			||||||
 | 
					            "
 | 
				
			||||||
            @update:checked="(val) => userStore.updateUserAvailability(val ? 'away' : 'online')"
 | 
					            @update:checked="(val) => userStore.updateUserAvailability(val ? 'away' : 'online')"
 | 
				
			||||||
          />
 | 
					          />
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </DropdownMenuLabel>
 | 
					      </DropdownMenuLabel>
 | 
				
			||||||
      <DropdownMenuSeparator />
 | 
					      <DropdownMenuSeparator />
 | 
				
			||||||
      <DropdownMenuGroup>
 | 
					      <DropdownMenuGroup>
 | 
				
			||||||
        <DropdownMenuItem>
 | 
					        <DropdownMenuItem @click.prevent="router.push({ name: 'account' })">
 | 
				
			||||||
          <router-link to="/account" class="flex items-center">
 | 
					 | 
				
			||||||
          <CircleUserRound size="18" class="mr-2" />
 | 
					          <CircleUserRound size="18" class="mr-2" />
 | 
				
			||||||
          Account
 | 
					          Account
 | 
				
			||||||
          </router-link>
 | 
					 | 
				
			||||||
        </DropdownMenuItem>
 | 
					        </DropdownMenuItem>
 | 
				
			||||||
      </DropdownMenuGroup>
 | 
					      </DropdownMenuGroup>
 | 
				
			||||||
      <DropdownMenuSeparator />
 | 
					      <DropdownMenuSeparator />
 | 
				
			||||||
@@ -85,7 +88,10 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
 | 
				
			|||||||
import { Switch } from '@/components/ui/switch'
 | 
					import { Switch } from '@/components/ui/switch'
 | 
				
			||||||
import { ChevronsUpDown, CircleUserRound, LogOut } from 'lucide-vue-next'
 | 
					import { ChevronsUpDown, CircleUserRound, LogOut } from 'lucide-vue-next'
 | 
				
			||||||
import { useUserStore } from '@/stores/user'
 | 
					import { useUserStore } from '@/stores/user'
 | 
				
			||||||
 | 
					import { useRouter } from 'vue-router'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const userStore = useUserStore()
 | 
					const userStore = useUserStore()
 | 
				
			||||||
 | 
					const router = useRouter()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const logout = () => {
 | 
					const logout = () => {
 | 
				
			||||||
  window.location.href = '/logout'
 | 
					  window.location.href = '/logout'
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,7 @@
 | 
				
			|||||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
 | 
					import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
 | 
				
			||||||
import { useUserStore } from '@/stores/user'
 | 
					import { useUserStore } from '@/stores/user'
 | 
				
			||||||
import { debounce } from '@/utils/debounce'
 | 
					import { debounce } from '@/utils/debounce'
 | 
				
			||||||
 | 
					import { useStorage } from '@vueuse/core'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function useIdleDetection () {
 | 
					export function useIdleDetection () {
 | 
				
			||||||
    const userStore = useUserStore()
 | 
					    const userStore = useUserStore()
 | 
				
			||||||
@@ -8,14 +9,19 @@ export function useIdleDetection () {
 | 
				
			|||||||
    const AWAY_THRESHOLD = 4 * 60 * 1000
 | 
					    const AWAY_THRESHOLD = 4 * 60 * 1000
 | 
				
			||||||
    // 1 minute
 | 
					    // 1 minute
 | 
				
			||||||
    const CHECK_INTERVAL = 60 * 1000
 | 
					    const CHECK_INTERVAL = 60 * 1000
 | 
				
			||||||
    const lastActivity = ref(Date.now())
 | 
					
 | 
				
			||||||
 | 
					    // Store last activity time in localStorage to sync across tabs
 | 
				
			||||||
 | 
					    const lastActivity = useStorage('last_active', Date.now())
 | 
				
			||||||
    const timer = ref(null)
 | 
					    const timer = ref(null)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    function resetTimer () {
 | 
					    function resetTimer () {
 | 
				
			||||||
        if (userStore.user.availability_status === 'away' || userStore.user.availability_status === 'offline') {
 | 
					        if (userStore.user.availability_status === 'away' || userStore.user.availability_status === 'offline') {
 | 
				
			||||||
            userStore.updateUserAvailability('online', false)
 | 
					            userStore.updateUserAvailability('online', false)
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        lastActivity.value = Date.now()
 | 
					        const now = Date.now()
 | 
				
			||||||
 | 
					        if (lastActivity.value < now) {
 | 
				
			||||||
 | 
					            lastActivity.value = now
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const debouncedResetTimer = debounce(resetTimer, 200)
 | 
					    const debouncedResetTimer = debounce(resetTimer, 200)
 | 
				
			||||||
@@ -38,6 +44,16 @@ export function useIdleDetection () {
 | 
				
			|||||||
        window.removeEventListener('mousemove', debouncedResetTimer)
 | 
					        window.removeEventListener('mousemove', debouncedResetTimer)
 | 
				
			||||||
        window.removeEventListener('keypress', debouncedResetTimer)
 | 
					        window.removeEventListener('keypress', debouncedResetTimer)
 | 
				
			||||||
        window.removeEventListener('click', debouncedResetTimer)
 | 
					        window.removeEventListener('click', debouncedResetTimer)
 | 
				
			||||||
 | 
					        if (timer.value) {
 | 
				
			||||||
            clearInterval(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()
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -18,5 +18,5 @@ export function useSla (dueAt, actualAt) {
 | 
				
			|||||||
            clearInterval(intervalId)
 | 
					            clearInterval(intervalId)
 | 
				
			||||||
        })
 | 
					        })
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
    return { sla, updateSla }
 | 
					    return sla
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -10,7 +10,6 @@
 | 
				
			|||||||
          <div class="flex items-center justify-between">
 | 
					          <div class="flex items-center justify-between">
 | 
				
			||||||
            <div class="flex gap-5">
 | 
					            <div class="flex gap-5">
 | 
				
			||||||
              <div class="w-48">
 | 
					              <div class="w-48">
 | 
				
			||||||
 | 
					 | 
				
			||||||
                <!-- Type -->
 | 
					                <!-- Type -->
 | 
				
			||||||
                <Select
 | 
					                <Select
 | 
				
			||||||
                  v-model="action.type"
 | 
					                  v-model="action.type"
 | 
				
			||||||
@@ -109,15 +108,13 @@
 | 
				
			|||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <div
 | 
					          <div
 | 
				
			||||||
 | 
					            class="box p-2 h-96 min-h-96"
 | 
				
			||||||
            v-if="action.type && conversationActions[action.type]?.type === 'richtext'"
 | 
					            v-if="action.type && conversationActions[action.type]?.type === 'richtext'"
 | 
				
			||||||
            class="pl-0 shadow"
 | 
					 | 
				
			||||||
          >
 | 
					          >
 | 
				
			||||||
            <QuillEditor
 | 
					            <Editor
 | 
				
			||||||
              theme="snow"
 | 
					              v-model:htmlContent="action.value[0]"
 | 
				
			||||||
              v-model:content="action.value[0]"
 | 
					              @update:htmlContent="(value) => handleEditorChange(value, index)"
 | 
				
			||||||
              contentType="html"
 | 
					              :placeholder="'Shift + Enter to add new line'"
 | 
				
			||||||
              @update:content="(value) => handleValueChange(value, index)"
 | 
					 | 
				
			||||||
              class="h-32 mb-12"
 | 
					 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
@@ -142,12 +139,12 @@ import {
 | 
				
			|||||||
  SelectTrigger,
 | 
					  SelectTrigger,
 | 
				
			||||||
  SelectValue
 | 
					  SelectValue
 | 
				
			||||||
} from '@/components/ui/select'
 | 
					} 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 ComboBox from '@/components/ui/combobox/ComboBox.vue'
 | 
				
			||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
 | 
					import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
 | 
				
			||||||
import { SelectTag } from '@/components/ui/select'
 | 
					import { SelectTag } from '@/components/ui/select'
 | 
				
			||||||
import { useConversationFilters } from '@/composables/useConversationFilters'
 | 
					import { useConversationFilters } from '@/composables/useConversationFilters'
 | 
				
			||||||
 | 
					import { getTextFromHTML } from '@/utils/strings.js'
 | 
				
			||||||
 | 
					import Editor from '@/features/conversation/ConversationTextEditor.vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const props = defineProps({
 | 
				
			||||||
  actions: {
 | 
					  actions: {
 | 
				
			||||||
@@ -175,6 +172,16 @@ const handleValueChange = (value, index) => {
 | 
				
			|||||||
  emitUpdate(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) => {
 | 
					const removeAction = (index) => {
 | 
				
			||||||
  emit('remove-action', index)
 | 
					  emit('remove-action', index)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -31,7 +31,7 @@
 | 
				
			|||||||
          </template>
 | 
					          </template>
 | 
				
			||||||
        </draggable>
 | 
					        </draggable>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
      <div v-else>
 | 
					      <div v-else class="space-y-5">
 | 
				
			||||||
        <RuleList
 | 
					        <RuleList
 | 
				
			||||||
          v-for="rule in rules"
 | 
					          v-for="rule in rules"
 | 
				
			||||||
          :key="rule.id"
 | 
					          :key="rule.id"
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -108,19 +108,6 @@
 | 
				
			|||||||
              placeholder="Select tag"
 | 
					              placeholder="Select tag"
 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
          </div>
 | 
					          </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>
 | 
					      </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
@@ -139,14 +126,12 @@ import {
 | 
				
			|||||||
  SelectTrigger,
 | 
					  SelectTrigger,
 | 
				
			||||||
  SelectValue
 | 
					  SelectValue
 | 
				
			||||||
} from '@/components/ui/select'
 | 
					} 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 { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
 | 
				
			||||||
import { SelectTag } from '@/components/ui/select'
 | 
					import { SelectTag } from '@/components/ui/select'
 | 
				
			||||||
import { useTagStore } from '@/stores/tag'
 | 
					import { useTagStore } from '@/stores/tag'
 | 
				
			||||||
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
 | 
					import ComboBox from '@/components/ui/combobox/ComboBox.vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const model = defineModel({
 | 
					const model = defineModel("actions", {
 | 
				
			||||||
  type: Array,
 | 
					  type: Array,
 | 
				
			||||||
  required: true,
 | 
					  required: true,
 | 
				
			||||||
  default: () => []
 | 
					  default: () => []
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -13,16 +13,25 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    <FormField v-slot="{ componentField }" name="message_content">
 | 
					    <FormField v-slot="{ componentField }" name="message_content">
 | 
				
			||||||
      <FormItem>
 | 
					      <FormItem>
 | 
				
			||||||
        <FormLabel>Response to be sent when macro is used</FormLabel>
 | 
					        <FormLabel>Response to be sent when macro is used (optional)</FormLabel>
 | 
				
			||||||
        <FormControl>
 | 
					        <FormControl>
 | 
				
			||||||
          <QuillEditor
 | 
					          <div class="box p-2 h-96 min-h-96">
 | 
				
			||||||
            v-model:content="componentField.modelValue"
 | 
					            <Editor
 | 
				
			||||||
            placeholder="Add a response (optional)"
 | 
					              v-model:htmlContent="componentField.modelValue"
 | 
				
			||||||
            theme="snow"
 | 
					              @update:htmlContent="(value) => componentField.onChange(value)"
 | 
				
			||||||
            contentType="html"
 | 
					              :placeholder="'Shift + Enter to add new line'"
 | 
				
			||||||
            class="h-32 mb-12"
 | 
					 | 
				
			||||||
            @update:content="(value) => componentField.onChange(value)"
 | 
					 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
 | 
					          </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>
 | 
					        </FormControl>
 | 
				
			||||||
        <FormMessage />
 | 
					        <FormMessage />
 | 
				
			||||||
      </FormItem>
 | 
					      </FormItem>
 | 
				
			||||||
@@ -106,16 +115,6 @@
 | 
				
			|||||||
        <FormMessage />
 | 
					        <FormMessage />
 | 
				
			||||||
      </FormItem>
 | 
					      </FormItem>
 | 
				
			||||||
    </FormField>
 | 
					    </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>
 | 
					    <Button type="submit" :isLoading="isLoading">{{ submitLabel }}</Button>
 | 
				
			||||||
  </form>
 | 
					  </form>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
@@ -133,9 +132,8 @@ import ActionBuilder from '@/features/admin/macros/ActionBuilder.vue'
 | 
				
			|||||||
import { useConversationFilters } from '@/composables/useConversationFilters'
 | 
					import { useConversationFilters } from '@/composables/useConversationFilters'
 | 
				
			||||||
import { useUsersStore } from '@/stores/users'
 | 
					import { useUsersStore } from '@/stores/users'
 | 
				
			||||||
import { useTeamStore } from '@/stores/team'
 | 
					import { useTeamStore } from '@/stores/team'
 | 
				
			||||||
 | 
					import { getTextFromHTML } from '@/utils/strings.js'
 | 
				
			||||||
import { formSchema } from './formSchema.js'
 | 
					import { formSchema } from './formSchema.js'
 | 
				
			||||||
import { QuillEditor } from '@vueup/vue-quill'
 | 
					 | 
				
			||||||
import '@vueup/vue-quill/dist/vue-quill.snow.css'
 | 
					 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Select,
 | 
					  Select,
 | 
				
			||||||
  SelectContent,
 | 
					  SelectContent,
 | 
				
			||||||
@@ -145,6 +143,7 @@ import {
 | 
				
			|||||||
  SelectValue
 | 
					  SelectValue
 | 
				
			||||||
} from '@/components/ui/select'
 | 
					} from '@/components/ui/select'
 | 
				
			||||||
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
 | 
					import ComboBox from '@/components/ui/combobox/ComboBox.vue'
 | 
				
			||||||
 | 
					import Editor from '@/features/conversation/ConversationTextEditor.vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const { macroActions } = useConversationFilters()
 | 
					const { macroActions } = useConversationFilters()
 | 
				
			||||||
const formLoading = ref(false)
 | 
					const formLoading = ref(false)
 | 
				
			||||||
@@ -181,6 +180,11 @@ const actionConfig = ref({
 | 
				
			|||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const onSubmit = form.handleSubmit(async (values) => {
 | 
					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)
 | 
					  props.submitForm(values)
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,4 +1,5 @@
 | 
				
			|||||||
import * as z from 'zod'
 | 
					import * as z from 'zod'
 | 
				
			||||||
 | 
					import { getTextFromHTML } from '@/utils/strings.js'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const actionSchema = z.array(
 | 
					const actionSchema = z.array(
 | 
				
			||||||
  z.object({
 | 
					  z.object({
 | 
				
			||||||
@@ -10,8 +11,42 @@ const actionSchema = z.array(
 | 
				
			|||||||
export const formSchema = z.object({
 | 
					export const formSchema = z.object({
 | 
				
			||||||
  name: z.string().min(1, 'Macro name is required'),
 | 
					  name: z.string().min(1, 'Macro name is required'),
 | 
				
			||||||
  message_content: z.string().optional(),
 | 
					  message_content: z.string().optional(),
 | 
				
			||||||
  actions: actionSchema,
 | 
					  actions: actionSchema.optional().default([]), // Default to empty array if not provided
 | 
				
			||||||
  visibility: z.enum(['all', 'team', 'user']),
 | 
					  visibility: z.enum(['all', 'team', 'user']),
 | 
				
			||||||
  team_id: z.string().nullable().optional(),
 | 
					  team_id: z.string().nullable().optional(),
 | 
				
			||||||
  user_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'],
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
@@ -65,6 +65,7 @@
 | 
				
			|||||||
          <Input type="number" placeholder="2" v-bind="componentField" />
 | 
					          <Input type="number" placeholder="2" v-bind="componentField" />
 | 
				
			||||||
        </FormControl>
 | 
					        </FormControl>
 | 
				
			||||||
        <FormMessage />
 | 
					        <FormMessage />
 | 
				
			||||||
 | 
					        <FormDescription> Maximum concurrent connections to the server. </FormDescription>
 | 
				
			||||||
      </FormItem>
 | 
					      </FormItem>
 | 
				
			||||||
    </FormField>
 | 
					    </FormField>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -76,6 +77,10 @@
 | 
				
			|||||||
          <Input type="text" placeholder="15s" v-bind="componentField" />
 | 
					          <Input type="text" placeholder="15s" v-bind="componentField" />
 | 
				
			||||||
        </FormControl>
 | 
					        </FormControl>
 | 
				
			||||||
        <FormMessage />
 | 
					        <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>
 | 
					      </FormItem>
 | 
				
			||||||
    </FormField>
 | 
					    </FormField>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -87,6 +92,10 @@
 | 
				
			|||||||
          <Input type="text" placeholder="5s" v-bind="componentField" />
 | 
					          <Input type="text" placeholder="5s" v-bind="componentField" />
 | 
				
			||||||
        </FormControl>
 | 
					        </FormControl>
 | 
				
			||||||
        <FormMessage />
 | 
					        <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>
 | 
					      </FormItem>
 | 
				
			||||||
    </FormField>
 | 
					    </FormField>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -139,6 +148,7 @@
 | 
				
			|||||||
          <Input type="number" placeholder="2" v-bind="componentField" />
 | 
					          <Input type="number" placeholder="2" v-bind="componentField" />
 | 
				
			||||||
        </FormControl>
 | 
					        </FormControl>
 | 
				
			||||||
        <FormMessage />
 | 
					        <FormMessage />
 | 
				
			||||||
 | 
					        <FormDescription> Number of times to retry when a message fails. </FormDescription>
 | 
				
			||||||
      </FormItem>
 | 
					      </FormItem>
 | 
				
			||||||
    </FormField>
 | 
					    </FormField>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -51,8 +51,8 @@ export const smtpConfigSchema = z.object({
 | 
				
			|||||||
    auth_protocol: z
 | 
					    auth_protocol: z
 | 
				
			||||||
        .enum(['plain', 'login', 'cram', 'none'])
 | 
					        .enum(['plain', 'login', 'cram', 'none'])
 | 
				
			||||||
        .describe('Authentication protocol'),
 | 
					        .describe('Authentication protocol'),
 | 
				
			||||||
    email_address: z.string().describe('Email address').email().nonempty({
 | 
					    email_address: z.string().describe('From email address with name (e.g., "Name <email@example.com>")').nonempty({
 | 
				
			||||||
        message: "Email address is required"
 | 
					        message: "From email address is required"
 | 
				
			||||||
    }),
 | 
					    }),
 | 
				
			||||||
    max_msg_retries: z
 | 
					    max_msg_retries: z
 | 
				
			||||||
        .number({
 | 
					        .number({
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -13,7 +13,11 @@
 | 
				
			|||||||
      <FormItem>
 | 
					      <FormItem>
 | 
				
			||||||
        <FormLabel>Description</FormLabel>
 | 
					        <FormLabel>Description</FormLabel>
 | 
				
			||||||
        <FormControl>
 | 
					        <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>
 | 
					        </FormControl>
 | 
				
			||||||
        <FormMessage />
 | 
					        <FormMessage />
 | 
				
			||||||
      </FormItem>
 | 
					      </FormItem>
 | 
				
			||||||
@@ -24,13 +28,19 @@
 | 
				
			|||||||
    <div v-for="entity in permissions" :key="entity.name" class="box p-4">
 | 
					    <div v-for="entity in permissions" :key="entity.name" class="box p-4">
 | 
				
			||||||
      <p class="text-lg mb-5">{{ entity.name }}</p>
 | 
					      <p class="text-lg mb-5">{{ entity.name }}</p>
 | 
				
			||||||
      <div class="space-y-4">
 | 
					      <div class="space-y-4">
 | 
				
			||||||
        <FormField v-for="permission in entity.permissions" :key="permission.name" type="checkbox"
 | 
					        <FormField
 | 
				
			||||||
          :name="permission.name">
 | 
					          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">
 | 
					          <FormItem class="flex flex-col gap-y-5 space-y-0 rounded-lg">
 | 
				
			||||||
            <div class="flex space-x-3">
 | 
					            <div class="flex space-x-3">
 | 
				
			||||||
              <FormControl>
 | 
					              <FormControl>
 | 
				
			||||||
                <Checkbox :checked="selectedPermissions.includes(permission.name)"
 | 
					                <Checkbox
 | 
				
			||||||
                  @update:checked="(newValue) => handleChange(newValue, permission.name)" />
 | 
					                  :checked="selectedPermissions.includes(permission.name)"
 | 
				
			||||||
 | 
					                  @update:checked="(newValue) => handleChange(newValue, permission.name)"
 | 
				
			||||||
 | 
					                />
 | 
				
			||||||
                <FormLabel>{{ permission.label }}</FormLabel>
 | 
					                <FormLabel>{{ permission.label }}</FormLabel>
 | 
				
			||||||
              </FormControl>
 | 
					              </FormControl>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
@@ -69,7 +79,7 @@ const props = defineProps({
 | 
				
			|||||||
  },
 | 
					  },
 | 
				
			||||||
  isLoading: {
 | 
					  isLoading: {
 | 
				
			||||||
    type: Boolean,
 | 
					    type: Boolean,
 | 
				
			||||||
    required: false,
 | 
					    required: false
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -77,7 +87,8 @@ const permissions = ref([
 | 
				
			|||||||
  {
 | 
					  {
 | 
				
			||||||
    name: 'Conversation',
 | 
					    name: 'Conversation',
 | 
				
			||||||
    permissions: [
 | 
					    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_assigned', label: 'View conversations assigned to me' },
 | 
				
			||||||
      { name: 'conversations:read_all', label: 'View all conversations' },
 | 
					      { name: 'conversations:read_all', label: 'View all conversations' },
 | 
				
			||||||
      { name: 'conversations:read_unassigned', label: 'View all unassigned 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: 'conversations:update_tags', label: 'Add or remove conversation tags' },
 | 
				
			||||||
      { name: 'messages:read', label: 'View conversation messages' },
 | 
					      { name: 'messages:read', label: 'View conversation messages' },
 | 
				
			||||||
      { name: 'messages:write', label: 'Send messages in conversations' },
 | 
					      { 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: 'reports:manage', label: 'Manage Reports' },
 | 
				
			||||||
      { name: 'business_hours:manage', label: 'Manage Business Hours' },
 | 
					      { name: 'business_hours:manage', label: 'Manage Business Hours' },
 | 
				
			||||||
      { name: 'sla:manage', label: 'Manage SLA Policies' },
 | 
					      { name: 'sla:manage', label: 'Manage SLA Policies' },
 | 
				
			||||||
 | 
					      { name: 'ai:manage', label: 'Manage AI Features' }
 | 
				
			||||||
    ]
 | 
					    ]
 | 
				
			||||||
  },
 | 
					  }
 | 
				
			||||||
])
 | 
					])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const selectedPermissions = ref([])
 | 
					const selectedPermissions = ref([])
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,7 +1,11 @@
 | 
				
			|||||||
<template>
 | 
					<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" />
 | 
					    <CommandInput placeholder="Type a command or search..." @keydown="onInputKeydown" />
 | 
				
			||||||
    <CommandList class="!min-h-[400px]">
 | 
					    <CommandList class="!min-h-[60vh] !min-w-[50vw]">
 | 
				
			||||||
      <CommandEmpty>
 | 
					      <CommandEmpty>
 | 
				
			||||||
        <p class="text-muted-foreground">No command available</p>
 | 
					        <p class="text-muted-foreground">No command available</p>
 | 
				
			||||||
      </CommandEmpty>
 | 
					      </CommandEmpty>
 | 
				
			||||||
@@ -10,7 +14,7 @@
 | 
				
			|||||||
      <CommandGroup
 | 
					      <CommandGroup
 | 
				
			||||||
        heading="Conversations"
 | 
					        heading="Conversations"
 | 
				
			||||||
        value="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-snooze" @select="setNestedCommand('snooze')"> Snooze </CommandItem>
 | 
				
			||||||
        <CommandItem value="conv-resolve" @select="resolveConversation"> Resolve </CommandItem>
 | 
					        <CommandItem value="conv-resolve" @select="resolveConversation"> Resolve </CommandItem>
 | 
				
			||||||
@@ -32,12 +36,12 @@
 | 
				
			|||||||
      </CommandGroup>
 | 
					      </CommandGroup>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <!-- Macros -->
 | 
					      <!-- Macros -->
 | 
				
			||||||
      <!-- TODO move to a separate component -->
 | 
					 | 
				
			||||||
      <div v-if="nestedCommand === 'apply-macro'" class="bg-background">
 | 
					      <div v-if="nestedCommand === 'apply-macro'" class="bg-background">
 | 
				
			||||||
        <CommandGroup heading="Apply macro" class="pb-2">
 | 
					        <CommandGroup heading="Apply macro" class="pb-2">
 | 
				
			||||||
          <div class="min-h-[400px] overflow-auto">
 | 
					          <div class="min-h-[400px] overflow-auto">
 | 
				
			||||||
            <div class="grid grid-cols-12 gap-3">
 | 
					            <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
 | 
					                <CommandItem
 | 
				
			||||||
                  v-for="(macro, index) in macroStore.macroOptions"
 | 
					                  v-for="(macro, index) in macroStore.macroOptions"
 | 
				
			||||||
                  :key="macro.value"
 | 
					                  :key="macro.value"
 | 
				
			||||||
@@ -45,25 +49,29 @@
 | 
				
			|||||||
                  :data-index="index"
 | 
					                  :data-index="index"
 | 
				
			||||||
                  @select="handleApplyMacro(macro)"
 | 
					                  @select="handleApplyMacro(macro)"
 | 
				
			||||||
                  class="px-3 py-2 rounded-md cursor-pointer transition-all duration-200 hover:bg-primary/10 hover:text-primary"
 | 
					                  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">
 | 
					                  <div class="flex items-center gap-2">
 | 
				
			||||||
                    <Zap :size="14" class="text-primary" />
 | 
					                    <Zap size="14" class="text-primary shrink-0" />
 | 
				
			||||||
                    <span class="text-sm overflow">{{ macro.label }}</span>
 | 
					                    <span class="text-sm truncate w-full break-words whitespace-normal">{{
 | 
				
			||||||
 | 
					                      macro.label
 | 
				
			||||||
 | 
					                    }}</span>
 | 
				
			||||||
                  </div>
 | 
					                  </div>
 | 
				
			||||||
                </CommandItem>
 | 
					                </CommandItem>
 | 
				
			||||||
              </div>
 | 
					              </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					              <!-- Right Column: Macro Details (70%) -->
 | 
				
			||||||
              <div class="col-span-8 pl-2">
 | 
					              <div class="col-span-8 pl-2">
 | 
				
			||||||
                <div class="space-y-3 text-xs">
 | 
					                <div class="space-y-3 text-xs">
 | 
				
			||||||
 | 
					                  <!-- Reply Preview -->
 | 
				
			||||||
                  <div v-if="replyContent" class="space-y-1">
 | 
					                  <div v-if="replyContent" class="space-y-1">
 | 
				
			||||||
                    <p class="text-xs font-semibold text-primary">Reply Preview</p>
 | 
					                    <p class="text-xs font-semibold text-primary">Reply Preview</p>
 | 
				
			||||||
                    <div
 | 
					                    <div
 | 
				
			||||||
                      class="w-full min-h-200 p-2 bg-muted/50 rounded-md overflow-auto shadow-sm"
 | 
					                      class="w-full min-h-200 p-2 bg-muted/50 rounded-md overflow-auto shadow-sm native-html"
 | 
				
			||||||
                      v-html="replyContent"
 | 
					                      v-dompurify-html="replyContent"
 | 
				
			||||||
                    />
 | 
					                    />
 | 
				
			||||||
                  </div>
 | 
					                  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                  <!-- Actions -->
 | 
				
			||||||
                  <div v-if="otherActions.length > 0" class="space-y-1">
 | 
					                  <div v-if="otherActions.length > 0" class="space-y-1">
 | 
				
			||||||
                    <p class="text-xs font-semibold text-primary">Actions</p>
 | 
					                    <p class="text-xs font-semibold text-primary">Actions</p>
 | 
				
			||||||
                    <div class="space-y-1.5 max-w-sm">
 | 
					                    <div class="space-y-1.5 max-w-sm">
 | 
				
			||||||
@@ -105,6 +113,8 @@
 | 
				
			|||||||
                      </div>
 | 
					                      </div>
 | 
				
			||||||
                    </div>
 | 
					                    </div>
 | 
				
			||||||
                  </div>
 | 
					                  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                  <!-- Empty State -->
 | 
				
			||||||
                  <div
 | 
					                  <div
 | 
				
			||||||
                    v-if="!replyContent && otherActions.length === 0"
 | 
					                    v-if="!replyContent && otherActions.length === 0"
 | 
				
			||||||
                    class="flex items-center justify-center h-20"
 | 
					                    class="flex items-center justify-center h-20"
 | 
				
			||||||
@@ -122,7 +132,6 @@
 | 
				
			|||||||
    </CommandList>
 | 
					    </CommandList>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <!-- Navigation -->
 | 
					    <!-- Navigation -->
 | 
				
			||||||
    <!-- TODO: Move to a separate component -->
 | 
					 | 
				
			||||||
    <div class="mt-2 px-4 py-2 text-xs text-gray-500 flex space-x-4">
 | 
					    <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>Enter</kbd> select</span>
 | 
				
			||||||
      <span><kbd>↑</kbd>/<kbd>↓</kbd> navigate</span>
 | 
					      <span><kbd>↑</kbd>/<kbd>↓</kbd> navigate</span>
 | 
				
			||||||
@@ -132,7 +141,6 @@
 | 
				
			|||||||
  </CommandDialog>
 | 
					  </CommandDialog>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <!-- Date Picker for Custom Snooze -->
 | 
					  <!-- Date Picker for Custom Snooze -->
 | 
				
			||||||
  <!-- TODO: Move to a separate component -->
 | 
					 | 
				
			||||||
  <Dialog :open="showDatePicker" @update:open="closeDatePicker">
 | 
					  <Dialog :open="showDatePicker" @update:open="closeDatePicker">
 | 
				
			||||||
    <DialogContent class="sm:max-w-[425px]">
 | 
					    <DialogContent class="sm:max-w-[425px]">
 | 
				
			||||||
      <DialogHeader>
 | 
					      <DialogHeader>
 | 
				
			||||||
@@ -219,7 +227,9 @@ watch([Meta_K, Ctrl_K], ([mac, win]) => {
 | 
				
			|||||||
const highlightedMacro = ref(null)
 | 
					const highlightedMacro = ref(null)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function handleApplyMacro(macro) {
 | 
					function handleApplyMacro(macro) {
 | 
				
			||||||
  conversationStore.setMacro(macro)
 | 
					  // Create a deep copy.
 | 
				
			||||||
 | 
					  const plainMacro = JSON.parse(JSON.stringify(macro))
 | 
				
			||||||
 | 
					  conversationStore.setMacro(plainMacro)
 | 
				
			||||||
  handleOpenChange()
 | 
					  handleOpenChange()
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -38,7 +38,7 @@
 | 
				
			|||||||
    <div class="flex flex-col flex-grow overflow-hidden">
 | 
					    <div class="flex flex-col flex-grow overflow-hidden">
 | 
				
			||||||
      <MessageList class="flex-1 overflow-y-auto" />
 | 
					      <MessageList class="flex-1 overflow-y-auto" />
 | 
				
			||||||
      <div class="sticky bottom-0">
 | 
					      <div class="sticky bottom-0">
 | 
				
			||||||
        <ReplyBox class="h-max" />
 | 
					        <ReplyBox />
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,5 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div class="max-h-[600px] overflow-y-auto">
 | 
					  <div class="editor-wrapper h-full overflow-y-auto">
 | 
				
			||||||
    <BubbleMenu
 | 
					    <BubbleMenu
 | 
				
			||||||
      :editor="editor"
 | 
					      :editor="editor"
 | 
				
			||||||
      :tippy-options="{ duration: 100 }"
 | 
					      :tippy-options="{ duration: 100 }"
 | 
				
			||||||
@@ -7,7 +7,7 @@
 | 
				
			|||||||
      class="bg-white p-1 box will-change-transform"
 | 
					      class="bg-white p-1 box will-change-transform"
 | 
				
			||||||
    >
 | 
					    >
 | 
				
			||||||
      <div class="flex space-x-1 items-center">
 | 
					      <div class="flex space-x-1 items-center">
 | 
				
			||||||
        <DropdownMenu>
 | 
					        <DropdownMenu v-if="aiPrompts.length > 0">
 | 
				
			||||||
          <DropdownMenuTrigger>
 | 
					          <DropdownMenuTrigger>
 | 
				
			||||||
            <Button size="sm" variant="ghost" class="flex items-center justify-center">
 | 
					            <Button size="sm" variant="ghost" class="flex items-center justify-center">
 | 
				
			||||||
              <span class="flex items-center">
 | 
					              <span class="flex items-center">
 | 
				
			||||||
@@ -30,7 +30,7 @@
 | 
				
			|||||||
        <Button
 | 
					        <Button
 | 
				
			||||||
          size="sm"
 | 
					          size="sm"
 | 
				
			||||||
          variant="ghost"
 | 
					          variant="ghost"
 | 
				
			||||||
          @click="isBold = !isBold"
 | 
					          @click.prevent="isBold = !isBold"
 | 
				
			||||||
          :active="isBold"
 | 
					          :active="isBold"
 | 
				
			||||||
          :class="{ 'bg-gray-200': isBold }"
 | 
					          :class="{ 'bg-gray-200': isBold }"
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
@@ -39,22 +39,39 @@
 | 
				
			|||||||
        <Button
 | 
					        <Button
 | 
				
			||||||
          size="sm"
 | 
					          size="sm"
 | 
				
			||||||
          variant="ghost"
 | 
					          variant="ghost"
 | 
				
			||||||
          @click="isItalic = !isItalic"
 | 
					          @click.prevent="isItalic = !isItalic"
 | 
				
			||||||
          :active="isItalic"
 | 
					          :active="isItalic"
 | 
				
			||||||
          :class="{ 'bg-gray-200': isItalic }"
 | 
					          :class="{ 'bg-gray-200': isItalic }"
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          <Italic size="14" />
 | 
					          <Italic size="14" />
 | 
				
			||||||
        </Button>
 | 
					        </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>
 | 
					      </div>
 | 
				
			||||||
    </BubbleMenu>
 | 
					    </BubbleMenu>
 | 
				
			||||||
    <EditorContent :editor="editor" />
 | 
					    <EditorContent :editor="editor" class="native-html" />
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { ref, watch, watchEffect, onUnmounted } from 'vue'
 | 
					import { ref, watch, watchEffect, onUnmounted } from 'vue'
 | 
				
			||||||
import { useEditor, EditorContent, BubbleMenu } from '@tiptap/vue-3'
 | 
					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 { Button } from '@/components/ui/button'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  DropdownMenu,
 | 
					  DropdownMenu,
 | 
				
			||||||
@@ -95,28 +112,7 @@ const getSelectionText = (from, to, doc) => doc.textBetween(from, to)
 | 
				
			|||||||
const editorConfig = {
 | 
					const editorConfig = {
 | 
				
			||||||
  extensions: [
 | 
					  extensions: [
 | 
				
			||||||
    // Lists are unstyled in tailwind, so need to add classes to them.
 | 
					    // Lists are unstyled in tailwind, so need to add classes to them.
 | 
				
			||||||
    StarterKit.configure({
 | 
					    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'
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }),
 | 
					 | 
				
			||||||
    Image.configure({ HTMLAttributes: { class: 'inline-image' } }),
 | 
					    Image.configure({ HTMLAttributes: { class: 'inline-image' } }),
 | 
				
			||||||
    Placeholder.configure({ placeholder: () => props.placeholder }),
 | 
					    Placeholder.configure({ placeholder: () => props.placeholder }),
 | 
				
			||||||
    Link
 | 
					    Link
 | 
				
			||||||
@@ -179,13 +175,20 @@ watchEffect(() => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
watch(
 | 
					watch(
 | 
				
			||||||
  () => props.contentToSet,
 | 
					  () => props.contentToSet,
 | 
				
			||||||
  (newContent) => {
 | 
					  (newContentData) => {
 | 
				
			||||||
    if (newContent === '') {
 | 
					    if (!newContentData) return
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const parsedData = JSON.parse(newContentData)
 | 
				
			||||||
 | 
					      const content = parsedData.content
 | 
				
			||||||
 | 
					      if (content === '') {
 | 
				
			||||||
        editor.value?.commands.clearContent()
 | 
					        editor.value?.commands.clearContent()
 | 
				
			||||||
      } else {
 | 
					      } else {
 | 
				
			||||||
      editor.value?.commands.setContent(newContent, true)
 | 
					        editor.value?.commands.setContent(content, true)
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
      editor.value?.commands.focus()
 | 
					      editor.value?.commands.focus()
 | 
				
			||||||
 | 
					    } catch (e) {
 | 
				
			||||||
 | 
					      console.error('Error parsing content data', e)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -231,6 +234,18 @@ watch(
 | 
				
			|||||||
onUnmounted(() => {
 | 
					onUnmounted(() => {
 | 
				
			||||||
  editor.value?.destroy()
 | 
					  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>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<style lang="scss">
 | 
					<style lang="scss">
 | 
				
			||||||
@@ -243,22 +258,26 @@ onUnmounted(() => {
 | 
				
			|||||||
  height: 0;
 | 
					  height: 0;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Editor height
 | 
					// Ensure the parent div has a proper height
 | 
				
			||||||
.ProseMirror {
 | 
					.editor-wrapper div[aria-expanded='false'] {
 | 
				
			||||||
  min-height: 80px !important;
 | 
					  display: flex;
 | 
				
			||||||
  max-height: 60% !important;
 | 
					  flex-direction: column;
 | 
				
			||||||
  overflow-y: scroll !important;
 | 
					  height: 100%;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.fullscreen-tiptap-editor {
 | 
					// Ensure the editor content has a proper height and breaks words
 | 
				
			||||||
  @apply p-0;
 | 
					.tiptap.ProseMirror {
 | 
				
			||||||
  .ProseMirror {
 | 
					  flex: 1;
 | 
				
			||||||
    min-height: 600px !important;
 | 
					  min-height: 70px;
 | 
				
			||||||
    width: 90%;
 | 
					  overflow-y: auto;
 | 
				
			||||||
    scrollbar-width: none;
 | 
					  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 {
 | 
					.tiptap {
 | 
				
			||||||
  a {
 | 
					  a {
 | 
				
			||||||
    color: #0066cc;
 | 
					    color: #0066cc;
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										345
									
								
								frontend/src/features/conversation/CreateConversation.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										345
									
								
								frontend/src/features/conversation/CreateConversation.vue
									
									
									
									
									
										Normal 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>
 | 
				
			||||||
@@ -1,16 +1,16 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div class="flex flex-wrap px-2 py-1">
 | 
					  <div class="flex flex-wrap">
 | 
				
			||||||
    <div class="flex flex-wrap gap-2">
 | 
					    <div class="flex flex-wrap gap-2">
 | 
				
			||||||
      <div
 | 
					      <div
 | 
				
			||||||
        v-for="action in actions"
 | 
					        v-for="action in actions"
 | 
				
			||||||
        :key="action.type"
 | 
					        :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
 | 
					          <component
 | 
				
			||||||
            :is="getIcon(action.type)"
 | 
					            :is="getIcon(action.type)"
 | 
				
			||||||
            size="16"
 | 
					            size="16"
 | 
				
			||||||
            class="text-primary group-hover:text-primary"
 | 
					            class="text-gray-500 text-primary group-hover:text-primary"
 | 
				
			||||||
          />
 | 
					          />
 | 
				
			||||||
          <Tooltip>
 | 
					          <Tooltip>
 | 
				
			||||||
            <TooltipTrigger as-child>
 | 
					            <TooltipTrigger as-child>
 | 
				
			||||||
@@ -27,7 +27,7 @@
 | 
				
			|||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
        <button
 | 
					        <button
 | 
				
			||||||
          @click.stop="onRemove(action)"
 | 
					          @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"
 | 
					          title="Remove action"
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          <X size="14" />
 | 
					          <X size="14" />
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,330 +1,202 @@
 | 
				
			|||||||
<template>
 | 
					<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">
 | 
					  <div class="text-foreground bg-background">
 | 
				
			||||||
    <!-- Fullscreen editor -->
 | 
					    <!-- Fullscreen editor -->
 | 
				
			||||||
    <Dialog :open="isEditorFullscreen" @update:open="isEditorFullscreen = false">
 | 
					    <Dialog :open="isEditorFullscreen" @update:open="isEditorFullscreen = false">
 | 
				
			||||||
      <DialogContent
 | 
					      <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"
 | 
					        @escapeKeyDown="isEditorFullscreen = false"
 | 
				
			||||||
        hide-close-button="true"
 | 
					        :hide-close-button="true"
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        <div v-if="isEditorFullscreen" class="h-full flex flex-col">
 | 
					        <ReplyBoxContent
 | 
				
			||||||
          <!-- Message type toggle -->
 | 
					          v-if="isEditorFullscreen"
 | 
				
			||||||
          <div class="flex justify-between items-center border-b border-border pb-4">
 | 
					          :isFullscreen="true"
 | 
				
			||||||
            <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"
 | 
					          :aiPrompts="aiPrompts"
 | 
				
			||||||
              @aiPromptSelected="handleAiPromptSelected"
 | 
					          :isSending="isSending"
 | 
				
			||||||
              :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"
 | 
					          :uploadingFiles="uploadingFiles"
 | 
				
			||||||
            :onDelete="handleOnFileDelete"
 | 
					          :clearEditorContent="clearEditorContent"
 | 
				
			||||||
            v-if="attachments.length > 0 || uploadingFiles.length > 0"
 | 
					          :htmlContent="htmlContent"
 | 
				
			||||||
            class="mt-4"
 | 
					          :textContent="textContent"
 | 
				
			||||||
          />
 | 
					          :selectedText="selectedText"
 | 
				
			||||||
 | 
					 | 
				
			||||||
          <!-- Bottom menu bar -->
 | 
					 | 
				
			||||||
          <ReplyBoxBottomMenuBar
 | 
					 | 
				
			||||||
            class="mt-4  pt-4"
 | 
					 | 
				
			||||||
            :handleFileUpload="handleFileUpload"
 | 
					 | 
				
			||||||
            :handleInlineImageUpload="handleInlineImageUpload"
 | 
					 | 
				
			||||||
          :isBold="isBold"
 | 
					          :isBold="isBold"
 | 
				
			||||||
          :isItalic="isItalic"
 | 
					          :isItalic="isItalic"
 | 
				
			||||||
            :isSending="isSending"
 | 
					          :cursorPosition="cursorPosition"
 | 
				
			||||||
            @toggleBold="toggleBold"
 | 
					          :contentToSet="contentToSet"
 | 
				
			||||||
            @toggleItalic="toggleItalic"
 | 
					          :cc="cc"
 | 
				
			||||||
            :enableSend="enableSend"
 | 
					          :bcc="bcc"
 | 
				
			||||||
            :handleSend="handleSend"
 | 
					          :emailErrors="emailErrors"
 | 
				
			||||||
            @emojiSelect="handleEmojiSelect"
 | 
					          :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"
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </DialogContent>
 | 
					      </DialogContent>
 | 
				
			||||||
    </Dialog>
 | 
					    </Dialog>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <!-- Main Editor non-fullscreen -->
 | 
					    <!-- 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
 | 
					    <div
 | 
				
			||||||
          v-if="emailErrors.length > 0"
 | 
					      class="bg-card text-card-foreground box m-2 px-2 pt-2 flex flex-col"
 | 
				
			||||||
          class="mb-4 px-2 py-1 bg-destructive/10 border border-destructive text-destructive rounded"
 | 
					      v-if="!isEditorFullscreen"
 | 
				
			||||||
    >
 | 
					    >
 | 
				
			||||||
          <p v-for="error in emailErrors" :key="error" class="text-sm">{{ error }}</p>
 | 
					      <ReplyBoxContent
 | 
				
			||||||
        </div>
 | 
					        :isFullscreen="false"
 | 
				
			||||||
 | 
					 | 
				
			||||||
        <!-- 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"
 | 
					        :aiPrompts="aiPrompts"
 | 
				
			||||||
          @aiPromptSelected="handleAiPromptSelected"
 | 
					        :isSending="isSending"
 | 
				
			||||||
          :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"
 | 
					        :uploadingFiles="uploadingFiles"
 | 
				
			||||||
          :onDelete="handleOnFileDelete"
 | 
					        :clearEditorContent="clearEditorContent"
 | 
				
			||||||
          v-if="attachments.length > 0 || uploadingFiles.length > 0"
 | 
					        :htmlContent="htmlContent"
 | 
				
			||||||
          class="mt-4"
 | 
					        :textContent="textContent"
 | 
				
			||||||
        />
 | 
					        :selectedText="selectedText"
 | 
				
			||||||
 | 
					 | 
				
			||||||
        <!-- Bottom menu bar -->
 | 
					 | 
				
			||||||
        <ReplyBoxBottomMenuBar
 | 
					 | 
				
			||||||
          class="mt-1"
 | 
					 | 
				
			||||||
          :handleFileUpload="handleFileUpload"
 | 
					 | 
				
			||||||
          :handleInlineImageUpload="handleInlineImageUpload"
 | 
					 | 
				
			||||||
        :isBold="isBold"
 | 
					        :isBold="isBold"
 | 
				
			||||||
        :isItalic="isItalic"
 | 
					        :isItalic="isItalic"
 | 
				
			||||||
          :isSending="isSending"
 | 
					        :cursorPosition="cursorPosition"
 | 
				
			||||||
          @toggleBold="toggleBold"
 | 
					        :contentToSet="contentToSet"
 | 
				
			||||||
          @toggleItalic="toggleItalic"
 | 
					        :cc="cc"
 | 
				
			||||||
          :enableSend="enableSend"
 | 
					        :bcc="bcc"
 | 
				
			||||||
          :handleSend="handleSend"
 | 
					        :emailErrors="emailErrors"
 | 
				
			||||||
          @emojiSelect="handleEmojiSelect"
 | 
					        :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>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
  </div>
 | 
					 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { ref, onMounted, computed, nextTick, watch } from 'vue'
 | 
					import { ref, onMounted, nextTick, watch, computed } from 'vue'
 | 
				
			||||||
import { transformImageSrcToCID } from '@/utils/strings'
 | 
					import { transformImageSrcToCID } from '@/utils/strings'
 | 
				
			||||||
import { handleHTTPError } from '@/utils/http'
 | 
					import { handleHTTPError } from '@/utils/http'
 | 
				
			||||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
					import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
				
			||||||
import { Maximize2, Minimize2 } from 'lucide-vue-next'
 | 
					import { useUserStore } from '@/stores/user'
 | 
				
			||||||
import api from '@/api'
 | 
					import api from '@/api'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import Editor from './ConversationTextEditor.vue'
 | 
					 | 
				
			||||||
import { useConversationStore } from '@/stores/conversation'
 | 
					import { useConversationStore } from '@/stores/conversation'
 | 
				
			||||||
import { Input } from '@/components/ui/input'
 | 
					 | 
				
			||||||
import { Button } from '@/components/ui/button'
 | 
					import { Button } from '@/components/ui/button'
 | 
				
			||||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
 | 
					import {
 | 
				
			||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
 | 
					  Dialog,
 | 
				
			||||||
 | 
					  DialogContent,
 | 
				
			||||||
 | 
					  DialogDescription,
 | 
				
			||||||
 | 
					  DialogFooter,
 | 
				
			||||||
 | 
					  DialogHeader,
 | 
				
			||||||
 | 
					  DialogTitle
 | 
				
			||||||
 | 
					} from '@/components/ui/dialog'
 | 
				
			||||||
 | 
					import { Input } from '@/components/ui/input'
 | 
				
			||||||
import { useEmitter } from '@/composables/useEmitter'
 | 
					import { useEmitter } from '@/composables/useEmitter'
 | 
				
			||||||
import AttachmentsPreview from '@/features/conversation/message/attachment/AttachmentsPreview.vue'
 | 
					import ReplyBoxContent from '@/features/conversation/ReplyBoxContent.vue'
 | 
				
			||||||
import MacroActionsPreview from '@/features/conversation/MacroActionsPreview.vue'
 | 
					import {
 | 
				
			||||||
import ReplyBoxBottomMenuBar from '@/features/conversation/ReplyBoxMenuBar.vue'
 | 
					  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 conversationStore = useConversationStore()
 | 
				
			||||||
const emitter = useEmitter()
 | 
					const emitter = useEmitter()
 | 
				
			||||||
const insertContent = ref(null)
 | 
					const userStore = useUserStore()
 | 
				
			||||||
const setInlineImage = ref(null)
 | 
					const openAIKeyPrompt = ref(false)
 | 
				
			||||||
 | 
					const isOpenAIKeyUpdating = ref(false)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Shared state between the two editor components.
 | 
				
			||||||
const clearEditorContent = ref(false)
 | 
					const clearEditorContent = ref(false)
 | 
				
			||||||
const isEditorFullscreen = ref(false)
 | 
					const isEditorFullscreen = ref(false)
 | 
				
			||||||
const isSending = 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 messageType = ref('reply')
 | 
				
			||||||
const showBcc = ref(false)
 | 
					 | 
				
			||||||
const cc = ref('')
 | 
					const cc = ref('')
 | 
				
			||||||
const bcc = ref('')
 | 
					const bcc = ref('')
 | 
				
			||||||
 | 
					const showBcc = ref(false)
 | 
				
			||||||
const emailErrors = ref([])
 | 
					const emailErrors = ref([])
 | 
				
			||||||
const aiPrompts = ref([])
 | 
					const aiPrompts = ref([])
 | 
				
			||||||
const uploadingFiles = 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 () => {
 | 
					onMounted(async () => {
 | 
				
			||||||
  await fetchAiPrompts()
 | 
					  await fetchAiPrompts()
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const hideBcc = () => {
 | 
					/**
 | 
				
			||||||
  showBcc.value = !showBcc.value
 | 
					 * Fetches AI prompts from the server.
 | 
				
			||||||
}
 | 
					 */
 | 
				
			||||||
 | 
					 | 
				
			||||||
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 }
 | 
					 | 
				
			||||||
)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const fetchAiPrompts = async () => {
 | 
					const fetchAiPrompts = async () => {
 | 
				
			||||||
  try {
 | 
					  try {
 | 
				
			||||||
    const resp = await api.getAiPrompts()
 | 
					    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) => {
 | 
					const handleAiPromptSelected = async (key) => {
 | 
				
			||||||
  try {
 | 
					  try {
 | 
				
			||||||
    const resp = await api.aiCompletion({
 | 
					    const resp = await api.aiCompletion({
 | 
				
			||||||
      prompt_key: key,
 | 
					      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) {
 | 
					  } 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, {
 | 
					    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
      title: 'Error',
 | 
					      title: 'Error',
 | 
				
			||||||
      variant: 'destructive',
 | 
					      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
 | 
					 * 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 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
 | 
					 | 
				
			||||||
})
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const handleFileUpload = (event) => {
 | 
					const handleFileUpload = (event) => {
 | 
				
			||||||
  const files = Array.from(event.target.files)
 | 
					  const files = Array.from(event.target.files)
 | 
				
			||||||
  uploadingFiles.value = files
 | 
					  uploadingFiles.value = files
 | 
				
			||||||
@@ -407,6 +294,7 @@ const handleFileUpload = (event) => {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Inline image upload is not supported yet.
 | 
				
			||||||
const handleInlineImageUpload = (event) => {
 | 
					const handleInlineImageUpload = (event) => {
 | 
				
			||||||
  for (const file of event.target.files) {
 | 
					  for (const file of event.target.files) {
 | 
				
			||||||
    api
 | 
					    api
 | 
				
			||||||
@@ -416,12 +304,13 @@ const handleInlineImageUpload = (event) => {
 | 
				
			|||||||
        linked_model: 'messages'
 | 
					        linked_model: 'messages'
 | 
				
			||||||
      })
 | 
					      })
 | 
				
			||||||
      .then((resp) => {
 | 
					      .then((resp) => {
 | 
				
			||||||
        setInlineImage.value = {
 | 
					        const imageData = {
 | 
				
			||||||
          src: resp.data.data.url,
 | 
					          src: resp.data.data.url,
 | 
				
			||||||
          alt: resp.data.data.filename,
 | 
					          alt: resp.data.data.filename,
 | 
				
			||||||
          title: resp.data.data.uuid
 | 
					          title: resp.data.data.uuid
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        conversationStore.conversation.mediaFiles.push(resp.data.data)
 | 
					        conversationStore.conversation.mediaFiles.push(resp.data.data)
 | 
				
			||||||
 | 
					        return imageData
 | 
				
			||||||
      })
 | 
					      })
 | 
				
			||||||
      .catch((error) => {
 | 
					      .catch((error) => {
 | 
				
			||||||
        emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
					        emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
@@ -433,44 +322,24 @@ const handleInlineImageUpload = (event) => {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const validateEmails = (field) => {
 | 
					/**
 | 
				
			||||||
  const emails = field === 'cc' ? cc.value : bcc.value
 | 
					 * Returns true if the editor has text content.
 | 
				
			||||||
  const emailList = emails
 | 
					 */
 | 
				
			||||||
    .split(',')
 | 
					const hasTextContent = computed(() => {
 | 
				
			||||||
    .map((e) => e.trim())
 | 
					  return textContent.value.trim().length > 0
 | 
				
			||||||
    .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
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Processes the send action.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					const processSend = async () => {
 | 
				
			||||||
 | 
					  let hasAPIErrored = false
 | 
				
			||||||
  isEditorFullscreen.value = false
 | 
					  isEditorFullscreen.value = false
 | 
				
			||||||
  try {
 | 
					  try {
 | 
				
			||||||
    isSending.value = true
 | 
					    isSending.value = true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Send message if there is text content in the editor.
 | 
					    // Send message if there is text content in the editor.
 | 
				
			||||||
    if (hasTextContent.value) {
 | 
					    if (hasTextContent.value > 0) {
 | 
				
			||||||
      // Replace inline image url with cid.
 | 
					      // Replace inline image url with cid.
 | 
				
			||||||
      const message = transformImageSrcToCID(htmlContent.value)
 | 
					      const message = transformImageSrcToCID(htmlContent.value)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -498,7 +367,7 @@ const handleSend = async () => {
 | 
				
			|||||||
          .split(',')
 | 
					          .split(',')
 | 
				
			||||||
          .map((email) => email.trim())
 | 
					          .map((email) => email.trim())
 | 
				
			||||||
          .filter((email) => email),
 | 
					          .filter((email) => email),
 | 
				
			||||||
        bcc: showBcc.value
 | 
					        bcc: bcc.value
 | 
				
			||||||
          ? bcc.value
 | 
					          ? bcc.value
 | 
				
			||||||
              .split(',')
 | 
					              .split(',')
 | 
				
			||||||
              .map((email) => email.trim())
 | 
					              .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) {
 | 
					    if (conversationStore.conversation?.macro?.actions?.length > 0) {
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
        await api.applyMacro(
 | 
					        await api.applyMacro(
 | 
				
			||||||
          conversationStore.current.uuid,
 | 
					          conversationStore.current.uuid,
 | 
				
			||||||
          conversationStore.conversation.macro.id,
 | 
					          conversationStore.conversation.macro.id,
 | 
				
			||||||
          conversationStore.conversation.macro.actions
 | 
					          conversationStore.conversation.macro.actions
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
      } catch (error) {
 | 
					      } catch (error) {
 | 
				
			||||||
        emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
					        emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
          title: 'Error',
 | 
					          title: 'Error',
 | 
				
			||||||
          variant: 'destructive',
 | 
					          variant: 'destructive',
 | 
				
			||||||
          description: handleHTTPError(error).message
 | 
					          description: handleHTTPError(error).message
 | 
				
			||||||
        })
 | 
					        })
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    hasAPIErrored = true
 | 
				
			||||||
 | 
					    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
 | 
					      title: 'Error',
 | 
				
			||||||
 | 
					      variant: 'destructive',
 | 
				
			||||||
 | 
					      description: handleHTTPError(error).message
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
  } finally {
 | 
					  } finally {
 | 
				
			||||||
    isSending.value = false
 | 
					    // If API has NOT errored clear state.
 | 
				
			||||||
 | 
					    if (hasAPIErrored === false) {
 | 
				
			||||||
 | 
					      // Clear editor.
 | 
				
			||||||
      clearEditorContent.value = true
 | 
					      clearEditorContent.value = true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Clear macro.
 | 
				
			||||||
      conversationStore.resetMacro()
 | 
					      conversationStore.resetMacro()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Clear media files.
 | 
				
			||||||
      conversationStore.resetMediaFiles()
 | 
					      conversationStore.resetMediaFiles()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Clear any email errors.
 | 
				
			||||||
      emailErrors.value = []
 | 
					      emailErrors.value = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      nextTick(() => {
 | 
					      nextTick(() => {
 | 
				
			||||||
        clearEditorContent.value = false
 | 
					        clearEditorContent.value = false
 | 
				
			||||||
      })
 | 
					      })
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					    isSending.value = false
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  // Update assignee last seen timestamp.
 | 
				
			||||||
  api.updateAssigneeLastSeen(conversationStore.current.uuid)
 | 
					  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) => {
 | 
					const handleOnFileDelete = (uuid) => {
 | 
				
			||||||
  conversationStore.conversation.mediaFiles = conversationStore.conversation.mediaFiles.filter(
 | 
					  conversationStore.conversation.mediaFiles = conversationStore.conversation.mediaFiles.filter(
 | 
				
			||||||
    (item) => item.uuid !== uuid
 | 
					    (item) => item.uuid !== uuid
 | 
				
			||||||
  )
 | 
					  )
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const handleEmojiSelect = (emoji) => {
 | 
					/**
 | 
				
			||||||
  insertContent.value = undefined
 | 
					 * Watches for changes in the conversation's macro id and update message content.
 | 
				
			||||||
  // 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.
 | 
					 | 
				
			||||||
watch(
 | 
					watch(
 | 
				
			||||||
  () => conversationStore.conversation.macro,
 | 
					  () => conversationStore.conversation.macro.id,
 | 
				
			||||||
  () => {
 | 
					  () => {
 | 
				
			||||||
    // hack: Quill editor adds <p><br></p> replace with <p></p>
 | 
					    // Setting timestamp, so the same macro can be set again.
 | 
				
			||||||
    if (conversationStore.conversation?.macro?.message_content) {
 | 
					    contentToSet.value = JSON.stringify({
 | 
				
			||||||
      contentToSet.value = conversationStore.conversation.macro.message_content.replace(
 | 
					      content: conversationStore.conversation.macro.message_content,
 | 
				
			||||||
        /<p><br><\/p>/g,
 | 
					      timestamp: Date.now()
 | 
				
			||||||
        '<p></p>'
 | 
					    })
 | 
				
			||||||
      )
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  { deep: true }
 | 
					  { 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>
 | 
					</script>
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										307
									
								
								frontend/src/features/conversation/ReplyBoxContent.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										307
									
								
								frontend/src/features/conversation/ReplyBoxContent.vue
									
									
									
									
									
										Normal 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>
 | 
				
			||||||
@@ -35,7 +35,9 @@
 | 
				
			|||||||
        <Smile class="h-4 w-4" />
 | 
					        <Smile class="h-4 w-4" />
 | 
				
			||||||
      </Toggle>
 | 
					      </Toggle>
 | 
				
			||||||
    </div>
 | 
					    </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>
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -52,11 +54,10 @@ const attachmentInput = ref(null)
 | 
				
			|||||||
const inlineImageInput = ref(null)
 | 
					const inlineImageInput = ref(null)
 | 
				
			||||||
const isEmojiPickerVisible = ref(false)
 | 
					const isEmojiPickerVisible = ref(false)
 | 
				
			||||||
const emojiPickerRef = ref(null)
 | 
					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({
 | 
					defineProps({
 | 
				
			||||||
  isBold: Boolean,
 | 
					 | 
				
			||||||
  isItalic: Boolean,
 | 
					 | 
				
			||||||
  isSending: Boolean,
 | 
					  isSending: Boolean,
 | 
				
			||||||
  enableSend: Boolean,
 | 
					  enableSend: Boolean,
 | 
				
			||||||
  handleSend: Function,
 | 
					  handleSend: Function,
 | 
				
			||||||
@@ -69,8 +70,12 @@ onClickOutside(emojiPickerRef, () => {
 | 
				
			|||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const triggerFileUpload = () => {
 | 
					const triggerFileUpload = () => {
 | 
				
			||||||
 | 
					  if (attachmentInput.value) {
 | 
				
			||||||
 | 
					    // Clear the value to allow the same file to be uploaded again.
 | 
				
			||||||
 | 
					    attachmentInput.value.value = ''
 | 
				
			||||||
    attachmentInput.value.click()
 | 
					    attachmentInput.value.click()
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const toggleEmojiPicker = () => {
 | 
					const toggleEmojiPicker = () => {
 | 
				
			||||||
  isEmojiPickerVisible.value = !isEmojiPickerVisible.value
 | 
					  isEmojiPickerVisible.value = !isEmojiPickerVisible.value
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -8,7 +8,8 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    <!-- Filters -->
 | 
					    <!-- Filters -->
 | 
				
			||||||
    <div class="bg-white p-2 flex justify-between items-center">
 | 
					    <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>
 | 
					        <DropdownMenuTrigger asChild>
 | 
				
			||||||
          <Button variant="ghost" class="w-30">
 | 
					          <Button variant="ghost" class="w-30">
 | 
				
			||||||
            <div>
 | 
					            <div>
 | 
				
			||||||
@@ -28,6 +29,9 @@
 | 
				
			|||||||
          </DropdownMenuItem>
 | 
					          </DropdownMenuItem>
 | 
				
			||||||
        </DropdownMenuContent>
 | 
					        </DropdownMenuContent>
 | 
				
			||||||
      </DropdownMenu>
 | 
					      </DropdownMenu>
 | 
				
			||||||
 | 
					      <div v-else></div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <!-- Sort dropdown-menu -->
 | 
				
			||||||
      <DropdownMenu>
 | 
					      <DropdownMenu>
 | 
				
			||||||
        <DropdownMenuTrigger asChild>
 | 
					        <DropdownMenuTrigger asChild>
 | 
				
			||||||
          <Button variant="ghost" class="w-30">
 | 
					          <Button variant="ghost" class="w-30">
 | 
				
			||||||
@@ -124,7 +128,10 @@
 | 
				
			|||||||
          <Loader2 v-if="isLoading" class="mr-2 h-4 w-4 animate-spin" />
 | 
					          <Loader2 v-if="isLoading" class="mr-2 h-4 w-4 animate-spin" />
 | 
				
			||||||
          {{ isLoading ? 'Loading...' : 'Load more' }}
 | 
					          {{ isLoading ? 'Loading...' : 'Load more' }}
 | 
				
			||||||
        </Button>
 | 
					        </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
 | 
					          All conversations loaded
 | 
				
			||||||
        </p>
 | 
					        </p>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -57,16 +57,18 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        <div class="flex items-center mt-2 space-x-2">
 | 
					        <div class="flex items-center mt-2 space-x-2">
 | 
				
			||||||
          <SlaBadge
 | 
					          <SlaBadge
 | 
				
			||||||
 | 
					            v-if="conversation.first_response_due_at"
 | 
				
			||||||
            :dueAt="conversation.first_response_due_at"
 | 
					            :dueAt="conversation.first_response_due_at"
 | 
				
			||||||
            :actualAt="conversation.first_reply_at"
 | 
					            :actualAt="conversation.first_reply_at"
 | 
				
			||||||
            :label="'FRD'"
 | 
					            :label="'FRD'"
 | 
				
			||||||
            :showSLAMet="false"
 | 
					            :showExtra="false"
 | 
				
			||||||
          />
 | 
					          />
 | 
				
			||||||
          <SlaBadge
 | 
					          <SlaBadge
 | 
				
			||||||
 | 
					            v-if="conversation.resolution_due_at"
 | 
				
			||||||
            :dueAt="conversation.resolution_due_at"
 | 
					            :dueAt="conversation.resolution_due_at"
 | 
				
			||||||
            :actualAt="conversation.resolved_at"
 | 
					            :actualAt="conversation.resolved_at"
 | 
				
			||||||
            :label="'RD'"
 | 
					            :label="'RD'"
 | 
				
			||||||
            :showSLAMet="false"
 | 
					            :showExtra="false"
 | 
				
			||||||
          />
 | 
					          />
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -19,7 +19,11 @@
 | 
				
			|||||||
        }"
 | 
					        }"
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        <!-- Message Content -->
 | 
					        <!-- 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 -->
 | 
					        <!-- Attachments -->
 | 
				
			||||||
        <MessageAttachmentPreview :attachments="nonInlineAttachments" />
 | 
					        <MessageAttachmentPreview :attachments="nonInlineAttachments" />
 | 
				
			||||||
@@ -125,3 +129,9 @@ const retryMessage = (msg) => {
 | 
				
			|||||||
  api.retryMessage(convStore.current.uuid, msg.uuid)
 | 
					  api.retryMessage(convStore.current.uuid, msg.uuid)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style scoped>
 | 
				
			||||||
 | 
					.overflow-wrap-anywhere {
 | 
				
			||||||
 | 
					  overflow-wrap: anywhere;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -29,7 +29,7 @@
 | 
				
			|||||||
        <Letter
 | 
					        <Letter
 | 
				
			||||||
          :html="sanitizedMessageContent"
 | 
					          :html="sanitizedMessageContent"
 | 
				
			||||||
          :allowedSchemas="['cid', 'https', 'http']"
 | 
					          :allowedSchemas="['cid', 'https', 'http']"
 | 
				
			||||||
          class="mb-1"
 | 
					          class="mb-1 native-html"
 | 
				
			||||||
          :class="{ 'mb-3': message.attachments.length > 0 }"
 | 
					          :class="{ 'mb-3': message.attachments.length > 0 }"
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -22,16 +22,14 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        <MessagesSkeleton :count="10" v-if="conversationStore.messages.loading" />
 | 
					        <MessagesSkeleton :count="10" v-if="conversationStore.messages.loading" />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <TransitionGroup
 | 
					        <TransitionGroup v-else enter-active-class="animate-slide-in" tag="div" class="space-y-4">
 | 
				
			||||||
          v-else
 | 
					 | 
				
			||||||
          enter-active-class="animate-slide-in"
 | 
					 | 
				
			||||||
          tag="div"
 | 
					 | 
				
			||||||
          class="space-y-4"
 | 
					 | 
				
			||||||
        >
 | 
					 | 
				
			||||||
          <div
 | 
					          <div
 | 
				
			||||||
            v-for="message in conversationStore.conversationMessages"
 | 
					            v-for="(message, index) in conversationStore.conversationMessages"
 | 
				
			||||||
            :key="message.uuid"
 | 
					            :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">
 | 
					            <div v-if="!message.private">
 | 
				
			||||||
              <ContactMessageBubble :message="message" v-if="message.type === 'incoming'" />
 | 
					              <ContactMessageBubble :message="message" v-if="message.type === 'incoming'" />
 | 
				
			||||||
@@ -57,7 +55,7 @@
 | 
				
			|||||||
      leave-from-class="opacity-100 translate-y-0"
 | 
					      leave-from-class="opacity-100 translate-y-0"
 | 
				
			||||||
      leave-to-class="opacity-0 translate-y-1"
 | 
					      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
 | 
					        <button
 | 
				
			||||||
          @click="handleScrollToBottom"
 | 
					          @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"
 | 
					          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"
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -27,8 +27,10 @@
 | 
				
			|||||||
    <div class="flex justify-start items-center space-x-2">
 | 
					    <div class="flex justify-start items-center space-x-2">
 | 
				
			||||||
      <p class="font-medium">First reply at</p>
 | 
					      <p class="font-medium">First reply at</p>
 | 
				
			||||||
      <SlaBadge
 | 
					      <SlaBadge
 | 
				
			||||||
 | 
					        v-if="conversation.first_response_due_at"
 | 
				
			||||||
        :dueAt="conversation.first_response_due_at"
 | 
					        :dueAt="conversation.first_response_due_at"
 | 
				
			||||||
        :actualAt="conversation.first_reply_at"
 | 
					        :actualAt="conversation.first_reply_at"
 | 
				
			||||||
 | 
					        :key="conversation.uuid"
 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
    <Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
 | 
					    <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 flex-col gap-1 mb-5">
 | 
				
			||||||
    <div class="flex justify-start items-center space-x-2">
 | 
					    <div class="flex justify-start items-center space-x-2">
 | 
				
			||||||
      <p class="font-medium">Resolved at</p>
 | 
					      <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>
 | 
					    </div>
 | 
				
			||||||
    <Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
 | 
					    <Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
 | 
				
			||||||
    <div v-else>
 | 
					    <div v-else>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,19 +1,21 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <div v-if="dueAt" class="flex justify-start items-center space-x-2">
 | 
					  <div v-if="dueAt" class="flex justify-start items-center space-x-2">
 | 
				
			||||||
    <TransitionGroup name="fade">
 | 
					 | 
				
			||||||
    <!-- Overdue-->
 | 
					    <!-- Overdue-->
 | 
				
			||||||
    <span v-if="sla?.status === 'overdue'" key="overdue" class="sla-badge box sla-overdue">
 | 
					    <span v-if="sla?.status === 'overdue'" key="overdue" class="sla-badge box sla-overdue">
 | 
				
			||||||
        <AlertCircle size="10" class="text-red-800" />
 | 
					      <AlertCircle size="12" class="text-red-800" />
 | 
				
			||||||
        <span class="text-xs text-red-800">{{ label }} Overdue</span>
 | 
					      <span class="sla-text text-red-800"
 | 
				
			||||||
 | 
					        >{{ label }} Overdue
 | 
				
			||||||
 | 
					        <span v-if="showExtra">by {{ sla.value }}</span>
 | 
				
			||||||
 | 
					      </span>
 | 
				
			||||||
    </span>
 | 
					    </span>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <!-- SLA Hit -->
 | 
					    <!-- SLA Hit -->
 | 
				
			||||||
    <span
 | 
					    <span
 | 
				
			||||||
        v-else-if="sla?.status === 'hit' && showSLAMet"
 | 
					      v-else-if="sla?.status === 'hit' && showExtra"
 | 
				
			||||||
      key="sla-hit"
 | 
					      key="sla-hit"
 | 
				
			||||||
      class="sla-badge box sla-hit"
 | 
					      class="sla-badge box sla-hit"
 | 
				
			||||||
    >
 | 
					    >
 | 
				
			||||||
        <CheckCircle size="10" />
 | 
					      <CheckCircle size="12" />
 | 
				
			||||||
      <span class="sla-text">{{ label }} SLA met</span>
 | 
					      <span class="sla-text">{{ label }} SLA met</span>
 | 
				
			||||||
    </span>
 | 
					    </span>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -23,10 +25,9 @@
 | 
				
			|||||||
      key="remaining"
 | 
					      key="remaining"
 | 
				
			||||||
      class="sla-badge box sla-remaining"
 | 
					      class="sla-badge box sla-remaining"
 | 
				
			||||||
    >
 | 
					    >
 | 
				
			||||||
        <Clock size="10" />
 | 
					      <Clock size="12" />
 | 
				
			||||||
      <span class="sla-text">{{ label }} {{ sla.value }}</span>
 | 
					      <span class="sla-text">{{ label }} {{ sla.value }}</span>
 | 
				
			||||||
    </span>
 | 
					    </span>
 | 
				
			||||||
    </TransitionGroup>
 | 
					 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -38,12 +39,16 @@ const props = defineProps({
 | 
				
			|||||||
  dueAt: String,
 | 
					  dueAt: String,
 | 
				
			||||||
  actualAt: String,
 | 
					  actualAt: String,
 | 
				
			||||||
  label: String,
 | 
					  label: String,
 | 
				
			||||||
  showSLAMet: {
 | 
					  showExtra: {
 | 
				
			||||||
    type: Boolean,
 | 
					    type: Boolean,
 | 
				
			||||||
    default: true
 | 
					    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>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<style scoped>
 | 
					<style scoped>
 | 
				
			||||||
@@ -62,4 +67,8 @@ const { sla } = useSla(ref(props.dueAt), ref(props.actualAt))
 | 
				
			|||||||
.sla-remaining {
 | 
					.sla-remaining {
 | 
				
			||||||
  @apply bg-yellow-100 text-yellow-800;
 | 
					  @apply bg-yellow-100 text-yellow-800;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.sla-text {
 | 
				
			||||||
 | 
					  @apply text-[0.65rem];
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
</style>
 | 
					</style>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -65,6 +65,7 @@
 | 
				
			|||||||
              </template>
 | 
					              </template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
              <template #selected="{ selected }">
 | 
					              <template #selected="{ selected }">
 | 
				
			||||||
 | 
					                <div v-if="!selected">Select value</div>
 | 
				
			||||||
                <div v-if="modelFilter.field === 'assigned_user_id'">
 | 
					                <div v-if="modelFilter.field === 'assigned_user_id'">
 | 
				
			||||||
                  <div class="flex items-center gap-2">
 | 
					                  <div class="flex items-center gap-2">
 | 
				
			||||||
                    <div v-if="selected" class="flex items-center gap-1">
 | 
					                    <div v-if="selected" class="flex items-center gap-1">
 | 
				
			||||||
@@ -76,7 +77,6 @@
 | 
				
			|||||||
                      </Avatar>
 | 
					                      </Avatar>
 | 
				
			||||||
                      <span>{{ selected.label }}</span>
 | 
					                      <span>{{ selected.label }}</span>
 | 
				
			||||||
                    </div>
 | 
					                    </div>
 | 
				
			||||||
                    <span v-else>Select user</span>
 | 
					 | 
				
			||||||
                  </div>
 | 
					                  </div>
 | 
				
			||||||
                </div>
 | 
					                </div>
 | 
				
			||||||
                <div v-else-if="modelFilter.field === 'assigned_team_id'">
 | 
					                <div v-else-if="modelFilter.field === 'assigned_team_id'">
 | 
				
			||||||
@@ -85,7 +85,6 @@
 | 
				
			|||||||
                      {{ selected.emoji }}
 | 
					                      {{ selected.emoji }}
 | 
				
			||||||
                      <span>{{ selected.label }}</span>
 | 
					                      <span>{{ selected.label }}</span>
 | 
				
			||||||
                    </span>
 | 
					                    </span>
 | 
				
			||||||
                    <span v-else>Select team</span>
 | 
					 | 
				
			||||||
                  </div>
 | 
					                  </div>
 | 
				
			||||||
                </div>
 | 
					                </div>
 | 
				
			||||||
                <div v-else-if="selected">
 | 
					                <div v-else-if="selected">
 | 
				
			||||||
@@ -114,7 +113,7 @@
 | 
				
			|||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <div class="flex items-center justify-between pt-3">
 | 
					    <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
 | 
					        <Plus class="w-3 h-3 mr-1" /> Add filter
 | 
				
			||||||
      </Button>
 | 
					      </Button>
 | 
				
			||||||
      <div class="flex gap-2" v-if="showButtons">
 | 
					      <div class="flex gap-2" v-if="showButtons">
 | 
				
			||||||
@@ -159,7 +158,7 @@ const createFilter = () => ({ field: '', operator: '', value: '' })
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
onMounted(() => {
 | 
					onMounted(() => {
 | 
				
			||||||
  if (modelValue.value.length === 0) {
 | 
					  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)
 | 
					  const fieldConfig = props.fields.find((f) => f.field === field)
 | 
				
			||||||
  return fieldConfig?.model || ''
 | 
					  return fieldConfig?.model || ''
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Set model for each filter
 | 
				
			||||||
watch(
 | 
					watch(
 | 
				
			||||||
  () => modelValue.value,
 | 
					  () => modelValue.value,
 | 
				
			||||||
  (filters) => {
 | 
					  (filters) => {
 | 
				
			||||||
@@ -183,8 +184,25 @@ watch(
 | 
				
			|||||||
  { deep: true }
 | 
					  { deep: true }
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const addFilter = () => modelValue.value.push(createFilter())
 | 
					// Reset operator and value when field changes for a filter at a given index
 | 
				
			||||||
const removeFilter = (index) => modelValue.value.splice(index, 1)
 | 
					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 applyFilters = () => emit('apply', validFilters.value)
 | 
				
			||||||
const clearFilters = () => {
 | 
					const clearFilters = () => {
 | 
				
			||||||
  modelValue.value = []
 | 
					  modelValue.value = []
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,9 +1,11 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <Dialog :open="openDialog" @update:open="openDialog = false">
 | 
					  <Dialog :open="openDialog" @update:open="openDialog = false">
 | 
				
			||||||
    <DialogContent>
 | 
					    <DialogContent class="min-w-[40%] min-h-[30%]">
 | 
				
			||||||
      <DialogHeader class="space-y-1">
 | 
					      <DialogHeader class="space-y-1">
 | 
				
			||||||
        <DialogTitle>{{ view?.id ? 'Edit' : 'Create' }} view</DialogTitle>
 | 
					        <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>
 | 
					      </DialogHeader>
 | 
				
			||||||
      <form @submit.prevent="onSubmit">
 | 
					      <form @submit.prevent="onSubmit">
 | 
				
			||||||
        <div class="grid gap-4 py-4">
 | 
					        <div class="grid gap-4 py-4">
 | 
				
			||||||
@@ -11,7 +13,13 @@
 | 
				
			|||||||
            <FormItem>
 | 
					            <FormItem>
 | 
				
			||||||
              <FormLabel>Name</FormLabel>
 | 
					              <FormLabel>Name</FormLabel>
 | 
				
			||||||
              <FormControl>
 | 
					              <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>
 | 
					              </FormControl>
 | 
				
			||||||
              <FormDescription>Enter an unique name for your view.</FormDescription>
 | 
					              <FormDescription>Enter an unique name for your view.</FormDescription>
 | 
				
			||||||
              <FormMessage />
 | 
					              <FormMessage />
 | 
				
			||||||
@@ -21,9 +29,13 @@
 | 
				
			|||||||
            <FormItem>
 | 
					            <FormItem>
 | 
				
			||||||
              <FormLabel>Filters</FormLabel>
 | 
					              <FormLabel>Filters</FormLabel>
 | 
				
			||||||
              <FormControl>
 | 
					              <FormControl>
 | 
				
			||||||
                <FilterBuilder :fields="filterFields" :showButtons="false" v-bind="componentField" />
 | 
					                <FilterBuilder
 | 
				
			||||||
 | 
					                  :fields="filterFields"
 | 
				
			||||||
 | 
					                  :showButtons="false"
 | 
				
			||||||
 | 
					                  v-bind="componentField"
 | 
				
			||||||
 | 
					                />
 | 
				
			||||||
              </FormControl>
 | 
					              </FormControl>
 | 
				
			||||||
              <FormDescription>Add multiple filters to customize view.</FormDescription>
 | 
					              <FormDescription> Set one or more filters to customize view.</FormDescription>
 | 
				
			||||||
              <FormMessage />
 | 
					              <FormMessage />
 | 
				
			||||||
            </FormItem>
 | 
					            </FormItem>
 | 
				
			||||||
          </FormField>
 | 
					          </FormField>
 | 
				
			||||||
@@ -65,6 +77,7 @@ import { toTypedSchema } from '@vee-validate/zod'
 | 
				
			|||||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
					import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
				
			||||||
import { useEmitter } from '@/composables/useEmitter'
 | 
					import { useEmitter } from '@/composables/useEmitter'
 | 
				
			||||||
import { handleHTTPError } from '@/utils/http'
 | 
					import { handleHTTPError } from '@/utils/http'
 | 
				
			||||||
 | 
					import { OPERATOR } from '@/constants/filterConfig.js'
 | 
				
			||||||
import { z } from 'zod'
 | 
					import { z } from 'zod'
 | 
				
			||||||
import api from '@/api'
 | 
					import api from '@/api'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -91,27 +104,53 @@ const formSchema = toTypedSchema(
 | 
				
			|||||||
    name: z
 | 
					    name: z
 | 
				
			||||||
      .string()
 | 
					      .string()
 | 
				
			||||||
      .min(2, { message: 'Name must be at least 2 characters.' })
 | 
					      .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
 | 
					    filters: z
 | 
				
			||||||
      .array(
 | 
					      .array(
 | 
				
			||||||
        z.object({
 | 
					        z.object({
 | 
				
			||||||
          model: z.string({ required_error: 'Filter required' }),
 | 
					          model: z.string({ required_error: 'Filter required' }),
 | 
				
			||||||
          field: z.string({ required_error: 'Filter required' }),
 | 
					          field: z.string({ required_error: 'Filter required' }),
 | 
				
			||||||
          operator: 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([])
 | 
					      .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
 | 
					  if (isSubmitting.value) return
 | 
				
			||||||
 | 
					 | 
				
			||||||
  isSubmitting.value = true
 | 
					  isSubmitting.value = true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  try {
 | 
					  try {
 | 
				
			||||||
 | 
					    const values = form.values
 | 
				
			||||||
    if (values.id) {
 | 
					    if (values.id) {
 | 
				
			||||||
      await api.updateView(values.id, values)
 | 
					      await api.updateView(values.id, values)
 | 
				
			||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
@@ -129,8 +168,9 @@ const onSubmit = form.handleSubmit(async (values) => {
 | 
				
			|||||||
  } finally {
 | 
					  } finally {
 | 
				
			||||||
    isSubmitting.value = false
 | 
					    isSubmitting.value = false
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
})
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Set form values when view prop changes
 | 
				
			||||||
watch(
 | 
					watch(
 | 
				
			||||||
  () => view.value,
 | 
					  () => view.value,
 | 
				
			||||||
  (newVal) => {
 | 
					  (newVal) => {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -7,6 +7,7 @@ import mitt from 'mitt'
 | 
				
			|||||||
import api from './api'
 | 
					import api from './api'
 | 
				
			||||||
import './assets/styles/main.scss'
 | 
					import './assets/styles/main.scss'
 | 
				
			||||||
import './utils/strings.js'
 | 
					import './utils/strings.js'
 | 
				
			||||||
 | 
					import VueDOMPurifyHTML from 'vue-dompurify-html'
 | 
				
			||||||
import Root from './Root.vue'
 | 
					import Root from './Root.vue'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const setFavicon = (url) => {
 | 
					const setFavicon = (url) => {
 | 
				
			||||||
@@ -50,6 +51,7 @@ async function initApp () {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  app.use(router)
 | 
					  app.use(router)
 | 
				
			||||||
  app.use(i18n)
 | 
					  app.use(i18n)
 | 
				
			||||||
 | 
					  app.use(VueDOMPurifyHTML)
 | 
				
			||||||
  app.mount('#app')
 | 
					  app.mount('#app')
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -65,7 +65,6 @@ const routes = [
 | 
				
			|||||||
            path: '',
 | 
					            path: '',
 | 
				
			||||||
            name: 'team-inbox',
 | 
					            name: 'team-inbox',
 | 
				
			||||||
            component: InboxView,
 | 
					            component: InboxView,
 | 
				
			||||||
            props: true,
 | 
					 | 
				
			||||||
            meta: { title: 'Team inbox' }
 | 
					            meta: { title: 'Team inbox' }
 | 
				
			||||||
          },
 | 
					          },
 | 
				
			||||||
          {
 | 
					          {
 | 
				
			||||||
@@ -88,7 +87,6 @@ const routes = [
 | 
				
			|||||||
            path: '',
 | 
					            path: '',
 | 
				
			||||||
            name: 'view-inbox',
 | 
					            name: 'view-inbox',
 | 
				
			||||||
            component: InboxView,
 | 
					            component: InboxView,
 | 
				
			||||||
            props: true,
 | 
					 | 
				
			||||||
            meta: { title: 'View inbox' }
 | 
					            meta: { title: 'View inbox' }
 | 
				
			||||||
          },
 | 
					          },
 | 
				
			||||||
          {
 | 
					          {
 | 
				
			||||||
@@ -118,7 +116,6 @@ const routes = [
 | 
				
			|||||||
            path: '',
 | 
					            path: '',
 | 
				
			||||||
            name: 'inbox',
 | 
					            name: 'inbox',
 | 
				
			||||||
            component: InboxView,
 | 
					            component: InboxView,
 | 
				
			||||||
            props: true,
 | 
					 | 
				
			||||||
            meta: {
 | 
					            meta: {
 | 
				
			||||||
              title: 'Inbox',
 | 
					              title: 'Inbox',
 | 
				
			||||||
              type: route => route.params.type === 'assigned' ? 'My inbox' : route.params.type
 | 
					              type: route => route.params.type === 'assigned' ? 'My inbox' : route.params.type
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,5 @@
 | 
				
			|||||||
import { defineStore } from 'pinia'
 | 
					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 { CONVERSATION_LIST_TYPE, CONVERSATION_DEFAULT_STATUSES } from '@/constants/conversation'
 | 
				
			||||||
import { handleHTTPError } from '@/utils/http'
 | 
					import { handleHTTPError } from '@/utils/http'
 | 
				
			||||||
import { useEmitter } from '@/composables/useEmitter'
 | 
					import { useEmitter } from '@/composables/useEmitter'
 | 
				
			||||||
@@ -8,8 +8,8 @@ import MessageCache from '@/utils/conversation-message-cache'
 | 
				
			|||||||
import api from '@/api'
 | 
					import api from '@/api'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const useConversationStore = defineStore('conversation', () => {
 | 
					export const useConversationStore = defineStore('conversation', () => {
 | 
				
			||||||
  const CONV_LIST_PAGE_SIZE = 100
 | 
					  const CONV_LIST_PAGE_SIZE = 50
 | 
				
			||||||
  const MESSAGE_LIST_PAGE_SIZE = 100
 | 
					  const MESSAGE_LIST_PAGE_SIZE = 30
 | 
				
			||||||
  const priorities = ref([])
 | 
					  const priorities = ref([])
 | 
				
			||||||
  const statuses = ref([])
 | 
					  const statuses = ref([])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -110,8 +110,11 @@ export const useConversationStore = defineStore('conversation', () => {
 | 
				
			|||||||
    clearInterval(reRenderInterval)
 | 
					    clearInterval(reRenderInterval)
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  function setMacro (macros) {
 | 
					  async function setMacro (macro) {
 | 
				
			||||||
    conversation.macro = macros
 | 
					    // Clear existing macro.
 | 
				
			||||||
 | 
					    conversation.macro = {}
 | 
				
			||||||
 | 
					    await nextTick()
 | 
				
			||||||
 | 
					    conversation.macro = macro
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  function removeMacroAction (action) {
 | 
					  function removeMacroAction (action) {
 | 
				
			||||||
@@ -231,6 +234,10 @@ export const useConversationStore = defineStore('conversation', () => {
 | 
				
			|||||||
    return conversation.data || {}
 | 
					    return conversation.data || {}
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const hasConversationOpen = computed(() => {
 | 
				
			||||||
 | 
					    return Object.keys(conversation.data || {}).length > 0
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const currentBCC = computed(() => {
 | 
					  const currentBCC = computed(() => {
 | 
				
			||||||
    return conversation.data?.bcc || []
 | 
					    return conversation.data?.bcc || []
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
@@ -282,8 +289,10 @@ export const useConversationStore = defineStore('conversation', () => {
 | 
				
			|||||||
  async function fetchMessages (uuid, fetchNextPage = false) {
 | 
					  async function fetchMessages (uuid, fetchNextPage = false) {
 | 
				
			||||||
    // Messages are already cached?
 | 
					    // Messages are already cached?
 | 
				
			||||||
    let hasMessages = messages.data.getAllPagesMessages(uuid)
 | 
					    let hasMessages = messages.data.getAllPagesMessages(uuid)
 | 
				
			||||||
    if (hasMessages.length > 0 && !fetchNextPage)
 | 
					    if (hasMessages.length > 0 && !fetchNextPage) {
 | 
				
			||||||
 | 
					      markConversationAsRead(uuid)
 | 
				
			||||||
      return
 | 
					      return
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Fetch messages from server.
 | 
					    // Fetch messages from server.
 | 
				
			||||||
    messages.loading = true
 | 
					    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 response = await api.getConversationMessages(uuid, { page: page, page_size: MESSAGE_LIST_PAGE_SIZE })
 | 
				
			||||||
      const result = response.data?.data || {}
 | 
					      const result = response.data?.data || {}
 | 
				
			||||||
      const newMessages = result.results || []
 | 
					      const newMessages = result.results || []
 | 
				
			||||||
      // Mark conversation as read
 | 
					 | 
				
			||||||
      markConversationAsRead(uuid)
 | 
					      markConversationAsRead(uuid)
 | 
				
			||||||
      // Cache messages
 | 
					      // Cache messages
 | 
				
			||||||
      messages.data.addMessages(uuid, newMessages, result.page, result.total_pages)
 | 
					      messages.data.addMessages(uuid, newMessages, result.page, result.total_pages)
 | 
				
			||||||
@@ -608,8 +616,8 @@ export const useConversationStore = defineStore('conversation', () => {
 | 
				
			|||||||
    Object.assign(conversation, {
 | 
					    Object.assign(conversation, {
 | 
				
			||||||
      data: null,
 | 
					      data: null,
 | 
				
			||||||
      participants: {},
 | 
					      participants: {},
 | 
				
			||||||
      macro: {},
 | 
					 | 
				
			||||||
      mediaFiles: [],
 | 
					      mediaFiles: [],
 | 
				
			||||||
 | 
					      macro: {},
 | 
				
			||||||
      loading: false,
 | 
					      loading: false,
 | 
				
			||||||
      errorMessage: ''
 | 
					      errorMessage: ''
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
@@ -629,6 +637,7 @@ export const useConversationStore = defineStore('conversation', () => {
 | 
				
			|||||||
    conversationsList,
 | 
					    conversationsList,
 | 
				
			||||||
    conversationMessages,
 | 
					    conversationMessages,
 | 
				
			||||||
    currentConversationHasMoreMessages,
 | 
					    currentConversationHasMoreMessages,
 | 
				
			||||||
 | 
					    hasConversationOpen,
 | 
				
			||||||
    current,
 | 
					    current,
 | 
				
			||||||
    currentContactName,
 | 
					    currentContactName,
 | 
				
			||||||
    currentBCC,
 | 
					    currentBCC,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,4 +1,4 @@
 | 
				
			|||||||
import { ref, computed } from 'vue'
 | 
					import { ref, computed, watch } from 'vue'
 | 
				
			||||||
import { defineStore } from 'pinia'
 | 
					import { defineStore } from 'pinia'
 | 
				
			||||||
import { handleHTTPError } from '@/utils/http'
 | 
					import { handleHTTPError } from '@/utils/http'
 | 
				
			||||||
import { useEmitter } from '@/composables/useEmitter'
 | 
					import { useEmitter } from '@/composables/useEmitter'
 | 
				
			||||||
@@ -6,6 +6,7 @@ import { EMITTER_EVENTS } from '@/constants/emitterEvents'
 | 
				
			|||||||
import { adminNavItems, reportsNavItems } from '@/constants/navigation'
 | 
					import { adminNavItems, reportsNavItems } from '@/constants/navigation'
 | 
				
			||||||
import { filterNavItems } from '@/utils/nav-permissions'
 | 
					import { filterNavItems } from '@/utils/nav-permissions'
 | 
				
			||||||
import api from '@/api'
 | 
					import api from '@/api'
 | 
				
			||||||
 | 
					import { useStorage } from '@vueuse/core'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const useUserStore = defineStore('user', () => {
 | 
					export const useUserStore = defineStore('user', () => {
 | 
				
			||||||
  const user = ref({
 | 
					  const user = ref({
 | 
				
			||||||
@@ -88,11 +89,18 @@ export const useUserStore = defineStore('user', () => {
 | 
				
			|||||||
    user.value.avatar_url = ''
 | 
					    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) => {
 | 
					  const updateUserAvailability = async (status, isManual = true) => {
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      const apiStatus = status === 'away' && isManual ? 'away_manual' : status
 | 
					      const apiStatus = status === 'away' && isManual ? 'away_manual' : status
 | 
				
			||||||
      await api.updateCurrentUserAvailability({ status: apiStatus })
 | 
					      await api.updateCurrentUserAvailability({ status: apiStatus })
 | 
				
			||||||
      user.value.availability_status = apiStatus
 | 
					      user.value.availability_status = apiStatus
 | 
				
			||||||
 | 
					      availabilityStatusStorage.value = apiStatus
 | 
				
			||||||
    } catch (error) {
 | 
					    } catch (error) {
 | 
				
			||||||
      if (error?.response?.status === 401) window.location.href = '/'
 | 
					      if (error?.response?.status === 401) window.location.href = '/'
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -48,8 +48,13 @@ export const isGoHourMinuteDuration = (value) => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
const template = document.createElement('template')
 | 
					const template = document.createElement('template')
 | 
				
			||||||
export function getTextFromHTML(htmlString) {
 | 
					export function getTextFromHTML(htmlString) {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
        template.innerHTML = htmlString
 | 
					        template.innerHTML = htmlString
 | 
				
			||||||
        const text = template.content.textContent || template.content.innerText || ''
 | 
					        const text = template.content.textContent || template.content.innerText || ''
 | 
				
			||||||
        template.innerHTML = ''
 | 
					        template.innerHTML = ''
 | 
				
			||||||
    return text;
 | 
					        return text.trim()
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					        console.error('Error converting HTML to text:', error)
 | 
				
			||||||
 | 
					        return ''
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -155,6 +155,7 @@ import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
				
			|||||||
import { useEmitter } from '@/composables/useEmitter'
 | 
					import { useEmitter } from '@/composables/useEmitter'
 | 
				
			||||||
import { handleHTTPError } from '@/utils/http'
 | 
					import { handleHTTPError } from '@/utils/http'
 | 
				
			||||||
import { SelectTag } from '@/components/ui/select'
 | 
					import { SelectTag } from '@/components/ui/select'
 | 
				
			||||||
 | 
					import { OPERATOR } from '@/constants/filterConfig'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Select,
 | 
					  Select,
 | 
				
			||||||
  SelectContent,
 | 
					  SelectContent,
 | 
				
			||||||
@@ -315,7 +316,8 @@ const handleSave = async (values) => {
 | 
				
			|||||||
    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
					    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
      title: 'Invalid rules',
 | 
					      title: 'Invalid rules',
 | 
				
			||||||
      variant: 'destructive',
 | 
					      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
 | 
					    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 = () => {
 | 
					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.
 | 
					  // Must have atleast one action.
 | 
				
			||||||
  if (rule.value.rules[0].actions.length == 0) {
 | 
					  if (rule.value.rules[0].actions.length == 0) {
 | 
				
			||||||
    return false
 | 
					    return false
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // Must have atleast 1 group.
 | 
					  // Make sure each action has value.
 | 
				
			||||||
  if (rule.value.rules[0].groups.length == 0) {
 | 
					  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']
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Empty array, no value selected.
 | 
				
			||||||
 | 
					    if (action.value.length === 0) {
 | 
				
			||||||
      return false
 | 
					      return false
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // Group should have atleast one rule.
 | 
					    // Check if all values are present.
 | 
				
			||||||
  if (rule.value.rules[0].groups[0].rules.length == 0) {
 | 
					    for (const key in action.value) {
 | 
				
			||||||
    return false
 | 
					      if (!action.value[key]) {
 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // 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) {
 | 
					 | 
				
			||||||
        return false
 | 
					        return false
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -62,9 +62,9 @@ const submitForm = async (values) => {
 | 
				
			|||||||
      }
 | 
					      }
 | 
				
			||||||
      await api.updateOIDC(props.id, values)
 | 
					      await api.updateOIDC(props.id, values)
 | 
				
			||||||
      toastDescription = 'Provider updated successfully'
 | 
					      toastDescription = 'Provider updated successfully'
 | 
				
			||||||
      router.push({ name: 'sso-list' })
 | 
					 | 
				
			||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
      await api.createOIDC(values)
 | 
					      await api.createOIDC(values)
 | 
				
			||||||
 | 
					      router.push({ name: 'sso-list' })
 | 
				
			||||||
      toastDescription = 'Provider created successfully'
 | 
					      toastDescription = 'Provider created successfully'
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
					    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -12,7 +12,7 @@
 | 
				
			|||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { watch, onMounted } from 'vue'
 | 
					import { watch, onMounted, onUnmounted } from 'vue'
 | 
				
			||||||
import { useConversationStore } from '@/stores/conversation'
 | 
					import { useConversationStore } from '@/stores/conversation'
 | 
				
			||||||
import Conversation from '@/features/conversation/Conversation.vue'
 | 
					import Conversation from '@/features/conversation/Conversation.vue'
 | 
				
			||||||
import ConversationSideBarWrapper from '@/features/conversation/sidebar/ConversationSideBarWrapper.vue'
 | 
					import ConversationSideBarWrapper from '@/features/conversation/sidebar/ConversationSideBarWrapper.vue'
 | 
				
			||||||
@@ -37,6 +37,10 @@ onMounted(() => {
 | 
				
			|||||||
  if (props.uuid) fetchConversation(props.uuid)
 | 
					  if (props.uuid) fetchConversation(props.uuid)
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					onUnmounted(() => {
 | 
				
			||||||
 | 
					  conversationStore.resetCurrentConversation()
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Watcher for UUID changes
 | 
					// Watcher for UUID changes
 | 
				
			||||||
watch(
 | 
					watch(
 | 
				
			||||||
  () => props.uuid,
 | 
					  () => props.uuid,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -76,8 +76,7 @@
 | 
				
			|||||||
    </main>
 | 
					    </main>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <footer class="p-6 text-center">
 | 
					    <footer class="p-6 text-center">
 | 
				
			||||||
      <div class="text-sm text-muted-foreground space-x-4">
 | 
					      <div class="text-sm text-muted-foreground space-x-4"></div>
 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    </footer>
 | 
					    </footer>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
@@ -93,10 +92,13 @@ import { Button } from '@/components/ui/button'
 | 
				
			|||||||
import { Error } from '@/components/ui/error'
 | 
					import { Error } from '@/components/ui/error'
 | 
				
			||||||
import { Card, CardContent, CardTitle } from '@/components/ui/card'
 | 
					import { Card, CardContent, CardTitle } from '@/components/ui/card'
 | 
				
			||||||
import { Input } from '@/components/ui/input'
 | 
					import { Input } from '@/components/ui/input'
 | 
				
			||||||
 | 
					import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
 | 
				
			||||||
 | 
					import { useEmitter } from '@/composables/useEmitter'
 | 
				
			||||||
import { Label } from '@/components/ui/label'
 | 
					import { Label } from '@/components/ui/label'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const errorMessage = ref('')
 | 
					const errorMessage = ref('')
 | 
				
			||||||
const isLoading = ref(false)
 | 
					const isLoading = ref(false)
 | 
				
			||||||
 | 
					const emitter = useEmitter()
 | 
				
			||||||
const router = useRouter()
 | 
					const router = useRouter()
 | 
				
			||||||
const resetForm = ref({
 | 
					const resetForm = ref({
 | 
				
			||||||
  email: ''
 | 
					  email: ''
 | 
				
			||||||
@@ -121,16 +123,16 @@ const requestResetAction = async () => {
 | 
				
			|||||||
    await api.resetPassword({
 | 
					    await api.resetPassword({
 | 
				
			||||||
      email: resetForm.value.email
 | 
					      email: resetForm.value.email
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
    toast({
 | 
					    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
      title: 'Reset link sent',
 | 
					      title: 'Reset link sent',
 | 
				
			||||||
      description: 'Please check your email for the reset link.'
 | 
					      description: 'Please check your email for the reset link.'
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
    router.push({ name: 'login' })
 | 
					    router.push({ name: 'login' })
 | 
				
			||||||
  } catch (err) {
 | 
					  } catch (err) {
 | 
				
			||||||
    toast({
 | 
					    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
				
			||||||
      title: 'Error',
 | 
					      title: 'Reset link sent',
 | 
				
			||||||
      description: err.response.data.message,
 | 
					      variant: 'destructive',
 | 
				
			||||||
      variant: 'destructive'
 | 
					      description: handleHTTPError(err).message
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
    errorMessage.value = handleHTTPError(err).message
 | 
					    errorMessage.value = handleHTTPError(err).message
 | 
				
			||||||
    useTemporaryClass('reset-password-container', 'animate-shake')
 | 
					    useTemporaryClass('reset-password-container', 'animate-shake')
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -125,18 +125,16 @@ onMounted(() => {
 | 
				
			|||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const validateForm = () => {
 | 
					const validateForm = () => {
 | 
				
			||||||
  if (!passwordForm.value.password || passwordForm.value.password.length < 8) {
 | 
					  if (!passwordForm.value.password) {
 | 
				
			||||||
    errorMessage.value = 'Password must be at least 8 characters long.'
 | 
					    errorMessage.value = 'Password is required.'
 | 
				
			||||||
    useTemporaryClass('set-password-container', 'animate-shake')
 | 
					    useTemporaryClass('set-password-container', 'animate-shake')
 | 
				
			||||||
    return false
 | 
					    return false
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (passwordForm.value.password !== passwordForm.value.confirmPassword) {
 | 
					  if (passwordForm.value.password !== passwordForm.value.confirmPassword) {
 | 
				
			||||||
    errorMessage.value = 'Passwords do not match.'
 | 
					    errorMessage.value = 'Passwords do not match.'
 | 
				
			||||||
    useTemporaryClass('set-password-container', 'animate-shake')
 | 
					    useTemporaryClass('set-password-container', 'animate-shake')
 | 
				
			||||||
    return false
 | 
					    return false
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					 | 
				
			||||||
  return true
 | 
					  return true
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -156,11 +154,6 @@ const setPasswordAction = async () => {
 | 
				
			|||||||
    })
 | 
					    })
 | 
				
			||||||
    router.push({ name: 'login' })
 | 
					    router.push({ name: 'login' })
 | 
				
			||||||
  } catch (err) {
 | 
					  } catch (err) {
 | 
				
			||||||
    emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
 | 
					 | 
				
			||||||
      title: 'Error',
 | 
					 | 
				
			||||||
      variant: 'destructive',
 | 
					 | 
				
			||||||
      description: handleHTTPError(err).message
 | 
					 | 
				
			||||||
    })
 | 
					 | 
				
			||||||
    errorMessage.value = handleHTTPError(err).message
 | 
					    errorMessage.value = handleHTTPError(err).message
 | 
				
			||||||
    useTemporaryClass('set-password-container', 'animate-shake')
 | 
					    useTemporaryClass('set-password-container', 'animate-shake')
 | 
				
			||||||
  } finally {
 | 
					  } finally {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,4 +1,5 @@
 | 
				
			|||||||
const animate = require("tailwindcss-animate")
 | 
					const animate = require("tailwindcss-animate")
 | 
				
			||||||
 | 
					const typography = require("@tailwindcss/typography")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/** @type {import('tailwindcss').Config} */
 | 
					/** @type {import('tailwindcss').Config} */
 | 
				
			||||||
module.exports = {
 | 
					module.exports = {
 | 
				
			||||||
@@ -140,5 +141,5 @@ module.exports = {
 | 
				
			|||||||
      },
 | 
					      },
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  plugins: [animate],
 | 
					  plugins: [animate, typography],
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -5,6 +5,7 @@ import (
 | 
				
			|||||||
	"database/sql"
 | 
						"database/sql"
 | 
				
			||||||
	"embed"
 | 
						"embed"
 | 
				
			||||||
	"encoding/json"
 | 
						"encoding/json"
 | 
				
			||||||
 | 
						"errors"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/ai/models"
 | 
						"github.com/abhinavxd/libredesk/internal/ai/models"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/dbutil"
 | 
						"github.com/abhinavxd/libredesk/internal/dbutil"
 | 
				
			||||||
@@ -16,6 +17,9 @@ import (
 | 
				
			|||||||
var (
 | 
					var (
 | 
				
			||||||
	//go:embed queries.sql
 | 
						//go:embed queries.sql
 | 
				
			||||||
	efs embed.FS
 | 
						efs embed.FS
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						ErrInvalidAPIKey = errors.New("invalid API Key")
 | 
				
			||||||
 | 
						ErrApiKeyNotSet  = errors.New("api Key not set")
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Manager manages LLM providers.
 | 
					// Manager manages LLM providers.
 | 
				
			||||||
@@ -35,6 +39,7 @@ type queries struct {
 | 
				
			|||||||
	GetDefaultProvider *sqlx.Stmt `query:"get-default-provider"`
 | 
						GetDefaultProvider *sqlx.Stmt `query:"get-default-provider"`
 | 
				
			||||||
	GetPrompt          *sqlx.Stmt `query:"get-prompt"`
 | 
						GetPrompt          *sqlx.Stmt `query:"get-prompt"`
 | 
				
			||||||
	GetPrompts         *sqlx.Stmt `query:"get-prompts"`
 | 
						GetPrompts         *sqlx.Stmt `query:"get-prompts"`
 | 
				
			||||||
 | 
						SetOpenAIKey       *sqlx.Stmt `query:"set-openai-key"`
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// New creates and returns a new instance of the Manager.
 | 
					// 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)
 | 
						response, err := client.SendPrompt(payload)
 | 
				
			||||||
	if err != nil {
 | 
						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)
 | 
							m.lo.Error("error sending prompt to provider", "error", err)
 | 
				
			||||||
		return "", envelope.NewError(envelope.GeneralError, err.Error(), nil)
 | 
							return "", envelope.NewError(envelope.GeneralError, err.Error(), nil)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -86,6 +99,26 @@ func (m *Manager) GetPrompts() ([]models.Prompt, error) {
 | 
				
			|||||||
	return prompts, nil
 | 
						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.
 | 
					// getPrompt returns a prompt from the database.
 | 
				
			||||||
func (m *Manager) getPrompt(k string) (string, error) {
 | 
					func (m *Manager) getPrompt(k string) (string, error) {
 | 
				
			||||||
	var p models.Prompt
 | 
						var p models.Prompt
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -8,6 +8,7 @@ import (
 | 
				
			|||||||
	"net/http"
 | 
						"net/http"
 | 
				
			||||||
	"time"
 | 
						"time"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"github.com/valyala/fasthttp"
 | 
				
			||||||
	"github.com/zerodha/logf"
 | 
						"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.
 | 
					// SendPrompt sends a prompt to the OpenAI API and returns the response text.
 | 
				
			||||||
func (o *OpenAIClient) SendPrompt(payload PromptPayload) (string, error) {
 | 
					func (o *OpenAIClient) SendPrompt(payload PromptPayload) (string, error) {
 | 
				
			||||||
	if o.apikey == "" {
 | 
						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"
 | 
						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)
 | 
							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 {
 | 
						if err != nil {
 | 
				
			||||||
		o.lo.Error("error creating request", "error", err)
 | 
							o.lo.Error("error creating request", "error", err)
 | 
				
			||||||
		return "", fmt.Errorf("error creating request: %w", 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()
 | 
						defer resp.Body.Close()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if resp.StatusCode == http.StatusUnauthorized {
 | 
						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 {
 | 
						if resp.StatusCode != http.StatusOK {
 | 
				
			||||||
		body, _ := io.ReadAll(resp.Body)
 | 
							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)
 | 
							return "", fmt.Errorf("API error: %s, body: %s", resp.Status, body)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,3 +6,12 @@ SELECT id, key, title, content FROM ai_prompts where key = $1;
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
-- name: get-prompts
 | 
					-- 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';
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -90,9 +90,10 @@ func New(cfg Config, rd *redis.Client, logger *logf.Logger) (*Auth, error) {
 | 
				
			|||||||
		EnableAutoCreate: true,
 | 
							EnableAutoCreate: true,
 | 
				
			||||||
		SessionIDLength:  64,
 | 
							SessionIDLength:  64,
 | 
				
			||||||
		Cookie: simplesessions.CookieOptions{
 | 
							Cookie: simplesessions.CookieOptions{
 | 
				
			||||||
 | 
								Name:       "libredesk_session",
 | 
				
			||||||
			IsHTTPOnly: true,
 | 
								IsHTTPOnly: true,
 | 
				
			||||||
			IsSecure:   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()),
 | 
							Path:     string(c.Path()),
 | 
				
			||||||
		Domain:   string(c.Domain()),
 | 
							Domain:   string(c.Domain()),
 | 
				
			||||||
		Expires:  c.Expire(),
 | 
							Expires:  c.Expire(),
 | 
				
			||||||
 | 
							MaxAge:   c.MaxAge(),
 | 
				
			||||||
		Secure:   c.Secure(),
 | 
							Secure:   c.Secure(),
 | 
				
			||||||
		HttpOnly: c.HTTPOnly(),
 | 
							HttpOnly: c.HTTPOnly(),
 | 
				
			||||||
		SameSite: http.SameSite(c.SameSite()),
 | 
							SameSite: http.SameSite(c.SameSite()),
 | 
				
			||||||
@@ -410,6 +412,7 @@ func simpleSessSetCookieCB(c *http.Cookie, w interface{}) error {
 | 
				
			|||||||
	fc.SetPath(c.Path)
 | 
						fc.SetPath(c.Path)
 | 
				
			||||||
	fc.SetDomain(c.Domain)
 | 
						fc.SetDomain(c.Domain)
 | 
				
			||||||
	fc.SetExpire(c.Expires)
 | 
						fc.SetExpire(c.Expires)
 | 
				
			||||||
 | 
						fc.SetMaxAge(int(c.MaxAge))
 | 
				
			||||||
	fc.SetSecure(c.Secure)
 | 
						fc.SetSecure(c.Secure)
 | 
				
			||||||
	fc.SetHTTPOnly(c.HttpOnly)
 | 
						fc.SetHTTPOnly(c.HttpOnly)
 | 
				
			||||||
	fc.SetSameSite(fasthttp.CookieSameSite(c.SameSite))
 | 
						fc.SetSameSite(fasthttp.CookieSameSite(c.SameSite))
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -12,6 +12,7 @@ const (
 | 
				
			|||||||
	PermConversationsUpdatePriority     = "conversations:update_priority"
 | 
						PermConversationsUpdatePriority     = "conversations:update_priority"
 | 
				
			||||||
	PermConversationsUpdateStatus       = "conversations:update_status"
 | 
						PermConversationsUpdateStatus       = "conversations:update_status"
 | 
				
			||||||
	PermConversationsUpdateTags         = "conversations:update_tags"
 | 
						PermConversationsUpdateTags         = "conversations:update_tags"
 | 
				
			||||||
 | 
						PermConversationWrite               = "conversations:write"
 | 
				
			||||||
	PermMessagesRead                    = "messages:read"
 | 
						PermMessagesRead                    = "messages:read"
 | 
				
			||||||
	PermMessagesWrite                   = "messages:write"
 | 
						PermMessagesWrite                   = "messages:write"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -62,6 +63,9 @@ const (
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	// OpenID Connect SSO
 | 
						// OpenID Connect SSO
 | 
				
			||||||
	PermOIDCManage = "oidc:manage"
 | 
						PermOIDCManage = "oidc:manage"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// AI
 | 
				
			||||||
 | 
						PermAIManage = "ai:manage"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
var validPermissions = map[string]struct{}{
 | 
					var validPermissions = map[string]struct{}{
 | 
				
			||||||
@@ -75,6 +79,7 @@ var validPermissions = map[string]struct{}{
 | 
				
			|||||||
	PermConversationsUpdatePriority:     {},
 | 
						PermConversationsUpdatePriority:     {},
 | 
				
			||||||
	PermConversationsUpdateStatus:       {},
 | 
						PermConversationsUpdateStatus:       {},
 | 
				
			||||||
	PermConversationsUpdateTags:         {},
 | 
						PermConversationsUpdateTags:         {},
 | 
				
			||||||
 | 
						PermConversationWrite:               {},
 | 
				
			||||||
	PermMessagesRead:                    {},
 | 
						PermMessagesRead:                    {},
 | 
				
			||||||
	PermMessagesWrite:                   {},
 | 
						PermMessagesWrite:                   {},
 | 
				
			||||||
	PermViewManage:                      {},
 | 
						PermViewManage:                      {},
 | 
				
			||||||
@@ -93,6 +98,7 @@ var validPermissions = map[string]struct{}{
 | 
				
			|||||||
	PermGeneralSettingsManage:           {},
 | 
						PermGeneralSettingsManage:           {},
 | 
				
			||||||
	PermNotificationSettingsManage:      {},
 | 
						PermNotificationSettingsManage:      {},
 | 
				
			||||||
	PermOIDCManage:                      {},
 | 
						PermOIDCManage:                      {},
 | 
				
			||||||
 | 
						PermAIManage:                        {},
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// IsValidPermission returns true if it's a valid permission.
 | 
					// IsValidPermission returns true if it's a valid permission.
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -24,6 +24,7 @@ import (
 | 
				
			|||||||
	"github.com/abhinavxd/libredesk/internal/dbutil"
 | 
						"github.com/abhinavxd/libredesk/internal/dbutil"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/envelope"
 | 
						"github.com/abhinavxd/libredesk/internal/envelope"
 | 
				
			||||||
	"github.com/abhinavxd/libredesk/internal/inbox"
 | 
						"github.com/abhinavxd/libredesk/internal/inbox"
 | 
				
			||||||
 | 
						imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
 | 
				
			||||||
	mmodels "github.com/abhinavxd/libredesk/internal/media/models"
 | 
						mmodels "github.com/abhinavxd/libredesk/internal/media/models"
 | 
				
			||||||
	notifier "github.com/abhinavxd/libredesk/internal/notification"
 | 
						notifier "github.com/abhinavxd/libredesk/internal/notification"
 | 
				
			||||||
	slaModels "github.com/abhinavxd/libredesk/internal/sla/models"
 | 
						slaModels "github.com/abhinavxd/libredesk/internal/sla/models"
 | 
				
			||||||
@@ -96,7 +97,7 @@ type teamStore interface {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type userStore interface {
 | 
					type userStore interface {
 | 
				
			||||||
	Get(int) (umodels.User, error)
 | 
						GetAgent(int) (umodels.User, error)
 | 
				
			||||||
	GetSystemUser() (umodels.User, error)
 | 
						GetSystemUser() (umodels.User, error)
 | 
				
			||||||
	CreateContact(user *umodels.User) error
 | 
						CreateContact(user *umodels.User) error
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -112,6 +113,7 @@ type mediaStore interface {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
type inboxStore interface {
 | 
					type inboxStore interface {
 | 
				
			||||||
	Get(int) (inbox.Inbox, error)
 | 
						Get(int) (inbox.Inbox, error)
 | 
				
			||||||
 | 
						GetDBRecord(int) (imodels.Inbox, error)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type settingsStore interface {
 | 
					type settingsStore interface {
 | 
				
			||||||
@@ -182,7 +184,6 @@ func New(
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
type queries struct {
 | 
					type queries struct {
 | 
				
			||||||
	// Conversation queries.
 | 
						// Conversation queries.
 | 
				
			||||||
	GetLatestReceivedMessageSourceID   *sqlx.Stmt `query:"get-latest-received-message-source-id"`
 | 
					 | 
				
			||||||
	GetToAddress                       *sqlx.Stmt `query:"get-to-address"`
 | 
						GetToAddress                       *sqlx.Stmt `query:"get-to-address"`
 | 
				
			||||||
	GetConversationUUID                *sqlx.Stmt `query:"get-conversation-uuid"`
 | 
						GetConversationUUID                *sqlx.Stmt `query:"get-conversation-uuid"`
 | 
				
			||||||
	GetConversation                    *sqlx.Stmt `query:"get-conversation"`
 | 
						GetConversation                    *sqlx.Stmt `query:"get-conversation"`
 | 
				
			||||||
@@ -207,6 +208,7 @@ type queries struct {
 | 
				
			|||||||
	UnassignOpenConversations          *sqlx.Stmt `query:"unassign-open-conversations"`
 | 
						UnassignOpenConversations          *sqlx.Stmt `query:"unassign-open-conversations"`
 | 
				
			||||||
	ReOpenConversation                 *sqlx.Stmt `query:"re-open-conversation"`
 | 
						ReOpenConversation                 *sqlx.Stmt `query:"re-open-conversation"`
 | 
				
			||||||
	UnsnoozeAll                        *sqlx.Stmt `query:"unsnooze-all"`
 | 
						UnsnoozeAll                        *sqlx.Stmt `query:"unsnooze-all"`
 | 
				
			||||||
 | 
						DeleteConversation                 *sqlx.Stmt `query:"delete-conversation"`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Dashboard queries.
 | 
						// Dashboard queries.
 | 
				
			||||||
	GetDashboardCharts string `query:"get-dashboard-charts"`
 | 
						GetDashboardCharts string `query:"get-dashboard-charts"`
 | 
				
			||||||
@@ -216,6 +218,7 @@ type queries struct {
 | 
				
			|||||||
	GetMessage                         *sqlx.Stmt `query:"get-message"`
 | 
						GetMessage                         *sqlx.Stmt `query:"get-message"`
 | 
				
			||||||
	GetMessages                        string     `query:"get-messages"`
 | 
						GetMessages                        string     `query:"get-messages"`
 | 
				
			||||||
	GetPendingMessages                 *sqlx.Stmt `query:"get-pending-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"`
 | 
						GetConversationUUIDFromMessageUUID *sqlx.Stmt `query:"get-conversation-uuid-from-message-uuid"`
 | 
				
			||||||
	InsertMessage                      *sqlx.Stmt `query:"insert-message"`
 | 
						InsertMessage                      *sqlx.Stmt `query:"insert-message"`
 | 
				
			||||||
	UpdateMessageStatus                *sqlx.Stmt `query:"update-message-status"`
 | 
						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.
 | 
					// 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 (
 | 
						var (
 | 
				
			||||||
		id     int
 | 
							id     int
 | 
				
			||||||
		uuid   string
 | 
							uuid   string
 | 
				
			||||||
		prefix 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)
 | 
							c.lo.Error("error inserting new conversation into the DB", "error", err)
 | 
				
			||||||
		return id, uuid, err
 | 
							return id, uuid, err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -738,26 +741,28 @@ func (m *Manager) GetToAddress(conversationID int) ([]string, error) {
 | 
				
			|||||||
	return addr, nil
 | 
						return addr, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// GetLatestReceivedMessageSourceID returns the last received message source ID.
 | 
					// GetMessageSourceIDs retrieves source IDs for messages in a conversation in descending order.
 | 
				
			||||||
func (m *Manager) GetLatestReceivedMessageSourceID(conversationID int) (string, error) {
 | 
					// So the oldest message will be the last in the list.
 | 
				
			||||||
	var out string
 | 
					func (m *Manager) GetMessageSourceIDs(conversationID, limit int) ([]string, error) {
 | 
				
			||||||
	if err := m.q.GetLatestReceivedMessageSourceID.Get(&out, conversationID); err != nil {
 | 
						var refs []string
 | 
				
			||||||
		m.lo.Error("error fetching message source id", "error", err, "conversation_id", conversationID)
 | 
						if err := m.q.GetMessageSourceIDs.Select(&refs, conversationID, limit); err != nil {
 | 
				
			||||||
		return out, err
 | 
							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.
 | 
					// SendAssignedConversationEmail sends a email for an assigned conversation to the passed user ids.
 | 
				
			||||||
func (m *Manager) SendAssignedConversationEmail(userIDs []int, conversation models.Conversation) error {
 | 
					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 {
 | 
						if err != nil {
 | 
				
			||||||
		m.lo.Error("error fetching agent", "user_id", userIDs[0], "error", err)
 | 
							m.lo.Error("error fetching agent", "user_id", userIDs[0], "error", err)
 | 
				
			||||||
		return fmt.Errorf("fetching agent: %w", err)
 | 
							return fmt.Errorf("fetching agent: %w", err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	content, subject, err := m.template.RenderNamedTemplate(template.TmplConversationAssigned,
 | 
						content, subject, err := m.template.RenderStoredEmailTemplate(template.TmplConversationAssigned,
 | 
				
			||||||
		map[string]interface{}{
 | 
							map[string]any{
 | 
				
			||||||
 | 
								// Kept these lower case keys for backward compatibility.
 | 
				
			||||||
			"conversation": map[string]string{
 | 
								"conversation": map[string]string{
 | 
				
			||||||
				"subject":          conversation.Subject.String,
 | 
									"subject":          conversation.Subject.String,
 | 
				
			||||||
				"uuid":             conversation.UUID,
 | 
									"uuid":             conversation.UUID,
 | 
				
			||||||
@@ -767,6 +772,31 @@ func (m *Manager) SendAssignedConversationEmail(userIDs []int, conversation mode
 | 
				
			|||||||
			"agent": map[string]string{
 | 
								"agent": map[string]string{
 | 
				
			||||||
				"full_name": agent.FullName(),
 | 
									"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 {
 | 
						if err != nil {
 | 
				
			||||||
		m.lo.Error("error rendering template", "template", template.TmplConversationAssigned, "conversation_uuid", conversation.UUID, "error", err)
 | 
							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:
 | 
						case amodels.ActionSendPrivateNote:
 | 
				
			||||||
		return m.SendPrivateNote([]mmodels.Media{}, user.ID, conv.UUID, action.Value[0])
 | 
							return m.SendPrivateNote([]mmodels.Media{}, user.ID, conv.UUID, action.Value[0])
 | 
				
			||||||
	case amodels.ActionReply:
 | 
						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:
 | 
						case amodels.ActionSetSLA:
 | 
				
			||||||
		slaID, _ := strconv.Atoi(action.Value[0])
 | 
							slaID, _ := strconv.Atoi(action.Value[0])
 | 
				
			||||||
		return m.ApplySLA(conv, slaID, user)
 | 
							return m.ApplySLA(conv, slaID, user)
 | 
				
			||||||
@@ -887,7 +917,16 @@ func (m *Manager) SendCSATReply(actorUserID int, conversation models.Conversatio
 | 
				
			|||||||
	meta := map[string]interface{}{
 | 
						meta := map[string]interface{}{
 | 
				
			||||||
		"is_csat": true,
 | 
							"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.
 | 
					// addConversationParticipant adds a user as participant to a conversation.
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -50,7 +50,7 @@ const (
 | 
				
			|||||||
	ContentTypeHTML = "html"
 | 
						ContentTypeHTML = "html"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	maxLastMessageLen  = 45
 | 
						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
 | 
					// 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
 | 
							return
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Set message sender and receiver
 | 
						// Set from and to addresses
 | 
				
			||||||
	message.From = inbox.FromAddress()
 | 
						message.From = inbox.FromAddress()
 | 
				
			||||||
	message.To, _ = m.GetToAddress(message.ConversationID)
 | 
						message.To, err = m.GetToAddress(message.ConversationID)
 | 
				
			||||||
	message.InReplyTo, _ = m.GetLatestReceivedMessageSourceID(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
 | 
						// Send message
 | 
				
			||||||
	err = inbox.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)
 | 
								m.lo.Error("error fetching conversation", "uuid", message.ConversationUUID, "error", err)
 | 
				
			||||||
			return fmt.Errorf("fetching conversation: %w", 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 {
 | 
							if err != nil {
 | 
				
			||||||
			m.lo.Error("could not render email content using template", "id", message.ID, "error", err)
 | 
								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)
 | 
								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.
 | 
					// 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)
 | 
						cc = stringutil.RemoveEmpty(cc)
 | 
				
			||||||
	bcc = stringutil.RemoveEmpty(bcc)
 | 
						bcc = stringutil.RemoveEmpty(bcc)
 | 
				
			||||||
 | 
					 | 
				
			||||||
	// Save cc and bcc as JSON in meta.
 | 
					 | 
				
			||||||
	if len(cc) > 0 {
 | 
						if len(cc) > 0 {
 | 
				
			||||||
		meta["cc"] = cc
 | 
							meta["cc"] = cc
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -308,6 +346,19 @@ func (m *Manager) SendReply(media []mmodels.Media, senderID int, conversationUUI
 | 
				
			|||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return envelope.NewError(envelope.GeneralError, "Error marshalling message meta", 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{
 | 
						message := models.Message{
 | 
				
			||||||
		ConversationUUID: conversationUUID,
 | 
							ConversationUUID: conversationUUID,
 | 
				
			||||||
		SenderID:         senderID,
 | 
							SenderID:         senderID,
 | 
				
			||||||
@@ -319,6 +370,7 @@ func (m *Manager) SendReply(media []mmodels.Media, senderID int, conversationUUI
 | 
				
			|||||||
		Private:          false,
 | 
							Private:          false,
 | 
				
			||||||
		Media:            media,
 | 
							Media:            media,
 | 
				
			||||||
		Meta:             string(metaJSON),
 | 
							Meta:             string(metaJSON),
 | 
				
			||||||
 | 
							SourceID:         null.StringFrom(sourceID),
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return m.InsertMessage(&message)
 | 
						return m.InsertMessage(&message)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -355,8 +407,14 @@ func (m *Manager) InsertMessage(message *models.Message) error {
 | 
				
			|||||||
		return err
 | 
							return err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Update conversation last message details in conversation metadata.
 | 
						// Hide CSAT message content as it contains a public link to the survey.
 | 
				
			||||||
	m.UpdateConversationLastMessage(message.ConversationID, message.ConversationUUID, message.TextContent, message.SenderType, message.CreatedAt)
 | 
						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.
 | 
						// Broadcast new message.
 | 
				
			||||||
	m.BroadcastNewMessage(message)
 | 
						m.BroadcastNewMessage(message)
 | 
				
			||||||
@@ -371,7 +429,7 @@ func (m *Manager) RecordAssigneeUserChange(conversationUUID string, assigneeID i
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Assignment to another user.
 | 
						// Assignment to another user.
 | 
				
			||||||
	assignee, err := m.userStore.Get(assigneeID)
 | 
						assignee, err := m.userStore.GetAgent(assigneeID)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return err
 | 
							return err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -655,11 +713,8 @@ func (m *Manager) findOrCreateConversation(in *models.Message, inboxID, contactC
 | 
				
			|||||||
		conversationUUID string
 | 
							conversationUUID string
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Search for existing conversation.
 | 
						// Search for existing conversation using the in-reply-to and references.
 | 
				
			||||||
	sourceIDs := in.References
 | 
						sourceIDs := append([]string{in.InReplyTo}, in.References...)
 | 
				
			||||||
	if in.InReplyTo != "" {
 | 
					 | 
				
			||||||
		sourceIDs = append(sourceIDs, in.InReplyTo)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	conversationID, err = m.findConversationID(sourceIDs)
 | 
						conversationID, err = m.findConversationID(sourceIDs)
 | 
				
			||||||
	if err != nil && err != errConversationNotFound {
 | 
						if err != nil && err != errConversationNotFound {
 | 
				
			||||||
		return new, err
 | 
							return new, err
 | 
				
			||||||
@@ -670,7 +725,7 @@ func (m *Manager) findOrCreateConversation(in *models.Message, inboxID, contactC
 | 
				
			|||||||
		new = true
 | 
							new = true
 | 
				
			||||||
		lastMessage := stringutil.HTML2Text(in.Content)
 | 
							lastMessage := stringutil.HTML2Text(in.Content)
 | 
				
			||||||
		lastMessageAt := time.Now()
 | 
							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 {
 | 
							if err != nil || conversationID == 0 {
 | 
				
			||||||
			return new, err
 | 
								return new, err
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -119,6 +119,7 @@ type Message struct {
 | 
				
			|||||||
	InReplyTo        string                 `json:"-"`
 | 
						InReplyTo        string                 `json:"-"`
 | 
				
			||||||
	Headers          textproto.MIMEHeader   `json:"-"`
 | 
						Headers          textproto.MIMEHeader   `json:"-"`
 | 
				
			||||||
	Media            []mmodels.Media        `db:"-" json:"-"`
 | 
						Media            []mmodels.Media        `db:"-" json:"-"`
 | 
				
			||||||
 | 
						IsCSAT           bool                   `db:"-" json:"-"`
 | 
				
			||||||
	Total            int                    `db:"total" 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.
 | 
					// IncomingMessage links a message with the contact information and inbox id.
 | 
				
			||||||
type IncomingMessage struct {
 | 
					type IncomingMessage struct {
 | 
				
			||||||
	Message Message
 | 
						Message Message
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -9,7 +9,7 @@ status_id AS (
 | 
				
			|||||||
   SELECT id FROM conversation_statuses WHERE name = $3
 | 
					   SELECT id FROM conversation_statuses WHERE name = $3
 | 
				
			||||||
),
 | 
					),
 | 
				
			||||||
reference_number AS (
 | 
					reference_number AS (
 | 
				
			||||||
   SELECT generate_reference_number($8) as reference_number
 | 
					   SELECT generate_reference_number($8) AS reference_number
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
INSERT INTO conversations
 | 
					INSERT INTO conversations
 | 
				
			||||||
(contact_id, contact_channel_id, status_id, inbox_id, last_message, last_message_at, subject, reference_number)
 | 
					(contact_id, contact_channel_id, status_id, inbox_id, last_message, last_message_at, subject, reference_number)
 | 
				
			||||||
@@ -20,7 +20,10 @@ VALUES(
 | 
				
			|||||||
   $4, 
 | 
					   $4, 
 | 
				
			||||||
   $5, 
 | 
					   $5, 
 | 
				
			||||||
   $6, 
 | 
					   $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)
 | 
					   (SELECT reference_number FROM reference_number)
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
RETURNING id, uuid;
 | 
					RETURNING id, uuid;
 | 
				
			||||||
@@ -362,13 +365,16 @@ SET assigned_user_id = NULL,
 | 
				
			|||||||
    updated_at = now()
 | 
					    updated_at = now()
 | 
				
			||||||
WHERE assigned_user_id = $1 AND status_id in (SELECT id FROM conversation_statuses WHERE name NOT IN ('Resolved', 'Closed'));
 | 
					WHERE assigned_user_id = $1 AND status_id in (SELECT id FROM conversation_statuses WHERE name NOT IN ('Resolved', 'Closed'));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
-- MESSAGE queries.
 | 
					-- MESSAGE queries.
 | 
				
			||||||
-- name: get-latest-received-message-source-id
 | 
					-- name: get-message-source-ids
 | 
				
			||||||
SELECT source_id
 | 
					SELECT 
 | 
				
			||||||
 | 
					    source_id
 | 
				
			||||||
FROM conversation_messages
 | 
					FROM conversation_messages
 | 
				
			||||||
WHERE conversation_id = $1 and status = 'received'
 | 
					WHERE conversation_id = $1
 | 
				
			||||||
 | 
					AND type in ('incoming', 'outgoing')
 | 
				
			||||||
ORDER BY id DESC
 | 
					ORDER BY id DESC
 | 
				
			||||||
LIMIT 1;
 | 
					LIMIT $2;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
-- name: get-pending-messages
 | 
					-- name: get-pending-messages
 | 
				
			||||||
SELECT
 | 
					SELECT
 | 
				
			||||||
@@ -521,3 +527,6 @@ SET status_id = (SELECT id FROM conversation_statuses WHERE name = 'Open'), snoo
 | 
				
			|||||||
WHERE uuid = $1 and status_id in (
 | 
					WHERE uuid = $1 and status_id in (
 | 
				
			||||||
    SELECT id FROM conversation_statuses WHERE name IN ('Snoozed', 'Closed', 'Resolved')
 | 
					    SELECT id FROM conversation_statuses WHERE name IN ('Snoozed', 'Closed', 'Resolved')
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					-- name: delete-conversation
 | 
				
			||||||
 | 
					DELETE FROM conversations WHERE uuid = $1;
 | 
				
			||||||
@@ -112,12 +112,17 @@ func (e *Email) Send(m models.Message) error {
 | 
				
			|||||||
		email.Headers.Set(key, value[0])
 | 
							email.Headers.Set(key, value[0])
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Set In-Reply-To and References headers
 | 
						// Set In-Reply-To header
 | 
				
			||||||
	if m.InReplyTo != "" {
 | 
						if m.InReplyTo != "" {
 | 
				
			||||||
		email.Headers.Set(headerInReplyTo, "<"+m.InReplyTo+">")
 | 
							email.Headers.Set(headerInReplyTo, "<"+m.InReplyTo+">")
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Set references message ids
 | 
						// Set message id header
 | 
				
			||||||
 | 
						if m.SourceID.String != "" {
 | 
				
			||||||
 | 
							email.Headers.Set(headerMessageID, fmt.Sprintf("<%s>", m.SourceID.String))
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Set references header
 | 
				
			||||||
	var references string
 | 
						var references string
 | 
				
			||||||
	for _, ref := range m.References {
 | 
						for _, ref := range m.References {
 | 
				
			||||||
		references += "<" + ref + "> "
 | 
							references += "<" + ref + "> "
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										36
									
								
								internal/migrations/v0.4.0.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								internal/migrations/v0.4.0.go
									
									
									
									
									
										Normal 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
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -1,8 +1,8 @@
 | 
				
			|||||||
-- name: get-all-oidc
 | 
					-- 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
 | 
					-- 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
 | 
					-- name: get-oidc
 | 
				
			||||||
SELECT * FROM oidc WHERE id = $1;
 | 
					SELECT * FROM oidc WHERE id = $1;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -16,3 +16,10 @@ type Message struct {
 | 
				
			|||||||
	ConversationUUID            string    `db:"conversation_uuid" json:"conversation_uuid"`
 | 
						ConversationUUID            string    `db:"conversation_uuid" json:"conversation_uuid"`
 | 
				
			||||||
	ConversationReferenceNumber string    `db:"conversation_reference_number" json:"conversation_reference_number"`
 | 
						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"`
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -16,3 +16,16 @@ SELECT
 | 
				
			|||||||
FROM conversation_messages m
 | 
					FROM conversation_messages m
 | 
				
			||||||
    JOIN conversations c ON m.conversation_id = c.id
 | 
					    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;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -32,6 +32,7 @@ type Opts struct {
 | 
				
			|||||||
type queries struct {
 | 
					type queries struct {
 | 
				
			||||||
	SearchConversations *sqlx.Stmt `query:"search-conversations"`
 | 
						SearchConversations *sqlx.Stmt `query:"search-conversations"`
 | 
				
			||||||
	SearchMessages      *sqlx.Stmt `query:"search-messages"`
 | 
						SearchMessages      *sqlx.Stmt `query:"search-messages"`
 | 
				
			||||||
 | 
						SearchContacts      *sqlx.Stmt `query:"search-contacts"`
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// New creates a new search manager
 | 
					// New creates a new search manager
 | 
				
			||||||
@@ -62,3 +63,13 @@ func (s *Manager) Messages(query string) ([]models.Message, error) {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
	return results, nil
 | 
						return results, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Contacts searches contacts based on the query
 | 
				
			||||||
 | 
					func (s *Manager) Contacts(query string) ([]models.Contact, error) {
 | 
				
			||||||
 | 
						var results = make([]models.Contact, 0)
 | 
				
			||||||
 | 
						if err := s.q.SearchContacts.Select(&results, query); err != nil {
 | 
				
			||||||
 | 
							s.lo.Error("error searching contacts", "error", err)
 | 
				
			||||||
 | 
							return nil, envelope.NewError(envelope.GeneralError, "Error searching contacts", nil)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return results, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -75,6 +75,7 @@ func (m *Manager) GetAll() (models.Settings, error) {
 | 
				
			|||||||
func (m *Manager) GetAllJSON() (types.JSONText, error) {
 | 
					func (m *Manager) GetAllJSON() (types.JSONText, error) {
 | 
				
			||||||
	var b types.JSONText
 | 
						var b types.JSONText
 | 
				
			||||||
	if err := m.q.GetAll.Get(&b); err != nil {
 | 
						if err := m.q.GetAll.Get(&b); err != nil {
 | 
				
			||||||
 | 
							m.lo.Error("error fetching settings", "error", err)
 | 
				
			||||||
		return b, err
 | 
							return b, err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return b, nil
 | 
						return b, nil
 | 
				
			||||||
@@ -85,10 +86,12 @@ func (m *Manager) Update(s interface{}) error {
 | 
				
			|||||||
	// Marshal settings.
 | 
						// Marshal settings.
 | 
				
			||||||
	b, err := json.Marshal(s)
 | 
						b, err := json.Marshal(s)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
 | 
							m.lo.Error("error marshalling settings", "error", err)
 | 
				
			||||||
		return envelope.NewError(envelope.GeneralError, "Error updating settings", nil)
 | 
							return envelope.NewError(envelope.GeneralError, "Error updating settings", nil)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	// Update the settings in the DB.
 | 
						// Update the settings in the DB.
 | 
				
			||||||
	if _, err := m.q.Update.Exec(b); err != nil {
 | 
						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 envelope.NewError(envelope.GeneralError, "Error updating settings", nil)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return nil
 | 
						return nil
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,10 +3,14 @@ package stringutil
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
	"crypto/rand"
 | 
						"crypto/rand"
 | 
				
			||||||
 | 
						"encoding/base64"
 | 
				
			||||||
 | 
						"fmt"
 | 
				
			||||||
 | 
						"net/mail"
 | 
				
			||||||
	"net/url"
 | 
						"net/url"
 | 
				
			||||||
	"path/filepath"
 | 
						"path/filepath"
 | 
				
			||||||
	"regexp"
 | 
						"regexp"
 | 
				
			||||||
	"strings"
 | 
						"strings"
 | 
				
			||||||
 | 
						"time"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"github.com/k3a/html2text"
 | 
						"github.com/k3a/html2text"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
@@ -94,3 +98,65 @@ func RemoveEmpty(s []string) []string {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
	return r
 | 
						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
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -22,17 +22,19 @@ const (
 | 
				
			|||||||
	TmplContent = "content"
 | 
						TmplContent = "content"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// RenderWithBaseTemplate merges the given content with the default outgoing email template, if available.
 | 
					// RenderEmailWithTemplate renders content inside the default outgoing email template.
 | 
				
			||||||
func (m *Manager) RenderWithBaseTemplate(data any, content string) (string, error) {
 | 
					func (m *Manager) RenderEmailWithTemplate(data any, content string) (string, error) {
 | 
				
			||||||
	m.mutex.RLock()
 | 
						m.mutex.RLock()
 | 
				
			||||||
	defer m.mutex.RUnlock()
 | 
						defer m.mutex.RUnlock()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	defaultTmpl, err := m.getDefaultOutgoingEmailTemplate()
 | 
						defaultTmpl, err := m.getDefaultOutgoingEmailTemplate()
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		if err == ErrTemplateNotFound {
 | 
							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 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)
 | 
						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
 | 
						return rendered.String(), nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// RenderNamedTemplate fetches a named template from DB and merges it with the default base template, if available.
 | 
					// RenderStoredEmailTemplate fetches and renders an email template from the database, including subject and body and returns the rendered content.
 | 
				
			||||||
func (m *Manager) RenderNamedTemplate(name string, data any) (string, string, error) {
 | 
					func (m *Manager) RenderStoredEmailTemplate(name string, data any) (string, string, error) {
 | 
				
			||||||
	tmpl, err := m.getByName(name)
 | 
						tmpl, err := m.getByName(name)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		if err == ErrTemplateNotFound {
 | 
							if err == ErrTemplateNotFound {
 | 
				
			||||||
@@ -137,8 +139,9 @@ func (m *Manager) RenderNamedTemplate(name string, data any) (string, string, er
 | 
				
			|||||||
	return rendered.String(), subject, nil
 | 
						return rendered.String(), subject, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// RenderTemplate executes a named in-memory template with the provided data.
 | 
					// RenderInMemoryTemplate executes an in-memory template with data and returns the rendered content.
 | 
				
			||||||
func (m *Manager) RenderTemplate(name string, data interface{}) (string, error) {
 | 
					// 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()
 | 
						m.mutex.RLock()
 | 
				
			||||||
	defer m.mutex.RUnlock()
 | 
						defer m.mutex.RUnlock()
 | 
				
			||||||
	var buf bytes.Buffer
 | 
						var buf bytes.Buffer
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -45,7 +45,7 @@ FROM users u
 | 
				
			|||||||
LEFT JOIN user_roles ur ON ur.user_id = u.id
 | 
					LEFT JOIN user_roles ur ON ur.user_id = u.id
 | 
				
			||||||
LEFT JOIN roles r ON r.id = ur.role_id,
 | 
					LEFT JOIN roles r ON r.id = ur.role_id,
 | 
				
			||||||
     unnest(r.permissions) p
 | 
					     unnest(r.permissions) p
 | 
				
			||||||
WHERE (u.id = $1 OR u.email = $2) AND u.deleted_at IS NULL AND u.type = 'agent'
 | 
					WHERE (u.id = $1 OR u.email = $2) AND u.type = $3 AND u.deleted_at IS NULL
 | 
				
			||||||
GROUP BY u.id;
 | 
					GROUP BY u.id;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
-- name: set-user-password
 | 
					-- name: set-user-password
 | 
				
			||||||
@@ -97,7 +97,10 @@ WHERE id = $1;
 | 
				
			|||||||
-- name: update-inactive-offline
 | 
					-- name: update-inactive-offline
 | 
				
			||||||
UPDATE users
 | 
					UPDATE users
 | 
				
			||||||
SET availability_status = 'offline'
 | 
					SET availability_status = 'offline'
 | 
				
			||||||
WHERE last_active_at < now() - interval '5 minutes' and 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
 | 
					-- name: get-permissions
 | 
				
			||||||
SELECT DISTINCT unnest(r.permissions)
 | 
					SELECT DISTINCT unnest(r.permissions)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -95,7 +95,7 @@ func New(i18n *i18n.I18n, opts Opts) (*Manager, error) {
 | 
				
			|||||||
// VerifyPassword authenticates an user by email and password.
 | 
					// VerifyPassword authenticates an user by email and password.
 | 
				
			||||||
func (u *Manager) VerifyPassword(email string, password []byte) (models.User, error) {
 | 
					func (u *Manager) VerifyPassword(email string, password []byte) (models.User, error) {
 | 
				
			||||||
	var user models.User
 | 
						var user models.User
 | 
				
			||||||
	if err := u.q.GetUser.Get(&user, 0, email); err != nil {
 | 
						if err := u.q.GetUser.Get(&user, 0, email, UserTypeAgent); err != nil {
 | 
				
			||||||
		if errors.Is(err, sql.ErrNoRows) {
 | 
							if errors.Is(err, sql.ErrNoRows) {
 | 
				
			||||||
			return user, envelope.NewError(envelope.InputError, u.i18n.T("user.invalidEmailPassword"), nil)
 | 
								return user, envelope.NewError(envelope.InputError, u.i18n.T("user.invalidEmailPassword"), nil)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
@@ -154,10 +154,25 @@ func (u *Manager) CreateAgent(user *models.User) error {
 | 
				
			|||||||
	return nil
 | 
						return nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// 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.
 | 
					// Get retrieves an user by ID.
 | 
				
			||||||
func (u *Manager) Get(id int) (models.User, error) {
 | 
					func (u *Manager) Get(id int, type_ string) (models.User, error) {
 | 
				
			||||||
	var user models.User
 | 
						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) {
 | 
							if errors.Is(err, sql.ErrNoRows) {
 | 
				
			||||||
			u.lo.Error("user not found", "id", id, "error", err)
 | 
								u.lo.Error("user not found", "id", id, "error", err)
 | 
				
			||||||
			return user, envelope.NewError(envelope.GeneralError, "User not found", nil)
 | 
								return user, envelope.NewError(envelope.GeneralError, "User not found", nil)
 | 
				
			||||||
@@ -169,9 +184,9 @@ func (u *Manager) Get(id int) (models.User, error) {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// GetByEmail retrieves an user by email
 | 
					// GetByEmail retrieves an user by email
 | 
				
			||||||
func (u *Manager) GetByEmail(email string) (models.User, error) {
 | 
					func (u *Manager) GetByEmail(email, type_ string) (models.User, error) {
 | 
				
			||||||
	var user models.User
 | 
						var user models.User
 | 
				
			||||||
	if err := u.q.GetUser.Get(&user, 0, email); err != nil {
 | 
						if err := u.q.GetUser.Get(&user, 0, email, type_); err != nil {
 | 
				
			||||||
		if errors.Is(err, sql.ErrNoRows) {
 | 
							if errors.Is(err, sql.ErrNoRows) {
 | 
				
			||||||
			return user, envelope.NewError(envelope.GeneralError, "User not found", nil)
 | 
								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.
 | 
					// GetSystemUser retrieves the system user.
 | 
				
			||||||
func (u *Manager) GetSystemUser() (models.User, error) {
 | 
					func (u *Manager) GetSystemUser() (models.User, error) {
 | 
				
			||||||
	return u.GetByEmail(systemUserEmail)
 | 
						return u.GetByEmail(systemUserEmail, UserTypeAgent)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// UpdateAvatar updates the user avatar.
 | 
					// UpdateAvatar updates the user avatar.
 | 
				
			||||||
@@ -277,10 +292,14 @@ func (u *Manager) ResetPassword(token, password string) error {
 | 
				
			|||||||
		u.lo.Error("error generating bcrypt password", "error", err)
 | 
							u.lo.Error("error generating bcrypt password", "error", err)
 | 
				
			||||||
		return envelope.NewError(envelope.GeneralError, "Error setting new password", nil)
 | 
							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)
 | 
							u.lo.Error("error setting new password", "error", err)
 | 
				
			||||||
		return envelope.NewError(envelope.GeneralError, "Error setting new password", nil)
 | 
							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
 | 
						return nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -328,7 +347,6 @@ func (u *Manager) MonitorAgentAvailability(ctx context.Context) {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
// markInactiveAgentsOffline sets agents offline if they have been inactive for more than 5 minutes.
 | 
					// markInactiveAgentsOffline sets agents offline if they have been inactive for more than 5 minutes.
 | 
				
			||||||
func (u *Manager) markInactiveAgentsOffline() {
 | 
					func (u *Manager) markInactiveAgentsOffline() {
 | 
				
			||||||
	u.lo.Debug("marking inactive agents offline")
 | 
					 | 
				
			||||||
	if res, err := u.q.UpdateInactiveOffline.Exec(); err != nil {
 | 
						if res, err := u.q.UpdateInactiveOffline.Exec(); err != nil {
 | 
				
			||||||
		u.lo.Error("error setting users offline", "error", err)
 | 
							u.lo.Error("error setting users offline", "error", err)
 | 
				
			||||||
	} else {
 | 
						} else {
 | 
				
			||||||
@@ -337,7 +355,6 @@ func (u *Manager) markInactiveAgentsOffline() {
 | 
				
			|||||||
			u.lo.Info("set inactive users offline", "count", rows)
 | 
								u.lo.Info("set inactive users offline", "count", rows)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	u.lo.Debug("marked inactive agents offline")
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// verifyPassword compares the provided password with the stored password hash.
 | 
					// verifyPassword compares the provided password with the stored password hash.
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										19
									
								
								schema.sql
									
									
									
									
									
								
							
							
						
						
									
										19
									
								
								schema.sql
									
									
									
									
									
								
							@@ -129,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) 
 | 
					CREATE UNIQUE INDEX index_unique_users_on_email_and_type_when_deleted_at_is_null ON users (email, type) 
 | 
				
			||||||
WHERE deleted_at IS NULL;
 | 
					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;
 | 
					DROP TABLE IF EXISTS user_roles CASCADE;
 | 
				
			||||||
CREATE TABLE user_roles (
 | 
					CREATE TABLE user_roles (
 | 
				
			||||||
@@ -536,28 +537,30 @@ VALUES
 | 
				
			|||||||
	(
 | 
						(
 | 
				
			||||||
		'Admin',
 | 
							'Admin',
 | 
				
			||||||
		'Role for users who have complete access to everything.',
 | 
							'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
 | 
					-- Email notification templates
 | 
				
			||||||
INSERT INTO public.templates
 | 
					INSERT INTO templates
 | 
				
			||||||
("type", body, is_default, "name", subject, is_builtin)
 | 
					("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>
 | 
					<p>A new conversation has been assigned to you:</p>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<div>
 | 
					<div>
 | 
				
			||||||
    Reference number: {{.conversation.reference_number }} <br>
 | 
					    Reference number: {{ .Conversation.ReferenceNumber }} <br>
 | 
				
			||||||
    Priority: {{.conversation.priority }}<br>
 | 
					    Subject: {{ .Conversation.Subject }}
 | 
				
			||||||
    Subject: {{.conversation.subject }}
 | 
					 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<p>
 | 
					<p>
 | 
				
			||||||
<a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .conversation.uuid }}">View Conversation</a>
 | 
					    <a href="{{ RootURL }}/inboxes/assigned/conversation/{{ .Conversation.UUID }}">View Conversation</a>
 | 
				
			||||||
</p>
 | 
					</p>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<div>
 | 
					<div>
 | 
				
			||||||
    Best regards,<br>
 | 
					    Best regards,<br>
 | 
				
			||||||
    Libredesk
 | 
					    Libredesk
 | 
				
			||||||
</div>', false, 'Conversation assigned', 'New conversation assigned to you', true);
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					', false, 'Conversation assigned', 'New conversation assigned to you', true);
 | 
				
			||||||
@@ -100,7 +100,7 @@
 | 
				
			|||||||
    <div class="wrap">
 | 
					    <div class="wrap">
 | 
				
			||||||
        <div class="header">
 | 
					        <div class="header">
 | 
				
			||||||
            {{ if ne LogoURL "" }}
 | 
					            {{ if ne LogoURL "" }}
 | 
				
			||||||
                <img src="{{ LogoURL }}" alt="" />
 | 
					                <img src="{{ LogoURL }}" alt="{{ SiteName }}" style="max-width: 150px;">
 | 
				
			||||||
            {{ end }}
 | 
					            {{ end }}
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
{{ end }}
 | 
					{{ end }}
 | 
				
			||||||
@@ -108,6 +108,7 @@
 | 
				
			|||||||
{{ define "footer" }}
 | 
					{{ define "footer" }}
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
    <div class="footer">
 | 
					    <div class="footer">
 | 
				
			||||||
 | 
					        <span style="opacity: 0.6;">Powered by <a href="https://libredesk.io/" target="_blank">Libredesk</a></span>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
    <div class="gutter"> </div>
 | 
					    <div class="gutter"> </div>
 | 
				
			||||||
</body>
 | 
					</body>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,23 +1,29 @@
 | 
				
			|||||||
{{ define "welcome" }}
 | 
					{{ define "welcome" }}
 | 
				
			||||||
{{ template "header" . }}
 | 
					{{ template "header" . }}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<h1 style="text-align: center; margin-top: 0; color: #1f2937;">
 | 
				
			||||||
    {{ if ne SiteName "" }}
 | 
					    {{ if ne SiteName "" }}
 | 
				
			||||||
<p>Welcome to {{ SiteName }}!</p>
 | 
					    Welcome to {{ SiteName }}
 | 
				
			||||||
    {{ else }}
 | 
					    {{ else }}
 | 
				
			||||||
<p>Welcome!</p>
 | 
					    Welcome
 | 
				
			||||||
    {{ end }}
 | 
					    {{ end }}
 | 
				
			||||||
 | 
					</h1>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<p>A new account has been created for you.</p>
 | 
					<p>A new account has been created for you with <strong>{{ .Email }}</strong></p>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<p>Your login email is <strong>{{ .Email }}</strong></p>
 | 
					<p>To set your password, click the button below:</p>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<p>To set your password, click the link below:</p>
 | 
					<p style="color: #ef4444; font-size: 14px; margin-bottom: 16px;">This link will expire in 24 hours.</p>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<p>{{ RootURL }}/set-password?token={{ .ResetToken }}</p>
 | 
					<div style="text-align: center; margin: 24px 0;">
 | 
				
			||||||
 | 
					    <a href="{{ RootURL }}/set-password?token={{ .ResetToken }}" class="button">
 | 
				
			||||||
 | 
					        Set Your Password
 | 
				
			||||||
 | 
					    </a>
 | 
				
			||||||
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<p>This link will expire in 24 hours.</p>
 | 
					<div style="text-align: center; margin-top: 24px;">
 | 
				
			||||||
 | 
					    <p>After setting your password, <a href="{{ RootURL }}">log in here</a></p>
 | 
				
			||||||
<p>Once you've set your password, you can log in <a href="{{ RootURL }}">here.</a></p>
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{{ template "footer" . }}
 | 
					{{ template "footer" . }}
 | 
				
			||||||
{{ end }}
 | 
					{{ end }}
 | 
				
			||||||
		Reference in New Issue
	
	Block a user