mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-11-02 04:53:41 +00:00
feat: adds dropdown to automation form fields
- feat: adds csrf token check - feat: adds conversation sub and unsub for WS updates. - Clean up and remove unncessary code - refactor and simplify auth middlewares - fix: automation rules - Update schema.sql
This commit is contained in:
@@ -131,6 +131,8 @@ func handleUpdateConversationUserAssignee(r *fastglue.Request) error {
|
||||
if err := app.conversation.UpdateConversationUserAssignee(uuid, assigneeID, user); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
app.automation.EvaluateConversationUpdateRules(uuid)
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
@@ -152,6 +154,8 @@ func handleUpdateTeamAssignee(r *fastglue.Request) error {
|
||||
if err := app.conversation.UpdateConversationTeamAssignee(uuid, assigneeID, user); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
app.automation.EvaluateConversationUpdateRules(uuid)
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
@@ -171,6 +175,8 @@ func handleUpdateConversationPriority(r *fastglue.Request) error {
|
||||
if err := app.conversation.UpdateConversationPriority(uuid, priority, user); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
app.automation.EvaluateConversationUpdateRules(uuid)
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
@@ -190,6 +196,8 @@ func handleUpdateConversationStatus(r *fastglue.Request) error {
|
||||
if err := app.conversation.UpdateConversationStatus(uuid, status, user); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
app.automation.EvaluateConversationUpdateRules(uuid)
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
@@ -219,10 +227,10 @@ func handleAddConversationTags(r *fastglue.Request) error {
|
||||
if err := app.conversation.UpsertConversationTags(uuid, tagIDs); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
app.automation.EvaluateConversationUpdateRules(uuid)
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
|
||||
// handleDashboardCounts retrieves general dashboard counts for all users.
|
||||
func handleDashboardCounts(r *fastglue.Request) error {
|
||||
var (
|
||||
|
||||
170
cmd/handlers.go
170
cmd/handlers.go
@@ -15,136 +15,134 @@ import (
|
||||
func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
||||
// Authentication.
|
||||
g.POST("/api/login", handleLogin)
|
||||
g.GET("/api/logout", sess(authSess((handleLogout))))
|
||||
g.GET("/api/logout", authMiddleware(handleLogout, "", ""))
|
||||
g.GET("/api/oidc/{id}/login", handleOIDCLogin)
|
||||
g.GET("/api/oidc/finish", handleOIDCCallback)
|
||||
|
||||
// Health check.
|
||||
g.GET("/health", handleHealthCheck)
|
||||
|
||||
// Serve uploaded files.
|
||||
g.GET("/uploads/{uuid}", sess(authSess(handleServeUploadedFiles)))
|
||||
// Serve media files.
|
||||
g.GET("/uploads/{uuid}", authMiddleware(handleServeMedia, "", ""))
|
||||
|
||||
// Settings.
|
||||
g.GET("/api/settings/general", handleGetGeneralSettings)
|
||||
g.PUT("/api/settings/general", perm(handleUpdateGeneralSettings, "settings_general", "write"))
|
||||
g.GET("/api/settings/notifications/email", perm(handleGetEmailNotificationSettings, "settings_notifications", "read"))
|
||||
g.PUT("/api/settings/notifications/email", perm(handleUpdateEmailNotificationSettings, "settings_notifications", "write"))
|
||||
g.PUT("/api/settings/general", authMiddleware(handleUpdateGeneralSettings, "settings_general", "write"))
|
||||
g.GET("/api/settings/notifications/email", authMiddleware(handleGetEmailNotificationSettings, "settings_notifications", "read"))
|
||||
g.PUT("/api/settings/notifications/email", authMiddleware(handleUpdateEmailNotificationSettings, "settings_notifications", "write"))
|
||||
|
||||
// OpenID SSO.
|
||||
g.GET("/api/oidc", handleGetAllOIDC)
|
||||
g.GET("/api/oidc/{id}", perm(handleGetOIDC, "oidc", "read"))
|
||||
g.POST("/api/oidc", perm(handleCreateOIDC, "oidc", "write"))
|
||||
g.PUT("/api/oidc/{id}", perm(handleUpdateOIDC, "oidc", "write"))
|
||||
g.DELETE("/api/oidc/{id}", perm(handleDeleteOIDC, "oidc", "delete"))
|
||||
g.GET("/api/oidc/{id}", authMiddleware(handleGetOIDC, "oidc", "read"))
|
||||
g.POST("/api/oidc", authMiddleware(handleCreateOIDC, "oidc", "write"))
|
||||
g.PUT("/api/oidc/{id}", authMiddleware(handleUpdateOIDC, "oidc", "write"))
|
||||
g.DELETE("/api/oidc/{id}", authMiddleware(handleDeleteOIDC, "oidc", "delete"))
|
||||
|
||||
// Conversation and message.
|
||||
g.GET("/api/conversations/all", perm(handleGetAllConversations, "conversations", "read_all"))
|
||||
g.GET("/api/conversations/unassigned", perm(handleGetUnassignedConversations, "conversations", "read_unassigned"))
|
||||
g.GET("/api/conversations/assigned", perm(handleGetAssignedConversations, "conversations", "read_assigned"))
|
||||
|
||||
g.PUT("/api/conversations/{uuid}/assignee/user", perm(handleUpdateConversationUserAssignee, "conversations", "update_user_assignee"))
|
||||
g.PUT("/api/conversations/{uuid}/assignee/team", perm(handleUpdateTeamAssignee, "conversations", "update_team_assignee"))
|
||||
g.PUT("/api/conversations/{uuid}/priority", perm(handleUpdateConversationPriority, "conversations", "update_priority"))
|
||||
g.PUT("/api/conversations/{uuid}/status", perm(handleUpdateConversationStatus, "conversations", "update_status"))
|
||||
|
||||
g.GET("/api/conversations/{uuid}", perm(handleGetConversation, "conversations", "read"))
|
||||
g.GET("/api/conversations/{uuid}/participants", perm(handleGetConversationParticipants, "conversations", "read"))
|
||||
g.PUT("/api/conversations/{uuid}/last-seen", perm(handleUpdateConversationAssigneeLastSeen, "conversations", "read"))
|
||||
g.POST("/api/conversations/{uuid}/tags", perm(handleAddConversationTags, "conversations", "update_tags"))
|
||||
g.GET("/api/conversations/{uuid}/messages", perm(handleGetMessages, "messages", "read"))
|
||||
g.POST("/api/conversations/{cuuid}/messages", perm(handleSendMessage, "messages", "write"))
|
||||
g.PUT("/api/conversations/{cuuid}/messages/{uuid}/retry", perm(handleRetryMessage, "messages", "write"))
|
||||
g.GET("/api/conversations/{cuuid}/messages/{uuid}", perm(handleGetMessage, "messages", "read"))
|
||||
g.GET("/api/conversations/all", authMiddleware(handleGetAllConversations, "conversations", "read_all"))
|
||||
g.GET("/api/conversations/unassigned", authMiddleware(handleGetUnassignedConversations, "conversations", "read_unassigned"))
|
||||
g.GET("/api/conversations/assigned", authMiddleware(handleGetAssignedConversations, "conversations", "read_assigned"))
|
||||
g.GET("/api/conversations/{uuid}", authMiddleware(handleGetConversation, "conversations", "read"))
|
||||
g.GET("/api/conversations/{uuid}/participants", authMiddleware(handleGetConversationParticipants, "conversations", "read"))
|
||||
g.PUT("/api/conversations/{uuid}/assignee/user", authMiddleware(handleUpdateConversationUserAssignee, "conversations", "update_user_assignee"))
|
||||
g.PUT("/api/conversations/{uuid}/assignee/team", authMiddleware(handleUpdateTeamAssignee, "conversations", "update_team_assignee"))
|
||||
g.PUT("/api/conversations/{uuid}/priority", authMiddleware(handleUpdateConversationPriority, "conversations", "update_priority"))
|
||||
g.PUT("/api/conversations/{uuid}/status", authMiddleware(handleUpdateConversationStatus, "conversations", "update_status"))
|
||||
g.PUT("/api/conversations/{uuid}/last-seen", authMiddleware(handleUpdateConversationAssigneeLastSeen, "conversations", "read"))
|
||||
g.POST("/api/conversations/{uuid}/tags", authMiddleware(handleAddConversationTags, "conversations", "update_tags"))
|
||||
g.POST("/api/conversations/{cuuid}/messages", authMiddleware(handleSendMessage, "messages", "write"))
|
||||
g.GET("/api/conversations/{uuid}/messages", authMiddleware(handleGetMessages, "messages", "read"))
|
||||
g.PUT("/api/conversations/{cuuid}/messages/{uuid}/retry", authMiddleware(handleRetryMessage, "messages", "write"))
|
||||
g.GET("/api/conversations/{cuuid}/messages/{uuid}", authMiddleware(handleGetMessage, "messages", "read"))
|
||||
|
||||
// Status and priority.
|
||||
g.GET("/api/statuses", sess(authSess(handleGetStatuses)))
|
||||
g.POST("/api/statuses", perm(handleCreateStatus, "status", "write"))
|
||||
g.PUT("/api/statuses/{id}", perm(handleUpdateStatus, "status", "write"))
|
||||
g.DELETE("/api/statuses/{id}", perm(handleDeleteStatus, "status", "delete"))
|
||||
g.GET("/api/priorities", sess(authSess(handleGetPriorities)))
|
||||
g.GET("/api/statuses", authMiddleware(handleGetStatuses, "", ""))
|
||||
g.POST("/api/statuses", authMiddleware(handleCreateStatus, "status", "write"))
|
||||
g.PUT("/api/statuses/{id}", authMiddleware(handleUpdateStatus, "status", "write"))
|
||||
g.DELETE("/api/statuses/{id}", authMiddleware(handleDeleteStatus, "status", "delete"))
|
||||
g.GET("/api/priorities", authMiddleware(handleGetPriorities, "", ""))
|
||||
|
||||
// Tag.
|
||||
g.GET("/api/tags", sess(authSess(handleGetTags)))
|
||||
g.POST("/api/tags", perm(handleCreateTag, "tags", "write"))
|
||||
g.PUT("/api/tags/{id}", perm(handleUpdateTag, "tags", "write"))
|
||||
g.DELETE("/api/tags/{id}", perm(handleDeleteTag, "tags", "delete"))
|
||||
g.GET("/api/tags", authMiddleware(handleGetTags, "", ""))
|
||||
g.POST("/api/tags", authMiddleware(handleCreateTag, "tags", "write"))
|
||||
g.PUT("/api/tags/{id}", authMiddleware(handleUpdateTag, "tags", "write"))
|
||||
g.DELETE("/api/tags/{id}", authMiddleware(handleDeleteTag, "tags", "delete"))
|
||||
|
||||
// Media.
|
||||
g.POST("/api/media", sess(handleMediaUpload))
|
||||
g.POST("/api/media", authMiddleware(handleMediaUpload, "", ""))
|
||||
|
||||
// Canned response.
|
||||
g.GET("/api/canned-responses", sess(authSess(handleGetCannedResponses)))
|
||||
g.POST("/api/canned-responses", perm(handleCreateCannedResponse, "canned_responses", "write"))
|
||||
g.PUT("/api/canned-responses/{id}", perm(handleUpdateCannedResponse, "canned_responses", "write"))
|
||||
g.DELETE("/api/canned-responses/{id}", perm(handleDeleteCannedResponse, "canned_responses", "delete"))
|
||||
g.GET("/api/canned-responses", authMiddleware(handleGetCannedResponses, "", ""))
|
||||
g.POST("/api/canned-responses", authMiddleware(handleCreateCannedResponse, "canned_responses", "write"))
|
||||
g.PUT("/api/canned-responses/{id}", authMiddleware(handleUpdateCannedResponse, "canned_responses", "write"))
|
||||
g.DELETE("/api/canned-responses/{id}", authMiddleware(handleDeleteCannedResponse, "canned_responses", "delete"))
|
||||
|
||||
// User.
|
||||
g.GET("/api/users/me", sess(authSess(handleGetCurrentUser)))
|
||||
g.PUT("/api/users/me", sess(authSess(handleUpdateCurrentUser)))
|
||||
g.DELETE("/api/users/me/avatar", sess(authSess(handleDeleteAvatar)))
|
||||
g.GET("/api/users/compact", sess(authSess(handleGetUsersCompact)))
|
||||
g.GET("/api/users", perm(handleGetUsers, "users", "read"))
|
||||
g.GET("/api/users/{id}", perm(handleGetUser, "users", "read"))
|
||||
g.POST("/api/users", perm(handleCreateUser, "users", "write"))
|
||||
g.PUT("/api/users/{id}", perm(handleUpdateUser, "users", "write"))
|
||||
g.GET("/api/users/me", authMiddleware(handleGetCurrentUser, "", ""))
|
||||
g.PUT("/api/users/me", authMiddleware(handleUpdateCurrentUser, "", ""))
|
||||
g.DELETE("/api/users/me/avatar", authMiddleware(handleDeleteAvatar, "", ""))
|
||||
g.GET("/api/users/compact", authMiddleware(handleGetUsersCompact, "", ""))
|
||||
g.GET("/api/users", authMiddleware(handleGetUsers, "users", "read"))
|
||||
g.GET("/api/users/{id}", authMiddleware(handleGetUser, "users", "read"))
|
||||
g.POST("/api/users", authMiddleware(handleCreateUser, "users", "write"))
|
||||
g.PUT("/api/users/{id}", authMiddleware(handleUpdateUser, "users", "write"))
|
||||
|
||||
// Team.
|
||||
g.GET("/api/teams/compact", sess(authSess(handleGetTeamsCompact)))
|
||||
g.GET("/api/teams", perm(handleGetTeams, "teams", "read"))
|
||||
g.GET("/api/teams/{id}", perm(handleGetTeam, "teams", "read"))
|
||||
g.PUT("/api/teams/{id}", perm(handleUpdateTeam, "teams", "write"))
|
||||
g.POST("/api/teams", perm(handleCreateTeam, "teams", "write"))
|
||||
g.GET("/api/teams/compact", authMiddleware(handleGetTeamsCompact, "", ""))
|
||||
g.GET("/api/teams", authMiddleware(handleGetTeams, "teams", "read"))
|
||||
g.GET("/api/teams/{id}", authMiddleware(handleGetTeam, "teams", "read"))
|
||||
g.PUT("/api/teams/{id}", authMiddleware(handleUpdateTeam, "teams", "write"))
|
||||
g.POST("/api/teams", authMiddleware(handleCreateTeam, "teams", "write"))
|
||||
|
||||
// i18n.
|
||||
g.GET("/api/lang/{lang}", handleGetI18nLang)
|
||||
|
||||
// Automation.
|
||||
g.GET("/api/automation/rules", perm(handleGetAutomationRules, "automations", "read"))
|
||||
g.GET("/api/automation/rules/{id}", perm(handleGetAutomationRule, "automations", "read"))
|
||||
g.POST("/api/automation/rules", perm(handleCreateAutomationRule, "automations", "write"))
|
||||
g.PUT("/api/automation/rules/{id}/toggle", perm(handleToggleAutomationRule, "automations", "write"))
|
||||
g.PUT("/api/automation/rules/{id}", perm(handleUpdateAutomationRule, "automations", "write"))
|
||||
g.DELETE("/api/automation/rules/{id}", perm(handleDeleteAutomationRule, "automations", "delete"))
|
||||
g.GET("/api/automation/rules", authMiddleware(handleGetAutomationRules, "automations", "read"))
|
||||
g.GET("/api/automation/rules/{id}", authMiddleware(handleGetAutomationRule, "automations", "read"))
|
||||
g.POST("/api/automation/rules", authMiddleware(handleCreateAutomationRule, "automations", "write"))
|
||||
g.PUT("/api/automation/rules/{id}/toggle", authMiddleware(handleToggleAutomationRule, "automations", "write"))
|
||||
g.PUT("/api/automation/rules/{id}", authMiddleware(handleUpdateAutomationRule, "automations", "write"))
|
||||
g.DELETE("/api/automation/rules/{id}", authMiddleware(handleDeleteAutomationRule, "automations", "delete"))
|
||||
|
||||
// Inbox.
|
||||
g.GET("/api/inboxes", perm(handleGetInboxes, "inboxes", "read"))
|
||||
g.GET("/api/inboxes/{id}", perm(handleGetInbox, "inboxes", "read"))
|
||||
g.POST("/api/inboxes", perm(handleCreateInbox, "inboxes", "write"))
|
||||
g.PUT("/api/inboxes/{id}/toggle", perm(handleToggleInbox, "inboxes", "write"))
|
||||
g.PUT("/api/inboxes/{id}", perm(handleUpdateInbox, "inboxes", "write"))
|
||||
g.DELETE("/api/inboxes/{id}", perm(handleDeleteInbox, "inboxes", "delete"))
|
||||
g.GET("/api/inboxes", authMiddleware(handleGetInboxes, "inboxes", "read"))
|
||||
g.GET("/api/inboxes/{id}", authMiddleware(handleGetInbox, "inboxes", "read"))
|
||||
g.POST("/api/inboxes", authMiddleware(handleCreateInbox, "inboxes", "write"))
|
||||
g.PUT("/api/inboxes/{id}/toggle", authMiddleware(handleToggleInbox, "inboxes", "write"))
|
||||
g.PUT("/api/inboxes/{id}", authMiddleware(handleUpdateInbox, "inboxes", "write"))
|
||||
g.DELETE("/api/inboxes/{id}", authMiddleware(handleDeleteInbox, "inboxes", "delete"))
|
||||
|
||||
// Role.
|
||||
g.GET("/api/roles", perm(handleGetRoles, "roles", "read"))
|
||||
g.GET("/api/roles/{id}", perm(handleGetRole, "roles", "read"))
|
||||
g.POST("/api/roles", perm(handleCreateRole, "roles", "write"))
|
||||
g.PUT("/api/roles/{id}", perm(handleUpdateRole, "roles", "write"))
|
||||
g.DELETE("/api/roles/{id}", perm(handleDeleteRole, "roles", "delete"))
|
||||
g.GET("/api/roles", authMiddleware(handleGetRoles, "roles", "read"))
|
||||
g.GET("/api/roles/{id}", authMiddleware(handleGetRole, "roles", "read"))
|
||||
g.POST("/api/roles", authMiddleware(handleCreateRole, "roles", "write"))
|
||||
g.PUT("/api/roles/{id}", authMiddleware(handleUpdateRole, "roles", "write"))
|
||||
g.DELETE("/api/roles/{id}", authMiddleware(handleDeleteRole, "roles", "delete"))
|
||||
|
||||
// Dashboard.
|
||||
g.GET("/api/dashboard/global/counts", perm(handleDashboardCounts, "dashboard_global", "read"))
|
||||
g.GET("/api/dashboard/global/charts", perm(handleDashboardCharts, "dashboard_global", "read"))
|
||||
g.GET("/api/dashboard/global/counts", authMiddleware(handleDashboardCounts, "dashboard_global", "read"))
|
||||
g.GET("/api/dashboard/global/charts", authMiddleware(handleDashboardCharts, "dashboard_global", "read"))
|
||||
|
||||
// Template.
|
||||
g.GET("/api/templates", perm(handleGetTemplates, "templates", "read"))
|
||||
g.GET("/api/templates/{id}", perm(handleGetTemplate, "templates", "read"))
|
||||
g.POST("/api/templates", perm(handleCreateTemplate, "templates", "write"))
|
||||
g.PUT("/api/templates/{id}", perm(handleUpdateTemplate, "templates", "write"))
|
||||
g.DELETE("/api/templates/{id}", perm(handleDeleteTemplate, "templates", "delete"))
|
||||
g.GET("/api/templates", authMiddleware(handleGetTemplates, "templates", "read"))
|
||||
g.GET("/api/templates/{id}", authMiddleware(handleGetTemplate, "templates", "read"))
|
||||
g.POST("/api/templates", authMiddleware(handleCreateTemplate, "templates", "write"))
|
||||
g.PUT("/api/templates/{id}", authMiddleware(handleUpdateTemplate, "templates", "write"))
|
||||
g.DELETE("/api/templates/{id}", authMiddleware(handleDeleteTemplate, "templates", "delete"))
|
||||
|
||||
// WebSocket.
|
||||
g.GET("/api/ws", sess(authSess(func(r *fastglue.Request) error {
|
||||
g.GET("/api/ws", authMiddleware(func(r *fastglue.Request) error {
|
||||
return handleWS(r, hub)
|
||||
})))
|
||||
}, "", ""))
|
||||
|
||||
// Frontend pages.
|
||||
g.GET("/", sess(noAuthPage(serveIndexPage)))
|
||||
g.GET("/dashboard", sess(authPage(serveIndexPage)))
|
||||
g.GET("/conversations", sess(authPage(serveIndexPage)))
|
||||
g.GET("/conversations/{all:*}", sess(authPage(serveIndexPage)))
|
||||
g.GET("/account/profile", sess(authPage(serveIndexPage)))
|
||||
g.GET("/admin/{all:*}", sess(authPage(serveIndexPage)))
|
||||
g.GET("/", notAuthenticatedPage(serveIndexPage))
|
||||
g.GET("/dashboard", authenticatedPage(serveIndexPage))
|
||||
g.GET("/conversations", authenticatedPage(serveIndexPage))
|
||||
g.GET("/conversations/{all:*}", authenticatedPage(serveIndexPage))
|
||||
g.GET("/account/profile", authenticatedPage(serveIndexPage))
|
||||
g.GET("/admin/{all:*}", authenticatedPage(serveIndexPage))
|
||||
g.GET("/assets/{all:*}", serveStaticFiles)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ func handleLogin(r *fastglue.Request) error {
|
||||
app.lo.Error("error saving session", "error", err)
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("user.errorAcquiringSession"), nil))
|
||||
}
|
||||
app.auth.SetCSRFCookie(r)
|
||||
return r.SendEnvelope(user)
|
||||
}
|
||||
|
||||
|
||||
28
cmd/media.go
28
cmd/media.go
@@ -133,8 +133,8 @@ func handleMediaUpload(r *fastglue.Request) error {
|
||||
return r.SendEnvelope(media)
|
||||
}
|
||||
|
||||
// handleServeUploadedFiles serves uploaded files from the local filesystem or S3.
|
||||
func handleServeUploadedFiles(r *fastglue.Request) error {
|
||||
// handleServeMedia serves uploaded media.
|
||||
func handleServeMedia(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
user = r.RequestCtx.UserValue("user").(umodels.User)
|
||||
@@ -148,28 +148,28 @@ func handleServeUploadedFiles(r *fastglue.Request) error {
|
||||
}
|
||||
|
||||
// Check if the user has permission to access the linked model.
|
||||
// TODO: Move this out of here.
|
||||
if media.Model.String == "messages" {
|
||||
allowed, err := app.authz.Enforce(user, media.Model.String, "read")
|
||||
if err != nil {
|
||||
return r.SendErrorEnvelope(http.StatusInternalServerError, "Error checking permissions", nil, envelope.GeneralError)
|
||||
}
|
||||
if !allowed {
|
||||
return r.SendErrorEnvelope(http.StatusUnauthorized, "Permission denied", nil, envelope.PermissionError)
|
||||
}
|
||||
allowed, err := app.authz.EnforceMediaAccess(user, media.Model.String)
|
||||
if err != nil {
|
||||
app.lo.Error("error checking media permission", "error", err, "model", media.Model.String, "model_id", media.ModelID)
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
// Validate access to the related conversation.
|
||||
// For messages, check access to the conversation this message is part of.
|
||||
if media.Model.String == "messages" {
|
||||
conversation, err := app.conversation.GetConversationByMessageID(media.ModelID.Int)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
_, err = enforceConversationAccess(app, conversation.UUID, user)
|
||||
allowed, err = app.authz.EnforceConversationAccess(user, conversation)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
return r.SendErrorEnvelope(http.StatusUnauthorized, "Permission denied", nil, envelope.PermissionError)
|
||||
}
|
||||
|
||||
switch ko.String("upload.provider") {
|
||||
case "fs":
|
||||
fasthttp.ServeFile(r.RequestCtx, filepath.Join(ko.String("upload.fs.upload_path"), uuid))
|
||||
|
||||
@@ -9,44 +9,60 @@ import (
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
func perm(handler fastglue.FastRequestHandler, obj, act string) fastglue.FastRequestHandler {
|
||||
// authMiddleware does session validation, CSRF checking, and permission enforcement.
|
||||
func authMiddleware(handler fastglue.FastRequestHandler, object, action string) fastglue.FastRequestHandler {
|
||||
return func(r *fastglue.Request) error {
|
||||
var app = r.Context.(*App)
|
||||
app := r.Context.(*App)
|
||||
|
||||
user, err := app.auth.ValidateSession(r)
|
||||
// Validate session and fetch user.
|
||||
userSession, err := app.auth.ValidateSession(r)
|
||||
if err != nil {
|
||||
app.lo.Error("error validating session", "error", err)
|
||||
return r.SendErrorEnvelope(http.StatusUnauthorized, "Invalid or expired session", nil, envelope.PermissionError)
|
||||
}
|
||||
|
||||
// Fetch user and permissions from DB.
|
||||
user, err = app.user.Get(user.ID)
|
||||
user, err := app.user.Get(userSession.ID)
|
||||
if err != nil {
|
||||
return r.SendErrorEnvelope(http.StatusInternalServerError, "Something went wrong", nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
// CSRF check.
|
||||
cookieToken := string(r.RequestCtx.Request.Header.Cookie("csrf_token"))
|
||||
hdrToken := string(r.RequestCtx.Request.Header.Peek("X-CSRFTOKEN"))
|
||||
if cookieToken == "" || hdrToken == "" || cookieToken != hdrToken {
|
||||
return r.SendErrorEnvelope(http.StatusForbidden, "Invalid CSRF token", nil, envelope.PermissionError)
|
||||
}
|
||||
|
||||
// Permission enforcement.
|
||||
if object != "" && action != "" {
|
||||
ok, err := app.authz.Enforce(user, object, action)
|
||||
if err != nil {
|
||||
return r.SendErrorEnvelope(http.StatusInternalServerError, "Error checking permissions", nil, envelope.GeneralError)
|
||||
}
|
||||
if !ok {
|
||||
return r.SendErrorEnvelope(http.StatusForbidden, "Permission denied", nil, envelope.PermissionError)
|
||||
}
|
||||
}
|
||||
|
||||
// Set user in the request context.
|
||||
r.RequestCtx.SetUserValue("user", user)
|
||||
|
||||
// Enforce the permissions with the user, object, and action.
|
||||
ok, err := app.authz.Enforce(user, obj, act)
|
||||
if err != nil {
|
||||
return r.SendErrorEnvelope(http.StatusInternalServerError, "Something went wrong", nil, envelope.GeneralError)
|
||||
}
|
||||
if !ok {
|
||||
return r.SendErrorEnvelope(http.StatusForbidden, "Permission denied", nil, envelope.PermissionError)
|
||||
}
|
||||
|
||||
// Return handler.
|
||||
// Proceed to the next handler.
|
||||
return handler(r)
|
||||
}
|
||||
}
|
||||
|
||||
// authPage middleware makes sure user is logged in to access the page else redirects to login page.
|
||||
func authPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
||||
// getUserFromContext retrieves the authenticated user from the request context.
|
||||
func getUserFromContext(r *fastglue.Request) (umodels.User, bool) {
|
||||
user, ok := r.RequestCtx.UserValue("user").(umodels.User)
|
||||
return user, ok
|
||||
}
|
||||
|
||||
// authenticatedPage ensures the user is logged in; otherwise, redirects to the login page.
|
||||
func authenticatedPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
||||
return func(r *fastglue.Request) error {
|
||||
// Check if user is logged in. If logged in return next handler.
|
||||
userID, ok := getAuthUserFromSess(r)
|
||||
if ok && userID > 0 {
|
||||
user, ok := getUserFromContext(r)
|
||||
if ok && user.ID > 0 {
|
||||
return handler(r)
|
||||
}
|
||||
nextURI := r.RequestCtx.QueryArgs().Peek("next")
|
||||
@@ -59,54 +75,17 @@ func authPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// getAuthUserFromSess retrives authUser from request context set by the sess() middleware.
|
||||
func getAuthUserFromSess(r *fastglue.Request) (int, bool) {
|
||||
user, ok := r.RequestCtx.UserValue("user").(umodels.User)
|
||||
if user.ID == 0 || !ok {
|
||||
return user.ID, false
|
||||
}
|
||||
return user.ID, true
|
||||
}
|
||||
|
||||
func sess(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
||||
// notAuthenticatedPage allows access only if the user is not authenticated; otherwise, redirects to the dashboard.
|
||||
func notAuthenticatedPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
||||
return func(r *fastglue.Request) error {
|
||||
var app = r.Context.(*App)
|
||||
user, err := app.auth.ValidateSession(r)
|
||||
if err != nil {
|
||||
app.lo.Error("error validating session", "error", err)
|
||||
return r.SendErrorEnvelope(http.StatusUnauthorized, "Invalid or expired session", nil, envelope.PermissionError)
|
||||
}
|
||||
if user.ID >= 0 {
|
||||
r.RequestCtx.SetUserValue("user", user)
|
||||
user, _ := getUserFromContext(r)
|
||||
if user.ID != 0 {
|
||||
nextURI := string(r.RequestCtx.QueryArgs().Peek("next"))
|
||||
if nextURI == "" {
|
||||
nextURI = "/dashboard"
|
||||
}
|
||||
return r.RedirectURI(nextURI, fasthttp.StatusFound, nil, "")
|
||||
}
|
||||
return handler(r)
|
||||
}
|
||||
}
|
||||
|
||||
func authSess(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
||||
return func(r *fastglue.Request) error {
|
||||
var (
|
||||
userID, ok = getAuthUserFromSess(r)
|
||||
)
|
||||
if !ok || userID <= 0 {
|
||||
return sendErrorEnvelope(r,
|
||||
envelope.NewError(envelope.GeneralError, "Invalid or expired session.", nil))
|
||||
}
|
||||
return handler(r)
|
||||
}
|
||||
}
|
||||
|
||||
func noAuthPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
||||
return func(r *fastglue.Request) error {
|
||||
_, ok := getAuthUserFromSess(r)
|
||||
if !ok {
|
||||
return handler(r)
|
||||
}
|
||||
// User is logged in direct if `next` is available else redirect.
|
||||
nextURI := string(r.RequestCtx.QueryArgs().Peek("next"))
|
||||
if len(nextURI) == 0 {
|
||||
nextURI = "/dashboard"
|
||||
}
|
||||
return r.RedirectURI(nextURI, fasthttp.StatusFound, nil, "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@ const allNavLinks = ref([
|
||||
const bottomLinks = ref([
|
||||
{
|
||||
to: '/api/logout',
|
||||
isLink: false,
|
||||
icon: 'lucide:log-out',
|
||||
title: 'Logout'
|
||||
}
|
||||
|
||||
@@ -6,8 +6,27 @@ const http = axios.create({
|
||||
responseType: 'json'
|
||||
})
|
||||
|
||||
// Function to extract CSRF token from cookies
|
||||
function getCSRFToken() {
|
||||
const name = 'csrf_token=';
|
||||
const cookies = document.cookie.split(';');
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
let c = cookies[i].trim();
|
||||
if (c.indexOf(name) === 0) {
|
||||
return c.substring(name.length, c.length);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// Request interceptor.
|
||||
http.interceptors.request.use((request) => {
|
||||
// Add csrf token
|
||||
const token = getCSRFToken()
|
||||
if (token) {
|
||||
request.headers['X-CSRFTOKEN'] = token
|
||||
}
|
||||
|
||||
// Set content type for POST/PUT requests if the content type is not set.
|
||||
if ((request.method === 'post' || request.method === 'put') && !request.headers['Content-Type']) {
|
||||
request.headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
||||
|
||||
@@ -6,38 +6,43 @@
|
||||
<hr class="border-t-2 border-dotted border-gray-300" />
|
||||
</div>
|
||||
<div class="flex space-x-5 justify-between">
|
||||
<Select
|
||||
v-model="action.type"
|
||||
@update:modelValue="(value) => handleFieldChange(value, index)"
|
||||
>
|
||||
<SelectTrigger class="w-56">
|
||||
<SelectValue placeholder="Select action" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Conversation</SelectLabel>
|
||||
<SelectItem
|
||||
v-for="(actionItem, key) in conversationActions"
|
||||
:key="key"
|
||||
:value="key"
|
||||
>
|
||||
{{ actionItem.label }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div class="flex space-x-5">
|
||||
|
||||
<!-- Field -->
|
||||
<Select v-model="action.type" @update:modelValue="(value) => handleFieldChange(value, index)">
|
||||
<SelectTrigger class="w-56">
|
||||
<SelectValue placeholder="Select action" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Conversation</SelectLabel>
|
||||
<SelectItem v-for="(actionItem, key) in conversationActions" :key="key" :value="key">
|
||||
{{ actionItem.label }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<!-- Value -->
|
||||
<Select v-model="action.value" @update:modelValue="(value) => handleValueChange(value, index)">
|
||||
<SelectTrigger class="w-56">
|
||||
<SelectValue placeholder="Select value" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="(act, index) in getDropdownValues(action.type).value" :key="index"
|
||||
:value="act.value.toString()">
|
||||
{{ act.name }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
</div>
|
||||
<div class="cursor-pointer" @click.prevent="removeAction(index)">
|
||||
<CircleX size="21" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Set value"
|
||||
:modelValue="action.value"
|
||||
@update:modelValue="(value) => handleValueChange(value, index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -47,7 +52,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { toRefs } from 'vue'
|
||||
import { toRefs, ref, onMounted } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CircleX } from 'lucide-vue-next'
|
||||
import {
|
||||
@@ -59,7 +64,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import api from '@/api'
|
||||
|
||||
const props = defineProps({
|
||||
actions: {
|
||||
@@ -69,10 +77,59 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const { actions } = toRefs(props)
|
||||
|
||||
const emitter = useEmitter()
|
||||
const teams = ref([])
|
||||
const users = ref([])
|
||||
const statuses = ref([])
|
||||
const priorities = ref([
|
||||
{
|
||||
value: "Low",
|
||||
name: "Low"
|
||||
},
|
||||
{
|
||||
value: "Medium",
|
||||
name: "Medium"
|
||||
},
|
||||
{
|
||||
value: "High",
|
||||
name: "High"
|
||||
},
|
||||
])
|
||||
const emit = defineEmits(['update-actions', 'add-action', 'remove-action'])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [teamsResp, usersResp, statusesResp] = await Promise.all([
|
||||
api.getTeamsCompact(),
|
||||
api.getUsersCompact(),
|
||||
api.getStatuses()
|
||||
])
|
||||
|
||||
teams.value = teamsResp.data.data.map(team => ({
|
||||
value: team.id,
|
||||
name: team.name
|
||||
}))
|
||||
|
||||
users.value = usersResp.data.data.map(user => ({
|
||||
value: user.id,
|
||||
name: user.first_name + ' ' + user.last_name
|
||||
}))
|
||||
|
||||
statuses.value = statusesResp.data.data.map(status => ({
|
||||
value: status.name,
|
||||
name: status.name
|
||||
}))
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Something went wrong',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const handleFieldChange = (value, index) => {
|
||||
actions.value[index].value = ''
|
||||
actions.value[index].type = value
|
||||
emitUpdate(index)
|
||||
}
|
||||
@@ -108,4 +165,15 @@ const conversationActions = {
|
||||
label: 'Set priority'
|
||||
}
|
||||
}
|
||||
|
||||
const actionDropdownValues = {
|
||||
assign_team: teams,
|
||||
assign_user: users,
|
||||
set_status: statuses,
|
||||
set_priority: priorities,
|
||||
}
|
||||
|
||||
const getDropdownValues = (field) => {
|
||||
return actionDropdownValues[field] || []
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<Tabs default-value="conversation_creation">
|
||||
<TabsList class="grid w-full grid-cols-3 mb-5">
|
||||
<TabsTrigger value="conversation_creation"> Conversation creation </TabsTrigger>
|
||||
<TabsTrigger value="conversation_updates"> Conversation updates </TabsTrigger>
|
||||
<TabsTrigger value="conversation_creation"> New conversation </TabsTrigger>
|
||||
<TabsTrigger value="conversation_updates"> Conversation update </TabsTrigger>
|
||||
<TabsTrigger value="time_triggers"> Time triggers </TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="conversation_creation">
|
||||
|
||||
@@ -128,7 +128,6 @@ const rule = ref({
|
||||
type: 'new_conversation',
|
||||
rules: [
|
||||
{
|
||||
type: 'new_conversation',
|
||||
groups: [
|
||||
{
|
||||
rules: [],
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-5">
|
||||
<RadioGroup
|
||||
class="flex"
|
||||
:modelValue="ruleGroup.logical_op"
|
||||
@update:modelValue="handleGroupOperator"
|
||||
>
|
||||
<RadioGroup class="flex" :modelValue="ruleGroup.logical_op" @update:modelValue="handleGroupOperator">
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroupItem value="OR" />
|
||||
<Label for="r1">Match <b>ANY</b> of below.</Label>
|
||||
@@ -24,10 +20,8 @@
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<div class="flex space-x-5">
|
||||
<Select
|
||||
v-model="rule.field"
|
||||
@update:modelValue="(value) => handleFieldChange(value, index)"
|
||||
>
|
||||
<!-- Field selection -->
|
||||
<Select v-model="rule.field" @update:modelValue="(value) => handleFieldChange(value, index)">
|
||||
<SelectTrigger class="w-56">
|
||||
<SelectValue placeholder="Select field" />
|
||||
</SelectTrigger>
|
||||
@@ -41,17 +35,15 @@
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
v-model="rule.operator"
|
||||
@update:modelValue="(value) => handleOperatorChange(value, index)"
|
||||
>
|
||||
<!-- Operator selection -->
|
||||
<Select v-model="rule.operator" @update:modelValue="(value) => handleOperatorChange(value, index)">
|
||||
<SelectTrigger class="w-56">
|
||||
<SelectValue placeholder="Select operator" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="(field, key) in operators" :key="key" :value="key">
|
||||
{{ field.label }}
|
||||
<SelectItem v-for="(op, key) in getFieldOperators(rule.field)" :key="key" :value="op">
|
||||
{{ op }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
@@ -61,20 +53,32 @@
|
||||
<CircleX size="21" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Set value"
|
||||
:modelValue="rule.value"
|
||||
@update:modelValue="(value) => handleValueChange(value, index)"
|
||||
/>
|
||||
|
||||
<!-- Value input based on field type -->
|
||||
<div v-if="showInput(index)">
|
||||
<!-- Text input -->
|
||||
<Input type="text" placeholder="Set value" v-if="inputType(index) === 'text'" :modelValue="rule.value"
|
||||
@update:modelValue="(value) => handleValueChange(value, index)" />
|
||||
|
||||
<!-- Dropdown -->
|
||||
<Select v-model="rule.value" @update:modelValue="(value) => handleValueChange(value, index)"
|
||||
v-if="inputType(index) === 'select'">
|
||||
<SelectTrigger class="w-56">
|
||||
<SelectValue placeholder="Select value" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="(op, key) in getFieldOptions(rule.field)" :key="key" :value="op">
|
||||
{{ op }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="terms"
|
||||
:defaultChecked="rule.case_sensitive_match"
|
||||
@update:checked="(value) => handleCaseSensitiveCheck(value, index)"
|
||||
/>
|
||||
<Checkbox id="terms" :defaultChecked="rule.case_sensitive_match"
|
||||
@update:checked="(value) => handleCaseSensitiveCheck(value, index)" />
|
||||
<label for="terms"> Case sensitive match </label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,7 +91,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { toRefs } from 'vue'
|
||||
import { toRefs, ref, onMounted } from 'vue'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -103,6 +107,10 @@ import {
|
||||
import { CircleX } from 'lucide-vue-next'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import api from '@/api'
|
||||
|
||||
const props = defineProps({
|
||||
ruleGroup: {
|
||||
@@ -110,13 +118,31 @@ const props = defineProps({
|
||||
required: true
|
||||
},
|
||||
groupIndex: {
|
||||
Type: Number,
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emitter = useEmitter()
|
||||
const statuses = ref([])
|
||||
const priorities = ref([
|
||||
"Low", "Medium", "High"
|
||||
])
|
||||
const { ruleGroup } = toRefs(props)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [statusesResp] = await Promise.all([api.getStatuses()])
|
||||
statuses.value = statusesResp.data.data.map(status => (status.name))
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Could not fetch statuses',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update-group', 'add-condition', 'remove-condition'])
|
||||
|
||||
const handleGroupOperator = (value) => {
|
||||
@@ -125,11 +151,16 @@ const handleGroupOperator = (value) => {
|
||||
}
|
||||
|
||||
const handleFieldChange = (value, ruleIndex) => {
|
||||
// Clear operator and value on field change
|
||||
ruleGroup.value.rules[ruleIndex].operator = ''
|
||||
ruleGroup.value.rules[ruleIndex].value = ''
|
||||
ruleGroup.value.rules[ruleIndex].field = value
|
||||
emitUpdate()
|
||||
}
|
||||
|
||||
const handleOperatorChange = (value, ruleIndex) => {
|
||||
// Clear value on operator change
|
||||
ruleGroup.value.rules[ruleIndex].value = ''
|
||||
ruleGroup.value.rules[ruleIndex].operator = value
|
||||
emitUpdate()
|
||||
}
|
||||
@@ -157,44 +188,50 @@ const emitUpdate = () => {
|
||||
}
|
||||
|
||||
const conversationFields = {
|
||||
content: {
|
||||
label: 'Content'
|
||||
},
|
||||
subject: {
|
||||
label: 'Subject'
|
||||
},
|
||||
status: {
|
||||
label: 'Status'
|
||||
},
|
||||
priority: {
|
||||
label: 'Priority'
|
||||
},
|
||||
assigned_team: {
|
||||
label: 'Assigned team'
|
||||
},
|
||||
assigned_user: {
|
||||
label: 'Assigned user'
|
||||
}
|
||||
content: { label: 'Content' },
|
||||
subject: { label: 'Subject' },
|
||||
status: { label: 'Status' },
|
||||
priority: { label: 'Priority' },
|
||||
assigned_team: { label: 'Assigned team' },
|
||||
assigned_user: { label: 'Assigned user' }
|
||||
}
|
||||
|
||||
const operators = {
|
||||
contains: {
|
||||
label: 'Contains'
|
||||
},
|
||||
not_contains: {
|
||||
label: 'Not contains'
|
||||
},
|
||||
equals: {
|
||||
label: 'Equals'
|
||||
},
|
||||
not_equals: {
|
||||
label: 'Not equals'
|
||||
},
|
||||
set: {
|
||||
label: 'Set'
|
||||
},
|
||||
not_set: {
|
||||
label: 'Not set'
|
||||
const fieldOperators = {
|
||||
content: ["contains", "not contains", "equals", "not equals", "set", "not set"],
|
||||
subject: ["contains", "not contains", "equals", "not equals", "set", "not set"],
|
||||
status: ["equals", "not equals", "set", "not set"],
|
||||
priority: ["equals", "not equals", "set", "not set"],
|
||||
assigned_team: ["set", "not set"],
|
||||
assigned_user: ["set", "not set"]
|
||||
}
|
||||
|
||||
const fieldOptions = {
|
||||
status: statuses,
|
||||
priority: priorities,
|
||||
}
|
||||
|
||||
const getFieldOperators = (field) => {
|
||||
return fieldOperators[field] || []
|
||||
}
|
||||
|
||||
const getFieldOptions = (field) => {
|
||||
return fieldOptions[field]?.value || []
|
||||
}
|
||||
|
||||
const inputType = (index) => {
|
||||
const field = ruleGroup.value.rules[index]?.field
|
||||
const operator = ruleGroup.value.rules[index]?.operator
|
||||
if (["status", "priority"].includes(field)) {
|
||||
return "select"
|
||||
}
|
||||
if (["equals", "not equals", "contains", "not contains"].includes(operator)) {
|
||||
return "text"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const showInput = (index) => {
|
||||
const operator = ruleGroup.value.rules[index]?.operator
|
||||
return !["set", "not set"].includes(operator)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
|
||||
<FormField name="allowed_file_upload_extensions" v-slot="{ componentField }">
|
||||
<FormItem>
|
||||
<FormLabel>Allowed file extensions</FormLabel>
|
||||
<FormLabel>Allowed file upload extensions</FormLabel>
|
||||
<FormControl>
|
||||
<TagsInput v-model="componentField.modelValue">
|
||||
<TagsInputItem v-for="item in componentField.modelValue" :key="item" :value="item">
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<!-- Header end -->
|
||||
|
||||
<!-- Messages & reply box -->
|
||||
<div class="flex flex-col h-screen">
|
||||
<div class="flex flex-col h-screen" v-auto-animate>
|
||||
<MessageList class="flex-1" />
|
||||
<ReplyBox class="h-max mb-12" />
|
||||
</div>
|
||||
@@ -37,6 +37,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { vAutoAnimate } from '@formkit/auto-animate/vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<Editor @keydown="handleKeydown" @editorText="handleEditorText" :placeholder="editorPlaceholder" :isBold="isBold"
|
||||
:clearContent="clearContent" :isItalic="isItalic" @updateBold="updateBold" @updateItalic="updateItalic"
|
||||
@contentCleared="handleContentCleared" @contentSet="clearContentToSet" @editorReady="onEditorReady"
|
||||
:messageType="messageType" :contentToSet="contentToSet" :cannedResponses="cannedResponsesStore.responses" />
|
||||
:messageType="messageType" :contentToSet="contentToSet" :cannedResponses="cannedResponses" />
|
||||
|
||||
<!-- Attachments preview -->
|
||||
<AttachmentsPreview :attachments="uploadedFiles" :onDelete="handleOnFileDelete"></AttachmentsPreview>
|
||||
@@ -52,7 +52,6 @@ import api from '@/api'
|
||||
|
||||
import Editor from './ConversationTextEditor.vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { useCannedResponses } from '@/stores/canned_responses'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import AttachmentsPreview from '@/components/attachment/AttachmentsPreview.vue'
|
||||
@@ -66,7 +65,6 @@ const editorText = ref('')
|
||||
const editorHTML = ref('')
|
||||
const contentToSet = ref('')
|
||||
const conversationStore = useConversationStore()
|
||||
const cannedResponsesStore = useCannedResponses()
|
||||
const filteredCannedResponses = ref([])
|
||||
const uploadedFiles = ref([])
|
||||
const messageType = ref('reply')
|
||||
@@ -74,10 +72,17 @@ const selectedResponseIndex = ref(-1)
|
||||
const responsesList = ref(null)
|
||||
let editorInstance = null
|
||||
|
||||
onMounted(() => {
|
||||
cannedResponsesStore.fetchAll()
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const resp = await api.getCannedResponses()
|
||||
cannedResponses.value = resp.data.data
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
})
|
||||
|
||||
const cannedResponses = ref([])
|
||||
|
||||
const updateBold = (newState) => {
|
||||
isBold.value = newState
|
||||
}
|
||||
@@ -105,7 +110,7 @@ const filterCannedResponses = (input) => {
|
||||
const searchText = input.substring(lastSlashIndex + 1).trim()
|
||||
|
||||
// Filter canned responses based on the search text
|
||||
filteredCannedResponses.value = cannedResponsesStore.responses.filter((response) =>
|
||||
filteredCannedResponses.value = cannedResponses.value.filter((response) =>
|
||||
response.title.toLowerCase().includes(searchText.toLowerCase())
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<div class="h-screen">
|
||||
|
||||
<!-- Filters -->
|
||||
<ConversationListFilters v-model:type="conversationType"></ConversationListFilters>
|
||||
|
||||
@@ -11,12 +10,15 @@
|
||||
:message="conversationStore.conversations.errorMessage" :icon="MessageCircleWarning"></EmptyList>
|
||||
|
||||
<div class="h-screen overflow-y-scroll pb-[180px] flex flex-col">
|
||||
<!-- Item -->
|
||||
<ConversationListItem />
|
||||
|
||||
<!-- List skeleton -->
|
||||
<div v-if="conversationsLoading">
|
||||
<ConversationListItemSkeleton v-for="index in 8" :key="index"></ConversationListItemSkeleton>
|
||||
<ConversationListItemSkeleton v-for="index in 10" :key="index"></ConversationListItemSkeleton>
|
||||
</div>
|
||||
|
||||
<!-- Item -->
|
||||
<div v-auto-animate>
|
||||
<ConversationListItem :conversation="conversation" :currentConversation="conversationStore.current"
|
||||
v-for="conversation in conversationStore.sortedConversations" :key="conversation.uuid" />
|
||||
</div>
|
||||
|
||||
<!-- Load more -->
|
||||
@@ -36,6 +38,7 @@
|
||||
|
||||
<script setup>
|
||||
import { onMounted, watch, computed, onUnmounted } from 'vue'
|
||||
import { vAutoAnimate } from '@formkit/auto-animate/vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { subscribeConversationsList } from '@/websocket.js'
|
||||
import { CONVERSATION_LIST_TYPE } from '@/constants/conversation'
|
||||
@@ -63,6 +66,7 @@ onMounted(() => {
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(listRefreshInterval)
|
||||
conversationStore.clearListReRenderInterval()
|
||||
})
|
||||
|
||||
watch(conversationType, (newType) => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="flex items-center cursor-pointer flex-row hover:bg-slate-50"
|
||||
:class="{ 'bg-slate-100': conversation.uuid === conversationStore.current?.uuid }"
|
||||
v-for="conversation in conversationStore.sortedConversations" :key="conversation.uuid"
|
||||
:class="{ 'bg-slate-100': conversation.uuid === currentConversation?.uuid }"
|
||||
@click="router.push('/conversations/' + conversation.uuid)">
|
||||
|
||||
<div class="pl-3">
|
||||
@@ -12,7 +11,7 @@
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="ml-3 w-full border-b pb-2">
|
||||
<div class="flex justify-between pt-2 pr-3">
|
||||
<div>
|
||||
@@ -55,6 +54,10 @@ import { Mail, CheckCheck } from 'lucide-vue-next'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
|
||||
const router = useRouter()
|
||||
defineProps({
|
||||
conversation: Object,
|
||||
currentConversation: Object
|
||||
})
|
||||
const conversationStore = useConversationStore()
|
||||
const getContactFullName = (uuid) => {
|
||||
return conversationStore.getContactFullName(uuid)
|
||||
|
||||
@@ -3,3 +3,9 @@ export const CONVERSATION_LIST_TYPE = {
|
||||
UNASSIGNED: 'unassigned',
|
||||
ALL: 'all'
|
||||
}
|
||||
|
||||
export const CONVERSTION_WS_ACTIONS = {
|
||||
SUB_LIST: 'conversations_list_sub',
|
||||
SET_CURRENT: 'conversation_set_current',
|
||||
UNSET_CURRENT: 'conversation_unset_current'
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import api from '@/api'
|
||||
|
||||
export const useAgents = defineStore('agents', () => {
|
||||
const agents = ref([])
|
||||
|
||||
async function fetchAll() {
|
||||
try {
|
||||
const resp = await api.getAgents()
|
||||
agents.value = resp.data.data
|
||||
} catch (error) {
|
||||
// Pass
|
||||
} finally {
|
||||
// Pass
|
||||
}
|
||||
}
|
||||
|
||||
return { agents, fetchAll }
|
||||
})
|
||||
@@ -1,20 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import api from '@/api'
|
||||
|
||||
export const useCannedResponses = defineStore('canned_responses', () => {
|
||||
const responses = ref([])
|
||||
|
||||
async function fetchAll() {
|
||||
try {
|
||||
const resp = await api.getCannedResponses()
|
||||
responses.value = resp.data.data
|
||||
} catch (error) {
|
||||
// Pass
|
||||
} finally {
|
||||
// Pass
|
||||
}
|
||||
}
|
||||
|
||||
return { responses, fetchAll }
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, reactive, onUnmounted } from 'vue'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import { computed, reactive } from 'vue'
|
||||
import { CONVERSATION_LIST_TYPE } from '@/constants/conversation'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents'
|
||||
import api from '@/api'
|
||||
@@ -42,10 +42,10 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
}, 120000)
|
||||
const emitter = useEmitter()
|
||||
|
||||
// Cleanup.
|
||||
onUnmounted(() => {
|
||||
// Clears the re-render interval
|
||||
function clearListReRenderInterval() {
|
||||
clearInterval(reRenderInterval)
|
||||
})
|
||||
}
|
||||
|
||||
// Sort conversations by last_message_at
|
||||
const sortedConversations = computed(() => {
|
||||
@@ -96,6 +96,8 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
conversation.data = resp.data.data
|
||||
// Mark this conversation as read.
|
||||
markAsRead(uuid)
|
||||
// Reset messages state.
|
||||
resetMessages()
|
||||
} catch (error) {
|
||||
conversation.errorMessage = handleHTTPError(error).message
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
@@ -128,8 +130,6 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
|
||||
// Fetches messages of a conversation.
|
||||
async function fetchMessages (uuid) {
|
||||
// Reset state.
|
||||
resetMessages()
|
||||
messages.loading = true
|
||||
try {
|
||||
const response = await api.getConversationMessages(uuid, messages.page)
|
||||
@@ -394,22 +394,11 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function $reset () {
|
||||
// Reset conversations state
|
||||
conversations.data = []
|
||||
conversations.loading = false
|
||||
conversations.page = 1
|
||||
conversations.hasMore = true
|
||||
conversations.errorMessage = ''
|
||||
|
||||
// Reset conversation state
|
||||
function resetCurrentConversation () {
|
||||
conversation.data = null
|
||||
conversation.participants = {}
|
||||
conversation.loading = false
|
||||
conversation.loading = false,
|
||||
conversation.errorMessage = ''
|
||||
|
||||
// Reset messages state
|
||||
resetMessages()
|
||||
}
|
||||
|
||||
function resetMessages () {
|
||||
@@ -429,6 +418,7 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
sortedMessages,
|
||||
current,
|
||||
currentContactName,
|
||||
clearListReRenderInterval,
|
||||
conversationUUIDExists,
|
||||
updateConversationProp,
|
||||
addNewConversation,
|
||||
@@ -447,6 +437,7 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
updatePriority,
|
||||
updateStatus,
|
||||
updateConversationLastMessage,
|
||||
$reset
|
||||
resetMessages,
|
||||
resetCurrentConversation,
|
||||
}
|
||||
})
|
||||
@@ -1,5 +1,8 @@
|
||||
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 useUserStore = defineStore('user', () => {
|
||||
@@ -7,6 +10,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
const userFirstName = ref('')
|
||||
const userLastName = ref('')
|
||||
const userPermissions = ref([])
|
||||
const emitter = useEmitter()
|
||||
|
||||
// Setters
|
||||
const setAvatar = (avatar) => {
|
||||
@@ -31,22 +35,25 @@ export const useUserStore = defineStore('user', () => {
|
||||
const getFullName = computed(() => {
|
||||
return `${userFirstName.value} ${userLastName.value}`
|
||||
})
|
||||
|
||||
// Fetch current user data
|
||||
|
||||
// Fetches current user.
|
||||
const getCurrentUser = async () => {
|
||||
try {
|
||||
const response = await api.getCurrentUser()
|
||||
const userData = response?.data?.data
|
||||
|
||||
if (userData) {
|
||||
const { avatar_url, first_name, last_name, permissions } = userData
|
||||
setAvatar("/uploads/" +avatar_url)
|
||||
setAvatar("/uploads/" + avatar_url)
|
||||
setFirstName(first_name)
|
||||
setLastName(last_name)
|
||||
userPermissions.value = permissions || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching current user:', error)
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
title: 'Something went wrong',
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,20 +9,15 @@
|
||||
<ConversationPlaceholder v-else></ConversationPlaceholder>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel
|
||||
:min-size="10"
|
||||
:default-size="16"
|
||||
:max-size="30"
|
||||
v-if="conversationStore.current"
|
||||
class="shadow shadow-gray-300"
|
||||
>
|
||||
<ResizablePanel :min-size="10" :default-size="16" :max-size="30" v-if="conversationStore.current"
|
||||
class="shadow shadow-gray-300">
|
||||
<ConversationSideBar></ConversationSideBar>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, watch } from 'vue'
|
||||
import { onMounted, watch, onUnmounted } from 'vue'
|
||||
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
|
||||
import ConversationList from '@/components/conversation/list/ConversationList.vue'
|
||||
@@ -30,6 +25,7 @@ import Conversation from '@/components/conversation/ConversationPage.vue'
|
||||
import ConversationSideBar from '@/components/conversation/sidebar/ConversationSideBar.vue'
|
||||
import ConversationPlaceholder from '@/components/conversation/ConversationPlaceholder.vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { unsetCurrentConversation, setCurrentConversation } from '@/websocket'
|
||||
|
||||
const props = defineProps({
|
||||
uuid: String
|
||||
@@ -40,10 +36,17 @@ onMounted(() => {
|
||||
fetchConversation(props.uuid)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unsetCurrentConversation()
|
||||
conversationStore.resetCurrentConversation()
|
||||
conversationStore.resetMessages()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.uuid,
|
||||
(newUUID, oldUUID) => {
|
||||
if (newUUID !== oldUUID) {
|
||||
unsetCurrentConversation()
|
||||
fetchConversation(newUUID)
|
||||
}
|
||||
}
|
||||
@@ -53,6 +56,7 @@ const fetchConversation = (uuid) => {
|
||||
if (!uuid) return
|
||||
conversationStore.fetchParticipants(uuid)
|
||||
conversationStore.fetchConversation(uuid)
|
||||
setCurrentConversation(uuid)
|
||||
conversationStore.fetchMessages(uuid)
|
||||
conversationStore.updateAssigneeLastSeen(uuid)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div>
|
||||
<DashboardGreet></DashboardGreet>
|
||||
</div>
|
||||
<div class="mt-7">
|
||||
<div class="mt-7" v-auto-animate>
|
||||
<Card :counts="cardCounts" :labels="agentCountCardsLabels" />
|
||||
</div>
|
||||
<div class="flex my-7 justify-between items-center space-x-5">
|
||||
@@ -23,6 +23,7 @@ import { onMounted, ref } from 'vue'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import api from '@/api'
|
||||
|
||||
import { vAutoAnimate } from '@formkit/auto-animate/vue'
|
||||
import Card from '@/components/dashboard/DashboardCard.vue'
|
||||
import LineChart from '@/components/dashboard/DashboardLineChart.vue'
|
||||
import BarChart from '@/components/dashboard/DashboardBarChart.vue'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useConversationStore } from './stores/conversation';
|
||||
import { CONVERSTION_WS_ACTIONS } from './constants/conversation';
|
||||
|
||||
let socket;
|
||||
let reconnectInterval = 1000;
|
||||
@@ -10,19 +11,18 @@ let convStore;
|
||||
|
||||
function initializeWebSocket () {
|
||||
// TODO: Update URL.
|
||||
socket = new WebSocket('ws://localhost:9009/api/ws');
|
||||
|
||||
socket.addEventListener('open', handleOpen);
|
||||
socket.addEventListener('message', handleMessage);
|
||||
socket.addEventListener('error', handleError);
|
||||
socket.addEventListener('close', handleClose);
|
||||
socket = new WebSocket('ws://localhost:9009/api/ws')
|
||||
socket.addEventListener('open', handleOpen)
|
||||
socket.addEventListener('message', handleMessage)
|
||||
socket.addEventListener('error', handleError)
|
||||
socket.addEventListener('close', handleClose)
|
||||
}
|
||||
|
||||
function handleOpen () {
|
||||
console.log('WebSocket connection established');
|
||||
console.log('WebSocket connection established')
|
||||
reconnectInterval = 1000;
|
||||
if (reconnectTimeout) {
|
||||
clearTimeout(reconnectTimeout);
|
||||
clearTimeout(reconnectTimeout)
|
||||
reconnectTimeout = null;
|
||||
}
|
||||
}
|
||||
@@ -30,37 +30,37 @@ function handleOpen () {
|
||||
function handleMessage (event) {
|
||||
try {
|
||||
if (event.data) {
|
||||
const data = JSON.parse(event.data);
|
||||
const data = JSON.parse(event.data)
|
||||
switch (data.type) {
|
||||
case 'new_message':
|
||||
convStore.updateConversationLastMessage(data.data);
|
||||
convStore.updateConversationMessageList(data.data);
|
||||
convStore.updateConversationLastMessage(data.data)
|
||||
convStore.updateConversationMessageList(data.data)
|
||||
break;
|
||||
case 'message_prop_update':
|
||||
convStore.updateMessageProp(data.data);
|
||||
convStore.updateMessageProp(data.data)
|
||||
break;
|
||||
case 'new_conversation':
|
||||
convStore.addNewConversation(data.data);
|
||||
convStore.addNewConversation(data.data)
|
||||
break;
|
||||
case 'conversation_prop_update':
|
||||
convStore.updateConversationProp(data.data);
|
||||
convStore.updateConversationProp(data.data)
|
||||
break;
|
||||
default:
|
||||
console.warn(`Unknown websocket event type: ${data.type}`);
|
||||
console.warn(`Unknown websocket event type: ${data.type}`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error handling WebSocket message:', error);
|
||||
console.error('Error handling WebSocket message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function handleError (event) {
|
||||
console.error('WebSocket error observed:', event);
|
||||
console.error('WebSocket error observed:', event)
|
||||
}
|
||||
|
||||
function handleClose () {
|
||||
if (!manualClose) {
|
||||
reconnect();
|
||||
reconnect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,58 +68,77 @@ function reconnect () {
|
||||
if (isReconnecting) return;
|
||||
isReconnecting = true;
|
||||
reconnectTimeout = setTimeout(() => {
|
||||
initializeWebSocket();
|
||||
reconnectInterval = Math.min(reconnectInterval * 2, maxReconnectInterval);
|
||||
initializeWebSocket()
|
||||
reconnectInterval = Math.min(reconnectInterval * 2, maxReconnectInterval)
|
||||
isReconnecting = false;
|
||||
}, reconnectInterval);
|
||||
}, reconnectInterval)
|
||||
}
|
||||
|
||||
function setupNetworkListeners () {
|
||||
window.addEventListener('online', () => {
|
||||
if (!isReconnecting && socket.readyState !== WebSocket.OPEN) {
|
||||
reconnectInterval = 1000;
|
||||
reconnect();
|
||||
reconnect()
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function initWS () {
|
||||
convStore = useConversationStore();
|
||||
initializeWebSocket();
|
||||
setupNetworkListeners();
|
||||
convStore = useConversationStore()
|
||||
initializeWebSocket()
|
||||
setupNetworkListeners()
|
||||
}
|
||||
|
||||
function waitForWebSocketOpen (callback) {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
callback();
|
||||
callback()
|
||||
} else {
|
||||
socket.addEventListener('open', function handler () {
|
||||
socket.removeEventListener('open', handler);
|
||||
callback();
|
||||
});
|
||||
socket.removeEventListener('open', handler)
|
||||
callback()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function sendMessage (message) {
|
||||
waitForWebSocketOpen(() => {
|
||||
socket.send(JSON.stringify(message));
|
||||
});
|
||||
socket.send(JSON.stringify(message))
|
||||
})
|
||||
}
|
||||
|
||||
export function subscribeConversationsList (type, filter) {
|
||||
const message = {
|
||||
action: 'conversations_list_sub',
|
||||
action: CONVERSTION_WS_ACTIONS.SUB_LIST,
|
||||
type: type,
|
||||
filter: filter
|
||||
};
|
||||
}
|
||||
waitForWebSocketOpen(() => {
|
||||
socket.send(JSON.stringify(message));
|
||||
});
|
||||
socket.send(JSON.stringify(message))
|
||||
})
|
||||
}
|
||||
|
||||
export function setCurrentConversation (uuid) {
|
||||
const message = {
|
||||
action: CONVERSTION_WS_ACTIONS.SET_CURRENT,
|
||||
uuid: uuid,
|
||||
}
|
||||
waitForWebSocketOpen(() => {
|
||||
socket.send(JSON.stringify(message))
|
||||
})
|
||||
}
|
||||
|
||||
export function unsetCurrentConversation () {
|
||||
const message = {
|
||||
action: CONVERSTION_WS_ACTIONS.UNSET_CURRENT
|
||||
}
|
||||
waitForWebSocketOpen(() => {
|
||||
socket.send(JSON.stringify(message))
|
||||
})
|
||||
}
|
||||
|
||||
export function closeWebSocket () {
|
||||
manualClose = true;
|
||||
if (socket) {
|
||||
socket.close();
|
||||
socket.close()
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -20,6 +22,10 @@ import (
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
const (
|
||||
csrfTokenLength = 20
|
||||
)
|
||||
|
||||
type OIDCclaim struct {
|
||||
Email string `json:"email"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
@@ -162,6 +168,22 @@ func (a *Auth) SaveSession(user models.User, r *fastglue.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetCSRFCookie sets the CSRF token in the response cookie
|
||||
func (a *Auth) SetCSRFCookie(r *fastglue.Request) error {
|
||||
token, err := generateCSRFToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var csrfCookie fasthttp.Cookie
|
||||
csrfCookie.SetKey("csrf_token")
|
||||
csrfCookie.SetValue(token)
|
||||
csrfCookie.SetPath("/")
|
||||
csrfCookie.SetSecure(true)
|
||||
csrfCookie.SetHTTPOnly(false)
|
||||
r.RequestCtx.Response.Header.SetCookie(&csrfCookie)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateSession validates session and returns the user.
|
||||
func (a *Auth) ValidateSession(r *fastglue.Request) (models.User, error) {
|
||||
sess, err := a.sess.Acquire(r.RequestCtx, r, r)
|
||||
@@ -206,6 +228,15 @@ func (a *Auth) DestroySession(r *fastglue.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateCSRFToken creates a random CSRF token
|
||||
func generateCSRFToken() (string, error) {
|
||||
b := make([]byte, csrfTokenLength)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// getRequestCookie returns fashttp.Cookie for the given name.
|
||||
func getRequestCookie(name string, r *fastglue.Request) (*fasthttp.Cookie, error) {
|
||||
// Cookie value.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// package authz provides Casbin-based authorization.
|
||||
package authz
|
||||
|
||||
import (
|
||||
@@ -20,17 +21,17 @@ type Enforcer struct {
|
||||
}
|
||||
|
||||
const casbinModel = `
|
||||
[request_definition]
|
||||
r = sub, obj, act
|
||||
[request_definition]
|
||||
r = sub, obj, act
|
||||
|
||||
[policy_definition]
|
||||
p = sub, obj, act
|
||||
[policy_definition]
|
||||
p = sub, obj, act
|
||||
|
||||
[policy_effect]
|
||||
e = some(where (p.eft == allow))
|
||||
[policy_effect]
|
||||
e = some(where (p.eft == allow))
|
||||
|
||||
[matchers]
|
||||
m = r.sub == p.sub && r.obj == p.obj && r.act == p.act
|
||||
[matchers]
|
||||
m = r.sub == p.sub && r.obj == p.obj && r.act == p.act
|
||||
`
|
||||
|
||||
// NewEnforcer initializes a new Enforcer with the hardcoded model
|
||||
@@ -39,7 +40,6 @@ func NewEnforcer(lo *logf.Logger) (*Enforcer, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Casbin model: %v", err)
|
||||
}
|
||||
|
||||
e, err := casbin.NewEnforcer(m)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Casbin enforcer: %v", err)
|
||||
@@ -56,11 +56,10 @@ func (e *Enforcer) LoadPermissions(user umodels.User) error {
|
||||
return fmt.Errorf("invalid permission format: %s", perm)
|
||||
}
|
||||
|
||||
permObj, permAct := parts[0], parts[1]
|
||||
|
||||
ok, err := e.enforcer.HasPolicy(strconv.Itoa(user.ID), permObj, permAct)
|
||||
userID, permObj, permAct := strconv.Itoa(user.ID), parts[0], parts[1]
|
||||
ok, err := e.enforcer.HasPolicy(userID, permObj, permAct)
|
||||
if err != nil || !ok {
|
||||
if _, err := e.enforcer.AddPolicy(strconv.Itoa(user.ID), permObj, permAct); err != nil {
|
||||
if _, err := e.enforcer.AddPolicy(userID, permObj, permAct); err != nil {
|
||||
return fmt.Errorf("failed to add policy: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -85,7 +84,7 @@ func (e *Enforcer) Enforce(user umodels.User, obj, act string) (bool, error) {
|
||||
}
|
||||
|
||||
// EnforceConversationAccess checks if a user has access to a conversation based on their permissions.
|
||||
// It returns true if the user has read_all permission, or read_team permission and is in the assigned team,
|
||||
// It returns true if the user has read_all permission, or read_assigned permission and is in the assigned team,
|
||||
// or read_assigned permission and is the assigned user. Returns false otherwise.
|
||||
func (e *Enforcer) EnforceConversationAccess(user umodels.User, conversation cmodels.Conversation) (bool, error) {
|
||||
// Check for `read_all` permission
|
||||
@@ -106,8 +105,8 @@ func (e *Enforcer) EnforceConversationAccess(user umodels.User, conversation cmo
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Check for `read_team` permission
|
||||
allowed, err = e.enforcer.Enforce(strconv.Itoa(user.ID), "conversations", "read_team")
|
||||
// Check for `read_assigned` permission
|
||||
allowed, err = e.enforcer.Enforce(strconv.Itoa(user.ID), "conversations", "read_assigned")
|
||||
if err != nil {
|
||||
return false, envelope.NewError(envelope.GeneralError, "Error checking permissions", nil)
|
||||
}
|
||||
@@ -121,3 +120,20 @@ func (e *Enforcer) EnforceConversationAccess(user umodels.User, conversation cmo
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// EnforceMediaAccess checks for read access on linked model to media.
|
||||
func (e *Enforcer) EnforceMediaAccess(user umodels.User, model string) (bool, error) {
|
||||
switch model {
|
||||
case "messages":
|
||||
allowed, err := e.Enforce(user, model, "read")
|
||||
if err != nil {
|
||||
return false, envelope.NewError(envelope.GeneralError, "Error checking permissions", nil)
|
||||
}
|
||||
if !allowed {
|
||||
return false, envelope.NewError(envelope.UnauthorizedError, "Permission denied", nil)
|
||||
}
|
||||
default:
|
||||
return true, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
124
internal/authz/models/models.go
Normal file
124
internal/authz/models/models.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package models
|
||||
|
||||
const (
|
||||
// Conversation
|
||||
PermConversationsReadAll = "conversations:read_all"
|
||||
PermConversationsReadUnassigned = "conversations:read_unassigned"
|
||||
PermConversationsReadAssigned = "conversations:read_assigned"
|
||||
PermConversationsRead = "conversations:read"
|
||||
PermConversationsUpdateUserAssignee = "conversations:update_user_assignee"
|
||||
PermConversationsUpdateTeamAssignee = "conversations:update_team_assignee"
|
||||
PermConversationsUpdatePriority = "conversations:update_priority"
|
||||
PermConversationsUpdateStatus = "conversations:update_status"
|
||||
PermConversationsUpdateTags = "conversations:update_tags"
|
||||
PermMessagesRead = "messages:read"
|
||||
PermMessagesWrite = "messages:write"
|
||||
|
||||
// Conversation Status
|
||||
PermStatusRead = "status:read"
|
||||
PermStatusWrite = "status:write"
|
||||
PermStatusDelete = "status:delete"
|
||||
|
||||
// Admin
|
||||
PermAdminRead = "admin:read"
|
||||
|
||||
// Settings
|
||||
PermSettingsGeneralWrite = "settings_general:write"
|
||||
PermSettingsNotificationsWrite = "settings_notifications:write"
|
||||
PermSettingsNotificationsRead = "settings_notifications:read"
|
||||
|
||||
// OpenID Connect SSO
|
||||
PermOIDCRead = "oidc:read"
|
||||
PermOIDCWrite = "oidc:write"
|
||||
PermOIDCDelete = "oidc:delete"
|
||||
|
||||
// Tags
|
||||
PermTagsWrite = "tags:write"
|
||||
PermTagsDelete = "tags:delete"
|
||||
|
||||
// Canned Responses
|
||||
PermCannedResponsesWrite = "canned_responses:write"
|
||||
PermCannedResponsesDelete = "canned_responses:delete"
|
||||
|
||||
// Dashboard
|
||||
PermDashboardGlobalRead = "dashboard_global:read"
|
||||
|
||||
// Users
|
||||
PermUsersRead = "users:read"
|
||||
PermUsersWrite = "users:write"
|
||||
|
||||
// Teams
|
||||
PermTeamsRead = "teams:read"
|
||||
PermTeamsWrite = "teams:write"
|
||||
|
||||
// Automations
|
||||
PermAutomationsRead = "automations:read"
|
||||
PermAutomationsWrite = "automations:write"
|
||||
PermAutomationsDelete = "automations:delete"
|
||||
|
||||
// Inboxes
|
||||
PermInboxesRead = "inboxes:read"
|
||||
PermInboxesWrite = "inboxes:write"
|
||||
PermInboxesDelete = "inboxes:delete"
|
||||
|
||||
// Roles
|
||||
PermRolesRead = "roles:read"
|
||||
PermRolesWrite = "roles:write"
|
||||
PermRolesDelete = "roles:delete"
|
||||
|
||||
// Templates
|
||||
PermTemplatesRead = "templates:read"
|
||||
PermTemplatesWrite = "templates:write"
|
||||
PermTemplatesDelete = "templates:delete"
|
||||
)
|
||||
|
||||
var validPermissions = map[string]struct{}{
|
||||
PermConversationsReadAll: {},
|
||||
PermConversationsReadUnassigned: {},
|
||||
PermConversationsReadAssigned: {},
|
||||
PermConversationsRead: {},
|
||||
PermConversationsUpdateUserAssignee: {},
|
||||
PermConversationsUpdateTeamAssignee: {},
|
||||
PermConversationsUpdatePriority: {},
|
||||
PermConversationsUpdateStatus: {},
|
||||
PermConversationsUpdateTags: {},
|
||||
PermMessagesRead: {},
|
||||
PermMessagesWrite: {},
|
||||
PermStatusRead: {},
|
||||
PermStatusWrite: {},
|
||||
PermStatusDelete: {},
|
||||
PermAdminRead: {},
|
||||
PermSettingsGeneralWrite: {},
|
||||
PermSettingsNotificationsWrite: {},
|
||||
PermSettingsNotificationsRead: {},
|
||||
PermOIDCRead: {},
|
||||
PermOIDCWrite: {},
|
||||
PermOIDCDelete: {},
|
||||
PermTagsWrite: {},
|
||||
PermTagsDelete: {},
|
||||
PermCannedResponsesWrite: {},
|
||||
PermCannedResponsesDelete: {},
|
||||
PermDashboardGlobalRead: {},
|
||||
PermUsersRead: {},
|
||||
PermUsersWrite: {},
|
||||
PermTeamsRead: {},
|
||||
PermTeamsWrite: {},
|
||||
PermAutomationsRead: {},
|
||||
PermAutomationsWrite: {},
|
||||
PermAutomationsDelete: {},
|
||||
PermInboxesRead: {},
|
||||
PermInboxesWrite: {},
|
||||
PermInboxesDelete: {},
|
||||
PermRolesRead: {},
|
||||
PermRolesWrite: {},
|
||||
PermRolesDelete: {},
|
||||
PermTemplatesRead: {},
|
||||
PermTemplatesWrite: {},
|
||||
PermTemplatesDelete: {},
|
||||
}
|
||||
|
||||
// IsValidPermission retuns true if it's a valid perm.
|
||||
func IsValidPermission(permission string) bool {
|
||||
_, exists := validPermissions[permission]
|
||||
return exists
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func New(teamManager *team.Manager, conversationManager *conversation.Manager, s
|
||||
// Run initiates the conversation assignment process and is to be invoked as a goroutine.
|
||||
// This function continuously assigns unassigned conversations to agents at regular intervals.
|
||||
func (e *Engine) Run(ctx context.Context) {
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
ticker := time.NewTicker(60 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -43,7 +44,7 @@ type Opts struct {
|
||||
|
||||
type ConversationStore interface {
|
||||
GetConversation(uuid string) (cmodels.Conversation, error)
|
||||
GetRecentConversations(t time.Time) ([]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, status []byte, actor umodels.User) error
|
||||
@@ -65,6 +66,7 @@ func New(systemUser umodels.User, opt Opts) (*Engine, error) {
|
||||
var (
|
||||
q queries
|
||||
e = &Engine{
|
||||
systemUser: systemUser,
|
||||
lo: opt.Lo,
|
||||
newConversationQ: make(chan string, 5000),
|
||||
updateConversationQ: make(chan string, 5000),
|
||||
@@ -108,9 +110,11 @@ func (e *Engine) Run(ctx context.Context) {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case conversationUUID := <-e.newConversationQ:
|
||||
e.lo.Info("evaluating new conversation rules", "uuid", conversationUUID)
|
||||
newConversationSemaphore <- struct{}{}
|
||||
go e.handleNewConversation(conversationUUID, newConversationSemaphore)
|
||||
case conversationUUID := <-e.updateConversationQ:
|
||||
e.lo.Info("evaluating conversation rules on update", "uuid", conversationUUID)
|
||||
updateConversationSemaphore <- struct{}{}
|
||||
go e.handleUpdateConversation(conversationUUID, updateConversationSemaphore)
|
||||
case <-ticker.C:
|
||||
@@ -216,11 +220,12 @@ func (e *Engine) handleTimeTrigger(semaphore chan struct{}) {
|
||||
defer func() { <-semaphore }()
|
||||
|
||||
thirtyDaysAgo := time.Now().Add(-30 * 24 * time.Hour)
|
||||
conversations, err := e.conversationStore.GetRecentConversations(thirtyDaysAgo)
|
||||
conversations, err := e.conversationStore.GetConversationsCreatedAfter(thirtyDaysAgo)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
@@ -249,26 +254,33 @@ func (e *Engine) EvaluateConversationUpdateRules(conversationUUID string) {
|
||||
// queryRules fetches automation rules from the database.
|
||||
func (e *Engine) queryRules() []models.Rule {
|
||||
var (
|
||||
rulesJSON []string
|
||||
rules []models.Rule
|
||||
rules []struct {
|
||||
Type string `db:"type"`
|
||||
Rules string `db:"rules"`
|
||||
}
|
||||
filteredRules []models.Rule
|
||||
)
|
||||
err := e.q.GetEnabledRules.Select(&rulesJSON)
|
||||
err := e.q.GetEnabledRules.Select(&rules)
|
||||
if err != nil {
|
||||
e.lo.Error("error fetching automation rules", "error", err)
|
||||
return rules
|
||||
return filteredRules
|
||||
}
|
||||
|
||||
e.lo.Debug("fetched rules from db", "count", len(rulesJSON))
|
||||
e.lo.Info("fetched rules from db", "count", len(rules))
|
||||
|
||||
for _, ruleJSON := range rulesJSON {
|
||||
for _, rule := range rules {
|
||||
var rulesBatch []models.Rule
|
||||
if err := json.Unmarshal([]byte(ruleJSON), &rulesBatch); err != nil {
|
||||
if err := json.Unmarshal([]byte(rule.Rules), &rulesBatch); err != nil {
|
||||
e.lo.Error("error unmarshalling rule JSON", "error", err)
|
||||
continue
|
||||
}
|
||||
rules = append(rules, rulesBatch...)
|
||||
// Set the Type for each rule in rulesBatch
|
||||
for i := range rulesBatch {
|
||||
rulesBatch[i].Type = rule.Type
|
||||
}
|
||||
filteredRules = append(filteredRules, rulesBatch...)
|
||||
}
|
||||
return rules
|
||||
return filteredRules
|
||||
}
|
||||
|
||||
// filterRulesByType filters rules by type.
|
||||
@@ -278,6 +290,7 @@ func (e *Engine) filterRulesByType(ruleType string) []models.Rule {
|
||||
|
||||
var filteredRules []models.Rule
|
||||
for _, rule := range e.rules {
|
||||
fmt.Println(rule)
|
||||
if rule.Type == ruleType {
|
||||
filteredRules = append(filteredRules, rule)
|
||||
}
|
||||
|
||||
@@ -14,29 +14,29 @@ import (
|
||||
// the corresponding actions are executed.
|
||||
func (e *Engine) evalConversationRules(rules []models.Rule, conversation cmodels.Conversation) {
|
||||
for _, rule := range rules {
|
||||
e.lo.Debug("evaluating rule for conversation", "rule", rule, "conversation_uuid", conversation.UUID)
|
||||
|
||||
// At max there can be only 2 groups.
|
||||
if len(rule.Groups) > 2 {
|
||||
e.lo.Warn("more than 2 groups found for rules")
|
||||
e.lo.Warn("WARNING: more than 2 groups found for rules skipping evaluation")
|
||||
continue
|
||||
}
|
||||
|
||||
var results []bool
|
||||
|
||||
for _, group := range rule.Groups {
|
||||
e.lo.Debug("evaluating group rule", "logical_op", group.LogicalOp)
|
||||
result := e.evaluateGroup(group.Rules, group.LogicalOp, conversation)
|
||||
e.lo.Debug("group evaluation status", "status", result)
|
||||
e.lo.Debug("evaluating group rules", "logical_op", group.LogicalOp, "result", result, "conversation_uuid", conversation.UUID)
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
if evaluateFinalResult(results, rule.GroupOperator) {
|
||||
e.lo.Debug("rule fully evaluated, executing actions")
|
||||
e.lo.Debug("rule evaluation successfull executing actions", "conversation_uuid", conversation.UUID)
|
||||
// All group rule evaluations successful, execute the actions.
|
||||
for _, action := range rule.Actions {
|
||||
e.applyAction(action, conversation)
|
||||
}
|
||||
} else {
|
||||
e.lo.Debug("rule evaluation failed, NOT executing actions")
|
||||
e.lo.Debug("rule evaluation failed", "conversation_uuid", conversation.UUID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,16 +106,16 @@ func (e *Engine) evaluateRule(rule models.RuleDetail, conversation cmodels.Conve
|
||||
valueToCompare = conversation.Status.String
|
||||
case models.ConversationFieldPriority:
|
||||
valueToCompare = conversation.Priority.String
|
||||
case models.ConversationFieldAssignedTeamID:
|
||||
case models.ConversationFieldAssignedTeam:
|
||||
if conversation.AssignedTeamID.Valid {
|
||||
valueToCompare = strconv.Itoa(conversation.AssignedTeamID.Int)
|
||||
}
|
||||
case models.ConversationFieldAssignedUserID:
|
||||
case models.ConversationFieldAssignedUser:
|
||||
if conversation.AssignedUserID.Valid {
|
||||
valueToCompare = strconv.Itoa(conversation.AssignedUserID.Int)
|
||||
}
|
||||
default:
|
||||
e.lo.Error("rule field not recognized", "field", rule.Field)
|
||||
e.lo.Error("unrecognized rule field", "field", rule.Field)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -124,8 +124,9 @@ func (e *Engine) evaluateRule(rule models.RuleDetail, conversation cmodels.Conve
|
||||
rule.Value = strings.ToLower(rule.Value)
|
||||
}
|
||||
|
||||
e.lo.Debug("comparing values", "conversation_value", valueToCompare, "rule_value", rule.Value)
|
||||
e.lo.Debug("evaluating rule", "rule_field", rule.Field, "rule_operator", rule.Operator, "rule_value", rule.Value, "compared_with", valueToCompare, "coversation_uuid", conversation.UUID)
|
||||
|
||||
// Compare with set operator.
|
||||
switch rule.Operator {
|
||||
case models.RuleEquals:
|
||||
conditionMet = valueToCompare == rule.Value
|
||||
@@ -140,9 +141,10 @@ func (e *Engine) evaluateRule(rule models.RuleDetail, conversation cmodels.Conve
|
||||
case models.RuleNotSet:
|
||||
conditionMet = len(valueToCompare) == 0
|
||||
default:
|
||||
e.lo.Error("rule logical operator not recognized", "operator", rule.Operator)
|
||||
e.lo.Error("unrecognized rule logical operator", "operator", rule.Operator)
|
||||
return false
|
||||
}
|
||||
e.lo.Debug("rule conditions met", "coversation_uuid", conversation.UUID)
|
||||
return conditionMet
|
||||
}
|
||||
|
||||
@@ -150,6 +152,7 @@ func (e *Engine) evaluateRule(rule models.RuleDetail, conversation cmodels.Conve
|
||||
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, err := strconv.Atoi(action.Action)
|
||||
if err != nil {
|
||||
e.lo.Error("error converting string to int", "string", action.Action, "error", err)
|
||||
@@ -159,6 +162,7 @@ func (e *Engine) applyAction(action models.RuleAction, conversation cmodels.Conv
|
||||
return err
|
||||
}
|
||||
case models.ActionAssignUser:
|
||||
e.lo.Debug("executing assign user action", "value", action.Action, "conversation_uuid", conversation.UUID)
|
||||
agentID, err := strconv.Atoi(action.Action)
|
||||
if err != nil {
|
||||
e.lo.Error("error converting string to int", "string", action.Action, "error", err)
|
||||
@@ -168,10 +172,12 @@ func (e *Engine) applyAction(action models.RuleAction, conversation cmodels.Conv
|
||||
return err
|
||||
}
|
||||
case models.ActionSetPriority:
|
||||
e.lo.Debug("executing set priority action", "value", action.Action, "conversation_uuid", conversation.UUID)
|
||||
if err := e.conversationStore.UpdateConversationPriority(conversation.UUID, []byte(action.Action), e.systemUser); err != nil {
|
||||
return err
|
||||
}
|
||||
case models.ActionSetStatus:
|
||||
e.lo.Debug("executing set status action", "value", action.Action, "conversation_uuid", conversation.UUID)
|
||||
if err := e.conversationStore.UpdateConversationStatus(conversation.UUID, []byte(action.Action), e.systemUser); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -15,22 +15,22 @@ const (
|
||||
OperatorOR = "OR"
|
||||
|
||||
RuleContains = "contains"
|
||||
RuleNotContains = "not_contains"
|
||||
RuleNotContains = "not contains"
|
||||
RuleEquals = "equals"
|
||||
RuleNotEqual = "not_equals"
|
||||
RuleNotEqual = "not equals"
|
||||
RuleSet = "set"
|
||||
RuleNotSet = "not_set"
|
||||
RuleNotSet = "not set"
|
||||
|
||||
RuleTypeNewConversation = "new_conversation"
|
||||
RuleTypeConversationUpdate = "conversation_update"
|
||||
RuleTypeTimeTrigger = "time_trigger"
|
||||
|
||||
ConversationFieldSubject = "subject"
|
||||
ConversationFieldContent = "content"
|
||||
ConversationFieldStatus = "status"
|
||||
ConversationFieldPriority = "priority"
|
||||
ConversationFieldAssignedUserID = "assigned_user_id"
|
||||
ConversationFieldAssignedTeamID = "assigned_team_id"
|
||||
ConversationFieldSubject = "subject"
|
||||
ConversationFieldContent = "content"
|
||||
ConversationFieldStatus = "status"
|
||||
ConversationFieldPriority = "priority"
|
||||
ConversationFieldAssignedUser = "assigned_user"
|
||||
ConversationFieldAssignedTeam = "assigned_team"
|
||||
)
|
||||
|
||||
// RuleRecord represents a rule record in the database
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
-- name: get-enabled-rules
|
||||
select
|
||||
select
|
||||
type,
|
||||
rules
|
||||
from automation_rules where disabled is not TRUE;
|
||||
|
||||
|
||||
@@ -142,10 +142,10 @@ type queries struct {
|
||||
GetConversationID *sqlx.Stmt `query:"get-conversation-id"`
|
||||
GetConversationUUID *sqlx.Stmt `query:"get-conversation-uuid"`
|
||||
GetConversation *sqlx.Stmt `query:"get-conversation"`
|
||||
GetRecentConversations *sqlx.Stmt `query:"get-recent-conversations"`
|
||||
GetConversationsCreatedAfter *sqlx.Stmt `query:"get-conversations-created-after"`
|
||||
GetUnassignedConversations *sqlx.Stmt `query:"get-unassigned-conversations"`
|
||||
GetConversations string `query:"get-conversations"`
|
||||
GetConversationsUUIDs string `query:"get-conversations-uuids"`
|
||||
GetConversationsListUUIDs string `query:"get-conversations-list-uuids"`
|
||||
GetConversationParticipants *sqlx.Stmt `query:"get-conversation-participants"`
|
||||
GetAssignedConversations *sqlx.Stmt `query:"get-assigned-conversations"`
|
||||
|
||||
@@ -203,10 +203,10 @@ func (c *Manager) GetConversation(uuid string) (models.Conversation, error) {
|
||||
return conversation, nil
|
||||
}
|
||||
|
||||
// GetRecentConversations retrieves conversations created after the specified time.
|
||||
func (c *Manager) GetRecentConversations(time time.Time) ([]models.Conversation, error) {
|
||||
// GetConversationsCreatedAfter retrieves conversations created after the specified time.
|
||||
func (c *Manager) GetConversationsCreatedAfter(time time.Time) ([]models.Conversation, error) {
|
||||
var conversations = make([]models.Conversation, 0)
|
||||
if err := c.q.GetRecentConversations.Select(&conversations, time); err != nil {
|
||||
if err := c.q.GetConversationsCreatedAfter.Select(&conversations, time); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
c.lo.Error("conversations not found", "created_after", time)
|
||||
return conversations, err
|
||||
@@ -334,11 +334,11 @@ func (c *Manager) GetConversations(userID int, typ, order, orderBy string, page,
|
||||
return conversations, nil
|
||||
}
|
||||
|
||||
// GetConversationUUIDs retrieves the UUIDs of conversations based on user ID, type, and optional filtering, ordering, and pagination.
|
||||
func (c *Manager) GetConversationUUIDs(userID, page, pageSize int, typ string) ([]string, error) {
|
||||
// GetConversationsListUUIDs retrieves the UUIDs of conversations list.
|
||||
func (c *Manager) GetConversationsListUUIDs(userID, page, pageSize int, typ string) ([]string, error) {
|
||||
var ids = make([]string, 0)
|
||||
|
||||
query, qArgs, err := c.generateConversationsListQuery(userID, c.q.GetConversationsUUIDs, typ, "", "", page, pageSize)
|
||||
query, qArgs, err := c.generateConversationsListQuery(userID, c.q.GetConversationsListUUIDs, typ, "", "", page, pageSize)
|
||||
if err != nil {
|
||||
c.lo.Error("error generating conversations query", "error", err)
|
||||
return ids, err
|
||||
|
||||
@@ -412,8 +412,6 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error {
|
||||
// Evaluate automation rules for this conversation.
|
||||
if isNewConversation {
|
||||
m.automation.EvaluateNewConversationRules(in.Message.ConversationUUID)
|
||||
} else {
|
||||
m.automation.EvaluateConversationUpdateRules(in.Message.ConversationUUID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ FROM conversations c
|
||||
LEFT JOIN priority p ON c.priority_id = p.id
|
||||
WHERE 1=1 %s
|
||||
|
||||
-- name: get-conversations-uuids
|
||||
-- name: get-conversations-list-uuids
|
||||
SELECT
|
||||
c.uuid
|
||||
FROM conversations c
|
||||
@@ -81,7 +81,7 @@ LEFT JOIN status s ON c.status_id = s.id
|
||||
LEFT JOIN priority p ON c.priority_id = p.id
|
||||
WHERE c.uuid = $1;
|
||||
|
||||
-- name: get-recent-conversations
|
||||
-- name: get-conversations-created-after
|
||||
SELECT
|
||||
c.created_at,
|
||||
c.updated_at,
|
||||
@@ -111,7 +111,7 @@ LEFT JOIN users u ON u.id = c.assigned_user_id
|
||||
LEFT JOIN teams at ON at.id = c.assigned_team_id
|
||||
LEFT JOIN status s ON c.status_id = s.id
|
||||
LEFT JOIN priority p ON c.priority_id = p.id
|
||||
WHERE c.created_at > $1 AND c.uuid = 'e2f69c9f-17f5-4d09-9aae-12c2a79046a2';
|
||||
WHERE c.created_at > $1;
|
||||
|
||||
-- name: get-conversation-id
|
||||
SELECT id from conversations where uuid = $1;
|
||||
@@ -428,7 +428,9 @@ WHERE source_id = ANY($1::text []);
|
||||
-- name: get-conversation-by-message-id
|
||||
SELECT
|
||||
c.id,
|
||||
c.uuid
|
||||
c.uuid,
|
||||
c.assigned_team_id,
|
||||
c.assigned_user_id
|
||||
FROM messages m
|
||||
JOIN conversations c ON m.conversation_id = c.id
|
||||
WHERE m.id = $1;
|
||||
|
||||
@@ -4,6 +4,7 @@ package role
|
||||
import (
|
||||
"embed"
|
||||
|
||||
amodels "github.com/abhinavxd/artemis/internal/authz/models"
|
||||
"github.com/abhinavxd/artemis/internal/dbutil"
|
||||
"github.com/abhinavxd/artemis/internal/envelope"
|
||||
"github.com/abhinavxd/artemis/internal/role/models"
|
||||
@@ -83,6 +84,9 @@ func (t *Manager) Delete(id int) error {
|
||||
|
||||
// Create creates a new role.
|
||||
func (u *Manager) Create(r models.Role) error {
|
||||
if !u.areValidPerms(r.Permissions) {
|
||||
return envelope.NewError(envelope.InputError, "Invalid permissions", nil)
|
||||
}
|
||||
if _, err := u.q.Insert.Exec(r.Name, r.Description, pq.Array(r.Permissions)); err != nil {
|
||||
u.lo.Error("error inserting role", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, "Error creating role", nil)
|
||||
@@ -92,9 +96,23 @@ func (u *Manager) Create(r models.Role) error {
|
||||
|
||||
// Update updates an existing role.
|
||||
func (u *Manager) Update(id int, r models.Role) error {
|
||||
if !u.areValidPerms(r.Permissions) {
|
||||
return envelope.NewError(envelope.InputError, "Invalid permissions", nil)
|
||||
}
|
||||
if _, err := u.q.Update.Exec(id, r.Name, r.Description, pq.Array(r.Permissions)); err != nil {
|
||||
u.lo.Error("error updating role", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, "Error updating role", nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// areValidPerms returns true if the permissions are one of the valid permissions
|
||||
func (u *Manager) areValidPerms(permissions []string) bool {
|
||||
for _, perm := range permissions {
|
||||
if !amodels.IsValidPermission(perm) {
|
||||
u.lo.Error("invalid permission", "permission", perm)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -5,7 +5,7 @@ ORDER BY u.updated_at DESC;
|
||||
|
||||
-- name: get-users-compact
|
||||
SELECT u.id, u.first_name, u.last_name, u.disabled
|
||||
FROM users u
|
||||
FROM users u where u.email != 'System'
|
||||
ORDER BY u.updated_at DESC;
|
||||
|
||||
-- name: get-email
|
||||
|
||||
@@ -299,7 +299,7 @@ func promptAndHashPassword() ([]byte, error) {
|
||||
for {
|
||||
fmt.Print("Please set System admin password (min 8, max 50 characters, at least 1 uppercase letter, 1 number): ")
|
||||
fmt.Scanf("%s", &password)
|
||||
if isStringSystemUserPassword(password) {
|
||||
if isStrongSystemUserPassword(password) {
|
||||
break
|
||||
}
|
||||
fmt.Println("Password does not meet the strength requirements.")
|
||||
@@ -322,8 +322,8 @@ func updateSystemUserPassword(db *sqlx.DB, hashedPassword []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// isStringSystemUserPassword checks if the password meets the required strength for system user.
|
||||
func isStringSystemUserPassword(password string) bool {
|
||||
// isStrongSystemUserPassword checks if the password meets the required strength for system user.
|
||||
func isStrongSystemUserPassword(password string) bool {
|
||||
if len(password) < MinSystemUserPasswordLen || len(password) > MaxSystemUserPasswordLen {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ type SafeBool struct {
|
||||
func (b *SafeBool) Set(value bool) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.flag = value
|
||||
b.flag = value
|
||||
}
|
||||
|
||||
// Get returns the value of the SafeBool.
|
||||
@@ -50,6 +50,9 @@ type Client struct {
|
||||
// To prevent pushes to the channel.
|
||||
Closed SafeBool
|
||||
|
||||
// Currently opened conversation UUID.
|
||||
CurrentConversationUUID string
|
||||
|
||||
// Buffered channel of outbound ws messages.
|
||||
Send chan models.WSMessage
|
||||
}
|
||||
@@ -118,13 +121,27 @@ func (c *Client) processIncomingMessage(data []byte) {
|
||||
|
||||
// Add the new subscriptions.
|
||||
for page := 1; page <= maxConversationsPagesToSub; page++ {
|
||||
conversationUUIDs, err := c.Hub.conversationStore.GetConversationUUIDs(c.ID, page, maxConversationsPageSize, subReq.Type)
|
||||
conversationUUIDs, err := c.Hub.conversationStore.GetConversationsListUUIDs(c.ID, page, maxConversationsPageSize, subReq.Type)
|
||||
if err != nil {
|
||||
log.Println("error fetching conversation ids", err)
|
||||
continue
|
||||
}
|
||||
c.SubscribeConversations(c.ID, conversationUUIDs)
|
||||
}
|
||||
case models.ActionSetCurrentConversation:
|
||||
var subReq models.ConversationCurrentSet
|
||||
if err := json.Unmarshal(data, &subReq); err != nil {
|
||||
c.SendError("error unmarshalling request")
|
||||
return
|
||||
}
|
||||
|
||||
if c.CurrentConversationUUID != subReq.UUID {
|
||||
c.UnsubscribeConversation(c.ID, c.CurrentConversationUUID)
|
||||
c.CurrentConversationUUID = subReq.UUID
|
||||
c.SubscribeConversations(c.ID, []string{subReq.UUID})
|
||||
}
|
||||
case models.ActionUnsetCurrentConversation:
|
||||
c.UnsubscribeConversation(c.ID, c.CurrentConversationUUID)
|
||||
c.CurrentConversationUUID = ""
|
||||
default:
|
||||
c.SendError("unknown action")
|
||||
}
|
||||
@@ -176,6 +193,22 @@ func (c *Client) RemoveAllUserConversationSubscriptions(userID int) {
|
||||
}
|
||||
}
|
||||
|
||||
// UnsubscribeConversation unsubscribes the user from the specified conversation.
|
||||
func (c *Client) UnsubscribeConversation(userID int, conversationUUID string) {
|
||||
if userIDs, ok := c.Hub.conversationSubs[conversationUUID]; ok {
|
||||
for i, id := range userIDs {
|
||||
if id == userID {
|
||||
c.Hub.conversationSubs[conversationUUID] = append(userIDs[:i], userIDs[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
// Remove the conversation from the map if no users are subscribed
|
||||
if len(c.Hub.conversationSubs[conversationUUID]) == 0 {
|
||||
delete(c.Hub.conversationSubs, conversationUUID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SendError sends an error message to client.
|
||||
func (c *Client) SendError(msg string) {
|
||||
out := models.Message{
|
||||
|
||||
@@ -2,7 +2,10 @@ package models
|
||||
|
||||
// Action constants for WebSocket messages.
|
||||
const (
|
||||
ActionConversationsListSub = "conversations_list_sub"
|
||||
ActionConversationsListSub = "conversations_list_sub"
|
||||
ActionSetCurrentConversation = "conversation_set_current"
|
||||
ActionUnsetCurrentConversation = "conversation_unset_current"
|
||||
|
||||
MessageTypeMessagePropUpdate = "message_prop_update"
|
||||
MessageTypeConversationPropertyUpdate = "conversation_prop_update"
|
||||
MessageTypeNewMessage = "new_message"
|
||||
@@ -38,3 +41,8 @@ type ConversationsListSubscribe struct {
|
||||
Type string `json:"type"`
|
||||
Filter string `json:"filter"`
|
||||
}
|
||||
|
||||
// ConversationCurrentSet represents a request to set current conversation
|
||||
type ConversationCurrentSet struct {
|
||||
UUID string `json:"uuid"`
|
||||
}
|
||||
@@ -24,7 +24,7 @@ type Hub struct {
|
||||
|
||||
// ConversationStore defines the interface for retrieving conversation UUIDs.
|
||||
type ConversationStore interface {
|
||||
GetConversationUUIDs(userID, page, pageSize int, typ string) ([]string, error)
|
||||
GetConversationsListUUIDs(userID, page, pageSize int, typ string) ([]string, error)
|
||||
}
|
||||
|
||||
// NewHub creates a new Hub.
|
||||
|
||||
@@ -120,13 +120,14 @@ CREATE TABLE roles (
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
-- Create roles.
|
||||
|
||||
-- Roles.
|
||||
INSERT INTO roles
|
||||
(permissions, "name", description)
|
||||
VALUES('{conversations:read_unassigned,conversations:read_all,conversations:read,conversations:read_assigned,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write,templates:write,templates:read,roles:delete,roles:write,roles:read,inboxes:delete,inboxes:write,inboxes:read,automations:write,automations:delete,automations:read,teams:write,teams:read,users:write,users:read,dashboard_global:read,canned_responses:delete,tags:delete,canned_responses:write,tags:write,status:delete,status:write,status:read,oidc:delete,oidc:read,oidc:write,settings_notifications:read,settings_notifications:write,settings_general:write,templates:delete,admin:read}', 'Admin', 'Role for users who have access to the admin panel.');
|
||||
VALUES('{conversations:read,conversations:read_unassigned,conversations:read_assigned,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write}', 'Agent', 'Role for all agents with limited access to conversations.');
|
||||
INSERT INTO roles
|
||||
(permissions, "name", description)
|
||||
VALUES('{conversations:read,conversations:read_unassigned,conversations:read_assigned,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,status:write,status:delete,tags:write,tags:delete,canned_responses:write,canned_responses:delete,dashboard:global,users:write,users:read,teams:read,teams:write,automations:read,automations:write,automations:delete,inboxes:read,inboxes:write,inboxes:delete,roles:read,roles:write,roles:delete,templates:read,templates:write,messages:read,messages:write,dashboard_global:read,oidc:delete,status:read,oidc:write,settings_notifications:read,oidc:read,settings_general:write,settings_notifications:write,conversations:read_all,templates:delete}', 'Agent', 'Role for all agents with limited access.');
|
||||
VALUES('{conversations:read_unassigned,conversations:read_all,conversations:read,conversations:read_assigned,conversations:update_user_assignee,conversations:update_team_assignee,conversations:update_priority,conversations:update_status,conversations:update_tags,messages:read,messages:write,templates:write,templates:read,roles:delete,roles:write,roles:read,inboxes:delete,inboxes:write,inboxes:read,automations:write,automations:delete,automations:read,teams:write,teams:read,users:write,users:read,dashboard_global:read,canned_responses:delete,tags:delete,canned_responses:write,tags:write,status:delete,status:write,status:read,oidc:delete,oidc:read,oidc:write,settings_notifications:read,settings_notifications:write,settings_general:write,templates:delete,admin:read}', 'Admin', 'Role for users who have complete access to everything.');
|
||||
|
||||
DROP TABLE IF EXISTS settings CASCADE;
|
||||
CREATE TABLE settings (
|
||||
|
||||
Reference in New Issue
Block a user