mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-11-04 14:03:19 +00:00
Compare commits
36 Commits
v0.3.2-alp
...
v0.4.2-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
772152c40c | ||
|
|
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 |
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -29,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",
|
||||||
|
|||||||
234
frontend/pnpm-lock.yaml
generated
234
frontend/pnpm-lock.yaml
generated
@@ -50,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)
|
||||||
@@ -826,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
|
||||||
|
|
||||||
@@ -1173,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==}
|
||||||
|
|
||||||
@@ -1360,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'}
|
||||||
@@ -1421,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'}
|
||||||
@@ -1692,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==}
|
||||||
|
|
||||||
@@ -1880,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'}
|
||||||
@@ -1910,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==}
|
||||||
|
|
||||||
@@ -2002,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==}
|
||||||
|
|
||||||
@@ -2083,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'}
|
||||||
@@ -2163,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==}
|
||||||
|
|
||||||
@@ -2178,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'}
|
||||||
@@ -2206,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'}
|
||||||
@@ -2337,12 +2278,6 @@ packages:
|
|||||||
lodash.castarray@4.4.0:
|
lodash.castarray@4.4.0:
|
||||||
resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==}
|
resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==}
|
||||||
|
|
||||||
lodash.clonedeep@4.5.0:
|
|
||||||
resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==}
|
|
||||||
|
|
||||||
lodash.isequal@4.5.0:
|
|
||||||
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
|
|
||||||
|
|
||||||
lodash.isplainobject@4.0.6:
|
lodash.isplainobject@4.0.6:
|
||||||
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
||||||
|
|
||||||
@@ -2491,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==}
|
||||||
|
|
||||||
@@ -2531,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'}
|
||||||
@@ -2774,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:
|
||||||
@@ -2803,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==}
|
||||||
|
|
||||||
@@ -2877,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'}
|
||||||
@@ -3907,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)
|
||||||
|
|
||||||
@@ -4000,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)
|
||||||
@@ -4386,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
|
||||||
@@ -4580,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
|
||||||
@@ -4646,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:
|
||||||
@@ -4964,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:
|
||||||
@@ -5204,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
|
||||||
@@ -5250,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:
|
||||||
@@ -5338,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: {}
|
||||||
@@ -5428,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
|
||||||
@@ -5490,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:
|
||||||
@@ -5505,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: {}
|
||||||
@@ -5527,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: {}
|
||||||
@@ -5647,10 +5480,6 @@ snapshots:
|
|||||||
|
|
||||||
lodash.castarray@4.4.0: {}
|
lodash.castarray@4.4.0: {}
|
||||||
|
|
||||||
lodash.clonedeep@4.5.0: {}
|
|
||||||
|
|
||||||
lodash.isequal@4.5.0: {}
|
|
||||||
|
|
||||||
lodash.isplainobject@4.0.6: {}
|
lodash.isplainobject@4.0.6: {}
|
||||||
|
|
||||||
lodash.merge@4.6.2: {}
|
lodash.merge@4.6.2: {}
|
||||||
@@ -5795,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
|
||||||
@@ -5837,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
|
||||||
@@ -6088,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
|
||||||
@@ -6138,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
|
||||||
@@ -6232,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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
@@ -46,23 +50,28 @@
|
|||||||
@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"
|
||||||
>
|
>
|
||||||
<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 prose-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">
|
||||||
@@ -104,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"
|
||||||
@@ -121,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>
|
||||||
@@ -131,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>
|
||||||
|
|||||||
@@ -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-full" />
|
<ReplyBox />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -238,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">
|
||||||
|
|||||||
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,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-wrap">
|
<div class="flex flex-wrap">
|
||||||
<div class="flex flex-wrap">
|
<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"
|
||||||
|
|||||||
@@ -1,4 +1,38 @@
|
|||||||
<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">
|
||||||
@@ -98,15 +132,44 @@ 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 { useUserStore } from '@/stores/user'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
|
|
||||||
import { useConversationStore } from '@/stores/conversation'
|
import { useConversationStore } from '@/stores/conversation'
|
||||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
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 ReplyBoxContent from '@/features/conversation/ReplyBoxContent.vue'
|
import ReplyBoxContent from '@/features/conversation/ReplyBoxContent.vue'
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormMessage
|
||||||
|
} from '@/components/ui/form'
|
||||||
|
import { toTypedSchema } from '@vee-validate/zod'
|
||||||
|
import * as z from 'zod'
|
||||||
|
|
||||||
|
const formSchema = toTypedSchema(
|
||||||
|
z.object({
|
||||||
|
apiKey: z.string().min(1, 'API key is required')
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
const conversationStore = useConversationStore()
|
const conversationStore = useConversationStore()
|
||||||
const emitter = useEmitter()
|
const emitter = useEmitter()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const openAIKeyPrompt = ref(false)
|
||||||
|
const isOpenAIKeyUpdating = ref(false)
|
||||||
|
|
||||||
// Shared state between the two editor components.
|
// Shared state between the two editor components.
|
||||||
const clearEditorContent = ref(false)
|
const clearEditorContent = ref(false)
|
||||||
@@ -164,6 +227,10 @@ const handleAiPromptSelected = async (key) => {
|
|||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
})
|
})
|
||||||
} 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',
|
||||||
@@ -172,6 +239,30 @@ const handleAiPromptSelected = async (key) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the file upload process when files are selected.
|
* Handles the file upload process when files are selected.
|
||||||
* Uploads each file to the server and adds them to the conversation's mediaFiles.
|
* Uploads each file to the server and adds them to the conversation's mediaFiles.
|
||||||
@@ -242,6 +333,7 @@ const hasTextContent = computed(() => {
|
|||||||
* Processes the send action.
|
* Processes the send action.
|
||||||
*/
|
*/
|
||||||
const processSend = async () => {
|
const processSend = async () => {
|
||||||
|
let hasAPIErrored = false
|
||||||
isEditorFullscreen.value = false
|
isEditorFullscreen.value = false
|
||||||
try {
|
try {
|
||||||
isSending.value = true
|
isSending.value = true
|
||||||
@@ -267,7 +359,7 @@ const processSend = async () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
await api.sendMessage(conversationStore.current.uuid, {
|
await api.sendMessage(conversationStore.current.uuid, {
|
||||||
private: messageType.value === 'private',
|
private: messageType.value === 'private_note',
|
||||||
message: message,
|
message: message,
|
||||||
attachments: conversationStore.conversation.mediaFiles.map((file) => file.id),
|
attachments: conversationStore.conversation.mediaFiles.map((file) => file.id),
|
||||||
// Convert email addresses to array and remove empty strings.
|
// Convert email addresses to array and remove empty strings.
|
||||||
@@ -284,31 +376,52 @@ const processSend = 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
|
||||||
// Reset media and macro in conversation store.
|
|
||||||
|
// 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.
|
// Update assignee last seen timestamp.
|
||||||
api.updateAssigneeLastSeen(conversationStore.current.uuid)
|
api.updateAssigneeLastSeen(conversationStore.current.uuid)
|
||||||
}
|
}
|
||||||
@@ -325,25 +438,16 @@ const handleOnFileDelete = (uuid) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Watches for changes in the conversation's macro and updates the editor content with the macro content.
|
* Watches for changes in the conversation's macro id and update message 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.
|
||||||
// Maybe use some other editor that doesn't add this?
|
|
||||||
if (conversationStore.conversation?.macro?.message_content) {
|
|
||||||
const contentToRender = conversationStore.conversation.macro.message_content.replace(
|
|
||||||
/<p><br><\/p>/g,
|
|
||||||
'<p></p>'
|
|
||||||
)
|
|
||||||
// Add timestamp to ensure the watcher detects the change even for identical content,
|
|
||||||
// As user can send the same macro multiple times.
|
|
||||||
contentToSet.value = JSON.stringify({
|
contentToSet.value = JSON.stringify({
|
||||||
content: contentToRender,
|
content: conversationStore.conversation.macro.message_content,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
})
|
})
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{ deep: true }
|
{ deep: true }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col h-full max-h-[600px]">
|
<!-- Set fixed width only when not in fullscreen. -->
|
||||||
|
<div class="flex flex-col h-full" :class="{ 'max-h-[600px]': !isFullscreen }">
|
||||||
<!-- Message type toggle -->
|
<!-- Message type toggle -->
|
||||||
<div
|
<div
|
||||||
class="flex justify-between items-center"
|
class="flex justify-between items-center"
|
||||||
@@ -200,7 +201,8 @@ const emitter = useEmitter()
|
|||||||
|
|
||||||
const insertContent = ref(null)
|
const insertContent = ref(null)
|
||||||
const setInlineImage = ref(null)
|
const setInlineImage = ref(null)
|
||||||
const editorPlaceholder = 'Press Enter to add a new line; Press Ctrl + Enter to send.'
|
const editorPlaceholder =
|
||||||
|
'Shift + Enter to add a new line. Cmd + Enter to send. Cmd + K to open command bar.'
|
||||||
|
|
||||||
const toggleBcc = async () => {
|
const toggleBcc = async () => {
|
||||||
showBcc.value = !showBcc.value
|
showBcc.value = !showBcc.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>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
<!-- Message Content -->
|
<!-- Message Content -->
|
||||||
<div
|
<div
|
||||||
v-dompurify-html="messageContent"
|
v-dompurify-html="messageContent"
|
||||||
class="whitespace-pre-wrap break-words overflow-wrap-anywhere"
|
class="whitespace-pre-wrap break-words overflow-wrap-anywhere native-html"
|
||||||
:class="{ 'mb-3': message.attachments.length > 0 }"
|
:class="{ 'mb-3': message.attachments.length > 0 }"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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'" />
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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([])
|
||||||
|
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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,17 @@ 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') and private = false
|
||||||
|
and source_id > ''
|
||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
LIMIT 1;
|
LIMIT $2;
|
||||||
|
|
||||||
-- name: get-pending-messages
|
-- name: get-pending-messages
|
||||||
SELECT
|
SELECT
|
||||||
@@ -521,3 +528,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,16 +112,24 @@ 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+">")
|
||||||
|
e.lo.Debug("In-Reply-To header set", "message_id", m.InReplyTo)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set references message ids
|
// Set message id header
|
||||||
|
if m.SourceID.String != "" {
|
||||||
|
email.Headers.Set(headerMessageID, fmt.Sprintf("<%s>", m.SourceID.String))
|
||||||
|
e.lo.Debug("Message-ID header set", "message_id", m.SourceID.String)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set references header
|
||||||
var references string
|
var references string
|
||||||
for _, ref := range m.References {
|
for _, ref := range m.References {
|
||||||
references += "<" + ref + "> "
|
references += "<" + ref + "> "
|
||||||
}
|
}
|
||||||
|
e.lo.Debug("References header set", "references", references)
|
||||||
email.Headers.Set(headerReferences, references)
|
email.Headers.Set(headerReferences, references)
|
||||||
|
|
||||||
// Set email content
|
// Set email content
|
||||||
|
|||||||
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.
|
||||||
@@ -332,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 {
|
||||||
@@ -341,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