feat: Macros, macros not completely replace canned responses as they do the same job as canned repsonse.

- tiptap editor fixes for hardlines.
- set app font to jakarta, fix shadcn components having a different font.
- UI improvements on /admin
- UI improvements on inbox tab.
This commit is contained in:
Abhinav Raut
2025-01-17 04:57:01 +05:30
parent 0a3cca75f5
commit 87c14aa15d
99 changed files with 2786 additions and 1781 deletions

View File

@@ -20,7 +20,7 @@ Self-hosted 100% open-source support desk. Single binary with minimal dependenci
| **SLA** | Configure and manage service level agreements. |
| **CSAT** | Measure customer satisfaction with post-interaction surveys. |
| **Reports** | Gain insights and analyze support performance, with complete freedom to integrate analytics tools like Metabase for generating custom reports. |
| **Canned Responses** | Save and reuse common replies for efficiency. |
| **Macros** | Save and reuse common replies and common actions for effciency |
| **Auto Assignment** | Automatically assign tickets to agents based on defined rules. |
| **Snooze Conversations** | Temporarily pause conversations and set reminders to revisit them later. |
| **Automation Rules** | Define rules to automate workflows on conversation creation, updates, or hourly triggers. |

View File

@@ -1,98 +0,0 @@
package main
import (
"strconv"
cmodels "github.com/abhinavxd/libredesk/internal/cannedresp/models"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
func handleGetCannedResponses(r *fastglue.Request) error {
var (
app = r.Context.(*App)
c []cmodels.CannedResponse
)
c, err := app.cannedResp.GetAll()
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(c)
}
func handleCreateCannedResponse(r *fastglue.Request) error {
var (
app = r.Context.(*App)
cannedResponse = cmodels.CannedResponse{}
)
if err := r.Decode(&cannedResponse, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
}
if cannedResponse.Title == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty canned response `Title`", nil, envelope.InputError)
}
if cannedResponse.Content == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty canned response `Content`", nil, envelope.InputError)
}
err := app.cannedResp.Create(cannedResponse.Title, cannedResponse.Content)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(cannedResponse)
}
func handleDeleteCannedResponse(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid canned response `id`.", nil, envelope.InputError)
}
if err := app.cannedResp.Delete(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(true)
}
func handleUpdateCannedResponse(r *fastglue.Request) error {
var (
app = r.Context.(*App)
cannedResponse = cmodels.CannedResponse{}
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid canned response `id`.", nil, envelope.InputError)
}
if err := r.Decode(&cannedResponse, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
}
if cannedResponse.Title == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty canned response `Title`", nil, envelope.InputError)
}
if cannedResponse.Content == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty canned response `Content`", nil, envelope.InputError)
}
if err = app.cannedResp.Update(id, cannedResponse.Title, cannedResponse.Content); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(cannedResponse)
}

View File

@@ -481,23 +481,20 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
return r.SendEnvelope("Status updated successfully")
}
// handleAddConversationTags adds tags to a conversation.
func handleAddConversationTags(r *fastglue.Request) error {
// handleUpdateConversationtags updates conversation tags.
func handleUpdateConversationtags(r *fastglue.Request) error {
var (
app = r.Context.(*App)
tagIDs = []int{}
tagJSON = r.RequestCtx.PostArgs().Peek("tag_ids")
auser = r.RequestCtx.UserValue("user").(amodels.User)
uuid = r.RequestCtx.UserValue("uuid").(string)
app = r.Context.(*App)
tagNames = []string{}
tagJSON = r.RequestCtx.PostArgs().Peek("tags")
auser = r.RequestCtx.UserValue("user").(amodels.User)
uuid = r.RequestCtx.UserValue("uuid").(string)
)
// Parse tag IDs from JSON
err := json.Unmarshal(tagJSON, &tagIDs)
if err != nil {
app.lo.Error("unmarshalling tag ids", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "error adding tags", nil, "")
if err := json.Unmarshal(tagJSON, &tagNames); err != nil {
app.lo.Error("error unmarshalling tags JSON", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error unmarshalling tags JSON", nil, envelope.GeneralError)
}
conversation, err := app.conversation.GetConversation(0, uuid)
if err != nil {
return sendErrorEnvelope(r, err)
@@ -516,7 +513,7 @@ func handleAddConversationTags(r *fastglue.Request) error {
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
}
if err := app.conversation.UpsertConversationTags(uuid, tagIDs); err != nil {
if err := app.conversation.UpsertConversationTags(uuid, tagNames); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Tags added successfully")

View File

@@ -58,7 +58,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.PUT("/api/v1/conversations/{uuid}/priority", perm(handleUpdateConversationPriority, "conversations:update_priority"))
g.PUT("/api/v1/conversations/{uuid}/status", perm(handleUpdateConversationStatus, "conversations:update_status"))
g.PUT("/api/v1/conversations/{uuid}/last-seen", perm(handleUpdateConversationAssigneeLastSeen, "conversations:read"))
g.POST("/api/v1/conversations/{uuid}/tags", perm(handleAddConversationTags, "conversations:update_tags"))
g.POST("/api/v1/conversations/{uuid}/tags", perm(handleUpdateConversationtags, "conversations:update_tags"))
g.GET("/api/v1/conversations/{cuuid}/messages/{uuid}", perm(handleGetMessage, "messages:read"))
g.GET("/api/v1/conversations/{uuid}/messages", perm(handleGetMessages, "messages:read"))
g.POST("/api/v1/conversations/{cuuid}/messages", perm(handleSendMessage, "messages:write"))
@@ -86,11 +86,13 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// Media.
g.POST("/api/v1/media", auth(handleMediaUpload))
// Canned response.
g.GET("/api/v1/canned-responses", auth(handleGetCannedResponses))
g.POST("/api/v1/canned-responses", perm(handleCreateCannedResponse, "canned_responses:manage"))
g.PUT("/api/v1/canned-responses/{id}", perm(handleUpdateCannedResponse, "canned_responses:manage"))
g.DELETE("/api/v1/canned-responses/{id}", perm(handleDeleteCannedResponse, "canned_responses:manage"))
// Macros.
g.GET("/api/v1/macros", auth(handleGetMacros))
g.GET("/api/v1/macros/{id}", perm(handleGetMacro, "macros:manage"))
g.POST("/api/v1/macros", perm(handleCreateMacro, "macros:manage"))
g.PUT("/api/v1/macros/{id}", perm(handleUpdateMacro, "macros:manage"))
g.DELETE("/api/v1/macros/{id}", perm(handleDeleteMacro, "macros:manage"))
g.POST("/api/v1/conversations/{uuid}/macros/{id}/apply", auth(handleApplyMacro))
// User.
g.GET("/api/v1/users/me", auth(handleGetCurrentUser))

View File

@@ -18,7 +18,6 @@ import (
"github.com/abhinavxd/libredesk/internal/autoassigner"
"github.com/abhinavxd/libredesk/internal/automation"
businesshours "github.com/abhinavxd/libredesk/internal/business_hours"
"github.com/abhinavxd/libredesk/internal/cannedresp"
"github.com/abhinavxd/libredesk/internal/conversation"
"github.com/abhinavxd/libredesk/internal/conversation/priority"
"github.com/abhinavxd/libredesk/internal/conversation/status"
@@ -26,6 +25,7 @@ import (
"github.com/abhinavxd/libredesk/internal/inbox"
"github.com/abhinavxd/libredesk/internal/inbox/channel/email"
imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
"github.com/abhinavxd/libredesk/internal/macro"
"github.com/abhinavxd/libredesk/internal/media"
fs "github.com/abhinavxd/libredesk/internal/media/stores/localfs"
"github.com/abhinavxd/libredesk/internal/media/stores/s3"
@@ -196,6 +196,7 @@ func initUser(i18n *i18n.I18n, DB *sqlx.DB) *user.Manager {
// initConversations inits conversation manager.
func initConversations(
i18n *i18n.I18n,
sla *sla.Manager,
status *status.Manager,
priority *priority.Manager,
hub *ws.Hub,
@@ -208,7 +209,7 @@ func initConversations(
automationEngine *automation.Engine,
template *tmpl.Manager,
) *conversation.Manager {
c, err := conversation.New(hub, i18n, notif, status, priority, inboxStore, userStore, teamStore, mediaStore, automationEngine, template, conversation.Opts{
c, err := conversation.New(hub, i18n, notif, sla, status, priority, inboxStore, userStore, teamStore, mediaStore, automationEngine, template, conversation.Opts{
DB: db,
Lo: initLogger("conversation_manager"),
OutgoingMessageQueueSize: ko.MustInt("message.outgoing_queue_size"),
@@ -246,17 +247,17 @@ func initView(db *sqlx.DB) *view.Manager {
return m
}
// initCannedResponse inits canned response manager.
func initCannedResponse(db *sqlx.DB) *cannedresp.Manager {
var lo = initLogger("canned-response")
c, err := cannedresp.New(cannedresp.Opts{
// initMacro inits macro manager.
func initMacro(db *sqlx.DB) *macro.Manager {
var lo = initLogger("macro")
m, err := macro.New(macro.Opts{
DB: db,
Lo: lo,
})
if err != nil {
log.Fatalf("error initializing canned responses manager: %v", err)
log.Fatalf("error initializing macro manager: %v", err)
}
return c
return m
}
// initBusinessHours inits business hours manager.
@@ -414,15 +415,9 @@ func initInbox(db *sqlx.DB) *inbox.Manager {
}
// initAutomationEngine initializes the automation engine.
func initAutomationEngine(db *sqlx.DB, userManager *user.Manager) *automation.Engine {
func initAutomationEngine(db *sqlx.DB) *automation.Engine {
var lo = initLogger("automation_engine")
systemUser, err := userManager.GetSystemUser()
if err != nil {
log.Fatalf("error fetching system user: %v", err)
}
engine, err := automation.New(systemUser, automation.Opts{
engine, err := automation.New(automation.Opts{
DB: db,
Lo: lo,
})

306
cmd/macro.go Normal file
View File

@@ -0,0 +1,306 @@
package main
import (
"encoding/json"
"fmt"
"slices"
"strconv"
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
autoModels "github.com/abhinavxd/libredesk/internal/automation/models"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/macro/models"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
// handleGetMacros returns all macros.
func handleGetMacros(r *fastglue.Request) error {
var app = r.Context.(*App)
macros, err := app.macro.GetAll()
if err != nil {
return sendErrorEnvelope(r, err)
}
for i, m := range macros {
var actions []autoModels.RuleAction
if err := json.Unmarshal(m.Actions, &actions); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error unmarshalling macro actions", nil, envelope.GeneralError)
}
// Set display values for actions as the value field can contain DB IDs
if err := setDisplayValues(app, actions); err != nil {
app.lo.Warn("error setting display values", "error", err)
}
if macros[i].Actions, err = json.Marshal(actions); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "marshal failed", nil, envelope.GeneralError)
}
}
return r.SendEnvelope(macros)
}
// handleGetMacro returns a macro.
func handleGetMacro(r *fastglue.Request) error {
var (
app = r.Context.(*App)
id, err = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
)
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid macro `id`.", nil, envelope.InputError)
}
macro, err := app.macro.Get(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
var actions []autoModels.RuleAction
if err := json.Unmarshal(macro.Actions, &actions); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error unmarshalling macro actions", nil, envelope.GeneralError)
}
// Set display values for actions as the value field can contain DB IDs
if err := setDisplayValues(app, actions); err != nil {
app.lo.Warn("error setting display values", "error", err)
}
if macro.Actions, err = json.Marshal(actions); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "marshal failed", nil, envelope.GeneralError)
}
return r.SendEnvelope(macro)
}
// handleCreateMacro creates new macro.
func handleCreateMacro(r *fastglue.Request) error {
var (
app = r.Context.(*App)
macro = models.Macro{}
)
if err := r.Decode(&macro, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
}
if err := validateMacro(macro); err != nil {
return sendErrorEnvelope(r, err)
}
err := app.macro.Create(macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.Actions)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(macro)
}
// handleUpdateMacro updates a macro.
func handleUpdateMacro(r *fastglue.Request) error {
var (
app = r.Context.(*App)
macro = models.Macro{}
)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid macro `id`.", nil, envelope.InputError)
}
if err := r.Decode(&macro, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "decode failed", err.Error(), envelope.InputError)
}
if err := validateMacro(macro); err != nil {
return sendErrorEnvelope(r, err)
}
if err = app.macro.Update(id, macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.Actions); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(macro)
}
// handleDeleteMacro deletes macro.
func handleDeleteMacro(r *fastglue.Request) error {
var app = r.Context.(*App)
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
if err != nil || id == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest,
"Invalid macro `id`.", nil, envelope.InputError)
}
if err := app.macro.Delete(id); err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope("Macro deleted successfully")
}
// handleApplyMacro applies macro actions to a conversation.
func handleApplyMacro(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
conversationUUID = r.RequestCtx.UserValue("uuid").(string)
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
incomingActions = []autoModels.RuleAction{}
)
user, err := app.user.Get(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Enforce conversation access.
conversation, err := app.conversation.GetConversation(0, conversationUUID)
if err != nil {
return sendErrorEnvelope(r, err)
}
if allowed, err := app.authz.EnforceConversationAccess(user, conversation); err != nil || !allowed {
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil))
}
macro, err := app.macro.Get(id)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Decode incoming actions.
if err := r.Decode(&incomingActions, "json"); err != nil {
app.lo.Error("error unmashalling incoming actions", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Failed to decode incoming actions", nil, envelope.InputError)
}
// Make sure no duplicate action types are present.
actionTypes := make(map[string]bool, len(incomingActions))
for _, act := range incomingActions {
if actionTypes[act.Type] {
app.lo.Warn("duplicate action types found in macro apply apply request", "action", act.Type, "user_id", user.ID)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Duplicate action types not allowed", nil, envelope.InputError)
}
actionTypes[act.Type] = true
}
// Validate action permissions.
for _, act := range incomingActions {
if !isMacroActionAllowed(act.Type) {
app.lo.Warn("action not allowed in macro", "action", act.Type, "user_id", user.ID)
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "Action not allowed in macro", nil, envelope.PermissionError)
}
if !hasActionPermission(act.Type, user.Permissions) {
app.lo.Warn("no permission to execute macro action", "action", act.Type, "user_id", user.ID)
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "No permission to execute this macro", nil, envelope.PermissionError)
}
}
// Apply actions.
successCount := 0
for _, act := range incomingActions {
if err := app.conversation.ApplyAction(act, conversation, user); err == nil {
successCount++
}
}
if successCount == 0 {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Failed to apply macro", nil, envelope.GeneralError)
}
// Increment usage count.
app.macro.IncrementUsageCount(macro.ID)
if successCount < len(incomingActions) {
return r.SendJSON(fasthttp.StatusMultiStatus, map[string]interface{}{
"message": fmt.Sprintf("Macro executed with errors. %d actions succeeded out of %d", successCount, len(incomingActions)),
})
}
return r.SendJSON(fasthttp.StatusOK, map[string]interface{}{
"message": "Macro applied successfully",
})
}
// hasActionPermission checks user permission for given action
func hasActionPermission(action string, userPerms []string) bool {
requiredPerm, exists := autoModels.ActionPermissions[action]
if !exists {
return false
}
return slices.Contains(userPerms, requiredPerm)
}
// setDisplayValues sets display values for actions.
func setDisplayValues(app *App, actions []autoModels.RuleAction) error {
getters := map[string]func(int) (string, error){
autoModels.ActionAssignTeam: func(id int) (string, error) {
t, err := app.team.Get(id)
if err != nil {
app.lo.Warn("team not found for macro action", "team_id", id)
return "", err
}
return t.Name, nil
},
autoModels.ActionAssignUser: func(id int) (string, error) {
u, err := app.user.Get(id)
if err != nil {
app.lo.Warn("user not found for macro action", "user_id", id)
return "", err
}
return u.FullName(), nil
},
autoModels.ActionSetPriority: func(id int) (string, error) {
p, err := app.priority.Get(id)
if err != nil {
app.lo.Warn("priority not found for macro action", "priority_id", id)
return "", err
}
return p.Name, nil
},
autoModels.ActionSetStatus: func(id int) (string, error) {
s, err := app.status.Get(id)
if err != nil {
app.lo.Warn("status not found for macro action", "status_id", id)
return "", err
}
return s.Name, nil
},
}
for i := range actions {
actions[i].DisplayValue = []string{}
if getter, ok := getters[actions[i].Type]; ok {
id, _ := strconv.Atoi(actions[i].Value[0])
if name, err := getter(id); err == nil {
actions[i].DisplayValue = append(actions[i].DisplayValue, name)
}
}
}
return nil
}
// validateMacro validates an incoming macro.
func validateMacro(macro models.Macro) error {
if macro.Name == "" {
return envelope.NewError(envelope.InputError, "Empty macro `name`", nil)
}
var act []autoModels.RuleAction
if err := json.Unmarshal(macro.Actions, &act); err != nil {
return envelope.NewError(envelope.InputError, "Could not parse macro actions", nil)
}
for _, a := range act {
if len(a.Value) == 0 {
return envelope.NewError(envelope.InputError, fmt.Sprintf("Empty value for action: %s", a.Type), nil)
}
}
return nil
}
// isMacroActionAllowed returns true if the action is allowed in a macro.
func isMacroActionAllowed(action string) bool {
switch action {
case autoModels.ActionSendPrivateNote, autoModels.ActionReply:
return false
case autoModels.ActionAssignTeam, autoModels.ActionAssignUser, autoModels.ActionSetStatus, autoModels.ActionSetPriority, autoModels.ActionSetTags:
return true
default:
return false
}
}

View File

@@ -14,12 +14,12 @@ import (
businesshours "github.com/abhinavxd/libredesk/internal/business_hours"
"github.com/abhinavxd/libredesk/internal/colorlog"
"github.com/abhinavxd/libredesk/internal/csat"
"github.com/abhinavxd/libredesk/internal/macro"
notifier "github.com/abhinavxd/libredesk/internal/notification"
"github.com/abhinavxd/libredesk/internal/sla"
"github.com/abhinavxd/libredesk/internal/view"
"github.com/abhinavxd/libredesk/internal/automation"
"github.com/abhinavxd/libredesk/internal/cannedresp"
"github.com/abhinavxd/libredesk/internal/conversation"
"github.com/abhinavxd/libredesk/internal/conversation/priority"
"github.com/abhinavxd/libredesk/internal/conversation/status"
@@ -67,7 +67,7 @@ type App struct {
tag *tag.Manager
inbox *inbox.Manager
tmpl *template.Manager
cannedResp *cannedresp.Manager
macro *macro.Manager
conversation *conversation.Manager
automation *automation.Engine
businessHours *businesshours.Manager
@@ -149,15 +149,14 @@ func main() {
businessHours = initBusinessHours(db)
user = initUser(i18n, db)
notifier = initNotifier(user)
automation = initAutomationEngine(db, user)
automation = initAutomationEngine(db)
sla = initSLA(db, team, settings, businessHours)
conversation = initConversations(i18n, status, priority, wsHub, notifier, db, inbox, user, team, media, automation, template)
conversation = initConversations(i18n, sla, status, priority, wsHub, notifier, db, inbox, user, team, media, automation, template)
autoassigner = initAutoAssigner(team, user, conversation)
)
// Set stores.
wsHub.SetConversationStore(conversation)
automation.SetConversationStore(conversation, sla)
automation.SetConversationStore(conversation)
// Start inbox receivers.
startInboxes(ctx, inbox, conversation)
@@ -209,8 +208,8 @@ func main() {
authz: initAuthz(),
role: initRole(db),
tag: initTag(db),
macro: initMacro(db),
ai: initAI(db),
cannedResp: initCannedResponse(db),
}
// Init fastglue and set app in ctx.
@@ -223,7 +222,7 @@ func main() {
initHandlers(g, wsHub)
s := &fasthttp.Server{
Name: "server",
Name: "libredesk",
ReadTimeout: ko.MustDuration("app.server.read_timeout"),
WriteTimeout: ko.MustDuration("app.server.write_timeout"),
MaxRequestBodySize: ko.MustInt("app.server.max_body_size"),

View File

@@ -1,16 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap"
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap"
rel="stylesheet">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@@ -20,10 +20,8 @@
"@radix-icons/vue": "^1.0.0",
"@tailwindcss/typography": "^0.5.10",
"@tanstack/vue-table": "^8.19.2",
"@tiptap/extension-hard-break": "^2.11.0",
"@tiptap/extension-image": "^2.5.9",
"@tiptap/extension-link": "^2.9.1",
"@tiptap/extension-list-item": "^2.4.0",
"@tiptap/extension-ordered-list": "^2.4.0",
"@tiptap/extension-placeholder": "^2.4.0",
"@tiptap/pm": "^2.4.0",

View File

@@ -23,18 +23,12 @@ importers:
'@tanstack/vue-table':
specifier: ^8.19.2
version: 8.20.5(vue@3.5.13(typescript@5.7.3))
'@tiptap/extension-hard-break':
specifier: ^2.11.0
version: 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))
'@tiptap/extension-image':
specifier: ^2.5.9
version: 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))
'@tiptap/extension-link':
specifier: ^2.9.1
version: 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))(@tiptap/pm@2.11.2)
'@tiptap/extension-list-item':
specifier: ^2.4.0
version: 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))
'@tiptap/extension-ordered-list':
specifier: ^2.4.0
version: 2.11.2(@tiptap/core@2.11.2(@tiptap/pm@2.11.2))

View File

@@ -1,5 +1,4 @@
<template>
<Toaster />
<Sidebar
:isLoading="false"
:open="sidebarOpen"
@@ -10,30 +9,21 @@
@edit-view="editView"
@delete-view="deleteView"
>
<ResizablePanelGroup direction="horizontal" auto-save-id="app.vue.resizable.panel">
<ResizableHandle id="resize-handle-1" />
<ResizablePanel id="resize-panel-2">
<div class="w-full h-screen">
<PageHeader />
<RouterView />
</div>
</ResizablePanel>
<ViewForm v-model:openDialog="openCreateViewForm" v-model:view="view" />
</ResizablePanelGroup>
<div class="w-full h-screen border-l">
<PageHeader />
<RouterView />
</div>
<ViewForm v-model:openDialog="openCreateViewForm" v-model:view="view" />
</Sidebar>
<div class="font-jakarta">
<Command />
</div>
<Command />
</template>
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import { RouterView, useRouter } from 'vue-router'
import { RouterView } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { initWS } from '@/websocket.js'
import { Toaster } from '@/components/ui/sonner'
import { useToast } from '@/components/ui/toast/use-toast'
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { handleHTTPError } from '@/utils/http'
@@ -42,11 +32,13 @@ import { useInboxStore } from '@/stores/inbox'
import { useUsersStore } from '@/stores/users'
import { useTeamStore } from '@/stores/team'
import { useSlaStore } from '@/stores/sla'
import { useMacroStore } from '@/stores/macro'
import { useTagStore } from '@/stores/tag'
import PageHeader from './components/common/PageHeader.vue'
import ViewForm from '@/components/ViewForm.vue'
import api from '@/api'
import Sidebar from '@/components/sidebar/Sidebar.vue'
import Command from '@/components/command/command.vue'
import Command from '@/components/command/CommandBox.vue'
const { toast } = useToast()
const emitter = useEmitter()
@@ -57,7 +49,8 @@ const usersStore = useUsersStore()
const teamStore = useTeamStore()
const inboxStore = useInboxStore()
const slaStore = useSlaStore()
const router = useRouter()
const macroStore = useMacroStore()
const tagStore = useTagStore()
const userViews = ref([])
const view = ref({})
const openCreateViewForm = ref(false)
@@ -66,8 +59,6 @@ initWS()
onMounted(() => {
initToaster()
listenViewRefresh()
getCurrentUser()
getUserViews()
initStores()
})
@@ -76,14 +67,19 @@ onUnmounted(() => {
emitter.off(EMITTER_EVENTS.REFRESH_LIST, refreshViews)
})
// initialize data stores
const initStores = async () => {
await Promise.all([
await Promise.allSettled([
userStore.getCurrentUser(),
getUserViews(),
conversationStore.fetchStatuses(),
conversationStore.fetchPriorities(),
usersStore.fetchUsers(),
teamStore.fetchTeams(),
inboxStore.fetchInboxes(),
slaStore.fetchSlas()
slaStore.fetchSlas(),
macroStore.loadMacros(),
tagStore.fetchTags()
])
}
@@ -98,7 +94,6 @@ const deleteView = async (view) => {
emitter.emit(EMITTER_EVENTS.REFRESH_LIST, { model: 'view' })
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Success',
variant: 'success',
description: 'View deleted successfully'
})
} catch (err) {
@@ -123,14 +118,6 @@ const getUserViews = async () => {
}
}
const getCurrentUser = () => {
userStore.getCurrentUser().catch((err) => {
if (err.response && err.response.status === 401) {
router.push('/')
}
})
}
const initToaster = () => {
emitter.on(EMITTER_EVENTS.SHOW_TOAST, toast)
}

View File

@@ -1,7 +1,7 @@
<template>
<Toaster />
<TooltipProvider :delay-duration="200">
<div class="font-inter">
<div class="!font-jakarta">
<RouterView />
</div>
</TooltipProvider>

View File

@@ -182,10 +182,24 @@ const sendMessage = (uuid, data) =>
})
const getConversation = (uuid) => http.get(`/api/v1/conversations/${uuid}`)
const getConversationParticipants = (uuid) => http.get(`/api/v1/conversations/${uuid}/participants`)
const getCannedResponses = () => http.get('/api/v1/canned-responses')
const createCannedResponse = (data) => http.post('/api/v1/canned-responses', data)
const updateCannedResponse = (id, data) => http.put(`/api/v1/canned-responses/${id}`, data)
const deleteCannedResponse = (id) => http.delete(`/api/v1/canned-responses/${id}`)
const getAllMacros = () => http.get('/api/v1/macros')
const getMacro = (id) => http.get(`/api/v1/macros/${id}`)
const createMacro = (data) => http.post('/api/v1/macros', data, {
headers: {
'Content-Type': 'application/json'
}
})
const updateMacro = (id, data) => http.put(`/api/v1/macros/${id}`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const deleteMacro = (id) => http.delete(`/api/v1/macros/${id}`)
const applyMacro = (uuid, id, data) => http.post(`/api/v1/conversations/${uuid}/macros/${id}/apply`, data, {
headers: {
'Content-Type': 'application/json'
}
})
const getTeamUnassignedConversations = (teamID, params) =>
http.get(`/api/v1/teams/${teamID}/conversations/unassigned`, { params })
const getAssignedConversations = (params) => http.get('/api/v1/conversations/assigned', { params })
@@ -290,10 +304,12 @@ export default {
getConversationMessages,
getCurrentUser,
getCurrentUserTeams,
getCannedResponses,
createCannedResponse,
updateCannedResponse,
deleteCannedResponse,
getAllMacros,
getMacro,
createMacro,
updateMacro,
deleteMacro,
applyMacro,
updateCurrentUser,
updateAssignee,
updateConversationStatus,

View File

@@ -9,10 +9,16 @@
}
.page-content {
padding: 1rem 1rem;
height: 100%;
overflow-y: scroll;
padding-bottom: 100px;
@apply bg-slate-50;
}
@layer base {
html {
font-family: 'Plus Jakarta Sans', sans-serif;
}
}
body {
@@ -20,7 +26,6 @@ body {
}
// Theme.
@layer base {
:root {
--background: 0 0% 100%;

View File

@@ -1,11 +1,11 @@
<template>
<Dialog :open="openDialog" @update:open="openDialog = false">
<DialogContent>
<DialogHeader>
<DialogHeader class="space-y-1">
<DialogTitle>{{ view?.id ? 'Edit' : 'Create' }} view</DialogTitle>
<DialogDescription
>Views let you create custom filters and save them for reuse.</DialogDescription
>
<DialogDescription>
Views let you create custom filters and save them.
</DialogDescription>
</DialogHeader>
<form @submit.prevent="onSubmit">
<div class="grid gap-4 py-4">
@@ -50,7 +50,7 @@
<FormControl>
<Filter :fields="filterFields" :showButtons="false" v-bind="componentField" />
</FormControl>
<FormDescription>Add filters to customize view.</FormDescription>
<FormDescription>Add multiple filters to customize view.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
@@ -95,7 +95,7 @@ import {
} from '@/components/ui/form'
import { CONVERSATION_VIEWS_INBOXES } from '@/constants/conversation'
import { Input } from '@/components/ui/input'
import Filter from '@/components/common/Filter.vue'
import Filter from '@/components/common/FilterBuilder.vue'
import { useConversationFilters } from '@/composables/useConversationFilters'
import { toTypedSchema } from '@vee-validate/zod'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'

View File

@@ -10,6 +10,8 @@
<div class="flex items-center justify-between">
<div class="flex gap-5">
<div class="w-48">
<!-- Type -->
<Select
v-model="action.type"
@update:modelValue="(value) => handleFieldChange(value, index)"
@@ -31,12 +33,24 @@
</Select>
</div>
<!-- Value -->
<div
v-if="action.type && conversationActions[action.type]?.type === 'tag'"
class="w-full"
>
<SelectTag
v-model="action.value"
:items="tagsStore.tagNames"
placeholder="Select tag"
/>
</div>
<div
class="w-48"
v-if="action.type && conversationActions[action.type]?.type === 'select'"
>
<ComboBox
v-model="action.value"
v-model="action.value[0]"
:items="conversationActions[action.type]?.options"
placeholder="Select"
@select="handleValueChange($event, index)"
@@ -100,7 +114,7 @@
>
<QuillEditor
theme="snow"
v-model:content="action.value"
v-model:content="action.value[0]"
contentType="html"
@update:content="(value) => handleValueChange(value, index)"
class="h-32 mb-12"
@@ -119,6 +133,7 @@
import { toRefs } from 'vue'
import { Button } from '@/components/ui/button'
import { X } from 'lucide-vue-next'
import { useTagStore } from '@/stores/tag'
import {
Select,
SelectContent,
@@ -131,6 +146,7 @@ import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { SelectTag } from '@/components/ui/select'
import { useConversationFilters } from '@/composables/useConversationFilters'
const props = defineProps({
@@ -142,10 +158,11 @@ const props = defineProps({
const { actions } = toRefs(props)
const emit = defineEmits(['update-actions', 'add-action', 'remove-action'])
const tagsStore = useTagStore()
const { conversationActions } = useConversationFilters()
const handleFieldChange = (value, index) => {
actions.value[index].value = ''
actions.value[index].value = []
actions.value[index].type = value
emitUpdate(index)
}
@@ -154,7 +171,7 @@ const handleValueChange = (value, index) => {
if (typeof value === 'object') {
value = value.value
}
actions.value[index].value = value
actions.value[index].value = [value]
emitUpdate(index)
}

View File

@@ -1,5 +1,4 @@
<template>
<div class="w-8/12">
<div v-if="router.currentRoute.value.path === '/admin/automations'">
<div class="flex justify-between mb-5">
<div class="ml-auto">
@@ -11,7 +10,6 @@
</div>
</div>
<router-view />
</div>
</template>
<script setup>

View File

@@ -1,6 +1,4 @@
<template>
<div class="w-8/12">
<template v-if="router.currentRoute.value.path === '/admin/business-hours'">
<div class="flex justify-between mb-5">
<div></div>
@@ -16,7 +14,6 @@
<template v-else>
<router-view/>
</template>
</div>
</template>
<script setup>

View File

@@ -1,105 +0,0 @@
<template>
<div class="w-8/12">
<div class="flex justify-between mb-5">
<div class="flex justify-end mb-4 w-full">
<Dialog v-model:open="dialogOpen">
<DialogTrigger as-child>
<Button class="ml-auto">New canned response</Button>
</DialogTrigger>
<DialogContent class="sm:max-w-[625px]">
<DialogHeader>
<DialogTitle>New canned response</DialogTitle>
<DialogDescription>Set title and content, click save when you're done. </DialogDescription>
</DialogHeader>
<CannedResponsesForm @submit="onSubmit">
<template #footer>
<DialogFooter class="mt-7">
<Button type="submit">Save Changes</Button>
</DialogFooter>
</template>
</CannedResponsesForm>
</DialogContent>
</Dialog>
</div>
</div>
<Spinner v-if="formLoading"></Spinner>
<div v-else>
<DataTable :columns="columns" :data="cannedResponses" />
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import DataTable from '@/components/admin/DataTable.vue'
import { columns } from './dataTableColumns.js'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/spinner'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger
} from '@/components/ui/dialog'
import CannedResponsesForm from './CannedResponsesForm.vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { formSchema } from './formSchema.js'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import api from '@/api'
const formLoading = ref(false)
const cannedResponses = ref([])
const emit = useEmitter()
const dialogOpen = ref(false)
const form = useForm({
validationSchema: toTypedSchema(formSchema)
})
onMounted(() => {
getCannedResponses()
emit.on(EMITTER_EVENTS.REFRESH_LIST, refreshList)
form.setValues({
title: "",
content: "",
})
})
onUnmounted(() => {
emit.off(EMITTER_EVENTS.REFRESH_LIST, refreshList)
})
const refreshList = (data) => {
if (data?.model === 'canned_responses') getCannedResponses()
}
const getCannedResponses = async () => {
try {
formLoading.value = true
const resp = await api.getCannedResponses()
cannedResponses.value = resp.data.data
} finally {
formLoading.value = false
}
}
const onSubmit = form.handleSubmit(async (values) => {
try {
formLoading.value = true
await api.createCannedResponse(values)
dialogOpen.value = false
getCannedResponses()
} catch (error) {
console.error('Failed to create canned response:', error)
} finally {
formLoading.value = false
}
})
</script>

View File

@@ -1,43 +0,0 @@
<template>
<form class="space-y-6">
<FormField v-slot="{ componentField }" name="title">
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input type="text" placeholder="" v-bind="componentField" />
</FormControl>
<FormDescription></FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField, handleInput }" name="content">
<FormItem>
<FormLabel>Content</FormLabel>
<FormControl>
<QuillEditor theme="snow" v-model:content="componentField.modelValue" contentType="html"
@update:content="handleInput"></QuillEditor>
</FormControl>
<FormDescription></FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Form submit button slot -->
<slot name="footer"></slot>
</form>
</template>
<script setup>
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
</script>

View File

@@ -1,101 +0,0 @@
<template>
<Dialog v-model:open="dialogOpen">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" class="w-8 h-8 p-0">
<span class="sr-only">Open menu</span>
<MoreHorizontal class="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DialogTrigger as-child>
<DropdownMenuItem> Edit </DropdownMenuItem>
</DialogTrigger>
<DropdownMenuItem @click="deleteCannedResponse"> Delete </DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DialogContent class="sm:max-w-[625px]">
<DialogHeader>
<DialogTitle>Edit canned response</DialogTitle>
<DialogDescription>Edit title and content, click save when you're done. </DialogDescription>
</DialogHeader>
<CannedResponsesForm @submit="onSubmit">
<template #footer>
<DialogFooter class="mt-7">
<Button type="submit">Save Changes</Button>
</DialogFooter>
</template>
</CannedResponsesForm>
</DialogContent>
</Dialog>
</template>
<script setup>
import { watch, ref } from 'vue'
import { MoreHorizontal } from 'lucide-vue-next'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import CannedResponsesForm from './CannedResponsesForm.vue'
import '@vueup/vue-quill/dist/vue-quill.snow.css';
import { formSchema } from './formSchema.js'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger
} from '@/components/ui/dialog'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import api from '@/api/index.js'
const dialogOpen = ref(false)
const emit = useEmitter()
const props = defineProps({
cannedResponse: {
type: Object,
required: true,
}
})
const form = useForm({
validationSchema: toTypedSchema(formSchema)
})
const onSubmit = form.handleSubmit(async (values) => {
await api.updateCannedResponse(props.cannedResponse.id, values)
dialogOpen.value = false
emitRefreshCannedResponseList()
})
const deleteCannedResponse = async () => {
await api.deleteCannedResponse(props.cannedResponse.id)
dialogOpen.value = false
emitRefreshCannedResponseList()
}
const emitRefreshCannedResponseList = () => {
emit.emit(EMITTER_EVENTS.REFRESH_LIST, {
model: 'canned_responses'
})
}
// Watch for changes in initialValues and update the form.
watch(
() => props.cannedResponse,
(newValues) => {
form.setValues(newValues)
},
{ immediate: true, deep: true }
)
</script>

View File

@@ -1,18 +0,0 @@
import * as z from 'zod'
export const formSchema = z.object({
title: z
.string({
required_error: 'Title is required.'
})
.min(1, {
message: 'Title must be at least 1 character.'
}),
content: z
.string({
required_error: 'Content is required.'
})
.min(1, {
message: 'Content must be atleast 3 characters.'
})
})

View File

@@ -0,0 +1,53 @@
<template>
<div class="mb-5">
<CustomBreadcrumb :links="breadcrumbLinks" />
</div>
<MacroForm
:submitForm="onSubmit"
:isLoading="formLoading"
/>
</template>
<script setup>
import { ref } from 'vue'
import MacroForm from '@/components/admin/conversation/macros/MacroForm.vue'
import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
import { handleHTTPError } from '@/utils/http'
import { useRouter } from 'vue-router'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import api from '@/api'
const router = useRouter()
const emit = useEmitter()
const formLoading = ref(false)
const breadcrumbLinks = [
{ path: '/admin/conversations/macros', label: 'Macros' },
{ path: '#', label: 'New macro' }
]
const onSubmit = (values) => {
createMacro(values)
}
const createMacro = async (values) => {
try {
formLoading.value = true
await api.createMacro(values)
emit.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Success',
variant: 'success',
description: 'Macro created successfully'
})
router.push('/admin/conversations/macros')
} catch (error) {
emit.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
formLoading.value = false
}
}
</script>

View File

@@ -0,0 +1,74 @@
<template>
<div class="mb-5">
<CustomBreadcrumb :links="breadcrumbLinks" />
</div>
<Spinner v-if="isLoading"></Spinner>
<MacroForm :initialValues="macro" :submitForm="submitForm" :isLoading="formLoading" v-else />
</template>
<script setup>
import { onMounted, ref } from 'vue'
import api from '@/api'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { handleHTTPError } from '@/utils/http'
import MacroForm from '@/components/admin/conversation/macros/MacroForm.vue'
import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
import { Spinner } from '@/components/ui/spinner'
const macro = ref({})
const isLoading = ref(false)
const formLoading = ref(false)
const emitter = useEmitter()
const breadcrumbLinks = [
{ path: '/admin/conversations/macros', label: 'Macros' },
{ path: '#', label: 'Edit macro' }
]
const submitForm = (values) => {
updateMacro(values)
}
const updateMacro = async (payload) => {
try {
formLoading.value = true
await api.updateMacro(macro.value.id, payload)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Saved',
description: 'Macro updated successfully'
})
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Could not update macro',
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
formLoading.value = false
}
}
onMounted(async () => {
try {
isLoading.value = true
const resp = await api.getMacro(props.id)
macro.value = resp.data.data
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
isLoading.value = false
}
})
const props = defineProps({
id: {
type: String,
required: true
}
})
</script>

View File

@@ -0,0 +1,195 @@
<template>
<Spinner v-if="formLoading"></Spinner>
<form @submit="onSubmit" class="space-y-6 w-full" :class="{ 'opacity-50': formLoading }">
<FormField v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input type="text" placeholder="Macro name" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="message_content">
<FormItem>
<FormLabel>Response to be sent when macro is used</FormLabel>
<FormControl>
<QuillEditor
v-model:content="componentField.modelValue"
placeholder="Add a response (optional)"
theme="snow"
contentType="html"
class="h-32 mb-12"
@update:content="(value) => componentField.onChange(value)"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="visibility">
<FormItem>
<FormLabel>Visibility</FormLabel>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Select visibility" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">All</SelectItem>
<SelectItem value="team">Team</SelectItem>
<SelectItem value="user">User</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-if="form.values.visibility === 'team'" v-slot="{ componentField }" name="team_id">
<FormItem>
<FormLabel>Team</FormLabel>
<FormControl>
<ComboBox v-bind="componentField" :items="tStore.options" placeholder="Select team">
<template #item="{ item }">
<div class="flex items-center gap-2 ml-2">
<span>{{ item.emoji }}</span>
<span>{{ item.label }}</span>
</div>
</template>
<template #selected="{ selected }">
<div class="flex items-center gap-2">
<span v-if="selected">
{{ selected.emoji }}
<span>{{ selected.label }}</span>
</span>
<span v-else>Select team</span>
</div>
</template>
</ComboBox>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-if="form.values.visibility === 'user'" v-slot="{ componentField }" name="user_id">
<FormItem>
<FormLabel>User</FormLabel>
<FormControl>
<ComboBox v-bind="componentField" :items="uStore.options" placeholder="Select user">
<template #item="{ item }">
<div class="flex items-center gap-2 ml-2">
<Avatar class="w-7 h-7">
<AvatarImage :src="item.avatar_url" :alt="item.label.slice(0, 2)" />
<AvatarFallback>{{ item.label.slice(0, 2).toUpperCase() }}</AvatarFallback>
</Avatar>
<span>{{ item.label }}</span>
</div>
</template>
<template #selected="{ selected }">
<div class="flex items-center gap-2">
<div v-if="selected" class="flex items-center gap-2">
<Avatar class="w-7 h-7">
<AvatarImage :src="selected.avatar_url" :alt="selected.label.slice(0, 2)" />
<AvatarFallback>{{ selected.label.slice(0, 2).toUpperCase() }}</AvatarFallback>
</Avatar>
<span>{{ selected.label }}</span>
</div>
<span v-else>Select user</span>
</div>
</template>
</ComboBox>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="actions">
<FormItem>
<FormLabel> Actions </FormLabel>
<FormControl>
<ActionBuilder v-bind="componentField" :config="actionConfig" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<Button type="submit" :isLoading="isLoading">{{ submitLabel }}</Button>
</form>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/spinner'
import { Input } from '@/components/ui/input'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import ActionBuilder from '@/components/common/ActionBuilder.vue'
import { useConversationFilters } from '@/composables/useConversationFilters'
import { useUsersStore } from '@/stores/users'
import { useTeamStore } from '@/stores/team'
import { formSchema } from './formSchema.js'
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
const { conversationActions } = useConversationFilters()
const formLoading = ref(false)
const uStore = useUsersStore()
const tStore = useTeamStore()
const props = defineProps({
initialValues: {
type: Object,
default: () => ({})
},
submitForm: {
type: Function,
required: true
},
submitLabel: {
type: String,
default: 'Submit'
},
isLoading: {
type: Boolean,
default: false
}
})
const form = useForm({
validationSchema: toTypedSchema(formSchema)
})
const actionConfig = ref({
actions: conversationActions,
typePlaceholder: 'Select action type',
valuePlaceholder: 'Select value',
addButtonText: 'Add new action'
})
const onSubmit = form.handleSubmit(async (values) => {
props.submitForm(values)
})
watch(
() => props.initialValues,
(newValues) => {
if (Object.keys(newValues).length === 0) return
form.setValues(newValues)
},
{ immediate: true }
)
</script>

View File

@@ -0,0 +1,65 @@
<template>
<div v-if="router.currentRoute.value.path === '/admin/conversations/macros'">
<div class="flex justify-end mb-5">
<Button @click="toggleForm"> New macro </Button>
</div>
<div>
<Spinner v-if="isLoading"></Spinner>
<DataTable v-else :columns="columns" :data="macros" />
</div>
</div>
<template v-else>
<router-view></router-view>
</template>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import DataTable from '@/components/admin/DataTable.vue'
import { columns } from './dataTableColumns.js'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/spinner'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { handleHTTPError } from '@/utils/http'
import { useRouter } from 'vue-router'
import api from '@/api'
const router = useRouter()
const formLoading = ref(false)
const macros = ref([])
const emit = useEmitter()
onMounted(() => {
getMacros()
emit.on(EMITTER_EVENTS.REFRESH_LIST, refreshList)
})
onUnmounted(() => {
emit.off(EMITTER_EVENTS.REFRESH_LIST, refreshList)
})
const toggleForm = () => {
router.push('/admin/conversations/macros/new')
}
const refreshList = (data) => {
if (data?.model === 'macros') getMacros()
}
const getMacros = async () => {
try {
formLoading.value = true
const resp = await api.getAllMacros()
macros.value = resp.data.data
} catch (error) {
emit.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
formLoading.value = false
}
}
</script>

View File

@@ -4,12 +4,30 @@ import { format } from 'date-fns'
export const columns = [
{
accessorKey: 'title',
accessorKey: 'name',
header: function () {
return h('div', { class: 'text-center' }, 'Title')
return h('div', { class: 'text-center' }, 'Name')
},
cell: function ({ row }) {
return h('div', { class: 'text-center font-medium' }, row.getValue('title'))
return h('div', { class: 'text-center font-medium' }, row.getValue('name'))
}
},
{
accessorKey: 'visibility',
header: function () {
return h('div', { class: 'text-center' }, 'Visibility')
},
cell: function ({ row }) {
return h('div', { class: 'text-center' }, row.getValue('visibility'))
}
},
{
accessorKey: 'usage_count',
header: function () {
return h('div', { class: 'text-center' }, 'Usage')
},
cell: function ({ row }) {
return h('div', { class: 'text-center' }, row.getValue('usage_count'))
}
},
{
@@ -34,12 +52,12 @@ export const columns = [
id: 'actions',
enableHiding: false,
cell: ({ row }) => {
const cannedResponse = row.original
const macro = row.original
return h(
'div',
{ class: 'relative' },
h(dropdown, {
cannedResponse
macro
})
)
}

View File

@@ -0,0 +1,50 @@
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" class="w-8 h-8 p-0">
<span class="sr-only">Open menu</span>
<MoreHorizontal class="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="editMacro">Edit</DropdownMenuItem>
<DropdownMenuItem @click="deleteMacro">Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
<script setup>
import { MoreHorizontal } from 'lucide-vue-next'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useRouter } from 'vue-router'
import api from '@/api/index.js'
const router = useRouter()
const emit = useEmitter()
const props = defineProps({
macro: {
type: Object,
required: true
}
})
const deleteMacro = async () => {
await api.deleteMacro(props.macro.id)
emit.emit(EMITTER_EVENTS.REFRESH_LIST, {
model: 'macros'
})
}
const editMacro = () => {
router.push({ path: `/admin/conversations/macros/${props.macro.id}/edit` })
}
</script>

View File

@@ -0,0 +1,17 @@
import * as z from 'zod'
const actionSchema = z.array(
z.object({
type: z.string().min(1, 'Action type required'),
value: z.array(z.string().min(1, 'Action value required')).min(1, 'Action value required'),
})
)
export const formSchema = z.object({
name: z.string().min(1, 'Macro name is required'),
message_content: z.string().optional(),
actions: actionSchema,
visibility: z.enum(['all', 'team', 'user']),
team_id: z.string().nullable().optional(),
user_id: z.string().nullable().optional(),
})

View File

@@ -1,33 +1,30 @@
<template>
<div class="w-8/12">
<div class="flex justify-between mb-5">
<div class="flex justify-end mb-4 w-full">
<div class="flex justify-between mb-5">
<div class="flex justify-end mb-4 w-full">
<Dialog v-model:open="dialogOpen">
<DialogTrigger as-child>
<Button class="ml-auto">New Status</Button>
<Button class="ml-auto">New Status</Button>
</DialogTrigger>
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>New status</DialogTitle>
<DialogDescription> Set status name. Click save when you're done. </DialogDescription>
</DialogHeader>
<StatusForm @submit.prevent="onSubmit">
<template #footer>
<DialogFooter class="mt-10">
<Button type="submit"> Save changes </Button>
</DialogFooter>
</template>
</StatusForm>
</DialogContent>
</Dialog>
</div>
</div>
<Spinner v-if="isLoading"></Spinner>
<div>
<DataTable :columns="columns" :data="statuses" />
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>New status</DialogTitle>
<DialogDescription> Set status name. Click save when you're done. </DialogDescription>
</DialogHeader>
<StatusForm @submit.prevent="onSubmit">
<template #footer>
<DialogFooter class="mt-10">
<Button type="submit"> Save changes </Button>
</DialogFooter>
</template>
</StatusForm>
</DialogContent>
</Dialog>
</div>
</div>
<Spinner v-if="isLoading"></Spinner>
<div>
<DataTable :columns="columns" :data="statuses" />
</div>
</template>
<script setup>

View File

@@ -1,6 +1,4 @@
<template>
<div class="w-8/12">
<div class="flex justify-between mb-5">
<div class="flex justify-end mb-4 w-full">
<Dialog v-model:open="dialogOpen">
@@ -27,7 +25,6 @@
<div v-else>
<DataTable :columns="columns" :data="tags" />
</div>
</div>
</template>
<script setup>

View File

@@ -1,8 +1,5 @@
<template>
<div>
</div>
<div class="flex justify-center items-center flex-col w-8/12">
<div class="flex justify-center items-center flex-col">
<GeneralSettingForm :submitForm="submitForm" :initial-values="initialValues" submitLabel="Save" />
</div>
</template>

View File

@@ -242,7 +242,7 @@ const onSubmit = form.handleSubmit(async (values) => {
})
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Could not update settings',
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})

View File

@@ -1,6 +1,4 @@
<template>
<div class="w-8/12">
<template v-if="router.currentRoute.value.path === '/admin/inboxes'">
<div class="flex justify-between mb-5">
<div class="flex justify-end w-full mb-4">
@@ -15,7 +13,6 @@
<template v-else>
<router-view/>
</template>
</div>
</template>
<script setup>
@@ -50,7 +47,7 @@ const getInboxes = async () => {
data.value = response.data.data
} catch (error) {
toast({
title: 'Could not fetch inboxes',
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})

View File

@@ -4,7 +4,7 @@ import { isGoDuration } from '@/utils/strings'
export const formSchema = z.object({
name: z.string().describe('Name').default(''),
from: z.string().describe('From address').default(''),
csat_enabled: z.boolean().describe('Enable CSAT'),
csat_enabled: z.boolean().describe('Enable CSAT').optional(),
imap: z
.object({
host: z.string().describe('Host').default('imap.gmail.com'),

View File

@@ -1,11 +1,11 @@
<template>
<div class="w-8/12">
<div>
<Spinner v-if="formLoading"></Spinner>
<NotificationsForm :initial-values="initialValues" :submit-form="submitForm" :isLoading="formLoading" />
</div>
</div>
<Spinner v-if="formLoading" class="mx-auto" />
<NotificationsForm
v-else
:initial-values="initialValues"
:submit-form="submitForm"
:isLoading="formLoading"
/>
</template>
<script setup>
@@ -23,51 +23,53 @@ const formLoading = ref(false)
const emitter = useEmitter()
onMounted(() => {
getNotificationSettings()
getNotificationSettings()
})
const getNotificationSettings = async () => {
try {
formLoading.value = true
const resp = await api.getEmailNotificationSettings()
initialValues.value = Object.fromEntries(
Object.entries(resp.data.data).map(([key, value]) => [key.replace('notification.email.', ''), value])
)
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Could not fetch',
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
formLoading.value = false
}
try {
formLoading.value = true
const resp = await api.getEmailNotificationSettings()
initialValues.value = Object.fromEntries(
Object.entries(resp.data.data).map(([key, value]) => [
key.replace('notification.email.', ''),
value
])
)
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Could not fetch',
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
formLoading.value = false
}
}
const submitForm = async (values) => {
try {
formLoading.value = true
const updatedValues = Object.fromEntries(
Object.entries(values).map(([key, value]) => {
if (key === 'password' && value.includes('•')) {
return [`notification.email.${key}`, '']
}
return [`notification.email.${key}`, value]
})
);
await api.updateEmailNotificationSettings(updatedValues)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: "Saved successfully"
})
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Could not save',
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
formLoading.value = false
}
try {
formLoading.value = true
const updatedValues = Object.fromEntries(
Object.entries(values).map(([key, value]) => {
if (key === 'password' && value.includes('•')) {
return [`notification.email.${key}`, '']
}
return [`notification.email.${key}`, value]
})
)
await api.updateEmailNotificationSettings(updatedValues)
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: 'Saved successfully'
})
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Could not save',
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
formLoading.value = false
}
}
</script>

View File

@@ -1,22 +1,19 @@
<template>
<div class="w-8/12">
<template v-if="router.currentRoute.value.path === '/admin/oidc'">
<div class="flex justify-between mb-5">
<div></div>
<div>
<Button @click="navigateToAddOIDC">New OIDC</Button>
</div>
</div>
<template v-if="router.currentRoute.value.path === '/admin/oidc'">
<div class="flex justify-between mb-5">
<div></div>
<div>
<Spinner v-if="isLoading"></Spinner>
<DataTable :columns="columns" :data="oidc" v-else />
<Button @click="navigateToAddOIDC">New OIDC</Button>
</div>
</template>
<template v-else>
<router-view/>
</template>
</div>
</div>
<div>
<Spinner v-if="isLoading"></Spinner>
<DataTable :columns="columns" :data="oidc" v-else />
</div>
</template>
<template v-else>
<router-view />
</template>
</template>
<script setup>

View File

@@ -1,22 +1,19 @@
<template>
<div class="w-8/12">
<template v-if="router.currentRoute.value.path === '/admin/sla'">
<div class="flex justify-between mb-5">
<div></div>
<div>
<Button @click="navigateToAddSLA">New SLA</Button>
</div>
</div>
<div>
<Spinner v-if="isLoading"></Spinner>
<DataTable :columns="columns" :data="slas" v-else />
</div>
</template>
<template v-else>
<router-view/>
</template>
<template v-if="router.currentRoute.value.path === '/admin/sla'">
<div class="flex justify-between mb-5">
<div></div>
<div>
<Button @click="navigateToAddSLA">New SLA</Button>
</div>
</div>
<div>
<Spinner v-if="isLoading"></Spinner>
<DataTable :columns="columns" :data="slas" v-else />
</div>
</template>
<template v-else>
<router-view />
</template>
</template>
<script setup>
@@ -37,29 +34,29 @@ const router = useRouter()
const emit = useEmitter()
onMounted(() => {
fetchAll()
emit.on(EMITTER_EVENTS.REFRESH_LIST, refreshList)
fetchAll()
emit.on(EMITTER_EVENTS.REFRESH_LIST, refreshList)
})
onUnmounted(() => {
emit.off(EMITTER_EVENTS.REFRESH_LIST, refreshList)
emit.off(EMITTER_EVENTS.REFRESH_LIST, refreshList)
})
const refreshList = (data) => {
if (data?.model === 'sla') fetchAll()
if (data?.model === 'sla') fetchAll()
}
const fetchAll = async () => {
try {
isLoading.value = true
const resp = await api.getAllSLAs()
slas.value = resp.data.data
} finally {
isLoading.value = false
}
try {
isLoading.value = true
const resp = await api.getAllSLAs()
slas.value = resp.data.data
} finally {
isLoading.value = false
}
}
const navigateToAddSLA = () => {
router.push('/admin/sla/new')
router.push('/admin/sla/new')
}
</script>

View File

@@ -100,7 +100,7 @@ const permissions = ref([
{ name: 'status:manage', label: 'Manage Conversation Statuses' },
{ name: 'oidc:manage', label: 'Manage SSO Configuration' },
{ name: 'tags:manage', label: 'Manage Tags' },
{ name: 'canned_responses:manage', label: 'Manage Canned Responses' },
{ name: 'macros:manage', label: 'Manage Macros' },
{ name: 'users:manage', label: 'Manage Users' },
{ name: 'teams:manage', label: 'Manage Teams' },
{ name: 'automations:manage', label: 'Manage Automations' },

View File

@@ -1,6 +1,4 @@
<template>
<div class="w-8/12">
<div v-if="router.currentRoute.value.path === '/admin/teams/roles'">
<div class="flex justify-end mb-5">
<Button @click="navigateToAddRole"> New role </Button>
@@ -11,7 +9,6 @@
</div>
</div>
<router-view></router-view>
</div>
</template>
<script setup>
@@ -23,21 +20,15 @@ import { handleHTTPError } from '@/utils/http'
import { useToast } from '@/components/ui/toast/use-toast'
import api from '@/api'
import { useRouter } from 'vue-router'
import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
import { Spinner } from '@/components/ui/spinner'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
const { toast } = useToast()
const emit = useEmitter()
const router = useRouter()
const roles = ref([])
const isLoading = ref(false)
const breadcrumbLinks = [
{ path: '#', label: 'Roles' }
]
const getRoles = async () => {
try {

View File

@@ -1,21 +1,18 @@
<template>
<div class="w-8/12">
<div v-if="router.currentRoute.value.path === '/admin/teams/teams'">
<div class="flex justify-end mb-5">
<Button @click="navigateToAddTeam"> New team </Button>
</div>
<div v-if="router.currentRoute.value.path === '/admin/teams/teams'">
<div class="flex justify-end mb-5">
<Button @click="navigateToAddTeam"> New team </Button>
</div>
<div>
<div>
<div>
<Spinner v-if="isLoading"></Spinner>
<DataTable :columns="columns" :data="data" v-else />
</div>
<Spinner v-if="isLoading"></Spinner>
<DataTable :columns="columns" :data="data" v-else />
</div>
</div>
<template v-else>
<router-view></router-view>
</template>
</div>
<template v-else>
<router-view></router-view>
</template>
</template>
<script setup>
@@ -24,7 +21,6 @@ import { handleHTTPError } from '@/utils/http'
import { columns } from '@/components/admin/team/teams/TeamsDataTableColumns.js'
import { useToast } from '@/components/ui/toast/use-toast'
import { Button } from '@/components/ui/button'
import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
import DataTable from '@/components/admin/DataTable.vue'
import api from '@/api'
@@ -33,11 +29,6 @@ import { Spinner } from '@/components/ui/spinner'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
const breadcrumbLinks = [
{ path: '/admin/teams/', label: 'Teams' }
]
const emit = useEmitter()
const router = useRouter()
const data = ref([])

View File

@@ -121,9 +121,9 @@ const roles = ref([])
onMounted(async () => {
try {
const [teamsResp, rolesResp] = await Promise.all([api.getTeams(), api.getRoles()])
teams.value = teamsResp.data.data
roles.value = rolesResp.data.data
const [teamsResp, rolesResp] = await Promise.allSettled([api.getTeams(), api.getRoles()])
teams.value = teamsResp.value.data.data
roles.value = rolesResp.value.data.data
} catch (err) {
console.log(err)
}

View File

@@ -1,6 +1,4 @@
<template>
<div class="w-8/12">
<div v-if="router.currentRoute.value.path === '/admin/teams/users'">
<div class="flex justify-end mb-5">
<Button @click="navigateToAddUser"> New user </Button>
@@ -13,7 +11,6 @@
<template v-else>
<router-view></router-view>
</template>
</div>
</template>
<script setup>
@@ -25,8 +22,6 @@ import { handleHTTPError } from '@/utils/http'
import { useToast } from '@/components/ui/toast/use-toast'
import { useRouter } from 'vue-router'
import { useEmitter } from '@/composables/useEmitter'
import { CustomBreadcrumb } from '@/components/ui/breadcrumb'
import { Spinner } from '@/components/ui/spinner'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import api from '@/api'
@@ -36,10 +31,6 @@ const router = useRouter()
const isLoading = ref(false)
const data = ref([])
const emit = useEmitter()
const breadcrumbLinks = [
{ path: '#', label: 'Users' }
]
onMounted(async () => {
getData()
@@ -55,7 +46,7 @@ const getData = async () => {
data.value = response.data.data
} catch (error) {
toast({
title: 'Uh oh! Could not fetch users.',
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})

View File

@@ -1,6 +1,4 @@
<template>
<div class="w-8/12">
<template v-if="router.currentRoute.value.path === '/admin/templates'">
<div class="flex justify-between mb-5">
<div></div>
@@ -27,7 +25,6 @@
<template v-else>
<router-view/>
</template>
</div>
</template>
<script setup>

View File

@@ -1,31 +1,44 @@
<template>
<div class="flex m-2 items-end text-sm overflow-hidden text-ellipsis whitespace-nowrap cursor-pointer">
<div v-for="attachment in attachments" :key="attachment.uuid"
class="flex items-center p-1 bg-[#F5F5F4] gap-1 rounded-md max-w-[15rem]">
<!-- Filename tooltip -->
<Tooltip>
<TooltipTrigger as-child>
<div class="overflow-hidden text-ellipsis whitespace-nowrap">
{{ getAttachmentName(attachment.filename) }}
</div>
</TooltipTrigger>
<TooltipContent>
{{ attachment.filename }}
</TooltipContent>
</Tooltip>
<div>
{{ formatBytes(attachment.size) }}
<div class="flex flex-wrap gap-2 px-2 py-1">
<TransitionGroup name="attachment-list" tag="div" class="flex flex-wrap gap-2">
<div
v-for="attachment in attachments"
:key="attachment.uuid"
class="flex items-center bg-white border border-gray-200 rounded shadow-sm transition-all duration-300 ease-in-out hover:shadow-md group"
>
<div class="flex items-center space-x-2 px-3 py-2">
<PaperclipIcon size="16" class="text-gray-500 group-hover:text-primary" />
<Tooltip>
<TooltipTrigger as-child>
<div
class="max-w-[12rem] overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium text-primary group-hover:text-gray-900"
>
{{ getAttachmentName(attachment.filename) }}
</div>
</TooltipTrigger>
<TooltipContent>
<p class="text-sm">{{ attachment.filename }}</p>
</TooltipContent>
</Tooltip>
<span class="text-xs text-gray-500">
{{ formatBytes(attachment.size) }}
</span>
</div>
<button
@click.stop="onDelete(attachment.uuid)"
class="p-2 text-gray-400 hover:text-red-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 rounded transition-colors duration-300 ease-in-out"
title="Remove attachment"
>
<X size="14" />
</button>
</div>
<div @click="onDelete(attachment.uuid)">
<X size="13" />
</div>
</div>
</TransitionGroup>
</div>
</template>
<script setup>
import { formatBytes } from '@/utils/file.js'
import { X } from 'lucide-vue-next'
import { X, Paperclip as PaperclipIcon } from 'lucide-vue-next'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
defineProps({
@@ -40,6 +53,24 @@ defineProps({
})
const getAttachmentName = (name) => {
return name.substring(0, 20)
return name.length > 20 ? name.substring(0, 17) + '...' : name
}
</script>
<style scoped>
.attachment-list-move,
.attachment-list-enter-active,
.attachment-list-leave-active {
transition: all 0.5s ease;
}
.attachment-list-enter-from,
.attachment-list-leave-to {
opacity: 0;
transform: translateX(30px);
}
.attachment-list-leave-active {
position: absolute;
}
</style>

View File

@@ -0,0 +1,331 @@
<template>
<CommandDialog :open="open" @update:open="handleOpenChange">
<CommandInput placeholder="Type a command or search..." @keydown="onInputKeydown" />
<CommandList class="!min-h-[400px]">
<CommandEmpty>
<p class="text-muted-foreground">No command available</p>
</CommandEmpty>
<!-- Commands requiring a conversation to be open -->
<CommandGroup
heading="Conversations"
value="conversations"
v-if="nestedCommand === null && conversationStore.current"
>
<CommandItem value="conv-snooze" @select="setNestedCommand('snooze')"> Snooze </CommandItem>
<CommandItem value="conv-resolve" @select="resolveConversation"> Resolve </CommandItem>
<CommandItem value="apply-macro" @select="setNestedCommand('apply-macro')">
Apply macro
</CommandItem>
</CommandGroup>
<CommandGroup v-if="nestedCommand === 'snooze'" heading="Snooze for">
<CommandItem value="1 hour" @select="handleSnooze(60)">1 hour</CommandItem>
<CommandItem value="3 hours" @select="handleSnooze(180)">3 hours</CommandItem>
<CommandItem value="6 hours" @select="handleSnooze(360)">6 hours</CommandItem>
<CommandItem value="12 hours" @select="handleSnooze(720)">12 hours</CommandItem>
<CommandItem value="1 day" @select="handleSnooze(1440)">1 day</CommandItem>
<CommandItem value="2 days" @select="handleSnooze(2880)">2 days</CommandItem>
<CommandItem value="pick date & time" @select="showCustomDialog">
Pick date & time
</CommandItem>
</CommandGroup>
<!-- Macros -->
<!-- TODO move to a separate component -->
<div v-if="nestedCommand === 'apply-macro'" class="bg-background">
<CommandGroup heading="Apply macro" class="pb-2">
<div class="min-h-[400px] overflow-auto">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-4 border-r border-border/30 pr-2">
<CommandItem
v-for="(macro, index) in macroStore.macroOptions"
:key="macro.value"
:value="macro.label"
:data-index="index"
@select="handleApplyMacro(macro)"
class="px-3 py-2 rounded-md cursor-pointer transition-all duration-200 hover:bg-primary/10 hover:text-primary"
:class="{ 'bg-primary/5 text-primary': selectedMacroIndex === index }"
>
<div class="flex items-center space-x-2 justify-start">
<Zap :size="18" class="text-primary" />
<span class="text-sm overflow">{{ macro.label }}</span>
</div>
</CommandItem>
</div>
<div class="col-span-8 pl-2">
<div class="space-y-3 text-xs">
<div v-if="replyContent" class="space-y-1">
<p class="text-xs font-semibold text-primary">Reply Preview</p>
<div
class="w-full min-h-200 p-2 bg-muted/50 rounded-md overflow-auto shadow-sm"
v-html="replyContent"
/>
</div>
<div v-if="otherActions.length > 0" class="space-y-1">
<p class="text-xs font-semibold text-primary">Actions</p>
<div class="space-y-1.5 max-w-sm">
<div
v-for="action in otherActions"
:key="action.type"
class="flex items-center gap-2 px-2 py-1.5 bg-muted/30 hover:bg-accent hover:text-accent-foreground rounded-md text-xs transition-all duration-200 group"
>
<div
class="p-1 bg-primary/10 rounded-full group-hover:bg-primary/20 transition-colors duration-200"
>
<User
v-if="action.type === 'assign_user'"
:size="10"
class="shrink-0 text-primary"
/>
<Users
v-else-if="action.type === 'assign_team'"
:size="10"
class="shrink-0 text-primary"
/>
<Pin
v-else-if="action.type === 'set_status'"
:size="10"
class="shrink-0 text-primary"
/>
<Rocket
v-else-if="action.type === 'set_priority'"
:size="10"
class="shrink-0 text-primary"
/>
<Tags
v-else-if="action.type === 'set_tags'"
:size="10"
class="shrink-0 text-primary"
/>
</div>
<span class="truncate">{{ getActionLabel(action) }}</span>
</div>
</div>
</div>
<div
v-if="!replyContent && otherActions.length === 0"
class="flex items-center justify-center h-20"
>
<p class="text-xs text-muted-foreground italic">
Select a macro to view details
</p>
</div>
</div>
</div>
</div>
</div>
</CommandGroup>
</div>
</CommandList>
<!-- Navigation -->
<!-- TODO: Move to a separate component -->
<div class="mt-2 px-4 py-2 text-xs text-gray-500 flex space-x-4">
<span><kbd>Enter</kbd> select</span>
<span><kbd></kbd>/<kbd></kbd> navigate</span>
<span><kbd>Esc</kbd> close</span>
<span><kbd>Backspace</kbd> parent</span>
</div>
</CommandDialog>
<!-- Date Picker for Custom Snooze -->
<!-- TODO: Move to a separate component -->
<Dialog :open="showDatePicker" @update:open="closeDatePicker">
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Pick Snooze Time</DialogTitle>
</DialogHeader>
<div class="grid gap-4 py-4">
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" class="w-full justify-start text-left font-normal">
<CalendarIcon class="mr-2 h-4 w-4" />
{{ selectedDate ? selectedDate : 'Pick a date' }}
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0">
<Calendar mode="single" v-model="selectedDate" />
</PopoverContent>
</Popover>
<div class="grid gap-2">
<Label>Time</Label>
<Input type="time" v-model="selectedTime" />
</div>
</div>
<DialogFooter>
<Button @click="handleCustomSnooze">Snooze</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
<script setup>
import { ref, watch, onMounted, onUnmounted, computed } from 'vue'
import { useMagicKeys } from '@vueuse/core'
import { CalendarIcon } from 'lucide-vue-next'
import { useConversationStore } from '@/stores/conversation'
import { useMacroStore } from '@/stores/macro'
import { CONVERSATION_DEFAULT_STATUSES } from '@/constants/conversation'
import { Users, User, Pin, Rocket, Tags, Zap } from 'lucide-vue-next'
import {
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem
} from '@/components/ui/command'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
const conversationStore = useConversationStore()
const macroStore = useMacroStore()
const open = ref(false)
const emitter = useEmitter()
const nestedCommand = ref(null)
const showDatePicker = ref(false)
const selectedDate = ref(null)
const selectedTime = ref('12:00')
const keys = useMagicKeys()
const cmdK = keys['meta+k']
const ctrlK = keys['ctrl+k']
const highlightedMacro = ref(null)
function handleApplyMacro(macro) {
conversationStore.setMacro(macro)
handleOpenChange()
}
const getActionLabel = computed(() => (action) => {
const prefixes = {
assign_user: 'Assign to user',
assign_team: 'Assign to team',
set_status: 'Set status',
set_priority: 'Set priority',
set_tags: 'Set tags'
}
return `${prefixes[action.type]}: ${action.display_value.length > 0 ? action.display_value.join(', ') : action.value.join(', ')}`
})
const replyContent = computed(() => highlightedMacro.value?.message_content || '')
const otherActions = computed(
() =>
highlightedMacro.value?.actions?.filter(
(a) => a.type !== 'send_private_note' && a.type !== 'send_reply'
) || []
)
function handleOpenChange() {
if (!open.value) nestedCommand.value = null
open.value = !open.value
}
function setNestedCommand(command) {
nestedCommand.value = command
}
function formatDuration(minutes) {
return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h`
}
async function handleSnooze(minutes) {
await conversationStore.snoozeConversation(formatDuration(minutes))
handleOpenChange()
}
async function resolveConversation() {
await conversationStore.updateStatus(CONVERSATION_DEFAULT_STATUSES.RESOLVED)
handleOpenChange()
}
function showCustomDialog() {
handleOpenChange()
showDatePicker.value = true
}
function closeDatePicker() {
showDatePicker.value = false
}
function handleCustomSnooze() {
const [hours, minutes] = selectedTime.value.split(':')
const snoozeDate = new Date(selectedDate.value)
snoozeDate.setHours(parseInt(hours), parseInt(minutes))
const diffMinutes = Math.floor((snoozeDate - new Date()) / (1000 * 60))
if (diffMinutes <= 0) {
alert('Select a future time')
return
}
handleSnooze(diffMinutes)
closeDatePicker()
handleOpenChange()
}
function onInputKeydown(e) {
if (e.key === 'Backspace') {
const inputVal = e.target.value || ''
if (!inputVal && nestedCommand.value !== null) {
e.preventDefault()
nestedCommand.value = null
}
}
}
function preventDefaultKey(event) {
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
event.preventDefault()
event.stopPropagation()
return false
}
}
onMounted(() => {
emitter.on(EMITTER_EVENTS.SET_NESTED_COMMAND, (command) => {
setNestedCommand(command)
open.value = true
})
window.addEventListener('keydown', preventDefaultKey)
watchHighlightedMacro()
})
onUnmounted(() => {
emitter.off(EMITTER_EVENTS.SET_NESTED_COMMAND)
window.removeEventListener('keydown', preventDefaultKey)
})
watch([cmdK, ctrlK], ([mac, win]) => {
if (mac || win) handleOpenChange()
})
const watchHighlightedMacro = () => {
const observer = new MutationObserver(() => {
const highlightedEl = document.querySelector('[data-highlighted]')?.getAttribute('data-index')
highlightedMacro.value = highlightedEl ? macroStore.macroOptions[highlightedEl] : null
})
observer.observe(document.body, {
attributes: true,
subtree: true
})
}
</script>

View File

@@ -1,194 +0,0 @@
<template>
<CommandDialog :open="open" @update:open="handleOpenChange">
<CommandInput placeholder="Type a command or search..." @keydown="onInputKeydown" />
<CommandList>
<CommandEmpty>
<p class="text-muted-foreground">No command available</p>
</CommandEmpty>
<!-- Commands requiring a conversation to be open -->
<CommandGroup heading="Conversations" value="conversations"
v-if="nestedCommand === null && conversationStore.current">
<CommandItem value="conv-snooze" @select="setNestedCommand('snooze')">
Snooze
</CommandItem>
<CommandItem value="conv-resolve" @select="resolveConversation">
Resolve
</CommandItem>
</CommandGroup>
<CommandGroup v-if="nestedCommand === 'snooze'" heading="Snooze for">
<CommandItem value="snooze-1h" @select="handleSnooze(60)">1 hour</CommandItem>
<CommandItem value="snooze-3h" @select="handleSnooze(180)">3 hours</CommandItem>
<CommandItem value="snooze-6h" @select="handleSnooze(360)">6 hours</CommandItem>
<CommandItem value="snooze-12h" @select="handleSnooze(720)">12 hours</CommandItem>
<CommandItem value="snooze-1d" @select="handleSnooze(1440)">1 day</CommandItem>
<CommandItem value="snooze-2d" @select="handleSnooze(2880)">2 days</CommandItem>
<CommandItem value="snooze-custom" @select="showCustomDialog">Pick date & time</CommandItem>
</CommandGroup>
</CommandList>
<!-- Navigation -->
<div class="mt-2 px-4 py-2 text-xs text-gray-500 flex space-x-4">
<span><kbd>Enter</kbd> select</span>
<span><kbd></kbd>/<kbd></kbd> navigate</span>
<span><kbd>Esc</kbd> close</span>
<span><kbd>Backspace</kbd> parent</span>
</div>
</CommandDialog>
<Dialog :open="showDatePicker" @update:open="closeDatePicker">
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Pick Snooze Time</DialogTitle>
</DialogHeader>
<div class="grid gap-4 py-4">
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" class="w-full justify-start text-left font-normal">
<CalendarIcon class="mr-2 h-4 w-4" />
{{ selectedDate ? selectedDate : "Pick a date" }}
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0">
<Calendar mode="single" v-model="selectedDate" />
</PopoverContent>
</Popover>
<div class="grid gap-2">
<Label>Time</Label>
<Input type="time" v-model="selectedTime" />
</div>
</div>
<DialogFooter>
<Button @click="handleCustomSnooze">Snooze</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
<script setup>
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { useMagicKeys } from '@vueuse/core'
import { CalendarIcon } from 'lucide-vue-next'
import { useConversationStore } from '@/stores/conversation'
import { CONVERSATION_DEFAULT_STATUSES } from '@/constants/conversation'
import {
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem
} from '@/components/ui/command'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
const conversationStore = useConversationStore()
const open = ref(false)
const emitter = useEmitter()
const nestedCommand = ref(null)
const showDatePicker = ref(false)
const selectedDate = ref(null)
const selectedTime = ref('12:00')
const keys = useMagicKeys()
const cmdK = keys['meta+k']
const ctrlK = keys['ctrl+k']
function handleOpenChange () {
if (!open.value) nestedCommand.value = null
open.value = !open.value
}
function setNestedCommand (command) {
nestedCommand.value = command
}
function formatDuration (minutes) {
return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h`
}
async function handleSnooze (minutes) {
await conversationStore.snoozeConversation(formatDuration(minutes))
handleOpenChange()
}
async function resolveConversation () {
await conversationStore.updateStatus(CONVERSATION_DEFAULT_STATUSES.RESOLVED)
handleOpenChange()
}
function showCustomDialog () {
handleOpenChange()
showDatePicker.value = true
}
function closeDatePicker () {
showDatePicker.value = false
}
function handleCustomSnooze () {
const [hours, minutes] = selectedTime.value.split(':')
const snoozeDate = new Date(selectedDate.value)
snoozeDate.setHours(parseInt(hours), parseInt(minutes))
const diffMinutes = Math.floor((snoozeDate - new Date()) / (1000 * 60))
if (diffMinutes <= 0) {
alert('Select a future time')
return
}
handleSnooze(diffMinutes)
closeDatePicker()
handleOpenChange()
}
function onInputKeydown (e) {
if (e.key === 'Backspace') {
const inputVal = e.target.value || ''
if (!inputVal && nestedCommand.value !== null) {
e.preventDefault()
nestedCommand.value = null
}
}
}
function preventDefaultKey (event) {
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
event.preventDefault()
event.stopPropagation()
return false
}
}
onMounted(() => {
emitter.on(EMITTER_EVENTS.SET_NESTED_COMMAND, (command) => {
setNestedCommand(command)
open.value = true
})
window.addEventListener('keydown', preventDefaultKey)
})
onUnmounted(() => {
emitter.off(EMITTER_EVENTS.SET_NESTED_COMMAND)
window.removeEventListener('keydown', preventDefaultKey)
})
watch([cmdK, ctrlK], ([mac, win]) => {
if (mac || win) handleOpenChange()
})
</script>

View File

@@ -0,0 +1,186 @@
<template>
<div class="space-y-5 rounded-lg">
<div class="space-y-5">
<div v-for="(action, index) in model" :key="index" class="space-y-5">
<hr v-if="index" class="border-t-2 border-dotted border-gray-300" />
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex gap-5">
<div class="w-48">
<Select
v-model="action.type"
@update:modelValue="(value) => updateField(value, index)"
>
<SelectTrigger>
<SelectValue :placeholder="config.typePlaceholder" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem
v-for="(actionConfig, key) in config.actions"
:key="key"
:value="key"
>
{{ actionConfig.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div
v-if="action.type && config.actions[action.type]?.type === 'select'"
class="w-48"
>
<ComboBox
v-model="action.value[0]"
:items="config.actions[action.type].options"
:placeholder="config.valuePlaceholder"
@update:modelValue="(value) => updateValue(value, index)"
>
<template #item="{ item }">
<div v-if="action.type === 'assign_user'">
<div class="flex items-center flex-1 gap-2 ml-2">
<Avatar class="w-7 h-7">
<AvatarImage :src="item.avatar_url" :alt="item.label.slice(0, 2)" />
<AvatarFallback
>{{ item.label.slice(0, 2).toUpperCase() }}
</AvatarFallback>
</Avatar>
<span>{{ item.label }}</span>
</div>
</div>
<div v-else-if="action.type === 'assign_team'">
<div class="flex items-center gap-2 ml-2">
<span>{{ item.emoji }}</span>
<span>{{ item.label }}</span>
</div>
</div>
<div v-else>
{{ item.label }}
</div>
</template>
<template #selected="{ selected }">
<div v-if="action.type === 'assign_user'">
<div class="flex items-center gap-2">
<div v-if="selected" class="flex items-center gap-2">
<Avatar class="w-7 h-7">
<AvatarImage
:src="selected.avatar_url"
:alt="selected.label.slice(0, 2)"
/>
<AvatarFallback>{{
selected.label.slice(0, 2).toUpperCase()
}}</AvatarFallback>
</Avatar>
<span>{{ selected.label }}</span>
</div>
<span v-else>Select user</span>
</div>
</div>
<div v-else-if="action.type === 'assign_team'">
<div class="flex items-center gap-2">
<span v-if="selected">
{{ selected.emoji }}
<span>{{ selected.label }}</span>
</span>
<span v-else>Select team</span>
</div>
</div>
<div v-else-if="selected">
{{ selected.label }}
</div>
<div v-else>Select</div>
</template>
</ComboBox>
</div>
</div>
<X class="cursor-pointer w-4" @click="remove(index)" />
</div>
<div v-if="action.type && config.actions[action.type]?.type === 'tag'">
<SelectTag
v-model="action.value"
:items="tagsStore.tagNames"
placeholder="Select tag"
/>
</div>
<div
v-if="action.type && config.actions[action.type]?.type === 'richtext'"
class="pl-0 shadow"
>
<QuillEditor
v-model:content="action.value[0]"
theme="snow"
contentType="html"
@update:content="(value) => updateValue(value, index)"
class="h-32 mb-12"
/>
</div>
</div>
</div>
</div>
<Button type="button" variant="outline" @click.prevent="add">{{ config.addButtonText }}</Button>
</div>
</template>
<script setup>
import { Button } from '@/components/ui/button'
import { X } from 'lucide-vue-next'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { QuillEditor } from '@vueup/vue-quill'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { SelectTag } from '@/components/ui/select'
import { useTagStore } from '@/stores/tag'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
const model = defineModel({
type: Array,
required: true,
default: () => []
})
defineProps({
config: {
type: Object,
required: true
}
})
const tagsStore = useTagStore()
const updateField = (value, index) => {
const newModel = [...model.value]
newModel[index] = { type: value, value: [] }
model.value = newModel
}
const updateValue = (value, index) => {
const newModel = [...model.value]
newModel[index] = {
...newModel[index],
value: [value?.value ?? value]
}
model.value = newModel
}
const remove = (index) => {
model.value = model.value.filter((_, i) => i !== index)
}
const add = () => {
model.value = [...model.value, { type: '', value: [] }]
}
</script>

View File

@@ -1,145 +0,0 @@
<template>
<div class="space-y-4">
<div v-for="(modelFilter, index) in modelValue" :key="index" class="group flex items-center gap-3">
<div class="grid grid-cols-3 gap-2 w-full">
<!-- Field -->
<Select v-model="modelFilter.field">
<SelectTrigger class="bg-transparent hover:bg-slate-100 w-full">
<SelectValue placeholder="Field" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="field in fields" :key="field.field" :value="field.field">
{{ field.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<!-- Operator -->
<Select v-model="modelFilter.operator" v-if="modelFilter.field">
<SelectTrigger class="bg-transparent hover:bg-slate-100 w-full">
<SelectValue placeholder="Operator" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="op in getFieldOperators(modelFilter)" :key="op" :value="op">
{{ op }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<!-- Value -->
<div class="w-full" v-if="modelFilter.field && modelFilter.operator">
<template v-if="modelFilter.operator !== 'set' && modelFilter.operator !== 'not set'">
<Select v-if="getFieldOptions(modelFilter).length > 0" v-model="modelFilter.value">
<SelectTrigger class="bg-transparent hover:bg-slate-100">
<SelectValue placeholder="Select value" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="opt in getFieldOptions(modelFilter)" :key="opt.value" :value="opt.value">
{{ opt.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Input v-else v-model="modelFilter.value" class="bg-transparent hover:bg-slate-100" placeholder="Value"
type="text" />
</template>
</div>
</div>
<button v-show="modelValue.length > 1" @click="removeFilter(index)" class="p-1 hover:bg-slate-100 rounded">
<X class="w-4 h-4 text-slate-500" />
</button>
</div>
<div class="flex items-center justify-between pt-3">
<Button variant="ghost" size="sm" @click.prevent="addFilter" class="text-slate-600">
<Plus class="w-3 h-3 mr-1" /> Add filter
</Button>
<div class="flex gap-2" v-if="showButtons">
<Button variant="ghost" @click="clearFilters">Reset</Button>
<Button @click="applyFilters">Apply</Button>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, watch, onUnmounted } from 'vue'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Plus, X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
const props = defineProps({
fields: {
type: Array,
required: true,
},
showButtons: {
type: Boolean,
default: true,
},
})
const emit = defineEmits(['apply', 'clear'])
const modelValue = defineModel('modelValue', { required: false, default: () => [] })
const createFilter = () => ({ field: '', operator: '', value: '' })
onMounted(() => {
if (modelValue.value.length === 0) {
modelValue.value.push(createFilter())
}
})
onUnmounted(() => {
modelValue.value = []
})
const getModel = (field) => {
const fieldConfig = props.fields.find(f => f.field === field)
return fieldConfig?.model || ''
}
watch(() => modelValue.value, (filters) => {
filters.forEach(filter => {
if (filter.field && !filter.model) {
filter.model = getModel(filter.field)
}
})
}, { deep: true })
const addFilter = () => modelValue.value.push(createFilter())
const removeFilter = (index) => modelValue.value.splice(index, 1)
const applyFilters = () => emit('apply', validFilters.value)
const clearFilters = () => {
modelValue.value = []
emit('clear')
}
const validFilters = computed(() => {
return modelValue.value.filter(filter => filter.field && filter.operator && filter.value)
})
const getFieldOptions = (fieldValue) => {
const field = props.fields.find(f => f.field === fieldValue.field)
return field?.options || []
}
const getFieldOperators = (modelFilter) => {
const field = props.fields.find(f => f.field === modelFilter.field)
return field?.operators || []
}
</script>

View File

@@ -0,0 +1,207 @@
<template>
<div class="space-y-4">
<div
v-for="(modelFilter, index) in modelValue"
:key="index"
class="group flex items-center gap-3"
>
<div class="grid grid-cols-3 gap-2 w-full">
<!-- Field -->
<Select v-model="modelFilter.field">
<SelectTrigger class="bg-transparent hover:bg-slate-100 w-full">
<SelectValue placeholder="Field" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="field in fields" :key="field.field" :value="field.field">
{{ field.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<!-- Operator -->
<Select v-model="modelFilter.operator" v-if="modelFilter.field">
<SelectTrigger class="bg-transparent hover:bg-slate-100 w-full">
<SelectValue placeholder="Operator" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="op in getFieldOperators(modelFilter)" :key="op" :value="op">
{{ op }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<!-- Value -->
<div class="w-full" v-if="modelFilter.field && modelFilter.operator">
<template v-if="modelFilter.operator !== 'set' && modelFilter.operator !== 'not set'">
<ComboBox
v-if="getFieldOptions(modelFilter).length > 0"
v-model="modelFilter.value"
:items="getFieldOptions(modelFilter)"
placeholder="Select"
>
<template #item="{ item }">
<div v-if="modelFilter.field === 'assigned_user_id'">
<div class="flex items-center gap-1">
<Avatar class="w-6 h-6">
<AvatarImage :src="item.avatar_url" :alt="item.label.slice(0, 2)" />
<AvatarFallback>{{ item.label.slice(0, 2).toUpperCase() }} </AvatarFallback>
</Avatar>
<span>{{ item.label }}</span>
</div>
</div>
<div v-else-if="modelFilter.field === 'assigned_team_id'">
<div class="flex items-center gap-2 ml-2">
<span>{{ item.emoji }}</span>
<span>{{ item.label }}</span>
</div>
</div>
<div v-else>
{{ item.label }}
</div>
</template>
<template #selected="{ selected }">
<div v-if="modelFilter.field === 'assigned_user_id'">
<div class="flex items-center gap-2">
<div v-if="selected" class="flex items-center gap-1">
<Avatar class="w-6 h-6">
<AvatarImage :src="selected.avatar_url" :alt="selected.label.slice(0, 2)" />
<AvatarFallback>{{
selected.label.slice(0, 2).toUpperCase()
}}</AvatarFallback>
</Avatar>
<span>{{ selected.label }}</span>
</div>
<span v-else>Select user</span>
</div>
</div>
<div v-else-if="modelFilter.field === 'assigned_team_id'">
<div class="flex items-center gap-2">
<span v-if="selected">
{{ selected.emoji }}
<span>{{ selected.label }}</span>
</span>
<span v-else>Select team</span>
</div>
</div>
<div v-else-if="selected">
{{ selected.label }}
</div>
</template>
</ComboBox>
<Input
v-else
v-model="modelFilter.value"
class="bg-transparent hover:bg-slate-100"
placeholder="Value"
type="text"
/>
</template>
</div>
</div>
<button
v-show="modelValue.length > 1"
@click="removeFilter(index)"
class="p-1 hover:bg-slate-100 rounded"
>
<X class="w-4 h-4 text-slate-500" />
</button>
</div>
<div class="flex items-center justify-between pt-3">
<Button variant="ghost" size="sm" @click.prevent="addFilter" class="text-slate-600">
<Plus class="w-3 h-3 mr-1" /> Add filter
</Button>
<div class="flex gap-2" v-if="showButtons">
<Button variant="ghost" @click="clearFilters">Reset</Button>
<Button @click="applyFilters">Apply</Button>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, watch, onUnmounted } from 'vue'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Plus, X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
const props = defineProps({
fields: {
type: Array,
required: true
},
showButtons: {
type: Boolean,
default: true
}
})
const emit = defineEmits(['apply', 'clear'])
const modelValue = defineModel('modelValue', { required: false, default: () => [] })
const createFilter = () => ({ field: '', operator: '', value: '' })
onMounted(() => {
if (modelValue.value.length === 0) {
modelValue.value.push(createFilter())
}
})
onUnmounted(() => {
modelValue.value = []
})
const getModel = (field) => {
const fieldConfig = props.fields.find((f) => f.field === field)
return fieldConfig?.model || ''
}
watch(
() => modelValue.value,
(filters) => {
filters.forEach((filter) => {
if (filter.field && !filter.model) {
filter.model = getModel(filter.field)
}
})
},
{ deep: true }
)
const addFilter = () => modelValue.value.push(createFilter())
const removeFilter = (index) => modelValue.value.splice(index, 1)
const applyFilters = () => emit('apply', validFilters.value)
const clearFilters = () => {
modelValue.value = []
emit('clear')
}
const validFilters = computed(() => {
return modelValue.value.filter((filter) => filter.field && filter.operator && filter.value)
})
const getFieldOptions = (fieldValue) => {
const field = props.fields.find((f) => f.field === fieldValue.field)
return field?.options || []
}
const getFieldOperators = (modelFilter) => {
const field = props.fields.find((f) => f.field === modelFilter.field)
return field?.operators || []
}
</script>

View File

@@ -1,45 +0,0 @@
<template>
<nav class="flex space-x-2 lg:flex-col lg:space-x-0 lg:space-y-1">
<router-link v-for="item in navItems" :key="item.title" :to="item.href">
<template v-slot="{ navigate, isActive }">
<Button
as="a"
:href="item.href"
variant="ghost"
:class="
cn(
'w-full text-left justify-start h-16 pl-3',
isActive || isChildActive(item.href) ? 'bg-muted hover:bg-muted' : ''
)
"
@click="navigate"
>
<div class="flex flex-col items-start space-y-1">
<span class="text-sm">{{ item.title }}</span>
<p class="text-muted-foreground text-xs break-words whitespace-normal">{{ item.description }}</p>
</div>
</Button>
</template>
</router-link>
</nav>
</template>
<script setup>
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { useRoute } from 'vue-router'
const route = useRoute()
defineProps({
navItems: {
type: Array,
required: true,
default: () => []
}
})
const isChildActive = (href) => {
return route.path.startsWith(href)
}
</script>

View File

@@ -1,7 +1,7 @@
<template>
<div v-if="conversationStore.messages.data">
<!-- Header -->
<div class="p-3 border-b flex items-center justify-between">
<div class="p-2 border-b flex items-center justify-between">
<div class="flex items-center space-x-3 text-sm">
<div class="font-medium">
{{ conversationStore.current.subject }}
@@ -16,7 +16,7 @@
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem v-for="status in conversationStore.statusesForSelect" :key="status.value"
<DropdownMenuItem v-for="status in conversationStore.statusOptions" :key="status.value"
@click="handleUpdateStatus(status.label)">
{{ status.label }}
</DropdownMenuItem>

View File

@@ -64,7 +64,6 @@ import {
} from '@/components/ui/dropdown-menu'
import Placeholder from '@tiptap/extension-placeholder'
import Image from '@tiptap/extension-image'
import HardBreak from '@tiptap/extension-hard-break'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
@@ -95,18 +94,26 @@ const getSelectionText = (from, to, doc) => doc.textBetween(from, to)
const editorConfig = {
extensions: [
// Lists are unstyled in tailwind, so we need to add classes to them.
StarterKit.configure({
hardBreak: false
}),
HardBreak.extend({
addKeyboardShortcuts() {
return {
Enter: () => {
if (this.editor.isActive('orderedList') || this.editor.isActive('bulletList')) {
return this.editor.chain().createParagraphNear().run()
}
return this.editor.commands.setHardBreak()
}
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'
}
}
}),
@@ -194,7 +201,7 @@ watch(
if (!props.clearContent) return
editor.value?.commands.clearContent()
editor.value?.commands.focus()
// `onUpdate` is not called when clearing content, so we need to manually reset the values.
// `onUpdate` is not called when clearing content, so need to reset the content here.
htmlContent.value = ''
textContent.value = ''
cursorPosition.value = 0
@@ -238,10 +245,10 @@ onUnmounted(() => {
// Editor height
.ProseMirror {
min-height: 150px !important;
max-height: 100% !important;
min-height: 200px !important;
max-height: 60% !important;
overflow-y: scroll !important;
padding: 10px 10px;
padding: 10px;
}
.tiptap {
@@ -254,8 +261,4 @@ onUnmounted(() => {
}
}
}
br.ProseMirror-trailingBreak {
display: none;
}
</style>

View File

@@ -5,26 +5,6 @@
<Dialog :open="isEditorFullscreen" @update:open="isEditorFullscreen = $event">
<DialogContent class="max-w-[70%] max-h-[70%] h-[70%] m-0 p-6">
<div v-if="isEditorFullscreen">
<div
v-if="filteredCannedResponses.length > 0"
class="w-full overflow-hidden p-2 border-t backdrop-blur"
>
<ul ref="cannedResponsesRef" class="space-y-2 max-h-96 overflow-y-auto">
<li
v-for="(response, index) in filteredCannedResponses"
:key="response.id"
:class="[
'cursor-pointer rounded p-1 hover:bg-secondary',
{ 'bg-secondary': index === selectedResponseIndex }
]"
@click="selectCannedResponse(response.content)"
@mouseenter="selectedResponseIndex = index"
>
<span class="font-semibold">{{ response.title }}</span> -
{{ getTextFromHTML(response.content).slice(0, 150) }}...
</li>
</ul>
</div>
<Editor
v-model:selectedText="selectedText"
v-model:isBold="isBold"
@@ -33,7 +13,6 @@
v-model:textContent="textContent"
:placeholder="editorPlaceholder"
:aiPrompts="aiPrompts"
@keydown="handleKeydown"
@aiPromptSelected="handleAiPromptSelected"
:contentToSet="contentToSet"
v-model:cursorPosition="cursorPosition"
@@ -45,28 +24,6 @@
</DialogContent>
</Dialog>
<!-- Canned responses on non-fullscreen editor -->
<div
v-if="filteredCannedResponses.length > 0 && !isEditorFullscreen"
class="w-full overflow-hidden p-2 border-t backdrop-blur"
>
<ul ref="cannedResponsesRef" class="space-y-2 max-h-96 overflow-y-auto">
<li
v-for="(response, index) in filteredCannedResponses"
:key="response.id"
:class="[
'cursor-pointer rounded p-1 hover:bg-secondary',
{ 'bg-secondary': index === selectedResponseIndex }
]"
@click="selectCannedResponse(response.content)"
@mouseenter="selectedResponseIndex = index"
>
<span class="font-semibold">{{ response.title }}</span> -
{{ getTextFromHTML(response.content).slice(0, 150) }}...
</li>
</ul>
</div>
<!-- Main Editor non-fullscreen -->
<div class="border-t" v-if="!isEditorFullscreen">
<!-- Message type toggle -->
@@ -94,7 +51,6 @@
v-model:textContent="textContent"
:placeholder="editorPlaceholder"
:aiPrompts="aiPrompts"
@keydown="handleKeydown"
@aiPromptSelected="handleAiPromptSelected"
:contentToSet="contentToSet"
@send="handleSend"
@@ -104,18 +60,30 @@
:insertContent="insertContent"
/>
<!-- Macro preview -->
<MacroActionsPreview
v-if="conversationStore.conversation?.macro?.actions?.length > 0"
:actions="conversationStore.conversation.macro.actions"
:onRemove="conversationStore.removeMacroAction"
/>
<!-- Attachments preview -->
<AttachmentsPreview :attachments="attachments" :onDelete="handleOnFileDelete" />
<AttachmentsPreview
:attachments="attachments"
:onDelete="handleOnFileDelete"
v-if="attachments.length > 0"
/>
<!-- Bottom menu bar -->
<ReplyBoxBottomMenuBar
class="mt-1"
:handleFileUpload="handleFileUpload"
:handleInlineImageUpload="handleInlineImageUpload"
:isBold="isBold"
:isItalic="isItalic"
@toggleBold="toggleBold"
@toggleItalic="toggleItalic"
:hasText="hasText"
:enableSend="enableSend"
:handleSend="handleSend"
@emojiSelect="handleEmojiSelect"
>
@@ -125,25 +93,24 @@
</template>
<script setup>
import { ref, onMounted, computed, watch, nextTick } from 'vue'
import { ref, onMounted, computed, nextTick, watch } from 'vue'
import { transformImageSrcToCID } from '@/utils/strings'
import { handleHTTPError } from '@/utils/http'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { Fullscreen } from 'lucide-vue-next'
import api from '@/api'
import { getTextFromHTML } from '@/utils/strings'
import Editor from './ConversationTextEditor.vue'
import { useConversationStore } from '@/stores/conversation'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useEmitter } from '@/composables/useEmitter'
import AttachmentsPreview from '@/components/attachment/AttachmentsPreview.vue'
import MacroActionsPreview from '../macro/MacroActionsPreview.vue'
import ReplyBoxBottomMenuBar from '@/components/conversation/ReplyBoxMenuBar.vue'
const conversationStore = useConversationStore()
const emitter = useEmitter()
const insertContent = ref(null)
const setInlineImage = ref(null)
const clearEditorContent = ref(false)
@@ -155,22 +122,14 @@ const textContent = ref('')
const contentToSet = ref('')
const isBold = ref(false)
const isItalic = ref(false)
const uploadedFiles = ref([])
const messageType = ref('reply')
const filteredCannedResponses = ref([])
const selectedResponseIndex = ref(-1)
const cannedResponsesRef = ref(null)
const cannedResponses = ref([])
const aiPrompts = ref([])
const editorPlaceholder =
"Press Enter to add a new line; Press '/' to select a Canned Response; Press Ctrl + Enter to send."
const editorPlaceholder = 'Press Enter to add a new line; Press Ctrl + Enter to send.'
onMounted(async () => {
await Promise.all([fetchCannedResponses(), fetchAiPrompts()])
await fetchAiPrompts()
})
const fetchAiPrompts = async () => {
@@ -186,19 +145,6 @@ const fetchAiPrompts = async () => {
}
}
const fetchCannedResponses = async () => {
try {
const resp = await api.getCannedResponses()
cannedResponses.value = resp.data.data
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
const handleAiPromptSelected = async (key) => {
try {
const resp = await api.aiCompletion({
@@ -224,35 +170,20 @@ const toggleItalic = () => {
}
const attachments = computed(() => {
return uploadedFiles.value.filter((upload) => upload.disposition === 'attachment')
return conversationStore.conversation.mediaFiles.filter(
(upload) => upload.disposition === 'attachment'
)
})
// Watch for text content changes and filter canned responses
watch(textContent, (newVal) => {
filterCannedResponses(newVal)
const enableSend = computed(() => {
return textContent.value.trim().length > 0 ||
conversationStore.conversation?.macro?.actions?.length > 0
? true
: false
})
const filterCannedResponses = (input) => {
// Extract the text after the last `/`
const lastSlashIndex = input.lastIndexOf('/')
if (lastSlashIndex !== -1) {
const searchText = input.substring(lastSlashIndex + 1).trim()
// Filter canned responses based on the search text
filteredCannedResponses.value = cannedResponses.value.filter((response) =>
response.title.toLowerCase().includes(searchText.toLowerCase())
)
// Reset the selected response index
selectedResponseIndex.value = filteredCannedResponses.value.length > 0 ? 0 : -1
} else {
filteredCannedResponses.value = []
selectedResponseIndex.value = -1
}
}
const hasText = computed(() => {
return textContent.value.trim().length > 0 ? true : false
const hasTextContent = computed(() => {
return textContent.value.trim().length > 0
})
const handleFileUpload = (event) => {
@@ -264,7 +195,7 @@ const handleFileUpload = (event) => {
linked_model: 'messages'
})
.then((resp) => {
uploadedFiles.value.push(resp.data.data)
conversationStore.conversation.mediaFiles.push(resp.data.data)
})
.catch((error) => {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
@@ -290,7 +221,7 @@ const handleInlineImageUpload = (event) => {
alt: resp.data.data.filename,
title: resp.data.data.uuid
}
uploadedFiles.value.push(resp.data.data)
conversationStore.conversation.mediaFiles.push(resp.data.data)
})
.catch((error) => {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
@@ -304,29 +235,42 @@ const handleInlineImageUpload = (event) => {
const handleSend = async () => {
try {
// Replace inline image url with cid.
const message = transformImageSrcToCID(htmlContent.value)
// Check which images are still in editor before sending.
const parser = new DOMParser()
const doc = parser.parseFromString(htmlContent.value, 'text/html')
const inlineImageUUIDs = Array.from(doc.querySelectorAll('img.inline-image'))
.map((img) => img.getAttribute('title'))
.filter(Boolean)
// Send message if there is text content in the editor.
if (hasTextContent.value) {
// Replace inline image url with cid.
const message = transformImageSrcToCID(htmlContent.value)
uploadedFiles.value = uploadedFiles.value.filter(
(file) =>
// Keep if:
// 1. Not an inline image OR
// 2. Is an inline image that exists in editor
file.disposition !== 'inline' || inlineImageUUIDs.includes(file.uuid)
)
// Check which images are still in editor before sending.
const parser = new DOMParser()
const doc = parser.parseFromString(htmlContent.value, 'text/html')
const inlineImageUUIDs = Array.from(doc.querySelectorAll('img.inline-image'))
.map((img) => img.getAttribute('title'))
.filter(Boolean)
await api.sendMessage(conversationStore.current.uuid, {
private: messageType.value === 'private_note',
message: message,
attachments: uploadedFiles.value.map((file) => file.id)
})
conversationStore.conversation.mediaFiles = conversationStore.conversation.mediaFiles.filter(
(file) =>
// Keep if:
// 1. Not an inline image OR
// 2. Is an inline image that exists in editor
file.disposition !== 'inline' || inlineImageUUIDs.includes(file.uuid)
)
await api.sendMessage(conversationStore.current.uuid, {
private: messageType.value === 'private_note',
message: message,
attachments: conversationStore.conversation.mediaFiles.map((file) => file.id)
})
}
// Apply macro if it exists.
if (conversationStore.conversation?.macro?.actions?.length > 0) {
await api.applyMacro(
conversationStore.current.uuid,
conversationStore.conversation.macro.id,
conversationStore.conversation.macro.actions
)
}
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
@@ -335,6 +279,8 @@ const handleSend = async () => {
})
} finally {
clearEditorContent.value = true
conversationStore.resetMacro()
conversationStore.resetMediaFiles()
nextTick(() => {
clearEditorContent.value = false
})
@@ -343,49 +289,23 @@ const handleSend = async () => {
}
const handleOnFileDelete = (uuid) => {
uploadedFiles.value = uploadedFiles.value.filter((item) => item.uuid !== uuid)
}
const handleKeydown = (event) => {
if (filteredCannedResponses.value.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault()
selectedResponseIndex.value =
(selectedResponseIndex.value + 1) % filteredCannedResponses.value.length
scrollToSelectedItem()
} else if (event.key === 'ArrowUp') {
event.preventDefault()
selectedResponseIndex.value =
(selectedResponseIndex.value - 1 + filteredCannedResponses.value.length) %
filteredCannedResponses.value.length
scrollToSelectedItem()
} else if (event.key === 'Enter') {
event.preventDefault()
selectCannedResponse(filteredCannedResponses.value[selectedResponseIndex.value].content)
}
}
}
const scrollToSelectedItem = () => {
const list = cannedResponsesRef.value
const selectedItem = list.children[selectedResponseIndex.value]
if (selectedItem) {
selectedItem.scrollIntoView({
behavior: 'smooth',
block: 'nearest'
})
}
}
const selectCannedResponse = (content) => {
contentToSet.value = content
filteredCannedResponses.value = []
selectedResponseIndex.value = -1
conversationStore.conversation.mediaFiles = conversationStore.conversation.mediaFiles.filter(
(item) => item.uuid !== uuid
)
}
const handleEmojiSelect = (emoji) => {
insertContent.value = undefined
// Force reactivity so the user can select the same emoji multiple times
nextTick(() => insertContent.value = emoji)
nextTick(() => (insertContent.value = emoji))
}
// Watch for changes in macro content and update editor content.
watch(
() => conversationStore.conversation.macro,
() => {
contentToSet.value = conversationStore.conversation.macro.message_content
},
{ deep: true }
)
</script>

View File

@@ -35,7 +35,7 @@
<Smile class="h-4 w-4" />
</Toggle>
</div>
<Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!hasText">Send</Button>
<Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!enableSend">Send</Button>
</div>
</template>
@@ -57,7 +57,7 @@ const emit = defineEmits(['toggleBold', 'toggleItalic', 'emojiSelect'])
defineProps({
isBold: Boolean,
isItalic: Boolean,
hasText: Boolean,
enableSend: Boolean,
handleSend: Function,
handleFileUpload: Function,
handleInlineImageUpload: Function

View File

@@ -1,6 +1,6 @@
<template>
<div class="flex flex-col items-center justify-center h-64 space-y-2">
<component :is="icon" :stroke-width="1.4" :size="70" />
<component :is="icon" :stroke-width="1" :size="50" />
<h1 class="text-md font-semibold text-gray-800">
{{ title }}
</h1>

View File

@@ -1,21 +1,25 @@
<template>
<div class="h-screen flex flex-col">
<div class="flex justify-start items-center p-3 w-full space-x-4 border-b">
<SidebarTrigger class="cursor-pointer w-5 h-5" />
<span class="text-xl font-semibold">{{ title }}</span>
</div>
<!-- Header -->
<header class="border-b">
<div class="flex items-center space-x-4 p-2">
<SidebarTrigger class="text-gray-500 hover:text-gray-700 transition-colors" />
<span class="text-xl font-semibold text-gray-800">{{ title }}</span>
</div>
</header>
<div class="flex justify-between px-2 py-2 w-full">
<!-- Filters -->
<div class="bg-white px-4 py-3 flex justify-between items-center">
<DropdownMenu>
<DropdownMenuTrigger class="cursor-pointer">
<Button variant="ghost">
<DropdownMenuTrigger asChild>
<Button variant="ghost" class="w-30">
{{ conversationStore.getListStatus }}
<ChevronDown class="w-4 h-4 ml-2" />
<ChevronDown class="w-4 h-4 ml-2 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
v-for="status in conversationStore.statusesForSelect"
v-for="status in conversationStore.statusOptions"
:key="status.value"
@click="handleStatusChange(status)"
>
@@ -24,14 +28,13 @@
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger class="cursor-pointer">
<Button variant="ghost">
<DropdownMenuTrigger asChild>
<Button variant="ghost" class="w-30">
{{ conversationStore.getListSortField }}
<ChevronDown class="w-4 h-4 ml-2" />
<ChevronDown class="w-4 h-4 ml-2 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<!-- TODO move hardcoded values to consts -->
<DropdownMenuItem @click="handleSortChange('oldest')">Oldest</DropdownMenuItem>
<DropdownMenuItem @click="handleSortChange('newest')">Newest</DropdownMenuItem>
<DropdownMenuItem @click="handleSortChange('started_first')"
@@ -53,63 +56,79 @@
</DropdownMenu>
</div>
<!-- Empty -->
<EmptyList
class="px-4"
v-if="!hasConversations && !hasErrored && !isLoading"
title="No conversations found"
message="Try adjusting filters."
:icon="MessageCircleQuestion"
></EmptyList>
<!-- List -->
<!-- Content -->
<div class="flex-grow overflow-y-auto">
<EmptyList
class="px-4"
v-if="conversationStore.conversations.errorMessage"
title="Could not fetch conversations"
:message="conversationStore.conversations.errorMessage"
:icon="MessageCircleWarning"
></EmptyList>
v-if="!hasConversations && !hasErrored && !isLoading"
key="empty"
class="px-4 py-8"
title="No conversations found"
message="Try adjusting your filters"
:icon="MessageCircleQuestion"
/>
<!-- Items -->
<div v-else>
<div class="space-y-5 px-2">
<!-- Empty State -->
<TransitionGroup
enter-active-class="transition-all duration-300 ease-in-out"
enter-from-class="opacity-0 transform translate-y-4"
enter-to-class="opacity-100 transform translate-y-0"
leave-active-class="transition-all duration-300 ease-in-out"
leave-from-class="opacity-100 transform translate-y-0"
leave-to-class="opacity-0 transform translate-y-4"
>
<!-- Error State -->
<EmptyList
v-if="conversationStore.conversations.errorMessage"
key="error"
class="px-4 py-8"
title="Could not fetch conversations"
:message="conversationStore.conversations.errorMessage"
:icon="MessageCircleWarning"
/>
<!-- Conversation List -->
<div v-else key="list" class="divide-y divide-gray-200">
<ConversationListItem
class="mt-2"
:conversation="conversation"
:currentConversation="conversationStore.current"
v-for="conversation in conversationStore.conversationsList"
:key="conversation.uuid"
:conversation="conversation"
:currentConversation="conversationStore.current"
:contactFullName="conversationStore.getContactFullName(conversation.uuid)"
class="transition-colors duration-200 hover:bg-gray-50"
/>
</div>
</div>
<!-- skeleton -->
<div v-if="isLoading">
<ConversationListItemSkeleton v-for="index in 10" :key="index" />
</div>
<!-- Loading Skeleton -->
<div v-if="isLoading" key="loading" class="space-y-4 p-4">
<ConversationListItemSkeleton v-for="index in 10" :key="index" />
</div>
</TransitionGroup>
<!-- Load more -->
<div class="flex justify-center items-center p-5 relative" v-if="!hasErrored">
<div v-if="conversationStore.conversations.hasMore">
<Button variant="link" @click="loadNextPage">
<p v-if="!isLoading">Load more</p>
</Button>
</div>
<div v-else-if="!conversationStore.conversations.hasMore && hasConversations">
All conversations loaded
</div>
<!-- Load More -->
<div
v-if="!hasErrored && (conversationStore.conversations.hasMore || hasConversations)"
class="flex justify-center items-center p-5"
>
<Button
v-if="conversationStore.conversations.hasMore"
variant="outline"
@click="loadNextPage"
:disabled="isLoading"
class="transition-all duration-200 ease-in-out transform hover:scale-105"
>
<Loader2 v-if="isLoading" class="mr-2 h-4 w-4 animate-spin" />
{{ isLoading ? 'Loading...' : 'Load more' }}
</Button>
<p v-else class="text-sm text-gray-500">All conversations loaded</p>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, computed, onUnmounted } from 'vue'
import { onMounted, computed, onUnmounted, ref } from 'vue'
import { useConversationStore } from '@/stores/conversation'
import { MessageCircleQuestion, MessageCircleWarning, ChevronDown } from 'lucide-vue-next'
import { MessageCircleQuestion, MessageCircleWarning, ChevronDown, Loader2 } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
@@ -124,24 +143,26 @@ import { useRoute } from 'vue-router'
import ConversationListItemSkeleton from '@/components/conversation/list/ConversationListItemSkeleton.vue'
const conversationStore = useConversationStore()
let reFetchInterval = null
const route = useRoute()
let reFetchInterval = ref(null)
// Re-fetch conversations list every 30 seconds for any missed updates.
// FIXME: Figure out a better way to handle this.
const title = computed(() => {
const typeValue = route.meta?.type?.(route)
return (
(typeValue || route.meta?.title || '').charAt(0).toUpperCase() +
(typeValue || route.meta?.title || '').slice(1)
)
})
// FIXME: Figure how to get missed updates.
onMounted(() => {
reFetchInterval = setInterval(() => {
reFetchInterval.value = setInterval(() => {
conversationStore.reFetchConversationsList(false)
}, 30000)
})
const route = useRoute()
const title = computed(() => {
const typeValue = route.meta?.type?.(route)
return (typeValue || route.meta?.title || '').charAt(0).toUpperCase() + (typeValue || route.meta?.title || '').slice(1)
})
onUnmounted(() => {
clearInterval(reFetchInterval)
clearInterval(reFetchInterval.value)
conversationStore.clearListReRenderInterval()
})
@@ -157,15 +178,7 @@ const loadNextPage = () => {
conversationStore.fetchNextConversations()
}
const hasConversations = computed(() => {
return conversationStore.conversationsList.length !== 0
})
const hasErrored = computed(() => {
return conversationStore.conversations.errorMessage ? true : false
})
const isLoading = computed(() => {
return conversationStore.conversations.loading
})
const hasConversations = computed(() => conversationStore.conversationsList.length !== 0)
const hasErrored = computed(() => !!conversationStore.conversations.errorMessage)
const isLoading = computed(() => conversationStore.conversations.loading)
</script>

View File

@@ -18,26 +18,11 @@
<script setup>
import { ref, onMounted } from 'vue'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ListFilter, ChevronDown } from 'lucide-vue-next'
import { SidebarTrigger } from '@/components/ui/sidebar'
import { ListFilter } from 'lucide-vue-next'
import { useConversationStore } from '@/stores/conversation'
import { Button } from '@/components/ui/button'
import Filter from '@/components/common/Filter.vue'
import Filter from '@/components/common/FilterBuilder.vue'
import api from '@/api'
const conversationStore = useConversationStore()
@@ -52,14 +37,6 @@ onMounted(() => {
localFilters.value = [...conversationStore.conversations.filters]
})
const handleStatusChange = (status) => {
console.log('status', status)
}
const handleSortChange = (order) => {
console.log('order', order)
}
const fetchInitialData = async () => {
const [statusesResp, prioritiesResp] = await Promise.all([
api.getStatuses(),

View File

@@ -1,53 +1,49 @@
<template>
<div class="flex items-center cursor-pointer flex-row hover:bg-gray-100 hover:rounded-lg hover:box"
:class="{ 'bg-white rounded-lg box': conversation.uuid === currentConversation?.uuid }"
@click="navigateToConversation(conversation.uuid)">
<div class="pl-3">
<Avatar class="size-[45px]">
<div
class="relative p-4 transition-all duration-200 ease-in-out cursor-pointer hover:bg-gray-50 border-b border-gray-100 last:border-b-0"
:class="{ 'bg-blue-50': conversation.uuid === currentConversation?.uuid }"
@click="navigateToConversation(conversation.uuid)"
>
<div class="flex items-start space-x-4">
<Avatar class="w-12 h-12 rounded-full ring-2 ring-white">
<AvatarImage :src="conversation.avatar_url" v-if="conversation.avatar_url" />
<AvatarFallback>
{{ conversation.contact.first_name.substring(0, 2).toUpperCase() }}
</AvatarFallback>
</Avatar>
</div>
<div class="ml-3 w-full pb-2">
<div class="flex justify-between pt-2 pr-3">
<div>
<p class="text-xs text-gray-600 flex gap-x-1">
<Mail size="13" />
{{ conversation.inbox_name }}
</p>
<p class="text-base font-normal">
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-900 truncate">
{{ contactFullName }}
</p>
</div>
<div>
<span class="text-sm text-muted-foreground" v-if="conversation.last_message_at">
</h3>
<span class="text-xs text-gray-500" v-if="conversation.last_message_at">
{{ formatTime(conversation.last_message_at) }}
</span>
</div>
</div>
<div class="pt-2 pr-3">
<div class="flex justify-between">
<p class="text-gray-800 max-w-xs text-sm dark:text-white text-ellipsis flex gap-1">
<CheckCheck :size="14" /> {{ trimmedLastMessage }}
</p>
<div class="flex items-center justify-center bg-green-500 rounded-full w-[20px] h-[20px]"
v-if="conversation.unread_message_count > 0">
<span class="text-white text-xs font-extrabold">
{{ conversation.unread_message_count }}
</span>
</div>
<p class="mt-1 text-xs text-gray-500 flex items-center space-x-1">
<Mail class="w-3 h-3" />
<span>{{ conversation.inbox_name }}</span>
</p>
<p class="mt-2 text-sm text-gray-600 line-clamp-2">
<CheckCheck class="inline w-4 h-4 mr-1 text-green-500" />
{{ trimmedLastMessage }}
</p>
<div class="flex items-center mt-2 space-x-2">
<SlaDisplay :dueAt="conversation.first_reply_due_at" :actualAt="conversation.first_reply_at" :label="'FRD'" :showSLAHit="false" />
<SlaDisplay :dueAt="conversation.resolution_due_at" :actualAt="conversation.resolved_at" :label="'RD'" :showSLAHit="false" />
</div>
</div>
<div class="flex space-x-2 mt-2">
<SlaDisplay :dueAt="conversation.first_reply_due_at" :actualAt="conversation.first_reply_at" :label="'FRD'"
:showSLAHit="false" />
<SlaDisplay :dueAt="conversation.resolution_due_at" :actualAt="conversation.resolved_at" :label="'RD'"
:showSLAHit="false" />
</div>
</div>
<div
v-if="conversation.unread_message_count > 0"
class="absolute top-4 right-4 flex items-center justify-center w-6 h-6 bg-blue-500 text-white text-xs font-bold rounded-full"
>
{{ conversation.unread_message_count }}
</div>
</div>
</template>
@@ -88,6 +84,7 @@ const navigateToConversation = (uuid) => {
const trimmedLastMessage = computed(() => {
const message = props.conversation.last_message || ''
return message.length > 45 ? message.slice(0, 45) + "..." : message
return message.length > 100 ? message.slice(0, 100) + "..." : message
})
</script>

View File

@@ -1,11 +1,11 @@
<template>
<div class="flex items-center gap-5 p-6 border-b">
<Skeleton class="h-12 w-12 rounded-full" />
<div class="space-y-2">
<Skeleton class="h-4 w-[250px]" />
<Skeleton class="h-4 w-[200px]" />
</div>
<div class="flex items-center gap-5 p-6 border-b min-w-[200px]">
<Skeleton class="h-12 w-12 rounded-full aspect-square" />
<div class="space-y-2 flex-grow">
<Skeleton class="h-4 w-full max-w-[250px]" />
<Skeleton class="h-4 w-full max-w-[200px]" />
</div>
</div>
</template>
<script setup>

View File

@@ -1,15 +1,14 @@
<template>
<div v-if="conversationStore.current">
<ConversationSideBarContact :conversation="conversationStore.current" class="p-3" />
<Accordion type="multiple" collapsible class="border-t mt-4" :default-value="[]">
<Accordion type="multiple" collapsible class="border-t" :default-value="[]">
<AccordionItem value="Actions">
<AccordionTrigger class="bg-muted p-3"> Actions </AccordionTrigger>
<AccordionContent class="space-y-5 p-3">
<!-- Agent -->
<ComboBox
v-model="assignedUserID"
:items="usersStore.forSelect"
:items="usersStore.options"
placeholder="Search agent"
defaultLabel="Assign agent"
@select="selectAgent"
@@ -43,21 +42,21 @@
<!-- Team -->
<ComboBox
v-model="assignedTeamID"
:items="teamsStore.forSelect"
:items="teamsStore.options"
placeholder="Search team"
defaultLabel="Assign team"
@select="selectTeam"
>
<template #item="{ item }">
<div class="flex items-center gap-2 ml-2">
{{item.emoji}}
{{ item.emoji }}
<span>{{ item.label }}</span>
</div>
</template>
<template #selected="{ selected }">
<div v-if="selected" class="flex items-center gap-2">
{{selected.emoji}}
{{ selected.emoji }}
<span>{{ selected.label }}</span>
</div>
<span v-else>Select team</span>
@@ -67,7 +66,7 @@
<!-- Priority -->
<ComboBox
v-model="conversationStore.current.priority"
:items="conversationStore.prioritiesForSelect"
:items="conversationStore.priorityOptions"
:defaultLabel="conversationStore.current.priority ?? 'Select priority'"
placeholder="Select priority"
@select="selectPriority"
@@ -79,7 +78,6 @@
:items="tags"
placeholder="Select tags"
/>
</AccordionContent>
</AccordionItem>
<AccordionItem value="Information">
@@ -106,7 +104,6 @@ import {
} from '@/components/ui/accordion'
import ConversationInfo from './ConversationInfo.vue'
import ConversationSideBarContact from '@/components/conversation/sidebar/ConversationSideBarContact.vue'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
import { SelectTag } from '@/components/ui/select'
import { useToast } from '@/components/ui/toast/use-toast'
@@ -118,45 +115,32 @@ const conversationStore = useConversationStore()
const usersStore = useUsersStore()
const teamsStore = useTeamStore()
const tags = ref([])
const tagIDMap = {}
onMounted(async () => {
await Promise.all([fetchTags()])
await fetchTags()
})
// FIXME: Fix race.
watch(
() => conversationStore.current && conversationStore.current.tags,
() => conversationStore.current?.tags,
() => {
handleUpsertTags()
conversationStore.upsertTags({
tags: JSON.stringify(conversationStore.current.tags)
})
},
{ deep: true }
)
const assignedUserID = computed(() => String(conversationStore.current.assigned_user_id))
const assignedTeamID = computed(() => String(conversationStore.current.assigned_team_id))
const handleUpsertTags = () => {
let tagIDs = conversationStore.current.tags.map((tag) => {
if (tag in tagIDMap) {
return tagIDMap[tag]
}
})
conversationStore.upsertTags({
tag_ids: JSON.stringify(tagIDs)
})
}
const fetchTags = async () => {
try {
const resp = await api.getTags()
resp.data.data.forEach((item) => {
tagIDMap[item.name] = item.id
tags.value.push(item.name)
})
} catch (error) {
toast({
title: 'Could not fetch tags',
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})

View File

@@ -1,7 +1,7 @@
<template>
<div>
<Avatar class="size-20">
<AvatarImage :src="conversation?.avatar_url" v-if="conversation?.avatar_url" />
<AvatarImage :src="conversation?.contact?.avatar_url" v-if="conversation?.contact?.avatar_url" />
<AvatarFallback>
{{ conversation?.contact.first_name.toUpperCase().substring(0, 2) }}
</AvatarFallback>
@@ -13,7 +13,7 @@
<Mail class="size-3 mt-1"></Mail>
{{ conversation.contact.email }}
</p>
<p class="text-sm text-muted-foreground flex gap-2 mt-1" v-if="conversation?.contact.phone_number">
<p class="text-sm text-muted-foreground flex gap-2 mt-1" v-if="conversation?.contact?.phone_number">
<Phone class="size-3 mt-1"></Phone>
{{ conversation.contact.phone_number }}
</p>

View File

@@ -1,6 +1,6 @@
<template>
<BarChart :data="data" index="status" :categories="priorities" :show-grid-line="true" :show-x-axis="true"
:show-y-axis="true" type="grouped" :x-formatter="xFormatter" :y-formatter="yFormatter" />
:show-y-axis="true" type="grouped" :x-formatter="xFormatter" :y-formatter="yFormatter" :rounded-corners="4"/>
</template>
<script setup>

View File

@@ -0,0 +1,90 @@
<template>
<div class="flex flex-wrap px-2 py-1">
<div class="flex flex-wrap gap-2">
<div
v-for="action in actions"
:key="action.type"
class="flex items-center bg-white border border-gray-200 rounded shadow-sm transition-all duration-300 ease-in-out hover:shadow-md group"
>
<div class="flex items-center space-x-2 px-3 py-2">
<component
:is="getIcon(action.type)"
size="16"
class="text-primary group-hover:text-primary"
/>
<Tooltip>
<TooltipTrigger as-child>
<div
class="max-w-[12rem] overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium text-primary group-hover:text-gray-900"
>
{{ getDisplayValue(action) }}
</div>
</TooltipTrigger>
<TooltipContent>
<p class="text-sm">{{ getTooltip(action) }}</p>
</TooltipContent>
</Tooltip>
</div>
<button
@click.stop="onRemove(action)"
class="p-2 text-gray-400 hover:text-red-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 rounded transition-colors duration-300 ease-in-out"
title="Remove action"
>
<X size="14" />
</button>
</div>
</div>
</div>
</template>
<script setup>
import { X, Users, User, MessageSquare, Tags, Flag, Send } from 'lucide-vue-next'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
defineProps({
actions: {
type: Array,
required: true
},
onRemove: {
type: Function,
required: true
}
})
const getIcon = (type) =>
({
assign_team: Users,
assign_user: User,
set_status: MessageSquare,
set_priority: Flag,
send_reply: Send,
set_tags: Tags
})[type]
const getDisplayValue = (action) => {
if (action.display_value?.length) {
return action.display_value.join(', ')
}
return action.value.join(', ')
}
const getTooltip = (action) => {
switch (action.type) {
case 'assign_team':
return `Assign to team: ${getDisplayValue(action)}`
case 'assign_user':
return `Assign to user: ${getDisplayValue(action)}`
case 'set_status':
return `Set status to: ${getDisplayValue(action)}`
case 'set_priority':
return `Set priority to: ${getDisplayValue(action)}`
case 'send_reply':
return `Send reply: ${getDisplayValue(action)}`
case 'set_tags':
return `Set tags: ${getDisplayValue(action)}`
default:
return `Action: ${action.type}, Value: ${getDisplayValue(action)}`
}
}
</script>

View File

@@ -174,7 +174,7 @@ const adminNavItems = [
{
title: 'Conversations',
href: '/admin/conversations',
description: 'Manage tags, canned responses and statuses.',
description: 'Manage tags, macros and statuses.',
children: [
{
title: 'Tags',
@@ -183,9 +183,9 @@ const adminNavItems = [
permissions: ['tags:manage'],
},
{
title: 'Canned responses',
href: '/admin/conversations/canned-responses',
description: 'Manage canned responses.',
title: 'Macros',
href: '/admin/conversations/macros',
description: 'Manage macros.',
permissions: ['tags:manage'],
},
{
@@ -300,12 +300,12 @@ const adminNavItems = [
],
},
{
title: 'OpenID Connect SSO',
title: 'SSO',
href: '/admin/oidc',
description: 'Manage OpenID SSO configurations',
children: [
{
title: 'OpenID Connect SSO',
title: 'SSO',
href: '/admin/oidc',
description: 'Manage OpenID SSO configurations',
permissions: ['tags:manage'],
@@ -338,7 +338,7 @@ const hasConversationOpen = computed(() => {
<template>
<div class="flex flex-row justify-between h-full">
<div class="flex-1">
<SidebarProvider :open="open" @update:open="($event) => emit('update:open', $event)" style="--sidebar-width: 17rem;">
<SidebarProvider :open="open" @update:open="($event) => emit('update:open', $event)" style="--sidebar-width: 16rem;">
<!-- Flex Container that holds all the sidebar components -->
<Sidebar collapsible="icon" class="overflow-hidden [&>[data-sidebar=sidebar]]:flex-row !border-r-0">
@@ -519,7 +519,7 @@ const hasConversationOpen = computed(() => {
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild size="md">
<SidebarMenuButton asChild>
<div>
<span class="font-semibold text-2xl">Inbox</span>
</div>

View File

@@ -1,35 +1,42 @@
<template>
<div v-if="dueAt" class="flex items-center justify-center">
<span
v-if="actualAt && isAfterDueTime"
class="flex items-center bg-red-100 p-1 rounded-lg text-xs text-red-700 border border-red-300"
>
<AlertCircle class="w-4 h-4 mr-1" />
<span class="flex items-center">{{ label }} Overdue</span>
</span>
<TransitionGroup name="fade" class="animate-fade-in-down">
<span
v-if="actualAt && isAfterDueTime"
key="overdue"
class="inline-flex items-center bg-red-50 px-1 py-1 rounded-full text-xs font-medium text-red-700 border border-red-200 shadow-sm transition-all duration-300 ease-in-out hover:bg-red-100 animate-fade-in-down min-w-[90px]"
>
<AlertCircle class="w-3 h-3 flex-shrink-0" />
<span class="flex-1 text-center">{{ label }} Overdue</span>
</span>
<span v-else-if="actualAt && !isAfterDueTime" class="flex items-center text-xs text-green-700">
<template v-if="showSLAHit">
<CheckCircle class="w-4 h-4 mr-1" />
<span class="flex items-center">{{ label }} SLA Hit</span>
</template>
</span>
<span
v-else-if="actualAt && !isAfterDueTime && showSLAHit"
key="sla-hit"
class="inline-flex items-center bg-green-50 px-1 py-1 rounded-full text-xs font-medium text-green-700 border border-green-200 shadow-sm transition-all duration-300 ease-in-out hover:bg-green-100 animate-fade-in-down min-w-[90px]"
>
<CheckCircle class="w-3 h-3 flex-shrink-0" />
<span class="flex-1 text-center">{{ label }} SLA Hit</span>
</span>
<span
v-else-if="sla?.status === 'remaining'"
class="flex items-center bg-yellow-100 p-1 rounded-lg text-xs text-yellow-700 border border-yellow-300"
>
<Clock class="w-4 h-4 mr-1" />
<span class="flex items-center">{{ label }} {{ sla.value }}</span>
</span>
<span
v-else-if="sla?.status === 'remaining'"
key="remaining"
class="inline-flex items-center bg-yellow-50 px-1 py-1 rounded-full text-xs font-medium text-yellow-700 border border-yellow-200 shadow-sm transition-all duration-300 ease-in-out hover:bg-yellow-100 animate-fade-in-down min-w-[90px]"
>
<Clock class="w-3 h-3 flex-shrink-0" />
<span class="flex-1 text-center">{{ label }} {{ sla.value }}</span>
</span>
<span
v-else-if="sla?.status === 'overdue'"
class="flex items-center bg-red-100 p-1 rounded-lg text-xs text-red-700 border border-red-300"
>
<AlertCircle class="w-4 h-4 mr-1" />
<span class="flex items-center">{{ label }} Overdue by {{ sla.value }}</span>
</span>
<span
v-else-if="sla?.status === 'overdue'"
key="sla-overdue"
class="inline-flex items-center bg-red-50 px-1 py-1 rounded-full text-xs font-medium text-red-700 border border-red-200 shadow-sm transition-all duration-300 ease-in-out hover:bg-red-100 animate-fade-in-down min-w-[90px]"
>
<AlertCircle class="w-3 h-3 flex-shrink-0" />
<span class="flex-1 text-center">{{ label }} overdue</span>
</span>
</TransitionGroup>
</div>
</template>

View File

@@ -18,31 +18,31 @@ export function useConversationFilters () {
label: 'Status',
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: cStore.statusesForSelect
options: cStore.statusOptions
},
priority_id: {
label: 'Priority',
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: cStore.prioritiesForSelect
options: cStore.priorityOptions
},
assigned_team_id: {
label: 'Assigned team',
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: tStore.forSelect
options: tStore.options
},
assigned_user_id: {
label: 'Assigned user',
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: uStore.forSelect
options: uStore.options
},
inbox_id: {
label: 'Inbox',
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: iStore.forSelect
options: iStore.options
}
}))
@@ -61,25 +61,25 @@ export function useConversationFilters () {
label: 'Status',
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: cStore.statusesForSelect
options: cStore.statusOptions
},
priority: {
label: 'Priority',
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: cStore.prioritiesForSelect
options: cStore.priorityOptions
},
assigned_team: {
label: 'Assigned team',
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: tStore.forSelect
options: tStore.options
},
assigned_user: {
label: 'Assigned agent',
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: uStore.forSelect
options: uStore.options
},
hours_since_created: {
label: 'Hours since created',
@@ -95,7 +95,7 @@ export function useConversationFilters () {
label: 'Inbox',
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: iStore.forSelect
options: iStore.options
}
}))
@@ -103,22 +103,22 @@ export function useConversationFilters () {
assign_team: {
label: 'Assign to team',
type: FIELD_TYPE.SELECT,
options: tStore.forSelect
options: tStore.options
},
assign_user: {
label: 'Assign to user',
type: FIELD_TYPE.SELECT,
options: uStore.forSelect
options: uStore.options
},
set_status: {
label: 'Set status',
type: FIELD_TYPE.SELECT,
options: cStore.statusesForSelect
options: cStore.statusOptionsNoSnooze
},
set_priority: {
label: 'Set priority',
type: FIELD_TYPE.SELECT,
options: cStore.prioritiesForSelect
options: cStore.priorityOptions
},
send_private_note: {
label: 'Send private note',
@@ -131,7 +131,11 @@ export function useConversationFilters () {
set_sla: {
label: 'Set SLA',
type: FIELD_TYPE.SELECT,
options: slaStore.forSelect
options: slaStore.options
},
set_tags: {
label: 'Set tags',
type: FIELD_TYPE.TAG
}
}))

View File

@@ -1,6 +1,7 @@
export const EMITTER_EVENTS = {
REFRESH_LIST: 'refresh-list',
SHOW_TOAST: 'show-toast',
SHOW_SOONER: 'show-sooner',
NEW_MESSAGE: 'new-message',
SET_NESTED_COMMAND: 'set-nested-command',
}

View File

@@ -1,5 +1,6 @@
export const FIELD_TYPE = {
SELECT: 'select',
TAG: 'tag',
TEXT: 'text',
NUMBER: 'number',
RICHTEXT: 'richtext'

View File

@@ -339,17 +339,30 @@ const routes = [
{
path: 'tags',
component: () => import('@/components/admin/conversation/tags/Tags.vue'),
meta: { title: 'Conversation Tags' }
meta: { title: 'Tags' }
},
{
path: 'statuses',
component: () => import('@/components/admin/conversation/status/Status.vue'),
meta: { title: 'Conversation Statuses' }
meta: { title: 'Statuses' }
},
{
path: 'canned-responses',
component: () => import('@/components/admin/conversation/canned_responses/CannedResponses.vue'),
meta: { title: 'Canned Responses' }
path: 'Macros',
component: () => import('@/components/admin/conversation/macros/Macros.vue'),
meta: { title: 'Macros' },
children: [
{
path: 'new',
component: () => import('@/components/admin/conversation/macros/CreateMacro.vue'),
meta: { title: 'Create Macro' }
},
{
path: ':id/edit',
props: true,
component: () => import('@/components/admin/conversation/macros/EditMacro.vue'),
meta: { title: 'Edit Macro' }
},
]
}
]
}

View File

@@ -13,12 +13,19 @@ export const useConversationStore = defineStore('conversation', () => {
const priorities = ref([])
const statuses = ref([])
const prioritiesForSelect = computed(() => {
// Options for select fields
const priorityOptions = computed(() => {
return priorities.value.map(p => ({ label: p.name, value: p.id }))
})
const statusesForSelect = computed(() => {
const statusOptions = computed(() => {
return statuses.value.map(s => ({ label: s.name, value: s.id }))
})
const statusOptionsNoSnooze = computed(() =>
statuses.value.filter(s => s.name !== 'Snoozed').map(s => ({
label: s.name,
value: s.id
}))
)
const sortFieldMap = {
oldest: {
@@ -78,6 +85,8 @@ export const useConversationStore = defineStore('conversation', () => {
const conversation = reactive({
data: null,
participants: {},
mediaFiles: [],
macro: {},
loading: false,
errorMessage: ''
})
@@ -101,6 +110,22 @@ export const useConversationStore = defineStore('conversation', () => {
clearInterval(reRenderInterval)
}
function setMacro (macros) {
conversation.macro = macros
}
function removeMacroAction (action) {
conversation.macro.actions = conversation.macro.actions.filter(a => a.type !== action.type)
}
function resetMacro () {
conversation.macro = {}
}
function resetMediaFiles () {
conversation.mediaFiles = []
}
function setListStatus (status, fetch = true) {
if (conversations.status === status) return
conversations.status = status
@@ -193,6 +218,7 @@ export const useConversationStore = defineStore('conversation', () => {
async function fetchConversation (uuid) {
conversation.loading = true
resetCurrentConversation()
try {
const resp = await api.getConversation(uuid)
conversation.data = resp.data.data
@@ -419,7 +445,6 @@ export const useConversationStore = defineStore('conversation', () => {
}
}
async function upsertTags (v) {
try {
await api.upsertTags(conversation.data.uuid, v)
@@ -517,6 +542,8 @@ export const useConversationStore = defineStore('conversation', () => {
Object.assign(conversation, {
data: null,
participants: {},
macro: {},
mediaFiles: [],
loading: false,
errorMessage: ''
})
@@ -574,11 +601,16 @@ export const useConversationStore = defineStore('conversation', () => {
fetchPriorities,
setListSortField,
setListStatus,
removeMacroAction,
setMacro,
resetMacro,
resetMediaFiles,
getListSortField,
getListStatus,
statuses,
priorities,
prioritiesForSelect,
statusesForSelect
priorityOptions,
statusOptionsNoSnooze,
statusOptions
}
})

View File

@@ -8,7 +8,7 @@ import api from '@/api'
export const useInboxStore = defineStore('inbox', () => {
const inboxes = ref([])
const emitter = useEmitter()
const forSelect = computed(() => inboxes.value.map(inb => ({
const options = computed(() => inboxes.value.map(inb => ({
label: inb.name,
value: String(inb.id)
})))
@@ -27,7 +27,7 @@ export const useInboxStore = defineStore('inbox', () => {
}
return {
inboxes,
forSelect,
options,
fetchInboxes,
}
})

View File

@@ -0,0 +1,41 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { handleHTTPError } from '@/utils/http'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents'
import { useUserStore } from './user'
import api from '@/api'
export const useMacroStore = defineStore('macroStore', () => {
const macroList = ref([])
const emitter = useEmitter()
const userStore = useUserStore()
const macroOptions = computed(() => {
const userTeams = userStore.teams.map(team => String(team.id))
return macroList.value.filter(macro =>
macro.visibility === 'all' || userTeams.includes(macro.team_id) || String(macro.user_id) === String(userStore.userID)
).map(macro => ({
...macro,
label: macro.name,
value: String(macro.id),
}))
})
const loadMacros = async () => {
if (macroList.value.length) return
try {
const response = await api.getAllMacros()
macroList.value = response?.data?.data || []
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
return {
macroList,
macroOptions,
loadMacros,
}
})

View File

@@ -8,7 +8,7 @@ import api from '@/api'
export const useSlaStore = defineStore('sla', () => {
const slas = ref([])
const emitter = useEmitter()
const forSelect = computed(() => slas.value.map(sla => ({
const options = computed(() => slas.value.map(sla => ({
label: sla.name,
value: String(sla.id)
})))
@@ -27,7 +27,7 @@ export const useSlaStore = defineStore('sla', () => {
}
return {
slas,
forSelect,
options,
fetchSlas
}
})

View File

@@ -0,0 +1,37 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { handleHTTPError } from '@/utils/http'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents'
import api from '@/api'
export const useTagStore = defineStore('tags', () => {
const tags = ref([])
const emitter = useEmitter()
const tagNames = computed(() => tags.value.map(tag => tag.name))
const tagOptions = computed(() => tags.value.map(tag => ({
label: tag.name,
value: String(tag.id),
})))
const fetchTags = async () => {
if (tags.value.length) return
try {
const response = await api.getTags()
tags.value = response?.data?.data || []
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
title: 'Error',
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
return {
tags,
tagOptions,
tagNames,
fetchTags,
}
})

View File

@@ -8,7 +8,7 @@ import api from '@/api'
export const useTeamStore = defineStore('team', () => {
const teams = ref([])
const emitter = useEmitter()
const forSelect = computed(() => teams.value.map(team => ({
const options = computed(() => teams.value.map(team => ({
label: team.name,
value: String(team.id),
emoji: team.emoji,
@@ -28,7 +28,7 @@ export const useTeamStore = defineStore('team', () => {
}
return {
teams,
forSelect,
options,
fetchTeams,
}
})

View File

@@ -8,7 +8,7 @@ import api from '@/api'
export const useUsersStore = defineStore('users', () => {
const users = ref([])
const emitter = useEmitter()
const forSelect = computed(() => users.value.map(user => ({
const options = computed(() => users.value.map(user => ({
label: user.first_name + ' ' + user.last_name,
value: String(user.id),
avatar_url: user.avatar_url,
@@ -28,7 +28,7 @@ export const useUsersStore = defineStore('users', () => {
}
return {
users,
forSelect,
options,
fetchUsers,
}
})

View File

@@ -4,6 +4,16 @@ import Admin from '@/components/admin/AdminPage.vue'
<template>
<Admin class="page-content">
<router-view></router-view>
<main class="p-6 lg:p-8">
<div class="max-w-6xl mx-auto">
<div class="bg-white shadow-md rounded-lg overflow-hidden">
<div class="p-6 sm:p-8">
<div class="space-y-6">
<router-view></router-view>
</div>
</div>
</div>
</div>
</main>
</Admin>
</template>

View File

@@ -1,21 +1,17 @@
<template>
<ResizablePanelGroup direction="horizontal" auto-save-id="app.vue.resizable.panel">
<ResizablePanel :min-size="23" :default-size="23" :max-size="40">
<div class="flex">
<div class="border-r w-[380px]">
<ConversationList />
</ResizablePanel>
<ResizableHandle />
<ResizablePanel>
<div class="border-r">
<Conversation v-if="conversationStore.current"></Conversation>
<ConversationPlaceholder v-else></ConversationPlaceholder>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
<div class="border-r flex-1">
<Conversation v-if="conversationStore.current"></Conversation>
<ConversationPlaceholder v-else></ConversationPlaceholder>
</div>
</div>
</template>
<script setup>
import { watch, onUnmounted, onMounted } from 'vue'
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
import ConversationList from '@/components/conversation/list/ConversationList.vue'
import Conversation from '@/components/conversation/Conversation.vue'
import ConversationPlaceholder from '@/components/conversation/ConversationPlaceholder.vue'

View File

@@ -77,7 +77,7 @@ const stopRealtimeUpdates = () => {
const getDashboardData = () => {
isLoading.value = true
Promise.all([getCardStats(), getDashboardCharts()])
Promise.allSettled([getCardStats(), getDashboardCharts()])
.finally(() => {
isLoading.value = false
})

View File

@@ -24,7 +24,7 @@ module.exports = {
},
extend: {
fontFamily: {
inter: ['Inter', 'Helvetica Neue', 'sans-serif'],
jakarta: ['Plus Jakarta Sans', 'Helvetica Neue', 'sans-serif'],
},
colors: {
border: "hsl(var(--border))",
@@ -84,12 +84,23 @@ module.exports = {
from: { height: 'var(--radix-collapsible-content-height)' },
to: { height: 0 },
},
'fade-in-down': {
'0%': {
opacity: '0',
transform: 'translateY(-3px)'
},
'100%': {
opacity: '1',
transform: 'translateY(0)'
},
}
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
'collapsible-down': 'collapsible-down 0.2s ease-in-out',
'collapsible-up': 'collapsible-up 0.2s ease-in-out',
'fade-in-down': 'fade-in-down 0.3s ease-out'
},
},
},

View File

@@ -61,11 +61,11 @@ func (e *Enforcer) LoadPermissions(user umodels.User) error {
has, err := e.enforcer.HasPolicy(userID, permObj, permAct)
if err != nil {
return fmt.Errorf("failed to check policy: %v", err)
return fmt.Errorf("failed to check casbin policy: %v", err)
}
if !has {
if _, err := e.enforcer.AddPolicy(userID, permObj, permAct); err != nil {
return fmt.Errorf("failed to add policy: %v", err)
return fmt.Errorf("failed to add casbin policy: %v", err)
}
}
}

View File

@@ -24,8 +24,8 @@ const (
// Tags
PermTagsManage = "tags:manage"
// Canned Responses
PermCannedResponsesManage = "canned_responses:manage"
// Macros
PermMacrosManage = "macros:manage"
// Users
PermUsersManage = "users:manage"
@@ -80,7 +80,7 @@ var validPermissions = map[string]struct{}{
PermViewManage: {},
PermStatusManage: {},
PermTagsManage: {},
PermCannedResponsesManage: {},
PermMacrosManage: {},
PermUsersManage: {},
PermTeamsManage: {},
PermAutomationsManage: {},

View File

@@ -15,7 +15,6 @@ import (
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/envelope"
mmodels "github.com/abhinavxd/libredesk/internal/media/models"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
@@ -25,7 +24,6 @@ import (
var (
//go:embed queries.sql
efs embed.FS
// MaxQueueSize defines the maximum size of the task queues.
MaxQueueSize = 5000
)
@@ -51,9 +49,7 @@ type Engine struct {
rulesMu sync.RWMutex
q queries
lo *logf.Logger
conversationStore ConversationStore
slaStore SLAStore
systemUser umodels.User
conversationStore conversationStore
taskQueue chan ConversationTask
closed bool
closedMu sync.RWMutex
@@ -65,20 +61,10 @@ type Opts struct {
Lo *logf.Logger
}
type ConversationStore interface {
GetConversation(id int, uuid string) (cmodels.Conversation, error)
GetConversationsCreatedAfter(t time.Time) ([]cmodels.Conversation, error)
UpdateConversationTeamAssignee(uuid string, teamID int, actor umodels.User) error
UpdateConversationUserAssignee(uuid string, assigneeID int, actor umodels.User) error
UpdateConversationStatus(uuid string, statusID int, status, snoozeDur string, actor umodels.User) error
UpdateConversationPriority(uuid string, priorityID int, priority string, actor umodels.User) error
SendPrivateNote(media []mmodels.Media, senderID int, conversationUUID, content string) error
SendReply(media []mmodels.Media, senderID int, conversationUUID, content, meta string) error
RecordSLASet(conversationUUID string, actor umodels.User) error
}
type SLAStore interface {
ApplySLA(conversationID, slaID int) error
type conversationStore interface {
ApplyAction(action models.RuleAction, conversation cmodels.Conversation, user umodels.User) error
GetConversation(teamID int, uuid string) (cmodels.Conversation, error)
GetConversationsCreatedAfter(time.Time) ([]cmodels.Conversation, error)
}
type queries struct {
@@ -94,13 +80,12 @@ type queries struct {
}
// New initializes a new Engine.
func New(systemUser umodels.User, opt Opts) (*Engine, error) {
func New(opt Opts) (*Engine, error) {
var (
q queries
e = &Engine{
systemUser: systemUser,
lo: opt.Lo,
taskQueue: make(chan ConversationTask, MaxQueueSize),
lo: opt.Lo,
taskQueue: make(chan ConversationTask, MaxQueueSize),
}
)
if err := dbutil.ScanSQLFile("queries.sql", &q, opt.DB, efs); err != nil {
@@ -112,9 +97,8 @@ func New(systemUser umodels.User, opt Opts) (*Engine, error) {
}
// SetConversationStore sets conversations store.
func (e *Engine) SetConversationStore(store ConversationStore, slaStore SLAStore) {
func (e *Engine) SetConversationStore(store conversationStore) {
e.conversationStore = store
e.slaStore = slaStore
}
// ReloadRules reloads automation rules from DB.
@@ -277,43 +261,6 @@ func (e *Engine) UpdateRuleExecutionMode(ruleType, mode string) error {
return nil
}
// handleNewConversation handles new conversation events.
func (e *Engine) handleNewConversation(conversationUUID string) {
conversation, err := e.conversationStore.GetConversation(0, conversationUUID)
if err != nil {
e.lo.Error("error fetching conversation for new event", "uuid", conversationUUID, "error", err)
return
}
rules := e.filterRulesByType(models.RuleTypeNewConversation, "")
e.evalConversationRules(rules, conversation)
}
// handleUpdateConversation handles update conversation events with specific eventType.
func (e *Engine) handleUpdateConversation(conversationUUID, eventType string) {
conversation, err := e.conversationStore.GetConversation(0, conversationUUID)
if err != nil {
e.lo.Error("error fetching conversation for update event", "uuid", conversationUUID, "error", err)
return
}
rules := e.filterRulesByType(models.RuleTypeConversationUpdate, eventType)
e.evalConversationRules(rules, conversation)
}
// handleTimeTrigger handles time trigger events.
func (e *Engine) handleTimeTrigger() {
thirtyDaysAgo := time.Now().Add(-30 * 24 * time.Hour)
conversations, err := e.conversationStore.GetConversationsCreatedAfter(thirtyDaysAgo)
if err != nil {
e.lo.Error("error fetching conversations for time trigger", "error", err)
return
}
rules := e.filterRulesByType(models.RuleTypeTimeTrigger, "")
e.lo.Debug("fetched conversations for evaluating time triggers", "conversations_count", len(conversations), "rules_count", len(rules))
for _, conversation := range conversations {
e.evalConversationRules(rules, conversation)
}
}
// EvaluateNewConversationRules enqueues a new conversation for rule evaluation.
func (e *Engine) EvaluateNewConversationRules(conversationUUID string) {
e.closedMu.RLock()
@@ -355,6 +302,43 @@ func (e *Engine) EvaluateConversationUpdateRules(conversationUUID string, eventT
}
}
// handleNewConversation handles new conversation events.
func (e *Engine) handleNewConversation(conversationUUID string) {
conversation, err := e.conversationStore.GetConversation(0, conversationUUID)
if err != nil {
e.lo.Error("error fetching conversation for new event", "uuid", conversationUUID, "error", err)
return
}
rules := e.filterRulesByType(models.RuleTypeNewConversation, "")
e.evalConversationRules(rules, conversation)
}
// handleUpdateConversation handles update conversation events with specific eventType.
func (e *Engine) handleUpdateConversation(conversationUUID, eventType string) {
conversation, err := e.conversationStore.GetConversation(0, conversationUUID)
if err != nil {
e.lo.Error("error fetching conversation for update event", "uuid", conversationUUID, "error", err)
return
}
rules := e.filterRulesByType(models.RuleTypeConversationUpdate, eventType)
e.evalConversationRules(rules, conversation)
}
// handleTimeTrigger handles time trigger events.
func (e *Engine) handleTimeTrigger() {
thirtyDaysAgo := time.Now().Add(-30 * 24 * time.Hour)
conversations, err := e.conversationStore.GetConversationsCreatedAfter(thirtyDaysAgo)
if err != nil {
e.lo.Error("error fetching conversations for time trigger", "error", err)
return
}
rules := e.filterRulesByType(models.RuleTypeTimeTrigger, "")
e.lo.Debug("fetched conversations for evaluating time triggers", "conversations_count", len(conversations), "rules_count", len(rules))
for _, conversation := range conversations {
e.evalConversationRules(rules, conversation)
}
}
// queryRules fetches automation rules from the database.
func (e *Engine) queryRules() []models.Rule {
var (

View File

@@ -8,7 +8,7 @@ import (
"github.com/abhinavxd/libredesk/internal/automation/models"
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
mmodels "github.com/abhinavxd/libredesk/internal/media/models"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
)
// evalConversationRules evaluates a list of rules against a given conversation.
@@ -39,7 +39,7 @@ func (e *Engine) evalConversationRules(rules []models.Rule, conversation cmodels
if evaluateFinalResult(groupEvalResults, rule.GroupOperator) {
e.lo.Debug("rule evaluation successful executing actions", "conversation_uuid", conversation.UUID)
for _, action := range rule.Actions {
e.applyAction(action, conversation)
e.conversationStore.ApplyAction(action, conversation, umodels.User{})
}
if rule.ExecutionMode == models.ExecutionModeFirstMatch {
e.lo.Debug("first match rule execution mode, breaking out of rule evaluation", "conversation_uuid", conversation.UUID)
@@ -138,7 +138,6 @@ func (e *Engine) evaluateRule(rule models.RuleDetail, conversation cmodels.Conve
return false
}
// Case sensitivity handling
if !rule.CaseSensitiveMatch {
valueToCompare = strings.ToLower(valueToCompare)
rule.Value = strings.ToLower(rule.Value)
@@ -210,55 +209,3 @@ func (e *Engine) evaluateRule(rule models.RuleDetail, conversation cmodels.Conve
e.lo.Debug("conversation automation rule status", "has_met", conditionMet, "conversation_uuid", conversation.UUID)
return conditionMet
}
// applyAction applies a specific action to the given conversation.
func (e *Engine) applyAction(action models.RuleAction, conversation cmodels.Conversation) error {
switch action.Type {
case models.ActionAssignTeam:
e.lo.Debug("executing assign team action", "value", action.Action, "conversation_uuid", conversation.UUID)
teamID, _ := strconv.Atoi(action.Action)
if err := e.conversationStore.UpdateConversationTeamAssignee(conversation.UUID, teamID, e.systemUser); err != nil {
return err
}
case models.ActionAssignUser:
e.lo.Debug("executing assign user action", "value", action.Action, "conversation_uuid", conversation.UUID)
agentID, _ := strconv.Atoi(action.Action)
if err := e.conversationStore.UpdateConversationUserAssignee(conversation.UUID, agentID, e.systemUser); err != nil {
return err
}
case models.ActionSetPriority:
e.lo.Debug("executing set priority action", "value", action.Action, "conversation_uuid", conversation.UUID)
priorityID, _ := strconv.Atoi(action.Action)
if err := e.conversationStore.UpdateConversationPriority(conversation.UUID, priorityID, "", e.systemUser); err != nil {
return err
}
case models.ActionSetStatus:
e.lo.Debug("executing set status action", "value", action.Action, "conversation_uuid", conversation.UUID)
statusID, _ := strconv.Atoi(action.Action)
if err := e.conversationStore.UpdateConversationStatus(conversation.UUID, statusID, "", "", e.systemUser); err != nil {
return err
}
case models.ActionSendPrivateNote:
e.lo.Debug("executing send private note action", "value", action.Action, "conversation_uuid", conversation.UUID)
if err := e.conversationStore.SendPrivateNote([]mmodels.Media{}, e.systemUser.ID, conversation.UUID, action.Action); err != nil {
return err
}
case models.ActionReply:
e.lo.Debug("executing reply action", "value", action.Action, "conversation_uuid", conversation.UUID)
if err := e.conversationStore.SendReply([]mmodels.Media{}, e.systemUser.ID, conversation.UUID, action.Action, "" /**meta**/); err != nil {
return err
}
case models.ActionSetSLA:
e.lo.Debug("executing SLA action", "value", action.Action, "conversation_uuid", conversation.UUID)
slaID, _ := strconv.Atoi(action.Action)
if err := e.slaStore.ApplySLA(conversation.ID, slaID); err != nil {
return err
}
if err := e.conversationStore.RecordSLASet(conversation.UUID, e.systemUser); err != nil {
return err
}
default:
return fmt.Errorf("unrecognized rule action: %s", action.Type)
}
return nil
}

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"time"
authzModels "github.com/abhinavxd/libredesk/internal/authz/models"
"github.com/lib/pq"
)
@@ -15,6 +16,7 @@ const (
ActionSendPrivateNote = "send_private_note"
ActionReply = "send_reply"
ActionSetSLA = "set_sla"
ActionSetTags = "set_tags"
OperatorAnd = "AND"
OperatorOR = "OR"
@@ -52,6 +54,17 @@ const (
ExecutionModeFirstMatch = "first_match"
)
// ActionPermissions maps actions to permissions
var ActionPermissions = map[string]string{
ActionAssignTeam: authzModels.PermConversationsUpdateTeamAssignee,
ActionAssignUser: authzModels.PermConversationsUpdateUserAssignee,
ActionSetStatus: authzModels.PermConversationsUpdateStatus,
ActionSetPriority: authzModels.PermConversationsUpdatePriority,
ActionSendPrivateNote: authzModels.PermMessagesWrite,
ActionReply: authzModels.PermMessagesWrite,
ActionSetTags: authzModels.PermConversationsUpdateTags,
}
// RuleRecord represents a rule record in the database
type RuleRecord struct {
ID int `db:"id" json:"id"`
@@ -89,6 +102,7 @@ type RuleDetail struct {
}
type RuleAction struct {
Type string `json:"type" db:"type"`
Action string `json:"value" db:"value"`
Type string `json:"type" db:"type"`
Value []string `json:"value" db:"value"`
DisplayValue []string `json:"display_value" db:"-"`
}

View File

@@ -1,97 +0,0 @@
// Package cannedresp provides functionality to manage canned responses in the system.
package cannedresp
import (
"embed"
"github.com/abhinavxd/libredesk/internal/cannedresp/models"
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/jmoiron/sqlx"
"github.com/zerodha/logf"
)
var (
//go:embed queries.sql
efs embed.FS
)
// Manager handles the operations related to canned responses.
type Manager struct {
q queries
lo *logf.Logger
}
// Opts holds the options for creating a new Manager.
type Opts struct {
DB *sqlx.DB
Lo *logf.Logger
}
type queries struct {
GetAll *sqlx.Stmt `query:"get-all"`
Create *sqlx.Stmt `query:"create"`
Update *sqlx.Stmt `query:"update"`
Delete *sqlx.Stmt `query:"delete"`
}
// New initializes a new Manager.
func New(opts Opts) (*Manager, error) {
var q queries
if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
return nil, err
}
return &Manager{
q: q,
lo: opts.Lo,
}, nil
}
// GetAll retrieves all canned responses.
func (t *Manager) GetAll() ([]models.CannedResponse, error) {
var c = make([]models.CannedResponse, 0)
if err := t.q.GetAll.Select(&c); err != nil {
t.lo.Error("error fetching canned responses", "error", err)
return c, envelope.NewError(envelope.GeneralError, "Error fetching canned responses", nil)
}
return c, nil
}
// Create adds a new canned response.
func (t *Manager) Create(title, content string) error {
if _, err := t.q.Create.Exec(title, content); err != nil {
t.lo.Error("error creating canned response", "error", err)
return envelope.NewError(envelope.GeneralError, "Error creating canned response", nil)
}
return nil
}
// Update modifies an existing canned response.
func (t *Manager) Update(id int, title, content string) error {
result, err := t.q.Update.Exec(id, title, content)
if err != nil {
t.lo.Error("error updating canned response", "error", err)
return envelope.NewError(envelope.GeneralError, "Error updating canned response", nil)
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
return envelope.NewError(envelope.NotFoundError, "Canned response not found", nil)
}
return nil
}
// Delete removes a canned response by ID.
func (t *Manager) Delete(id int) error {
result, err := t.q.Delete.Exec(id)
if err != nil {
t.lo.Error("error deleting canned response", "error", err)
return envelope.NewError(envelope.GeneralError, "Error deleting canned response", nil)
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
return envelope.NewError(envelope.NotFoundError, "Canned response not found", nil)
}
return nil
}

View File

@@ -1,11 +0,0 @@
package models
import "time"
type CannedResponse struct {
ID string `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Title string `db:"title" json:"title"`
Content string `db:"content" json:"content"`
}

View File

@@ -1,13 +0,0 @@
-- name: get-all
SELECT id, title, content, created_at, updated_at FROM canned_responses order by updated_at desc;
-- name: create
INSERT INTO canned_responses (title, content)
VALUES ($1, $2);
-- name: update
UPDATE canned_responses
SET title = $2, content = $3, updated_at = now() where id = $1;
-- name: delete
DELETE FROM canned_responses WHERE id = $1;

View File

@@ -9,10 +9,12 @@ import (
"errors"
"fmt"
"io"
"strconv"
"sync"
"time"
"github.com/abhinavxd/libredesk/internal/automation"
amodels "github.com/abhinavxd/libredesk/internal/automation/models"
"github.com/abhinavxd/libredesk/internal/conversation/models"
pmodels "github.com/abhinavxd/libredesk/internal/conversation/priority/models"
smodels "github.com/abhinavxd/libredesk/internal/conversation/status/models"
@@ -21,6 +23,7 @@ import (
"github.com/abhinavxd/libredesk/internal/inbox"
mmodels "github.com/abhinavxd/libredesk/internal/media/models"
notifier "github.com/abhinavxd/libredesk/internal/notification"
slaModels "github.com/abhinavxd/libredesk/internal/sla/models"
tmodels "github.com/abhinavxd/libredesk/internal/team/models"
"github.com/abhinavxd/libredesk/internal/template"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
@@ -52,6 +55,7 @@ type Manager struct {
mediaStore mediaStore
statusStore statusStore
priorityStore priorityStore
slaStore slaStore
notifier *notifier.Service
lo *logf.Logger
db *sqlx.DB
@@ -67,6 +71,10 @@ type Manager struct {
wg sync.WaitGroup
}
type slaStore interface {
ApplySLA(conversationID, slaID int) (slaModels.SLAPolicy, error)
}
type statusStore interface {
Get(int) (smodels.Status, error)
}
@@ -82,6 +90,7 @@ type teamStore interface {
type userStore interface {
Get(int) (umodels.User, error)
GetSystemUser() (umodels.User, error)
CreateContact(user *umodels.User) error
}
@@ -110,6 +119,7 @@ func New(
wsHub *ws.Hub,
i18n *i18n.I18n,
notifier *notifier.Service,
sla slaStore,
status statusStore,
priority priorityStore,
inboxStore inboxStore,
@@ -134,6 +144,7 @@ func New(
userStore: userStore,
teamStore: teamStore,
mediaStore: mediaStore,
slaStore: sla,
statusStore: status,
priorityStore: priority,
automation: automation,
@@ -626,8 +637,8 @@ func (c *Manager) GetDashboardChart(userID, teamID int) (json.RawMessage, error)
}
// UpsertConversationTags upserts the tags associated with a conversation.
func (t *Manager) UpsertConversationTags(uuid string, tagIDs []int) error {
if _, err := t.q.UpsertConversationTags.Exec(uuid, pq.Array(tagIDs)); err != nil {
func (t *Manager) UpsertConversationTags(uuid string, tagNames []string) error {
if _, err := t.q.UpsertConversationTags.Exec(uuid, pq.Array(tagNames)); err != nil {
t.lo.Error("error upserting conversation tags", "error", err)
return envelope.NewError(envelope.GeneralError, "Error upserting tags", nil)
}
@@ -747,3 +758,76 @@ func (m *Manager) UnassignOpen(userID int) error {
}
return nil
}
// ApplyAction applies an action to a conversation, this can be called from multiple packages across the app to perform actions on conversations.
// all actions are executed on behalf of the provided user if the user is not provided, system user is used.
func (m *Manager) ApplyAction(action amodels.RuleAction, conversation models.Conversation, user umodels.User) error {
if len(action.Value) == 0 {
m.lo.Warn("no value provided for action", "action", action.Type, "conversation_uuid", conversation.UUID)
return fmt.Errorf("no value provided for action %s", action.Type)
}
// If user is not provided, use system user.
if user.ID == 0 {
systemUser, err := m.userStore.GetSystemUser()
if err != nil {
return fmt.Errorf("could not apply %s action. could not fetch system user: %w", action.Type, err)
}
user = systemUser
}
switch action.Type {
case amodels.ActionAssignTeam:
m.lo.Debug("executing assign team action", "value", action.Value[0], "conversation_uuid", conversation.UUID)
teamID, _ := strconv.Atoi(action.Value[0])
if err := m.UpdateConversationTeamAssignee(conversation.UUID, teamID, user); err != nil {
return fmt.Errorf("could not apply %s action: %w", action.Type, err)
}
case amodels.ActionAssignUser:
m.lo.Debug("executing assign user action", "value", action.Value[0], "conversation_uuid", conversation.UUID)
agentID, _ := strconv.Atoi(action.Value[0])
if err := m.UpdateConversationUserAssignee(conversation.UUID, agentID, user); err != nil {
return fmt.Errorf("could not apply %s action: %w", action.Type, err)
}
case amodels.ActionSetPriority:
m.lo.Debug("executing set priority action", "value", action.Value[0], "conversation_uuid", conversation.UUID)
priorityID, _ := strconv.Atoi(action.Value[0])
if err := m.UpdateConversationPriority(conversation.UUID, priorityID, "", user); err != nil {
return fmt.Errorf("could not apply %s action: %w", action.Type, err)
}
case amodels.ActionSetStatus:
m.lo.Debug("executing set status action", "value", action.Value[0], "conversation_uuid", conversation.UUID)
statusID, _ := strconv.Atoi(action.Value[0])
if err := m.UpdateConversationStatus(conversation.UUID, statusID, "", "", user); err != nil {
return fmt.Errorf("could not apply %s action: %w", action.Type, err)
}
case amodels.ActionSendPrivateNote:
m.lo.Debug("executing send private note action", "value", action.Value[0], "conversation_uuid", conversation.UUID)
if err := m.SendPrivateNote([]mmodels.Media{}, user.ID, conversation.UUID, action.Value[0]); err != nil {
return fmt.Errorf("could not apply %s action: %w", action.Type, err)
}
case amodels.ActionReply:
m.lo.Debug("executing reply action", "value", action.Value[0], "conversation_uuid", conversation.UUID)
if err := m.SendReply([]mmodels.Media{}, user.ID, conversation.UUID, action.Value[0], ""); err != nil {
return fmt.Errorf("could not apply %s action: %w", action.Type, err)
}
case amodels.ActionSetSLA:
m.lo.Debug("executing apply SLA action", "value", action.Value[0], "conversation_uuid", conversation.UUID)
slaID, _ := strconv.Atoi(action.Value[0])
slaPolicy, err := m.slaStore.ApplySLA(conversation.ID, slaID)
if err != nil {
return fmt.Errorf("could not apply %s action: %w", action.Type, err)
}
if err := m.RecordSLASet(conversation.UUID, slaPolicy.Name, user); err != nil {
m.lo.Error("error recording SLA set activity", "error", err)
}
case amodels.ActionSetTags:
m.lo.Debug("executing set tags action", "value", action.Value, "conversation_uuid", conversation.UUID)
if err := m.UpsertConversationTags(conversation.UUID, action.Value); err != nil {
return fmt.Errorf("could not apply %s action: %w", action.Type, err)
}
default:
return fmt.Errorf("unrecognized action type %s", action.Type)
}
return nil
}

View File

@@ -376,8 +376,8 @@ func (m *Manager) RecordStatusChange(status, conversationUUID string, actor umod
}
// RecordSLASet records an activity for an SLA set.
func (m *Manager) RecordSLASet(conversationUUID string, actor umodels.User) error {
return m.InsertConversationActivity(ActivitySLASet, conversationUUID, "", actor)
func (m *Manager) RecordSLASet(conversationUUID string, slaName string, actor umodels.User) error {
return m.InsertConversationActivity(ActivitySLASet, conversationUUID, slaName, actor)
}
// InsertConversationActivity inserts an activity message.
@@ -425,13 +425,13 @@ func (m *Manager) getMessageActivityContent(activityType, newValue, actorName st
case ActivitySelfAssign:
content = fmt.Sprintf("%s self-assigned this conversation", actorName)
case ActivityPriorityChange:
content = fmt.Sprintf("%s changed priority to %s", actorName, newValue)
content = fmt.Sprintf("%s set priority to %s", actorName, newValue)
case ActivityStatusChange:
content = fmt.Sprintf("%s marked the conversation as %s", actorName, newValue)
case ActivityTagChange:
content = fmt.Sprintf("%s added tags %s", actorName, newValue)
case ActivitySLASet:
content = fmt.Sprintf("%s set an SLA to this conversation", actorName)
content = fmt.Sprintf("%s set %s SLA", actorName, newValue)
default:
return "", fmt.Errorf("invalid activity type %s", activityType)
}

View File

@@ -81,7 +81,8 @@ SELECT
ct.first_name as "contact.first_name",
ct.last_name as "contact.last_name",
ct.email as "contact.email",
ct.avatar_url as "contact.avatar_url"
ct.avatar_url as "contact.avatar_url",
ct.phone_number as "contact.phone_number"
FROM conversations c
JOIN users ct ON c.contact_id = ct.id
LEFT JOIN sla_policies sla ON c.sla_policy_id = sla.id
@@ -320,13 +321,16 @@ WITH conversation_id AS (
),
inserted AS (
INSERT INTO conversation_tags (conversation_id, tag_id)
SELECT conversation_id.id, unnest($2::int[])
FROM conversation_id
SELECT conversation_id.id, t.id
FROM conversation_id, tags t
WHERE t.name = ANY($2::text[])
ON CONFLICT (conversation_id, tag_id) DO UPDATE SET tag_id = EXCLUDED.tag_id
)
DELETE FROM conversation_tags
WHERE conversation_id = (SELECT id FROM conversation_id)
AND tag_id NOT IN (SELECT unnest($2::int[]));
AND tag_id NOT IN (
SELECT id FROM tags WHERE name = ANY($2::text[])
);
-- name: get-to-address
SELECT cc.identifier

117
internal/macro/macro.go Normal file
View File

@@ -0,0 +1,117 @@
// Package macro provides functionality for managing templated text responses and actions.
package macro
import (
"embed"
"encoding/json"
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/macro/models"
"github.com/jmoiron/sqlx"
"github.com/zerodha/logf"
)
var (
//go:embed queries.sql
efs embed.FS
)
// Manager is the macro manager.
type Manager struct {
q queries
lo *logf.Logger
}
// Predefined queries.
type queries struct {
Get *sqlx.Stmt `query:"get"`
GetAll *sqlx.Stmt `query:"get-all"`
Create *sqlx.Stmt `query:"create"`
Update *sqlx.Stmt `query:"update"`
Delete *sqlx.Stmt `query:"delete"`
IncUsageCount *sqlx.Stmt `query:"increment-usage-count"`
}
// Opts contains the dependencies for the macro manager.
type Opts struct {
DB *sqlx.DB
Lo *logf.Logger
}
// New initializes a macro manager.
func New(opts Opts) (*Manager, error) {
var q queries
err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs)
if err != nil {
return nil, err
}
return &Manager{q: q, lo: opts.Lo}, nil
}
// Get returns a macro by ID.
func (m *Manager) Get(id int) (models.Macro, error) {
macro := models.Macro{}
err := m.q.Get.Get(&macro, id)
if err != nil {
m.lo.Error("error getting macro", "error", err)
return macro, envelope.NewError(envelope.GeneralError, "Error getting macro", nil)
}
return macro, nil
}
// Create adds a new macro.
func (m *Manager) Create(name, messageContent string, userID, teamID *int, visibility string, actions json.RawMessage) error {
_, err := m.q.Create.Exec(name, messageContent, userID, teamID, visibility, actions)
if err != nil {
m.lo.Error("error creating macro", "error", err)
return envelope.NewError(envelope.GeneralError, "Error creating macro", nil)
}
return nil
}
// Update modifies an existing macro.
func (m *Manager) Update(id int, name, messageContent string, userID, teamID *int, visibility string, actions json.RawMessage) error {
result, err := m.q.Update.Exec(id, name, messageContent, userID, teamID, visibility, actions)
if err != nil {
m.lo.Error("error updating macro", "error", err)
return envelope.NewError(envelope.GeneralError, "Error updating macro", nil)
}
if rows, _ := result.RowsAffected(); rows == 0 {
return envelope.NewError(envelope.NotFoundError, "Macro not found", nil)
}
return nil
}
// GetAll returns all macros.
func (m *Manager) GetAll() ([]models.Macro, error) {
macros := make([]models.Macro, 0)
err := m.q.GetAll.Select(&macros)
if err != nil {
m.lo.Error("error fetching macros", "error", err)
return nil, envelope.NewError(envelope.GeneralError, "Error fetching macros", nil)
}
return macros, nil
}
// Delete deletes a macro by ID.
func (m *Manager) Delete(id int) error {
result, err := m.q.Delete.Exec(id)
if err != nil {
m.lo.Error("error deleting macro", "error", err)
return envelope.NewError(envelope.GeneralError, "Error deleting macro", nil)
}
if rows, _ := result.RowsAffected(); rows == 0 {
return envelope.NewError(envelope.NotFoundError, "Macro not found", nil)
}
return nil
}
// IncrementUsageCount increments the usage count of a macro.
func (m *Manager) IncrementUsageCount(id int) error {
if _, err := m.q.IncUsageCount.Exec(id); err != nil {
m.lo.Error("error incrementing usage count", "error", err)
return envelope.NewError(envelope.GeneralError, "Error incrementing macro usage count", nil)
}
return nil
}

View File

@@ -0,0 +1,19 @@
package models
import (
"encoding/json"
"time"
)
type Macro struct {
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Name string `db:"name" json:"name"`
MessageContent string `db:"message_content" json:"message_content"`
Visibility string `db:"visibility" json:"visibility"`
UserID *int `db:"user_id" json:"user_id,string"`
TeamID *int `db:"team_id" json:"team_id,string"`
UsageCount int `db:"usage_count" json:"usage_count"`
Actions json.RawMessage `db:"actions" json:"actions"`
}

View File

@@ -0,0 +1,67 @@
-- name: get
SELECT
id,
name,
message_content,
created_at,
updated_at,
visibility,
user_id,
team_id,
actions,
usage_count
FROM
macros
WHERE
id = $1;
-- name: get-all
SELECT
id,
name,
message_content,
created_at,
updated_at,
visibility,
user_id,
team_id,
actions,
usage_count
FROM
macros
ORDER BY
updated_at DESC;
-- name: create
INSERT INTO
macros (name, message_content, user_id, team_id, visibility, actions)
VALUES
($1, $2, $3, $4, $5, $6);
-- name: update
UPDATE
macros
SET
name = $2,
message_content = $3,
user_id = $4,
team_id = $5,
visibility = $6,
actions = $7,
updated_at = NOW()
WHERE
id = $1;
-- name: delete
DELETE FROM
macros
WHERE
id = $1;
-- name: increment-usage-count
UPDATE
macros
SET
usage_count = usage_count + 1
WHERE
id = $1;

View File

@@ -141,10 +141,10 @@ func (m *Manager) Update(id int, name, description, firstResponseDuration, resol
}
// ApplySLA associates an SLA policy with a conversation.
func (m *Manager) ApplySLA(conversationID, slaPolicyID int) error {
func (m *Manager) ApplySLA(conversationID, slaPolicyID int) (models.SLAPolicy, error) {
sla, err := m.Get(slaPolicyID)
if err != nil {
return err
return sla, err
}
for _, t := range []string{SLATypeFirstResponse, SLATypeResolution} {
if t == SLATypeFirstResponse && sla.FirstResponseTime == "" {
@@ -155,10 +155,10 @@ func (m *Manager) ApplySLA(conversationID, slaPolicyID int) error {
}
if _, err := m.q.InsertConversationSLA.Exec(conversationID, slaPolicyID, t); err != nil && !dbutil.IsUniqueViolationError(err) {
m.lo.Error("error applying SLA to conversation", "error", err)
return err
return sla, envelope.NewError(envelope.GeneralError, "Error applying SLA to conversation", nil)
}
}
return nil
return sla, nil
}
// Run starts the SLA worker pool and periodically processes unbreached SLAs (blocking).

View File

@@ -15,7 +15,7 @@ type Team struct {
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
Emoji null.String `db:"emoji" json:"emoji"`
Name string `db:"name" json:"name"`
ConversationAssignmentType string `db:"conversation_assignment_type"`
ConversationAssignmentType string `db:"conversation_assignment_type" json:"conversation_assignment_type,omitempty"`
Timezone string `db:"timezone" json:"timezone,omitempty"`
BusinessHoursID int `db:"business_hours_id" json:"business_hours_id,omitempty"`
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/lib/pq"
"github.com/volatiletech/null/v9"
)
type User struct {
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
@@ -15,6 +16,7 @@ type User struct {
LastName string `db:"last_name" json:"last_name"`
Email null.String `db:"email" json:"email,omitempty"`
Type string `db:"type" json:"type"`
PhoneNumber null.String `db:"phone_number" json:"phone_number,omitempty"`
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
Disabled bool `db:"disabled" json:"disabled"`
Password string `db:"password" json:"-"`

View File

@@ -11,7 +11,7 @@ DROP TYPE IF EXISTS "template_type" CASCADE; CREATE TYPE "template_type" AS ENUM
DROP TYPE IF EXISTS "user_type" CASCADE; CREATE TYPE "user_type" AS ENUM ('agent', 'contact');
DROP TYPE IF EXISTS "ai_provider" CASCADE; CREATE TYPE "ai_provider" AS ENUM ('openai');
DROP TYPE IF EXISTS "automation_execution_mode" CASCADE; CREATE TYPE "automation_execution_mode" AS ENUM ('all', 'first_match');
DROP TYPE IF EXISTS "macro_visibility" CASCADE; CREATE TYPE "visibility" AS ENUM ('all', 'team', 'user');
DROP TABLE IF EXISTS conversation_slas CASCADE;
CREATE TABLE conversation_slas (
@@ -163,15 +163,20 @@ CREATE TABLE automation_rules (
CONSTRAINT constraint_automation_rules_on_description CHECK (length(description) <= 300)
);
DROP TABLE IF EXISTS canned_responses CASCADE;
CREATE TABLE canned_responses (
id SERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
title TEXT NOT NULL,
"content" TEXT NOT NULL,
CONSTRAINT constraint_canned_responses_on_title CHECK (length(title) <= 140),
CONSTRAINT constraint_canned_responses_on_content CHECK (length("content") <= 5000)
DROP TABLE IF EXISTS macros CASCADE;
CREATE TABLE macros (
id SERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
title TEXT NOT NULL,
actions JSONB DEFAULT '{}'::jsonb NOT NULL,
visibility macro_visibility NOT NULL,
message_content TEXT NOT NULL,
user_id INT REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE,
team_id INT REFERENCES teams(id) ON DELETE CASCADE ON UPDATE CASCADE,
usage_count INT DEFAULT 0 NOT NULL,
CONSTRAINT title_length CHECK (length(title) <= 255),
CONSTRAINT message_content_length CHECK (length(message_content) <= 1000)
);
DROP TABLE IF EXISTS conversation_participants CASCADE;
@@ -252,10 +257,11 @@ CREATE INDEX index_settings_on_key ON settings USING btree ("key");
DROP TABLE IF EXISTS tags CASCADE;
CREATE TABLE tags (
id SERIAL PRIMARY KEY,
"name" TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
"name" TEXT NOT NULL,
CONSTRAINT constraint_tags_on_name_unique UNIQUE ("name")
CONSTRAINT constraint_tags_on_name_unique UNIQUE ("name"),
CONSTRAINT constraint_tags_on_name CHECK (length("name") <= 140)
);
DROP TABLE IF EXISTS team_members CASCADE;
@@ -464,5 +470,5 @@ VALUES
(
'Admin',
'Role for users who have complete access to everything.',
'{general_settings:manage,notification_settings:manage,oidc:manage,conversations:read_all,conversations:read_unassigned,conversations:read_assigned,conversations:read_team_inbox,conversations:read,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write,view:manage,status:manage,tags:manage,canned_responses:manage,users:manage,teams:manage,automations:manage,inboxes:manage,roles:manage,reports:manage,templates:manage,business_hours:manage,sla: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}'
);