some more commits?

This commit is contained in:
Abhinav Raut
2024-07-01 03:08:45 +05:30
parent 6d027c92ab
commit d7fb9be211
64 changed files with 1546 additions and 1013 deletions

View File

@@ -38,7 +38,7 @@ func handleAttachmentUpload(r *fastglue.Request) error {
file, err := form.File["files"][0].Open() file, err := form.File["files"][0].Open()
srcFileName := form.File["files"][0].Filename srcFileName := form.File["files"][0].Filename
srcContentType := form.File["files"][0].Header.Get("Content-Type") srcContentType := form.File["files"][0].Header.Get("Content-Type")
srcFileSize := form.File["files"][0].Size srcFileSize := strconv.FormatInt(form.File["files"][0].Size, 10)
srcDisposition := form.Value["disposition"][0] srcDisposition := form.Value["disposition"][0]
if err != nil { if err != nil {
app.lo.Error("reading file into the memory", "error", err) app.lo.Error("reading file into the memory", "error", err)
@@ -57,7 +57,7 @@ func handleAttachmentUpload(r *fastglue.Request) error {
// Reset the ptr. // Reset the ptr.
file.Seek(0, 0) file.Seek(0, 0)
url, mediaUUID, _, err := app.attachmentMgr.Upload("" /**message uuid**/, srcFileName, srcContentType, srcDisposition, strconv.FormatInt(srcFileSize, 10), file) url, mediaUUID, _, err := app.attachmentMgr.Upload("" /**message uuid**/, srcFileName, srcContentType, srcDisposition, srcFileSize, file)
if err != nil { if err != nil {
app.lo.Error("error uploading file", "error", err) app.lo.Error("error uploading file", "error", err)
return r.SendErrorEnvelope(http.StatusInternalServerError, "Error uploading file", nil, "GeneralException") return r.SendErrorEnvelope(http.StatusInternalServerError, "Error uploading file", nil, "GeneralException")
@@ -68,7 +68,7 @@ func handleAttachmentUpload(r *fastglue.Request) error {
"uuid": mediaUUID, "uuid": mediaUUID,
"content_type": srcContentType, "content_type": srcContentType,
"name": srcFileName, "name": srcFileName,
"size": strconv.FormatInt(srcFileSize, 10), "size": srcFileSize,
}) })
} }

View File

@@ -125,16 +125,16 @@ func handleUpdateAssignee(r *fastglue.Request) error {
userUUID = r.RequestCtx.UserValue("user_uuid").(string) userUUID = r.RequestCtx.UserValue("user_uuid").(string)
) )
if err := app.conversationMgr.UpdateAssignee(convUUID, assigneeUUID, assigneeType); err != nil { if assigneeType == "user" {
return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "") if err := app.conversationMgr.UpdateUserAssignee(convUUID, assigneeUUID); err != nil {
} return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "")
}
if assigneeType == "agent" { app.msgMgr.RecordAssigneeUserChange(convUUID, string(assigneeUUID), userUUID)
app.msgMgr.RecordAssigneeUserChange(string(assigneeUUID), convUUID, userUUID) } else if assigneeType == "team" {
} if err := app.conversationMgr.UpdateTeamAssignee(convUUID, assigneeUUID); err != nil {
return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "")
if assigneeType == "team" { }
app.msgMgr.RecordAssigneeTeamChange(string(assigneeUUID), convUUID, userUUID) app.msgMgr.RecordAssigneeTeamChange(convUUID, string(assigneeUUID), userUUID)
} }
return r.SendEnvelope("ok") return r.SendEnvelope("ok")
@@ -188,7 +188,7 @@ func handlAddConversationTags(r *fastglue.Request) error {
return r.SendErrorEnvelope(http.StatusInternalServerError, "error adding tags", nil, "") return r.SendErrorEnvelope(http.StatusInternalServerError, "error adding tags", nil, "")
} }
if err := app.conversationTagsMgr.AddTags(uuid, tagIDs); err != nil { if err := app.conversationMgr.AddTags(uuid, tagIDs); err != nil {
return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "") return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "")
} }
return r.SendEnvelope("ok") return r.SendEnvelope("ok")

View File

@@ -28,7 +28,6 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/api/canned-responses", auth(handleGetCannedResponses)) g.GET("/api/canned-responses", auth(handleGetCannedResponses))
g.GET("/api/attachment/{conversation_uuid}", auth(handleGetAttachment)) g.GET("/api/attachment/{conversation_uuid}", auth(handleGetAttachment))
g.GET("/api/users/me", auth(handleGetCurrentUser)) g.GET("/api/users/me", auth(handleGetCurrentUser))
g.GET("/api/users/filters/{page}", auth(handleGetUserFilters))
g.GET("/api/users", auth(handleGetUsers)) g.GET("/api/users", auth(handleGetUsers))
g.GET("/api/teams", auth(handleGetTeams)) g.GET("/api/teams", auth(handleGetTeams))
g.GET("/api/tags", auth(handleGetTags)) g.GET("/api/tags", auth(handleGetTags))

View File

@@ -8,21 +8,22 @@ import (
"github.com/abhinavxd/artemis/internal/attachment" "github.com/abhinavxd/artemis/internal/attachment"
"github.com/abhinavxd/artemis/internal/attachment/stores/s3" "github.com/abhinavxd/artemis/internal/attachment/stores/s3"
uauth "github.com/abhinavxd/artemis/internal/auth"
"github.com/abhinavxd/artemis/internal/autoassigner" "github.com/abhinavxd/artemis/internal/autoassigner"
"github.com/abhinavxd/artemis/internal/automation" "github.com/abhinavxd/artemis/internal/automation"
"github.com/abhinavxd/artemis/internal/cannedresp" "github.com/abhinavxd/artemis/internal/cannedresp"
"github.com/abhinavxd/artemis/internal/contact" "github.com/abhinavxd/artemis/internal/contact"
"github.com/abhinavxd/artemis/internal/conversation" "github.com/abhinavxd/artemis/internal/conversation"
convtag "github.com/abhinavxd/artemis/internal/conversation/tag"
"github.com/abhinavxd/artemis/internal/inbox" "github.com/abhinavxd/artemis/internal/inbox"
"github.com/abhinavxd/artemis/internal/inbox/channel/email" "github.com/abhinavxd/artemis/internal/inbox/channel/email"
"github.com/abhinavxd/artemis/internal/initz" "github.com/abhinavxd/artemis/internal/initz"
"github.com/abhinavxd/artemis/internal/message" "github.com/abhinavxd/artemis/internal/message"
"github.com/abhinavxd/artemis/internal/rbac" notifier "github.com/abhinavxd/artemis/internal/notification"
emailnotifier "github.com/abhinavxd/artemis/internal/notification/providers/email"
"github.com/abhinavxd/artemis/internal/tag" "github.com/abhinavxd/artemis/internal/tag"
"github.com/abhinavxd/artemis/internal/team" "github.com/abhinavxd/artemis/internal/team"
"github.com/abhinavxd/artemis/internal/template"
"github.com/abhinavxd/artemis/internal/user" "github.com/abhinavxd/artemis/internal/user"
"github.com/abhinavxd/artemis/internal/user/filterstore"
"github.com/abhinavxd/artemis/internal/ws" "github.com/abhinavxd/artemis/internal/ws"
"github.com/go-redis/redis/v8" "github.com/go-redis/redis/v8"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
@@ -112,8 +113,8 @@ func initUserDB(DB *sqlx.DB, lo *logf.Logger) *user.Manager {
return mgr return mgr
} }
func initConversations(db *sqlx.DB, lo *logf.Logger) *conversation.Manager { func initConversations(hub *ws.Hub, db *sqlx.DB, lo *logf.Logger) *conversation.Manager {
c, err := conversation.New(conversation.Opts{ c, err := conversation.New(hub, conversation.Opts{
DB: db, DB: db,
Lo: lo, Lo: lo,
ReferenceNumPattern: ko.String("app.constants.conversation_reference_number_pattern"), ReferenceNumPattern: ko.String("app.constants.conversation_reference_number_pattern"),
@@ -124,17 +125,6 @@ func initConversations(db *sqlx.DB, lo *logf.Logger) *conversation.Manager {
return c return c
} }
func initConversationTags(db *sqlx.DB, lo *logf.Logger) *convtag.Manager {
mgr, err := convtag.New(convtag.Opts{
DB: db,
Lo: lo,
})
if err != nil {
log.Fatalf("error initializing conversation tags: %v", err)
}
return mgr
}
func initTags(db *sqlx.DB, lo *logf.Logger) *tag.Manager { func initTags(db *sqlx.DB, lo *logf.Logger) *tag.Manager {
mgr, err := tag.New(tag.Opts{ mgr, err := tag.New(tag.Opts{
DB: db, DB: db,
@@ -168,6 +158,14 @@ func initContactManager(db *sqlx.DB, lo *logf.Logger) *contact.Manager {
return m return m
} }
func initTemplateMgr(db *sqlx.DB) *template.Manager {
m, err := template.New(db)
if err != nil {
log.Fatalf("error initializing template manager: %v", err)
}
return m
}
func initMessages(db *sqlx.DB, func initMessages(db *sqlx.DB,
lo *logf.Logger, lo *logf.Logger,
wsHub *ws.Hub, wsHub *ws.Hub,
@@ -177,7 +175,9 @@ func initMessages(db *sqlx.DB,
attachmentMgr *attachment.Manager, attachmentMgr *attachment.Manager,
conversationMgr *conversation.Manager, conversationMgr *conversation.Manager,
inboxMgr *inbox.Manager, inboxMgr *inbox.Manager,
automationEngine *automation.Engine) *message.Manager { automationEngine *automation.Engine,
templateManager *template.Manager,
) *message.Manager {
mgr, err := message.New( mgr, err := message.New(
wsHub, wsHub,
userMgr, userMgr,
@@ -187,6 +187,7 @@ func initMessages(db *sqlx.DB,
inboxMgr, inboxMgr,
conversationMgr, conversationMgr,
automationEngine, automationEngine,
templateManager,
message.Opts{ message.Opts{
DB: db, DB: db,
Lo: lo, Lo: lo,
@@ -268,28 +269,36 @@ func initAutomationEngine(db *sqlx.DB, lo *logf.Logger) *automation.Engine {
return engine return engine
} }
func initAutoAssignmentEngine(teamMgr *team.Manager, convMgr *conversation.Manager, msgMgr *message.Manager, lo *logf.Logger) *autoassigner.Engine { func initAutoAssignmentEngine(teamMgr *team.Manager, convMgr *conversation.Manager, msgMgr *message.Manager,
engine, err := autoassigner.New(teamMgr, convMgr, msgMgr, lo) notifier notifier.Notifier, hub *ws.Hub, lo *logf.Logger) *autoassigner.Engine {
engine, err := autoassigner.New(teamMgr, convMgr, msgMgr, notifier, hub, lo)
if err != nil { if err != nil {
log.Fatalf("error initializing auto assignment engine: %v", err) log.Fatalf("error initializing auto assignment engine: %v", err)
} }
return engine return engine
} }
func initRBACEngine(db *sqlx.DB) *rbac.Engine { func initRBACEngine(db *sqlx.DB) *uauth.Engine {
engine, err := rbac.New(db) engine, err := uauth.New(db, &logf.Logger{})
if err != nil { if err != nil {
log.Fatalf("error initializing rbac enginer: %v", err) log.Fatalf("error initializing rbac enginer: %v", err)
} }
return engine return engine
} }
func initUserFilterMgr(db *sqlx.DB) *filterstore.Manager { func initNotifier(userStore notifier.UserStore, templateRenderer notifier.TemplateRenderer) notifier.Notifier {
filterMgr, err := filterstore.New(db) var smtpCfg email.SMTPConfig
if err != nil { if err := ko.UnmarshalWithConf("notification.provider.email", &smtpCfg, koanf.UnmarshalConf{Tag: "json"}); err != nil {
log.Fatalf("error initializing user filter manager: %v", err) log.Fatalf("error unmarshalling email notification provider config: %v", err)
} }
return filterMgr notifier, err := emailnotifier.New([]email.SMTPConfig{smtpCfg}, userStore, templateRenderer, emailnotifier.Opts{
Lo: initz.Logger(ko.MustString("app.log_level"), ko.MustString("app.env"), "email-notifier"),
FromEmail: ko.String("notification.provider.email.email_address"),
})
if err != nil {
log.Fatalf("error initializing email notifier: %v", err)
}
return notifier
} }
// initEmailInbox initializes the email inbox. // initEmailInbox initializes the email inbox.
@@ -327,7 +336,7 @@ func initEmailInbox(inboxRecord inbox.InboxRecord, store inbox.MessageStore) (in
return nil, err return nil, err
} }
log.Printf("`%s` inbox: `%s` successfully initalized. %d smtp servers. %d imap clients.", inboxRecord.Channel, inboxRecord.Name, len(config.SMTP), len(config.IMAP)) log.Printf("`%s` inbox successfully initalized. %d smtp servers. %d imap clients.", inboxRecord.Name, len(config.SMTP), len(config.IMAP))
return inbox, nil return inbox, nil
} }

View File

@@ -9,18 +9,16 @@ import (
"time" "time"
"github.com/abhinavxd/artemis/internal/attachment" "github.com/abhinavxd/artemis/internal/attachment"
uauth "github.com/abhinavxd/artemis/internal/auth"
"github.com/abhinavxd/artemis/internal/cannedresp" "github.com/abhinavxd/artemis/internal/cannedresp"
"github.com/abhinavxd/artemis/internal/contact" "github.com/abhinavxd/artemis/internal/contact"
"github.com/abhinavxd/artemis/internal/conversation" "github.com/abhinavxd/artemis/internal/conversation"
convtag "github.com/abhinavxd/artemis/internal/conversation/tag"
"github.com/abhinavxd/artemis/internal/inbox" "github.com/abhinavxd/artemis/internal/inbox"
"github.com/abhinavxd/artemis/internal/initz" "github.com/abhinavxd/artemis/internal/initz"
"github.com/abhinavxd/artemis/internal/message" "github.com/abhinavxd/artemis/internal/message"
"github.com/abhinavxd/artemis/internal/rbac"
"github.com/abhinavxd/artemis/internal/tag" "github.com/abhinavxd/artemis/internal/tag"
"github.com/abhinavxd/artemis/internal/team" "github.com/abhinavxd/artemis/internal/team"
"github.com/abhinavxd/artemis/internal/user" "github.com/abhinavxd/artemis/internal/user"
"github.com/abhinavxd/artemis/internal/user/filterstore"
"github.com/abhinavxd/artemis/internal/ws" "github.com/abhinavxd/artemis/internal/ws"
"github.com/knadh/koanf/v2" "github.com/knadh/koanf/v2"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
@@ -29,25 +27,31 @@ import (
"github.com/zerodha/logf" "github.com/zerodha/logf"
) )
var ko = koanf.New(".") var (
ko = koanf.New(".")
)
const (
// ANSI escape colour codes.
colourRed = "\x1b[31m"
colourGreen = "\x1b[32m"
)
// App is the global app context which is passed and injected in the http handlers. // App is the global app context which is passed and injected in the http handlers.
type App struct { type App struct {
constants consts constants consts
lo *logf.Logger lo *logf.Logger
cntctMgr *contact.Manager cntctMgr *contact.Manager
userMgr *user.Manager userMgr *user.Manager
teamMgr *team.Manager teamMgr *team.Manager
sessMgr *simplesessions.Manager sessMgr *simplesessions.Manager
tagMgr *tag.Manager tagMgr *tag.Manager
msgMgr *message.Manager msgMgr *message.Manager
rbac *rbac.Engine rbac *uauth.Engine
userFilterMgr *filterstore.Manager inboxMgr *inbox.Manager
inboxMgr *inbox.Manager attachmentMgr *attachment.Manager
attachmentMgr *attachment.Manager cannedRespMgr *cannedresp.Manager
cannedRespMgr *cannedresp.Manager conversationMgr *conversation.Manager
conversationMgr *conversation.Manager
conversationTagsMgr *convtag.Manager
} }
func main() { func main() {
@@ -58,62 +62,65 @@ func main() {
initz.Config(ko) initz.Config(ko)
var ( var (
shutdownCh = make(chan struct{}) shutdownCh = make(chan struct{})
ctx, stop = signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT, syscall.SIGTERM) ctx, stop = signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
lo = initz.Logger(ko.MustString("app.log_level"), ko.MustString("app.env"), "artemis") lo = initz.Logger(ko.MustString("app.log_level"), ko.MustString("app.env"), "artemis")
rd = initz.Redis(ko) rd = initz.Redis(ko)
db = initz.DB(ko) db = initz.DB(ko)
wsHub = ws.NewHub() wsHub = ws.NewHub()
templateMgr = initTemplateMgr(db)
attachmentMgr = initAttachmentsManager(db, lo) attachmentMgr = initAttachmentsManager(db, lo)
cntctMgr = initContactManager(db, lo) cntctMgr = initContactManager(db, lo)
inboxMgr = initInboxManager(db, lo) inboxMgr = initInboxManager(db, lo)
teamMgr = initTeamMgr(db, lo) teamMgr = initTeamMgr(db, lo)
userMgr = initUserDB(db, lo) userMgr = initUserDB(db, lo)
conversationMgr = initConversations(db, lo) notifier = initNotifier(userMgr, templateMgr)
conversationMgr = initConversations(wsHub, db, lo)
automationEngine = initAutomationEngine(db, lo) automationEngine = initAutomationEngine(db, lo)
msgMgr = initMessages(db, lo, wsHub, userMgr, teamMgr, cntctMgr, attachmentMgr, conversationMgr, inboxMgr, automationEngine) msgMgr = initMessages(db, lo, wsHub, userMgr, teamMgr, cntctMgr, attachmentMgr, conversationMgr, inboxMgr, automationEngine, templateMgr)
autoAssignerEngine = initAutoAssignmentEngine(teamMgr, conversationMgr, msgMgr, lo) autoAssignerEngine = initAutoAssignmentEngine(teamMgr, conversationMgr, msgMgr, notifier, wsHub, lo)
) )
// Init the app
var app = &App{
lo: lo,
cntctMgr: cntctMgr,
inboxMgr: inboxMgr,
userMgr: userMgr,
teamMgr: teamMgr,
attachmentMgr: attachmentMgr,
conversationMgr: conversationMgr,
msgMgr: msgMgr,
constants: initConstants(),
rbac: initRBACEngine(db),
tagMgr: initTags(db, lo),
userFilterMgr: initUserFilterMgr(db),
sessMgr: initSessionManager(rd),
cannedRespMgr: initCannedResponse(db, lo),
conversationTagsMgr: initConversationTags(db, lo),
}
// Register all inboxes with the inbox manager. // Register all inboxes with the inbox manager.
registerInboxes(inboxMgr, msgMgr) registerInboxes(inboxMgr, msgMgr)
automationEngine.SetMsgRecorder(msgMgr) // Set conversation store for the websocket hub.
automationEngine.SetConvUpdater(conversationMgr) wsHub.SetConversationStore(conversationMgr)
// Set stores for the automation engine.
automationEngine.SetMessageStore(msgMgr)
automationEngine.SetConversationStore(conversationMgr)
// Start receivers for all active inboxes. // Start receivers for all active inboxes.
inboxMgr.Receive(ctx) inboxMgr.Receive(ctx)
// Start inserting incoming msgs and dispatch pending outgoing messages. // Start automation rule evaluation engine.
go app.msgMgr.StartDBInserts(ctx, ko.MustInt("message.reader_concurrency"))
go app.msgMgr.StartDispatcher(ctx, ko.MustInt("message.dispatch_concurrency"), ko.MustDuration("message.dispatch_read_interval"))
// Start automation rule engine.
go automationEngine.Serve(ctx) go automationEngine.Serve(ctx)
// Start conversation auto assigner engine. // Start conversation auto assigner engine.
go autoAssignerEngine.Serve(ctx, ko.MustDuration("autoassigner.assign_interval")) go autoAssignerEngine.Serve(ctx, ko.MustDuration("autoassigner.assign_interval"))
// Start inserting incoming messages from all active inboxes and dispatch pending outgoing messages.
go msgMgr.StartDBInserts(ctx, ko.MustInt("message.reader_concurrency"))
go msgMgr.StartDispatcher(ctx, ko.MustInt("message.dispatch_concurrency"), ko.MustDuration("message.dispatch_read_interval"))
// Init the app
var app = &App{
lo: lo,
cntctMgr: cntctMgr,
inboxMgr: inboxMgr,
userMgr: userMgr,
teamMgr: teamMgr,
attachmentMgr: attachmentMgr,
conversationMgr: conversationMgr,
msgMgr: msgMgr,
constants: initConstants(),
rbac: initRBACEngine(db),
tagMgr: initTags(db, lo),
sessMgr: initSessionManager(rd),
cannedRespMgr: initCannedResponse(db, lo),
}
// Init fastglue http server. // Init fastglue http server.
g := fastglue.NewGlue() g := fastglue.NewGlue()
@@ -137,20 +144,18 @@ func main() {
// Wait for the interruption signal // Wait for the interruption signal
<-ctx.Done() <-ctx.Done()
log.Printf("\x1b[%dm%s\x1b[0m", 31, "Shutting down the server please wait...") log.Printf("%sShutting down the server. Please wait.\x1b[0m", colourRed)
// Additional grace period before triggering shutdown time.Sleep(5 * time.Second)
time.Sleep(7 * time.Second)
// Signal to shutdown the server // Signal to shutdown the server
shutdownCh <- struct{}{} shutdownCh <- struct{}{}
stop() stop()
}() }()
// Starting the server and waiting for the shutdown signal log.Printf("%s🚀 server listening on %s %s\x1b[0m", colourGreen, ko.String("app.server.address"), ko.String("app.server.socket"))
log.Printf("🚀 server listening on %s %s", ko.String("app.server.address"), ko.String("app.server.socket"))
if err := g.ListenServeAndWaitGracefully(ko.String("app.server.address"), ko.String("server.socket"), s, shutdownCh); err != nil { if err := g.ListenServeAndWaitGracefully(ko.String("app.server.address"), ko.String("server.socket"), s, shutdownCh); err != nil {
log.Fatalf("error starting frontend server: %v", err) log.Fatalf("error starting frontend server: %v", err)
} }
log.Println("Server shutdown completed")
} }

View File

@@ -3,7 +3,6 @@ package main
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"time"
"github.com/abhinavxd/artemis/internal/attachment/models" "github.com/abhinavxd/artemis/internal/attachment/models"
"github.com/abhinavxd/artemis/internal/message" "github.com/abhinavxd/artemis/internal/message"
@@ -108,13 +107,10 @@ func handleSendMessage(r *fastglue.Request) error {
// Update conversation meta with the last message details. // Update conversation meta with the last message details.
trimmedMessage := app.msgMgr.TrimMsg(msg.Content) trimmedMessage := app.msgMgr.TrimMsg(msg.Content)
app.conversationMgr.UpdateMeta(0, conversationUUID, map[string]string{ app.conversationMgr.UpdateLastMessage(0, conversationUUID, trimmedMessage, msg.CreatedAt)
"last_message": trimmedMessage,
"last_message_at": msg.CreatedAt.Format(time.RFC3339),
})
// Send WS update. // Send WS update.
app.msgMgr.BroadcastNewMsg(msg, trimmedMessage) app.msgMgr.BroadcastNewConversationMessage(msg, trimmedMessage)
return r.SendEnvelope("Message sent") return r.SendEnvelope("Message sent")
} }

View File

@@ -28,16 +28,3 @@ func handleGetCurrentUser(r *fastglue.Request) error {
} }
return r.SendEnvelope(u) return r.SendEnvelope(u)
} }
func handleGetUserFilters(r *fastglue.Request) error {
var (
app = r.Context.(*App)
userID = r.RequestCtx.UserValue("user_id").(int)
page = r.RequestCtx.UserValue("page").(string)
)
filters, err := app.userFilterMgr.GetFilters(userID, page)
if err != nil {
return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "")
}
return r.SendEnvelope(filters)
}

View File

@@ -34,23 +34,9 @@ func handleWS(r *fastglue.Request, hub *ws.Hub) error {
ID: userID, ID: userID,
Hub: hub, Hub: hub,
Conn: conn, Conn: conn,
Send: make(chan ws.Message, 100000), Send: make(chan ws.Message, 10000),
} }
// Sub this client to all assigned conversations.
convs, err := app.conversationMgr.GetAssignedConversations(userID)
if err != nil {
return
}
// Extract uuids.
uuids := make([]string, len(convs))
for i, conv := range convs {
uuids[i] = conv.UUID
}
c.SubConv(userID, uuids...)
hub.AddClient(&c) hub.AddClient(&c)
go c.Listen() go c.Listen()
c.Serve(2 * time.Second) c.Serve(2 * time.Second)
}) })

BIN
frontend/bun.lockb Executable file

Binary file not shown.

View File

@@ -45,6 +45,7 @@
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tiptap-extension-resize-image": "^1.1.5", "tiptap-extension-resize-image": "^1.1.5",
"vue": "^3.4.15", "vue": "^3.4.15",
"vue-draggable-resizable": "^3.0.0",
"vue-i18n": "9", "vue-i18n": "9",
"vue-letter": "^0.2.0", "vue-letter": "^0.2.0",
"vue-router": "^4.2.5" "vue-router": "^4.2.5"

View File

@@ -29,10 +29,10 @@ import { ref, onMounted } from "vue"
import { RouterView, useRouter } from 'vue-router' import { RouterView, useRouter } from 'vue-router'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { initWS } from "./websocket.js" import { initWS } from "@/websocket.js"
import { Toaster } from '@/components/ui/toast' import { Toaster } from '@/components/ui/toast'
import NavBar from './components/NavBar.vue' import NavBar from '@/components/NavBar.vue'
import { import {
ResizableHandle, ResizableHandle,
ResizablePanel, ResizablePanel,
@@ -43,7 +43,6 @@ import {
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
const isCollapsed = ref(false) const isCollapsed = ref(false)
const navLinks = [ const navLinks = [
{ {

View File

@@ -174,10 +174,9 @@ $editorContainerId: 'editor-container';
} }
} }
.tiptap-editor-image { // .tiptap-editor-image {
width: 100px; // width: 60%;
height: 200px; // }
}
.box { .box {
box-shadow: rgb(243, 243, 243) 1px 1px 0px 0px; box-shadow: rgb(243, 243, 243) 1px 1px 0px 0px;

View File

@@ -1,8 +1,8 @@
<template> <template>
<div class="h-screen" v-if="conversationStore.messages.data"> <div class="relative" v-if="conversationStore.messages.data">
<!-- Header --> <!-- Header -->
<div class="h-12 px-4 box relative"> <div class="h-10 px-4 box">
<div class="flex flex-row justify-between items-center pt-1"> <div class="flex flex-row justify-between items-center">
<div class="flex h-5 items-center space-x-4 text-sm"> <div class="flex h-5 items-center space-x-4 text-sm">
<Tooltip> <Tooltip>
<TooltipTrigger>#{{ conversationStore.conversation.data.reference_number }} <TooltipTrigger>#{{ conversationStore.conversation.data.reference_number }}
@@ -45,12 +45,11 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Body -->
<Error class="sticky" :error-message="conversationStore.messages.errorMessage"></Error> <Error class="sticky" :error-message="conversationStore.messages.errorMessage"></Error>
<div class="flex flex-col h-screen"> <div class="flex flex-col h-screen">
<!-- Messages --> <!-- flex-1-->
<MessageList :messages="conversationStore.sortedMessages" class="flex-1 bg-[#f8f9fa41]" /> <MessageList :messages="conversationStore.sortedMessages" class="flex-1 bg-[#f8f9fa41]" />
<ReplyBox /> <ReplyBox class="h-max mb-10"/>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -315,7 +315,7 @@ onMounted(() => {
}) })
const handleAssignedAgentChange = (v) => { const handleAssignedAgentChange = (v) => {
conversationStore.updateAssignee("agent", { conversationStore.updateAssignee("user", {
"assignee_uuid": v.split(":")[0] "assignee_uuid": v.split(":")[0]
}) })
} }

View File

@@ -1,5 +1,6 @@
<template> <template>
<!-- <div v-if="cannedResponsesStore.responses.length === 0" class="w-full drop-shadow-sm overflow-hidden p-2 border-t"> <div>
<!-- <div v-if="cannedResponsesStore.responses.length === 0" class="w-full drop-shadow-sm overflow-hidden p-2 border-t">
<ul class="space-y-2 max-h-96"> <ul class="space-y-2 max-h-96">
<li v-for="(response, index) in filteredCannedResponses" :key="response.id" <li v-for="(response, index) in filteredCannedResponses" :key="response.id"
@click="selectResponse(response.content)" class="cursor-pointer rounded p-1 hover:bg-secondary" @click="selectResponse(response.content)" class="cursor-pointer rounded p-1 hover:bg-secondary"
@@ -8,7 +9,9 @@
</li> </li>
</ul> </ul>
</div> --> </div> -->
<TextEditor @send="sendMessage" :conversationuuid="conversationStore.conversation.data.uuid" class="mb-[40px]" />
<TextEditor @send="sendMessage" :conversationuuid="conversationStore.conversation.data.uuid" />
</div>
</template> </template>
<script setup> <script setup>

View File

@@ -40,8 +40,6 @@ import { Button } from '@/components/ui/button'
import { useEditor, EditorContent } from '@tiptap/vue-3' import { useEditor, EditorContent } from '@tiptap/vue-3'
import Placeholder from "@tiptap/extension-placeholder" import Placeholder from "@tiptap/extension-placeholder"
import StarterKit from '@tiptap/starter-kit' import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
import ImageResize from 'tiptap-extension-resize-image';
import { Toggle } from '@/components/ui/toggle' import { Toggle } from '@/components/ui/toggle'
import { Paperclip, Bold, Italic } from "lucide-vue-next" import { Paperclip, Bold, Italic } from "lucide-vue-next"
import AttachmentsPreview from "@/components/attachment/AttachmentsPreview.vue" import AttachmentsPreview from "@/components/attachment/AttachmentsPreview.vue"
@@ -78,7 +76,6 @@ const inputText = ref('')
const isBold = ref(false) const isBold = ref(false)
const isItalic = ref(false) const isItalic = ref(false)
const attachmentInput = ref(null) const attachmentInput = ref(null)
const imageInput = ref(null)
const cannedResponseIndex = ref(0) const cannedResponseIndex = ref(0)
const uploadedFiles = ref([]) const uploadedFiles = ref([])
@@ -94,13 +91,6 @@ const editor = ref(useEditor({
'Control-i': () => applyItalic(), 'Control-i': () => applyItalic(),
} }
}), }),
Image.configure({
inline: false,
HTMLAttributes: {
class: 'tiptap-editor-image',
},
}),
ImageResize,
], ],
autofocus: true, autofocus: true,
editorProps: { editorProps: {
@@ -174,6 +164,7 @@ onMounted(async () => {
editor.value.commands.setTextSelection(editor.value.state.doc.content.size) editor.value.commands.setTextSelection(editor.value.state.doc.content.size)
}) })
} }
await nextTick()
}) })
// Cleanup. // Cleanup.
@@ -259,4 +250,6 @@ const handleOnFileDelete = uuid => {
overflow: scroll; overflow: scroll;
padding: 10px 10px; padding: 10px 10px;
} }
@import "vue-draggable-resizable/style.css";
</style> </style>

View File

@@ -2,7 +2,7 @@
<div class="h-screen"> <div class="h-screen">
<div class="px-3 pb-2 border-b-2 rounded-b-lg shadow-md"> <div class="px-3 pb-2 border-b-2 rounded-b-lg shadow-md">
<div class="flex justify-between mt-3"> <div class="flex justify-between mt-3">
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight flex gap-x-2"> <h3 class="scroll-m-20 text-2xl font-medium flex gap-x-2">
Conversations Conversations
</h3> </h3>
<div class="w-[8rem]"> <div class="w-[8rem]">
@@ -12,7 +12,10 @@
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
<SelectLabel>Status</SelectLabel> <!-- <SelectLabel>Status</SelectLabel> -->
<SelectItem value="status_all">
All
</SelectItem>
<SelectItem value="status_open"> <SelectItem value="status_open">
Open Open
</SelectItem> </SelectItem>
@@ -98,8 +101,9 @@
</template> </template>
<script setup> <script setup>
import { onMounted, ref, watch, computed } from 'vue' import { onMounted, ref, watch, computed, onUnmounted } from 'vue'
import { useConversationStore } from '@/stores/conversation' import { useConversationStore } from '@/stores/conversation'
import { subscribeConversations } from "@/websocket.js"
import { CONVERSATION_LIST_TYPE, CONVERSATION_PRE_DEFINED_FILTERS } from '@/constants/conversation' import { CONVERSATION_LIST_TYPE, CONVERSATION_PRE_DEFINED_FILTERS } from '@/constants/conversation'
import { Error } from '@/components/ui/error' import { Error } from '@/components/ui/error'
@@ -122,7 +126,6 @@ import {
SelectContent, SelectContent,
SelectGroup, SelectGroup,
SelectItem, SelectItem,
SelectLabel,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
@@ -132,17 +135,38 @@ import ConversationListItem from '@/components/conversationlist/ConversationList
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
const predefinedFilter = ref(CONVERSATION_PRE_DEFINED_FILTERS.STATUS_OPEN) const predefinedFilter = ref(CONVERSATION_PRE_DEFINED_FILTERS.ALL)
const conversationType = ref(CONVERSATION_LIST_TYPE.ASSIGNED) const conversationType = ref(CONVERSATION_LIST_TYPE.ASSIGNED)
let listRefreshInterval = null
onMounted(() => { onMounted(() => {
conversationStore.fetchConversations(conversationType.value, predefinedFilter.value) conversationStore.fetchConversations(conversationType.value, predefinedFilter.value)
subscribeConversations(conversationType.value, predefinedFilter.value)
// Refesh list every 1 minute to sync any missed changes.
listRefreshInterval = setInterval(() => {
conversationStore.fetchConversations(conversationType.value, predefinedFilter.value)
}, 60000)
})
onUnmounted(() => {
clearInterval(listRefreshInterval)
}) })
watch(conversationType, (newType) => { watch(conversationType, (newType) => {
conversationStore.fetchConversations(newType, predefinedFilter.value) conversationStore.fetchConversations(newType, predefinedFilter.value)
subscribeConversations(newType, predefinedFilter.value)
}); });
const handleFilterChange = (filter) => {
predefinedFilter.value = filter
conversationStore.fetchConversations(conversationType.value, filter)
subscribeConversations(conversationType.value, predefinedFilter.value)
}
const loadNextPage = () => {
conversationStore.fetchNextConversations(conversationType.value, predefinedFilter.value)
};
const hasConversations = computed(() => { const hasConversations = computed(() => {
return conversationStore.sortedConversations.length !== 0 && !conversationStore.conversations.errorMessage && !conversationStore.conversations.loading return conversationStore.sortedConversations.length !== 0 && !conversationStore.conversations.errorMessage && !conversationStore.conversations.loading
}) })
@@ -159,13 +183,5 @@ const conversationsLoading = computed(() => {
return conversationStore.conversations.loading return conversationStore.conversations.loading
}) })
const handleFilterChange = (filter) => {
predefinedFilter.value = filter
conversationStore.fetchConversations(conversationType.value, filter)
}
const loadNextPage = () => {
conversationStore.fetchNextConversations(conversationType.value, predefinedFilter.value)
};
</script> </script>

View File

@@ -1,4 +1,5 @@
export const CONVERSATION_PRE_DEFINED_FILTERS = { export const CONVERSATION_PRE_DEFINED_FILTERS = {
ALL: "status_all",
STATUS_OPEN: 'status_open', STATUS_OPEN: 'status_open',
STATUS_PROCESSING: 'status_processing', STATUS_PROCESSING: 'status_processing',
STATUS_SPAM: 'status_spam', STATUS_SPAM: 'status_spam',

View File

@@ -266,22 +266,39 @@ export const useConversationStore = defineStore('conversation', () => {
} }
} }
} }
function updateMessageList (msg) {
function updateConversationProp (update) {
if (conversation?.data?.uuid === update.uuid) {
conversation.data[update.prop] = update.val
}
}
function addNewConversation (conv) {
if (!conversationUUIDExists(conv.uuid)) {
conversations.data.push(conv)
}
}
function conversationUUIDExists (uuid) {
return conversations.data?.find(c => c.uuid === uuid) ? true : false
}
function updateMessageList (message) {
// Check if this conversation is selected and then update messages list. // Check if this conversation is selected and then update messages list.
if (conversation?.data?.uuid === msg.conversation_uuid) { if (conversation?.data?.uuid === message.conversation_uuid) {
// Fetch entire msg if the give msg does not exist in the msg list. // Fetch entire message if the give msg does not exist in the msg list.
if (!messages.data.some(message => message.uuid === msg.uuid)) { if (!messages.data.some(msg => msg.uuid === message.uuid)) {
fetchParticipants(msg.conversation_uuid) fetchParticipants(message.conversation_uuid)
fetchMessage(msg.uuid) fetchMessage(message.uuid)
updateAssigneeLastSeen(msg.conversation_uuid) updateAssigneeLastSeen(message.conversation_uuid)
} }
} }
} }
function updateMessageStatus (uuid, status) { function updateMessageProp (message) {
const message = messages.data.find(m => m.uuid === uuid) const existingMessage = messages.data.find(m => m.uuid === message.uuid)
if (message) { if (existingMessage) {
message.status = status existingMessage[message.prop] = message.val
} }
} }
@@ -305,5 +322,5 @@ export const useConversationStore = defineStore('conversation', () => {
messages.errorMessage = "" messages.errorMessage = ""
} }
return { conversations, conversation, messages, sortedConversations, sortedMessages, getContactFullName, fetchParticipants, fetchNextConversations, updateMessageStatus, updateAssigneeLastSeen, updateMessageList, fetchConversation, fetchConversations, fetchMessages, upsertTags, updateAssignee, updatePriority, updateStatus, updateConversationList, $reset }; return { conversations, conversation, messages, sortedConversations, sortedMessages, conversationUUIDExists, updateConversationProp, addNewConversation, getContactFullName, fetchParticipants, fetchNextConversations, updateMessageProp, updateAssigneeLastSeen, updateMessageList, fetchConversation, fetchConversations, fetchMessages, upsertTags, updateAssignee, updatePriority, updateStatus, updateConversationList, $reset };
}) })

View File

@@ -18,6 +18,8 @@
<script setup> <script setup>
import { onMounted, watch } from "vue" import { onMounted, watch } from "vue"
import { subscribeConversation } from "@/websocket.js"
import { import {
ResizableHandle, ResizableHandle,
ResizablePanel, ResizablePanel,
@@ -35,21 +37,29 @@ const props = defineProps({
const conversationStore = useConversationStore(); const conversationStore = useConversationStore();
onMounted(() => { onMounted(() => {
if (props.uuid) { fetchConversation(props.uuid)
fetchConversation(props.uuid)
}
}); });
watch(() => props.uuid, (uuid) => { watch(() => props.uuid, (newUUID, oldUUID) => {
if (uuid) { if (newUUID !== oldUUID) {
fetchConversation(uuid) fetchConversation(newUUID)
} }
}); });
const fetchConversation = (uuid) => { const fetchConversation = (uuid) => {
if (!uuid) return
conversationStore.fetchParticipants(uuid) conversationStore.fetchParticipants(uuid)
conversationStore.fetchConversation(uuid) conversationStore.fetchConversation(uuid)
subscribeCurrentConversation(uuid)
conversationStore.fetchMessages(uuid) conversationStore.fetchMessages(uuid)
conversationStore.updateAssigneeLastSeen(uuid) conversationStore.updateAssigneeLastSeen(uuid)
} }
// subscribes user to the conversation.
const subscribeCurrentConversation = async (uuid) => {
if (!conversationStore.conversationUUIDExists(uuid)) {
subscribeConversation(uuid)
}
}
</script> </script>

View File

@@ -9,7 +9,7 @@
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent class="grid gap-4"> <CardContent class="grid gap-4">
<div class="grid grid-cols-1 gap-6"> <!-- <div class="grid grid-cols-1 gap-6">
<Button variant="outline"> <Button variant="outline">
<svg role="img" viewBox="0 0 24 24" class="mr-2 h-4 w-4"> <svg role="img" viewBox="0 0 24 24" class="mr-2 h-4 w-4">
<path fill="currentColor" <path fill="currentColor"
@@ -27,14 +27,15 @@
Or continue with Or continue with
</span> </span>
</div> </div>
</div> </div> -->
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="email">Email</Label> <Label for="email">Email</Label>
<Input id="email" type="email" placeholder="m@example.com" v-model.trim="loginForm.email" /> <Input id="email" type="email" placeholder="Enter your email address"
v-model.trim="loginForm.email" />
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="password">Password</Label> <Label for="password">Password</Label>
<Input id="password" type="password" v-model="loginForm.password" /> <Input id="password" type="password" placeholder="Password" v-model="loginForm.password" />
</div> </div>
</CardContent> </CardContent>
<CardFooter class="flex flex-col gap-5"> <CardFooter class="flex flex-col gap-5">
@@ -43,7 +44,7 @@
</Button> </Button>
<Error :errorMessage="errorMessage" :border="true"></Error> <Error :errorMessage="errorMessage" :border="true"></Error>
<div> <div>
<a href="#" class="text-xs">Forgot ID or Password?</a> <a href="#" class="text-xs">Forgot Email or Password?</a>
</div> </div>
</CardFooter> </CardFooter>
</Card> </Card>

View File

@@ -1,52 +1,145 @@
import { useConversationStore } from "./stores/conversation"; import { useConversationStore } from './stores/conversation'
export function initWS () { let socket
let reconnectInterval = 1000 // Initial reconnection interval
let maxReconnectInterval = 30000 // Maximum reconnection interval
let reconnectTimeout
let isReconnecting = false
let manualClose = false
export function initWS() {
let convStore = useConversationStore() let convStore = useConversationStore()
// Create a new WebSocket connection to the specified WebSocket URL // Initialize the WebSocket connection
const socket = new WebSocket('ws://localhost:9009/api/ws'); function initializeWebSocket() {
socket = new WebSocket('ws://localhost:9009/api/ws')
// Connection opened event // Connection opened event
socket.addEventListener('open', function (event) { socket.addEventListener('open', function () {
// Send a message to the server once the connection is opened console.log('WebSocket connection established')
socket.send('Hello, server!!'); reconnectInterval = 1000 // Reset the reconnection interval
}); if (reconnectTimeout) {
clearTimeout(reconnectTimeout) // Clear any existing reconnection timeout
// Listen for messages from the server reconnectTimeout = null
socket.addEventListener('message', function (e) {
console.log('Message from server !', e.data);
if (e.data) {
let event = JSON.parse(e.data)
switch (event.ev) {
case "new_msg":
convStore.updateConversationList(event.d)
convStore.updateMessageList(event.d)
break
case "msg_status_update":
convStore.updateMessageStatus(event.d.uuid, event.d.status)
break
default:
console.log(`Unknown event ${event.ev}`);
} }
})
// Listen for messages from the server
socket.addEventListener('message', function (e) {
console.log('Message from server:', e.data)
if (e.data) {
let event = JSON.parse(e.data)
// TODO: move event type to consts.
switch (event.typ) {
case 'new_msg':
convStore.updateConversationList(event.d)
convStore.updateMessageList(event.d)
break
case 'msg_prop_update':
convStore.updateMessageProp(event.d)
break
case 'new_conv':
convStore.addNewConversation(event.d)
break
case 'conv_prop_update':
convStore.updateConversationProp(event.d)
break
default:
console.log(`Unknown event ${event.ev}`)
}
}
})
// Handle possible errors
socket.addEventListener('error', function (event) {
console.error('WebSocket error observed:', event)
})
// Handle the connection close event
socket.addEventListener('close', function (event) {
console.log('WebSocket connection closed:', event)
if (!manualClose) {
reconnect()
}
})
}
// Start the initial WebSocket connection
initializeWebSocket()
// Reconnect logic
function reconnect() {
if (isReconnecting) return
isReconnecting = true
reconnectTimeout = setTimeout(() => {
initializeWebSocket()
reconnectInterval = Math.min(reconnectInterval * 2, maxReconnectInterval)
isReconnecting = false
}, reconnectInterval)
}
// Detect network status and handle reconnection
window.addEventListener('online', () => {
if (!isReconnecting && socket.readyState !== WebSocket.OPEN) {
reconnectInterval = 1000 // Reset reconnection interval
reconnect()
} }
}); })
}
// Handle possible errors
socket.addEventListener('error', function (event) { function waitForWebSocketOpen(socket, callback) {
console.error('WebSocket error observed:', event); if (socket.readyState === WebSocket.OPEN) {
console.log('WebSocket readyState:', socket.readyState); // Log the state of the WebSocket callback()
}); } else {
socket.addEventListener('open', function handler() {
socket.removeEventListener('open', handler)
// Handle the connection close event callback()
socket.addEventListener('close', function (event) { })
console.log('WebSocket connection closed!:', event); }
}); }
socket.onerror = function (event) { export function sendMessage(message) {
console.error("WebSocket error:", event); waitForWebSocketOpen(socket, () => {
}; socket.send(JSON.stringify(message))
socket.onclose = function (event) { })
console.log("WebSocket connection closed:", event); }
};
export function subscribeConversations(type, preDefinedFilter) {
let message = {
a: 'conversations_sub',
t: type,
pf: preDefinedFilter
}
waitForWebSocketOpen(socket, () => {
socket.send(JSON.stringify(message))
})
}
export function subscribeConversation(uuid) {
if (!uuid) {
return
}
let message = {
a: 'conversation_sub',
uuid: uuid
}
waitForWebSocketOpen(socket, () => {
socket.send(JSON.stringify(message))
})
}
export function unsubscribeConversation(uuid) {
if (!uuid) {
return
}
let message = {
a: 'conversation_unsub',
uuid: uuid
}
waitForWebSocketOpen(socket, () => {
socket.send(JSON.stringify(message))
})
} }

View File

@@ -8,7 +8,7 @@ import (
"net/textproto" "net/textproto"
"github.com/abhinavxd/artemis/internal/attachment/models" "github.com/abhinavxd/artemis/internal/attachment/models"
"github.com/abhinavxd/artemis/internal/dbutils" "github.com/abhinavxd/artemis/internal/dbutil"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/zerodha/logf" "github.com/zerodha/logf"
) )
@@ -56,7 +56,7 @@ func New(opt Opts) (*Manager, error) {
// Scan SQL file // Scan SQL file
if err := dbutils.ScanSQLFile("queries.sql", &q, opt.DB, efs); err != nil { if err := dbutil.ScanSQLFile("queries.sql", &q, opt.DB, efs); err != nil {
return nil, err return nil, err
} }
return &Manager{ return &Manager{

View File

@@ -1 +0,0 @@
package auditlog

View File

@@ -1,10 +1,11 @@
package rbac package auth
import ( import (
"embed" "embed"
"github.com/abhinavxd/artemis/internal/dbutils" "github.com/abhinavxd/artemis/internal/dbutil"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/zerodha/logf"
) )
var ( var (
@@ -13,31 +14,30 @@ var (
) )
type Engine struct { type Engine struct {
q queries q queries
lo *logf.Logger
} }
type queries struct { type queries struct {
HasPermission *sqlx.Stmt `query:"has-permission"` HasPermission *sqlx.Stmt `query:"has-permission"`
} }
func New(db *sqlx.DB) (*Engine, error) { func New(db *sqlx.DB, lo *logf.Logger) (*Engine, error) {
var q queries var q queries
if err := dbutil.ScanSQLFile("queries.sql", &q, db, efs); err != nil {
if err := dbutils.ScanSQLFile("queries.sql", &q, db, efs); err != nil {
return nil, err return nil, err
} }
return &Engine{ return &Engine{
q: q, q: q,
lo: lo,
}, nil }, nil
} }
func (e *Engine) HasPermission(userID int, perm string) (bool, error) { func (e *Engine) HasPermission(userID int, perm string) (bool, error) {
var hasPerm bool var hasPerm bool
if err := e.q.HasPermission.Get(&hasPerm, userID, perm); err != nil { if err := e.q.HasPermission.Get(&hasPerm, userID, perm); err != nil {
e.lo.Error("error fetching user permissions", "user_id", userID, "error", err)
return hasPerm, err return hasPerm, err
} }
return hasPerm, nil return hasPerm, nil
} }

View File

@@ -8,8 +8,10 @@ import (
"github.com/abhinavxd/artemis/internal/conversation" "github.com/abhinavxd/artemis/internal/conversation"
"github.com/abhinavxd/artemis/internal/conversation/models" "github.com/abhinavxd/artemis/internal/conversation/models"
"github.com/abhinavxd/artemis/internal/message" "github.com/abhinavxd/artemis/internal/message"
notifier "github.com/abhinavxd/artemis/internal/notification"
"github.com/abhinavxd/artemis/internal/systeminfo" "github.com/abhinavxd/artemis/internal/systeminfo"
"github.com/abhinavxd/artemis/internal/team" "github.com/abhinavxd/artemis/internal/team"
"github.com/abhinavxd/artemis/internal/ws"
"github.com/mr-karan/balance" "github.com/mr-karan/balance"
"github.com/zerodha/logf" "github.com/zerodha/logf"
) )
@@ -17,71 +19,48 @@ import (
const ( const (
roundRobinDefaultWeight = 1 roundRobinDefaultWeight = 1
strategyRoundRobin = "round_robin" strategyRoundRobin = "round_robin"
strategyLoadBalances = "load_balanced"
) )
// Engine handles the assignment of unassigned conversations to agents using a round-robin strategy. // Engine handles the assignment of unassigned conversations to agents of a tean using a round-robin strategy.
type Engine struct { type Engine struct {
teamRoundRobinBalancer map[int]*balance.Balance teamRoundRobinBalancer map[int]*balance.Balance
mu sync.Mutex // Mutex to protect the balancer map userIDs map[string]int
convMgr *conversation.Manager // Mutex to protect the balancer map
teamMgr *team.Manager mu sync.Mutex
msgMgr *message.Manager convMgr *conversation.Manager
lo *logf.Logger teamMgr *team.Manager
strategy string msgMgr *message.Manager
lo *logf.Logger
hub *ws.Hub
notifier notifier.Notifier
strategy string
} }
// New creates a new instance of the Engine. // New creates a new instance of the Engine.
func New(teamMgr *team.Manager, convMgr *conversation.Manager, msgMgr *message.Manager, lo *logf.Logger) (*Engine, error) { func New(teamMgr *team.Manager, convMgr *conversation.Manager, msgMgr *message.Manager,
balance, err := populateBalancerPool(teamMgr) notifier notifier.Notifier, hub *ws.Hub, lo *logf.Logger) (*Engine, error) {
var e = Engine{
notifier: notifier,
strategy: strategyRoundRobin,
convMgr: convMgr,
teamMgr: teamMgr,
msgMgr: msgMgr,
lo: lo,
hub: hub,
mu: sync.Mutex{},
userIDs: map[string]int{},
}
balancer, err := e.populateBalancerPool()
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &Engine{ e.teamRoundRobinBalancer = balancer
teamRoundRobinBalancer: balance, return &e, nil
strategy: strategyRoundRobin,
convMgr: convMgr,
teamMgr: teamMgr,
msgMgr: msgMgr,
lo: lo,
mu: sync.Mutex{},
}, nil
}
func populateBalancerPool(teamMgr *team.Manager) (map[int]*balance.Balance, error) {
var (
balancer = make(map[int]*balance.Balance)
teams, err = teamMgr.GetAll()
)
if err != nil {
return nil, err
}
for _, team := range teams {
users, err := teamMgr.GetTeamMembers(team.Name)
if err != nil {
return nil, err
}
// Now add the users to team balance map.
for _, user := range users {
if _, ok := balancer[team.ID]; !ok {
balancer[team.ID] = balance.NewBalance()
}
balancer[team.ID].Add(user.UUID, roundRobinDefaultWeight)
}
}
return balancer, nil
} }
func (e *Engine) Serve(ctx context.Context, interval time.Duration) { func (e *Engine) Serve(ctx context.Context, interval time.Duration) {
// Start updating the balancer pool periodically in a separate goroutine
go e.refreshBalancerPeriodically(ctx, 1*time.Minute)
ticker := time.NewTicker(interval) ticker := time.NewTicker(interval)
defer ticker.Stop() defer ticker.Stop()
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -94,67 +73,11 @@ func (e *Engine) Serve(ctx context.Context, interval time.Duration) {
} }
} }
// assignConversations fetches unassigned conversations and assigns them. func (e *Engine) RefreshBalancer() error {
func (e *Engine) assignConversations() error {
unassignedConv, err := e.convMgr.GetUnassigned()
if err != nil {
return err
}
if len(unassignedConv) > 0 {
e.lo.Debug("found unassigned conversations", "count", len(unassignedConv))
}
for _, conv := range unassignedConv {
if e.strategy == strategyRoundRobin {
e.roundRobin(conv)
}
}
return nil
}
// roundRobin fetches an user from the team balancer pool and assigns the conversation to that user.
func (e *Engine) roundRobin(conv models.Conversation) {
pool, ok := e.teamRoundRobinBalancer[conv.AssignedTeamID.Int]
if !ok {
e.lo.Warn("team not found in balancer", "id", conv.AssignedTeamID.Int)
}
userUUID := pool.Get()
e.lo.Debug("fetched user from rr pool for assignment", "user_uuid", userUUID)
if userUUID == "" {
e.lo.Warn("empty user returned from rr pool")
return
}
if err := e.convMgr.UpdateAssignee(conv.UUID, []byte(userUUID), "agent"); err != nil {
e.lo.Error("error updating conversation assignee", "error", err, "conv_uuid", conv.UUID, "user_uuid", userUUID)
return
}
if err := e.msgMgr.RecordAssigneeUserChange(userUUID, conv.UUID, systeminfo.SystemUserUUID); err != nil {
e.lo.Error("error recording conversation user change msg", "error", err, "conv_uuid", conv.UUID, "user_uuid", userUUID)
}
}
func (e *Engine) refreshBalancerPeriodically(ctx context.Context, updateInterval time.Duration) {
ticker := time.NewTicker(updateInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := e.refreshBalancer(); err != nil {
e.lo.Error("Error updating team balancer pool", "error", err)
}
}
}
}
func (e *Engine) refreshBalancer() error {
e.mu.Lock() e.mu.Lock()
defer e.mu.Unlock() defer e.mu.Unlock()
balancer, err := populateBalancerPool(e.teamMgr) balancer, err := e.populateBalancerPool()
if err != nil { if err != nil {
e.lo.Error("Error updating team balancer pool", "error", err) e.lo.Error("Error updating team balancer pool", "error", err)
return err return err
@@ -162,3 +85,79 @@ func (e *Engine) refreshBalancer() error {
e.teamRoundRobinBalancer = balancer e.teamRoundRobinBalancer = balancer
return nil return nil
} }
// populateBalancerPool populates the team balancer bool with the team members.
func (e *Engine) populateBalancerPool() (map[int]*balance.Balance, error) {
var (
balancer = make(map[int]*balance.Balance)
teams, err = e.teamMgr.GetAll()
)
if err != nil {
return nil, err
}
for _, team := range teams {
users, err := e.teamMgr.GetTeamMembers(team.Name)
if err != nil {
return nil, err
}
// Add the users to team balance map.
for _, user := range users {
if _, ok := balancer[team.ID]; !ok {
balancer[team.ID] = balance.NewBalance()
}
// FIXME: Balancer only supports strings.
balancer[team.ID].Add(user.UUID, roundRobinDefaultWeight)
e.userIDs[user.UUID] = user.ID
}
}
return balancer, nil
}
// assignConversations fetches unassigned conversations and assigns them.
func (e *Engine) assignConversations() error {
unassignedConversations, err := e.convMgr.GetUnassigned()
if err != nil {
return err
}
if len(unassignedConversations) > 0 {
e.lo.Debug("found unassigned conversations", "count", len(unassignedConversations))
}
for _, conversation := range unassignedConversations {
if e.strategy == strategyRoundRobin {
userUUID := e.getUser(conversation)
if userUUID == "" {
e.lo.Warn("user uuid not found for round robin assignment", "team_id", conversation.AssignedTeamID.Int)
continue
}
// Update assignee and record the assigne change message.
if err := e.convMgr.UpdateUserAssignee(conversation.UUID, []byte(userUUID)); err != nil {
continue
}
// Fixme: maybe move to messages?
e.hub.BroadcastConversationAssignment(e.userIDs[userUUID], conversation.UUID, conversation.AvatarURL.String, conversation.FirstName, conversation.LastName, conversation.LastMessage, conversation.InboxName, conversation.LastMessageAt.Time, 1)
e.msgMgr.RecordAssigneeUserChange(conversation.UUID, userUUID, systeminfo.SystemUserUUID)
// Send notification to the assignee.
e.notifier.SendAssignedConversationNotification([]string{userUUID}, conversation.UUID)
}
}
return nil
}
// getUser returns user uuid from the team balancer pool.
func (e *Engine) getUser(conversation models.Conversation) string {
pool, ok := e.teamRoundRobinBalancer[conversation.AssignedTeamID.Int]
if !ok {
e.lo.Warn("team not found in balancer", "id", conversation.AssignedTeamID.Int)
return ""
}
return pool.Get()
}

View File

@@ -3,11 +3,11 @@ package automation
import ( import (
"context" "context"
"embed" "embed"
"fmt" "encoding/json"
"github.com/abhinavxd/artemis/internal/automation/models" "github.com/abhinavxd/artemis/internal/automation/models"
cmodels "github.com/abhinavxd/artemis/internal/conversation/models" cmodels "github.com/abhinavxd/artemis/internal/conversation/models"
"github.com/abhinavxd/artemis/internal/dbutils" "github.com/abhinavxd/artemis/internal/dbutil"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/zerodha/logf" "github.com/zerodha/logf"
) )
@@ -17,29 +17,13 @@ var (
efs embed.FS efs embed.FS
) )
type queries struct {
GetNewConversationRules *sqlx.Stmt `query:"get-rules"`
GetRuleActions *sqlx.Stmt `query:"get-rule-actions"`
}
type Engine struct { type Engine struct {
q queries q queries
lo *logf.Logger lo *logf.Logger
convUpdater ConversationUpdater conversationStore ConversationStore
msgRecorder MessageRecorder messageStore MessageStore
conversationQ chan cmodels.Conversation rules []models.Rule
rules []models.Rule conversationQ chan cmodels.Conversation
actions []models.Action
}
type ConversationUpdater interface {
UpdateAssignee(uuid string, assigneeUUID []byte, assigneeType string) error
UpdateStatus(uuid string, status []byte) error
}
type MessageRecorder interface {
RecordAssigneeUserChange(updatedValue, convUUID, actorUUID string) error
RecordStatusChange(updatedValue, convUUID, actorUUID string) error
} }
type Opts struct { type Opts struct {
@@ -47,6 +31,20 @@ type Opts struct {
Lo *logf.Logger Lo *logf.Logger
} }
type ConversationStore interface {
UpdateTeamAssignee(uuid string, assigneeUUID []byte) error
UpdateStatus(uuid string, status []byte) error
}
type MessageStore interface {
RecordAssigneeTeamChange(convUUID, value, actorUUID string) error
RecordStatusChange(updatedValue, convUUID, actorUUID string) error
}
type queries struct {
GetRules *sqlx.Stmt `query:"get-rules"`
}
func New(opt Opts) (*Engine, error) { func New(opt Opts) (*Engine, error) {
var ( var (
q queries q queries
@@ -55,29 +53,24 @@ func New(opt Opts) (*Engine, error) {
conversationQ: make(chan cmodels.Conversation, 10000), conversationQ: make(chan cmodels.Conversation, 10000),
} }
) )
if err := dbutil.ScanSQLFile("queries.sql", &q, opt.DB, efs); err != nil {
if err := dbutils.ScanSQLFile("queries.sql", &q, opt.DB, efs); err != nil {
return nil, err return nil, err
} }
// Fetch rules and actions from the DB.
if err := q.GetNewConversationRules.Select(&e.rules); err != nil {
return nil, fmt.Errorf("fetching rules: %w", err)
}
if err := q.GetRuleActions.Select(&e.actions); err != nil {
return nil, fmt.Errorf("fetching rule actions: %w", err)
}
e.q = q e.q = q
e.rules = e.getRules()
return e, nil return e, nil
} }
func (e *Engine) SetMsgRecorder(msgRecorder MessageRecorder) { func (e *Engine) ReloadRules() {
e.msgRecorder = msgRecorder e.rules = e.getRules()
} }
func (e *Engine) SetConvUpdater(convUpdater ConversationUpdater) { func (e *Engine) SetMessageStore(messageStore MessageStore) {
e.convUpdater = convUpdater e.messageStore = messageStore
}
func (e *Engine) SetConversationStore(conversationStore ConversationStore) {
e.conversationStore = conversationStore
} }
func (e *Engine) Serve(ctx context.Context) { func (e *Engine) Serve(ctx context.Context) {
@@ -85,12 +78,39 @@ func (e *Engine) Serve(ctx context.Context) {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return return
case conv := <-e.conversationQ: case conversation := <-e.conversationQ:
e.processConversations(conv) e.processConversations(conversation)
} }
} }
} }
func (e *Engine) EvaluateRules(c cmodels.Conversation) { func (e *Engine) EvaluateRules(c cmodels.Conversation) {
e.conversationQ <- c select {
case e.conversationQ <- c:
default:
// Queue is full.
e.lo.Warn("EvaluateRules: conversationQ is full, unable to enqueue conversation")
}
}
func (e *Engine) getRules() []models.Rule {
var rulesJSON []string
err := e.q.GetRules.Select(&rulesJSON)
if err != nil {
e.lo.Error("error fetching automation rules", "error", err)
return nil
}
var rules []models.Rule
for _, ruleJSON := range rulesJSON {
var rulesBatch []models.Rule
if err := json.Unmarshal([]byte(ruleJSON), &rulesBatch); err != nil {
e.lo.Error("error unmarshalling rule JSON", "error", err)
continue
}
rules = append(rules, rulesBatch...)
}
e.lo.Debug("fetched rules", "num", len(rules), "rules", rules)
return rules
} }

View File

@@ -3,22 +3,28 @@ package models
const ( const (
ActionAssignTeam = "assign_team" ActionAssignTeam = "assign_team"
ActionAssignAgent = "assign_agent" ActionAssignAgent = "assign_agent"
OperatorAnd = "AND"
RuleTypeNewConversation = "new_conversation" OperatorOR = "OR"
) )
type Rule struct { type Rule struct {
ID int `db:"id"` GroupOperator string `json:"group_operator" db:"group_operator"`
Type string `db:"type"` Groups []RuleGroup `json:"groups" db:"groups"`
Field string `db:"field"` Actions []RuleAction `json:"actions" db:"actions"`
Operator string `db:"operator"`
Value string `db:"value"`
GroupID int `db:"group_id"`
LogicalOp string `db:"logical_op"`
} }
type Action struct { type RuleGroup struct {
RuleID int `db:"rule_id"` LogicalOp string `json:"logical_op" db:"logical_op"`
Type string `db:"action_type"` Rules []RuleDetail `json:"rules" db:"rules"`
Action string `db:"action"` }
type RuleDetail struct {
Field string `json:"field" db:"field"`
Operator string `json:"operator" db:"operator"`
Value string `json:"value" db:"value"`
}
type RuleAction struct {
Type string `json:"action_type" db:"action_type"`
Action string `json:"action" db:"action"`
} }

View File

@@ -1,142 +0,0 @@
package automation
import (
"fmt"
"strings"
"github.com/abhinavxd/artemis/internal/automation/models"
cmodels "github.com/abhinavxd/artemis/internal/conversation/models"
"github.com/abhinavxd/artemis/internal/systeminfo"
)
func (e *Engine) processConversations(conv cmodels.Conversation) {
var (
groupRules = make(map[int][]models.Rule)
groupOperator = make(map[int]string)
)
// Group rules by RuleID and their logical operators.
for _, rule := range e.rules {
groupRules[rule.GroupID] = append(groupRules[rule.GroupID], rule)
groupOperator[rule.GroupID] = rule.LogicalOp
}
fmt.Printf("%+v \n", e.actions)
fmt.Printf("%+v \n", e.rules)
// Evaluate rules grouped by RuleID
for groupID, rules := range groupRules {
e.lo.Debug("evaluating group rule", "group_id", groupID, "operator", groupOperator[groupID])
if e.evaluateGroup(rules, groupOperator[groupID], conv) {
for _, action := range e.actions {
if action.RuleID == rules[0].ID {
e.executeActions(conv)
}
}
}
}
}
// Helper function to evaluate a group of rules
func (e *Engine) evaluateGroup(rules []models.Rule, operator string, conv cmodels.Conversation) bool {
switch operator {
case "AND":
// All conditions within the group must be true
for _, rule := range rules {
if !e.evaluateRule(rule, conv) {
e.lo.Debug("rule evaluation was not success", "id", rule.ID)
return false
}
}
e.lo.Debug("all AND rules are success")
return true
case "OR":
// At least one condition within the group must be true
for _, rule := range rules {
if e.evaluateRule(rule, conv) {
e.lo.Debug("OR rules are success", "id", rule.ID)
return true
}
}
return false
default:
e.lo.Error("invalid group operator", "operator", operator)
}
return false
}
func (e *Engine) evaluateRule(rule models.Rule, conv cmodels.Conversation) bool {
var (
conversationValue string
conditionMet bool
)
// Extract the value from the conversation based on the rule's field
switch rule.Field {
case "subject":
conversationValue = conv.Subject
case "content":
conversationValue = conv.FirstMessage
case "status":
conversationValue = conv.Status.String
case "priority":
conversationValue = conv.Priority.String
default:
e.lo.Error("rule field not recognized", "field", rule.Field)
return false
}
// Lower case the value.
conversationValue = strings.ToLower(conversationValue)
// Compare the conversation value with the rule's value based on the operator
switch rule.Operator {
case "equals":
conditionMet = conversationValue == rule.Value
case "not equal":
conditionMet = conversationValue != rule.Value
case "contains":
e.lo.Debug("eval rule", "field", rule.Field, "conv_val", conversationValue, "rule_val", rule.Value)
conditionMet = strings.Contains(conversationValue, rule.Value)
case "startsWith":
conditionMet = strings.HasPrefix(conversationValue, rule.Value)
case "endsWith":
conditionMet = strings.HasSuffix(conversationValue, rule.Value)
default:
e.lo.Error("logical operator not recognized for evaluating rules", "operator", rule.Operator)
return false
}
return conditionMet
}
func (e *Engine) executeActions(conv cmodels.Conversation) {
for _, action := range e.actions {
err := e.processAction(action, conv)
if err != nil {
e.lo.Error("error executing rule action", "action", action.Action, "error", err)
}
}
}
func (e *Engine) processAction(action models.Action, conv cmodels.Conversation) error {
switch action.Type {
case models.ActionAssignTeam:
if err := e.convUpdater.UpdateAssignee(conv.UUID, []byte(action.Action), "team"); err != nil {
return err
}
if err := e.msgRecorder.RecordAssigneeUserChange(action.Action, conv.UUID, systeminfo.SystemUserUUID); err != nil {
return err
}
case models.ActionAssignAgent:
if err := e.convUpdater.UpdateStatus(conv.UUID, []byte(action.Action)); err != nil {
return err
}
if err := e.msgRecorder.RecordStatusChange(action.Action, conv.UUID, systeminfo.SystemUserUUID); err != nil {
return err
}
default:
return fmt.Errorf("rule action not recognized: %s", action.Type)
}
return nil
}

View File

@@ -0,0 +1,153 @@
package automation
import (
"fmt"
"strings"
"github.com/abhinavxd/artemis/internal/automation/models"
cmodels "github.com/abhinavxd/artemis/internal/conversation/models"
"github.com/abhinavxd/artemis/internal/systeminfo"
)
func (e *Engine) processConversations(conversation cmodels.Conversation) {
e.lo.Debug("num rules", "rules", len(e.rules))
for _, rule := range e.rules {
e.lo.Debug("eval rule", "groups", len(rule.Groups), "rule", rule)
if len(rule.Groups) > 2 {
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)
results = append(results, result)
}
if evaluateFinalResult(results, rule.GroupOperator) {
e.lo.Debug("rule fully evalauted, executing actions")
// All group rule evaluations successful, execute the actions.
for _, action := range rule.Actions {
e.executeActions(conversation, action)
}
}
}
}
// evaluateFinalResult
func evaluateFinalResult(results []bool, operator string) bool {
if operator == models.OperatorAnd {
for _, result := range results {
if !result {
return false
}
}
return true
}
if operator == models.OperatorOR {
for _, result := range results {
if result {
return true
}
}
return false
}
return false
}
// evaluateGroup
func (e *Engine) evaluateGroup(rules []models.RuleDetail, operator string, conversation cmodels.Conversation) bool {
switch operator {
case models.OperatorAnd:
// All conditions within the group must be true
for _, rule := range rules {
if !e.evaluateRule(rule, conversation) {
return false
}
}
return true
case models.OperatorOR:
// At least one condition within the group must be true
for _, rule := range rules {
if e.evaluateRule(rule, conversation) {
return true
}
}
return false
default:
e.lo.Error("invalid group operator", "operator", operator)
}
return false
}
func (e *Engine) evaluateRule(rule models.RuleDetail, conversation cmodels.Conversation) bool {
var (
conversationValue string
conditionMet bool
)
// Extract the value from the conversation based on the rule's field
switch rule.Field {
case "subject":
conversationValue = conversation.Subject
case "content":
conversationValue = conversation.FirstMessage
case "status":
conversationValue = conversation.Status.String
case "priority":
conversationValue = conversation.Priority.String
default:
e.lo.Error("rule field not recognized", "field", rule.Field)
return false
}
// Lower case the value.
conversationValue = strings.ToLower(conversationValue)
// Compare the conversation value with the rule's value based on the operator
switch rule.Operator {
case "equals":
conditionMet = conversationValue == rule.Value
case "not equal":
conditionMet = conversationValue != rule.Value
case "contains":
conditionMet = strings.Contains(conversationValue, rule.Value)
case "startsWith":
conditionMet = strings.HasPrefix(conversationValue, rule.Value)
case "endsWith":
conditionMet = strings.HasSuffix(conversationValue, rule.Value)
default:
e.lo.Error("logical operator not recognized for evaluating rules", "operator", rule.Operator)
return false
}
return conditionMet
}
func (e *Engine) executeActions(conversation cmodels.Conversation, action models.RuleAction) {
err := e.applyAction(action, conversation)
if err != nil {
e.lo.Error("error executing rule action", "action", action.Action, "error", err)
}
}
func (e *Engine) applyAction(action models.RuleAction, conversation cmodels.Conversation) error {
switch action.Type {
case models.ActionAssignTeam:
if err := e.conversationStore.UpdateTeamAssignee(conversation.UUID, []byte(action.Action)); err != nil {
return err
}
if err := e.messageStore.RecordAssigneeTeamChange(conversation.UUID, action.Action, systeminfo.SystemUserUUID); err != nil {
return err
}
case models.ActionAssignAgent:
if err := e.conversationStore.UpdateStatus(conversation.UUID, []byte(action.Action)); err != nil {
return err
}
if err := e.messageStore.RecordStatusChange(action.Action, conversation.UUID, systeminfo.SystemUserUUID); err != nil {
return err
}
default:
return fmt.Errorf("unrecognized rule action: %s", action.Type)
}
return nil
}

View File

@@ -1,6 +1,3 @@
-- name: get-rules -- name: get-rules
select er.id, er.type, ec.field, ec."operator", ec.value, ec.group_id, ecg.logical_op from engine_rules er inner join engine_conditions ec on ec.rule_id = er.id select rules
inner join engine_condition_groups ecg on ecg.id = ec.group_id; from automation_rules;
-- name: get-rule-actions
select rule_id, action_type, action from engine_actions;

View File

@@ -4,7 +4,7 @@ import (
"embed" "embed"
"fmt" "fmt"
"github.com/abhinavxd/artemis/internal/dbutils" "github.com/abhinavxd/artemis/internal/dbutil"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/zerodha/logf" "github.com/zerodha/logf"
) )
@@ -37,7 +37,7 @@ type queries struct {
func New(opts Opts) (*Manager, error) { func New(opts Opts) (*Manager, error) {
var q queries var q queries
if err := dbutils.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil { if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
return nil, err return nil, err
} }

View File

@@ -4,7 +4,7 @@ import (
"embed" "embed"
"github.com/abhinavxd/artemis/internal/contact/models" "github.com/abhinavxd/artemis/internal/contact/models"
"github.com/abhinavxd/artemis/internal/dbutils" "github.com/abhinavxd/artemis/internal/dbutil"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/zerodha/logf" "github.com/zerodha/logf"
) )
@@ -31,7 +31,7 @@ type queries struct {
func New(opts Opts) (*Manager, error) { func New(opts Opts) (*Manager, error) {
var q queries var q queries
if err := dbutils.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil { if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
return nil, err return nil, err
} }

View File

@@ -1,16 +1,20 @@
package models package models
import "time" import (
"time"
"github.com/volatiletech/null/v9"
)
type Contact struct { type Contact struct {
ID int `db:"id" json:"id"` ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"` CreatedAt time.Time `db:"created_at" json:"created_at"`
FirstName string `db:"first_name" json:"first_name"` FirstName string `db:"first_name" json:"first_name"`
LastName string `db:"last_name" json:"last_name"` LastName string `db:"last_name" json:"last_name"`
Email string `db:"email" json:"email"` Email string `db:"email" json:"email"`
PhoneNumber *string `db:"phone_number" json:"phone_number"` PhoneNumber *string `db:"phone_number" json:"phone_number"`
AvatarURL *string `db:"avatar_url" json:"avatar_url"` AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
InboxID int `db:"inbox_id" json:"inbox_id"` InboxID int `db:"inbox_id" json:"inbox_id"`
Source string `db:"source" json:"source"` Source string `db:"source" json:"source"`
SourceID string `db:"source_id" json:"source_id"` SourceID string `db:"source_id" json:"source_id"`
} }

View File

@@ -11,8 +11,9 @@ import (
"time" "time"
"github.com/abhinavxd/artemis/internal/conversation/models" "github.com/abhinavxd/artemis/internal/conversation/models"
"github.com/abhinavxd/artemis/internal/dbutils" "github.com/abhinavxd/artemis/internal/dbutil"
"github.com/abhinavxd/artemis/internal/stringutils" "github.com/abhinavxd/artemis/internal/stringutil"
"github.com/abhinavxd/artemis/internal/ws"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/lib/pq" "github.com/lib/pq"
"github.com/zerodha/logf" "github.com/zerodha/logf"
@@ -43,11 +44,23 @@ var (
PriortiyMedium, PriortiyMedium,
PriorityHigh, PriorityHigh,
} }
preDefinedFilters = map[string]string{
"status_open": " c.status = 'Open'",
"status_processing": " c.status = 'Processing'",
"status_spam": " c.status = 'Spam'",
"status_resolved": " c.status = 'Resolved'",
"status_all": " 1=1 ",
}
assigneeTypeTeam = "team"
assigneeTypeUser = "user"
) )
type Manager struct { type Manager struct {
lo *logf.Logger lo *logf.Logger
db *sqlx.DB db *sqlx.DB
hub *ws.Hub
q queries q queries
ReferenceNumPattern string ReferenceNumPattern string
} }
@@ -66,6 +79,7 @@ type queries struct {
GetUnassigned *sqlx.Stmt `query:"get-unassigned"` GetUnassigned *sqlx.Stmt `query:"get-unassigned"`
GetConversationParticipants *sqlx.Stmt `query:"get-conversation-participants"` GetConversationParticipants *sqlx.Stmt `query:"get-conversation-participants"`
GetConversations string `query:"get-conversations"` GetConversations string `query:"get-conversations"`
GetConversationsUUIDs string `query:"get-conversations-uuids"`
GetAssignedConversations *sqlx.Stmt `query:"get-assigned-conversations"` GetAssignedConversations *sqlx.Stmt `query:"get-assigned-conversations"`
GetAssigneeStats *sqlx.Stmt `query:"get-assignee-stats"` GetAssigneeStats *sqlx.Stmt `query:"get-assignee-stats"`
InsertConverstionParticipant *sqlx.Stmt `query:"insert-conversation-participant"` InsertConverstionParticipant *sqlx.Stmt `query:"insert-conversation-participant"`
@@ -77,15 +91,18 @@ type queries struct {
UpdatePriority *sqlx.Stmt `query:"update-priority"` UpdatePriority *sqlx.Stmt `query:"update-priority"`
UpdateStatus *sqlx.Stmt `query:"update-status"` UpdateStatus *sqlx.Stmt `query:"update-status"`
UpdateMeta *sqlx.Stmt `query:"update-meta"` UpdateMeta *sqlx.Stmt `query:"update-meta"`
AddTag *sqlx.Stmt `query:"add-tag"`
DeleteTags *sqlx.Stmt `query:"delete-tags"`
} }
func New(opts Opts) (*Manager, error) { func New(hub *ws.Hub, opts Opts) (*Manager, error) {
var q queries var q queries
if err := dbutils.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil { if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
return nil, err return nil, err
} }
c := &Manager{ c := &Manager{
q: q, q: q,
hub: hub,
db: opts.DB, db: opts.DB,
lo: opts.Lo, lo: opts.Lo,
ReferenceNumPattern: opts.ReferenceNumPattern, ReferenceNumPattern: opts.ReferenceNumPattern,
@@ -93,16 +110,17 @@ func New(opts Opts) (*Manager, error) {
return c, nil return c, nil
} }
func (c *Manager) Create(contactID int, inboxID int, meta []byte) (int, error) { func (c *Manager) Create(contactID int, inboxID int, meta []byte) (int, string, error) {
var ( var (
id int id int
uuid string
refNum, _ = c.generateRefNum(c.ReferenceNumPattern) refNum, _ = c.generateRefNum(c.ReferenceNumPattern)
) )
if err := c.q.InsertConversation.QueryRow(refNum, contactID, StatusOpen, inboxID, meta).Scan(&id); err != nil { if err := c.q.InsertConversation.QueryRow(refNum, contactID, StatusOpen, inboxID, meta).Scan(&id, &uuid); err != nil {
c.lo.Error("inserting new conversation into the DB", "error", err) c.lo.Error("inserting new conversation into the DB", "error", err)
return id, err return id, uuid, err
} }
return id, nil return id, uuid, nil
} }
func (c *Manager) Get(uuid string) (models.Conversation, error) { func (c *Manager) Get(uuid string) (models.Conversation, error) {
@@ -144,24 +162,33 @@ func (c *Manager) AddParticipant(userID int, convUUID string) error {
return nil return nil
} }
func (c *Manager) UpdateMeta(convID int, convUUID string, meta map[string]string) error { func (c *Manager) UpdateMeta(conversationID int, conversationUUID string, meta map[string]string) error {
metaJSON, err := json.Marshal(meta) metaJSON, err := json.Marshal(meta)
if err != nil { if err != nil {
c.lo.Error("error marshalling meta", "error", err) c.lo.Error("error marshalling meta", "error", err)
return err return err
} }
if _, err := c.q.UpdateMeta.Exec(convID, convUUID, metaJSON); err != nil { if _, err := c.q.UpdateMeta.Exec(conversationID, conversationUUID, metaJSON); err != nil {
c.lo.Error("error updating conversation meta", "error", "error") c.lo.Error("error updating conversation meta", "error", "error")
return err return err
} }
return nil return nil
} }
func (c *Manager) UpdateFirstReplyAt(convID int, at time.Time) error { func (c *Manager) UpdateLastMessage(conversationID int, conversationUUID, lastMessage string, lastMessageAt time.Time) error {
if _, err := c.q.UpdateFirstReplyAt.Exec(convID, at); err != nil { return c.UpdateMeta(conversationID, conversationUUID, map[string]string{
"last_message": lastMessage,
"last_message_at": lastMessageAt.Format(time.RFC3339),
})
}
func (c *Manager) UpdateFirstReplyAt(conversationUUID string, conversationID int, at time.Time) error {
if _, err := c.q.UpdateFirstReplyAt.Exec(conversationID, at); err != nil {
c.lo.Error("error updating conversation first reply at", "error", err) c.lo.Error("error updating conversation first reply at", "error", err)
return err return err
} }
// Send ws update.
c.hub.BroadcastConversationPropertyUpdate(conversationUUID, "first_reply_at", time.Now().Format(time.RFC3339))
return nil return nil
} }
@@ -217,24 +244,10 @@ func (c *Manager) GetConversations(userID int, typ, order, orderBy, predefinedFi
qArgs []interface{} qArgs []interface{}
cond string cond string
// TODO: Remove these hardcoded values. // TODO: Remove these hardcoded values.
validOrderBy = map[string]bool{"created_at": true, "priority": true, "status": true, "last_message_at": true} validOrderBy = map[string]bool{"created_at": true, "priority": true, "status": true, "last_message_at": true}
validOrder = []string{"ASC", "DESC"} validOrder = []string{"ASC", "DESC"}
preDefinedFilters = map[string]string{
"status_open": " c.status = 'Open'",
"status_processing": " c.status = 'Processing'",
"status_spam": " c.status = 'Spam'",
"status_resolved": " c.status = 'Resolved'",
}
) )
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 20
}
switch typ { switch typ {
case "assigned": case "assigned":
cond = "AND c.assigned_user_id = $1" cond = "AND c.assigned_user_id = $1"
@@ -251,11 +264,7 @@ func (c *Manager) GetConversations(userID int, typ, order, orderBy, predefinedFi
cond += " AND " + filterClause cond += " AND " + filterClause
} }
// Calculate offset based on page number and page size // Ensure orderBy is valid.
offset := (page - 1) * pageSize
qArgs = append(qArgs, pageSize, offset)
// Ensure orderBy is valid to prevent SQL injection
orderByClause := "" orderByClause := ""
if _, ok := validOrderBy[orderBy]; ok { if _, ok := validOrderBy[orderBy]; ok {
orderByClause = fmt.Sprintf(" ORDER BY %s", orderBy) orderByClause = fmt.Sprintf(" ORDER BY %s", orderBy)
@@ -269,6 +278,16 @@ func (c *Manager) GetConversations(userID int, typ, order, orderBy, predefinedFi
orderByClause += " DESC " orderByClause += " DESC "
} }
// Calculate offset based on page number and page size.
if page <= 0 {
page = 1
}
if pageSize <= 0 || pageSize > 20 {
pageSize = 20
}
offset := (page - 1) * pageSize
qArgs = append(qArgs, pageSize, offset)
tx, err := c.db.BeginTxx(context.Background(), nil) tx, err := c.db.BeginTxx(context.Background(), nil)
defer tx.Rollback() defer tx.Rollback()
if err != nil { if err != nil {
@@ -276,7 +295,7 @@ func (c *Manager) GetConversations(userID int, typ, order, orderBy, predefinedFi
return conversations, err return conversations, err
} }
// Include LIMIT, OFFSET, and ORDER BY in the SQL query // Include LIMIT, OFFSET, and ORDER BY in the SQL query.
sqlQuery := fmt.Sprintf("%s %s LIMIT $%d OFFSET $%d", fmt.Sprintf(c.q.GetConversations, cond), orderByClause, len(qArgs)-1, len(qArgs)) sqlQuery := fmt.Sprintf("%s %s LIMIT $%d OFFSET $%d", fmt.Sprintf(c.q.GetConversations, cond), orderByClause, len(qArgs)-1, len(qArgs))
if err := tx.Select(&conversations, sqlQuery, qArgs...); err != nil { if err := tx.Select(&conversations, sqlQuery, qArgs...); err != nil {
c.lo.Error("Error fetching conversations", "error", err) c.lo.Error("Error fetching conversations", "error", err)
@@ -286,6 +305,54 @@ func (c *Manager) GetConversations(userID int, typ, order, orderBy, predefinedFi
return conversations, nil return conversations, nil
} }
func (c *Manager) GetConversationUUIDs(userID, page, pageSize int, typ, predefinedFilter string) ([]string, error) {
var (
conversationUUIDs []string
qArgs []interface{}
cond string
)
switch typ {
case "assigned":
cond = "AND c.assigned_user_id = $1"
qArgs = append(qArgs, userID)
case "unassigned":
cond = "AND c.assigned_user_id IS NULL AND c.assigned_team_id IN (SELECT team_id FROM team_members WHERE user_id = $1)"
qArgs = append(qArgs, userID)
case "all":
default:
return conversationUUIDs, errors.New("invalid type")
}
if filterClause, ok := preDefinedFilters[predefinedFilter]; ok {
cond += " AND " + filterClause
}
tx, err := c.db.BeginTxx(context.Background(), nil)
defer tx.Rollback()
if err != nil {
c.lo.Error("Error preparing get conversation ids query", "error", err)
return conversationUUIDs, err
}
// Calculate offset based on page number and page size.
if page <= 0 {
page = 1
}
if pageSize <= 0 || pageSize > 20 {
pageSize = 20
}
offset := (page - 1) * pageSize
qArgs = append(qArgs, pageSize, offset)
// Include LIMIT, OFFSET, and ORDER BY in the SQL query.
sqlQuery := fmt.Sprintf("%s LIMIT $%d OFFSET $%d", fmt.Sprintf(c.q.GetConversationsUUIDs, cond), len(qArgs)-1, len(qArgs))
if err := tx.Select(&conversationUUIDs, sqlQuery, qArgs...); err != nil {
c.lo.Error("Error fetching conversations", "error", err)
return conversationUUIDs, err
}
return conversationUUIDs, nil
}
func (c *Manager) GetAssignedConversations(userID int) ([]models.Conversation, error) { func (c *Manager) GetAssignedConversations(userID int) ([]models.Conversation, error) {
var conversations []models.Conversation var conversations []models.Conversation
if err := c.q.GetAssignedConversations.Select(&conversations, userID); err != nil { if err := c.q.GetAssignedConversations.Select(&conversations, userID); err != nil {
@@ -295,18 +362,28 @@ func (c *Manager) GetAssignedConversations(userID int) ([]models.Conversation, e
return conversations, nil return conversations, nil
} }
func (c *Manager) UpdateTeamAssignee(uuid string, assigneeUUID []byte) error {
return c.UpdateAssignee(uuid, assigneeUUID, assigneeTypeTeam)
}
func (c *Manager) UpdateUserAssignee(uuid string, assigneeUUID []byte) error {
return c.UpdateAssignee(uuid, assigneeUUID, assigneeTypeUser)
}
func (c *Manager) UpdateAssignee(uuid string, assigneeUUID []byte, assigneeType string) error { func (c *Manager) UpdateAssignee(uuid string, assigneeUUID []byte, assigneeType string) error {
switch assigneeType { switch assigneeType {
case "agent": case assigneeTypeUser:
if _, err := c.q.UpdateAssignedUser.Exec(uuid, assigneeUUID); err != nil { if _, err := c.q.UpdateAssignedUser.Exec(uuid, assigneeUUID); err != nil {
c.lo.Error("updating conversation assignee", "error", err) c.lo.Error("updating conversation assignee", "error", err)
return fmt.Errorf("error updating assignee") return fmt.Errorf("error updating assignee")
} }
case "team": c.hub.BroadcastConversationPropertyUpdate(uuid, "assigned_user_uuid", string(assigneeUUID))
case assigneeTypeTeam:
if _, err := c.q.UpdateAssignedTeam.Exec(uuid, assigneeUUID); err != nil { if _, err := c.q.UpdateAssignedTeam.Exec(uuid, assigneeUUID); err != nil {
c.lo.Error("updating conversation assignee", "error", err) c.lo.Error("updating conversation assignee", "error", err)
return fmt.Errorf("error updating assignee") return fmt.Errorf("error updating assignee")
} }
c.hub.BroadcastConversationPropertyUpdate(uuid, "assigned_team_uuid", string(assigneeUUID))
default: default:
return errors.New("invalid assignee type") return errors.New("invalid assignee type")
} }
@@ -314,13 +391,15 @@ func (c *Manager) UpdateAssignee(uuid string, assigneeUUID []byte, assigneeType
} }
func (c *Manager) UpdatePriority(uuid string, priority []byte) error { func (c *Manager) UpdatePriority(uuid string, priority []byte) error {
if !slices.Contains(priorities, string(priority)) { var priorityStr = string(priority)
return fmt.Errorf("invalid `priority` value %s", priority) if !slices.Contains(priorities, string(priorityStr)) {
return fmt.Errorf("invalid `priority` value %s", priorityStr)
} }
if _, err := c.q.UpdatePriority.Exec(uuid, priority); err != nil { if _, err := c.q.UpdatePriority.Exec(uuid, priority); err != nil {
c.lo.Error("updating conversation priority", "error", err) c.lo.Error("updating conversation priority", "error", err)
return fmt.Errorf("error updating priority") return fmt.Errorf("error updating priority")
} }
c.hub.BroadcastConversationPropertyUpdate(uuid, "priority", priorityStr)
return nil return nil
} }
@@ -332,6 +411,7 @@ func (c *Manager) UpdateStatus(uuid string, status []byte) error {
c.lo.Error("updating conversation status", "error", err) c.lo.Error("updating conversation status", "error", err)
return fmt.Errorf("error updating status") return fmt.Errorf("error updating status")
} }
c.hub.BroadcastConversationPropertyUpdate(uuid, "status", string(status))
return nil return nil
} }
@@ -347,11 +427,26 @@ func (c *Manager) GetAssigneeStats(userID int) (models.ConversationCounts, error
return counts, nil return counts, nil
} }
func (t *Manager) AddTags(convUUID string, tagIDs []int) error {
// Delete tags that have been removed.
if _, err := t.q.DeleteTags.Exec(convUUID, pq.Array(tagIDs)); err != nil {
t.lo.Error("error deleting conversation tags", "error", err)
}
// Add new tags.
for _, tagID := range tagIDs {
if _, err := t.q.AddTag.Exec(convUUID, tagID); err != nil {
t.lo.Error("error adding tags to conversation", "error", err)
}
}
return nil
}
func (c *Manager) generateRefNum(pattern string) (string, error) { func (c *Manager) generateRefNum(pattern string) (string, error) {
if len(pattern) <= 5 { if len(pattern) <= 5 {
pattern = "01234567890" pattern = "01234567890"
} }
randomNumbers, err := stringutils.RandomNumericString(len(pattern)) randomNumbers, err := stringutil.RandomNumericString(len(pattern))
if err != nil { if err != nil {
return "", err return "", err
} }

View File

@@ -18,7 +18,7 @@ type Conversation struct {
ReferenceNumber null.String `db:"reference_number" json:"reference_number,omitempty"` ReferenceNumber null.String `db:"reference_number" json:"reference_number,omitempty"`
Priority null.String `db:"priority" json:"priority"` Priority null.String `db:"priority" json:"priority"`
Status null.String `db:"status" json:"status"` Status null.String `db:"status" json:"status"`
FirstReplyAt *time.Time `db:"first_reply_at" json:"first_reply_at"` FirstReplyAt null.Time `db:"first_reply_at" json:"first_reply_at"`
AssignedUserID null.Int `db:"assigned_user_id" json:"-"` AssignedUserID null.Int `db:"assigned_user_id" json:"-"`
AssignedTeamID null.Int `db:"assigned_team_id" json:"-"` AssignedTeamID null.Int `db:"assigned_team_id" json:"-"`
AssigneeLastSeenAt *time.Time `db:"assignee_last_seen_at" json:"assignee_last_seen_at"` AssigneeLastSeenAt *time.Time `db:"assignee_last_seen_at" json:"assignee_last_seen_at"`
@@ -32,7 +32,7 @@ type Conversation struct {
ContactAvatarURL *string `db:"contact_avatar_url" json:"contact_avatar_url"` ContactAvatarURL *string `db:"contact_avatar_url" json:"contact_avatar_url"`
AssignedTeamUUID *string `db:"assigned_team_uuid" json:"assigned_team_uuid"` AssignedTeamUUID *string `db:"assigned_team_uuid" json:"assigned_team_uuid"`
AssignedAgentUUID *string `db:"assigned_user_uuid" json:"assigned_user_uuid"` AssignedAgentUUID *string `db:"assigned_user_uuid" json:"assigned_user_uuid"`
LastMessageAt *time.Time `db:"last_message_at" json:"last_message_at"` LastMessageAt null.Time `db:"last_message_at" json:"last_message_at"`
LastMessage string `db:"last_message" json:"last_message"` LastMessage string `db:"last_message" json:"last_message"`
FirstMessage string `json:"-"` FirstMessage string `json:"-"`
} }

View File

@@ -2,7 +2,7 @@
INSERT INTO conversations INSERT INTO conversations
(reference_number, contact_id, status, inbox_id, meta) (reference_number, contact_id, status, inbox_id, meta)
VALUES($1, $2, $3, $4, $5) VALUES($1, $2, $3, $4, $5)
returning id; returning id, uuid;
-- name: get-conversations -- name: get-conversations
@@ -28,6 +28,12 @@ FROM conversations c
JOIN inboxes inb on c.inbox_id = inb.id JOIN inboxes inb on c.inbox_id = inb.id
WHERE 1=1 %s WHERE 1=1 %s
-- name: get-conversations-uuids
SELECT
c.uuid
FROM conversations c
WHERE 1=1 %s
-- name: get-assigned-conversations -- name: get-assigned-conversations
SELECT uuid from conversations where assigned_user_id = $1; SELECT uuid from conversations where assigned_user_id = $1;
@@ -131,7 +137,27 @@ VALUES($1, (select id from conversations where uuid = $2));
select uuids from conversations where assigned_user_id = $1; select uuids from conversations where assigned_user_id = $1;
-- name: get-unassigned -- name: get-unassigned
SELECT id, uuid, assigned_team_id from conversations where assigned_user_id is NULL and assigned_team_id is not null; SELECT
c.updated_at,
c.uuid,
c.assignee_last_seen_at,
c.assigned_team_id,
inb.channel as inbox_channel,
inb.name as inbox_name,
ct.first_name,
ct.last_name,
ct.avatar_url,
COALESCE(c.meta->>'subject', '') as subject,
COALESCE(c.meta->>'last_message', '') as last_message,
COALESCE((c.meta->>'last_message_at')::timestamp, '1970-01-01 00:00:00'::timestamp) as last_message_at,
(
SELECT COUNT(*)
FROM messages m
WHERE m.conversation_id = c.id AND m.created_at > c.assignee_last_seen_at
) AS unread_message_count
FROM conversations c
JOIN contacts ct ON c.contact_id = ct.id
JOIN inboxes inb on c.inbox_id = inb.id where assigned_user_id is NULL and assigned_team_id is not null;
-- name: get-assignee-stats -- name: get-assignee-stats
SELECT SELECT
@@ -149,3 +175,22 @@ WHERE
UPDATE conversations UPDATE conversations
SET first_reply_at = $2 SET first_reply_at = $2
WHERE first_reply_at IS NULL AND id = $1; WHERE first_reply_at IS NULL AND id = $1;
-- name: add-tag
INSERT INTO conversation_tags (conversation_id, tag_id)
VALUES(
(
SELECT id
from conversations
where uuid = $1
),
$2
) ON CONFLICT DO NOTHING
-- name: delete-tags
DELETE FROM conversation_tags
WHERE conversation_id = (
SELECT id
from conversations
where uuid = $1
) AND tag_id NOT IN (SELECT unnest($2::int[]));

View File

@@ -1,18 +0,0 @@
-- name: add-tag
INSERT INTO conversation_tags (conversation_id, tag_id)
VALUES(
(
SELECT id
from conversations
where uuid = $1
),
$2
) ON CONFLICT DO NOTHING;
-- name: delete-tags
DELETE FROM conversation_tags
WHERE conversation_id = (
SELECT id
from conversations
where uuid = $1
) AND tag_id NOT IN (SELECT unnest($2::int[]));

View File

@@ -1,58 +0,0 @@
package tag
import (
"embed"
"github.com/abhinavxd/artemis/internal/dbutils"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
"github.com/zerodha/logf"
)
var (
//go:embed queries.sql
efs embed.FS
)
type Manager struct {
q queries
lo *logf.Logger
}
type Opts struct {
DB *sqlx.DB
Lo *logf.Logger
}
type queries struct {
AddTag *sqlx.Stmt `query:"add-tag"`
DeleteTags *sqlx.Stmt `query:"delete-tags"`
}
func New(opts Opts) (*Manager, error) {
var q queries
if err := dbutils.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
return nil, err
}
return &Manager{
q: q,
lo: opts.Lo,
}, nil
}
func (t *Manager) AddTags(convUUID string, tagIDs []int) error {
// Delete tags that have been removed.
if _, err := t.q.DeleteTags.Exec(convUUID, pq.Array(tagIDs)); err != nil {
t.lo.Error("inserting tag for conversation", "error", err, "converastion_uuid", convUUID, "tag_id", tagIDs)
}
// Add new tags one by one.
for _, tagID := range tagIDs {
if _, err := t.q.AddTag.Exec(convUUID, tagID); err != nil {
t.lo.Error("inserting tag for conversation", "error", err, "converastion_uuid", convUUID, "tag_id", tagID)
}
}
return nil
}

View File

@@ -1,4 +1,4 @@
package dbutils package dbutil
import ( import (
"io/fs" "io/fs"

View File

@@ -122,7 +122,6 @@ func (e *Email) processEnvelope(c *imapclient.Client, env *imap.Envelope, seqNum
exists, err := e.msgStore.MessageExists(env.MessageID) exists, err := e.msgStore.MessageExists(env.MessageID)
if exists || err != nil { if exists || err != nil {
e.lo.Debug("email message already exists, skipping", "message_id", env.MessageID)
return nil return nil
} }

View File

@@ -13,10 +13,11 @@ import (
) )
const ( const (
headerReturnPath = "Return-Path" headerReturnPath = "Return-Path"
headerMessageID = "Message-ID" headerMessageID = "Message-ID"
headerReferences = "References" headerReferences = "References"
headerInReplyTo = "In-Reply-To" headerInReplyTo = "In-Reply-To"
dispositionInline = "inline"
) )
// New returns an SMTP e-mail channels from the given SMTP server configcfg. // New returns an SMTP e-mail channels from the given SMTP server configcfg.

View File

@@ -6,7 +6,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"github.com/abhinavxd/artemis/internal/dbutils" "github.com/abhinavxd/artemis/internal/dbutil"
"github.com/abhinavxd/artemis/internal/message/models" "github.com/abhinavxd/artemis/internal/message/models"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/zerodha/logf" "github.com/zerodha/logf"
@@ -20,7 +20,7 @@ var (
ErrInboxNotFound = errors.New("inbox not found") ErrInboxNotFound = errors.New("inbox not found")
) )
// Closer provides function for closing a channel. // Closer provides function for closing an inbox.
type Closer interface { type Closer interface {
Close() error Close() error
} }
@@ -83,7 +83,7 @@ func New(lo *logf.Logger, db *sqlx.DB) (*Manager, error) {
var q queries var q queries
// Scan the sql file into the queries struct. // Scan the sql file into the queries struct.
if err := dbutils.ScanSQLFile("queries.sql", &q, db, efs); err != nil { if err := dbutil.ScanSQLFile("queries.sql", &q, db, efs); err != nil {
return nil, err return nil, err
} }

View File

@@ -16,13 +16,13 @@ import (
"github.com/abhinavxd/artemis/internal/automation" "github.com/abhinavxd/artemis/internal/automation"
"github.com/abhinavxd/artemis/internal/contact" "github.com/abhinavxd/artemis/internal/contact"
"github.com/abhinavxd/artemis/internal/conversation" "github.com/abhinavxd/artemis/internal/conversation"
"github.com/abhinavxd/artemis/internal/team"
"github.com/abhinavxd/artemis/internal/user"
cmodels "github.com/abhinavxd/artemis/internal/conversation/models" cmodels "github.com/abhinavxd/artemis/internal/conversation/models"
"github.com/abhinavxd/artemis/internal/dbutils" "github.com/abhinavxd/artemis/internal/dbutil"
"github.com/abhinavxd/artemis/internal/inbox" "github.com/abhinavxd/artemis/internal/inbox"
"github.com/abhinavxd/artemis/internal/message/models" "github.com/abhinavxd/artemis/internal/message/models"
"github.com/abhinavxd/artemis/internal/team"
"github.com/abhinavxd/artemis/internal/template"
"github.com/abhinavxd/artemis/internal/user"
"github.com/abhinavxd/artemis/internal/ws" "github.com/abhinavxd/artemis/internal/ws"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/k3a/html2text" "github.com/k3a/html2text"
@@ -66,19 +66,20 @@ const (
) )
type Manager struct { type Manager struct {
q queries q queries
lo *logf.Logger lo *logf.Logger
contactMgr *contact.Manager contactMgr *contact.Manager
attachmentMgr *attachment.Manager attachmentMgr *attachment.Manager
conversationMgr *conversation.Manager conversationMgr *conversation.Manager
inboxMgr *inbox.Manager inboxMgr *inbox.Manager
userMgr *user.Manager userMgr *user.Manager
teamMgr *team.Manager teamMgr *team.Manager
automationEngine *automation.Engine automationEngine *automation.Engine
wsHub *ws.Hub wsHub *ws.Hub
incomingMsgQ chan models.IncomingMessage templateManager *template.Manager
outgoingMsgQ chan models.Message incomingMsgQ chan models.IncomingMessage
outgoingProcessingMsgs sync.Map outgoingMessageQueue chan models.Message
outgoingProcessingMessages sync.Map
} }
type Opts struct { type Opts struct {
@@ -109,26 +110,29 @@ func New(
inboxMgr *inbox.Manager, inboxMgr *inbox.Manager,
conversationMgr *conversation.Manager, conversationMgr *conversation.Manager,
automationEngine *automation.Engine, automationEngine *automation.Engine,
opts Opts) (*Manager, error) { templateManager *template.Manager,
opts Opts,
) (*Manager, error) {
var q queries var q queries
if err := dbutils.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil { if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
return nil, err return nil, err
} }
return &Manager{ return &Manager{
q: q, q: q,
lo: opts.Lo, lo: opts.Lo,
wsHub: wsHub, wsHub: wsHub,
userMgr: userMgr, userMgr: userMgr,
teamMgr: teamMgr, teamMgr: teamMgr,
contactMgr: contactMgr, contactMgr: contactMgr,
attachmentMgr: attachmentMgr, attachmentMgr: attachmentMgr,
conversationMgr: conversationMgr, conversationMgr: conversationMgr,
inboxMgr: inboxMgr, inboxMgr: inboxMgr,
automationEngine: automationEngine, automationEngine: automationEngine,
incomingMsgQ: make(chan models.IncomingMessage, opts.IncomingMsgQueueSize), templateManager: templateManager,
outgoingMsgQ: make(chan models.Message, opts.OutgoingMsgQueueSize), incomingMsgQ: make(chan models.IncomingMessage, opts.IncomingMsgQueueSize),
outgoingProcessingMsgs: sync.Map{}, outgoingMessageQueue: make(chan models.Message, opts.OutgoingMsgQueueSize),
outgoingProcessingMessages: sync.Map{},
}, nil }, nil
} }
@@ -208,70 +212,74 @@ func (m *Manager) StartDispatcher(ctx context.Context, concurrency int, readInte
// Prepare and push the message to the outgoing queue. // Prepare and push the message to the outgoing queue.
for _, msg := range pendingMsgs { for _, msg := range pendingMsgs {
m.outgoingProcessingMsgs.Store(msg.ID, msg.ID) var err error
m.outgoingMsgQ <- msg msg.Content, _, err = m.templateManager.RenderDefault(map[string]string{
"Content": msg.Content,
})
if err != nil {
m.lo.Error("error rendering message template", "error", err)
m.UpdateMessageStatus(msg.UUID, StatusFailed)
continue
}
m.outgoingProcessingMessages.Store(msg.ID, msg.ID)
m.outgoingMessageQueue <- msg
} }
} }
} }
} }
func (m *Manager) DispatchWorker() { func (m *Manager) DispatchWorker() {
for msg := range m.outgoingMsgQ { for message := range m.outgoingMessageQueue {
inbox, err := m.inboxMgr.GetInbox(msg.InboxID) inbox, err := m.inboxMgr.GetInbox(message.InboxID)
if err != nil { if err != nil {
m.lo.Error("error fetching inbox", "error", err, "inbox_id", msg.InboxID) m.lo.Error("error fetching inbox", "error", err, "inbox_id", message.InboxID)
m.outgoingProcessingMsgs.Delete(msg.ID) m.outgoingProcessingMessages.Delete(message.ID)
continue continue
} }
msg.From = inbox.FromAddress() message.From = inbox.FromAddress()
if err := m.attachAttachments(&msg); err != nil { if err := m.attachAttachments(&message); err != nil {
m.lo.Error("error attaching attachments to msg", "error", err) m.lo.Error("error attaching attachments to message", "error", err)
m.outgoingProcessingMsgs.Delete(msg.ID) m.outgoingProcessingMessages.Delete(message.ID)
continue continue
} }
msg.To, _ = m.GetToAddress(msg.ConversationID, inbox.Channel()) message.To, _ = m.GetToAddress(message.ConversationID, inbox.Channel())
if inbox.Channel() == "email" { if inbox.Channel() == "email" {
msg.InReplyTo, _ = m.GetInReplyTo(msg.ConversationID) message.InReplyTo, _ = m.GetInReplyTo(message.ConversationID)
m.lo.Debug("set in reply to for outgoing email message", "in_reply_to", msg.InReplyTo)
} }
err = inbox.Send(msg) err = inbox.Send(message)
var newStatus = StatusSent var newStatus = StatusSent
if err != nil { if err != nil {
newStatus = StatusFailed newStatus = StatusFailed
m.lo.Error("error sending message", "error", err, "inbox_id", msg.InboxID) m.lo.Error("error sending message", "error", err, "inbox_id", message.InboxID)
} }
if _, err := m.q.UpdateMessageStatus.Exec(newStatus, msg.UUID); err != nil { if _, err := m.q.UpdateMessageStatus.Exec(newStatus, message.UUID); err != nil {
m.lo.Error("error updating message status in DB", "error", err, "inbox_id", msg.InboxID) m.lo.Error("error updating message status in DB", "error", err, "inbox_id", message.InboxID)
} }
switch newStatus { switch newStatus {
case StatusSent: case StatusSent:
m.lo.Debug("updating first reply at", "conv_id", msg.ConversationID, "at", msg.CreatedAt) m.conversationMgr.UpdateFirstReplyAt(message.ConversationUUID, message.ConversationID, message.CreatedAt)
m.conversationMgr.UpdateFirstReplyAt(msg.ConversationID, msg.CreatedAt)
} }
// Broadcast the new message status. // Broadcast message status update to the subscribers.
m.wsHub.BroadcastMsgStatus(msg.ConversationUUID, map[string]interface{}{ m.wsHub.BroadcastMessagePropUpdate(message.ConversationUUID, message.UUID, "status" /*message field*/, newStatus)
"uuid": msg.UUID,
"conversation_uuid": msg.ConversationUUID,
"status": newStatus,
})
m.outgoingProcessingMsgs.Delete(msg.ID) // Remove message from the processing list.
m.outgoingProcessingMessages.Delete(message.ID)
} }
} }
func (m *Manager) GetToAddress(convID int, channel string) ([]string, error) { func (m *Manager) GetToAddress(convID int, channel string) ([]string, error) {
var addr []string var addr []string
if err := m.q.GetToAddress.Select(&addr, convID, channel); err != nil { if err := m.q.GetToAddress.Select(&addr, convID, channel); err != nil {
m.lo.Error("error fetching to address for msg", "error", err, "conv_id", convID) m.lo.Error("error fetching to address for msg", "error", err, "conversation_id", convID)
return addr, err return addr, err
} }
return addr, nil return addr, nil
@@ -281,10 +289,10 @@ func (m *Manager) GetInReplyTo(convID int) (string, error) {
var out string var out string
if err := m.q.GetInReplyTo.Get(&out, convID); err != nil { if err := m.q.GetInReplyTo.Get(&out, convID); err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
m.lo.Error("in reply to not found", "error", err, "conv_id", convID) m.lo.Error("in reply to not found", "error", err, "conversation_id", convID)
return out, nil return out, nil
} }
m.lo.Error("error fetching in reply to", "error", err, "conv_id", convID) m.lo.Error("error fetching in reply to", "error", err, "conversation_id", convID)
return out, err return out, err
} }
return out, nil return out, nil
@@ -311,37 +319,36 @@ func (m *Manager) InsertWorker(ctx context.Context) {
} }
} }
func (m *Manager) RecordAssigneeUserChange(updatedValue, convUUID, actorUUID string) error { func (m *Manager) RecordAssigneeUserChange(conversationUUID, assigneeUUID, actorUUID string) error {
if updatedValue == actorUUID { // Self assign.
return m.RecordActivity(ActivitySelfAssign, updatedValue, convUUID, actorUUID) if assigneeUUID == actorUUID {
return m.RecordActivity(ActivitySelfAssign, assigneeUUID, conversationUUID, actorUUID)
} }
assignee, err := m.userMgr.GetUser(0, updatedValue) assignee, err := m.userMgr.GetUser(0, assigneeUUID)
if err != nil { if err != nil {
m.lo.Error("Error fetching user to record assignee change", "error", err) m.lo.Error("Error fetching user to record assignee change", "conversation_uuid", conversationUUID, "actor_uuid", actorUUID, "error", err)
return err return err
} }
updatedValue = assignee.FullName() return m.RecordActivity(ActivityAssignedUserChange, assignee.FullName() /*new_value*/, conversationUUID, actorUUID)
return m.RecordActivity(ActivityAssignedUserChange, updatedValue, convUUID, actorUUID)
} }
func (m *Manager) RecordAssigneeTeamChange(updatedValue, convUUID, actorUUID string) error { func (m *Manager) RecordAssigneeTeamChange(conversationUUID, value, actorUUID string) error {
team, err := m.teamMgr.GetTeam(updatedValue) team, err := m.teamMgr.GetTeam(value)
if err != nil { if err != nil {
return err return err
} }
updatedValue = team.Name return m.RecordActivity(ActivityAssignedTeamChange, team.Name /*new_value*/, conversationUUID, actorUUID)
return m.RecordActivity(ActivityAssignedTeamChange, updatedValue, convUUID, actorUUID)
} }
func (m *Manager) RecordPriorityChange(updatedValue, convUUID, actorUUID string) error { func (m *Manager) RecordPriorityChange(updatedValue, conversationUUID, actorUUID string) error {
return m.RecordActivity(ActivityPriorityChange, updatedValue, convUUID, actorUUID) return m.RecordActivity(ActivityPriorityChange, updatedValue, conversationUUID, actorUUID)
} }
func (m *Manager) RecordStatusChange(updatedValue, convUUID, actorUUID string) error { func (m *Manager) RecordStatusChange(updatedValue, conversationUUID, actorUUID string) error {
return m.RecordActivity(ActivityStatusChange, updatedValue, convUUID, actorUUID) return m.RecordActivity(ActivityStatusChange, updatedValue, conversationUUID, actorUUID)
} }
func (m *Manager) RecordActivity(activityType, updatedValue, conversationUUID, actorUUID string) error { func (m *Manager) RecordActivity(activityType, newValue, conversationUUID, actorUUID string) error {
var ( var (
actor, err = m.userMgr.GetUser(0, actorUUID) actor, err = m.userMgr.GetUser(0, actorUUID)
) )
@@ -350,7 +357,7 @@ func (m *Manager) RecordActivity(activityType, updatedValue, conversationUUID, a
return err return err
} }
var content = m.getActivityContent(activityType, updatedValue, actor.FullName()) var content = m.getActivityContent(activityType, newValue, actor.FullName())
if content == "" { if content == "" {
m.lo.Error("Error invalid activity for recording activity", "activity", activityType) m.lo.Error("Error invalid activity for recording activity", "activity", activityType)
return errors.New("invalid activity type for recording activity") return errors.New("invalid activity type for recording activity")
@@ -369,8 +376,8 @@ func (m *Manager) RecordActivity(activityType, updatedValue, conversationUUID, a
} }
m.RecordMessage(&msg) m.RecordMessage(&msg)
m.BroadcastNewMsg(msg, "") m.BroadcastNewConversationMessage(msg, content)
m.conversationMgr.UpdateLastMessage(0, conversationUUID, content, msg.CreatedAt)
return nil return nil
} }
@@ -425,28 +432,33 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error {
return nil return nil
} }
if err = m.findOrCreateConversation(&in.Message, in.InboxID, senderID, convMetaJSON); err != nil { isNewConversation, err := m.findOrCreateConversation(&in.Message, in.InboxID, senderID, convMetaJSON)
m.lo.Error("error creating conversation", "error", err) if err != nil {
return err return err
} }
if err = m.RecordMessage(&in.Message); err != nil { if err = m.RecordMessage(&in.Message); err != nil {
m.lo.Error("error inserting conversation message", "error", err)
return fmt.Errorf("inserting conversation message: %w", err) return fmt.Errorf("inserting conversation message: %w", err)
} }
if err := m.uploadAttachments(&in.Message); err != nil { if err := m.uploadAttachments(&in.Message); err != nil {
m.lo.Error("error uploading message attachments", "msg_uuid", in.Message.UUID, "error", err)
return fmt.Errorf("uploading message attachments: %w", err) return fmt.Errorf("uploading message attachments: %w", err)
} }
// Send WS update. // Send WS update.
if in.Message.ConversationUUID > "" { if in.Message.ConversationUUID > "" {
m.BroadcastNewMsg(in.Message, "") var content = ""
if isNewConversation {
content = m.TrimMsg(in.Message.Subject)
} else {
content = m.TrimMsg(in.Message.Content)
}
m.BroadcastNewConversationMessage(in.Message, content)
m.conversationMgr.UpdateLastMessage(in.Message.ConversationID, in.Message.ConversationUUID, content, in.Message.CreatedAt)
} }
// Evaluate automation rules for this conversation. // Evaluate automation rules for this new conversation.
if in.Message.NewConversation { if isNewConversation {
m.automationEngine.EvaluateRules(cmodels.Conversation{ m.automationEngine.EvaluateRules(cmodels.Conversation{
UUID: in.Message.ConversationUUID, UUID: in.Message.ConversationUUID,
FirstMessage: in.Message.Content, FirstMessage: in.Message.Content,
@@ -518,52 +530,53 @@ func (m *Manager) uploadAttachments(in *models.Message) error {
return nil return nil
} }
func (m *Manager) findOrCreateConversation(in *models.Message, inboxID int, contactID int, meta []byte) error { func (m *Manager) findOrCreateConversation(in *models.Message, inboxID int, contactID int, meta []byte) (bool, error) {
var ( var (
new bool
err error
conversationID int conversationID int
conversationUUID string conversationUUID string
newConv bool
err error
) )
// Search for existing conversation. // Search for existing conversation.
if conversationID == 0 && in.InReplyTo > "" { sourceIDs := in.References
conversationID, err = m.findConversationID([]string{in.InReplyTo}) if in.InReplyTo > "" {
if err != nil && err != ErrConversationNotFound { sourceIDs = append(sourceIDs, in.InReplyTo)
return err
}
} }
if conversationID == 0 && len(in.References) > 0 { conversationID, err = m.findConversationID(sourceIDs)
conversationID, err = m.findConversationID(in.References) if err != nil && err != ErrConversationNotFound {
if err != nil && err != ErrConversationNotFound { return new, err
return err
}
} }
// Conversation not found, create one. // Conversation not found, create one.
if conversationID == 0 { if conversationID == 0 {
newConv = true new = true
conversationID, err = m.conversationMgr.Create(contactID, inboxID, meta) conversationID, conversationUUID, err = m.conversationMgr.Create(contactID, inboxID, meta)
if err != nil || conversationID == 0 { if err != nil || conversationID == 0 {
return fmt.Errorf("inserting conversation: %w", err) return new, err
}
in.ConversationID = conversationID
in.ConversationUUID = conversationUUID
return new, nil
}
// Set UUID if not available.
if conversationUUID == "" {
conversationUUID, err = m.conversationMgr.GetUUID(conversationID)
if err != nil {
return new, err
} }
} }
// Fetch & return the UUID of the conversation for UI updates.
conversationUUID, err = m.conversationMgr.GetUUID(conversationID)
if err != nil {
m.lo.Error("Error fetching conversation UUID from id", err)
}
in.ConversationID = conversationID in.ConversationID = conversationID
in.ConversationUUID = conversationUUID in.ConversationUUID = conversationUUID
in.NewConversation = newConv return new, nil
return nil
} }
// findConversationID finds the conversation ID from the message source ID. // findConversationID finds the conversation ID from the message source ID.
func (m *Manager) findConversationID(sourceIDs []string) (int, error) { func (m *Manager) findConversationID(sourceIDs []string) (int, error) {
if len(sourceIDs) == 0 {
return 0, ErrConversationNotFound
}
var conversationID int var conversationID int
if err := m.q.MessageExists.QueryRow(pq.Array(sourceIDs)).Scan(&conversationID); err != nil { if err := m.q.MessageExists.QueryRow(pq.Array(sourceIDs)).Scan(&conversationID); err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
@@ -583,6 +596,11 @@ func (m *Manager) attachAttachments(msg *models.Message) error {
return err return err
} }
// TODO: set attachment headers and replace the inline image src url w
// src="cid:ii_lxxsfhtp0"
// a.Header.Set("Content-Disposition", "inline")
// a.Header.Set("Content-ID", "<"+f.CID+">")
// Fetch the blobs and attach the attachments to the message. // Fetch the blobs and attach the attachments to the message.
for i, att := range attachments { for i, att := range attachments {
attachments[i].Content, err = m.attachmentMgr.Store.GetBlob(att.UUID) attachments[i].Content, err = m.attachmentMgr.Store.GetBlob(att.UUID)
@@ -599,7 +617,7 @@ func (m *Manager) attachAttachments(msg *models.Message) error {
// getOutgoingProcessingMsgIDs returns outgoing msg ids currently being processed. // getOutgoingProcessingMsgIDs returns outgoing msg ids currently being processed.
func (m *Manager) getOutgoingProcessingMsgIDs() []int { func (m *Manager) getOutgoingProcessingMsgIDs() []int {
var out = make([]int, 0) var out = make([]int, 0)
m.outgoingProcessingMsgs.Range(func(key, _ any) bool { m.outgoingProcessingMessages.Range(func(key, _ any) bool {
if k, ok := key.(int); ok { if k, ok := key.(int); ok {
out = append(out, k) out = append(out, k)
} }
@@ -608,21 +626,6 @@ func (m *Manager) getOutgoingProcessingMsgIDs() []int {
return out return out
} }
func (m *Manager) BroadcastNewMsg(msg models.Message, trimmedContent string) { func (m *Manager) BroadcastNewConversationMessage(message models.Message, trimmedContent string) {
if trimmedContent == "" { m.wsHub.BroadcastNewConversationMessage(message.ConversationUUID, trimmedContent, message.UUID, time.Now().Format(time.RFC3339), message.Private)
var content = ""
if msg.NewConversation {
content = m.TrimMsg(msg.Subject)
} else {
content = m.TrimMsg(msg.Content)
}
trimmedContent = content
}
m.wsHub.BroadcastNewMsg(msg.ConversationUUID, map[string]interface{}{
"conversation_uuid": msg.ConversationUUID,
"uuid": msg.UUID,
"last_message": trimmedContent,
"last_message_at": time.Now().Format(time.DateTime),
"private": msg.Private,
})
} }

View File

@@ -41,7 +41,6 @@ type Message struct {
References []string `json:"-"` References []string `json:"-"`
InReplyTo string `json:"-"` InReplyTo string `json:"-"`
Headers textproto.MIMEHeader `json:"-"` Headers textproto.MIMEHeader `json:"-"`
NewConversation bool `json:"-"`
} }
// IncomingMessage links a message with the contact information and inbox id. // IncomingMessage links a message with the contact information and inbox id.

View File

@@ -0,0 +1,22 @@
package notifier
// Notifier defines the interface for sending notifications.
type Notifier interface {
SendMessage(userUUIDs []string, subject, content string) error
SendAssignedConversationNotification(userUUIDs []string, convUUID string) error
}
// TemplateRenderer defines the interface for rendering templates.
type TemplateRenderer interface {
RenderDefault(data interface{}) (subject, content string, err error)
}
// UserEmailFetcher defines the interfaces for fetchign user email address.
type UserEmailFetcher interface {
GetEmail(id int, uuid string) (string, error)
}
// UserStore defines the interface for the user store.
type UserStore interface {
UserEmailFetcher
}

View File

@@ -0,0 +1,138 @@
package email
import (
"fmt"
"math/rand"
"net/textproto"
"github.com/abhinavxd/artemis/internal/inbox/channel/email"
"github.com/abhinavxd/artemis/internal/message/models"
notifier "github.com/abhinavxd/artemis/internal/notification"
"github.com/knadh/smtppool"
"github.com/zerodha/logf"
)
// Notifier handles email notifications.
type Notifier struct {
lo *logf.Logger
from string
smtpPools []*smtppool.Pool
userStore notifier.UserStore
TemplateRenderer notifier.TemplateRenderer
}
type Opts struct {
Lo *logf.Logger
FromEmail string
}
// New creates a new instance of email Notifier.
func New(smtpConfig []email.SMTPConfig, userStore notifier.UserStore, TemplateRenderer notifier.TemplateRenderer, opts Opts) (*Notifier, error) {
pools, err := email.NewSmtpPool(smtpConfig)
if err != nil {
return nil, err
}
return &Notifier{
lo: opts.Lo,
smtpPools: pools,
from: opts.FromEmail,
userStore: userStore,
TemplateRenderer: TemplateRenderer,
}, nil
}
// SendMessage sends an email using the default template to multiple users.
func (e *Notifier) SendMessage(userUUIDs []string, subject, content string) error {
var recipientEmails []string
for i := 0; i < len(userUUIDs); i++ {
userEmail, err := e.userStore.GetEmail(0, userUUIDs[i])
if err != nil {
e.lo.Error("error fetching user email for user uuid", "error", err)
return err
}
recipientEmails = append(recipientEmails, userEmail)
}
// Render with default template.
templateBody, templateSubject, err := e.TemplateRenderer.RenderDefault(map[string]string{
"Content": content,
})
if err != nil {
return err
}
if subject == "" {
subject = templateSubject
}
m := models.Message{
Subject: subject,
Content: templateBody,
From: e.from,
To: recipientEmails,
}
err = e.Send(m)
if err != nil {
e.lo.Error("error sending email notification", "error", err)
return err
}
return nil
}
func (e *Notifier) SendAssignedConversationNotification(userUUIDs []string, convUUID string) error {
subject := "New conversation assigned to you"
link := fmt.Sprintf("http://localhost:5173/conversations/%s", convUUID)
content := fmt.Sprintf("A new conversation has been assigned to you. <br>Please review the details and take necessary action by following this link: %s", link)
return e.SendMessage(userUUIDs, subject, content)
}
// Send sends an email message using one of the SMTP pools.
func (e *Notifier) Send(m models.Message) error {
var (
ln = len(e.smtpPools)
srv *smtppool.Pool
)
if ln > 1 {
srv = e.smtpPools[rand.Intn(ln)]
} else {
srv = e.smtpPools[0]
}
var files []smtppool.Attachment
if m.Attachments != nil {
files = make([]smtppool.Attachment, 0, len(m.Attachments))
for _, f := range m.Attachments {
a := smtppool.Attachment{
Filename: f.Filename,
Header: f.Header,
Content: make([]byte, len(f.Content)),
}
copy(a.Content, f.Content)
files = append(files, a)
}
}
em := smtppool.Email{
From: m.From,
To: m.To,
Subject: m.Subject,
Attachments: files,
Headers: textproto.MIMEHeader{},
}
for k, v := range m.Headers {
em.Headers.Set(k, v[0])
}
switch m.ContentType {
case "plain":
em.Text = []byte(m.Content)
default:
em.HTML = []byte(m.Content)
if len(m.AltContent) > 0 {
em.Text = []byte(m.AltContent)
}
}
return srv.Send(em)
}

View File

@@ -1 +0,0 @@
package models

View File

@@ -1,4 +1,4 @@
package stringutils package stringutil
import ( import (
"crypto/rand" "crypto/rand"
@@ -23,7 +23,7 @@ func RandomAlNumString(n int) (string, error) {
return string(bytes), nil return string(bytes), nil
} }
// RandomNumericString generates a random digit string of length n. // RandomNumericString generates a random digit numeric string of length n.
func RandomNumericString(n int) (string, error) { func RandomNumericString(n int) (string, error) {
const ( const (
dictionary = "0123456789" dictionary = "0123456789"

View File

@@ -5,7 +5,7 @@ import (
"fmt" "fmt"
"time" "time"
"github.com/abhinavxd/artemis/internal/dbutils" "github.com/abhinavxd/artemis/internal/dbutil"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/zerodha/logf" "github.com/zerodha/logf"
) )
@@ -16,7 +16,7 @@ var (
) )
type Tag struct { type Tag struct {
ID int `db:"id" json:"id"` ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"` CreatedAt time.Time `db:"created_at" json:"created_at"`
Name string `db:"name" json:"name"` Name string `db:"name" json:"name"`
} }
@@ -40,7 +40,7 @@ type queries struct {
func New(opts Opts) (*Manager, error) { func New(opts Opts) (*Manager, error) {
var q queries var q queries
if err := dbutils.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil { if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
return nil, err return nil, err
} }

View File

@@ -6,7 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/abhinavxd/artemis/internal/dbutils" "github.com/abhinavxd/artemis/internal/dbutil"
umodels "github.com/abhinavxd/artemis/internal/user/models" umodels "github.com/abhinavxd/artemis/internal/user/models"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/zerodha/logf" "github.com/zerodha/logf"
@@ -42,7 +42,7 @@ type queries struct {
func New(opts Opts) (*Manager, error) { func New(opts Opts) (*Manager, error) {
var q queries var q queries
if err := dbutils.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil { if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
return nil, err return nil, err
} }

View File

@@ -0,0 +1,6 @@
package models
type Template struct {
Body string `db:"body"`
Subject string `db:"subject"`
}

View File

@@ -0,0 +1,10 @@
-- name: insert-template
INSERT INTO templates
("name", subject, body, is_default)
VALUES($1, $2, $3, $4);
-- name: get-template
select id, name, subject, body from templates where name = $1;
-- name: get-default-template
select id, name, subject, body from templates where is_default is true;

View File

@@ -0,0 +1,26 @@
package template
import (
"bytes"
"text/template"
)
// RenderDefault renders the system default template with the data.
func (m *Manager) RenderDefault(data interface{}) (string, string, error) {
templ, err := m.GetDefaultTemplate()
if err != nil {
return "", "", err
}
tmpl, err := template.New("").Parse(templ.Body)
if err != nil {
return "", "", err
}
var rendered bytes.Buffer
if err := tmpl.Execute(&rendered, data); err != nil {
return "", "", err
}
return rendered.String(), templ.Subject, nil
}

View File

@@ -0,0 +1,57 @@
package template
import (
"embed"
"github.com/abhinavxd/artemis/internal/dbutil"
"github.com/abhinavxd/artemis/internal/template/models"
"github.com/jmoiron/sqlx"
)
var (
//go:embed queries.sql
efs embed.FS
)
type Manager struct {
q queries
}
type queries struct {
InsertTemplate *sqlx.Stmt `query:"insert-template"`
GetTemplate *sqlx.Stmt `query:"get-template"`
GetDefaultTemplate *sqlx.Stmt `query:"get-default-template"`
}
func New(db *sqlx.DB) (*Manager, error) {
var q queries
if err := dbutil.ScanSQLFile("queries.sql", &q, db, efs); err != nil {
return nil, err
}
return &Manager{q}, nil
}
func (m *Manager) InsertTemplate(name, subject, body string) error {
if _, err := m.q.InsertTemplate.Exec(name, subject, body); err != nil {
return err
}
return nil
}
func (m *Manager) GetTemplate(name string) (models.Template, error) {
var template models.Template
if err := m.q.GetTemplate.Get(&template, name); err != nil {
return template, err
}
return template, nil
}
func (m *Manager) GetDefaultTemplate() (models.Template, error) {
var template models.Template
if err := m.q.GetDefaultTemplate.Get(&template); err != nil {
return template, err
}
return template, nil
}

View File

@@ -1,42 +0,0 @@
package filterstore
import (
"embed"
"github.com/abhinavxd/artemis/internal/dbutils"
"github.com/abhinavxd/artemis/internal/user/filterstore/models"
"github.com/jmoiron/sqlx"
)
var (
//go:embed queries.sql
efs embed.FS
)
type Manager struct {
q queries
}
type queries struct {
GetFilters *sqlx.Stmt `query:"get-user-filters"`
}
func New(db *sqlx.DB) (*Manager, error) {
var q queries
if err := dbutils.ScanSQLFile("queries.sql", &q, db, efs); err != nil {
return nil, err
}
return &Manager{
q: q,
}, nil
}
func (m *Manager) GetFilters(userID int, page string) ([]models.Filter, error) {
var filters []models.Filter
if err := m.q.GetFilters.Select(&filters, userID, page); err != nil {
return filters, err
}
return filters, nil
}

View File

@@ -1,10 +0,0 @@
package models
import "encoding/json"
type Filter struct {
ID int `db:"id" json:"id"`
UserID int `db:"user_id" json:"user_id"`
Page string `db:"page" json:"page"`
Filters json.RawMessage `db:"filters" json:"filters"`
}

View File

@@ -1,2 +0,0 @@
-- name: get-user-filters
SELECT * from user_filters where user_id = $1 and page = $2;

View File

@@ -1,6 +1,9 @@
-- name: get-users -- name: get-users
SELECT first_name, last_name, uuid, disabled from users; SELECT first_name, last_name, uuid, disabled from users;
-- name: get-email
SELECT email from users where CASE WHEN $1 > 0 THEN id = $1 ELSE uuid = $2 END;
-- name: get-user-by-email -- name: get-user-by-email
select id, email, password, avatar_url, first_name, last_name, uuid from users where email = $1; select id, email, password, avatar_url, first_name, last_name, uuid from users where email = $1;

View File

@@ -7,7 +7,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/abhinavxd/artemis/internal/dbutils" "github.com/abhinavxd/artemis/internal/dbutil"
"github.com/abhinavxd/artemis/internal/user/models" "github.com/abhinavxd/artemis/internal/user/models"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/zerodha/logf" "github.com/zerodha/logf"
@@ -39,6 +39,7 @@ type Opts struct {
type queries struct { type queries struct {
GetUsers *sqlx.Stmt `query:"get-users"` GetUsers *sqlx.Stmt `query:"get-users"`
GetUser *sqlx.Stmt `query:"get-user"` GetUser *sqlx.Stmt `query:"get-user"`
GetEmail *sqlx.Stmt `query:"get-email"`
GetUserByEmail *sqlx.Stmt `query:"get-user-by-email"` GetUserByEmail *sqlx.Stmt `query:"get-user-by-email"`
SetUserPassword *sqlx.Stmt `query:"set-user-password"` SetUserPassword *sqlx.Stmt `query:"set-user-password"`
} }
@@ -46,7 +47,7 @@ type queries struct {
func New(opts Opts) (*Manager, error) { func New(opts Opts) (*Manager, error) {
var q queries var q queries
if err := dbutils.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil { if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
return nil, err return nil, err
} }
@@ -110,6 +111,23 @@ func (u *Manager) GetUser(id int, uuid string) (models.User, error) {
return user, nil return user, nil
} }
func (u *Manager) GetEmail(id int, uuid string) (string, error) {
var uu interface{}
if uuid != "" {
uu = uuid
}
var email string
if err := u.q.GetEmail.Get(&email, id, uu); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return email, fmt.Errorf("user not found")
}
u.lo.Error("error fetching user from db", "error", err)
return email, fmt.Errorf("fetching user: %w", err)
}
return email, nil
}
func (u *Manager) verifyPassword(pwd []byte, pwdHash string) error { func (u *Manager) verifyPassword(pwd []byte, pwdHash string) error {
err := bcrypt.CompareHashAndPassword([]byte(pwdHash), pwd) err := bcrypt.CompareHashAndPassword([]byte(pwdHash), pwd)
if err != nil { if err != nil {

View File

@@ -3,6 +3,7 @@ package ws
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"sync" "sync"
"time" "time"
@@ -10,6 +11,13 @@ import (
"github.com/fasthttp/websocket" "github.com/fasthttp/websocket"
) )
const (
// SubscribeConversations to last 1000 conversations.
// TODO: Move to config.
maxConversationsPagesToSub = 10
maxConversationsPageSize = 100
)
type SafeBool struct { type SafeBool struct {
flag bool flag bool
mu sync.Mutex mu sync.Mutex
@@ -58,8 +66,6 @@ Loop:
} }
case o, ok := <-c.Send: case o, ok := <-c.Send:
if !ok { if !ok {
// Disconnected.
fmt.Println("Client disconnected, breaking serve lop.")
break Loop break Loop
} }
c.Conn.WriteMessage(o.messageType, o.data) c.Conn.WriteMessage(o.messageType, o.data)
@@ -84,8 +90,6 @@ func (c *Client) Listen() {
return return
} }
} }
fmt.Println("loop broke closing")
c.Hub.RemoveClient(c) c.Hub.RemoveClient(c)
c.close() c.close()
} }
@@ -99,66 +103,95 @@ func (c *Client) processIncomingMessage(b []byte) {
} }
switch r.Action { switch r.Action {
// Sub to conversation updates. case models.ActionConversationsSub:
case models.ActionConvSub: var req = models.ConversationsSubscribe{}
var subR = models.ConvSubUnsubReq{} if err := json.Unmarshal(b, &req); err != nil {
if err := json.Unmarshal(b, &subR); err != nil {
return return
} }
c.SubConv(int(c.ID), subR.UUIDs...)
case models.ActionConvUnsub: // First remove all user conversation subscriptions.
var subR = models.ConvSubUnsubReq{} c.RemoveAllUserConversationSubscriptions(c.ID)
if err := json.Unmarshal(b, &subR); err != nil {
// Add the new subcriptions.
for page := range maxConversationsPagesToSub {
page++
conversationUUIDs, err := c.Hub.conversationStore.GetConversationUUIDs(c.ID, page, maxConversationsPageSize, req.Type, req.PreDefinedFilter)
if err != nil {
log.Println("error fetching convesation ids", err)
continue
}
c.SubscribeConversations(c.ID, conversationUUIDs)
}
case models.ActionConversationSub:
var req = models.ConversationSubscribe{}
if err := json.Unmarshal(b, &req); err != nil {
return return
} }
c.UnsubConv(int(c.ID), subR.UUIDs...) c.SubscribeConversations(c.ID, []string{req.UUID})
case models.ActionAssignedConvSub: case models.ActionConversationUnSub:
// Fetch all assigned conversation & sub. var req = models.ConversationUnsubscribe{}
case models.ActionAssignedConvUnSub: if err := json.Unmarshal(b, &req); err != nil {
// Fetch all unassigned conversation and sub. return
}
c.UnsubscribeConversation(c.ID, req.UUID)
default:
fmt.Println("new incoming ws msg ", string(b))
} }
} }
func (c *Client) close() { func (c *Client) close() {
c.RemoveAllUserConversationSubscriptions(c.ID)
c.Closed.Set(true) c.Closed.Set(true)
close(c.Send) close(c.Send)
} }
func (c *Client) SubConv(userID int, uuids ...string) { func (c *Client) SubscribeConversations(userID int, conversationUUIDs []string) {
c.Hub.SubMut.Lock() for _, conversationUUID := range conversationUUIDs {
defer c.Hub.SubMut.Unlock() // Initialize the slice if it doesn't exist
if c.Hub.ConversationSubs[conversationUUID] == nil {
for _, uuid := range uuids { c.Hub.ConversationSubs[conversationUUID] = []int{}
// Initialize the slice if this is the first subscription for this UUID
if _, ok := c.Hub.Csubs[uuid]; !ok {
c.Hub.Csubs[uuid] = []int{}
} }
// Append the user ID to the slice of subscribed user IDs
c.Hub.Csubs[uuid] = append(c.Hub.Csubs[uuid], userID)
}
}
func (c *Client) UnsubConv(userID int, uuids ...string) { // Check if userID already exists
c.Hub.SubMut.Lock() exists := false
defer c.Hub.SubMut.Unlock() for _, id := range c.Hub.ConversationSubs[conversationUUID] {
if id == userID {
for _, uuid := range uuids { exists = true
currentSubs, ok := c.Hub.Csubs[uuid] break
if !ok {
continue // No subscriptions for this UUID
}
j := 0
for _, sub := range currentSubs {
if sub != userID {
currentSubs[j] = sub
j++
} }
} }
currentSubs = currentSubs[:j] // Update the slice in-place
if len(currentSubs) == 0 { // Add userID if it doesn't exist
delete(c.Hub.Csubs, uuid) // Remove key if no more subscriptions if !exists {
} else { c.Hub.ConversationSubs[conversationUUID] = append(c.Hub.ConversationSubs[conversationUUID], userID)
c.Hub.Csubs[uuid] = currentSubs }
}
}
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
}
}
if len(c.Hub.ConversationSubs[conversationUUID]) == 0 {
delete(c.Hub.ConversationSubs, conversationUUID)
}
}
}
func (c *Client) RemoveAllUserConversationSubscriptions(userID int) {
for conversationID, userIDs := range c.Hub.ConversationSubs {
for i, id := range userIDs {
if id == userID {
c.Hub.ConversationSubs[conversationID] = append(userIDs[:i], userIDs[i+1:]...)
break
}
}
if len(c.Hub.ConversationSubs[conversationID]) == 0 {
delete(c.Hub.ConversationSubs, conversationID)
} }
} }
} }

View File

@@ -1,24 +1,36 @@
package models package models
const ( const (
ActionConvSub = "c_sub" ActionConversationsSub = "conversations_sub"
ActionConvUnsub = "c_unsub" ActionConversationSub = "conversation_sub"
ActionAssignedConvSub = "a_c_sub" ActionConversationUnSub = "conversation_unsub"
ActionAssignedConvUnSub = "a_c_unsub" MessageTypeNewMessage = "new_msg"
MessageTypeMessagePropUpdate = "msg_prop_update"
EventNewMsg = "new_msg" MessageTypeNewConversation = "new_conv"
EventMsgStatusUpdate = "msg_status_update" MessageTypeConversationPropertyUpdate = "conv_prop_update"
) )
type IncomingReq struct { type IncomingReq struct {
Action string `json:"a"` Action string `json:"a"`
} }
type ConversationsSubscribe struct {
Type string `json:"t"`
PreDefinedFilter string `json:"pf"`
}
type ConversationSubscribe struct {
UUID string `json:"uuid"`
}
type ConversationUnsubscribe struct {
UUID string `json:"uuid"`
}
type ConvSubUnsubReq struct { type ConvSubUnsubReq struct {
UUIDs []string `json:"v"` UUIDs []string `json:"v"`
} }
type Event struct { type Message struct {
Type string Type string `json:"typ"`
Data string Data interface{} `json:"d"`
} }

View File

@@ -4,27 +4,34 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"sync" "sync"
"time"
"github.com/abhinavxd/artemis/internal/ws/models" "github.com/abhinavxd/artemis/internal/ws/models"
"github.com/fasthttp/websocket" "github.com/fasthttp/websocket"
) )
// Hub maintains the set of registered clients. // Hub maintains the set of registered clients and their subscriptions.
type Hub struct { type Hub struct {
clients map[int][]*Client clients map[int][]*Client
clientsMutex sync.Mutex clientsMutex sync.Mutex
// Map of conversation uuid to slice of subbed userids. // Map of conversation uuid to a set of subscribed user IDs.
Csubs map[string][]int ConversationSubs map[string][]int
SubMut sync.Mutex
SubMut sync.Mutex
conversationStore ConversationStore
}
type ConversationStore interface {
GetConversationUUIDs(userID, page, pageSize int, typ, predefinedFilter string) ([]string, error)
} }
func NewHub() *Hub { func NewHub() *Hub {
return &Hub{ return &Hub{
clients: make(map[int][]*Client, 100000), clients: make(map[int][]*Client, 10000),
clientsMutex: sync.Mutex{}, clientsMutex: sync.Mutex{},
Csubs: map[string][]int{}, ConversationSubs: make(map[string][]int),
SubMut: sync.Mutex{}, SubMut: sync.Mutex{},
} }
} }
@@ -39,6 +46,10 @@ type PushMessage struct {
MaxUsers int `json:"max_users"` MaxUsers int `json:"max_users"`
} }
func (h *Hub) SetConversationStore(store ConversationStore) {
h.conversationStore = store
}
func (h *Hub) AddClient(c *Client) { func (h *Hub) AddClient(c *Client) {
h.clientsMutex.Lock() h.clientsMutex.Lock()
defer h.clientsMutex.Unlock() defer h.clientsMutex.Unlock()
@@ -59,7 +70,7 @@ func (h *Hub) RemoveClient(client *Client) {
} }
} }
// ClientAlreadyConnected checks if a user id is already connected or not. // ClientAlreadyConnected returns true if the client with this id is already connected else returns false.
func (h *Hub) ClientAlreadyConnected(id int) bool { func (h *Hub) ClientAlreadyConnected(id int) bool {
h.clientsMutex.Lock() h.clientsMutex.Lock()
defer h.clientsMutex.Unlock() defer h.clientsMutex.Unlock()
@@ -75,7 +86,6 @@ func (h *Hub) PushMessage(m PushMessage) {
h.clientsMutex.Lock() h.clientsMutex.Lock()
for _, userID := range m.Users { for _, userID := range m.Users {
for _, c := range h.clients[userID] { for _, c := range h.clients[userID] {
fmt.Printf("Pushing msg to %d", userID)
c.Conn.WriteMessage(websocket.TextMessage, m.Data) c.Conn.WriteMessage(websocket.TextMessage, m.Data)
} }
} }
@@ -97,52 +107,89 @@ func (h *Hub) PushMessage(m PushMessage) {
} }
} }
func (c *Hub) BroadcastNewMsg(convUUID string, msg map[string]interface{}) { func (c *Hub) BroadcastNewConversationMessage(conversationUUID, trimmedMessage, messageUUID, lastMessageAt string, private bool) {
// clientIDs, ok := c.Csubs[convUUID] userIDs, ok := c.ConversationSubs[conversationUUID]
// if !ok || len(clientIDs) == 0 { if !ok || len(userIDs) == 0 {
// return return
// }
clientIDs := []int{1, 2}
data := map[string]interface{}{
"ev": models.EventNewMsg,
"d": msg,
} }
// Marshal. message := models.Message{
dataB, err := json.Marshal(data) Type: models.MessageTypeNewMessage,
Data: map[string]interface{}{
"conversation_uuid": conversationUUID,
"last_message": trimmedMessage,
"uuid": messageUUID,
"last_message_at": lastMessageAt,
"private": private,
},
}
c.marshalAndPush(message, userIDs)
}
func (c *Hub) BroadcastMessagePropUpdate(conversationUUID, messageUUID, prop, value string) {
userIDs, ok := c.ConversationSubs[conversationUUID]
if !ok || len(userIDs) == 0 {
return
}
message := models.Message{
Type: models.MessageTypeMessagePropUpdate,
Data: map[string]interface{}{
"uuid": messageUUID,
"prop": prop,
"val": value,
},
}
c.marshalAndPush(message, userIDs)
}
func (c *Hub) BroadcastConversationAssignment(userID int, conversationUUID string, avatarUrl string, firstName, lastName, lastMessage, inboxName string, lastMessageAt time.Time, unreadMessageCount int) {
message := models.Message{
Type: models.MessageTypeNewConversation,
Data: map[string]interface{}{
"uuid": conversationUUID,
"avatar_url": avatarUrl,
"first_name": firstName,
"last_name": lastName,
"last_message": lastMessage,
"last_message_at": time.Now().Format(time.RFC3339),
"inbox_name": inboxName,
"unread_message_count": unreadMessageCount,
},
}
c.marshalAndPush(message, []int{userID})
}
func (c *Hub) BroadcastConversationPropertyUpdate(conversationUUID string, prop, val string) {
userIDs, ok := c.ConversationSubs[conversationUUID]
if !ok || len(userIDs) == 0 {
return
}
message := models.Message{
Type: models.MessageTypeConversationPropertyUpdate,
Data: map[string]interface{}{
"uuid": conversationUUID,
"prop": prop,
"val": val,
},
}
c.marshalAndPush(message, userIDs)
}
func (c *Hub) marshalAndPush(message models.Message, userIDs []int) {
messageB, err := json.Marshal(message)
if err != nil { if err != nil {
return return
} }
c.PushMessage(PushMessage{ fmt.Println("pushing msg", string(messageB), "type", message.Type, "to_user_ids", userIDs, "connected_userIds", len(c.clients))
Data: dataB,
Users: clientIDs,
})
}
func (c *Hub) BroadcastMsgStatus(convUUID string, msg map[string]interface{}) {
// clientIDs, ok := c.Csubs[convUUID]
// if !ok || len(clientIDs) == 0 {
// return
// }
clientIDs := []int{1, 2}
data := map[string]interface{}{
"ev": models.EventMsgStatusUpdate,
"d": msg,
}
// Marshal.
dataB, err := json.Marshal(data)
if err != nil {
return
}
c.PushMessage(PushMessage{ c.PushMessage(PushMessage{
Data: dataB, Data: messageB,
Users: clientIDs, Users: userIDs,
}) })
} }