From d7fb9be211f28078581d22ff877ea15c291500ec Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Mon, 1 Jul 2024 03:08:45 +0530 Subject: [PATCH] some more commits? --- cmd/attachment.go | 6 +- cmd/conversation.go | 22 +- cmd/handlers.go | 1 - cmd/init.go | 63 +++-- cmd/main.go | 125 +++++---- cmd/messages.go | 8 +- cmd/users.go | 13 - cmd/websocket.go | 16 +- frontend/bun.lockb | Bin 0 -> 340627 bytes frontend/package.json | 1 + frontend/src/App.vue | 5 +- frontend/src/assets/styles/main.scss | 7 +- .../components/conversation/Conversation.vue | 11 +- .../conversation/ConversationSideBar.vue | 2 +- .../src/components/conversation/ReplyBox.vue | 7 +- .../components/conversation/TextEditor.vue | 13 +- .../conversationlist/ConversationList.vue | 42 ++- frontend/src/constants/conversation.js | 1 + frontend/src/stores/conversation.js | 41 ++- frontend/src/views/ConversationView.vue | 28 +- frontend/src/views/UserLoginView.vue | 11 +- frontend/src/websocket.js | 177 +++++++++--- internal/attachment/attachment.go | 4 +- internal/auditlog/auditlog.go | 1 - internal/{rbac/rbac.go => auth/auth.go} | 20 +- internal/{rbac => auth}/queries.sql | 0 internal/autoassigner/autoassigner.go | 215 +++++++------- internal/automation/automation.go | 104 ++++--- internal/automation/models/models.go | 32 ++- internal/automation/preprocessor.go | 142 ---------- internal/automation/processor.go | 153 ++++++++++ internal/automation/queries.sql | 7 +- internal/cannedresp/cannedresp.go | 4 +- internal/contact/contact.go | 4 +- internal/contact/models/models.go | 26 +- internal/conversation/conversations.go | 173 +++++++++--- internal/conversation/models/models.go | 4 +- internal/conversation/queries.sql | 51 +++- internal/conversation/tag/queries.sql | 18 -- internal/conversation/tag/tag.go | 58 ---- internal/{dbutils => dbutil}/query_loader.go | 2 +- internal/inbox/channel/email/imap.go | 1 - internal/inbox/channel/email/smtp.go | 9 +- internal/inbox/inbox.go | 6 +- internal/message/messages.go | 265 +++++++++--------- internal/message/models/models.go | 1 - internal/notification/notification.go | 22 ++ .../notification/providers/email/email.go | 138 +++++++++ internal/rbac/models/models.go | 1 - .../strings.go => stringutil/stringutil.go} | 4 +- internal/tag/tag.go | 6 +- internal/team/team.go | 4 +- internal/template/models/models.go | 6 + internal/template/queries.sql | 10 + internal/template/renderer.go | 26 ++ internal/template/template.go | 57 ++++ internal/user/filterstore/filterstore.go | 42 --- internal/user/filterstore/models/models.go | 10 - internal/user/filterstore/queries.sql | 2 - internal/user/queries.sql | 3 + internal/user/user.go | 22 +- internal/ws/client.go | 129 +++++---- internal/ws/models/models.go | 32 ++- internal/ws/ws.go | 145 ++++++---- 64 files changed, 1546 insertions(+), 1013 deletions(-) create mode 100755 frontend/bun.lockb delete mode 100644 internal/auditlog/auditlog.go rename internal/{rbac/rbac.go => auth/auth.go} (57%) rename internal/{rbac => auth}/queries.sql (100%) delete mode 100644 internal/automation/preprocessor.go create mode 100644 internal/automation/processor.go delete mode 100644 internal/conversation/tag/queries.sql delete mode 100644 internal/conversation/tag/tag.go rename internal/{dbutils => dbutil}/query_loader.go (97%) create mode 100644 internal/notification/notification.go create mode 100644 internal/notification/providers/email/email.go delete mode 100644 internal/rbac/models/models.go rename internal/{stringutils/strings.go => stringutil/stringutil.go} (88%) create mode 100644 internal/template/models/models.go create mode 100644 internal/template/queries.sql create mode 100644 internal/template/renderer.go create mode 100644 internal/template/template.go delete mode 100644 internal/user/filterstore/filterstore.go delete mode 100644 internal/user/filterstore/models/models.go delete mode 100644 internal/user/filterstore/queries.sql diff --git a/cmd/attachment.go b/cmd/attachment.go index 044bcfdb..fc98ac32 100644 --- a/cmd/attachment.go +++ b/cmd/attachment.go @@ -38,7 +38,7 @@ func handleAttachmentUpload(r *fastglue.Request) error { file, err := form.File["files"][0].Open() srcFileName := form.File["files"][0].Filename 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] if err != nil { app.lo.Error("reading file into the memory", "error", err) @@ -57,7 +57,7 @@ func handleAttachmentUpload(r *fastglue.Request) error { // Reset the ptr. 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 { app.lo.Error("error uploading file", "error", err) return r.SendErrorEnvelope(http.StatusInternalServerError, "Error uploading file", nil, "GeneralException") @@ -68,7 +68,7 @@ func handleAttachmentUpload(r *fastglue.Request) error { "uuid": mediaUUID, "content_type": srcContentType, "name": srcFileName, - "size": strconv.FormatInt(srcFileSize, 10), + "size": srcFileSize, }) } diff --git a/cmd/conversation.go b/cmd/conversation.go index 3938bf4e..4b792046 100644 --- a/cmd/conversation.go +++ b/cmd/conversation.go @@ -125,16 +125,16 @@ func handleUpdateAssignee(r *fastglue.Request) error { userUUID = r.RequestCtx.UserValue("user_uuid").(string) ) - if err := app.conversationMgr.UpdateAssignee(convUUID, assigneeUUID, assigneeType); err != nil { - return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "") - } - - if assigneeType == "agent" { - app.msgMgr.RecordAssigneeUserChange(string(assigneeUUID), convUUID, userUUID) - } - - if assigneeType == "team" { - app.msgMgr.RecordAssigneeTeamChange(string(assigneeUUID), convUUID, userUUID) + if assigneeType == "user" { + if err := app.conversationMgr.UpdateUserAssignee(convUUID, assigneeUUID); err != nil { + return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "") + } + app.msgMgr.RecordAssigneeUserChange(convUUID, string(assigneeUUID), userUUID) + } else if assigneeType == "team" { + if err := app.conversationMgr.UpdateTeamAssignee(convUUID, assigneeUUID); err != nil { + return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "") + } + app.msgMgr.RecordAssigneeTeamChange(convUUID, string(assigneeUUID), userUUID) } return r.SendEnvelope("ok") @@ -188,7 +188,7 @@ func handlAddConversationTags(r *fastglue.Request) error { 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.SendEnvelope("ok") diff --git a/cmd/handlers.go b/cmd/handlers.go index b1bd62ee..677c7b7c 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -28,7 +28,6 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { g.GET("/api/canned-responses", auth(handleGetCannedResponses)) g.GET("/api/attachment/{conversation_uuid}", auth(handleGetAttachment)) g.GET("/api/users/me", auth(handleGetCurrentUser)) - g.GET("/api/users/filters/{page}", auth(handleGetUserFilters)) g.GET("/api/users", auth(handleGetUsers)) g.GET("/api/teams", auth(handleGetTeams)) g.GET("/api/tags", auth(handleGetTags)) diff --git a/cmd/init.go b/cmd/init.go index 61135396..3e222534 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -8,21 +8,22 @@ import ( "github.com/abhinavxd/artemis/internal/attachment" "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/automation" "github.com/abhinavxd/artemis/internal/cannedresp" "github.com/abhinavxd/artemis/internal/contact" "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/channel/email" "github.com/abhinavxd/artemis/internal/initz" "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/team" + "github.com/abhinavxd/artemis/internal/template" "github.com/abhinavxd/artemis/internal/user" - "github.com/abhinavxd/artemis/internal/user/filterstore" "github.com/abhinavxd/artemis/internal/ws" "github.com/go-redis/redis/v8" "github.com/jmoiron/sqlx" @@ -112,8 +113,8 @@ func initUserDB(DB *sqlx.DB, lo *logf.Logger) *user.Manager { return mgr } -func initConversations(db *sqlx.DB, lo *logf.Logger) *conversation.Manager { - c, err := conversation.New(conversation.Opts{ +func initConversations(hub *ws.Hub, db *sqlx.DB, lo *logf.Logger) *conversation.Manager { + c, err := conversation.New(hub, conversation.Opts{ DB: db, Lo: lo, 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 } -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 { mgr, err := tag.New(tag.Opts{ DB: db, @@ -168,6 +158,14 @@ func initContactManager(db *sqlx.DB, lo *logf.Logger) *contact.Manager { 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, lo *logf.Logger, wsHub *ws.Hub, @@ -177,7 +175,9 @@ func initMessages(db *sqlx.DB, attachmentMgr *attachment.Manager, conversationMgr *conversation.Manager, inboxMgr *inbox.Manager, - automationEngine *automation.Engine) *message.Manager { + automationEngine *automation.Engine, + templateManager *template.Manager, +) *message.Manager { mgr, err := message.New( wsHub, userMgr, @@ -187,6 +187,7 @@ func initMessages(db *sqlx.DB, inboxMgr, conversationMgr, automationEngine, + templateManager, message.Opts{ DB: db, Lo: lo, @@ -268,28 +269,36 @@ func initAutomationEngine(db *sqlx.DB, lo *logf.Logger) *automation.Engine { return engine } -func initAutoAssignmentEngine(teamMgr *team.Manager, convMgr *conversation.Manager, msgMgr *message.Manager, lo *logf.Logger) *autoassigner.Engine { - engine, err := autoassigner.New(teamMgr, convMgr, msgMgr, lo) +func initAutoAssignmentEngine(teamMgr *team.Manager, convMgr *conversation.Manager, msgMgr *message.Manager, + notifier notifier.Notifier, hub *ws.Hub, lo *logf.Logger) *autoassigner.Engine { + engine, err := autoassigner.New(teamMgr, convMgr, msgMgr, notifier, hub, lo) if err != nil { log.Fatalf("error initializing auto assignment engine: %v", err) } return engine } -func initRBACEngine(db *sqlx.DB) *rbac.Engine { - engine, err := rbac.New(db) +func initRBACEngine(db *sqlx.DB) *uauth.Engine { + engine, err := uauth.New(db, &logf.Logger{}) if err != nil { log.Fatalf("error initializing rbac enginer: %v", err) } return engine } -func initUserFilterMgr(db *sqlx.DB) *filterstore.Manager { - filterMgr, err := filterstore.New(db) - if err != nil { - log.Fatalf("error initializing user filter manager: %v", err) +func initNotifier(userStore notifier.UserStore, templateRenderer notifier.TemplateRenderer) notifier.Notifier { + var smtpCfg email.SMTPConfig + if err := ko.UnmarshalWithConf("notification.provider.email", &smtpCfg, koanf.UnmarshalConf{Tag: "json"}); err != nil { + 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. @@ -327,7 +336,7 @@ func initEmailInbox(inboxRecord inbox.InboxRecord, store inbox.MessageStore) (in 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 } diff --git a/cmd/main.go b/cmd/main.go index 6e797ba3..89a7c54b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -9,18 +9,16 @@ import ( "time" "github.com/abhinavxd/artemis/internal/attachment" + uauth "github.com/abhinavxd/artemis/internal/auth" "github.com/abhinavxd/artemis/internal/cannedresp" "github.com/abhinavxd/artemis/internal/contact" "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/initz" "github.com/abhinavxd/artemis/internal/message" - "github.com/abhinavxd/artemis/internal/rbac" "github.com/abhinavxd/artemis/internal/tag" "github.com/abhinavxd/artemis/internal/team" "github.com/abhinavxd/artemis/internal/user" - "github.com/abhinavxd/artemis/internal/user/filterstore" "github.com/abhinavxd/artemis/internal/ws" "github.com/knadh/koanf/v2" "github.com/valyala/fasthttp" @@ -29,25 +27,31 @@ import ( "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. type App struct { - constants consts - lo *logf.Logger - cntctMgr *contact.Manager - userMgr *user.Manager - teamMgr *team.Manager - sessMgr *simplesessions.Manager - tagMgr *tag.Manager - msgMgr *message.Manager - rbac *rbac.Engine - userFilterMgr *filterstore.Manager - inboxMgr *inbox.Manager - attachmentMgr *attachment.Manager - cannedRespMgr *cannedresp.Manager - conversationMgr *conversation.Manager - conversationTagsMgr *convtag.Manager + constants consts + lo *logf.Logger + cntctMgr *contact.Manager + userMgr *user.Manager + teamMgr *team.Manager + sessMgr *simplesessions.Manager + tagMgr *tag.Manager + msgMgr *message.Manager + rbac *uauth.Engine + inboxMgr *inbox.Manager + attachmentMgr *attachment.Manager + cannedRespMgr *cannedresp.Manager + conversationMgr *conversation.Manager } func main() { @@ -58,62 +62,65 @@ func main() { initz.Config(ko) var ( - shutdownCh = make(chan struct{}) - 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") - rd = initz.Redis(ko) - db = initz.DB(ko) - + shutdownCh = make(chan struct{}) + 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") + rd = initz.Redis(ko) + db = initz.DB(ko) wsHub = ws.NewHub() + templateMgr = initTemplateMgr(db) attachmentMgr = initAttachmentsManager(db, lo) cntctMgr = initContactManager(db, lo) inboxMgr = initInboxManager(db, lo) teamMgr = initTeamMgr(db, lo) userMgr = initUserDB(db, lo) - conversationMgr = initConversations(db, lo) + notifier = initNotifier(userMgr, templateMgr) + conversationMgr = initConversations(wsHub, db, lo) automationEngine = initAutomationEngine(db, lo) - msgMgr = initMessages(db, lo, wsHub, userMgr, teamMgr, cntctMgr, attachmentMgr, conversationMgr, inboxMgr, automationEngine) - autoAssignerEngine = initAutoAssignmentEngine(teamMgr, conversationMgr, msgMgr, lo) + msgMgr = initMessages(db, lo, wsHub, userMgr, teamMgr, cntctMgr, attachmentMgr, conversationMgr, inboxMgr, automationEngine, templateMgr) + 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. registerInboxes(inboxMgr, msgMgr) - automationEngine.SetMsgRecorder(msgMgr) - automationEngine.SetConvUpdater(conversationMgr) + // Set conversation store for the websocket hub. + wsHub.SetConversationStore(conversationMgr) + + // Set stores for the automation engine. + automationEngine.SetMessageStore(msgMgr) + automationEngine.SetConversationStore(conversationMgr) // Start receivers for all active inboxes. inboxMgr.Receive(ctx) - // Start inserting incoming msgs and dispatch pending outgoing messages. - 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. + // Start automation rule evaluation engine. go automationEngine.Serve(ctx) // Start conversation auto assigner engine. 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. g := fastglue.NewGlue() @@ -137,20 +144,18 @@ func main() { // Wait for the interruption signal <-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(7 * time.Second) + time.Sleep(5 * time.Second) // Signal to shutdown the server shutdownCh <- struct{}{} stop() }() - // Starting the server and waiting for the shutdown signal - log.Printf("🚀 server listening on %s %s", ko.String("app.server.address"), ko.String("app.server.socket")) + log.Printf("%s🚀 server listening on %s %s\x1b[0m", colourGreen, 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 { log.Fatalf("error starting frontend server: %v", err) } - log.Println("Server shutdown completed") } diff --git a/cmd/messages.go b/cmd/messages.go index 67022bc9..eeadcaa7 100644 --- a/cmd/messages.go +++ b/cmd/messages.go @@ -3,7 +3,6 @@ package main import ( "encoding/json" "net/http" - "time" "github.com/abhinavxd/artemis/internal/attachment/models" "github.com/abhinavxd/artemis/internal/message" @@ -108,13 +107,10 @@ func handleSendMessage(r *fastglue.Request) error { // Update conversation meta with the last message details. trimmedMessage := app.msgMgr.TrimMsg(msg.Content) - app.conversationMgr.UpdateMeta(0, conversationUUID, map[string]string{ - "last_message": trimmedMessage, - "last_message_at": msg.CreatedAt.Format(time.RFC3339), - }) + app.conversationMgr.UpdateLastMessage(0, conversationUUID, trimmedMessage, msg.CreatedAt) // Send WS update. - app.msgMgr.BroadcastNewMsg(msg, trimmedMessage) + app.msgMgr.BroadcastNewConversationMessage(msg, trimmedMessage) return r.SendEnvelope("Message sent") } diff --git a/cmd/users.go b/cmd/users.go index cdb0766e..9885dc18 100644 --- a/cmd/users.go +++ b/cmd/users.go @@ -28,16 +28,3 @@ func handleGetCurrentUser(r *fastglue.Request) error { } 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) -} diff --git a/cmd/websocket.go b/cmd/websocket.go index e81d4f82..808bd556 100644 --- a/cmd/websocket.go +++ b/cmd/websocket.go @@ -34,23 +34,9 @@ func handleWS(r *fastglue.Request, hub *ws.Hub) error { ID: userID, Hub: hub, 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) - go c.Listen() c.Serve(2 * time.Second) }) diff --git a/frontend/bun.lockb b/frontend/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..50353a5f305e1ac0381af738ae46c3aeb11c5750 GIT binary patch literal 340627 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 Nj(& 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 literal 0 HcmV?d00001 diff --git a/frontend/package.json b/frontend/package.json index 2d74a39e..7b4aeb36 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -45,6 +45,7 @@ "tailwindcss-animate": "^1.0.7", "tiptap-extension-resize-image": "^1.1.5", "vue": "^3.4.15", + "vue-draggable-resizable": "^3.0.0", "vue-i18n": "9", "vue-letter": "^0.2.0", "vue-router": "^4.2.5" diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 99ca1bae..f12cd5f0 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -29,10 +29,10 @@ import { ref, onMounted } from "vue" import { RouterView, useRouter } from 'vue-router' import { cn } from '@/lib/utils' import { useUserStore } from '@/stores/user' -import { initWS } from "./websocket.js" +import { initWS } from "@/websocket.js" import { Toaster } from '@/components/ui/toast' -import NavBar from './components/NavBar.vue' +import NavBar from '@/components/NavBar.vue' import { ResizableHandle, ResizablePanel, @@ -43,7 +43,6 @@ import { } from '@/components/ui/tooltip' - const isCollapsed = ref(false) const navLinks = [ { diff --git a/frontend/src/assets/styles/main.scss b/frontend/src/assets/styles/main.scss index f03f712c..4aae2c0f 100644 --- a/frontend/src/assets/styles/main.scss +++ b/frontend/src/assets/styles/main.scss @@ -174,10 +174,9 @@ $editorContainerId: 'editor-container'; } } -.tiptap-editor-image { - width: 100px; - height: 200px; -} +// .tiptap-editor-image { +// width: 60%; +// } .box { box-shadow: rgb(243, 243, 243) 1px 1px 0px 0px; diff --git a/frontend/src/components/conversation/Conversation.vue b/frontend/src/components/conversation/Conversation.vue index c5a51c61..9887127b 100644 --- a/frontend/src/components/conversation/Conversation.vue +++ b/frontend/src/components/conversation/Conversation.vue @@ -1,8 +1,8 @@ diff --git a/frontend/src/components/conversation/ConversationSideBar.vue b/frontend/src/components/conversation/ConversationSideBar.vue index f8d04563..eb703ee8 100644 --- a/frontend/src/components/conversation/ConversationSideBar.vue +++ b/frontend/src/components/conversation/ConversationSideBar.vue @@ -315,7 +315,7 @@ onMounted(() => { }) const handleAssignedAgentChange = (v) => { - conversationStore.updateAssignee("agent", { + conversationStore.updateAssignee("user", { "assignee_uuid": v.split(":")[0] }) } diff --git a/frontend/src/components/conversation/ReplyBox.vue b/frontend/src/components/conversation/ReplyBox.vue index a95bf459..6ff8bb3d 100644 --- a/frontend/src/components/conversation/ReplyBox.vue +++ b/frontend/src/components/conversation/ReplyBox.vue @@ -1,5 +1,6 @@ diff --git a/frontend/src/constants/conversation.js b/frontend/src/constants/conversation.js index b3f0e110..9dbb7703 100644 --- a/frontend/src/constants/conversation.js +++ b/frontend/src/constants/conversation.js @@ -1,4 +1,5 @@ export const CONVERSATION_PRE_DEFINED_FILTERS = { + ALL: "status_all", STATUS_OPEN: 'status_open', STATUS_PROCESSING: 'status_processing', STATUS_SPAM: 'status_spam', diff --git a/frontend/src/stores/conversation.js b/frontend/src/stores/conversation.js index cd705c4c..fd72ffd4 100644 --- a/frontend/src/stores/conversation.js +++ b/frontend/src/stores/conversation.js @@ -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. - if (conversation?.data?.uuid === msg.conversation_uuid) { - // Fetch entire msg if the give msg does not exist in the msg list. - if (!messages.data.some(message => message.uuid === msg.uuid)) { - fetchParticipants(msg.conversation_uuid) - fetchMessage(msg.uuid) - updateAssigneeLastSeen(msg.conversation_uuid) + if (conversation?.data?.uuid === message.conversation_uuid) { + // Fetch entire message if the give msg does not exist in the msg list. + if (!messages.data.some(msg => msg.uuid === message.uuid)) { + fetchParticipants(message.conversation_uuid) + fetchMessage(message.uuid) + updateAssigneeLastSeen(message.conversation_uuid) } } } - function updateMessageStatus (uuid, status) { - const message = messages.data.find(m => m.uuid === uuid) - if (message) { - message.status = status + function updateMessageProp (message) { + const existingMessage = messages.data.find(m => m.uuid === message.uuid) + if (existingMessage) { + existingMessage[message.prop] = message.val } } @@ -305,5 +322,5 @@ export const useConversationStore = defineStore('conversation', () => { 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 }; }) diff --git a/frontend/src/views/ConversationView.vue b/frontend/src/views/ConversationView.vue index 5cd35748..e4ebd0e9 100644 --- a/frontend/src/views/ConversationView.vue +++ b/frontend/src/views/ConversationView.vue @@ -18,6 +18,8 @@ diff --git a/frontend/src/views/UserLoginView.vue b/frontend/src/views/UserLoginView.vue index f4562256..d72b3a61 100644 --- a/frontend/src/views/UserLoginView.vue +++ b/frontend/src/views/UserLoginView.vue @@ -9,7 +9,7 @@ -

+
- +
- +
@@ -43,7 +44,7 @@ diff --git a/frontend/src/websocket.js b/frontend/src/websocket.js index ce3c2afb..876e38ba 100644 --- a/frontend/src/websocket.js +++ b/frontend/src/websocket.js @@ -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() - // Create a new WebSocket connection to the specified WebSocket URL - const socket = new WebSocket('ws://localhost:9009/api/ws'); + // Initialize the WebSocket connection + function initializeWebSocket() { + socket = new WebSocket('ws://localhost:9009/api/ws') - // Connection opened event - socket.addEventListener('open', function (event) { - // Send a message to the server once the connection is opened - socket.send('Hello, server!!'); - }); - - // 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) - 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}`); + // Connection opened event + socket.addEventListener('open', function () { + console.log('WebSocket connection established') + reconnectInterval = 1000 // Reset the reconnection interval + if (reconnectTimeout) { + clearTimeout(reconnectTimeout) // Clear any existing reconnection timeout + reconnectTimeout = null } + }) + + // 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) { - console.error('WebSocket error observed:', event); - console.log('WebSocket readyState:', socket.readyState); // Log the state of the WebSocket - }); +function waitForWebSocketOpen(socket, callback) { + if (socket.readyState === WebSocket.OPEN) { + callback() + } else { + socket.addEventListener('open', function handler() { + socket.removeEventListener('open', handler) + callback() + }) + } +} +export function sendMessage(message) { + waitForWebSocketOpen(socket, () => { + socket.send(JSON.stringify(message)) + }) +} - // Handle the connection close event - socket.addEventListener('close', function (event) { - console.log('WebSocket connection closed!:', event); - }); +export function subscribeConversations(type, preDefinedFilter) { + let message = { + a: 'conversations_sub', + t: type, + pf: preDefinedFilter + } - socket.onerror = function (event) { - console.error("WebSocket error:", event); - }; - socket.onclose = function (event) { - console.log("WebSocket connection closed:", event); - }; -} \ No newline at end of file + 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)) + }) +} diff --git a/internal/attachment/attachment.go b/internal/attachment/attachment.go index fe64744b..61510f0c 100644 --- a/internal/attachment/attachment.go +++ b/internal/attachment/attachment.go @@ -8,7 +8,7 @@ import ( "net/textproto" "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/zerodha/logf" ) @@ -56,7 +56,7 @@ func New(opt Opts) (*Manager, error) { // 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 &Manager{ diff --git a/internal/auditlog/auditlog.go b/internal/auditlog/auditlog.go deleted file mode 100644 index 7fd2171e..00000000 --- a/internal/auditlog/auditlog.go +++ /dev/null @@ -1 +0,0 @@ -package auditlog \ No newline at end of file diff --git a/internal/rbac/rbac.go b/internal/auth/auth.go similarity index 57% rename from internal/rbac/rbac.go rename to internal/auth/auth.go index 217036c6..0e17cb9a 100644 --- a/internal/rbac/rbac.go +++ b/internal/auth/auth.go @@ -1,10 +1,11 @@ -package rbac +package auth import ( "embed" - "github.com/abhinavxd/artemis/internal/dbutils" + "github.com/abhinavxd/artemis/internal/dbutil" "github.com/jmoiron/sqlx" + "github.com/zerodha/logf" ) var ( @@ -13,31 +14,30 @@ var ( ) type Engine struct { - q queries + q queries + lo *logf.Logger } type queries struct { 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 - - 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 &Engine{ - q: q, + q: q, + lo: lo, }, nil } func (e *Engine) HasPermission(userID int, perm string) (bool, error) { var hasPerm bool - 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, nil } diff --git a/internal/rbac/queries.sql b/internal/auth/queries.sql similarity index 100% rename from internal/rbac/queries.sql rename to internal/auth/queries.sql diff --git a/internal/autoassigner/autoassigner.go b/internal/autoassigner/autoassigner.go index f65fd5d2..fa312f85 100644 --- a/internal/autoassigner/autoassigner.go +++ b/internal/autoassigner/autoassigner.go @@ -8,8 +8,10 @@ import ( "github.com/abhinavxd/artemis/internal/conversation" "github.com/abhinavxd/artemis/internal/conversation/models" "github.com/abhinavxd/artemis/internal/message" + notifier "github.com/abhinavxd/artemis/internal/notification" "github.com/abhinavxd/artemis/internal/systeminfo" "github.com/abhinavxd/artemis/internal/team" + "github.com/abhinavxd/artemis/internal/ws" "github.com/mr-karan/balance" "github.com/zerodha/logf" ) @@ -17,71 +19,48 @@ import ( const ( roundRobinDefaultWeight = 1 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 { teamRoundRobinBalancer map[int]*balance.Balance - mu sync.Mutex // Mutex to protect the balancer map - convMgr *conversation.Manager - teamMgr *team.Manager - msgMgr *message.Manager - lo *logf.Logger - strategy string + userIDs map[string]int + // Mutex to protect the balancer map + mu sync.Mutex + convMgr *conversation.Manager + teamMgr *team.Manager + msgMgr *message.Manager + lo *logf.Logger + hub *ws.Hub + notifier notifier.Notifier + strategy string } // New creates a new instance of the Engine. -func New(teamMgr *team.Manager, convMgr *conversation.Manager, msgMgr *message.Manager, lo *logf.Logger) (*Engine, error) { - balance, err := populateBalancerPool(teamMgr) +func New(teamMgr *team.Manager, convMgr *conversation.Manager, msgMgr *message.Manager, + 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 { return nil, err } - return &Engine{ - teamRoundRobinBalancer: balance, - 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 + e.teamRoundRobinBalancer = balancer + return &e, nil } 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) defer ticker.Stop() - for { select { 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) 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 { +func (e *Engine) RefreshBalancer() error { e.mu.Lock() defer e.mu.Unlock() - balancer, err := populateBalancerPool(e.teamMgr) + balancer, err := e.populateBalancerPool() if err != nil { e.lo.Error("Error updating team balancer pool", "error", err) return err @@ -162,3 +85,79 @@ func (e *Engine) refreshBalancer() error { e.teamRoundRobinBalancer = balancer 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() +} diff --git a/internal/automation/automation.go b/internal/automation/automation.go index 0b05f6b2..3529ac8f 100644 --- a/internal/automation/automation.go +++ b/internal/automation/automation.go @@ -3,11 +3,11 @@ package automation import ( "context" "embed" - "fmt" + "encoding/json" "github.com/abhinavxd/artemis/internal/automation/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/zerodha/logf" ) @@ -17,29 +17,13 @@ var ( efs embed.FS ) -type queries struct { - GetNewConversationRules *sqlx.Stmt `query:"get-rules"` - GetRuleActions *sqlx.Stmt `query:"get-rule-actions"` -} - type Engine struct { - q queries - lo *logf.Logger - convUpdater ConversationUpdater - msgRecorder MessageRecorder - conversationQ chan cmodels.Conversation - rules []models.Rule - 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 + q queries + lo *logf.Logger + conversationStore ConversationStore + messageStore MessageStore + rules []models.Rule + conversationQ chan cmodels.Conversation } type Opts struct { @@ -47,6 +31,20 @@ type Opts struct { 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) { var ( q queries @@ -55,29 +53,24 @@ func New(opt Opts) (*Engine, error) { conversationQ: make(chan cmodels.Conversation, 10000), } ) - - 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 } - - // 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.rules = e.getRules() return e, nil } -func (e *Engine) SetMsgRecorder(msgRecorder MessageRecorder) { - e.msgRecorder = msgRecorder +func (e *Engine) ReloadRules() { + e.rules = e.getRules() } -func (e *Engine) SetConvUpdater(convUpdater ConversationUpdater) { - e.convUpdater = convUpdater +func (e *Engine) SetMessageStore(messageStore MessageStore) { + e.messageStore = messageStore +} + +func (e *Engine) SetConversationStore(conversationStore ConversationStore) { + e.conversationStore = conversationStore } func (e *Engine) Serve(ctx context.Context) { @@ -85,12 +78,39 @@ func (e *Engine) Serve(ctx context.Context) { select { case <-ctx.Done(): return - case conv := <-e.conversationQ: - e.processConversations(conv) + case conversation := <-e.conversationQ: + e.processConversations(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 } diff --git a/internal/automation/models/models.go b/internal/automation/models/models.go index e691e278..48cddfb4 100644 --- a/internal/automation/models/models.go +++ b/internal/automation/models/models.go @@ -3,22 +3,28 @@ package models const ( ActionAssignTeam = "assign_team" ActionAssignAgent = "assign_agent" - - RuleTypeNewConversation = "new_conversation" + OperatorAnd = "AND" + OperatorOR = "OR" ) type Rule struct { - ID int `db:"id"` - Type string `db:"type"` - Field string `db:"field"` - Operator string `db:"operator"` - Value string `db:"value"` - GroupID int `db:"group_id"` - LogicalOp string `db:"logical_op"` + GroupOperator string `json:"group_operator" db:"group_operator"` + Groups []RuleGroup `json:"groups" db:"groups"` + Actions []RuleAction `json:"actions" db:"actions"` } -type Action struct { - RuleID int `db:"rule_id"` - Type string `db:"action_type"` - Action string `db:"action"` +type RuleGroup struct { + LogicalOp string `json:"logical_op" db:"logical_op"` + Rules []RuleDetail `json:"rules" db:"rules"` +} + +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"` } diff --git a/internal/automation/preprocessor.go b/internal/automation/preprocessor.go deleted file mode 100644 index 93f3bc4c..00000000 --- a/internal/automation/preprocessor.go +++ /dev/null @@ -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 -} diff --git a/internal/automation/processor.go b/internal/automation/processor.go new file mode 100644 index 00000000..befeccb1 --- /dev/null +++ b/internal/automation/processor.go @@ -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 +} diff --git a/internal/automation/queries.sql b/internal/automation/queries.sql index 3d2768a7..b509615f 100644 --- a/internal/automation/queries.sql +++ b/internal/automation/queries.sql @@ -1,6 +1,3 @@ -- 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 -inner join engine_condition_groups ecg on ecg.id = ec.group_id; - --- name: get-rule-actions -select rule_id, action_type, action from engine_actions; \ No newline at end of file +select rules +from automation_rules; \ No newline at end of file diff --git a/internal/cannedresp/cannedresp.go b/internal/cannedresp/cannedresp.go index 506f573d..97f1c083 100644 --- a/internal/cannedresp/cannedresp.go +++ b/internal/cannedresp/cannedresp.go @@ -4,7 +4,7 @@ import ( "embed" "fmt" - "github.com/abhinavxd/artemis/internal/dbutils" + "github.com/abhinavxd/artemis/internal/dbutil" "github.com/jmoiron/sqlx" "github.com/zerodha/logf" ) @@ -37,7 +37,7 @@ type queries struct { func New(opts Opts) (*Manager, error) { 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 } diff --git a/internal/contact/contact.go b/internal/contact/contact.go index 711794fc..e2caef47 100644 --- a/internal/contact/contact.go +++ b/internal/contact/contact.go @@ -4,7 +4,7 @@ import ( "embed" "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/zerodha/logf" ) @@ -31,7 +31,7 @@ type queries struct { func New(opts Opts) (*Manager, error) { 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 } diff --git a/internal/contact/models/models.go b/internal/contact/models/models.go index fe308d05..ef989a48 100644 --- a/internal/contact/models/models.go +++ b/internal/contact/models/models.go @@ -1,16 +1,20 @@ package models -import "time" +import ( + "time" + + "github.com/volatiletech/null/v9" +) type Contact struct { - ID int `db:"id" json:"id"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - FirstName string `db:"first_name" json:"first_name"` - LastName string `db:"last_name" json:"last_name"` - Email string `db:"email" json:"email"` - PhoneNumber *string `db:"phone_number" json:"phone_number"` - AvatarURL *string `db:"avatar_url" json:"avatar_url"` - InboxID int `db:"inbox_id" json:"inbox_id"` - Source string `db:"source" json:"source"` - SourceID string `db:"source_id" json:"source_id"` + ID int `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + FirstName string `db:"first_name" json:"first_name"` + LastName string `db:"last_name" json:"last_name"` + Email string `db:"email" json:"email"` + PhoneNumber *string `db:"phone_number" json:"phone_number"` + AvatarURL null.String `db:"avatar_url" json:"avatar_url"` + InboxID int `db:"inbox_id" json:"inbox_id"` + Source string `db:"source" json:"source"` + SourceID string `db:"source_id" json:"source_id"` } diff --git a/internal/conversation/conversations.go b/internal/conversation/conversations.go index 19629ff1..0c2d9ad1 100644 --- a/internal/conversation/conversations.go +++ b/internal/conversation/conversations.go @@ -11,8 +11,9 @@ import ( "time" "github.com/abhinavxd/artemis/internal/conversation/models" - "github.com/abhinavxd/artemis/internal/dbutils" - "github.com/abhinavxd/artemis/internal/stringutils" + "github.com/abhinavxd/artemis/internal/dbutil" + "github.com/abhinavxd/artemis/internal/stringutil" + "github.com/abhinavxd/artemis/internal/ws" "github.com/jmoiron/sqlx" "github.com/lib/pq" "github.com/zerodha/logf" @@ -43,11 +44,23 @@ var ( PriortiyMedium, 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 { lo *logf.Logger db *sqlx.DB + hub *ws.Hub q queries ReferenceNumPattern string } @@ -66,6 +79,7 @@ type queries struct { GetUnassigned *sqlx.Stmt `query:"get-unassigned"` GetConversationParticipants *sqlx.Stmt `query:"get-conversation-participants"` GetConversations string `query:"get-conversations"` + GetConversationsUUIDs string `query:"get-conversations-uuids"` GetAssignedConversations *sqlx.Stmt `query:"get-assigned-conversations"` GetAssigneeStats *sqlx.Stmt `query:"get-assignee-stats"` InsertConverstionParticipant *sqlx.Stmt `query:"insert-conversation-participant"` @@ -77,15 +91,18 @@ type queries struct { UpdatePriority *sqlx.Stmt `query:"update-priority"` UpdateStatus *sqlx.Stmt `query:"update-status"` 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 - 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 } c := &Manager{ q: q, + hub: hub, db: opts.DB, lo: opts.Lo, ReferenceNumPattern: opts.ReferenceNumPattern, @@ -93,16 +110,17 @@ func New(opts Opts) (*Manager, error) { 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 ( id int + uuid string 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) - return id, err + return id, uuid, err } - return id, nil + return id, uuid, nil } func (c *Manager) Get(uuid string) (models.Conversation, error) { @@ -144,24 +162,33 @@ func (c *Manager) AddParticipant(userID int, convUUID string) error { 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) if err != nil { c.lo.Error("error marshalling meta", "error", 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") return err } return nil } -func (c *Manager) UpdateFirstReplyAt(convID int, at time.Time) error { - if _, err := c.q.UpdateFirstReplyAt.Exec(convID, at); err != nil { +func (c *Manager) UpdateLastMessage(conversationID int, conversationUUID, lastMessage string, lastMessageAt time.Time) error { + 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) return err } + // Send ws update. + c.hub.BroadcastConversationPropertyUpdate(conversationUUID, "first_reply_at", time.Now().Format(time.RFC3339)) return nil } @@ -217,24 +244,10 @@ func (c *Manager) GetConversations(userID int, typ, order, orderBy, predefinedFi qArgs []interface{} cond string // TODO: Remove these hardcoded values. - validOrderBy = map[string]bool{"created_at": true, "priority": true, "status": true, "last_message_at": true} - 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'", - } + validOrderBy = map[string]bool{"created_at": true, "priority": true, "status": true, "last_message_at": true} + validOrder = []string{"ASC", "DESC"} ) - if page <= 0 { - page = 1 - } - - if pageSize <= 0 { - pageSize = 20 - } - switch typ { case "assigned": cond = "AND c.assigned_user_id = $1" @@ -251,11 +264,7 @@ func (c *Manager) GetConversations(userID int, typ, order, orderBy, predefinedFi cond += " AND " + filterClause } - // Calculate offset based on page number and page size - offset := (page - 1) * pageSize - qArgs = append(qArgs, pageSize, offset) - - // Ensure orderBy is valid to prevent SQL injection + // Ensure orderBy is valid. orderByClause := "" if _, ok := validOrderBy[orderBy]; ok { orderByClause = fmt.Sprintf(" ORDER BY %s", orderBy) @@ -269,6 +278,16 @@ func (c *Manager) GetConversations(userID int, typ, order, orderBy, predefinedFi 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) defer tx.Rollback() if err != nil { @@ -276,7 +295,7 @@ func (c *Manager) GetConversations(userID int, typ, order, orderBy, predefinedFi 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)) if err := tx.Select(&conversations, sqlQuery, qArgs...); err != nil { 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 } +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) { var conversations []models.Conversation 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 } +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 { switch assigneeType { - case "agent": + case assigneeTypeUser: if _, err := c.q.UpdateAssignedUser.Exec(uuid, assigneeUUID); err != nil { c.lo.Error("updating conversation assignee", "error", err) 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 { c.lo.Error("updating conversation assignee", "error", err) return fmt.Errorf("error updating assignee") } + c.hub.BroadcastConversationPropertyUpdate(uuid, "assigned_team_uuid", string(assigneeUUID)) default: 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 { - if !slices.Contains(priorities, string(priority)) { - return fmt.Errorf("invalid `priority` value %s", priority) + var priorityStr = string(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 { c.lo.Error("updating conversation priority", "error", err) return fmt.Errorf("error updating priority") } + c.hub.BroadcastConversationPropertyUpdate(uuid, "priority", priorityStr) return nil } @@ -332,6 +411,7 @@ func (c *Manager) UpdateStatus(uuid string, status []byte) error { c.lo.Error("updating conversation status", "error", err) return fmt.Errorf("error updating status") } + c.hub.BroadcastConversationPropertyUpdate(uuid, "status", string(status)) return nil } @@ -347,11 +427,26 @@ func (c *Manager) GetAssigneeStats(userID int) (models.ConversationCounts, error 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) { if len(pattern) <= 5 { pattern = "01234567890" } - randomNumbers, err := stringutils.RandomNumericString(len(pattern)) + randomNumbers, err := stringutil.RandomNumericString(len(pattern)) if err != nil { return "", err } diff --git a/internal/conversation/models/models.go b/internal/conversation/models/models.go index 971f5583..1a2936b6 100644 --- a/internal/conversation/models/models.go +++ b/internal/conversation/models/models.go @@ -18,7 +18,7 @@ type Conversation struct { ReferenceNumber null.String `db:"reference_number" json:"reference_number,omitempty"` Priority null.String `db:"priority" json:"priority"` 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:"-"` AssignedTeamID null.Int `db:"assigned_team_id" json:"-"` 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"` AssignedTeamUUID *string `db:"assigned_team_uuid" json:"assigned_team_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"` FirstMessage string `json:"-"` } diff --git a/internal/conversation/queries.sql b/internal/conversation/queries.sql index b22d5566..21e6fe27 100644 --- a/internal/conversation/queries.sql +++ b/internal/conversation/queries.sql @@ -2,7 +2,7 @@ INSERT INTO conversations (reference_number, contact_id, status, inbox_id, meta) VALUES($1, $2, $3, $4, $5) -returning id; +returning id, uuid; -- name: get-conversations @@ -28,6 +28,12 @@ FROM conversations c JOIN inboxes inb on c.inbox_id = inb.id WHERE 1=1 %s +-- name: get-conversations-uuids +SELECT + c.uuid +FROM conversations c +WHERE 1=1 %s + -- name: get-assigned-conversations 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; -- 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 SELECT @@ -148,4 +174,23 @@ WHERE -- name: update-first-reply-at UPDATE conversations SET first_reply_at = $2 -WHERE first_reply_at IS NULL AND id = $1; \ No newline at end of file +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[])); diff --git a/internal/conversation/tag/queries.sql b/internal/conversation/tag/queries.sql deleted file mode 100644 index db11d604..00000000 --- a/internal/conversation/tag/queries.sql +++ /dev/null @@ -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[])); diff --git a/internal/conversation/tag/tag.go b/internal/conversation/tag/tag.go deleted file mode 100644 index 1b47716c..00000000 --- a/internal/conversation/tag/tag.go +++ /dev/null @@ -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 -} diff --git a/internal/dbutils/query_loader.go b/internal/dbutil/query_loader.go similarity index 97% rename from internal/dbutils/query_loader.go rename to internal/dbutil/query_loader.go index 3423bfef..0345a086 100644 --- a/internal/dbutils/query_loader.go +++ b/internal/dbutil/query_loader.go @@ -1,4 +1,4 @@ -package dbutils +package dbutil import ( "io/fs" diff --git a/internal/inbox/channel/email/imap.go b/internal/inbox/channel/email/imap.go index 707ef6c3..4de40f29 100644 --- a/internal/inbox/channel/email/imap.go +++ b/internal/inbox/channel/email/imap.go @@ -122,7 +122,6 @@ func (e *Email) processEnvelope(c *imapclient.Client, env *imap.Envelope, seqNum exists, err := e.msgStore.MessageExists(env.MessageID) if exists || err != nil { - e.lo.Debug("email message already exists, skipping", "message_id", env.MessageID) return nil } diff --git a/internal/inbox/channel/email/smtp.go b/internal/inbox/channel/email/smtp.go index 0d514dc9..6ed8be60 100644 --- a/internal/inbox/channel/email/smtp.go +++ b/internal/inbox/channel/email/smtp.go @@ -13,10 +13,11 @@ import ( ) const ( - headerReturnPath = "Return-Path" - headerMessageID = "Message-ID" - headerReferences = "References" - headerInReplyTo = "In-Reply-To" + headerReturnPath = "Return-Path" + headerMessageID = "Message-ID" + headerReferences = "References" + headerInReplyTo = "In-Reply-To" + dispositionInline = "inline" ) // New returns an SMTP e-mail channels from the given SMTP server configcfg. diff --git a/internal/inbox/inbox.go b/internal/inbox/inbox.go index 9705c99d..1ccae633 100644 --- a/internal/inbox/inbox.go +++ b/internal/inbox/inbox.go @@ -6,7 +6,7 @@ import ( "encoding/json" "errors" - "github.com/abhinavxd/artemis/internal/dbutils" + "github.com/abhinavxd/artemis/internal/dbutil" "github.com/abhinavxd/artemis/internal/message/models" "github.com/jmoiron/sqlx" "github.com/zerodha/logf" @@ -20,7 +20,7 @@ var ( ErrInboxNotFound = errors.New("inbox not found") ) -// Closer provides function for closing a channel. +// Closer provides function for closing an inbox. type Closer interface { Close() error } @@ -83,7 +83,7 @@ func New(lo *logf.Logger, db *sqlx.DB) (*Manager, error) { var q queries // 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 } diff --git a/internal/message/messages.go b/internal/message/messages.go index 47688986..8c50e9a2 100644 --- a/internal/message/messages.go +++ b/internal/message/messages.go @@ -16,13 +16,13 @@ import ( "github.com/abhinavxd/artemis/internal/automation" "github.com/abhinavxd/artemis/internal/contact" "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" - "github.com/abhinavxd/artemis/internal/dbutils" + "github.com/abhinavxd/artemis/internal/dbutil" "github.com/abhinavxd/artemis/internal/inbox" "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/jmoiron/sqlx" "github.com/k3a/html2text" @@ -66,19 +66,20 @@ const ( ) type Manager struct { - q queries - lo *logf.Logger - contactMgr *contact.Manager - attachmentMgr *attachment.Manager - conversationMgr *conversation.Manager - inboxMgr *inbox.Manager - userMgr *user.Manager - teamMgr *team.Manager - automationEngine *automation.Engine - wsHub *ws.Hub - incomingMsgQ chan models.IncomingMessage - outgoingMsgQ chan models.Message - outgoingProcessingMsgs sync.Map + q queries + lo *logf.Logger + contactMgr *contact.Manager + attachmentMgr *attachment.Manager + conversationMgr *conversation.Manager + inboxMgr *inbox.Manager + userMgr *user.Manager + teamMgr *team.Manager + automationEngine *automation.Engine + wsHub *ws.Hub + templateManager *template.Manager + incomingMsgQ chan models.IncomingMessage + outgoingMessageQueue chan models.Message + outgoingProcessingMessages sync.Map } type Opts struct { @@ -109,26 +110,29 @@ func New( inboxMgr *inbox.Manager, conversationMgr *conversation.Manager, automationEngine *automation.Engine, - opts Opts) (*Manager, error) { + templateManager *template.Manager, + opts Opts, +) (*Manager, error) { 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 &Manager{ - q: q, - lo: opts.Lo, - wsHub: wsHub, - userMgr: userMgr, - teamMgr: teamMgr, - contactMgr: contactMgr, - attachmentMgr: attachmentMgr, - conversationMgr: conversationMgr, - inboxMgr: inboxMgr, - automationEngine: automationEngine, - incomingMsgQ: make(chan models.IncomingMessage, opts.IncomingMsgQueueSize), - outgoingMsgQ: make(chan models.Message, opts.OutgoingMsgQueueSize), - outgoingProcessingMsgs: sync.Map{}, + q: q, + lo: opts.Lo, + wsHub: wsHub, + userMgr: userMgr, + teamMgr: teamMgr, + contactMgr: contactMgr, + attachmentMgr: attachmentMgr, + conversationMgr: conversationMgr, + inboxMgr: inboxMgr, + automationEngine: automationEngine, + templateManager: templateManager, + incomingMsgQ: make(chan models.IncomingMessage, opts.IncomingMsgQueueSize), + outgoingMessageQueue: make(chan models.Message, opts.OutgoingMsgQueueSize), + outgoingProcessingMessages: sync.Map{}, }, nil } @@ -208,70 +212,74 @@ func (m *Manager) StartDispatcher(ctx context.Context, concurrency int, readInte // Prepare and push the message to the outgoing queue. for _, msg := range pendingMsgs { - m.outgoingProcessingMsgs.Store(msg.ID, msg.ID) - m.outgoingMsgQ <- msg + var err error + 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() { - for msg := range m.outgoingMsgQ { - inbox, err := m.inboxMgr.GetInbox(msg.InboxID) + for message := range m.outgoingMessageQueue { + inbox, err := m.inboxMgr.GetInbox(message.InboxID) if err != nil { - m.lo.Error("error fetching inbox", "error", err, "inbox_id", msg.InboxID) - m.outgoingProcessingMsgs.Delete(msg.ID) + m.lo.Error("error fetching inbox", "error", err, "inbox_id", message.InboxID) + m.outgoingProcessingMessages.Delete(message.ID) continue } - msg.From = inbox.FromAddress() + message.From = inbox.FromAddress() - if err := m.attachAttachments(&msg); err != nil { - m.lo.Error("error attaching attachments to msg", "error", err) - m.outgoingProcessingMsgs.Delete(msg.ID) + if err := m.attachAttachments(&message); err != nil { + m.lo.Error("error attaching attachments to message", "error", err) + m.outgoingProcessingMessages.Delete(message.ID) continue } - msg.To, _ = m.GetToAddress(msg.ConversationID, inbox.Channel()) + message.To, _ = m.GetToAddress(message.ConversationID, inbox.Channel()) if inbox.Channel() == "email" { - msg.InReplyTo, _ = m.GetInReplyTo(msg.ConversationID) - m.lo.Debug("set in reply to for outgoing email message", "in_reply_to", msg.InReplyTo) + message.InReplyTo, _ = m.GetInReplyTo(message.ConversationID) } - err = inbox.Send(msg) + err = inbox.Send(message) var newStatus = StatusSent if err != nil { 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 { - m.lo.Error("error updating message status in DB", "error", err, "inbox_id", msg.InboxID) + if _, err := m.q.UpdateMessageStatus.Exec(newStatus, message.UUID); err != nil { + m.lo.Error("error updating message status in DB", "error", err, "inbox_id", message.InboxID) } switch newStatus { case StatusSent: - m.lo.Debug("updating first reply at", "conv_id", msg.ConversationID, "at", msg.CreatedAt) - m.conversationMgr.UpdateFirstReplyAt(msg.ConversationID, msg.CreatedAt) + m.conversationMgr.UpdateFirstReplyAt(message.ConversationUUID, message.ConversationID, message.CreatedAt) } - // Broadcast the new message status. - m.wsHub.BroadcastMsgStatus(msg.ConversationUUID, map[string]interface{}{ - "uuid": msg.UUID, - "conversation_uuid": msg.ConversationUUID, - "status": newStatus, - }) + // Broadcast message status update to the subscribers. + m.wsHub.BroadcastMessagePropUpdate(message.ConversationUUID, message.UUID, "status" /*message field*/, 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) { var addr []string 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, nil @@ -281,10 +289,10 @@ func (m *Manager) GetInReplyTo(convID int) (string, error) { var out string if err := m.q.GetInReplyTo.Get(&out, convID); err != nil { 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 } - 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, nil @@ -311,37 +319,36 @@ func (m *Manager) InsertWorker(ctx context.Context) { } } -func (m *Manager) RecordAssigneeUserChange(updatedValue, convUUID, actorUUID string) error { - if updatedValue == actorUUID { - return m.RecordActivity(ActivitySelfAssign, updatedValue, convUUID, actorUUID) +func (m *Manager) RecordAssigneeUserChange(conversationUUID, assigneeUUID, actorUUID string) error { + // Self assign. + 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 { - 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 } - updatedValue = assignee.FullName() - return m.RecordActivity(ActivityAssignedUserChange, updatedValue, convUUID, actorUUID) + return m.RecordActivity(ActivityAssignedUserChange, assignee.FullName() /*new_value*/, conversationUUID, actorUUID) } -func (m *Manager) RecordAssigneeTeamChange(updatedValue, convUUID, actorUUID string) error { - team, err := m.teamMgr.GetTeam(updatedValue) +func (m *Manager) RecordAssigneeTeamChange(conversationUUID, value, actorUUID string) error { + team, err := m.teamMgr.GetTeam(value) if err != nil { return err } - updatedValue = team.Name - return m.RecordActivity(ActivityAssignedTeamChange, updatedValue, convUUID, actorUUID) + return m.RecordActivity(ActivityAssignedTeamChange, team.Name /*new_value*/, conversationUUID, actorUUID) } -func (m *Manager) RecordPriorityChange(updatedValue, convUUID, actorUUID string) error { - return m.RecordActivity(ActivityPriorityChange, updatedValue, convUUID, actorUUID) +func (m *Manager) RecordPriorityChange(updatedValue, conversationUUID, actorUUID string) error { + return m.RecordActivity(ActivityPriorityChange, updatedValue, conversationUUID, actorUUID) } -func (m *Manager) RecordStatusChange(updatedValue, convUUID, actorUUID string) error { - return m.RecordActivity(ActivityStatusChange, updatedValue, convUUID, actorUUID) +func (m *Manager) RecordStatusChange(updatedValue, conversationUUID, actorUUID string) error { + 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 ( actor, err = m.userMgr.GetUser(0, actorUUID) ) @@ -350,7 +357,7 @@ func (m *Manager) RecordActivity(activityType, updatedValue, conversationUUID, a return err } - var content = m.getActivityContent(activityType, updatedValue, actor.FullName()) + var content = m.getActivityContent(activityType, newValue, actor.FullName()) if content == "" { m.lo.Error("Error invalid activity for recording activity", "activity", activityType) 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.BroadcastNewMsg(msg, "") - + m.BroadcastNewConversationMessage(msg, content) + m.conversationMgr.UpdateLastMessage(0, conversationUUID, content, msg.CreatedAt) return nil } @@ -425,28 +432,33 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error { return nil } - if err = m.findOrCreateConversation(&in.Message, in.InboxID, senderID, convMetaJSON); err != nil { - m.lo.Error("error creating conversation", "error", err) + isNewConversation, err := m.findOrCreateConversation(&in.Message, in.InboxID, senderID, convMetaJSON) + if err != nil { return err } 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) } 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) } // Send WS update. 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. - if in.Message.NewConversation { + // Evaluate automation rules for this new conversation. + if isNewConversation { m.automationEngine.EvaluateRules(cmodels.Conversation{ UUID: in.Message.ConversationUUID, FirstMessage: in.Message.Content, @@ -518,52 +530,53 @@ func (m *Manager) uploadAttachments(in *models.Message) error { 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 ( + new bool + err error conversationID int conversationUUID string - newConv bool - err error ) // Search for existing conversation. - if conversationID == 0 && in.InReplyTo > "" { - conversationID, err = m.findConversationID([]string{in.InReplyTo}) - if err != nil && err != ErrConversationNotFound { - return err - } + sourceIDs := in.References + if in.InReplyTo > "" { + sourceIDs = append(sourceIDs, in.InReplyTo) } - if conversationID == 0 && len(in.References) > 0 { - conversationID, err = m.findConversationID(in.References) - if err != nil && err != ErrConversationNotFound { - return err - } + conversationID, err = m.findConversationID(sourceIDs) + if err != nil && err != ErrConversationNotFound { + return new, err } // Conversation not found, create one. if conversationID == 0 { - newConv = true - conversationID, err = m.conversationMgr.Create(contactID, inboxID, meta) + new = true + conversationID, conversationUUID, err = m.conversationMgr.Create(contactID, inboxID, meta) 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.ConversationUUID = conversationUUID - in.NewConversation = newConv - - return nil + return new, nil } // findConversationID finds the conversation ID from the message source ID. func (m *Manager) findConversationID(sourceIDs []string) (int, error) { + if len(sourceIDs) == 0 { + return 0, ErrConversationNotFound + } var conversationID int if err := m.q.MessageExists.QueryRow(pq.Array(sourceIDs)).Scan(&conversationID); err != nil { if err == sql.ErrNoRows { @@ -583,6 +596,11 @@ func (m *Manager) attachAttachments(msg *models.Message) error { 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. for i, att := range attachments { 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. func (m *Manager) getOutgoingProcessingMsgIDs() []int { 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 { out = append(out, k) } @@ -608,21 +626,6 @@ func (m *Manager) getOutgoingProcessingMsgIDs() []int { return out } -func (m *Manager) BroadcastNewMsg(msg models.Message, trimmedContent string) { - if trimmedContent == "" { - 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, - }) +func (m *Manager) BroadcastNewConversationMessage(message models.Message, trimmedContent string) { + m.wsHub.BroadcastNewConversationMessage(message.ConversationUUID, trimmedContent, message.UUID, time.Now().Format(time.RFC3339), message.Private) } diff --git a/internal/message/models/models.go b/internal/message/models/models.go index 50491fc9..05beb04d 100644 --- a/internal/message/models/models.go +++ b/internal/message/models/models.go @@ -41,7 +41,6 @@ type Message struct { References []string `json:"-"` InReplyTo string `json:"-"` Headers textproto.MIMEHeader `json:"-"` - NewConversation bool `json:"-"` } // IncomingMessage links a message with the contact information and inbox id. diff --git a/internal/notification/notification.go b/internal/notification/notification.go new file mode 100644 index 00000000..0c3a94d9 --- /dev/null +++ b/internal/notification/notification.go @@ -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 +} diff --git a/internal/notification/providers/email/email.go b/internal/notification/providers/email/email.go new file mode 100644 index 00000000..7bafa4f2 --- /dev/null +++ b/internal/notification/providers/email/email.go @@ -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.
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) +} diff --git a/internal/rbac/models/models.go b/internal/rbac/models/models.go deleted file mode 100644 index 2640e7f9..00000000 --- a/internal/rbac/models/models.go +++ /dev/null @@ -1 +0,0 @@ -package models diff --git a/internal/stringutils/strings.go b/internal/stringutil/stringutil.go similarity index 88% rename from internal/stringutils/strings.go rename to internal/stringutil/stringutil.go index d303a016..3a9af1fe 100644 --- a/internal/stringutils/strings.go +++ b/internal/stringutil/stringutil.go @@ -1,4 +1,4 @@ -package stringutils +package stringutil import ( "crypto/rand" @@ -23,7 +23,7 @@ func RandomAlNumString(n int) (string, error) { 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) { const ( dictionary = "0123456789" diff --git a/internal/tag/tag.go b/internal/tag/tag.go index e38693d4..8af09cef 100644 --- a/internal/tag/tag.go +++ b/internal/tag/tag.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/abhinavxd/artemis/internal/dbutils" + "github.com/abhinavxd/artemis/internal/dbutil" "github.com/jmoiron/sqlx" "github.com/zerodha/logf" ) @@ -16,7 +16,7 @@ var ( ) type Tag struct { - ID int `db:"id" json:"id"` + ID int `db:"id" json:"id"` CreatedAt time.Time `db:"created_at" json:"created_at"` Name string `db:"name" json:"name"` } @@ -40,7 +40,7 @@ type queries struct { func New(opts Opts) (*Manager, error) { 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 } diff --git a/internal/team/team.go b/internal/team/team.go index 72c32c42..07f17dc7 100644 --- a/internal/team/team.go +++ b/internal/team/team.go @@ -6,7 +6,7 @@ import ( "errors" "fmt" - "github.com/abhinavxd/artemis/internal/dbutils" + "github.com/abhinavxd/artemis/internal/dbutil" umodels "github.com/abhinavxd/artemis/internal/user/models" "github.com/jmoiron/sqlx" "github.com/zerodha/logf" @@ -42,7 +42,7 @@ type queries struct { func New(opts Opts) (*Manager, error) { 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 } diff --git a/internal/template/models/models.go b/internal/template/models/models.go new file mode 100644 index 00000000..5939006d --- /dev/null +++ b/internal/template/models/models.go @@ -0,0 +1,6 @@ +package models + +type Template struct { + Body string `db:"body"` + Subject string `db:"subject"` +} diff --git a/internal/template/queries.sql b/internal/template/queries.sql new file mode 100644 index 00000000..f37565d7 --- /dev/null +++ b/internal/template/queries.sql @@ -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; \ No newline at end of file diff --git a/internal/template/renderer.go b/internal/template/renderer.go new file mode 100644 index 00000000..d8c09de6 --- /dev/null +++ b/internal/template/renderer.go @@ -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 +} diff --git a/internal/template/template.go b/internal/template/template.go new file mode 100644 index 00000000..c5bba087 --- /dev/null +++ b/internal/template/template.go @@ -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 +} diff --git a/internal/user/filterstore/filterstore.go b/internal/user/filterstore/filterstore.go deleted file mode 100644 index db8b2eba..00000000 --- a/internal/user/filterstore/filterstore.go +++ /dev/null @@ -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 -} diff --git a/internal/user/filterstore/models/models.go b/internal/user/filterstore/models/models.go deleted file mode 100644 index 1e8b0f11..00000000 --- a/internal/user/filterstore/models/models.go +++ /dev/null @@ -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"` -} diff --git a/internal/user/filterstore/queries.sql b/internal/user/filterstore/queries.sql deleted file mode 100644 index af1e88d0..00000000 --- a/internal/user/filterstore/queries.sql +++ /dev/null @@ -1,2 +0,0 @@ --- name: get-user-filters -SELECT * from user_filters where user_id = $1 and page = $2; \ No newline at end of file diff --git a/internal/user/queries.sql b/internal/user/queries.sql index 695a1ecc..479ee037 100644 --- a/internal/user/queries.sql +++ b/internal/user/queries.sql @@ -1,6 +1,9 @@ -- name: get-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 select id, email, password, avatar_url, first_name, last_name, uuid from users where email = $1; diff --git a/internal/user/user.go b/internal/user/user.go index c5b20366..6603a4e0 100644 --- a/internal/user/user.go +++ b/internal/user/user.go @@ -7,7 +7,7 @@ import ( "errors" "fmt" - "github.com/abhinavxd/artemis/internal/dbutils" + "github.com/abhinavxd/artemis/internal/dbutil" "github.com/abhinavxd/artemis/internal/user/models" "github.com/jmoiron/sqlx" "github.com/zerodha/logf" @@ -39,6 +39,7 @@ type Opts struct { type queries struct { GetUsers *sqlx.Stmt `query:"get-users"` GetUser *sqlx.Stmt `query:"get-user"` + GetEmail *sqlx.Stmt `query:"get-email"` GetUserByEmail *sqlx.Stmt `query:"get-user-by-email"` SetUserPassword *sqlx.Stmt `query:"set-user-password"` } @@ -46,7 +47,7 @@ type queries struct { func New(opts Opts) (*Manager, error) { 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 } @@ -110,6 +111,23 @@ func (u *Manager) GetUser(id int, uuid string) (models.User, error) { 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 { err := bcrypt.CompareHashAndPassword([]byte(pwdHash), pwd) if err != nil { diff --git a/internal/ws/client.go b/internal/ws/client.go index 421eb6cc..3287922b 100644 --- a/internal/ws/client.go +++ b/internal/ws/client.go @@ -3,6 +3,7 @@ package ws import ( "encoding/json" "fmt" + "log" "sync" "time" @@ -10,6 +11,13 @@ import ( "github.com/fasthttp/websocket" ) +const ( + // SubscribeConversations to last 1000 conversations. + // TODO: Move to config. + maxConversationsPagesToSub = 10 + maxConversationsPageSize = 100 +) + type SafeBool struct { flag bool mu sync.Mutex @@ -58,8 +66,6 @@ Loop: } case o, ok := <-c.Send: if !ok { - // Disconnected. - fmt.Println("Client disconnected, breaking serve lop.") break Loop } c.Conn.WriteMessage(o.messageType, o.data) @@ -84,8 +90,6 @@ func (c *Client) Listen() { return } } - - fmt.Println("loop broke closing") c.Hub.RemoveClient(c) c.close() } @@ -99,66 +103,95 @@ func (c *Client) processIncomingMessage(b []byte) { } switch r.Action { - // Sub to conversation updates. - case models.ActionConvSub: - var subR = models.ConvSubUnsubReq{} - if err := json.Unmarshal(b, &subR); err != nil { + case models.ActionConversationsSub: + var req = models.ConversationsSubscribe{} + if err := json.Unmarshal(b, &req); err != nil { return } - c.SubConv(int(c.ID), subR.UUIDs...) - case models.ActionConvUnsub: - var subR = models.ConvSubUnsubReq{} - if err := json.Unmarshal(b, &subR); err != nil { + + // First remove all user conversation subscriptions. + c.RemoveAllUserConversationSubscriptions(c.ID) + + // 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 } - c.UnsubConv(int(c.ID), subR.UUIDs...) - case models.ActionAssignedConvSub: - // Fetch all assigned conversation & sub. - case models.ActionAssignedConvUnSub: - // Fetch all unassigned conversation and sub. + c.SubscribeConversations(c.ID, []string{req.UUID}) + case models.ActionConversationUnSub: + var req = models.ConversationUnsubscribe{} + if err := json.Unmarshal(b, &req); err != nil { + return + } + c.UnsubscribeConversation(c.ID, req.UUID) + default: + fmt.Println("new incoming ws msg ", string(b)) } } func (c *Client) close() { + c.RemoveAllUserConversationSubscriptions(c.ID) c.Closed.Set(true) close(c.Send) } -func (c *Client) SubConv(userID int, uuids ...string) { - c.Hub.SubMut.Lock() - defer c.Hub.SubMut.Unlock() - - for _, uuid := range uuids { - // Initialize the slice if this is the first subscription for this UUID - if _, ok := c.Hub.Csubs[uuid]; !ok { - c.Hub.Csubs[uuid] = []int{} +func (c *Client) SubscribeConversations(userID int, conversationUUIDs []string) { + for _, conversationUUID := range conversationUUIDs { + // Initialize the slice if it doesn't exist + if c.Hub.ConversationSubs[conversationUUID] == nil { + c.Hub.ConversationSubs[conversationUUID] = []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) { - c.Hub.SubMut.Lock() - defer c.Hub.SubMut.Unlock() - - for _, uuid := range uuids { - currentSubs, ok := c.Hub.Csubs[uuid] - if !ok { - continue // No subscriptions for this UUID - } - j := 0 - for _, sub := range currentSubs { - if sub != userID { - currentSubs[j] = sub - j++ + // Check if userID already exists + exists := false + for _, id := range c.Hub.ConversationSubs[conversationUUID] { + if id == userID { + exists = true + break } } - currentSubs = currentSubs[:j] // Update the slice in-place - if len(currentSubs) == 0 { - delete(c.Hub.Csubs, uuid) // Remove key if no more subscriptions - } else { - c.Hub.Csubs[uuid] = currentSubs + + // Add userID if it doesn't exist + if !exists { + c.Hub.ConversationSubs[conversationUUID] = append(c.Hub.ConversationSubs[conversationUUID], userID) + } + } +} + +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) } } } diff --git a/internal/ws/models/models.go b/internal/ws/models/models.go index 0e7c7da5..8f6815b4 100644 --- a/internal/ws/models/models.go +++ b/internal/ws/models/models.go @@ -1,24 +1,36 @@ package models const ( - ActionConvSub = "c_sub" - ActionConvUnsub = "c_unsub" - ActionAssignedConvSub = "a_c_sub" - ActionAssignedConvUnSub = "a_c_unsub" - - EventNewMsg = "new_msg" - EventMsgStatusUpdate = "msg_status_update" + ActionConversationsSub = "conversations_sub" + ActionConversationSub = "conversation_sub" + ActionConversationUnSub = "conversation_unsub" + MessageTypeNewMessage = "new_msg" + MessageTypeMessagePropUpdate = "msg_prop_update" + MessageTypeNewConversation = "new_conv" + MessageTypeConversationPropertyUpdate = "conv_prop_update" ) type IncomingReq struct { 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 { UUIDs []string `json:"v"` } -type Event struct { - Type string - Data string +type Message struct { + Type string `json:"typ"` + Data interface{} `json:"d"` } diff --git a/internal/ws/ws.go b/internal/ws/ws.go index 78a63dc5..d91ac5ee 100644 --- a/internal/ws/ws.go +++ b/internal/ws/ws.go @@ -4,27 +4,34 @@ import ( "encoding/json" "fmt" "sync" + "time" "github.com/abhinavxd/artemis/internal/ws/models" "github.com/fasthttp/websocket" ) -// Hub maintains the set of registered clients. +// Hub maintains the set of registered clients and their subscriptions. type Hub struct { clients map[int][]*Client clientsMutex sync.Mutex - // Map of conversation uuid to slice of subbed userids. - Csubs map[string][]int - SubMut sync.Mutex + // Map of conversation uuid to a set of subscribed user IDs. + ConversationSubs map[string][]int + + SubMut sync.Mutex + conversationStore ConversationStore +} + +type ConversationStore interface { + GetConversationUUIDs(userID, page, pageSize int, typ, predefinedFilter string) ([]string, error) } func NewHub() *Hub { return &Hub{ - clients: make(map[int][]*Client, 100000), - clientsMutex: sync.Mutex{}, - Csubs: map[string][]int{}, - SubMut: sync.Mutex{}, + clients: make(map[int][]*Client, 10000), + clientsMutex: sync.Mutex{}, + ConversationSubs: make(map[string][]int), + SubMut: sync.Mutex{}, } } @@ -39,6 +46,10 @@ type PushMessage struct { MaxUsers int `json:"max_users"` } +func (h *Hub) SetConversationStore(store ConversationStore) { + h.conversationStore = store +} + func (h *Hub) AddClient(c *Client) { h.clientsMutex.Lock() 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 { h.clientsMutex.Lock() defer h.clientsMutex.Unlock() @@ -75,7 +86,6 @@ func (h *Hub) PushMessage(m PushMessage) { h.clientsMutex.Lock() for _, userID := range m.Users { for _, c := range h.clients[userID] { - fmt.Printf("Pushing msg to %d", userID) 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{}) { - // clientIDs, ok := c.Csubs[convUUID] - // if !ok || len(clientIDs) == 0 { - // return - // } - - clientIDs := []int{1, 2} - - data := map[string]interface{}{ - "ev": models.EventNewMsg, - "d": msg, +func (c *Hub) BroadcastNewConversationMessage(conversationUUID, trimmedMessage, messageUUID, lastMessageAt string, private bool) { + userIDs, ok := c.ConversationSubs[conversationUUID] + if !ok || len(userIDs) == 0 { + return } - // Marshal. - dataB, err := json.Marshal(data) + message := models.Message{ + 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 { return } - c.PushMessage(PushMessage{ - 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 - } + fmt.Println("pushing msg", string(messageB), "type", message.Type, "to_user_ids", userIDs, "connected_userIds", len(c.clients)) c.PushMessage(PushMessage{ - Data: dataB, - Users: clientIDs, + Data: messageB, + Users: userIDs, }) }