mirror of
				https://github.com/abhinavxd/libredesk.git
				synced 2025-11-04 05:53:30 +00:00 
			
		
		
		
	some more commits?
This commit is contained in:
		@@ -38,7 +38,7 @@ func handleAttachmentUpload(r *fastglue.Request) error {
 | 
				
			|||||||
	file, err := form.File["files"][0].Open()
 | 
						file, err := form.File["files"][0].Open()
 | 
				
			||||||
	srcFileName := form.File["files"][0].Filename
 | 
						srcFileName := form.File["files"][0].Filename
 | 
				
			||||||
	srcContentType := form.File["files"][0].Header.Get("Content-Type")
 | 
						srcContentType := form.File["files"][0].Header.Get("Content-Type")
 | 
				
			||||||
	srcFileSize := form.File["files"][0].Size
 | 
						srcFileSize := strconv.FormatInt(form.File["files"][0].Size, 10)
 | 
				
			||||||
	srcDisposition := form.Value["disposition"][0]
 | 
						srcDisposition := form.Value["disposition"][0]
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		app.lo.Error("reading file into the memory", "error", err)
 | 
							app.lo.Error("reading file into the memory", "error", err)
 | 
				
			||||||
@@ -57,7 +57,7 @@ func handleAttachmentUpload(r *fastglue.Request) error {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	// Reset the ptr.
 | 
						// Reset the ptr.
 | 
				
			||||||
	file.Seek(0, 0)
 | 
						file.Seek(0, 0)
 | 
				
			||||||
	url, mediaUUID, _, err := app.attachmentMgr.Upload("" /**message uuid**/, srcFileName, srcContentType, srcDisposition, strconv.FormatInt(srcFileSize, 10), file)
 | 
						url, mediaUUID, _, err := app.attachmentMgr.Upload("" /**message uuid**/, srcFileName, srcContentType, srcDisposition, srcFileSize, file)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		app.lo.Error("error uploading file", "error", err)
 | 
							app.lo.Error("error uploading file", "error", err)
 | 
				
			||||||
		return r.SendErrorEnvelope(http.StatusInternalServerError, "Error uploading file", nil, "GeneralException")
 | 
							return r.SendErrorEnvelope(http.StatusInternalServerError, "Error uploading file", nil, "GeneralException")
 | 
				
			||||||
@@ -68,7 +68,7 @@ func handleAttachmentUpload(r *fastglue.Request) error {
 | 
				
			|||||||
		"uuid":         mediaUUID,
 | 
							"uuid":         mediaUUID,
 | 
				
			||||||
		"content_type": srcContentType,
 | 
							"content_type": srcContentType,
 | 
				
			||||||
		"name":         srcFileName,
 | 
							"name":         srcFileName,
 | 
				
			||||||
		"size":         strconv.FormatInt(srcFileSize, 10),
 | 
							"size":         srcFileSize,
 | 
				
			||||||
	})
 | 
						})
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -125,16 +125,16 @@ func handleUpdateAssignee(r *fastglue.Request) error {
 | 
				
			|||||||
		userUUID     = r.RequestCtx.UserValue("user_uuid").(string)
 | 
							userUUID     = r.RequestCtx.UserValue("user_uuid").(string)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err := app.conversationMgr.UpdateAssignee(convUUID, assigneeUUID, assigneeType); err != nil {
 | 
						if assigneeType == "user" {
 | 
				
			||||||
		return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "")
 | 
							if err := app.conversationMgr.UpdateUserAssignee(convUUID, assigneeUUID); err != nil {
 | 
				
			||||||
	}
 | 
								return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "")
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
	if assigneeType == "agent" {
 | 
							app.msgMgr.RecordAssigneeUserChange(convUUID, string(assigneeUUID), userUUID)
 | 
				
			||||||
		app.msgMgr.RecordAssigneeUserChange(string(assigneeUUID), convUUID, userUUID)
 | 
						} else if assigneeType == "team" {
 | 
				
			||||||
	}
 | 
							if err := app.conversationMgr.UpdateTeamAssignee(convUUID, assigneeUUID); err != nil {
 | 
				
			||||||
 | 
								return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "")
 | 
				
			||||||
	if assigneeType == "team" {
 | 
							}
 | 
				
			||||||
		app.msgMgr.RecordAssigneeTeamChange(string(assigneeUUID), convUUID, userUUID)
 | 
							app.msgMgr.RecordAssigneeTeamChange(convUUID, string(assigneeUUID), userUUID)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return r.SendEnvelope("ok")
 | 
						return r.SendEnvelope("ok")
 | 
				
			||||||
@@ -188,7 +188,7 @@ func handlAddConversationTags(r *fastglue.Request) error {
 | 
				
			|||||||
		return r.SendErrorEnvelope(http.StatusInternalServerError, "error adding tags", nil, "")
 | 
							return r.SendErrorEnvelope(http.StatusInternalServerError, "error adding tags", nil, "")
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err := app.conversationTagsMgr.AddTags(uuid, tagIDs); err != nil {
 | 
						if err := app.conversationMgr.AddTags(uuid, tagIDs); err != nil {
 | 
				
			||||||
		return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "")
 | 
							return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "")
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope("ok")
 | 
						return r.SendEnvelope("ok")
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -28,7 +28,6 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
 | 
				
			|||||||
	g.GET("/api/canned-responses", auth(handleGetCannedResponses))
 | 
						g.GET("/api/canned-responses", auth(handleGetCannedResponses))
 | 
				
			||||||
	g.GET("/api/attachment/{conversation_uuid}", auth(handleGetAttachment))
 | 
						g.GET("/api/attachment/{conversation_uuid}", auth(handleGetAttachment))
 | 
				
			||||||
	g.GET("/api/users/me", auth(handleGetCurrentUser))
 | 
						g.GET("/api/users/me", auth(handleGetCurrentUser))
 | 
				
			||||||
	g.GET("/api/users/filters/{page}", auth(handleGetUserFilters))
 | 
					 | 
				
			||||||
	g.GET("/api/users", auth(handleGetUsers))
 | 
						g.GET("/api/users", auth(handleGetUsers))
 | 
				
			||||||
	g.GET("/api/teams", auth(handleGetTeams))
 | 
						g.GET("/api/teams", auth(handleGetTeams))
 | 
				
			||||||
	g.GET("/api/tags", auth(handleGetTags))
 | 
						g.GET("/api/tags", auth(handleGetTags))
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										63
									
								
								cmd/init.go
									
									
									
									
									
								
							
							
						
						
									
										63
									
								
								cmd/init.go
									
									
									
									
									
								
							@@ -8,21 +8,22 @@ import (
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/attachment"
 | 
						"github.com/abhinavxd/artemis/internal/attachment"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/attachment/stores/s3"
 | 
						"github.com/abhinavxd/artemis/internal/attachment/stores/s3"
 | 
				
			||||||
 | 
						uauth "github.com/abhinavxd/artemis/internal/auth"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/autoassigner"
 | 
						"github.com/abhinavxd/artemis/internal/autoassigner"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/automation"
 | 
						"github.com/abhinavxd/artemis/internal/automation"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/cannedresp"
 | 
						"github.com/abhinavxd/artemis/internal/cannedresp"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/contact"
 | 
						"github.com/abhinavxd/artemis/internal/contact"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/conversation"
 | 
						"github.com/abhinavxd/artemis/internal/conversation"
 | 
				
			||||||
	convtag "github.com/abhinavxd/artemis/internal/conversation/tag"
 | 
					 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/inbox"
 | 
						"github.com/abhinavxd/artemis/internal/inbox"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/inbox/channel/email"
 | 
						"github.com/abhinavxd/artemis/internal/inbox/channel/email"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/initz"
 | 
						"github.com/abhinavxd/artemis/internal/initz"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/message"
 | 
						"github.com/abhinavxd/artemis/internal/message"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/rbac"
 | 
						notifier "github.com/abhinavxd/artemis/internal/notification"
 | 
				
			||||||
 | 
						emailnotifier "github.com/abhinavxd/artemis/internal/notification/providers/email"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/tag"
 | 
						"github.com/abhinavxd/artemis/internal/tag"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/team"
 | 
						"github.com/abhinavxd/artemis/internal/team"
 | 
				
			||||||
 | 
						"github.com/abhinavxd/artemis/internal/template"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/user"
 | 
						"github.com/abhinavxd/artemis/internal/user"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/user/filterstore"
 | 
					 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/ws"
 | 
						"github.com/abhinavxd/artemis/internal/ws"
 | 
				
			||||||
	"github.com/go-redis/redis/v8"
 | 
						"github.com/go-redis/redis/v8"
 | 
				
			||||||
	"github.com/jmoiron/sqlx"
 | 
						"github.com/jmoiron/sqlx"
 | 
				
			||||||
@@ -112,8 +113,8 @@ func initUserDB(DB *sqlx.DB, lo *logf.Logger) *user.Manager {
 | 
				
			|||||||
	return mgr
 | 
						return mgr
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func initConversations(db *sqlx.DB, lo *logf.Logger) *conversation.Manager {
 | 
					func initConversations(hub *ws.Hub, db *sqlx.DB, lo *logf.Logger) *conversation.Manager {
 | 
				
			||||||
	c, err := conversation.New(conversation.Opts{
 | 
						c, err := conversation.New(hub, conversation.Opts{
 | 
				
			||||||
		DB:                  db,
 | 
							DB:                  db,
 | 
				
			||||||
		Lo:                  lo,
 | 
							Lo:                  lo,
 | 
				
			||||||
		ReferenceNumPattern: ko.String("app.constants.conversation_reference_number_pattern"),
 | 
							ReferenceNumPattern: ko.String("app.constants.conversation_reference_number_pattern"),
 | 
				
			||||||
@@ -124,17 +125,6 @@ func initConversations(db *sqlx.DB, lo *logf.Logger) *conversation.Manager {
 | 
				
			|||||||
	return c
 | 
						return c
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func initConversationTags(db *sqlx.DB, lo *logf.Logger) *convtag.Manager {
 | 
					 | 
				
			||||||
	mgr, err := convtag.New(convtag.Opts{
 | 
					 | 
				
			||||||
		DB: db,
 | 
					 | 
				
			||||||
		Lo: lo,
 | 
					 | 
				
			||||||
	})
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		log.Fatalf("error initializing conversation tags: %v", err)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	return mgr
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
func initTags(db *sqlx.DB, lo *logf.Logger) *tag.Manager {
 | 
					func initTags(db *sqlx.DB, lo *logf.Logger) *tag.Manager {
 | 
				
			||||||
	mgr, err := tag.New(tag.Opts{
 | 
						mgr, err := tag.New(tag.Opts{
 | 
				
			||||||
		DB: db,
 | 
							DB: db,
 | 
				
			||||||
@@ -168,6 +158,14 @@ func initContactManager(db *sqlx.DB, lo *logf.Logger) *contact.Manager {
 | 
				
			|||||||
	return m
 | 
						return m
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func initTemplateMgr(db *sqlx.DB) *template.Manager {
 | 
				
			||||||
 | 
						m, err := template.New(db)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							log.Fatalf("error initializing template manager: %v", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return m
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func initMessages(db *sqlx.DB,
 | 
					func initMessages(db *sqlx.DB,
 | 
				
			||||||
	lo *logf.Logger,
 | 
						lo *logf.Logger,
 | 
				
			||||||
	wsHub *ws.Hub,
 | 
						wsHub *ws.Hub,
 | 
				
			||||||
@@ -177,7 +175,9 @@ func initMessages(db *sqlx.DB,
 | 
				
			|||||||
	attachmentMgr *attachment.Manager,
 | 
						attachmentMgr *attachment.Manager,
 | 
				
			||||||
	conversationMgr *conversation.Manager,
 | 
						conversationMgr *conversation.Manager,
 | 
				
			||||||
	inboxMgr *inbox.Manager,
 | 
						inboxMgr *inbox.Manager,
 | 
				
			||||||
	automationEngine *automation.Engine) *message.Manager {
 | 
						automationEngine *automation.Engine,
 | 
				
			||||||
 | 
						templateManager *template.Manager,
 | 
				
			||||||
 | 
					) *message.Manager {
 | 
				
			||||||
	mgr, err := message.New(
 | 
						mgr, err := message.New(
 | 
				
			||||||
		wsHub,
 | 
							wsHub,
 | 
				
			||||||
		userMgr,
 | 
							userMgr,
 | 
				
			||||||
@@ -187,6 +187,7 @@ func initMessages(db *sqlx.DB,
 | 
				
			|||||||
		inboxMgr,
 | 
							inboxMgr,
 | 
				
			||||||
		conversationMgr,
 | 
							conversationMgr,
 | 
				
			||||||
		automationEngine,
 | 
							automationEngine,
 | 
				
			||||||
 | 
							templateManager,
 | 
				
			||||||
		message.Opts{
 | 
							message.Opts{
 | 
				
			||||||
			DB:                   db,
 | 
								DB:                   db,
 | 
				
			||||||
			Lo:                   lo,
 | 
								Lo:                   lo,
 | 
				
			||||||
@@ -268,28 +269,36 @@ func initAutomationEngine(db *sqlx.DB, lo *logf.Logger) *automation.Engine {
 | 
				
			|||||||
	return engine
 | 
						return engine
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func initAutoAssignmentEngine(teamMgr *team.Manager, convMgr *conversation.Manager, msgMgr *message.Manager, lo *logf.Logger) *autoassigner.Engine {
 | 
					func initAutoAssignmentEngine(teamMgr *team.Manager, convMgr *conversation.Manager, msgMgr *message.Manager,
 | 
				
			||||||
	engine, err := autoassigner.New(teamMgr, convMgr, msgMgr, lo)
 | 
						notifier notifier.Notifier, hub *ws.Hub, lo *logf.Logger) *autoassigner.Engine {
 | 
				
			||||||
 | 
						engine, err := autoassigner.New(teamMgr, convMgr, msgMgr, notifier, hub, lo)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		log.Fatalf("error initializing auto assignment engine: %v", err)
 | 
							log.Fatalf("error initializing auto assignment engine: %v", err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return engine
 | 
						return engine
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func initRBACEngine(db *sqlx.DB) *rbac.Engine {
 | 
					func initRBACEngine(db *sqlx.DB) *uauth.Engine {
 | 
				
			||||||
	engine, err := rbac.New(db)
 | 
						engine, err := uauth.New(db, &logf.Logger{})
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		log.Fatalf("error initializing rbac enginer: %v", err)
 | 
							log.Fatalf("error initializing rbac enginer: %v", err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return engine
 | 
						return engine
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func initUserFilterMgr(db *sqlx.DB) *filterstore.Manager {
 | 
					func initNotifier(userStore notifier.UserStore, templateRenderer notifier.TemplateRenderer) notifier.Notifier {
 | 
				
			||||||
	filterMgr, err := filterstore.New(db)
 | 
						var smtpCfg email.SMTPConfig
 | 
				
			||||||
	if err != nil {
 | 
						if err := ko.UnmarshalWithConf("notification.provider.email", &smtpCfg, koanf.UnmarshalConf{Tag: "json"}); err != nil {
 | 
				
			||||||
		log.Fatalf("error initializing user filter manager: %v", err)
 | 
							log.Fatalf("error unmarshalling email notification provider config: %v", err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return filterMgr
 | 
						notifier, err := emailnotifier.New([]email.SMTPConfig{smtpCfg}, userStore, templateRenderer, emailnotifier.Opts{
 | 
				
			||||||
 | 
							Lo:        initz.Logger(ko.MustString("app.log_level"), ko.MustString("app.env"), "email-notifier"),
 | 
				
			||||||
 | 
							FromEmail: ko.String("notification.provider.email.email_address"),
 | 
				
			||||||
 | 
						})
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							log.Fatalf("error initializing email notifier: %v", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return notifier
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// initEmailInbox initializes the email inbox.
 | 
					// initEmailInbox initializes the email inbox.
 | 
				
			||||||
@@ -327,7 +336,7 @@ func initEmailInbox(inboxRecord inbox.InboxRecord, store inbox.MessageStore) (in
 | 
				
			|||||||
		return nil, err
 | 
							return nil, err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	log.Printf("`%s` inbox: `%s` successfully initalized. %d smtp servers. %d imap clients.", inboxRecord.Channel, inboxRecord.Name, len(config.SMTP), len(config.IMAP))
 | 
						log.Printf("`%s` inbox successfully initalized. %d smtp servers. %d imap clients.", inboxRecord.Name, len(config.SMTP), len(config.IMAP))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return inbox, nil
 | 
						return inbox, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										125
									
								
								cmd/main.go
									
									
									
									
									
								
							
							
						
						
									
										125
									
								
								cmd/main.go
									
									
									
									
									
								
							@@ -9,18 +9,16 @@ import (
 | 
				
			|||||||
	"time"
 | 
						"time"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/attachment"
 | 
						"github.com/abhinavxd/artemis/internal/attachment"
 | 
				
			||||||
 | 
						uauth "github.com/abhinavxd/artemis/internal/auth"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/cannedresp"
 | 
						"github.com/abhinavxd/artemis/internal/cannedresp"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/contact"
 | 
						"github.com/abhinavxd/artemis/internal/contact"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/conversation"
 | 
						"github.com/abhinavxd/artemis/internal/conversation"
 | 
				
			||||||
	convtag "github.com/abhinavxd/artemis/internal/conversation/tag"
 | 
					 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/inbox"
 | 
						"github.com/abhinavxd/artemis/internal/inbox"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/initz"
 | 
						"github.com/abhinavxd/artemis/internal/initz"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/message"
 | 
						"github.com/abhinavxd/artemis/internal/message"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/rbac"
 | 
					 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/tag"
 | 
						"github.com/abhinavxd/artemis/internal/tag"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/team"
 | 
						"github.com/abhinavxd/artemis/internal/team"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/user"
 | 
						"github.com/abhinavxd/artemis/internal/user"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/user/filterstore"
 | 
					 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/ws"
 | 
						"github.com/abhinavxd/artemis/internal/ws"
 | 
				
			||||||
	"github.com/knadh/koanf/v2"
 | 
						"github.com/knadh/koanf/v2"
 | 
				
			||||||
	"github.com/valyala/fasthttp"
 | 
						"github.com/valyala/fasthttp"
 | 
				
			||||||
@@ -29,25 +27,31 @@ import (
 | 
				
			|||||||
	"github.com/zerodha/logf"
 | 
						"github.com/zerodha/logf"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
var ko = koanf.New(".")
 | 
					var (
 | 
				
			||||||
 | 
						ko = koanf.New(".")
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const (
 | 
				
			||||||
 | 
						// ANSI escape colour codes.
 | 
				
			||||||
 | 
						colourRed   = "\x1b[31m"
 | 
				
			||||||
 | 
						colourGreen = "\x1b[32m"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// App is the global app context which is passed and injected in the http handlers.
 | 
					// App is the global app context which is passed and injected in the http handlers.
 | 
				
			||||||
type App struct {
 | 
					type App struct {
 | 
				
			||||||
	constants           consts
 | 
						constants       consts
 | 
				
			||||||
	lo                  *logf.Logger
 | 
						lo              *logf.Logger
 | 
				
			||||||
	cntctMgr            *contact.Manager
 | 
						cntctMgr        *contact.Manager
 | 
				
			||||||
	userMgr             *user.Manager
 | 
						userMgr         *user.Manager
 | 
				
			||||||
	teamMgr             *team.Manager
 | 
						teamMgr         *team.Manager
 | 
				
			||||||
	sessMgr             *simplesessions.Manager
 | 
						sessMgr         *simplesessions.Manager
 | 
				
			||||||
	tagMgr              *tag.Manager
 | 
						tagMgr          *tag.Manager
 | 
				
			||||||
	msgMgr              *message.Manager
 | 
						msgMgr          *message.Manager
 | 
				
			||||||
	rbac                *rbac.Engine
 | 
						rbac            *uauth.Engine
 | 
				
			||||||
	userFilterMgr       *filterstore.Manager
 | 
						inboxMgr        *inbox.Manager
 | 
				
			||||||
	inboxMgr            *inbox.Manager
 | 
						attachmentMgr   *attachment.Manager
 | 
				
			||||||
	attachmentMgr       *attachment.Manager
 | 
						cannedRespMgr   *cannedresp.Manager
 | 
				
			||||||
	cannedRespMgr       *cannedresp.Manager
 | 
						conversationMgr *conversation.Manager
 | 
				
			||||||
	conversationMgr     *conversation.Manager
 | 
					 | 
				
			||||||
	conversationTagsMgr *convtag.Manager
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func main() {
 | 
					func main() {
 | 
				
			||||||
@@ -58,62 +62,65 @@ func main() {
 | 
				
			|||||||
	initz.Config(ko)
 | 
						initz.Config(ko)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		shutdownCh = make(chan struct{})
 | 
							shutdownCh         = make(chan struct{})
 | 
				
			||||||
		ctx, stop  = signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
 | 
							ctx, stop          = signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
 | 
				
			||||||
		lo         = initz.Logger(ko.MustString("app.log_level"), ko.MustString("app.env"), "artemis")
 | 
							lo                 = initz.Logger(ko.MustString("app.log_level"), ko.MustString("app.env"), "artemis")
 | 
				
			||||||
		rd         = initz.Redis(ko)
 | 
							rd                 = initz.Redis(ko)
 | 
				
			||||||
		db         = initz.DB(ko)
 | 
							db                 = initz.DB(ko)
 | 
				
			||||||
 | 
					 | 
				
			||||||
		wsHub              = ws.NewHub()
 | 
							wsHub              = ws.NewHub()
 | 
				
			||||||
 | 
							templateMgr        = initTemplateMgr(db)
 | 
				
			||||||
		attachmentMgr      = initAttachmentsManager(db, lo)
 | 
							attachmentMgr      = initAttachmentsManager(db, lo)
 | 
				
			||||||
		cntctMgr           = initContactManager(db, lo)
 | 
							cntctMgr           = initContactManager(db, lo)
 | 
				
			||||||
		inboxMgr           = initInboxManager(db, lo)
 | 
							inboxMgr           = initInboxManager(db, lo)
 | 
				
			||||||
		teamMgr            = initTeamMgr(db, lo)
 | 
							teamMgr            = initTeamMgr(db, lo)
 | 
				
			||||||
		userMgr            = initUserDB(db, lo)
 | 
							userMgr            = initUserDB(db, lo)
 | 
				
			||||||
		conversationMgr    = initConversations(db, lo)
 | 
							notifier           = initNotifier(userMgr, templateMgr)
 | 
				
			||||||
 | 
							conversationMgr    = initConversations(wsHub, db, lo)
 | 
				
			||||||
		automationEngine   = initAutomationEngine(db, lo)
 | 
							automationEngine   = initAutomationEngine(db, lo)
 | 
				
			||||||
		msgMgr             = initMessages(db, lo, wsHub, userMgr, teamMgr, cntctMgr, attachmentMgr, conversationMgr, inboxMgr, automationEngine)
 | 
							msgMgr             = initMessages(db, lo, wsHub, userMgr, teamMgr, cntctMgr, attachmentMgr, conversationMgr, inboxMgr, automationEngine, templateMgr)
 | 
				
			||||||
		autoAssignerEngine = initAutoAssignmentEngine(teamMgr, conversationMgr, msgMgr, lo)
 | 
							autoAssignerEngine = initAutoAssignmentEngine(teamMgr, conversationMgr, msgMgr, notifier, wsHub, lo)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Init the app
 | 
					 | 
				
			||||||
	var app = &App{
 | 
					 | 
				
			||||||
		lo:                  lo,
 | 
					 | 
				
			||||||
		cntctMgr:            cntctMgr,
 | 
					 | 
				
			||||||
		inboxMgr:            inboxMgr,
 | 
					 | 
				
			||||||
		userMgr:             userMgr,
 | 
					 | 
				
			||||||
		teamMgr:             teamMgr,
 | 
					 | 
				
			||||||
		attachmentMgr:       attachmentMgr,
 | 
					 | 
				
			||||||
		conversationMgr:     conversationMgr,
 | 
					 | 
				
			||||||
		msgMgr:              msgMgr,
 | 
					 | 
				
			||||||
		constants:           initConstants(),
 | 
					 | 
				
			||||||
		rbac:                initRBACEngine(db),
 | 
					 | 
				
			||||||
		tagMgr:              initTags(db, lo),
 | 
					 | 
				
			||||||
		userFilterMgr:       initUserFilterMgr(db),
 | 
					 | 
				
			||||||
		sessMgr:             initSessionManager(rd),
 | 
					 | 
				
			||||||
		cannedRespMgr:       initCannedResponse(db, lo),
 | 
					 | 
				
			||||||
		conversationTagsMgr: initConversationTags(db, lo),
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	// Register all inboxes with the inbox manager.
 | 
						// Register all inboxes with the inbox manager.
 | 
				
			||||||
	registerInboxes(inboxMgr, msgMgr)
 | 
						registerInboxes(inboxMgr, msgMgr)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	automationEngine.SetMsgRecorder(msgMgr)
 | 
						// Set conversation store for the websocket hub.
 | 
				
			||||||
	automationEngine.SetConvUpdater(conversationMgr)
 | 
						wsHub.SetConversationStore(conversationMgr)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Set stores for the automation engine.
 | 
				
			||||||
 | 
						automationEngine.SetMessageStore(msgMgr)
 | 
				
			||||||
 | 
						automationEngine.SetConversationStore(conversationMgr)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Start receivers for all active inboxes.
 | 
						// Start receivers for all active inboxes.
 | 
				
			||||||
	inboxMgr.Receive(ctx)
 | 
						inboxMgr.Receive(ctx)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Start inserting incoming msgs and dispatch pending outgoing messages.
 | 
						// Start automation rule evaluation engine.
 | 
				
			||||||
	go app.msgMgr.StartDBInserts(ctx, ko.MustInt("message.reader_concurrency"))
 | 
					 | 
				
			||||||
	go app.msgMgr.StartDispatcher(ctx, ko.MustInt("message.dispatch_concurrency"), ko.MustDuration("message.dispatch_read_interval"))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	// Start automation rule engine.
 | 
					 | 
				
			||||||
	go automationEngine.Serve(ctx)
 | 
						go automationEngine.Serve(ctx)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Start conversation auto assigner engine.
 | 
						// Start conversation auto assigner engine.
 | 
				
			||||||
	go autoAssignerEngine.Serve(ctx, ko.MustDuration("autoassigner.assign_interval"))
 | 
						go autoAssignerEngine.Serve(ctx, ko.MustDuration("autoassigner.assign_interval"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Start inserting incoming messages from all active inboxes and dispatch pending outgoing messages.
 | 
				
			||||||
 | 
						go msgMgr.StartDBInserts(ctx, ko.MustInt("message.reader_concurrency"))
 | 
				
			||||||
 | 
						go msgMgr.StartDispatcher(ctx, ko.MustInt("message.dispatch_concurrency"), ko.MustDuration("message.dispatch_read_interval"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Init the app
 | 
				
			||||||
 | 
						var app = &App{
 | 
				
			||||||
 | 
							lo:              lo,
 | 
				
			||||||
 | 
							cntctMgr:        cntctMgr,
 | 
				
			||||||
 | 
							inboxMgr:        inboxMgr,
 | 
				
			||||||
 | 
							userMgr:         userMgr,
 | 
				
			||||||
 | 
							teamMgr:         teamMgr,
 | 
				
			||||||
 | 
							attachmentMgr:   attachmentMgr,
 | 
				
			||||||
 | 
							conversationMgr: conversationMgr,
 | 
				
			||||||
 | 
							msgMgr:          msgMgr,
 | 
				
			||||||
 | 
							constants:       initConstants(),
 | 
				
			||||||
 | 
							rbac:            initRBACEngine(db),
 | 
				
			||||||
 | 
							tagMgr:          initTags(db, lo),
 | 
				
			||||||
 | 
							sessMgr:         initSessionManager(rd),
 | 
				
			||||||
 | 
							cannedRespMgr:   initCannedResponse(db, lo),
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Init fastglue http server.
 | 
						// Init fastglue http server.
 | 
				
			||||||
	g := fastglue.NewGlue()
 | 
						g := fastglue.NewGlue()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -137,20 +144,18 @@ func main() {
 | 
				
			|||||||
		// Wait for the interruption signal
 | 
							// Wait for the interruption signal
 | 
				
			||||||
		<-ctx.Done()
 | 
							<-ctx.Done()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		log.Printf("\x1b[%dm%s\x1b[0m", 31, "Shutting down the server please wait...")
 | 
							log.Printf("%sShutting down the server. Please wait.\x1b[0m", colourRed)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		// Additional grace period before triggering shutdown
 | 
							time.Sleep(5 * time.Second)
 | 
				
			||||||
		time.Sleep(7 * time.Second)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
		// Signal to shutdown the server
 | 
							// Signal to shutdown the server
 | 
				
			||||||
		shutdownCh <- struct{}{}
 | 
							shutdownCh <- struct{}{}
 | 
				
			||||||
		stop()
 | 
							stop()
 | 
				
			||||||
	}()
 | 
						}()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Starting the server and waiting for the shutdown signal
 | 
						log.Printf("%s🚀 server listening on %s %s\x1b[0m", colourGreen, ko.String("app.server.address"), ko.String("app.server.socket"))
 | 
				
			||||||
	log.Printf("🚀 server listening on %s %s", ko.String("app.server.address"), ko.String("app.server.socket"))
 | 
					
 | 
				
			||||||
	if err := g.ListenServeAndWaitGracefully(ko.String("app.server.address"), ko.String("server.socket"), s, shutdownCh); err != nil {
 | 
						if err := g.ListenServeAndWaitGracefully(ko.String("app.server.address"), ko.String("server.socket"), s, shutdownCh); err != nil {
 | 
				
			||||||
		log.Fatalf("error starting frontend server: %v", err)
 | 
							log.Fatalf("error starting frontend server: %v", err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	log.Println("Server shutdown completed")
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,7 +3,6 @@ package main
 | 
				
			|||||||
import (
 | 
					import (
 | 
				
			||||||
	"encoding/json"
 | 
						"encoding/json"
 | 
				
			||||||
	"net/http"
 | 
						"net/http"
 | 
				
			||||||
	"time"
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/attachment/models"
 | 
						"github.com/abhinavxd/artemis/internal/attachment/models"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/message"
 | 
						"github.com/abhinavxd/artemis/internal/message"
 | 
				
			||||||
@@ -108,13 +107,10 @@ func handleSendMessage(r *fastglue.Request) error {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	// Update conversation meta with the last message details.
 | 
						// Update conversation meta with the last message details.
 | 
				
			||||||
	trimmedMessage := app.msgMgr.TrimMsg(msg.Content)
 | 
						trimmedMessage := app.msgMgr.TrimMsg(msg.Content)
 | 
				
			||||||
	app.conversationMgr.UpdateMeta(0, conversationUUID, map[string]string{
 | 
						app.conversationMgr.UpdateLastMessage(0, conversationUUID, trimmedMessage, msg.CreatedAt)
 | 
				
			||||||
		"last_message":    trimmedMessage,
 | 
					 | 
				
			||||||
		"last_message_at": msg.CreatedAt.Format(time.RFC3339),
 | 
					 | 
				
			||||||
	})
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Send WS update.
 | 
						// Send WS update.
 | 
				
			||||||
	app.msgMgr.BroadcastNewMsg(msg, trimmedMessage)
 | 
						app.msgMgr.BroadcastNewConversationMessage(msg, trimmedMessage)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return r.SendEnvelope("Message sent")
 | 
						return r.SendEnvelope("Message sent")
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										13
									
								
								cmd/users.go
									
									
									
									
									
								
							
							
						
						
									
										13
									
								
								cmd/users.go
									
									
									
									
									
								
							@@ -28,16 +28,3 @@ func handleGetCurrentUser(r *fastglue.Request) error {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
	return r.SendEnvelope(u)
 | 
						return r.SendEnvelope(u)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					 | 
				
			||||||
func handleGetUserFilters(r *fastglue.Request) error {
 | 
					 | 
				
			||||||
	var (
 | 
					 | 
				
			||||||
		app    = r.Context.(*App)
 | 
					 | 
				
			||||||
		userID = r.RequestCtx.UserValue("user_id").(int)
 | 
					 | 
				
			||||||
		page   = r.RequestCtx.UserValue("page").(string)
 | 
					 | 
				
			||||||
	)
 | 
					 | 
				
			||||||
	filters, err := app.userFilterMgr.GetFilters(userID, page)
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "")
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	return r.SendEnvelope(filters)
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
@@ -34,23 +34,9 @@ func handleWS(r *fastglue.Request, hub *ws.Hub) error {
 | 
				
			|||||||
			ID:   userID,
 | 
								ID:   userID,
 | 
				
			||||||
			Hub:  hub,
 | 
								Hub:  hub,
 | 
				
			||||||
			Conn: conn,
 | 
								Conn: conn,
 | 
				
			||||||
			Send: make(chan ws.Message, 100000),
 | 
								Send: make(chan ws.Message, 10000),
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					 | 
				
			||||||
		// Sub this client to all assigned conversations.
 | 
					 | 
				
			||||||
		convs, err := app.conversationMgr.GetAssignedConversations(userID)
 | 
					 | 
				
			||||||
		if err != nil {
 | 
					 | 
				
			||||||
			return
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
		// Extract  uuids.
 | 
					 | 
				
			||||||
		uuids := make([]string, len(convs))
 | 
					 | 
				
			||||||
		for i, conv := range convs {
 | 
					 | 
				
			||||||
			uuids[i] = conv.UUID
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
		c.SubConv(userID, uuids...)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		hub.AddClient(&c)
 | 
							hub.AddClient(&c)
 | 
				
			||||||
 | 
					 | 
				
			||||||
		go c.Listen()
 | 
							go c.Listen()
 | 
				
			||||||
		c.Serve(2 * time.Second)
 | 
							c.Serve(2 * time.Second)
 | 
				
			||||||
	})
 | 
						})
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										
											BIN
										
									
								
								frontend/bun.lockb
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend/bun.lockb
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							@@ -45,6 +45,7 @@
 | 
				
			|||||||
    "tailwindcss-animate": "^1.0.7",
 | 
					    "tailwindcss-animate": "^1.0.7",
 | 
				
			||||||
    "tiptap-extension-resize-image": "^1.1.5",
 | 
					    "tiptap-extension-resize-image": "^1.1.5",
 | 
				
			||||||
    "vue": "^3.4.15",
 | 
					    "vue": "^3.4.15",
 | 
				
			||||||
 | 
					    "vue-draggable-resizable": "^3.0.0",
 | 
				
			||||||
    "vue-i18n": "9",
 | 
					    "vue-i18n": "9",
 | 
				
			||||||
    "vue-letter": "^0.2.0",
 | 
					    "vue-letter": "^0.2.0",
 | 
				
			||||||
    "vue-router": "^4.2.5"
 | 
					    "vue-router": "^4.2.5"
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -29,10 +29,10 @@ import { ref, onMounted } from "vue"
 | 
				
			|||||||
import { RouterView, useRouter } from 'vue-router'
 | 
					import { RouterView, useRouter } from 'vue-router'
 | 
				
			||||||
import { cn } from '@/lib/utils'
 | 
					import { cn } from '@/lib/utils'
 | 
				
			||||||
import { useUserStore } from '@/stores/user'
 | 
					import { useUserStore } from '@/stores/user'
 | 
				
			||||||
import { initWS } from "./websocket.js"
 | 
					import { initWS } from "@/websocket.js"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { Toaster } from '@/components/ui/toast'
 | 
					import { Toaster } from '@/components/ui/toast'
 | 
				
			||||||
import NavBar from './components/NavBar.vue'
 | 
					import NavBar from '@/components/NavBar.vue'
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  ResizableHandle,
 | 
					  ResizableHandle,
 | 
				
			||||||
  ResizablePanel,
 | 
					  ResizablePanel,
 | 
				
			||||||
@@ -43,7 +43,6 @@ import {
 | 
				
			|||||||
} from '@/components/ui/tooltip'
 | 
					} from '@/components/ui/tooltip'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 | 
				
			||||||
const isCollapsed = ref(false)
 | 
					const isCollapsed = ref(false)
 | 
				
			||||||
const navLinks = [
 | 
					const navLinks = [
 | 
				
			||||||
  {
 | 
					  {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -174,10 +174,9 @@ $editorContainerId: 'editor-container';
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.tiptap-editor-image {
 | 
					// .tiptap-editor-image {
 | 
				
			||||||
  width: 100px;
 | 
					//   width: 60%;
 | 
				
			||||||
  height: 200px;
 | 
					// }
 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
.box {
 | 
					.box {
 | 
				
			||||||
  box-shadow: rgb(243, 243, 243) 1px 1px 0px 0px;
 | 
					  box-shadow: rgb(243, 243, 243) 1px 1px 0px 0px;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,8 +1,8 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
    <div class="h-screen" v-if="conversationStore.messages.data">
 | 
					    <div class="relative" v-if="conversationStore.messages.data">
 | 
				
			||||||
        <!-- Header -->
 | 
					        <!-- Header -->
 | 
				
			||||||
        <div class="h-12 px-4 box relative">
 | 
					        <div class="h-10 px-4 box">
 | 
				
			||||||
            <div class="flex flex-row justify-between items-center pt-1">
 | 
					            <div class="flex flex-row justify-between items-center">
 | 
				
			||||||
                <div class="flex h-5 items-center space-x-4 text-sm">
 | 
					                <div class="flex h-5 items-center space-x-4 text-sm">
 | 
				
			||||||
                    <Tooltip>
 | 
					                    <Tooltip>
 | 
				
			||||||
                        <TooltipTrigger>#{{ conversationStore.conversation.data.reference_number }}
 | 
					                        <TooltipTrigger>#{{ conversationStore.conversation.data.reference_number }}
 | 
				
			||||||
@@ -45,12 +45,11 @@
 | 
				
			|||||||
                </div>
 | 
					                </div>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
        <!-- Body -->
 | 
					 | 
				
			||||||
        <Error class="sticky" :error-message="conversationStore.messages.errorMessage"></Error>
 | 
					        <Error class="sticky" :error-message="conversationStore.messages.errorMessage"></Error>
 | 
				
			||||||
        <div class="flex flex-col h-screen">
 | 
					        <div class="flex flex-col h-screen">
 | 
				
			||||||
            <!-- Messages -->
 | 
					            <!-- flex-1-->
 | 
				
			||||||
            <MessageList :messages="conversationStore.sortedMessages" class="flex-1 bg-[#f8f9fa41]" />
 | 
					            <MessageList :messages="conversationStore.sortedMessages" class="flex-1 bg-[#f8f9fa41]" />
 | 
				
			||||||
            <ReplyBox />
 | 
					            <ReplyBox class="h-max mb-10"/>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -315,7 +315,7 @@ onMounted(() => {
 | 
				
			|||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const handleAssignedAgentChange = (v) => {
 | 
					const handleAssignedAgentChange = (v) => {
 | 
				
			||||||
  conversationStore.updateAssignee("agent", {
 | 
					  conversationStore.updateAssignee("user", {
 | 
				
			||||||
    "assignee_uuid": v.split(":")[0]
 | 
					    "assignee_uuid": v.split(":")[0]
 | 
				
			||||||
  })
 | 
					  })
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,6 @@
 | 
				
			|||||||
<template>
 | 
					<template>
 | 
				
			||||||
    <!-- <div v-if="cannedResponsesStore.responses.length === 0" class="w-full drop-shadow-sm overflow-hidden p-2 border-t">
 | 
					    <div>
 | 
				
			||||||
 | 
					        <!-- <div v-if="cannedResponsesStore.responses.length === 0" class="w-full drop-shadow-sm overflow-hidden p-2 border-t">
 | 
				
			||||||
        <ul class="space-y-2 max-h-96">
 | 
					        <ul class="space-y-2 max-h-96">
 | 
				
			||||||
            <li v-for="(response, index) in filteredCannedResponses" :key="response.id"
 | 
					            <li v-for="(response, index) in filteredCannedResponses" :key="response.id"
 | 
				
			||||||
                @click="selectResponse(response.content)" class="cursor-pointer rounded p-1 hover:bg-secondary"
 | 
					                @click="selectResponse(response.content)" class="cursor-pointer rounded p-1 hover:bg-secondary"
 | 
				
			||||||
@@ -8,7 +9,9 @@
 | 
				
			|||||||
            </li>
 | 
					            </li>
 | 
				
			||||||
        </ul>
 | 
					        </ul>
 | 
				
			||||||
    </div> -->
 | 
					    </div> -->
 | 
				
			||||||
    <TextEditor @send="sendMessage" :conversationuuid="conversationStore.conversation.data.uuid" class="mb-[40px]" />
 | 
					
 | 
				
			||||||
 | 
					        <TextEditor @send="sendMessage" :conversationuuid="conversationStore.conversation.data.uuid" />
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -40,8 +40,6 @@ import { Button } from '@/components/ui/button'
 | 
				
			|||||||
import { useEditor, EditorContent } from '@tiptap/vue-3'
 | 
					import { useEditor, EditorContent } from '@tiptap/vue-3'
 | 
				
			||||||
import Placeholder from "@tiptap/extension-placeholder"
 | 
					import Placeholder from "@tiptap/extension-placeholder"
 | 
				
			||||||
import StarterKit from '@tiptap/starter-kit'
 | 
					import StarterKit from '@tiptap/starter-kit'
 | 
				
			||||||
import Image from '@tiptap/extension-image'
 | 
					 | 
				
			||||||
import ImageResize from 'tiptap-extension-resize-image';
 | 
					 | 
				
			||||||
import { Toggle } from '@/components/ui/toggle'
 | 
					import { Toggle } from '@/components/ui/toggle'
 | 
				
			||||||
import { Paperclip, Bold, Italic } from "lucide-vue-next"
 | 
					import { Paperclip, Bold, Italic } from "lucide-vue-next"
 | 
				
			||||||
import AttachmentsPreview from "@/components/attachment/AttachmentsPreview.vue"
 | 
					import AttachmentsPreview from "@/components/attachment/AttachmentsPreview.vue"
 | 
				
			||||||
@@ -78,7 +76,6 @@ const inputText = ref('')
 | 
				
			|||||||
const isBold = ref(false)
 | 
					const isBold = ref(false)
 | 
				
			||||||
const isItalic = ref(false)
 | 
					const isItalic = ref(false)
 | 
				
			||||||
const attachmentInput = ref(null)
 | 
					const attachmentInput = ref(null)
 | 
				
			||||||
const imageInput = ref(null)
 | 
					 | 
				
			||||||
const cannedResponseIndex = ref(0)
 | 
					const cannedResponseIndex = ref(0)
 | 
				
			||||||
const uploadedFiles = ref([])
 | 
					const uploadedFiles = ref([])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -94,13 +91,6 @@ const editor = ref(useEditor({
 | 
				
			|||||||
                'Control-i': () => applyItalic(),
 | 
					                'Control-i': () => applyItalic(),
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        }),
 | 
					        }),
 | 
				
			||||||
        Image.configure({
 | 
					 | 
				
			||||||
            inline: false,
 | 
					 | 
				
			||||||
            HTMLAttributes: {
 | 
					 | 
				
			||||||
                class: 'tiptap-editor-image',
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
        }),
 | 
					 | 
				
			||||||
        ImageResize,
 | 
					 | 
				
			||||||
    ],
 | 
					    ],
 | 
				
			||||||
    autofocus: true,
 | 
					    autofocus: true,
 | 
				
			||||||
    editorProps: {
 | 
					    editorProps: {
 | 
				
			||||||
@@ -174,6 +164,7 @@ onMounted(async () => {
 | 
				
			|||||||
            editor.value.commands.setTextSelection(editor.value.state.doc.content.size)
 | 
					            editor.value.commands.setTextSelection(editor.value.state.doc.content.size)
 | 
				
			||||||
        })
 | 
					        })
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					    await nextTick()
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Cleanup.
 | 
					// Cleanup.
 | 
				
			||||||
@@ -259,4 +250,6 @@ const handleOnFileDelete = uuid => {
 | 
				
			|||||||
    overflow: scroll;
 | 
					    overflow: scroll;
 | 
				
			||||||
    padding: 10px 10px;
 | 
					    padding: 10px 10px;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@import "vue-draggable-resizable/style.css";
 | 
				
			||||||
</style>
 | 
					</style>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,7 +2,7 @@
 | 
				
			|||||||
    <div class="h-screen">
 | 
					    <div class="h-screen">
 | 
				
			||||||
        <div class="px-3 pb-2 border-b-2 rounded-b-lg shadow-md">
 | 
					        <div class="px-3 pb-2 border-b-2 rounded-b-lg shadow-md">
 | 
				
			||||||
            <div class="flex justify-between mt-3">
 | 
					            <div class="flex justify-between mt-3">
 | 
				
			||||||
                <h3 class="scroll-m-20 text-2xl font-semibold tracking-tight flex gap-x-2">
 | 
					                <h3 class="scroll-m-20 text-2xl font-medium flex gap-x-2">
 | 
				
			||||||
                    Conversations
 | 
					                    Conversations
 | 
				
			||||||
                </h3>
 | 
					                </h3>
 | 
				
			||||||
                <div class="w-[8rem]">
 | 
					                <div class="w-[8rem]">
 | 
				
			||||||
@@ -12,7 +12,10 @@
 | 
				
			|||||||
                        </SelectTrigger>
 | 
					                        </SelectTrigger>
 | 
				
			||||||
                        <SelectContent>
 | 
					                        <SelectContent>
 | 
				
			||||||
                            <SelectGroup>
 | 
					                            <SelectGroup>
 | 
				
			||||||
                                <SelectLabel>Status</SelectLabel>
 | 
					                                <!-- <SelectLabel>Status</SelectLabel> -->
 | 
				
			||||||
 | 
					                                <SelectItem value="status_all">
 | 
				
			||||||
 | 
					                                    All
 | 
				
			||||||
 | 
					                                </SelectItem>
 | 
				
			||||||
                                <SelectItem value="status_open">
 | 
					                                <SelectItem value="status_open">
 | 
				
			||||||
                                    Open
 | 
					                                    Open
 | 
				
			||||||
                                </SelectItem>
 | 
					                                </SelectItem>
 | 
				
			||||||
@@ -98,8 +101,9 @@
 | 
				
			|||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { onMounted, ref, watch, computed } from 'vue'
 | 
					import { onMounted, ref, watch, computed, onUnmounted } from 'vue'
 | 
				
			||||||
import { useConversationStore } from '@/stores/conversation'
 | 
					import { useConversationStore } from '@/stores/conversation'
 | 
				
			||||||
 | 
					import { subscribeConversations } from "@/websocket.js"
 | 
				
			||||||
import { CONVERSATION_LIST_TYPE, CONVERSATION_PRE_DEFINED_FILTERS } from '@/constants/conversation'
 | 
					import { CONVERSATION_LIST_TYPE, CONVERSATION_PRE_DEFINED_FILTERS } from '@/constants/conversation'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { Error } from '@/components/ui/error'
 | 
					import { Error } from '@/components/ui/error'
 | 
				
			||||||
@@ -122,7 +126,6 @@ import {
 | 
				
			|||||||
    SelectContent,
 | 
					    SelectContent,
 | 
				
			||||||
    SelectGroup,
 | 
					    SelectGroup,
 | 
				
			||||||
    SelectItem,
 | 
					    SelectItem,
 | 
				
			||||||
    SelectLabel,
 | 
					 | 
				
			||||||
    SelectTrigger,
 | 
					    SelectTrigger,
 | 
				
			||||||
    SelectValue,
 | 
					    SelectValue,
 | 
				
			||||||
} from '@/components/ui/select'
 | 
					} from '@/components/ui/select'
 | 
				
			||||||
@@ -132,17 +135,38 @@ import ConversationListItem from '@/components/conversationlist/ConversationList
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const conversationStore = useConversationStore()
 | 
					const conversationStore = useConversationStore()
 | 
				
			||||||
const predefinedFilter = ref(CONVERSATION_PRE_DEFINED_FILTERS.STATUS_OPEN)
 | 
					const predefinedFilter = ref(CONVERSATION_PRE_DEFINED_FILTERS.ALL)
 | 
				
			||||||
const conversationType = ref(CONVERSATION_LIST_TYPE.ASSIGNED)
 | 
					const conversationType = ref(CONVERSATION_LIST_TYPE.ASSIGNED)
 | 
				
			||||||
 | 
					let listRefreshInterval = null
 | 
				
			||||||
 | 
					
 | 
				
			||||||
onMounted(() => {
 | 
					onMounted(() => {
 | 
				
			||||||
    conversationStore.fetchConversations(conversationType.value, predefinedFilter.value)
 | 
					    conversationStore.fetchConversations(conversationType.value, predefinedFilter.value)
 | 
				
			||||||
 | 
					    subscribeConversations(conversationType.value, predefinedFilter.value)
 | 
				
			||||||
 | 
					    // Refesh list every 1 minute to sync any missed changes.
 | 
				
			||||||
 | 
					    listRefreshInterval = setInterval(() => {
 | 
				
			||||||
 | 
					        conversationStore.fetchConversations(conversationType.value, predefinedFilter.value)
 | 
				
			||||||
 | 
					    }, 60000)
 | 
				
			||||||
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					onUnmounted(() => {
 | 
				
			||||||
 | 
					    clearInterval(listRefreshInterval)
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
watch(conversationType, (newType) => {
 | 
					watch(conversationType, (newType) => {
 | 
				
			||||||
    conversationStore.fetchConversations(newType, predefinedFilter.value)
 | 
					    conversationStore.fetchConversations(newType, predefinedFilter.value)
 | 
				
			||||||
 | 
					    subscribeConversations(newType, predefinedFilter.value)
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const handleFilterChange = (filter) => {
 | 
				
			||||||
 | 
					    predefinedFilter.value = filter
 | 
				
			||||||
 | 
					    conversationStore.fetchConversations(conversationType.value, filter)
 | 
				
			||||||
 | 
					    subscribeConversations(conversationType.value, predefinedFilter.value)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const loadNextPage = () => {
 | 
				
			||||||
 | 
					    conversationStore.fetchNextConversations(conversationType.value, predefinedFilter.value)
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const hasConversations = computed(() => {
 | 
					const hasConversations = computed(() => {
 | 
				
			||||||
    return conversationStore.sortedConversations.length !== 0 && !conversationStore.conversations.errorMessage && !conversationStore.conversations.loading
 | 
					    return conversationStore.sortedConversations.length !== 0 && !conversationStore.conversations.errorMessage && !conversationStore.conversations.loading
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
@@ -159,13 +183,5 @@ const conversationsLoading = computed(() => {
 | 
				
			|||||||
    return conversationStore.conversations.loading
 | 
					    return conversationStore.conversations.loading
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const handleFilterChange = (filter) => {
 | 
					 | 
				
			||||||
    predefinedFilter.value = filter
 | 
					 | 
				
			||||||
    conversationStore.fetchConversations(conversationType.value, filter)
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const loadNextPage = () => {
 | 
					 | 
				
			||||||
    conversationStore.fetchNextConversations(conversationType.value, predefinedFilter.value)
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,4 +1,5 @@
 | 
				
			|||||||
export const CONVERSATION_PRE_DEFINED_FILTERS = {
 | 
					export const CONVERSATION_PRE_DEFINED_FILTERS = {
 | 
				
			||||||
 | 
					    ALL: "status_all",
 | 
				
			||||||
    STATUS_OPEN: 'status_open',
 | 
					    STATUS_OPEN: 'status_open',
 | 
				
			||||||
    STATUS_PROCESSING: 'status_processing',
 | 
					    STATUS_PROCESSING: 'status_processing',
 | 
				
			||||||
    STATUS_SPAM: 'status_spam',
 | 
					    STATUS_SPAM: 'status_spam',
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -266,22 +266,39 @@ export const useConversationStore = defineStore('conversation', () => {
 | 
				
			|||||||
            }
 | 
					            }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    function updateMessageList (msg) {
 | 
					
 | 
				
			||||||
 | 
					    function updateConversationProp (update) {
 | 
				
			||||||
 | 
					        if (conversation?.data?.uuid === update.uuid) {
 | 
				
			||||||
 | 
					            conversation.data[update.prop] = update.val
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    function addNewConversation (conv) {
 | 
				
			||||||
 | 
					        if (!conversationUUIDExists(conv.uuid)) {
 | 
				
			||||||
 | 
					            conversations.data.push(conv)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    function conversationUUIDExists (uuid) {
 | 
				
			||||||
 | 
					        return conversations.data?.find(c => c.uuid === uuid) ? true : false
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    function updateMessageList (message) {
 | 
				
			||||||
        // Check if this conversation is selected and then update messages list.
 | 
					        // Check if this conversation is selected and then update messages list.
 | 
				
			||||||
        if (conversation?.data?.uuid === msg.conversation_uuid) {
 | 
					        if (conversation?.data?.uuid === message.conversation_uuid) {
 | 
				
			||||||
            // Fetch entire msg if the give msg does not exist in the msg list.
 | 
					            // Fetch entire message if the give msg does not exist in the msg list.
 | 
				
			||||||
            if (!messages.data.some(message => message.uuid === msg.uuid)) {
 | 
					            if (!messages.data.some(msg => msg.uuid === message.uuid)) {
 | 
				
			||||||
                fetchParticipants(msg.conversation_uuid)
 | 
					                fetchParticipants(message.conversation_uuid)
 | 
				
			||||||
                fetchMessage(msg.uuid)
 | 
					                fetchMessage(message.uuid)
 | 
				
			||||||
                updateAssigneeLastSeen(msg.conversation_uuid)
 | 
					                updateAssigneeLastSeen(message.conversation_uuid)
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    function updateMessageStatus (uuid, status) {
 | 
					    function updateMessageProp (message) {
 | 
				
			||||||
        const message = messages.data.find(m => m.uuid === uuid)
 | 
					        const existingMessage = messages.data.find(m => m.uuid === message.uuid)
 | 
				
			||||||
        if (message) {
 | 
					        if (existingMessage) {
 | 
				
			||||||
            message.status = status
 | 
					            existingMessage[message.prop] = message.val
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -305,5 +322,5 @@ export const useConversationStore = defineStore('conversation', () => {
 | 
				
			|||||||
        messages.errorMessage = ""
 | 
					        messages.errorMessage = ""
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return { conversations, conversation, messages, sortedConversations, sortedMessages, getContactFullName, fetchParticipants, fetchNextConversations, updateMessageStatus, updateAssigneeLastSeen, updateMessageList, fetchConversation, fetchConversations, fetchMessages, upsertTags, updateAssignee, updatePriority, updateStatus, updateConversationList, $reset };
 | 
					    return { conversations, conversation, messages, sortedConversations, sortedMessages, conversationUUIDExists, updateConversationProp, addNewConversation, getContactFullName, fetchParticipants, fetchNextConversations, updateMessageProp, updateAssigneeLastSeen, updateMessageList, fetchConversation, fetchConversations, fetchMessages, upsertTags, updateAssignee, updatePriority, updateStatus, updateConversationList, $reset };
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -18,6 +18,8 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<script setup>
 | 
				
			||||||
import { onMounted, watch } from "vue"
 | 
					import { onMounted, watch } from "vue"
 | 
				
			||||||
 | 
					import { subscribeConversation } from "@/websocket.js"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  ResizableHandle,
 | 
					  ResizableHandle,
 | 
				
			||||||
  ResizablePanel,
 | 
					  ResizablePanel,
 | 
				
			||||||
@@ -35,21 +37,29 @@ const props = defineProps({
 | 
				
			|||||||
const conversationStore = useConversationStore();
 | 
					const conversationStore = useConversationStore();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
onMounted(() => {
 | 
					onMounted(() => {
 | 
				
			||||||
  if (props.uuid) {
 | 
					  fetchConversation(props.uuid)
 | 
				
			||||||
    fetchConversation(props.uuid)
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
watch(() => props.uuid, (uuid) => {
 | 
					watch(() => props.uuid, (newUUID, oldUUID) => {
 | 
				
			||||||
  if (uuid) {
 | 
					  if (newUUID !== oldUUID) {
 | 
				
			||||||
    fetchConversation(uuid)
 | 
					    fetchConversation(newUUID)
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const fetchConversation = (uuid) => {
 | 
					const fetchConversation = (uuid) => {
 | 
				
			||||||
 | 
					  if (!uuid) return
 | 
				
			||||||
  conversationStore.fetchParticipants(uuid)
 | 
					  conversationStore.fetchParticipants(uuid)
 | 
				
			||||||
  conversationStore.fetchConversation(uuid)
 | 
					  conversationStore.fetchConversation(uuid)
 | 
				
			||||||
 | 
					  subscribeCurrentConversation(uuid)
 | 
				
			||||||
  conversationStore.fetchMessages(uuid)
 | 
					  conversationStore.fetchMessages(uuid)
 | 
				
			||||||
  conversationStore.updateAssigneeLastSeen(uuid)
 | 
					  conversationStore.updateAssigneeLastSeen(uuid)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// subscribes user to the conversation.
 | 
				
			||||||
 | 
					const subscribeCurrentConversation = async (uuid) => {
 | 
				
			||||||
 | 
					  if (!conversationStore.conversationUUIDExists(uuid)) {
 | 
				
			||||||
 | 
					    subscribeConversation(uuid)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -9,7 +9,7 @@
 | 
				
			|||||||
                        </CardTitle>
 | 
					                        </CardTitle>
 | 
				
			||||||
                    </CardHeader>
 | 
					                    </CardHeader>
 | 
				
			||||||
                    <CardContent class="grid gap-4">
 | 
					                    <CardContent class="grid gap-4">
 | 
				
			||||||
                        <div class="grid grid-cols-1 gap-6">
 | 
					                        <!-- <div class="grid grid-cols-1 gap-6">
 | 
				
			||||||
                            <Button variant="outline">
 | 
					                            <Button variant="outline">
 | 
				
			||||||
                                <svg role="img" viewBox="0 0 24 24" class="mr-2 h-4 w-4">
 | 
					                                <svg role="img" viewBox="0 0 24 24" class="mr-2 h-4 w-4">
 | 
				
			||||||
                                    <path fill="currentColor"
 | 
					                                    <path fill="currentColor"
 | 
				
			||||||
@@ -27,14 +27,15 @@
 | 
				
			|||||||
                                    Or continue with
 | 
					                                    Or continue with
 | 
				
			||||||
                                </span>
 | 
					                                </span>
 | 
				
			||||||
                            </div>
 | 
					                            </div>
 | 
				
			||||||
                        </div>
 | 
					                        </div> -->
 | 
				
			||||||
                        <div class="grid gap-2">
 | 
					                        <div class="grid gap-2">
 | 
				
			||||||
                            <Label for="email">Email</Label>
 | 
					                            <Label for="email">Email</Label>
 | 
				
			||||||
                            <Input id="email" type="email" placeholder="m@example.com" v-model.trim="loginForm.email" />
 | 
					                            <Input id="email" type="email" placeholder="Enter your email address"
 | 
				
			||||||
 | 
					                                v-model.trim="loginForm.email" />
 | 
				
			||||||
                        </div>
 | 
					                        </div>
 | 
				
			||||||
                        <div class="grid gap-2">
 | 
					                        <div class="grid gap-2">
 | 
				
			||||||
                            <Label for="password">Password</Label>
 | 
					                            <Label for="password">Password</Label>
 | 
				
			||||||
                            <Input id="password" type="password" v-model="loginForm.password" />
 | 
					                            <Input id="password" type="password" placeholder="Password" v-model="loginForm.password" />
 | 
				
			||||||
                        </div>
 | 
					                        </div>
 | 
				
			||||||
                    </CardContent>
 | 
					                    </CardContent>
 | 
				
			||||||
                    <CardFooter class="flex flex-col gap-5">
 | 
					                    <CardFooter class="flex flex-col gap-5">
 | 
				
			||||||
@@ -43,7 +44,7 @@
 | 
				
			|||||||
                        </Button>
 | 
					                        </Button>
 | 
				
			||||||
                        <Error :errorMessage="errorMessage" :border="true"></Error>
 | 
					                        <Error :errorMessage="errorMessage" :border="true"></Error>
 | 
				
			||||||
                        <div>
 | 
					                        <div>
 | 
				
			||||||
                            <a href="#" class="text-xs">Forgot ID or Password?</a>
 | 
					                            <a href="#" class="text-xs">Forgot Email or Password?</a>
 | 
				
			||||||
                        </div>
 | 
					                        </div>
 | 
				
			||||||
                    </CardFooter>
 | 
					                    </CardFooter>
 | 
				
			||||||
                </Card>
 | 
					                </Card>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,52 +1,145 @@
 | 
				
			|||||||
import { useConversationStore } from "./stores/conversation";
 | 
					import { useConversationStore } from './stores/conversation'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function initWS () {
 | 
					let socket
 | 
				
			||||||
 | 
					let reconnectInterval = 1000 // Initial reconnection interval
 | 
				
			||||||
 | 
					let maxReconnectInterval = 30000 // Maximum reconnection interval
 | 
				
			||||||
 | 
					let reconnectTimeout
 | 
				
			||||||
 | 
					let isReconnecting = false
 | 
				
			||||||
 | 
					let manualClose = false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function initWS() {
 | 
				
			||||||
    let convStore = useConversationStore()
 | 
					    let convStore = useConversationStore()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Create a new WebSocket connection to the specified WebSocket URL
 | 
					    // Initialize the WebSocket connection
 | 
				
			||||||
    const socket = new WebSocket('ws://localhost:9009/api/ws');
 | 
					    function initializeWebSocket() {
 | 
				
			||||||
 | 
					        socket = new WebSocket('ws://localhost:9009/api/ws')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Connection opened event
 | 
					        // Connection opened event
 | 
				
			||||||
    socket.addEventListener('open', function (event) {
 | 
					        socket.addEventListener('open', function () {
 | 
				
			||||||
        // Send a message to the server once the connection is opened
 | 
					            console.log('WebSocket connection established')
 | 
				
			||||||
        socket.send('Hello, server!!');
 | 
					            reconnectInterval = 1000 // Reset the reconnection interval
 | 
				
			||||||
    });
 | 
					            if (reconnectTimeout) {
 | 
				
			||||||
 | 
					                clearTimeout(reconnectTimeout) // Clear any existing reconnection timeout
 | 
				
			||||||
    // Listen for messages from the server
 | 
					                reconnectTimeout = null
 | 
				
			||||||
    socket.addEventListener('message', function (e) {
 | 
					 | 
				
			||||||
        console.log('Message from server !', e.data);
 | 
					 | 
				
			||||||
        if (e.data) {
 | 
					 | 
				
			||||||
            let event = JSON.parse(e.data)
 | 
					 | 
				
			||||||
            switch (event.ev) {
 | 
					 | 
				
			||||||
                case "new_msg":
 | 
					 | 
				
			||||||
                    convStore.updateConversationList(event.d)
 | 
					 | 
				
			||||||
                    convStore.updateMessageList(event.d)
 | 
					 | 
				
			||||||
                    break
 | 
					 | 
				
			||||||
                case "msg_status_update":
 | 
					 | 
				
			||||||
                    convStore.updateMessageStatus(event.d.uuid, event.d.status)
 | 
					 | 
				
			||||||
                    break
 | 
					 | 
				
			||||||
                default:
 | 
					 | 
				
			||||||
                    console.log(`Unknown event ${event.ev}`);
 | 
					 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Listen for messages from the server
 | 
				
			||||||
 | 
					        socket.addEventListener('message', function (e) {
 | 
				
			||||||
 | 
					            console.log('Message from server:', e.data)
 | 
				
			||||||
 | 
					            if (e.data) {
 | 
				
			||||||
 | 
					                let event = JSON.parse(e.data)
 | 
				
			||||||
 | 
					                // TODO: move event type to consts.
 | 
				
			||||||
 | 
					                switch (event.typ) {
 | 
				
			||||||
 | 
					                    case 'new_msg':
 | 
				
			||||||
 | 
					                        convStore.updateConversationList(event.d)
 | 
				
			||||||
 | 
					                        convStore.updateMessageList(event.d)
 | 
				
			||||||
 | 
					                        break
 | 
				
			||||||
 | 
					                    case 'msg_prop_update':
 | 
				
			||||||
 | 
					                        convStore.updateMessageProp(event.d)
 | 
				
			||||||
 | 
					                        break
 | 
				
			||||||
 | 
					                    case 'new_conv':
 | 
				
			||||||
 | 
					                        convStore.addNewConversation(event.d)
 | 
				
			||||||
 | 
					                        break
 | 
				
			||||||
 | 
					                    case 'conv_prop_update':
 | 
				
			||||||
 | 
					                        convStore.updateConversationProp(event.d)
 | 
				
			||||||
 | 
					                        break
 | 
				
			||||||
 | 
					                    default:
 | 
				
			||||||
 | 
					                        console.log(`Unknown event ${event.ev}`)
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Handle possible errors
 | 
				
			||||||
 | 
					        socket.addEventListener('error', function (event) {
 | 
				
			||||||
 | 
					            console.error('WebSocket error observed:', event)
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Handle the connection close event
 | 
				
			||||||
 | 
					        socket.addEventListener('close', function (event) {
 | 
				
			||||||
 | 
					            console.log('WebSocket connection closed:', event)
 | 
				
			||||||
 | 
					            if (!manualClose) {
 | 
				
			||||||
 | 
					                reconnect()
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Start the initial WebSocket connection
 | 
				
			||||||
 | 
					    initializeWebSocket()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Reconnect logic
 | 
				
			||||||
 | 
					    function reconnect() {
 | 
				
			||||||
 | 
					        if (isReconnecting) return
 | 
				
			||||||
 | 
					        isReconnecting = true
 | 
				
			||||||
 | 
					        reconnectTimeout = setTimeout(() => {
 | 
				
			||||||
 | 
					            initializeWebSocket()
 | 
				
			||||||
 | 
					            reconnectInterval = Math.min(reconnectInterval * 2, maxReconnectInterval)
 | 
				
			||||||
 | 
					            isReconnecting = false
 | 
				
			||||||
 | 
					        }, reconnectInterval)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Detect network status and handle reconnection
 | 
				
			||||||
 | 
					    window.addEventListener('online', () => {
 | 
				
			||||||
 | 
					        if (!isReconnecting && socket.readyState !== WebSocket.OPEN) {
 | 
				
			||||||
 | 
					            reconnectInterval = 1000 // Reset reconnection interval
 | 
				
			||||||
 | 
					            reconnect()
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
    });
 | 
					    })
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
    // Handle possible errors
 | 
					
 | 
				
			||||||
    socket.addEventListener('error', function (event) {
 | 
					function waitForWebSocketOpen(socket, callback) {
 | 
				
			||||||
        console.error('WebSocket error observed:', event);
 | 
					    if (socket.readyState === WebSocket.OPEN) {
 | 
				
			||||||
        console.log('WebSocket readyState:', socket.readyState); // Log the state of the WebSocket
 | 
					        callback()
 | 
				
			||||||
    });
 | 
					    } else {
 | 
				
			||||||
 | 
					        socket.addEventListener('open', function handler() {
 | 
				
			||||||
 | 
					            socket.removeEventListener('open', handler)
 | 
				
			||||||
    // Handle the connection close event
 | 
					            callback()
 | 
				
			||||||
    socket.addEventListener('close', function (event) {
 | 
					        })
 | 
				
			||||||
        console.log('WebSocket connection closed!:', event);
 | 
					    }
 | 
				
			||||||
    });
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    socket.onerror = function (event) {
 | 
					export function sendMessage(message) {
 | 
				
			||||||
        console.error("WebSocket error:", event);
 | 
					    waitForWebSocketOpen(socket, () => {
 | 
				
			||||||
    };
 | 
					        socket.send(JSON.stringify(message))
 | 
				
			||||||
    socket.onclose = function (event) {
 | 
					    })
 | 
				
			||||||
        console.log("WebSocket connection closed:", event);
 | 
					}
 | 
				
			||||||
    };
 | 
					
 | 
				
			||||||
 | 
					export function subscribeConversations(type, preDefinedFilter) {
 | 
				
			||||||
 | 
					    let message = {
 | 
				
			||||||
 | 
					        a: 'conversations_sub',
 | 
				
			||||||
 | 
					        t: type,
 | 
				
			||||||
 | 
					        pf: preDefinedFilter
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    waitForWebSocketOpen(socket, () => {
 | 
				
			||||||
 | 
					        socket.send(JSON.stringify(message))
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function subscribeConversation(uuid) {
 | 
				
			||||||
 | 
					    if (!uuid) {
 | 
				
			||||||
 | 
					        return
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    let message = {
 | 
				
			||||||
 | 
					        a: 'conversation_sub',
 | 
				
			||||||
 | 
					        uuid: uuid
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    waitForWebSocketOpen(socket, () => {
 | 
				
			||||||
 | 
					        socket.send(JSON.stringify(message))
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function unsubscribeConversation(uuid) {
 | 
				
			||||||
 | 
					    if (!uuid) {
 | 
				
			||||||
 | 
					        return
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    let message = {
 | 
				
			||||||
 | 
					        a: 'conversation_unsub',
 | 
				
			||||||
 | 
					        uuid: uuid
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    waitForWebSocketOpen(socket, () => {
 | 
				
			||||||
 | 
					        socket.send(JSON.stringify(message))
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -8,7 +8,7 @@ import (
 | 
				
			|||||||
	"net/textproto"
 | 
						"net/textproto"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/attachment/models"
 | 
						"github.com/abhinavxd/artemis/internal/attachment/models"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/dbutils"
 | 
						"github.com/abhinavxd/artemis/internal/dbutil"
 | 
				
			||||||
	"github.com/jmoiron/sqlx"
 | 
						"github.com/jmoiron/sqlx"
 | 
				
			||||||
	"github.com/zerodha/logf"
 | 
						"github.com/zerodha/logf"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
@@ -56,7 +56,7 @@ func New(opt Opts) (*Manager, error) {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	// Scan SQL file
 | 
						// Scan SQL file
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err := dbutils.ScanSQLFile("queries.sql", &q, opt.DB, efs); err != nil {
 | 
						if err := dbutil.ScanSQLFile("queries.sql", &q, opt.DB, efs); err != nil {
 | 
				
			||||||
		return nil, err
 | 
							return nil, err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return &Manager{
 | 
						return &Manager{
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1 +0,0 @@
 | 
				
			|||||||
package auditlog
 | 
					 | 
				
			||||||
@@ -1,10 +1,11 @@
 | 
				
			|||||||
package rbac
 | 
					package auth
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
	"embed"
 | 
						"embed"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/dbutils"
 | 
						"github.com/abhinavxd/artemis/internal/dbutil"
 | 
				
			||||||
	"github.com/jmoiron/sqlx"
 | 
						"github.com/jmoiron/sqlx"
 | 
				
			||||||
 | 
						"github.com/zerodha/logf"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
var (
 | 
					var (
 | 
				
			||||||
@@ -13,31 +14,30 @@ var (
 | 
				
			|||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type Engine struct {
 | 
					type Engine struct {
 | 
				
			||||||
	q queries
 | 
						q  queries
 | 
				
			||||||
 | 
						lo *logf.Logger
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type queries struct {
 | 
					type queries struct {
 | 
				
			||||||
	HasPermission *sqlx.Stmt `query:"has-permission"`
 | 
						HasPermission *sqlx.Stmt `query:"has-permission"`
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func New(db *sqlx.DB) (*Engine, error) {
 | 
					func New(db *sqlx.DB, lo *logf.Logger) (*Engine, error) {
 | 
				
			||||||
	var q queries
 | 
						var q queries
 | 
				
			||||||
 | 
						if err := dbutil.ScanSQLFile("queries.sql", &q, db, efs); err != nil {
 | 
				
			||||||
	if err := dbutils.ScanSQLFile("queries.sql", &q, db, efs); err != nil {
 | 
					 | 
				
			||||||
		return nil, err
 | 
							return nil, err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					 | 
				
			||||||
	return &Engine{
 | 
						return &Engine{
 | 
				
			||||||
		q: q,
 | 
							q:  q,
 | 
				
			||||||
 | 
							lo: lo,
 | 
				
			||||||
	}, nil
 | 
						}, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (e *Engine) HasPermission(userID int, perm string) (bool, error) {
 | 
					func (e *Engine) HasPermission(userID int, perm string) (bool, error) {
 | 
				
			||||||
	var hasPerm bool
 | 
						var hasPerm bool
 | 
				
			||||||
 | 
					 | 
				
			||||||
	if err := e.q.HasPermission.Get(&hasPerm, userID, perm); err != nil {
 | 
						if err := e.q.HasPermission.Get(&hasPerm, userID, perm); err != nil {
 | 
				
			||||||
 | 
							e.lo.Error("error fetching user permissions", "user_id", userID, "error", err)
 | 
				
			||||||
		return hasPerm, err
 | 
							return hasPerm, err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					 | 
				
			||||||
	return hasPerm, nil
 | 
						return hasPerm, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -8,8 +8,10 @@ import (
 | 
				
			|||||||
	"github.com/abhinavxd/artemis/internal/conversation"
 | 
						"github.com/abhinavxd/artemis/internal/conversation"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/conversation/models"
 | 
						"github.com/abhinavxd/artemis/internal/conversation/models"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/message"
 | 
						"github.com/abhinavxd/artemis/internal/message"
 | 
				
			||||||
 | 
						notifier "github.com/abhinavxd/artemis/internal/notification"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/systeminfo"
 | 
						"github.com/abhinavxd/artemis/internal/systeminfo"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/team"
 | 
						"github.com/abhinavxd/artemis/internal/team"
 | 
				
			||||||
 | 
						"github.com/abhinavxd/artemis/internal/ws"
 | 
				
			||||||
	"github.com/mr-karan/balance"
 | 
						"github.com/mr-karan/balance"
 | 
				
			||||||
	"github.com/zerodha/logf"
 | 
						"github.com/zerodha/logf"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
@@ -17,71 +19,48 @@ import (
 | 
				
			|||||||
const (
 | 
					const (
 | 
				
			||||||
	roundRobinDefaultWeight = 1
 | 
						roundRobinDefaultWeight = 1
 | 
				
			||||||
	strategyRoundRobin      = "round_robin"
 | 
						strategyRoundRobin      = "round_robin"
 | 
				
			||||||
	strategyLoadBalances    = "load_balanced"
 | 
					 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Engine handles the assignment of unassigned conversations to agents using a round-robin strategy.
 | 
					// Engine handles the assignment of unassigned conversations to agents of a tean using a round-robin strategy.
 | 
				
			||||||
type Engine struct {
 | 
					type Engine struct {
 | 
				
			||||||
	teamRoundRobinBalancer map[int]*balance.Balance
 | 
						teamRoundRobinBalancer map[int]*balance.Balance
 | 
				
			||||||
	mu                     sync.Mutex // Mutex to protect the balancer map
 | 
						userIDs                map[string]int
 | 
				
			||||||
	convMgr                *conversation.Manager
 | 
						// Mutex to protect the balancer map
 | 
				
			||||||
	teamMgr                *team.Manager
 | 
						mu       sync.Mutex
 | 
				
			||||||
	msgMgr                 *message.Manager
 | 
						convMgr  *conversation.Manager
 | 
				
			||||||
	lo                     *logf.Logger
 | 
						teamMgr  *team.Manager
 | 
				
			||||||
	strategy               string
 | 
						msgMgr   *message.Manager
 | 
				
			||||||
 | 
						lo       *logf.Logger
 | 
				
			||||||
 | 
						hub      *ws.Hub
 | 
				
			||||||
 | 
						notifier notifier.Notifier
 | 
				
			||||||
 | 
						strategy string
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// New creates a new instance of the Engine.
 | 
					// New creates a new instance of the Engine.
 | 
				
			||||||
func New(teamMgr *team.Manager, convMgr *conversation.Manager, msgMgr *message.Manager, lo *logf.Logger) (*Engine, error) {
 | 
					func New(teamMgr *team.Manager, convMgr *conversation.Manager, msgMgr *message.Manager,
 | 
				
			||||||
	balance, err := populateBalancerPool(teamMgr)
 | 
						notifier notifier.Notifier, hub *ws.Hub, lo *logf.Logger) (*Engine, error) {
 | 
				
			||||||
 | 
						var e = Engine{
 | 
				
			||||||
 | 
							notifier: notifier,
 | 
				
			||||||
 | 
							strategy: strategyRoundRobin,
 | 
				
			||||||
 | 
							convMgr:  convMgr,
 | 
				
			||||||
 | 
							teamMgr:  teamMgr,
 | 
				
			||||||
 | 
							msgMgr:   msgMgr,
 | 
				
			||||||
 | 
							lo:       lo,
 | 
				
			||||||
 | 
							hub:      hub,
 | 
				
			||||||
 | 
							mu:       sync.Mutex{},
 | 
				
			||||||
 | 
							userIDs:  map[string]int{},
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						balancer, err := e.populateBalancerPool()
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return nil, err
 | 
							return nil, err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return &Engine{
 | 
						e.teamRoundRobinBalancer = balancer
 | 
				
			||||||
		teamRoundRobinBalancer: balance,
 | 
						return &e, nil
 | 
				
			||||||
		strategy:               strategyRoundRobin,
 | 
					 | 
				
			||||||
		convMgr:                convMgr,
 | 
					 | 
				
			||||||
		teamMgr:                teamMgr,
 | 
					 | 
				
			||||||
		msgMgr:                 msgMgr,
 | 
					 | 
				
			||||||
		lo:                     lo,
 | 
					 | 
				
			||||||
		mu:                     sync.Mutex{},
 | 
					 | 
				
			||||||
	}, nil
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
func populateBalancerPool(teamMgr *team.Manager) (map[int]*balance.Balance, error) {
 | 
					 | 
				
			||||||
	var (
 | 
					 | 
				
			||||||
		balancer   = make(map[int]*balance.Balance)
 | 
					 | 
				
			||||||
		teams, err = teamMgr.GetAll()
 | 
					 | 
				
			||||||
	)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		return nil, err
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	for _, team := range teams {
 | 
					 | 
				
			||||||
		users, err := teamMgr.GetTeamMembers(team.Name)
 | 
					 | 
				
			||||||
		if err != nil {
 | 
					 | 
				
			||||||
			return nil, err
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		// Now add the users to team balance map.
 | 
					 | 
				
			||||||
		for _, user := range users {
 | 
					 | 
				
			||||||
			if _, ok := balancer[team.ID]; !ok {
 | 
					 | 
				
			||||||
				balancer[team.ID] = balance.NewBalance()
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
			balancer[team.ID].Add(user.UUID, roundRobinDefaultWeight)
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	return balancer, nil
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (e *Engine) Serve(ctx context.Context, interval time.Duration) {
 | 
					func (e *Engine) Serve(ctx context.Context, interval time.Duration) {
 | 
				
			||||||
	// Start updating the balancer pool periodically in a separate goroutine
 | 
					 | 
				
			||||||
	go e.refreshBalancerPeriodically(ctx, 1*time.Minute)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	ticker := time.NewTicker(interval)
 | 
						ticker := time.NewTicker(interval)
 | 
				
			||||||
	defer ticker.Stop()
 | 
						defer ticker.Stop()
 | 
				
			||||||
 | 
					 | 
				
			||||||
	for {
 | 
						for {
 | 
				
			||||||
		select {
 | 
							select {
 | 
				
			||||||
		case <-ctx.Done():
 | 
							case <-ctx.Done():
 | 
				
			||||||
@@ -94,67 +73,11 @@ func (e *Engine) Serve(ctx context.Context, interval time.Duration) {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// assignConversations fetches unassigned conversations and assigns them.
 | 
					func (e *Engine) RefreshBalancer() error {
 | 
				
			||||||
func (e *Engine) assignConversations() error {
 | 
					 | 
				
			||||||
	unassignedConv, err := e.convMgr.GetUnassigned()
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		return err
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	if len(unassignedConv) > 0 {
 | 
					 | 
				
			||||||
		e.lo.Debug("found unassigned conversations", "count", len(unassignedConv))
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	for _, conv := range unassignedConv {
 | 
					 | 
				
			||||||
		if e.strategy == strategyRoundRobin {
 | 
					 | 
				
			||||||
			e.roundRobin(conv)
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	return nil
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// roundRobin fetches an user from the team balancer pool and assigns the conversation to that user.
 | 
					 | 
				
			||||||
func (e *Engine) roundRobin(conv models.Conversation) {
 | 
					 | 
				
			||||||
	pool, ok := e.teamRoundRobinBalancer[conv.AssignedTeamID.Int]
 | 
					 | 
				
			||||||
	if !ok {
 | 
					 | 
				
			||||||
		e.lo.Warn("team not found in balancer", "id", conv.AssignedTeamID.Int)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	userUUID := pool.Get()
 | 
					 | 
				
			||||||
	e.lo.Debug("fetched user from rr pool for assignment", "user_uuid", userUUID)
 | 
					 | 
				
			||||||
	if userUUID == "" {
 | 
					 | 
				
			||||||
		e.lo.Warn("empty user returned from rr pool")
 | 
					 | 
				
			||||||
		return
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	if err := e.convMgr.UpdateAssignee(conv.UUID, []byte(userUUID), "agent"); err != nil {
 | 
					 | 
				
			||||||
		e.lo.Error("error updating conversation assignee", "error", err, "conv_uuid", conv.UUID, "user_uuid", userUUID)
 | 
					 | 
				
			||||||
		return
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	if err := e.msgMgr.RecordAssigneeUserChange(userUUID, conv.UUID, systeminfo.SystemUserUUID); err != nil {
 | 
					 | 
				
			||||||
		e.lo.Error("error recording conversation user change msg", "error", err, "conv_uuid", conv.UUID, "user_uuid", userUUID)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
func (e *Engine) refreshBalancerPeriodically(ctx context.Context, updateInterval time.Duration) {
 | 
					 | 
				
			||||||
	ticker := time.NewTicker(updateInterval)
 | 
					 | 
				
			||||||
	defer ticker.Stop()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	for {
 | 
					 | 
				
			||||||
		select {
 | 
					 | 
				
			||||||
		case <-ctx.Done():
 | 
					 | 
				
			||||||
			return
 | 
					 | 
				
			||||||
		case <-ticker.C:
 | 
					 | 
				
			||||||
			if err := e.refreshBalancer(); err != nil {
 | 
					 | 
				
			||||||
				e.lo.Error("Error updating team balancer pool", "error", err)
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
func (e *Engine) refreshBalancer() error {
 | 
					 | 
				
			||||||
	e.mu.Lock()
 | 
						e.mu.Lock()
 | 
				
			||||||
	defer e.mu.Unlock()
 | 
						defer e.mu.Unlock()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	balancer, err := populateBalancerPool(e.teamMgr)
 | 
						balancer, err := e.populateBalancerPool()
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		e.lo.Error("Error updating team balancer pool", "error", err)
 | 
							e.lo.Error("Error updating team balancer pool", "error", err)
 | 
				
			||||||
		return err
 | 
							return err
 | 
				
			||||||
@@ -162,3 +85,79 @@ func (e *Engine) refreshBalancer() error {
 | 
				
			|||||||
	e.teamRoundRobinBalancer = balancer
 | 
						e.teamRoundRobinBalancer = balancer
 | 
				
			||||||
	return nil
 | 
						return nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// populateBalancerPool populates the team balancer bool with the team members.
 | 
				
			||||||
 | 
					func (e *Engine) populateBalancerPool() (map[int]*balance.Balance, error) {
 | 
				
			||||||
 | 
						var (
 | 
				
			||||||
 | 
							balancer   = make(map[int]*balance.Balance)
 | 
				
			||||||
 | 
							teams, err = e.teamMgr.GetAll()
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						for _, team := range teams {
 | 
				
			||||||
 | 
							users, err := e.teamMgr.GetTeamMembers(team.Name)
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return nil, err
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Add the users to team balance map.
 | 
				
			||||||
 | 
							for _, user := range users {
 | 
				
			||||||
 | 
								if _, ok := balancer[team.ID]; !ok {
 | 
				
			||||||
 | 
									balancer[team.ID] = balance.NewBalance()
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								// FIXME: Balancer only supports strings.
 | 
				
			||||||
 | 
								balancer[team.ID].Add(user.UUID, roundRobinDefaultWeight)
 | 
				
			||||||
 | 
								e.userIDs[user.UUID] = user.ID
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return balancer, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// assignConversations fetches unassigned conversations and assigns them.
 | 
				
			||||||
 | 
					func (e *Engine) assignConversations() error {
 | 
				
			||||||
 | 
						unassignedConversations, err := e.convMgr.GetUnassigned()
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if len(unassignedConversations) > 0 {
 | 
				
			||||||
 | 
							e.lo.Debug("found unassigned conversations", "count", len(unassignedConversations))
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						for _, conversation := range unassignedConversations {
 | 
				
			||||||
 | 
							if e.strategy == strategyRoundRobin {
 | 
				
			||||||
 | 
								userUUID := e.getUser(conversation)
 | 
				
			||||||
 | 
								if userUUID == "" {
 | 
				
			||||||
 | 
									e.lo.Warn("user uuid not found for round robin assignment", "team_id", conversation.AssignedTeamID.Int)
 | 
				
			||||||
 | 
									continue
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								// Update assignee and record the assigne change message.
 | 
				
			||||||
 | 
								if err := e.convMgr.UpdateUserAssignee(conversation.UUID, []byte(userUUID)); err != nil {
 | 
				
			||||||
 | 
									continue
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								// Fixme: maybe move to messages?
 | 
				
			||||||
 | 
								e.hub.BroadcastConversationAssignment(e.userIDs[userUUID], conversation.UUID, conversation.AvatarURL.String, conversation.FirstName, conversation.LastName, conversation.LastMessage, conversation.InboxName, conversation.LastMessageAt.Time, 1)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								e.msgMgr.RecordAssigneeUserChange(conversation.UUID, userUUID, systeminfo.SystemUserUUID)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								// Send notification to the assignee.
 | 
				
			||||||
 | 
								e.notifier.SendAssignedConversationNotification([]string{userUUID}, conversation.UUID)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// getUser returns user uuid from the team balancer pool.
 | 
				
			||||||
 | 
					func (e *Engine) getUser(conversation models.Conversation) string {
 | 
				
			||||||
 | 
						pool, ok := e.teamRoundRobinBalancer[conversation.AssignedTeamID.Int]
 | 
				
			||||||
 | 
						if !ok {
 | 
				
			||||||
 | 
							e.lo.Warn("team not found in balancer", "id", conversation.AssignedTeamID.Int)
 | 
				
			||||||
 | 
							return ""
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return pool.Get()
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,11 +3,11 @@ package automation
 | 
				
			|||||||
import (
 | 
					import (
 | 
				
			||||||
	"context"
 | 
						"context"
 | 
				
			||||||
	"embed"
 | 
						"embed"
 | 
				
			||||||
	"fmt"
 | 
						"encoding/json"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/automation/models"
 | 
						"github.com/abhinavxd/artemis/internal/automation/models"
 | 
				
			||||||
	cmodels "github.com/abhinavxd/artemis/internal/conversation/models"
 | 
						cmodels "github.com/abhinavxd/artemis/internal/conversation/models"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/dbutils"
 | 
						"github.com/abhinavxd/artemis/internal/dbutil"
 | 
				
			||||||
	"github.com/jmoiron/sqlx"
 | 
						"github.com/jmoiron/sqlx"
 | 
				
			||||||
	"github.com/zerodha/logf"
 | 
						"github.com/zerodha/logf"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
@@ -17,29 +17,13 @@ var (
 | 
				
			|||||||
	efs embed.FS
 | 
						efs embed.FS
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type queries struct {
 | 
					 | 
				
			||||||
	GetNewConversationRules *sqlx.Stmt `query:"get-rules"`
 | 
					 | 
				
			||||||
	GetRuleActions          *sqlx.Stmt `query:"get-rule-actions"`
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
type Engine struct {
 | 
					type Engine struct {
 | 
				
			||||||
	q             queries
 | 
						q                 queries
 | 
				
			||||||
	lo            *logf.Logger
 | 
						lo                *logf.Logger
 | 
				
			||||||
	convUpdater   ConversationUpdater
 | 
						conversationStore ConversationStore
 | 
				
			||||||
	msgRecorder   MessageRecorder
 | 
						messageStore      MessageStore
 | 
				
			||||||
	conversationQ chan cmodels.Conversation
 | 
						rules             []models.Rule
 | 
				
			||||||
	rules         []models.Rule
 | 
						conversationQ     chan cmodels.Conversation
 | 
				
			||||||
	actions       []models.Action
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
type ConversationUpdater interface {
 | 
					 | 
				
			||||||
	UpdateAssignee(uuid string, assigneeUUID []byte, assigneeType string) error
 | 
					 | 
				
			||||||
	UpdateStatus(uuid string, status []byte) error
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
type MessageRecorder interface {
 | 
					 | 
				
			||||||
	RecordAssigneeUserChange(updatedValue, convUUID, actorUUID string) error
 | 
					 | 
				
			||||||
	RecordStatusChange(updatedValue, convUUID, actorUUID string) error
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type Opts struct {
 | 
					type Opts struct {
 | 
				
			||||||
@@ -47,6 +31,20 @@ type Opts struct {
 | 
				
			|||||||
	Lo *logf.Logger
 | 
						Lo *logf.Logger
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type ConversationStore interface {
 | 
				
			||||||
 | 
						UpdateTeamAssignee(uuid string, assigneeUUID []byte) error
 | 
				
			||||||
 | 
						UpdateStatus(uuid string, status []byte) error
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type MessageStore interface {
 | 
				
			||||||
 | 
						RecordAssigneeTeamChange(convUUID, value, actorUUID string) error
 | 
				
			||||||
 | 
						RecordStatusChange(updatedValue, convUUID, actorUUID string) error
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type queries struct {
 | 
				
			||||||
 | 
						GetRules *sqlx.Stmt `query:"get-rules"`
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func New(opt Opts) (*Engine, error) {
 | 
					func New(opt Opts) (*Engine, error) {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		q queries
 | 
							q queries
 | 
				
			||||||
@@ -55,29 +53,24 @@ func New(opt Opts) (*Engine, error) {
 | 
				
			|||||||
			conversationQ: make(chan cmodels.Conversation, 10000),
 | 
								conversationQ: make(chan cmodels.Conversation, 10000),
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
						if err := dbutil.ScanSQLFile("queries.sql", &q, opt.DB, efs); err != nil {
 | 
				
			||||||
	if err := dbutils.ScanSQLFile("queries.sql", &q, opt.DB, efs); err != nil {
 | 
					 | 
				
			||||||
		return nil, err
 | 
							return nil, err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					 | 
				
			||||||
	// Fetch rules and actions from the DB.
 | 
					 | 
				
			||||||
	if err := q.GetNewConversationRules.Select(&e.rules); err != nil {
 | 
					 | 
				
			||||||
		return nil, fmt.Errorf("fetching rules: %w", err)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	if err := q.GetRuleActions.Select(&e.actions); err != nil {
 | 
					 | 
				
			||||||
		return nil, fmt.Errorf("fetching rule actions: %w", err)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	e.q = q
 | 
						e.q = q
 | 
				
			||||||
 | 
						e.rules = e.getRules()
 | 
				
			||||||
	return e, nil
 | 
						return e, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (e *Engine) SetMsgRecorder(msgRecorder MessageRecorder) {
 | 
					func (e *Engine) ReloadRules() {
 | 
				
			||||||
	e.msgRecorder = msgRecorder
 | 
						e.rules = e.getRules()
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (e *Engine) SetConvUpdater(convUpdater ConversationUpdater) {
 | 
					func (e *Engine) SetMessageStore(messageStore MessageStore) {
 | 
				
			||||||
	e.convUpdater = convUpdater
 | 
						e.messageStore = messageStore
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (e *Engine) SetConversationStore(conversationStore ConversationStore) {
 | 
				
			||||||
 | 
						e.conversationStore = conversationStore
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (e *Engine) Serve(ctx context.Context) {
 | 
					func (e *Engine) Serve(ctx context.Context) {
 | 
				
			||||||
@@ -85,12 +78,39 @@ func (e *Engine) Serve(ctx context.Context) {
 | 
				
			|||||||
		select {
 | 
							select {
 | 
				
			||||||
		case <-ctx.Done():
 | 
							case <-ctx.Done():
 | 
				
			||||||
			return
 | 
								return
 | 
				
			||||||
		case conv := <-e.conversationQ:
 | 
							case conversation := <-e.conversationQ:
 | 
				
			||||||
			e.processConversations(conv)
 | 
								e.processConversations(conversation)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (e *Engine) EvaluateRules(c cmodels.Conversation) {
 | 
					func (e *Engine) EvaluateRules(c cmodels.Conversation) {
 | 
				
			||||||
	e.conversationQ <- c
 | 
						select {
 | 
				
			||||||
 | 
						case e.conversationQ <- c:
 | 
				
			||||||
 | 
						default:
 | 
				
			||||||
 | 
							// Queue is full.
 | 
				
			||||||
 | 
							e.lo.Warn("EvaluateRules: conversationQ is full, unable to enqueue conversation")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (e *Engine) getRules() []models.Rule {
 | 
				
			||||||
 | 
						var rulesJSON []string
 | 
				
			||||||
 | 
						err := e.q.GetRules.Select(&rulesJSON)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							e.lo.Error("error fetching automation rules", "error", err)
 | 
				
			||||||
 | 
							return nil
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						var rules []models.Rule
 | 
				
			||||||
 | 
						for _, ruleJSON := range rulesJSON {
 | 
				
			||||||
 | 
							var rulesBatch []models.Rule
 | 
				
			||||||
 | 
							if err := json.Unmarshal([]byte(ruleJSON), &rulesBatch); err != nil {
 | 
				
			||||||
 | 
								e.lo.Error("error unmarshalling rule JSON", "error", err)
 | 
				
			||||||
 | 
								continue
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							rules = append(rules, rulesBatch...)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						e.lo.Debug("fetched rules", "num", len(rules), "rules", rules)
 | 
				
			||||||
 | 
						return rules
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,22 +3,28 @@ package models
 | 
				
			|||||||
const (
 | 
					const (
 | 
				
			||||||
	ActionAssignTeam  = "assign_team"
 | 
						ActionAssignTeam  = "assign_team"
 | 
				
			||||||
	ActionAssignAgent = "assign_agent"
 | 
						ActionAssignAgent = "assign_agent"
 | 
				
			||||||
 | 
						OperatorAnd       = "AND"
 | 
				
			||||||
	RuleTypeNewConversation = "new_conversation"
 | 
						OperatorOR        = "OR"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type Rule struct {
 | 
					type Rule struct {
 | 
				
			||||||
	ID        int    `db:"id"`
 | 
						GroupOperator string       `json:"group_operator" db:"group_operator"`
 | 
				
			||||||
	Type      string `db:"type"`
 | 
						Groups        []RuleGroup  `json:"groups" db:"groups"`
 | 
				
			||||||
	Field     string `db:"field"`
 | 
						Actions       []RuleAction `json:"actions" db:"actions"`
 | 
				
			||||||
	Operator  string `db:"operator"`
 | 
					 | 
				
			||||||
	Value     string `db:"value"`
 | 
					 | 
				
			||||||
	GroupID   int    `db:"group_id"`
 | 
					 | 
				
			||||||
	LogicalOp string `db:"logical_op"`
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type Action struct {
 | 
					type RuleGroup struct {
 | 
				
			||||||
	RuleID int    `db:"rule_id"`
 | 
						LogicalOp string       `json:"logical_op" db:"logical_op"`
 | 
				
			||||||
	Type   string `db:"action_type"`
 | 
						Rules     []RuleDetail `json:"rules" db:"rules"`
 | 
				
			||||||
	Action string `db:"action"`
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type RuleDetail struct {
 | 
				
			||||||
 | 
						Field    string `json:"field" db:"field"`
 | 
				
			||||||
 | 
						Operator string `json:"operator" db:"operator"`
 | 
				
			||||||
 | 
						Value    string `json:"value" db:"value"`
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type RuleAction struct {
 | 
				
			||||||
 | 
						Type   string `json:"action_type" db:"action_type"`
 | 
				
			||||||
 | 
						Action string `json:"action" db:"action"`
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
							
								
								
									
										153
									
								
								internal/automation/processor.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										153
									
								
								internal/automation/processor.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,153 @@
 | 
				
			|||||||
 | 
					package automation
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"fmt"
 | 
				
			||||||
 | 
						"strings"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"github.com/abhinavxd/artemis/internal/automation/models"
 | 
				
			||||||
 | 
						cmodels "github.com/abhinavxd/artemis/internal/conversation/models"
 | 
				
			||||||
 | 
						"github.com/abhinavxd/artemis/internal/systeminfo"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (e *Engine) processConversations(conversation cmodels.Conversation) {
 | 
				
			||||||
 | 
						e.lo.Debug("num rules", "rules", len(e.rules))
 | 
				
			||||||
 | 
						for _, rule := range e.rules {
 | 
				
			||||||
 | 
							e.lo.Debug("eval rule", "groups", len(rule.Groups), "rule", rule)
 | 
				
			||||||
 | 
							if len(rule.Groups) > 2 {
 | 
				
			||||||
 | 
								continue
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							var results []bool
 | 
				
			||||||
 | 
							for _, group := range rule.Groups {
 | 
				
			||||||
 | 
								e.lo.Debug("evaluating group rule", "logical_op", group.LogicalOp)
 | 
				
			||||||
 | 
								result := e.evaluateGroup(group.Rules, group.LogicalOp, conversation)
 | 
				
			||||||
 | 
								e.lo.Debug("group evaluation status", "status", result)
 | 
				
			||||||
 | 
								results = append(results, result)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							if evaluateFinalResult(results, rule.GroupOperator) {
 | 
				
			||||||
 | 
								e.lo.Debug("rule fully evalauted, executing actions")
 | 
				
			||||||
 | 
								// All group rule evaluations successful, execute the actions.
 | 
				
			||||||
 | 
								for _, action := range rule.Actions {
 | 
				
			||||||
 | 
									e.executeActions(conversation, action)
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// evaluateFinalResult
 | 
				
			||||||
 | 
					func evaluateFinalResult(results []bool, operator string) bool {
 | 
				
			||||||
 | 
						if operator == models.OperatorAnd {
 | 
				
			||||||
 | 
							for _, result := range results {
 | 
				
			||||||
 | 
								if !result {
 | 
				
			||||||
 | 
									return false
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							return true
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if operator == models.OperatorOR {
 | 
				
			||||||
 | 
							for _, result := range results {
 | 
				
			||||||
 | 
								if result {
 | 
				
			||||||
 | 
									return true
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							return false
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return false
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// evaluateGroup
 | 
				
			||||||
 | 
					func (e *Engine) evaluateGroup(rules []models.RuleDetail, operator string, conversation cmodels.Conversation) bool {
 | 
				
			||||||
 | 
						switch operator {
 | 
				
			||||||
 | 
						case models.OperatorAnd:
 | 
				
			||||||
 | 
							// All conditions within the group must be true
 | 
				
			||||||
 | 
							for _, rule := range rules {
 | 
				
			||||||
 | 
								if !e.evaluateRule(rule, conversation) {
 | 
				
			||||||
 | 
									return false
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							return true
 | 
				
			||||||
 | 
						case models.OperatorOR:
 | 
				
			||||||
 | 
							// At least one condition within the group must be true
 | 
				
			||||||
 | 
							for _, rule := range rules {
 | 
				
			||||||
 | 
								if e.evaluateRule(rule, conversation) {
 | 
				
			||||||
 | 
									return true
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							return false
 | 
				
			||||||
 | 
						default:
 | 
				
			||||||
 | 
							e.lo.Error("invalid group operator", "operator", operator)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return false
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (e *Engine) evaluateRule(rule models.RuleDetail, conversation cmodels.Conversation) bool {
 | 
				
			||||||
 | 
						var (
 | 
				
			||||||
 | 
							conversationValue string
 | 
				
			||||||
 | 
							conditionMet      bool
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Extract the value from the conversation based on the rule's field
 | 
				
			||||||
 | 
						switch rule.Field {
 | 
				
			||||||
 | 
						case "subject":
 | 
				
			||||||
 | 
							conversationValue = conversation.Subject
 | 
				
			||||||
 | 
						case "content":
 | 
				
			||||||
 | 
							conversationValue = conversation.FirstMessage
 | 
				
			||||||
 | 
						case "status":
 | 
				
			||||||
 | 
							conversationValue = conversation.Status.String
 | 
				
			||||||
 | 
						case "priority":
 | 
				
			||||||
 | 
							conversationValue = conversation.Priority.String
 | 
				
			||||||
 | 
						default:
 | 
				
			||||||
 | 
							e.lo.Error("rule field not recognized", "field", rule.Field)
 | 
				
			||||||
 | 
							return false
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Lower case the value.
 | 
				
			||||||
 | 
						conversationValue = strings.ToLower(conversationValue)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Compare the conversation value with the rule's value based on the operator
 | 
				
			||||||
 | 
						switch rule.Operator {
 | 
				
			||||||
 | 
						case "equals":
 | 
				
			||||||
 | 
							conditionMet = conversationValue == rule.Value
 | 
				
			||||||
 | 
						case "not equal":
 | 
				
			||||||
 | 
							conditionMet = conversationValue != rule.Value
 | 
				
			||||||
 | 
						case "contains":
 | 
				
			||||||
 | 
							conditionMet = strings.Contains(conversationValue, rule.Value)
 | 
				
			||||||
 | 
						case "startsWith":
 | 
				
			||||||
 | 
							conditionMet = strings.HasPrefix(conversationValue, rule.Value)
 | 
				
			||||||
 | 
						case "endsWith":
 | 
				
			||||||
 | 
							conditionMet = strings.HasSuffix(conversationValue, rule.Value)
 | 
				
			||||||
 | 
						default:
 | 
				
			||||||
 | 
							e.lo.Error("logical operator not recognized for evaluating rules", "operator", rule.Operator)
 | 
				
			||||||
 | 
							return false
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return conditionMet
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (e *Engine) executeActions(conversation cmodels.Conversation, action models.RuleAction) {
 | 
				
			||||||
 | 
						err := e.applyAction(action, conversation)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							e.lo.Error("error executing rule action", "action", action.Action, "error", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (e *Engine) applyAction(action models.RuleAction, conversation cmodels.Conversation) error {
 | 
				
			||||||
 | 
						switch action.Type {
 | 
				
			||||||
 | 
						case models.ActionAssignTeam:
 | 
				
			||||||
 | 
							if err := e.conversationStore.UpdateTeamAssignee(conversation.UUID, []byte(action.Action)); err != nil {
 | 
				
			||||||
 | 
								return err
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							if err := e.messageStore.RecordAssigneeTeamChange(conversation.UUID, action.Action, systeminfo.SystemUserUUID); err != nil {
 | 
				
			||||||
 | 
								return err
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						case models.ActionAssignAgent:
 | 
				
			||||||
 | 
							if err := e.conversationStore.UpdateStatus(conversation.UUID, []byte(action.Action)); err != nil {
 | 
				
			||||||
 | 
								return err
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							if err := e.messageStore.RecordStatusChange(action.Action, conversation.UUID, systeminfo.SystemUserUUID); err != nil {
 | 
				
			||||||
 | 
								return err
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						default:
 | 
				
			||||||
 | 
							return fmt.Errorf("unrecognized rule action: %s", action.Type)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -1,6 +1,3 @@
 | 
				
			|||||||
-- name: get-rules
 | 
					-- name: get-rules
 | 
				
			||||||
select er.id, er.type, ec.field, ec."operator", ec.value, ec.group_id, ecg.logical_op from engine_rules er inner join engine_conditions ec on ec.rule_id = er.id  
 | 
					select rules
 | 
				
			||||||
inner join engine_condition_groups ecg on ecg.id = ec.group_id;
 | 
					from automation_rules;
 | 
				
			||||||
 | 
					 | 
				
			||||||
-- name: get-rule-actions
 | 
					 | 
				
			||||||
select rule_id, action_type, action from engine_actions;
 | 
					 | 
				
			||||||
@@ -4,7 +4,7 @@ import (
 | 
				
			|||||||
	"embed"
 | 
						"embed"
 | 
				
			||||||
	"fmt"
 | 
						"fmt"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/dbutils"
 | 
						"github.com/abhinavxd/artemis/internal/dbutil"
 | 
				
			||||||
	"github.com/jmoiron/sqlx"
 | 
						"github.com/jmoiron/sqlx"
 | 
				
			||||||
	"github.com/zerodha/logf"
 | 
						"github.com/zerodha/logf"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
@@ -37,7 +37,7 @@ type queries struct {
 | 
				
			|||||||
func New(opts Opts) (*Manager, error) {
 | 
					func New(opts Opts) (*Manager, error) {
 | 
				
			||||||
	var q queries
 | 
						var q queries
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err := dbutils.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
 | 
						if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
 | 
				
			||||||
		return nil, err
 | 
							return nil, err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -4,7 +4,7 @@ import (
 | 
				
			|||||||
	"embed"
 | 
						"embed"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/contact/models"
 | 
						"github.com/abhinavxd/artemis/internal/contact/models"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/dbutils"
 | 
						"github.com/abhinavxd/artemis/internal/dbutil"
 | 
				
			||||||
	"github.com/jmoiron/sqlx"
 | 
						"github.com/jmoiron/sqlx"
 | 
				
			||||||
	"github.com/zerodha/logf"
 | 
						"github.com/zerodha/logf"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
@@ -31,7 +31,7 @@ type queries struct {
 | 
				
			|||||||
func New(opts Opts) (*Manager, error) {
 | 
					func New(opts Opts) (*Manager, error) {
 | 
				
			||||||
	var q queries
 | 
						var q queries
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err := dbutils.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
 | 
						if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
 | 
				
			||||||
		return nil, err
 | 
							return nil, err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,16 +1,20 @@
 | 
				
			|||||||
package models
 | 
					package models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import "time"
 | 
					import (
 | 
				
			||||||
 | 
						"time"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"github.com/volatiletech/null/v9"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type Contact struct {
 | 
					type Contact struct {
 | 
				
			||||||
	ID          int     `db:"id" json:"id"`
 | 
						ID          int         `db:"id" json:"id"`
 | 
				
			||||||
	CreatedAt   time.Time `db:"created_at" json:"created_at"`
 | 
						CreatedAt   time.Time   `db:"created_at" json:"created_at"`
 | 
				
			||||||
	FirstName   string    `db:"first_name" json:"first_name"`
 | 
						FirstName   string      `db:"first_name" json:"first_name"`
 | 
				
			||||||
	LastName    string    `db:"last_name" json:"last_name"`
 | 
						LastName    string      `db:"last_name" json:"last_name"`
 | 
				
			||||||
	Email       string    `db:"email" json:"email"`
 | 
						Email       string      `db:"email" json:"email"`
 | 
				
			||||||
	PhoneNumber *string   `db:"phone_number" json:"phone_number"`
 | 
						PhoneNumber *string     `db:"phone_number" json:"phone_number"`
 | 
				
			||||||
	AvatarURL   *string   `db:"avatar_url" json:"avatar_url"`
 | 
						AvatarURL   null.String `db:"avatar_url" json:"avatar_url"`
 | 
				
			||||||
	InboxID     int       `db:"inbox_id" json:"inbox_id"`
 | 
						InboxID     int         `db:"inbox_id" json:"inbox_id"`
 | 
				
			||||||
	Source      string    `db:"source" json:"source"`
 | 
						Source      string      `db:"source" json:"source"`
 | 
				
			||||||
	SourceID    string    `db:"source_id" json:"source_id"`
 | 
						SourceID    string      `db:"source_id" json:"source_id"`
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -11,8 +11,9 @@ import (
 | 
				
			|||||||
	"time"
 | 
						"time"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/conversation/models"
 | 
						"github.com/abhinavxd/artemis/internal/conversation/models"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/dbutils"
 | 
						"github.com/abhinavxd/artemis/internal/dbutil"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/stringutils"
 | 
						"github.com/abhinavxd/artemis/internal/stringutil"
 | 
				
			||||||
 | 
						"github.com/abhinavxd/artemis/internal/ws"
 | 
				
			||||||
	"github.com/jmoiron/sqlx"
 | 
						"github.com/jmoiron/sqlx"
 | 
				
			||||||
	"github.com/lib/pq"
 | 
						"github.com/lib/pq"
 | 
				
			||||||
	"github.com/zerodha/logf"
 | 
						"github.com/zerodha/logf"
 | 
				
			||||||
@@ -43,11 +44,23 @@ var (
 | 
				
			|||||||
		PriortiyMedium,
 | 
							PriortiyMedium,
 | 
				
			||||||
		PriorityHigh,
 | 
							PriorityHigh,
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						preDefinedFilters = map[string]string{
 | 
				
			||||||
 | 
							"status_open":       " c.status = 'Open'",
 | 
				
			||||||
 | 
							"status_processing": " c.status = 'Processing'",
 | 
				
			||||||
 | 
							"status_spam":       " c.status = 'Spam'",
 | 
				
			||||||
 | 
							"status_resolved":   " c.status = 'Resolved'",
 | 
				
			||||||
 | 
							"status_all":        " 1=1  ",
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						assigneeTypeTeam = "team"
 | 
				
			||||||
 | 
						assigneeTypeUser = "user"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type Manager struct {
 | 
					type Manager struct {
 | 
				
			||||||
	lo                  *logf.Logger
 | 
						lo                  *logf.Logger
 | 
				
			||||||
	db                  *sqlx.DB
 | 
						db                  *sqlx.DB
 | 
				
			||||||
 | 
						hub                 *ws.Hub
 | 
				
			||||||
	q                   queries
 | 
						q                   queries
 | 
				
			||||||
	ReferenceNumPattern string
 | 
						ReferenceNumPattern string
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -66,6 +79,7 @@ type queries struct {
 | 
				
			|||||||
	GetUnassigned                *sqlx.Stmt `query:"get-unassigned"`
 | 
						GetUnassigned                *sqlx.Stmt `query:"get-unassigned"`
 | 
				
			||||||
	GetConversationParticipants  *sqlx.Stmt `query:"get-conversation-participants"`
 | 
						GetConversationParticipants  *sqlx.Stmt `query:"get-conversation-participants"`
 | 
				
			||||||
	GetConversations             string     `query:"get-conversations"`
 | 
						GetConversations             string     `query:"get-conversations"`
 | 
				
			||||||
 | 
						GetConversationsUUIDs        string     `query:"get-conversations-uuids"`
 | 
				
			||||||
	GetAssignedConversations     *sqlx.Stmt `query:"get-assigned-conversations"`
 | 
						GetAssignedConversations     *sqlx.Stmt `query:"get-assigned-conversations"`
 | 
				
			||||||
	GetAssigneeStats             *sqlx.Stmt `query:"get-assignee-stats"`
 | 
						GetAssigneeStats             *sqlx.Stmt `query:"get-assignee-stats"`
 | 
				
			||||||
	InsertConverstionParticipant *sqlx.Stmt `query:"insert-conversation-participant"`
 | 
						InsertConverstionParticipant *sqlx.Stmt `query:"insert-conversation-participant"`
 | 
				
			||||||
@@ -77,15 +91,18 @@ type queries struct {
 | 
				
			|||||||
	UpdatePriority               *sqlx.Stmt `query:"update-priority"`
 | 
						UpdatePriority               *sqlx.Stmt `query:"update-priority"`
 | 
				
			||||||
	UpdateStatus                 *sqlx.Stmt `query:"update-status"`
 | 
						UpdateStatus                 *sqlx.Stmt `query:"update-status"`
 | 
				
			||||||
	UpdateMeta                   *sqlx.Stmt `query:"update-meta"`
 | 
						UpdateMeta                   *sqlx.Stmt `query:"update-meta"`
 | 
				
			||||||
 | 
						AddTag                       *sqlx.Stmt `query:"add-tag"`
 | 
				
			||||||
 | 
						DeleteTags                   *sqlx.Stmt `query:"delete-tags"`
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func New(opts Opts) (*Manager, error) {
 | 
					func New(hub *ws.Hub, opts Opts) (*Manager, error) {
 | 
				
			||||||
	var q queries
 | 
						var q queries
 | 
				
			||||||
	if err := dbutils.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
 | 
						if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
 | 
				
			||||||
		return nil, err
 | 
							return nil, err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	c := &Manager{
 | 
						c := &Manager{
 | 
				
			||||||
		q:                   q,
 | 
							q:                   q,
 | 
				
			||||||
 | 
							hub:                 hub,
 | 
				
			||||||
		db:                  opts.DB,
 | 
							db:                  opts.DB,
 | 
				
			||||||
		lo:                  opts.Lo,
 | 
							lo:                  opts.Lo,
 | 
				
			||||||
		ReferenceNumPattern: opts.ReferenceNumPattern,
 | 
							ReferenceNumPattern: opts.ReferenceNumPattern,
 | 
				
			||||||
@@ -93,16 +110,17 @@ func New(opts Opts) (*Manager, error) {
 | 
				
			|||||||
	return c, nil
 | 
						return c, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (c *Manager) Create(contactID int, inboxID int, meta []byte) (int, error) {
 | 
					func (c *Manager) Create(contactID int, inboxID int, meta []byte) (int, string, error) {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		id        int
 | 
							id        int
 | 
				
			||||||
 | 
							uuid      string
 | 
				
			||||||
		refNum, _ = c.generateRefNum(c.ReferenceNumPattern)
 | 
							refNum, _ = c.generateRefNum(c.ReferenceNumPattern)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
	if err := c.q.InsertConversation.QueryRow(refNum, contactID, StatusOpen, inboxID, meta).Scan(&id); err != nil {
 | 
						if err := c.q.InsertConversation.QueryRow(refNum, contactID, StatusOpen, inboxID, meta).Scan(&id, &uuid); err != nil {
 | 
				
			||||||
		c.lo.Error("inserting new conversation into the DB", "error", err)
 | 
							c.lo.Error("inserting new conversation into the DB", "error", err)
 | 
				
			||||||
		return id, err
 | 
							return id, uuid, err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return id, nil
 | 
						return id, uuid, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (c *Manager) Get(uuid string) (models.Conversation, error) {
 | 
					func (c *Manager) Get(uuid string) (models.Conversation, error) {
 | 
				
			||||||
@@ -144,24 +162,33 @@ func (c *Manager) AddParticipant(userID int, convUUID string) error {
 | 
				
			|||||||
	return nil
 | 
						return nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (c *Manager) UpdateMeta(convID int, convUUID string, meta map[string]string) error {
 | 
					func (c *Manager) UpdateMeta(conversationID int, conversationUUID string, meta map[string]string) error {
 | 
				
			||||||
	metaJSON, err := json.Marshal(meta)
 | 
						metaJSON, err := json.Marshal(meta)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		c.lo.Error("error marshalling meta", "error", err)
 | 
							c.lo.Error("error marshalling meta", "error", err)
 | 
				
			||||||
		return err
 | 
							return err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if _, err := c.q.UpdateMeta.Exec(convID, convUUID, metaJSON); err != nil {
 | 
						if _, err := c.q.UpdateMeta.Exec(conversationID, conversationUUID, metaJSON); err != nil {
 | 
				
			||||||
		c.lo.Error("error updating conversation meta", "error", "error")
 | 
							c.lo.Error("error updating conversation meta", "error", "error")
 | 
				
			||||||
		return err
 | 
							return err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return nil
 | 
						return nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (c *Manager) UpdateFirstReplyAt(convID int, at time.Time) error {
 | 
					func (c *Manager) UpdateLastMessage(conversationID int, conversationUUID, lastMessage string, lastMessageAt time.Time) error {
 | 
				
			||||||
	if _, err := c.q.UpdateFirstReplyAt.Exec(convID, at); err != nil {
 | 
						return c.UpdateMeta(conversationID, conversationUUID, map[string]string{
 | 
				
			||||||
 | 
							"last_message":    lastMessage,
 | 
				
			||||||
 | 
							"last_message_at": lastMessageAt.Format(time.RFC3339),
 | 
				
			||||||
 | 
						})
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (c *Manager) UpdateFirstReplyAt(conversationUUID string, conversationID int, at time.Time) error {
 | 
				
			||||||
 | 
						if _, err := c.q.UpdateFirstReplyAt.Exec(conversationID, at); err != nil {
 | 
				
			||||||
		c.lo.Error("error updating conversation first reply at", "error", err)
 | 
							c.lo.Error("error updating conversation first reply at", "error", err)
 | 
				
			||||||
		return err
 | 
							return err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
						// Send ws update.
 | 
				
			||||||
 | 
						c.hub.BroadcastConversationPropertyUpdate(conversationUUID, "first_reply_at", time.Now().Format(time.RFC3339))
 | 
				
			||||||
	return nil
 | 
						return nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -217,24 +244,10 @@ func (c *Manager) GetConversations(userID int, typ, order, orderBy, predefinedFi
 | 
				
			|||||||
		qArgs         []interface{}
 | 
							qArgs         []interface{}
 | 
				
			||||||
		cond          string
 | 
							cond          string
 | 
				
			||||||
		// TODO: Remove these hardcoded values.
 | 
							// TODO: Remove these hardcoded values.
 | 
				
			||||||
		validOrderBy      = map[string]bool{"created_at": true, "priority": true, "status": true, "last_message_at": true}
 | 
							validOrderBy = map[string]bool{"created_at": true, "priority": true, "status": true, "last_message_at": true}
 | 
				
			||||||
		validOrder        = []string{"ASC", "DESC"}
 | 
							validOrder   = []string{"ASC", "DESC"}
 | 
				
			||||||
		preDefinedFilters = map[string]string{
 | 
					 | 
				
			||||||
			"status_open":       " c.status = 'Open'",
 | 
					 | 
				
			||||||
			"status_processing": " c.status = 'Processing'",
 | 
					 | 
				
			||||||
			"status_spam":       " c.status = 'Spam'",
 | 
					 | 
				
			||||||
			"status_resolved":   " c.status = 'Resolved'",
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if page <= 0 {
 | 
					 | 
				
			||||||
		page = 1
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	if pageSize <= 0 {
 | 
					 | 
				
			||||||
		pageSize = 20
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	switch typ {
 | 
						switch typ {
 | 
				
			||||||
	case "assigned":
 | 
						case "assigned":
 | 
				
			||||||
		cond = "AND c.assigned_user_id = $1"
 | 
							cond = "AND c.assigned_user_id = $1"
 | 
				
			||||||
@@ -251,11 +264,7 @@ func (c *Manager) GetConversations(userID int, typ, order, orderBy, predefinedFi
 | 
				
			|||||||
		cond += " AND " + filterClause
 | 
							cond += " AND " + filterClause
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Calculate offset based on page number and page size
 | 
						// Ensure orderBy is valid.
 | 
				
			||||||
	offset := (page - 1) * pageSize
 | 
					 | 
				
			||||||
	qArgs = append(qArgs, pageSize, offset)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	// Ensure orderBy is valid to prevent SQL injection
 | 
					 | 
				
			||||||
	orderByClause := ""
 | 
						orderByClause := ""
 | 
				
			||||||
	if _, ok := validOrderBy[orderBy]; ok {
 | 
						if _, ok := validOrderBy[orderBy]; ok {
 | 
				
			||||||
		orderByClause = fmt.Sprintf(" ORDER BY %s", orderBy)
 | 
							orderByClause = fmt.Sprintf(" ORDER BY %s", orderBy)
 | 
				
			||||||
@@ -269,6 +278,16 @@ func (c *Manager) GetConversations(userID int, typ, order, orderBy, predefinedFi
 | 
				
			|||||||
		orderByClause += " DESC "
 | 
							orderByClause += " DESC "
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Calculate offset based on page number and page size.
 | 
				
			||||||
 | 
						if page <= 0 {
 | 
				
			||||||
 | 
							page = 1
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if pageSize <= 0 || pageSize > 20 {
 | 
				
			||||||
 | 
							pageSize = 20
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						offset := (page - 1) * pageSize
 | 
				
			||||||
 | 
						qArgs = append(qArgs, pageSize, offset)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	tx, err := c.db.BeginTxx(context.Background(), nil)
 | 
						tx, err := c.db.BeginTxx(context.Background(), nil)
 | 
				
			||||||
	defer tx.Rollback()
 | 
						defer tx.Rollback()
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
@@ -276,7 +295,7 @@ func (c *Manager) GetConversations(userID int, typ, order, orderBy, predefinedFi
 | 
				
			|||||||
		return conversations, err
 | 
							return conversations, err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Include LIMIT, OFFSET, and ORDER BY in the SQL query
 | 
						// Include LIMIT, OFFSET, and ORDER BY in the SQL query.
 | 
				
			||||||
	sqlQuery := fmt.Sprintf("%s %s LIMIT $%d OFFSET $%d", fmt.Sprintf(c.q.GetConversations, cond), orderByClause, len(qArgs)-1, len(qArgs))
 | 
						sqlQuery := fmt.Sprintf("%s %s LIMIT $%d OFFSET $%d", fmt.Sprintf(c.q.GetConversations, cond), orderByClause, len(qArgs)-1, len(qArgs))
 | 
				
			||||||
	if err := tx.Select(&conversations, sqlQuery, qArgs...); err != nil {
 | 
						if err := tx.Select(&conversations, sqlQuery, qArgs...); err != nil {
 | 
				
			||||||
		c.lo.Error("Error fetching conversations", "error", err)
 | 
							c.lo.Error("Error fetching conversations", "error", err)
 | 
				
			||||||
@@ -286,6 +305,54 @@ func (c *Manager) GetConversations(userID int, typ, order, orderBy, predefinedFi
 | 
				
			|||||||
	return conversations, nil
 | 
						return conversations, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (c *Manager) GetConversationUUIDs(userID, page, pageSize int, typ, predefinedFilter string) ([]string, error) {
 | 
				
			||||||
 | 
						var (
 | 
				
			||||||
 | 
							conversationUUIDs []string
 | 
				
			||||||
 | 
							qArgs             []interface{}
 | 
				
			||||||
 | 
							cond              string
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
 | 
						switch typ {
 | 
				
			||||||
 | 
						case "assigned":
 | 
				
			||||||
 | 
							cond = "AND c.assigned_user_id = $1"
 | 
				
			||||||
 | 
							qArgs = append(qArgs, userID)
 | 
				
			||||||
 | 
						case "unassigned":
 | 
				
			||||||
 | 
							cond = "AND c.assigned_user_id IS NULL AND c.assigned_team_id IN (SELECT team_id FROM team_members WHERE user_id = $1)"
 | 
				
			||||||
 | 
							qArgs = append(qArgs, userID)
 | 
				
			||||||
 | 
						case "all":
 | 
				
			||||||
 | 
						default:
 | 
				
			||||||
 | 
							return conversationUUIDs, errors.New("invalid type")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if filterClause, ok := preDefinedFilters[predefinedFilter]; ok {
 | 
				
			||||||
 | 
							cond += " AND " + filterClause
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						tx, err := c.db.BeginTxx(context.Background(), nil)
 | 
				
			||||||
 | 
						defer tx.Rollback()
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							c.lo.Error("Error preparing get conversation ids query", "error", err)
 | 
				
			||||||
 | 
							return conversationUUIDs, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Calculate offset based on page number and page size.
 | 
				
			||||||
 | 
						if page <= 0 {
 | 
				
			||||||
 | 
							page = 1
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if pageSize <= 0 || pageSize > 20 {
 | 
				
			||||||
 | 
							pageSize = 20
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						offset := (page - 1) * pageSize
 | 
				
			||||||
 | 
						qArgs = append(qArgs, pageSize, offset)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Include LIMIT, OFFSET, and ORDER BY in the SQL query.
 | 
				
			||||||
 | 
						sqlQuery := fmt.Sprintf("%s LIMIT $%d OFFSET $%d", fmt.Sprintf(c.q.GetConversationsUUIDs, cond), len(qArgs)-1, len(qArgs))
 | 
				
			||||||
 | 
						if err := tx.Select(&conversationUUIDs, sqlQuery, qArgs...); err != nil {
 | 
				
			||||||
 | 
							c.lo.Error("Error fetching conversations", "error", err)
 | 
				
			||||||
 | 
							return conversationUUIDs, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return conversationUUIDs, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (c *Manager) GetAssignedConversations(userID int) ([]models.Conversation, error) {
 | 
					func (c *Manager) GetAssignedConversations(userID int) ([]models.Conversation, error) {
 | 
				
			||||||
	var conversations []models.Conversation
 | 
						var conversations []models.Conversation
 | 
				
			||||||
	if err := c.q.GetAssignedConversations.Select(&conversations, userID); err != nil {
 | 
						if err := c.q.GetAssignedConversations.Select(&conversations, userID); err != nil {
 | 
				
			||||||
@@ -295,18 +362,28 @@ func (c *Manager) GetAssignedConversations(userID int) ([]models.Conversation, e
 | 
				
			|||||||
	return conversations, nil
 | 
						return conversations, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (c *Manager) UpdateTeamAssignee(uuid string, assigneeUUID []byte) error {
 | 
				
			||||||
 | 
						return c.UpdateAssignee(uuid, assigneeUUID, assigneeTypeTeam)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (c *Manager) UpdateUserAssignee(uuid string, assigneeUUID []byte) error {
 | 
				
			||||||
 | 
						return c.UpdateAssignee(uuid, assigneeUUID, assigneeTypeUser)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (c *Manager) UpdateAssignee(uuid string, assigneeUUID []byte, assigneeType string) error {
 | 
					func (c *Manager) UpdateAssignee(uuid string, assigneeUUID []byte, assigneeType string) error {
 | 
				
			||||||
	switch assigneeType {
 | 
						switch assigneeType {
 | 
				
			||||||
	case "agent":
 | 
						case assigneeTypeUser:
 | 
				
			||||||
		if _, err := c.q.UpdateAssignedUser.Exec(uuid, assigneeUUID); err != nil {
 | 
							if _, err := c.q.UpdateAssignedUser.Exec(uuid, assigneeUUID); err != nil {
 | 
				
			||||||
			c.lo.Error("updating conversation assignee", "error", err)
 | 
								c.lo.Error("updating conversation assignee", "error", err)
 | 
				
			||||||
			return fmt.Errorf("error updating assignee")
 | 
								return fmt.Errorf("error updating assignee")
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	case "team":
 | 
							c.hub.BroadcastConversationPropertyUpdate(uuid, "assigned_user_uuid", string(assigneeUUID))
 | 
				
			||||||
 | 
						case assigneeTypeTeam:
 | 
				
			||||||
		if _, err := c.q.UpdateAssignedTeam.Exec(uuid, assigneeUUID); err != nil {
 | 
							if _, err := c.q.UpdateAssignedTeam.Exec(uuid, assigneeUUID); err != nil {
 | 
				
			||||||
			c.lo.Error("updating conversation assignee", "error", err)
 | 
								c.lo.Error("updating conversation assignee", "error", err)
 | 
				
			||||||
			return fmt.Errorf("error updating assignee")
 | 
								return fmt.Errorf("error updating assignee")
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
							c.hub.BroadcastConversationPropertyUpdate(uuid, "assigned_team_uuid", string(assigneeUUID))
 | 
				
			||||||
	default:
 | 
						default:
 | 
				
			||||||
		return errors.New("invalid assignee type")
 | 
							return errors.New("invalid assignee type")
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
@@ -314,13 +391,15 @@ func (c *Manager) UpdateAssignee(uuid string, assigneeUUID []byte, assigneeType
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (c *Manager) UpdatePriority(uuid string, priority []byte) error {
 | 
					func (c *Manager) UpdatePriority(uuid string, priority []byte) error {
 | 
				
			||||||
	if !slices.Contains(priorities, string(priority)) {
 | 
						var priorityStr = string(priority)
 | 
				
			||||||
		return fmt.Errorf("invalid `priority` value %s", priority)
 | 
						if !slices.Contains(priorities, string(priorityStr)) {
 | 
				
			||||||
 | 
							return fmt.Errorf("invalid `priority` value %s", priorityStr)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if _, err := c.q.UpdatePriority.Exec(uuid, priority); err != nil {
 | 
						if _, err := c.q.UpdatePriority.Exec(uuid, priority); err != nil {
 | 
				
			||||||
		c.lo.Error("updating conversation priority", "error", err)
 | 
							c.lo.Error("updating conversation priority", "error", err)
 | 
				
			||||||
		return fmt.Errorf("error updating priority")
 | 
							return fmt.Errorf("error updating priority")
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
						c.hub.BroadcastConversationPropertyUpdate(uuid, "priority", priorityStr)
 | 
				
			||||||
	return nil
 | 
						return nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -332,6 +411,7 @@ func (c *Manager) UpdateStatus(uuid string, status []byte) error {
 | 
				
			|||||||
		c.lo.Error("updating conversation status", "error", err)
 | 
							c.lo.Error("updating conversation status", "error", err)
 | 
				
			||||||
		return fmt.Errorf("error updating status")
 | 
							return fmt.Errorf("error updating status")
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
						c.hub.BroadcastConversationPropertyUpdate(uuid, "status", string(status))
 | 
				
			||||||
	return nil
 | 
						return nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -347,11 +427,26 @@ func (c *Manager) GetAssigneeStats(userID int) (models.ConversationCounts, error
 | 
				
			|||||||
	return counts, nil
 | 
						return counts, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (t *Manager) AddTags(convUUID string, tagIDs []int) error {
 | 
				
			||||||
 | 
						// Delete tags that have been removed.
 | 
				
			||||||
 | 
						if _, err := t.q.DeleteTags.Exec(convUUID, pq.Array(tagIDs)); err != nil {
 | 
				
			||||||
 | 
							t.lo.Error("error deleting conversation tags", "error", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Add new tags.
 | 
				
			||||||
 | 
						for _, tagID := range tagIDs {
 | 
				
			||||||
 | 
							if _, err := t.q.AddTag.Exec(convUUID, tagID); err != nil {
 | 
				
			||||||
 | 
								t.lo.Error("error adding tags to conversation", "error", err)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (c *Manager) generateRefNum(pattern string) (string, error) {
 | 
					func (c *Manager) generateRefNum(pattern string) (string, error) {
 | 
				
			||||||
	if len(pattern) <= 5 {
 | 
						if len(pattern) <= 5 {
 | 
				
			||||||
		pattern = "01234567890"
 | 
							pattern = "01234567890"
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	randomNumbers, err := stringutils.RandomNumericString(len(pattern))
 | 
						randomNumbers, err := stringutil.RandomNumericString(len(pattern))
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return "", err
 | 
							return "", err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -18,7 +18,7 @@ type Conversation struct {
 | 
				
			|||||||
	ReferenceNumber    null.String `db:"reference_number" json:"reference_number,omitempty"`
 | 
						ReferenceNumber    null.String `db:"reference_number" json:"reference_number,omitempty"`
 | 
				
			||||||
	Priority           null.String `db:"priority" json:"priority"`
 | 
						Priority           null.String `db:"priority" json:"priority"`
 | 
				
			||||||
	Status             null.String `db:"status" json:"status"`
 | 
						Status             null.String `db:"status" json:"status"`
 | 
				
			||||||
	FirstReplyAt       *time.Time  `db:"first_reply_at" json:"first_reply_at"`
 | 
						FirstReplyAt       null.Time   `db:"first_reply_at" json:"first_reply_at"`
 | 
				
			||||||
	AssignedUserID     null.Int    `db:"assigned_user_id" json:"-"`
 | 
						AssignedUserID     null.Int    `db:"assigned_user_id" json:"-"`
 | 
				
			||||||
	AssignedTeamID     null.Int    `db:"assigned_team_id" json:"-"`
 | 
						AssignedTeamID     null.Int    `db:"assigned_team_id" json:"-"`
 | 
				
			||||||
	AssigneeLastSeenAt *time.Time  `db:"assignee_last_seen_at" json:"assignee_last_seen_at"`
 | 
						AssigneeLastSeenAt *time.Time  `db:"assignee_last_seen_at" json:"assignee_last_seen_at"`
 | 
				
			||||||
@@ -32,7 +32,7 @@ type Conversation struct {
 | 
				
			|||||||
	ContactAvatarURL   *string          `db:"contact_avatar_url" json:"contact_avatar_url"`
 | 
						ContactAvatarURL   *string          `db:"contact_avatar_url" json:"contact_avatar_url"`
 | 
				
			||||||
	AssignedTeamUUID   *string          `db:"assigned_team_uuid" json:"assigned_team_uuid"`
 | 
						AssignedTeamUUID   *string          `db:"assigned_team_uuid" json:"assigned_team_uuid"`
 | 
				
			||||||
	AssignedAgentUUID  *string          `db:"assigned_user_uuid" json:"assigned_user_uuid"`
 | 
						AssignedAgentUUID  *string          `db:"assigned_user_uuid" json:"assigned_user_uuid"`
 | 
				
			||||||
	LastMessageAt      *time.Time       `db:"last_message_at" json:"last_message_at"`
 | 
						LastMessageAt      null.Time        `db:"last_message_at" json:"last_message_at"`
 | 
				
			||||||
	LastMessage        string           `db:"last_message" json:"last_message"`
 | 
						LastMessage        string           `db:"last_message" json:"last_message"`
 | 
				
			||||||
	FirstMessage       string           `json:"-"`
 | 
						FirstMessage       string           `json:"-"`
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,7 +2,7 @@
 | 
				
			|||||||
INSERT INTO conversations
 | 
					INSERT INTO conversations
 | 
				
			||||||
(reference_number, contact_id, status, inbox_id, meta)
 | 
					(reference_number, contact_id, status, inbox_id, meta)
 | 
				
			||||||
VALUES($1, $2, $3, $4, $5)
 | 
					VALUES($1, $2, $3, $4, $5)
 | 
				
			||||||
returning id;
 | 
					returning id, uuid;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
-- name: get-conversations
 | 
					-- name: get-conversations
 | 
				
			||||||
@@ -28,6 +28,12 @@ FROM conversations c
 | 
				
			|||||||
    JOIN inboxes inb on c.inbox_id = inb.id
 | 
					    JOIN inboxes inb on c.inbox_id = inb.id
 | 
				
			||||||
WHERE 1=1 %s
 | 
					WHERE 1=1 %s
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					-- name: get-conversations-uuids
 | 
				
			||||||
 | 
					SELECT
 | 
				
			||||||
 | 
					    c.uuid
 | 
				
			||||||
 | 
					FROM conversations c
 | 
				
			||||||
 | 
					WHERE 1=1 %s
 | 
				
			||||||
 | 
					
 | 
				
			||||||
-- name: get-assigned-conversations
 | 
					-- name: get-assigned-conversations
 | 
				
			||||||
SELECT uuid from conversations where assigned_user_id = $1;
 | 
					SELECT uuid from conversations where assigned_user_id = $1;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -131,7 +137,27 @@ VALUES($1, (select id from conversations where uuid = $2));
 | 
				
			|||||||
select uuids from conversations where assigned_user_id = $1;
 | 
					select uuids from conversations where assigned_user_id = $1;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
-- name: get-unassigned
 | 
					-- name: get-unassigned
 | 
				
			||||||
SELECT id, uuid, assigned_team_id from conversations where assigned_user_id is NULL and assigned_team_id is not null;
 | 
					SELECT
 | 
				
			||||||
 | 
					    c.updated_at,
 | 
				
			||||||
 | 
					    c.uuid,
 | 
				
			||||||
 | 
					    c.assignee_last_seen_at,
 | 
				
			||||||
 | 
					    c.assigned_team_id,
 | 
				
			||||||
 | 
					    inb.channel as inbox_channel,
 | 
				
			||||||
 | 
					    inb.name as inbox_name,
 | 
				
			||||||
 | 
					    ct.first_name,
 | 
				
			||||||
 | 
					    ct.last_name,
 | 
				
			||||||
 | 
					    ct.avatar_url,
 | 
				
			||||||
 | 
					    COALESCE(c.meta->>'subject', '') as subject,
 | 
				
			||||||
 | 
					    COALESCE(c.meta->>'last_message', '') as last_message,
 | 
				
			||||||
 | 
					    COALESCE((c.meta->>'last_message_at')::timestamp, '1970-01-01 00:00:00'::timestamp) as last_message_at,
 | 
				
			||||||
 | 
					    (
 | 
				
			||||||
 | 
					        SELECT COUNT(*)
 | 
				
			||||||
 | 
					        FROM messages m
 | 
				
			||||||
 | 
					        WHERE m.conversation_id = c.id AND m.created_at > c.assignee_last_seen_at
 | 
				
			||||||
 | 
					    ) AS unread_message_count
 | 
				
			||||||
 | 
					FROM conversations c
 | 
				
			||||||
 | 
					    JOIN contacts ct ON c.contact_id = ct.id
 | 
				
			||||||
 | 
					    JOIN inboxes inb on c.inbox_id = inb.id where assigned_user_id is NULL and assigned_team_id is not null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
-- name: get-assignee-stats
 | 
					-- name: get-assignee-stats
 | 
				
			||||||
SELECT 
 | 
					SELECT 
 | 
				
			||||||
@@ -149,3 +175,22 @@ WHERE
 | 
				
			|||||||
UPDATE conversations
 | 
					UPDATE conversations
 | 
				
			||||||
SET first_reply_at = $2
 | 
					SET first_reply_at = $2
 | 
				
			||||||
WHERE first_reply_at IS NULL AND id = $1;
 | 
					WHERE first_reply_at IS NULL AND id = $1;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					-- name: add-tag
 | 
				
			||||||
 | 
					INSERT INTO conversation_tags (conversation_id, tag_id)
 | 
				
			||||||
 | 
					VALUES(
 | 
				
			||||||
 | 
					    (
 | 
				
			||||||
 | 
					        SELECT id
 | 
				
			||||||
 | 
					        from conversations
 | 
				
			||||||
 | 
					        where uuid = $1
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
 | 
					    $2
 | 
				
			||||||
 | 
					) ON CONFLICT DO NOTHING
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					-- name: delete-tags
 | 
				
			||||||
 | 
					DELETE FROM conversation_tags
 | 
				
			||||||
 | 
					WHERE conversation_id = (
 | 
				
			||||||
 | 
					    SELECT id
 | 
				
			||||||
 | 
					    from conversations
 | 
				
			||||||
 | 
					    where uuid = $1
 | 
				
			||||||
 | 
					) AND tag_id NOT IN (SELECT unnest($2::int[]));
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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[]));
 | 
					 | 
				
			||||||
@@ -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
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,4 +1,4 @@
 | 
				
			|||||||
package dbutils
 | 
					package dbutil
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
	"io/fs"
 | 
						"io/fs"
 | 
				
			||||||
@@ -122,7 +122,6 @@ func (e *Email) processEnvelope(c *imapclient.Client, env *imap.Envelope, seqNum
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	exists, err := e.msgStore.MessageExists(env.MessageID)
 | 
						exists, err := e.msgStore.MessageExists(env.MessageID)
 | 
				
			||||||
	if exists || err != nil {
 | 
						if exists || err != nil {
 | 
				
			||||||
		e.lo.Debug("email message already exists, skipping", "message_id", env.MessageID)
 | 
					 | 
				
			||||||
		return nil
 | 
							return nil
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -13,10 +13,11 @@ import (
 | 
				
			|||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const (
 | 
					const (
 | 
				
			||||||
	headerReturnPath = "Return-Path"
 | 
						headerReturnPath  = "Return-Path"
 | 
				
			||||||
	headerMessageID  = "Message-ID"
 | 
						headerMessageID   = "Message-ID"
 | 
				
			||||||
	headerReferences = "References"
 | 
						headerReferences  = "References"
 | 
				
			||||||
	headerInReplyTo  = "In-Reply-To"
 | 
						headerInReplyTo   = "In-Reply-To"
 | 
				
			||||||
 | 
						dispositionInline = "inline"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// New returns an SMTP e-mail channels from the given SMTP server configcfg.
 | 
					// New returns an SMTP e-mail channels from the given SMTP server configcfg.
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,7 +6,7 @@ import (
 | 
				
			|||||||
	"encoding/json"
 | 
						"encoding/json"
 | 
				
			||||||
	"errors"
 | 
						"errors"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/dbutils"
 | 
						"github.com/abhinavxd/artemis/internal/dbutil"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/message/models"
 | 
						"github.com/abhinavxd/artemis/internal/message/models"
 | 
				
			||||||
	"github.com/jmoiron/sqlx"
 | 
						"github.com/jmoiron/sqlx"
 | 
				
			||||||
	"github.com/zerodha/logf"
 | 
						"github.com/zerodha/logf"
 | 
				
			||||||
@@ -20,7 +20,7 @@ var (
 | 
				
			|||||||
	ErrInboxNotFound = errors.New("inbox not found")
 | 
						ErrInboxNotFound = errors.New("inbox not found")
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Closer provides function for closing a channel.
 | 
					// Closer provides function for closing an inbox.
 | 
				
			||||||
type Closer interface {
 | 
					type Closer interface {
 | 
				
			||||||
	Close() error
 | 
						Close() error
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -83,7 +83,7 @@ func New(lo *logf.Logger, db *sqlx.DB) (*Manager, error) {
 | 
				
			|||||||
	var q queries
 | 
						var q queries
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Scan the sql	file into the queries struct.
 | 
						// Scan the sql	file into the queries struct.
 | 
				
			||||||
	if err := dbutils.ScanSQLFile("queries.sql", &q, db, efs); err != nil {
 | 
						if err := dbutil.ScanSQLFile("queries.sql", &q, db, efs); err != nil {
 | 
				
			||||||
		return nil, err
 | 
							return nil, err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -16,13 +16,13 @@ import (
 | 
				
			|||||||
	"github.com/abhinavxd/artemis/internal/automation"
 | 
						"github.com/abhinavxd/artemis/internal/automation"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/contact"
 | 
						"github.com/abhinavxd/artemis/internal/contact"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/conversation"
 | 
						"github.com/abhinavxd/artemis/internal/conversation"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/team"
 | 
					 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/user"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	cmodels "github.com/abhinavxd/artemis/internal/conversation/models"
 | 
						cmodels "github.com/abhinavxd/artemis/internal/conversation/models"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/dbutils"
 | 
						"github.com/abhinavxd/artemis/internal/dbutil"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/inbox"
 | 
						"github.com/abhinavxd/artemis/internal/inbox"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/message/models"
 | 
						"github.com/abhinavxd/artemis/internal/message/models"
 | 
				
			||||||
 | 
						"github.com/abhinavxd/artemis/internal/team"
 | 
				
			||||||
 | 
						"github.com/abhinavxd/artemis/internal/template"
 | 
				
			||||||
 | 
						"github.com/abhinavxd/artemis/internal/user"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/ws"
 | 
						"github.com/abhinavxd/artemis/internal/ws"
 | 
				
			||||||
	"github.com/jmoiron/sqlx"
 | 
						"github.com/jmoiron/sqlx"
 | 
				
			||||||
	"github.com/k3a/html2text"
 | 
						"github.com/k3a/html2text"
 | 
				
			||||||
@@ -66,19 +66,20 @@ const (
 | 
				
			|||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type Manager struct {
 | 
					type Manager struct {
 | 
				
			||||||
	q                      queries
 | 
						q                          queries
 | 
				
			||||||
	lo                     *logf.Logger
 | 
						lo                         *logf.Logger
 | 
				
			||||||
	contactMgr             *contact.Manager
 | 
						contactMgr                 *contact.Manager
 | 
				
			||||||
	attachmentMgr          *attachment.Manager
 | 
						attachmentMgr              *attachment.Manager
 | 
				
			||||||
	conversationMgr        *conversation.Manager
 | 
						conversationMgr            *conversation.Manager
 | 
				
			||||||
	inboxMgr               *inbox.Manager
 | 
						inboxMgr                   *inbox.Manager
 | 
				
			||||||
	userMgr                *user.Manager
 | 
						userMgr                    *user.Manager
 | 
				
			||||||
	teamMgr                *team.Manager
 | 
						teamMgr                    *team.Manager
 | 
				
			||||||
	automationEngine       *automation.Engine
 | 
						automationEngine           *automation.Engine
 | 
				
			||||||
	wsHub                  *ws.Hub
 | 
						wsHub                      *ws.Hub
 | 
				
			||||||
	incomingMsgQ           chan models.IncomingMessage
 | 
						templateManager            *template.Manager
 | 
				
			||||||
	outgoingMsgQ           chan models.Message
 | 
						incomingMsgQ               chan models.IncomingMessage
 | 
				
			||||||
	outgoingProcessingMsgs sync.Map
 | 
						outgoingMessageQueue       chan models.Message
 | 
				
			||||||
 | 
						outgoingProcessingMessages sync.Map
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type Opts struct {
 | 
					type Opts struct {
 | 
				
			||||||
@@ -109,26 +110,29 @@ func New(
 | 
				
			|||||||
	inboxMgr *inbox.Manager,
 | 
						inboxMgr *inbox.Manager,
 | 
				
			||||||
	conversationMgr *conversation.Manager,
 | 
						conversationMgr *conversation.Manager,
 | 
				
			||||||
	automationEngine *automation.Engine,
 | 
						automationEngine *automation.Engine,
 | 
				
			||||||
	opts Opts) (*Manager, error) {
 | 
						templateManager *template.Manager,
 | 
				
			||||||
 | 
						opts Opts,
 | 
				
			||||||
 | 
					) (*Manager, error) {
 | 
				
			||||||
	var q queries
 | 
						var q queries
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err := dbutils.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
 | 
						if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
 | 
				
			||||||
		return nil, err
 | 
							return nil, err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return &Manager{
 | 
						return &Manager{
 | 
				
			||||||
		q:                      q,
 | 
							q:                          q,
 | 
				
			||||||
		lo:                     opts.Lo,
 | 
							lo:                         opts.Lo,
 | 
				
			||||||
		wsHub:                  wsHub,
 | 
							wsHub:                      wsHub,
 | 
				
			||||||
		userMgr:                userMgr,
 | 
							userMgr:                    userMgr,
 | 
				
			||||||
		teamMgr:                teamMgr,
 | 
							teamMgr:                    teamMgr,
 | 
				
			||||||
		contactMgr:             contactMgr,
 | 
							contactMgr:                 contactMgr,
 | 
				
			||||||
		attachmentMgr:          attachmentMgr,
 | 
							attachmentMgr:              attachmentMgr,
 | 
				
			||||||
		conversationMgr:        conversationMgr,
 | 
							conversationMgr:            conversationMgr,
 | 
				
			||||||
		inboxMgr:               inboxMgr,
 | 
							inboxMgr:                   inboxMgr,
 | 
				
			||||||
		automationEngine:       automationEngine,
 | 
							automationEngine:           automationEngine,
 | 
				
			||||||
		incomingMsgQ:           make(chan models.IncomingMessage, opts.IncomingMsgQueueSize),
 | 
							templateManager:            templateManager,
 | 
				
			||||||
		outgoingMsgQ:           make(chan models.Message, opts.OutgoingMsgQueueSize),
 | 
							incomingMsgQ:               make(chan models.IncomingMessage, opts.IncomingMsgQueueSize),
 | 
				
			||||||
		outgoingProcessingMsgs: sync.Map{},
 | 
							outgoingMessageQueue:       make(chan models.Message, opts.OutgoingMsgQueueSize),
 | 
				
			||||||
 | 
							outgoingProcessingMessages: sync.Map{},
 | 
				
			||||||
	}, nil
 | 
						}, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -208,70 +212,74 @@ func (m *Manager) StartDispatcher(ctx context.Context, concurrency int, readInte
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
			// Prepare and push the message to the outgoing queue.
 | 
								// Prepare and push the message to the outgoing queue.
 | 
				
			||||||
			for _, msg := range pendingMsgs {
 | 
								for _, msg := range pendingMsgs {
 | 
				
			||||||
				m.outgoingProcessingMsgs.Store(msg.ID, msg.ID)
 | 
									var err error
 | 
				
			||||||
				m.outgoingMsgQ <- msg
 | 
									msg.Content, _, err = m.templateManager.RenderDefault(map[string]string{
 | 
				
			||||||
 | 
										"Content": msg.Content,
 | 
				
			||||||
 | 
									})
 | 
				
			||||||
 | 
									if err != nil {
 | 
				
			||||||
 | 
										m.lo.Error("error rendering message template", "error", err)
 | 
				
			||||||
 | 
										m.UpdateMessageStatus(msg.UUID, StatusFailed)
 | 
				
			||||||
 | 
										continue
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
									m.outgoingProcessingMessages.Store(msg.ID, msg.ID)
 | 
				
			||||||
 | 
									m.outgoingMessageQueue <- msg
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (m *Manager) DispatchWorker() {
 | 
					func (m *Manager) DispatchWorker() {
 | 
				
			||||||
	for msg := range m.outgoingMsgQ {
 | 
						for message := range m.outgoingMessageQueue {
 | 
				
			||||||
		inbox, err := m.inboxMgr.GetInbox(msg.InboxID)
 | 
							inbox, err := m.inboxMgr.GetInbox(message.InboxID)
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			m.lo.Error("error fetching inbox", "error", err, "inbox_id", msg.InboxID)
 | 
								m.lo.Error("error fetching inbox", "error", err, "inbox_id", message.InboxID)
 | 
				
			||||||
			m.outgoingProcessingMsgs.Delete(msg.ID)
 | 
								m.outgoingProcessingMessages.Delete(message.ID)
 | 
				
			||||||
			continue
 | 
								continue
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		msg.From = inbox.FromAddress()
 | 
							message.From = inbox.FromAddress()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		if err := m.attachAttachments(&msg); err != nil {
 | 
							if err := m.attachAttachments(&message); err != nil {
 | 
				
			||||||
			m.lo.Error("error attaching attachments to msg", "error", err)
 | 
								m.lo.Error("error attaching attachments to message", "error", err)
 | 
				
			||||||
			m.outgoingProcessingMsgs.Delete(msg.ID)
 | 
								m.outgoingProcessingMessages.Delete(message.ID)
 | 
				
			||||||
			continue
 | 
								continue
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		msg.To, _ = m.GetToAddress(msg.ConversationID, inbox.Channel())
 | 
							message.To, _ = m.GetToAddress(message.ConversationID, inbox.Channel())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		if inbox.Channel() == "email" {
 | 
							if inbox.Channel() == "email" {
 | 
				
			||||||
			msg.InReplyTo, _ = m.GetInReplyTo(msg.ConversationID)
 | 
								message.InReplyTo, _ = m.GetInReplyTo(message.ConversationID)
 | 
				
			||||||
			m.lo.Debug("set in reply to for outgoing email message", "in_reply_to", msg.InReplyTo)
 | 
					 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		err = inbox.Send(msg)
 | 
							err = inbox.Send(message)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		var newStatus = StatusSent
 | 
							var newStatus = StatusSent
 | 
				
			||||||
		if err != nil {
 | 
							if err != nil {
 | 
				
			||||||
			newStatus = StatusFailed
 | 
								newStatus = StatusFailed
 | 
				
			||||||
			m.lo.Error("error sending message", "error", err, "inbox_id", msg.InboxID)
 | 
								m.lo.Error("error sending message", "error", err, "inbox_id", message.InboxID)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		if _, err := m.q.UpdateMessageStatus.Exec(newStatus, msg.UUID); err != nil {
 | 
							if _, err := m.q.UpdateMessageStatus.Exec(newStatus, message.UUID); err != nil {
 | 
				
			||||||
			m.lo.Error("error updating message status in DB", "error", err, "inbox_id", msg.InboxID)
 | 
								m.lo.Error("error updating message status in DB", "error", err, "inbox_id", message.InboxID)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		switch newStatus {
 | 
							switch newStatus {
 | 
				
			||||||
		case StatusSent:
 | 
							case StatusSent:
 | 
				
			||||||
			m.lo.Debug("updating first reply at", "conv_id", msg.ConversationID, "at", msg.CreatedAt)
 | 
								m.conversationMgr.UpdateFirstReplyAt(message.ConversationUUID, message.ConversationID, message.CreatedAt)
 | 
				
			||||||
			m.conversationMgr.UpdateFirstReplyAt(msg.ConversationID, msg.CreatedAt)
 | 
					 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		// Broadcast the new message status.
 | 
							// Broadcast message status update to the subscribers.
 | 
				
			||||||
		m.wsHub.BroadcastMsgStatus(msg.ConversationUUID, map[string]interface{}{
 | 
							m.wsHub.BroadcastMessagePropUpdate(message.ConversationUUID, message.UUID, "status" /*message field*/, newStatus)
 | 
				
			||||||
			"uuid":              msg.UUID,
 | 
					 | 
				
			||||||
			"conversation_uuid": msg.ConversationUUID,
 | 
					 | 
				
			||||||
			"status":            newStatus,
 | 
					 | 
				
			||||||
		})
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
		m.outgoingProcessingMsgs.Delete(msg.ID)
 | 
							// Remove message from the processing list.
 | 
				
			||||||
 | 
							m.outgoingProcessingMessages.Delete(message.ID)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (m *Manager) GetToAddress(convID int, channel string) ([]string, error) {
 | 
					func (m *Manager) GetToAddress(convID int, channel string) ([]string, error) {
 | 
				
			||||||
	var addr []string
 | 
						var addr []string
 | 
				
			||||||
	if err := m.q.GetToAddress.Select(&addr, convID, channel); err != nil {
 | 
						if err := m.q.GetToAddress.Select(&addr, convID, channel); err != nil {
 | 
				
			||||||
		m.lo.Error("error fetching to address for msg", "error", err, "conv_id", convID)
 | 
							m.lo.Error("error fetching to address for msg", "error", err, "conversation_id", convID)
 | 
				
			||||||
		return addr, err
 | 
							return addr, err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return addr, nil
 | 
						return addr, nil
 | 
				
			||||||
@@ -281,10 +289,10 @@ func (m *Manager) GetInReplyTo(convID int) (string, error) {
 | 
				
			|||||||
	var out string
 | 
						var out string
 | 
				
			||||||
	if err := m.q.GetInReplyTo.Get(&out, convID); err != nil {
 | 
						if err := m.q.GetInReplyTo.Get(&out, convID); err != nil {
 | 
				
			||||||
		if err == sql.ErrNoRows {
 | 
							if err == sql.ErrNoRows {
 | 
				
			||||||
			m.lo.Error("in reply to not found", "error", err, "conv_id", convID)
 | 
								m.lo.Error("in reply to not found", "error", err, "conversation_id", convID)
 | 
				
			||||||
			return out, nil
 | 
								return out, nil
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		m.lo.Error("error fetching in reply to", "error", err, "conv_id", convID)
 | 
							m.lo.Error("error fetching in reply to", "error", err, "conversation_id", convID)
 | 
				
			||||||
		return out, err
 | 
							return out, err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	return out, nil
 | 
						return out, nil
 | 
				
			||||||
@@ -311,37 +319,36 @@ func (m *Manager) InsertWorker(ctx context.Context) {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (m *Manager) RecordAssigneeUserChange(updatedValue, convUUID, actorUUID string) error {
 | 
					func (m *Manager) RecordAssigneeUserChange(conversationUUID, assigneeUUID, actorUUID string) error {
 | 
				
			||||||
	if updatedValue == actorUUID {
 | 
						// Self assign.
 | 
				
			||||||
		return m.RecordActivity(ActivitySelfAssign, updatedValue, convUUID, actorUUID)
 | 
						if assigneeUUID == actorUUID {
 | 
				
			||||||
 | 
							return m.RecordActivity(ActivitySelfAssign, assigneeUUID, conversationUUID, actorUUID)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	assignee, err := m.userMgr.GetUser(0, updatedValue)
 | 
						assignee, err := m.userMgr.GetUser(0, assigneeUUID)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		m.lo.Error("Error fetching user to record assignee change", "error", err)
 | 
							m.lo.Error("Error fetching user to record assignee change", "conversation_uuid", conversationUUID, "actor_uuid", actorUUID, "error", err)
 | 
				
			||||||
		return err
 | 
							return err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	updatedValue = assignee.FullName()
 | 
						return m.RecordActivity(ActivityAssignedUserChange, assignee.FullName() /*new_value*/, conversationUUID, actorUUID)
 | 
				
			||||||
	return m.RecordActivity(ActivityAssignedUserChange, updatedValue, convUUID, actorUUID)
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (m *Manager) RecordAssigneeTeamChange(updatedValue, convUUID, actorUUID string) error {
 | 
					func (m *Manager) RecordAssigneeTeamChange(conversationUUID, value, actorUUID string) error {
 | 
				
			||||||
	team, err := m.teamMgr.GetTeam(updatedValue)
 | 
						team, err := m.teamMgr.GetTeam(value)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return err
 | 
							return err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	updatedValue = team.Name
 | 
						return m.RecordActivity(ActivityAssignedTeamChange, team.Name /*new_value*/, conversationUUID, actorUUID)
 | 
				
			||||||
	return m.RecordActivity(ActivityAssignedTeamChange, updatedValue, convUUID, actorUUID)
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (m *Manager) RecordPriorityChange(updatedValue, convUUID, actorUUID string) error {
 | 
					func (m *Manager) RecordPriorityChange(updatedValue, conversationUUID, actorUUID string) error {
 | 
				
			||||||
	return m.RecordActivity(ActivityPriorityChange, updatedValue, convUUID, actorUUID)
 | 
						return m.RecordActivity(ActivityPriorityChange, updatedValue, conversationUUID, actorUUID)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (m *Manager) RecordStatusChange(updatedValue, convUUID, actorUUID string) error {
 | 
					func (m *Manager) RecordStatusChange(updatedValue, conversationUUID, actorUUID string) error {
 | 
				
			||||||
	return m.RecordActivity(ActivityStatusChange, updatedValue, convUUID, actorUUID)
 | 
						return m.RecordActivity(ActivityStatusChange, updatedValue, conversationUUID, actorUUID)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (m *Manager) RecordActivity(activityType, updatedValue, conversationUUID, actorUUID string) error {
 | 
					func (m *Manager) RecordActivity(activityType, newValue, conversationUUID, actorUUID string) error {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
		actor, err = m.userMgr.GetUser(0, actorUUID)
 | 
							actor, err = m.userMgr.GetUser(0, actorUUID)
 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
@@ -350,7 +357,7 @@ func (m *Manager) RecordActivity(activityType, updatedValue, conversationUUID, a
 | 
				
			|||||||
		return err
 | 
							return err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	var content = m.getActivityContent(activityType, updatedValue, actor.FullName())
 | 
						var content = m.getActivityContent(activityType, newValue, actor.FullName())
 | 
				
			||||||
	if content == "" {
 | 
						if content == "" {
 | 
				
			||||||
		m.lo.Error("Error invalid activity for recording activity", "activity", activityType)
 | 
							m.lo.Error("Error invalid activity for recording activity", "activity", activityType)
 | 
				
			||||||
		return errors.New("invalid activity type for recording activity")
 | 
							return errors.New("invalid activity type for recording activity")
 | 
				
			||||||
@@ -369,8 +376,8 @@ func (m *Manager) RecordActivity(activityType, updatedValue, conversationUUID, a
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	m.RecordMessage(&msg)
 | 
						m.RecordMessage(&msg)
 | 
				
			||||||
	m.BroadcastNewMsg(msg, "")
 | 
						m.BroadcastNewConversationMessage(msg, content)
 | 
				
			||||||
 | 
						m.conversationMgr.UpdateLastMessage(0, conversationUUID, content, msg.CreatedAt)
 | 
				
			||||||
	return nil
 | 
						return nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -425,28 +432,33 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error {
 | 
				
			|||||||
		return nil
 | 
							return nil
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err = m.findOrCreateConversation(&in.Message, in.InboxID, senderID, convMetaJSON); err != nil {
 | 
						isNewConversation, err := m.findOrCreateConversation(&in.Message, in.InboxID, senderID, convMetaJSON)
 | 
				
			||||||
		m.lo.Error("error creating conversation", "error", err)
 | 
						if err != nil {
 | 
				
			||||||
		return err
 | 
							return err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err = m.RecordMessage(&in.Message); err != nil {
 | 
						if err = m.RecordMessage(&in.Message); err != nil {
 | 
				
			||||||
		m.lo.Error("error inserting conversation message", "error", err)
 | 
					 | 
				
			||||||
		return fmt.Errorf("inserting conversation message: %w", err)
 | 
							return fmt.Errorf("inserting conversation message: %w", err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err := m.uploadAttachments(&in.Message); err != nil {
 | 
						if err := m.uploadAttachments(&in.Message); err != nil {
 | 
				
			||||||
		m.lo.Error("error uploading message attachments", "msg_uuid", in.Message.UUID, "error", err)
 | 
					 | 
				
			||||||
		return fmt.Errorf("uploading message attachments: %w", err)
 | 
							return fmt.Errorf("uploading message attachments: %w", err)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Send WS update.
 | 
						// Send WS update.
 | 
				
			||||||
	if in.Message.ConversationUUID > "" {
 | 
						if in.Message.ConversationUUID > "" {
 | 
				
			||||||
		m.BroadcastNewMsg(in.Message, "")
 | 
							var content = ""
 | 
				
			||||||
 | 
							if isNewConversation {
 | 
				
			||||||
 | 
								content = m.TrimMsg(in.Message.Subject)
 | 
				
			||||||
 | 
							} else {
 | 
				
			||||||
 | 
								content = m.TrimMsg(in.Message.Content)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							m.BroadcastNewConversationMessage(in.Message, content)
 | 
				
			||||||
 | 
							m.conversationMgr.UpdateLastMessage(in.Message.ConversationID, in.Message.ConversationUUID, content, in.Message.CreatedAt)
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Evaluate automation rules for this conversation.
 | 
						// Evaluate automation rules for this new conversation.
 | 
				
			||||||
	if in.Message.NewConversation {
 | 
						if isNewConversation {
 | 
				
			||||||
		m.automationEngine.EvaluateRules(cmodels.Conversation{
 | 
							m.automationEngine.EvaluateRules(cmodels.Conversation{
 | 
				
			||||||
			UUID:         in.Message.ConversationUUID,
 | 
								UUID:         in.Message.ConversationUUID,
 | 
				
			||||||
			FirstMessage: in.Message.Content,
 | 
								FirstMessage: in.Message.Content,
 | 
				
			||||||
@@ -518,52 +530,53 @@ func (m *Manager) uploadAttachments(in *models.Message) error {
 | 
				
			|||||||
	return nil
 | 
						return nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (m *Manager) findOrCreateConversation(in *models.Message, inboxID int, contactID int, meta []byte) error {
 | 
					func (m *Manager) findOrCreateConversation(in *models.Message, inboxID int, contactID int, meta []byte) (bool, error) {
 | 
				
			||||||
	var (
 | 
						var (
 | 
				
			||||||
 | 
							new              bool
 | 
				
			||||||
 | 
							err              error
 | 
				
			||||||
		conversationID   int
 | 
							conversationID   int
 | 
				
			||||||
		conversationUUID string
 | 
							conversationUUID string
 | 
				
			||||||
		newConv          bool
 | 
					 | 
				
			||||||
		err              error
 | 
					 | 
				
			||||||
	)
 | 
						)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Search for existing conversation.
 | 
						// Search for existing conversation.
 | 
				
			||||||
	if conversationID == 0 && in.InReplyTo > "" {
 | 
						sourceIDs := in.References
 | 
				
			||||||
		conversationID, err = m.findConversationID([]string{in.InReplyTo})
 | 
						if in.InReplyTo > "" {
 | 
				
			||||||
		if err != nil && err != ErrConversationNotFound {
 | 
							sourceIDs = append(sourceIDs, in.InReplyTo)
 | 
				
			||||||
			return err
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
	if conversationID == 0 && len(in.References) > 0 {
 | 
						conversationID, err = m.findConversationID(sourceIDs)
 | 
				
			||||||
		conversationID, err = m.findConversationID(in.References)
 | 
						if err != nil && err != ErrConversationNotFound {
 | 
				
			||||||
		if err != nil && err != ErrConversationNotFound {
 | 
							return new, err
 | 
				
			||||||
			return err
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Conversation not found, create one.
 | 
						// Conversation not found, create one.
 | 
				
			||||||
	if conversationID == 0 {
 | 
						if conversationID == 0 {
 | 
				
			||||||
		newConv = true
 | 
							new = true
 | 
				
			||||||
		conversationID, err = m.conversationMgr.Create(contactID, inboxID, meta)
 | 
							conversationID, conversationUUID, err = m.conversationMgr.Create(contactID, inboxID, meta)
 | 
				
			||||||
		if err != nil || conversationID == 0 {
 | 
							if err != nil || conversationID == 0 {
 | 
				
			||||||
			return fmt.Errorf("inserting conversation: %w", err)
 | 
								return new, err
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							in.ConversationID = conversationID
 | 
				
			||||||
 | 
							in.ConversationUUID = conversationUUID
 | 
				
			||||||
 | 
							return new, nil
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Set UUID if not available.
 | 
				
			||||||
 | 
						if conversationUUID == "" {
 | 
				
			||||||
 | 
							conversationUUID, err = m.conversationMgr.GetUUID(conversationID)
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return new, err
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					 | 
				
			||||||
	// Fetch & return the UUID of the conversation for UI updates.
 | 
					 | 
				
			||||||
	conversationUUID, err = m.conversationMgr.GetUUID(conversationID)
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		m.lo.Error("Error fetching conversation UUID from id", err)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	in.ConversationID = conversationID
 | 
						in.ConversationID = conversationID
 | 
				
			||||||
	in.ConversationUUID = conversationUUID
 | 
						in.ConversationUUID = conversationUUID
 | 
				
			||||||
	in.NewConversation = newConv
 | 
						return new, nil
 | 
				
			||||||
 | 
					 | 
				
			||||||
	return nil
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// findConversationID finds the conversation ID from the message source ID.
 | 
					// findConversationID finds the conversation ID from the message source ID.
 | 
				
			||||||
func (m *Manager) findConversationID(sourceIDs []string) (int, error) {
 | 
					func (m *Manager) findConversationID(sourceIDs []string) (int, error) {
 | 
				
			||||||
 | 
						if len(sourceIDs) == 0 {
 | 
				
			||||||
 | 
							return 0, ErrConversationNotFound
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
	var conversationID int
 | 
						var conversationID int
 | 
				
			||||||
	if err := m.q.MessageExists.QueryRow(pq.Array(sourceIDs)).Scan(&conversationID); err != nil {
 | 
						if err := m.q.MessageExists.QueryRow(pq.Array(sourceIDs)).Scan(&conversationID); err != nil {
 | 
				
			||||||
		if err == sql.ErrNoRows {
 | 
							if err == sql.ErrNoRows {
 | 
				
			||||||
@@ -583,6 +596,11 @@ func (m *Manager) attachAttachments(msg *models.Message) error {
 | 
				
			|||||||
		return err
 | 
							return err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// TODO: set attachment headers and replace the inline image src url w
 | 
				
			||||||
 | 
						// src="cid:ii_lxxsfhtp0"
 | 
				
			||||||
 | 
						// a.Header.Set("Content-Disposition", "inline")
 | 
				
			||||||
 | 
						// a.Header.Set("Content-ID", "<"+f.CID+">")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Fetch the blobs and attach the attachments to the message.
 | 
						// Fetch the blobs and attach the attachments to the message.
 | 
				
			||||||
	for i, att := range attachments {
 | 
						for i, att := range attachments {
 | 
				
			||||||
		attachments[i].Content, err = m.attachmentMgr.Store.GetBlob(att.UUID)
 | 
							attachments[i].Content, err = m.attachmentMgr.Store.GetBlob(att.UUID)
 | 
				
			||||||
@@ -599,7 +617,7 @@ func (m *Manager) attachAttachments(msg *models.Message) error {
 | 
				
			|||||||
// getOutgoingProcessingMsgIDs returns outgoing msg ids currently being processed.
 | 
					// getOutgoingProcessingMsgIDs returns outgoing msg ids currently being processed.
 | 
				
			||||||
func (m *Manager) getOutgoingProcessingMsgIDs() []int {
 | 
					func (m *Manager) getOutgoingProcessingMsgIDs() []int {
 | 
				
			||||||
	var out = make([]int, 0)
 | 
						var out = make([]int, 0)
 | 
				
			||||||
	m.outgoingProcessingMsgs.Range(func(key, _ any) bool {
 | 
						m.outgoingProcessingMessages.Range(func(key, _ any) bool {
 | 
				
			||||||
		if k, ok := key.(int); ok {
 | 
							if k, ok := key.(int); ok {
 | 
				
			||||||
			out = append(out, k)
 | 
								out = append(out, k)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
@@ -608,21 +626,6 @@ func (m *Manager) getOutgoingProcessingMsgIDs() []int {
 | 
				
			|||||||
	return out
 | 
						return out
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (m *Manager) BroadcastNewMsg(msg models.Message, trimmedContent string) {
 | 
					func (m *Manager) BroadcastNewConversationMessage(message models.Message, trimmedContent string) {
 | 
				
			||||||
	if trimmedContent == "" {
 | 
						m.wsHub.BroadcastNewConversationMessage(message.ConversationUUID, trimmedContent, message.UUID, time.Now().Format(time.RFC3339), message.Private)
 | 
				
			||||||
		var content = ""
 | 
					 | 
				
			||||||
		if msg.NewConversation {
 | 
					 | 
				
			||||||
			content = m.TrimMsg(msg.Subject)
 | 
					 | 
				
			||||||
		} else {
 | 
					 | 
				
			||||||
			content = m.TrimMsg(msg.Content)
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
		trimmedContent = content
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
	m.wsHub.BroadcastNewMsg(msg.ConversationUUID, map[string]interface{}{
 | 
					 | 
				
			||||||
		"conversation_uuid": msg.ConversationUUID,
 | 
					 | 
				
			||||||
		"uuid":              msg.UUID,
 | 
					 | 
				
			||||||
		"last_message":      trimmedContent,
 | 
					 | 
				
			||||||
		"last_message_at":   time.Now().Format(time.DateTime),
 | 
					 | 
				
			||||||
		"private":           msg.Private,
 | 
					 | 
				
			||||||
	})
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -41,7 +41,6 @@ type Message struct {
 | 
				
			|||||||
	References       []string             `json:"-"`
 | 
						References       []string             `json:"-"`
 | 
				
			||||||
	InReplyTo        string               `json:"-"`
 | 
						InReplyTo        string               `json:"-"`
 | 
				
			||||||
	Headers          textproto.MIMEHeader `json:"-"`
 | 
						Headers          textproto.MIMEHeader `json:"-"`
 | 
				
			||||||
	NewConversation  bool                 `json:"-"`
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// IncomingMessage links a message with the contact information and inbox id.
 | 
					// IncomingMessage links a message with the contact information and inbox id.
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										22
									
								
								internal/notification/notification.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								internal/notification/notification.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,22 @@
 | 
				
			|||||||
 | 
					package notifier
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Notifier defines the interface for sending notifications.
 | 
				
			||||||
 | 
					type Notifier interface {
 | 
				
			||||||
 | 
						SendMessage(userUUIDs []string, subject, content string) error
 | 
				
			||||||
 | 
						SendAssignedConversationNotification(userUUIDs []string, convUUID string) error
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// TemplateRenderer defines the interface for rendering templates.
 | 
				
			||||||
 | 
					type TemplateRenderer interface {
 | 
				
			||||||
 | 
						RenderDefault(data interface{}) (subject, content string, err error)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// UserEmailFetcher defines the interfaces for fetchign user email address.
 | 
				
			||||||
 | 
					type UserEmailFetcher interface {
 | 
				
			||||||
 | 
						GetEmail(id int, uuid string) (string, error)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// UserStore defines the interface for the user store.
 | 
				
			||||||
 | 
					type UserStore interface {
 | 
				
			||||||
 | 
						UserEmailFetcher
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										138
									
								
								internal/notification/providers/email/email.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										138
									
								
								internal/notification/providers/email/email.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,138 @@
 | 
				
			|||||||
 | 
					package email
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"fmt"
 | 
				
			||||||
 | 
						"math/rand"
 | 
				
			||||||
 | 
						"net/textproto"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"github.com/abhinavxd/artemis/internal/inbox/channel/email"
 | 
				
			||||||
 | 
						"github.com/abhinavxd/artemis/internal/message/models"
 | 
				
			||||||
 | 
						notifier "github.com/abhinavxd/artemis/internal/notification"
 | 
				
			||||||
 | 
						"github.com/knadh/smtppool"
 | 
				
			||||||
 | 
						"github.com/zerodha/logf"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Notifier handles email notifications.
 | 
				
			||||||
 | 
					type Notifier struct {
 | 
				
			||||||
 | 
						lo               *logf.Logger
 | 
				
			||||||
 | 
						from             string
 | 
				
			||||||
 | 
						smtpPools        []*smtppool.Pool
 | 
				
			||||||
 | 
						userStore        notifier.UserStore
 | 
				
			||||||
 | 
						TemplateRenderer notifier.TemplateRenderer
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type Opts struct {
 | 
				
			||||||
 | 
						Lo        *logf.Logger
 | 
				
			||||||
 | 
						FromEmail string
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// New creates a new instance of email Notifier.
 | 
				
			||||||
 | 
					func New(smtpConfig []email.SMTPConfig, userStore notifier.UserStore, TemplateRenderer notifier.TemplateRenderer, opts Opts) (*Notifier, error) {
 | 
				
			||||||
 | 
						pools, err := email.NewSmtpPool(smtpConfig)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return &Notifier{
 | 
				
			||||||
 | 
							lo:               opts.Lo,
 | 
				
			||||||
 | 
							smtpPools:        pools,
 | 
				
			||||||
 | 
							from:             opts.FromEmail,
 | 
				
			||||||
 | 
							userStore:        userStore,
 | 
				
			||||||
 | 
							TemplateRenderer: TemplateRenderer,
 | 
				
			||||||
 | 
						}, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// SendMessage sends an email using the default template to multiple users.
 | 
				
			||||||
 | 
					func (e *Notifier) SendMessage(userUUIDs []string, subject, content string) error {
 | 
				
			||||||
 | 
						var recipientEmails []string
 | 
				
			||||||
 | 
						for i := 0; i < len(userUUIDs); i++ {
 | 
				
			||||||
 | 
							userEmail, err := e.userStore.GetEmail(0, userUUIDs[i])
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								e.lo.Error("error fetching user email for user uuid", "error", err)
 | 
				
			||||||
 | 
								return err
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							recipientEmails = append(recipientEmails, userEmail)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Render with default template.
 | 
				
			||||||
 | 
						templateBody, templateSubject, err := e.TemplateRenderer.RenderDefault(map[string]string{
 | 
				
			||||||
 | 
							"Content": content,
 | 
				
			||||||
 | 
						})
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if subject == "" {
 | 
				
			||||||
 | 
							subject = templateSubject
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						m := models.Message{
 | 
				
			||||||
 | 
							Subject: subject,
 | 
				
			||||||
 | 
							Content: templateBody,
 | 
				
			||||||
 | 
							From:    e.from,
 | 
				
			||||||
 | 
							To:      recipientEmails,
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						err = e.Send(m)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							e.lo.Error("error sending email notification", "error", err)
 | 
				
			||||||
 | 
							return err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (e *Notifier) SendAssignedConversationNotification(userUUIDs []string, convUUID string) error {
 | 
				
			||||||
 | 
						subject := "New conversation assigned to you"
 | 
				
			||||||
 | 
						link := fmt.Sprintf("http://localhost:5173/conversations/%s", convUUID)
 | 
				
			||||||
 | 
						content := fmt.Sprintf("A new conversation has been assigned to you. <br>Please review the details and take necessary action by following this link: %s", link)
 | 
				
			||||||
 | 
						return e.SendMessage(userUUIDs, subject, content)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Send sends an email message using one of the SMTP pools.
 | 
				
			||||||
 | 
					func (e *Notifier) Send(m models.Message) error {
 | 
				
			||||||
 | 
						var (
 | 
				
			||||||
 | 
							ln  = len(e.smtpPools)
 | 
				
			||||||
 | 
							srv *smtppool.Pool
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
 | 
						if ln > 1 {
 | 
				
			||||||
 | 
							srv = e.smtpPools[rand.Intn(ln)]
 | 
				
			||||||
 | 
						} else {
 | 
				
			||||||
 | 
							srv = e.smtpPools[0]
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						var files []smtppool.Attachment
 | 
				
			||||||
 | 
						if m.Attachments != nil {
 | 
				
			||||||
 | 
							files = make([]smtppool.Attachment, 0, len(m.Attachments))
 | 
				
			||||||
 | 
							for _, f := range m.Attachments {
 | 
				
			||||||
 | 
								a := smtppool.Attachment{
 | 
				
			||||||
 | 
									Filename: f.Filename,
 | 
				
			||||||
 | 
									Header:   f.Header,
 | 
				
			||||||
 | 
									Content:  make([]byte, len(f.Content)),
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								copy(a.Content, f.Content)
 | 
				
			||||||
 | 
								files = append(files, a)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						em := smtppool.Email{
 | 
				
			||||||
 | 
							From:        m.From,
 | 
				
			||||||
 | 
							To:          m.To,
 | 
				
			||||||
 | 
							Subject:     m.Subject,
 | 
				
			||||||
 | 
							Attachments: files,
 | 
				
			||||||
 | 
							Headers:     textproto.MIMEHeader{},
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						for k, v := range m.Headers {
 | 
				
			||||||
 | 
							em.Headers.Set(k, v[0])
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						switch m.ContentType {
 | 
				
			||||||
 | 
						case "plain":
 | 
				
			||||||
 | 
							em.Text = []byte(m.Content)
 | 
				
			||||||
 | 
						default:
 | 
				
			||||||
 | 
							em.HTML = []byte(m.Content)
 | 
				
			||||||
 | 
							if len(m.AltContent) > 0 {
 | 
				
			||||||
 | 
								em.Text = []byte(m.AltContent)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return srv.Send(em)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -1 +0,0 @@
 | 
				
			|||||||
package models
 | 
					 | 
				
			||||||
@@ -1,4 +1,4 @@
 | 
				
			|||||||
package stringutils
 | 
					package stringutil
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import (
 | 
					import (
 | 
				
			||||||
	"crypto/rand"
 | 
						"crypto/rand"
 | 
				
			||||||
@@ -23,7 +23,7 @@ func RandomAlNumString(n int) (string, error) {
 | 
				
			|||||||
	return string(bytes), nil
 | 
						return string(bytes), nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// RandomNumericString generates a random digit string of length n.
 | 
					// RandomNumericString generates a random digit numeric string of length n.
 | 
				
			||||||
func RandomNumericString(n int) (string, error) {
 | 
					func RandomNumericString(n int) (string, error) {
 | 
				
			||||||
	const (
 | 
						const (
 | 
				
			||||||
		dictionary = "0123456789"
 | 
							dictionary = "0123456789"
 | 
				
			||||||
@@ -5,7 +5,7 @@ import (
 | 
				
			|||||||
	"fmt"
 | 
						"fmt"
 | 
				
			||||||
	"time"
 | 
						"time"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/dbutils"
 | 
						"github.com/abhinavxd/artemis/internal/dbutil"
 | 
				
			||||||
	"github.com/jmoiron/sqlx"
 | 
						"github.com/jmoiron/sqlx"
 | 
				
			||||||
	"github.com/zerodha/logf"
 | 
						"github.com/zerodha/logf"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
@@ -16,7 +16,7 @@ var (
 | 
				
			|||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type Tag struct {
 | 
					type Tag struct {
 | 
				
			||||||
	ID        int     `db:"id" json:"id"`
 | 
						ID        int       `db:"id" json:"id"`
 | 
				
			||||||
	CreatedAt time.Time `db:"created_at" json:"created_at"`
 | 
						CreatedAt time.Time `db:"created_at" json:"created_at"`
 | 
				
			||||||
	Name      string    `db:"name" json:"name"`
 | 
						Name      string    `db:"name" json:"name"`
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -40,7 +40,7 @@ type queries struct {
 | 
				
			|||||||
func New(opts Opts) (*Manager, error) {
 | 
					func New(opts Opts) (*Manager, error) {
 | 
				
			||||||
	var q queries
 | 
						var q queries
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err := dbutils.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
 | 
						if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
 | 
				
			||||||
		return nil, err
 | 
							return nil, err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,7 +6,7 @@ import (
 | 
				
			|||||||
	"errors"
 | 
						"errors"
 | 
				
			||||||
	"fmt"
 | 
						"fmt"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/dbutils"
 | 
						"github.com/abhinavxd/artemis/internal/dbutil"
 | 
				
			||||||
	umodels "github.com/abhinavxd/artemis/internal/user/models"
 | 
						umodels "github.com/abhinavxd/artemis/internal/user/models"
 | 
				
			||||||
	"github.com/jmoiron/sqlx"
 | 
						"github.com/jmoiron/sqlx"
 | 
				
			||||||
	"github.com/zerodha/logf"
 | 
						"github.com/zerodha/logf"
 | 
				
			||||||
@@ -42,7 +42,7 @@ type queries struct {
 | 
				
			|||||||
func New(opts Opts) (*Manager, error) {
 | 
					func New(opts Opts) (*Manager, error) {
 | 
				
			||||||
	var q queries
 | 
						var q queries
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err := dbutils.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
 | 
						if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
 | 
				
			||||||
		return nil, err
 | 
							return nil, err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										6
									
								
								internal/template/models/models.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								internal/template/models/models.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,6 @@
 | 
				
			|||||||
 | 
					package models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type Template struct {
 | 
				
			||||||
 | 
						Body    string `db:"body"`
 | 
				
			||||||
 | 
						Subject string `db:"subject"`
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										10
									
								
								internal/template/queries.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								internal/template/queries.sql
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
				
			|||||||
 | 
					-- name: insert-template
 | 
				
			||||||
 | 
					INSERT INTO templates
 | 
				
			||||||
 | 
					("name", subject, body, is_default)
 | 
				
			||||||
 | 
					VALUES($1, $2, $3, $4);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					-- name: get-template
 | 
				
			||||||
 | 
					select id, name, subject, body from templates where name = $1;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					-- name: get-default-template
 | 
				
			||||||
 | 
					select id, name, subject, body from templates where is_default is true;
 | 
				
			||||||
							
								
								
									
										26
									
								
								internal/template/renderer.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								internal/template/renderer.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,26 @@
 | 
				
			|||||||
 | 
					package template
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"bytes"
 | 
				
			||||||
 | 
						"text/template"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// RenderDefault renders the system default template with the data.
 | 
				
			||||||
 | 
					func (m *Manager) RenderDefault(data interface{}) (string, string, error) {
 | 
				
			||||||
 | 
						templ, err := m.GetDefaultTemplate()
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return "", "", err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						tmpl, err := template.New("").Parse(templ.Body)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return "", "", err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						var rendered bytes.Buffer
 | 
				
			||||||
 | 
						if err := tmpl.Execute(&rendered, data); err != nil {
 | 
				
			||||||
 | 
							return "", "", err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return rendered.String(), templ.Subject, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										57
									
								
								internal/template/template.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								internal/template/template.go
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,57 @@
 | 
				
			|||||||
 | 
					package template
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"embed"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"github.com/abhinavxd/artemis/internal/dbutil"
 | 
				
			||||||
 | 
						"github.com/abhinavxd/artemis/internal/template/models"
 | 
				
			||||||
 | 
						"github.com/jmoiron/sqlx"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					var (
 | 
				
			||||||
 | 
						//go:embed queries.sql
 | 
				
			||||||
 | 
						efs embed.FS
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type Manager struct {
 | 
				
			||||||
 | 
						q queries
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type queries struct {
 | 
				
			||||||
 | 
						InsertTemplate     *sqlx.Stmt `query:"insert-template"`
 | 
				
			||||||
 | 
						GetTemplate        *sqlx.Stmt `query:"get-template"`
 | 
				
			||||||
 | 
						GetDefaultTemplate *sqlx.Stmt `query:"get-default-template"`
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func New(db *sqlx.DB) (*Manager, error) {
 | 
				
			||||||
 | 
						var q queries
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if err := dbutil.ScanSQLFile("queries.sql", &q, db, efs); err != nil {
 | 
				
			||||||
 | 
							return nil, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return &Manager{q}, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (m *Manager) InsertTemplate(name, subject, body string) error {
 | 
				
			||||||
 | 
						if _, err := m.q.InsertTemplate.Exec(name, subject, body); err != nil {
 | 
				
			||||||
 | 
							return err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (m *Manager) GetTemplate(name string) (models.Template, error) {
 | 
				
			||||||
 | 
						var template models.Template
 | 
				
			||||||
 | 
						if err := m.q.GetTemplate.Get(&template, name); err != nil {
 | 
				
			||||||
 | 
							return template, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return template, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (m *Manager) GetDefaultTemplate() (models.Template, error) {
 | 
				
			||||||
 | 
						var template models.Template
 | 
				
			||||||
 | 
						if err := m.q.GetDefaultTemplate.Get(&template); err != nil {
 | 
				
			||||||
 | 
							return template, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return template, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -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
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -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"`
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,2 +0,0 @@
 | 
				
			|||||||
-- name: get-user-filters
 | 
					 | 
				
			||||||
SELECT * from user_filters where user_id = $1 and page = $2;
 | 
					 | 
				
			||||||
@@ -1,6 +1,9 @@
 | 
				
			|||||||
-- name: get-users
 | 
					-- name: get-users
 | 
				
			||||||
SELECT first_name, last_name, uuid, disabled from users;
 | 
					SELECT first_name, last_name, uuid, disabled from users;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					-- name: get-email
 | 
				
			||||||
 | 
					SELECT email from users where CASE WHEN $1 > 0 THEN id = $1 ELSE uuid = $2 END; 
 | 
				
			||||||
 | 
					
 | 
				
			||||||
-- name: get-user-by-email
 | 
					-- name: get-user-by-email
 | 
				
			||||||
select id, email, password, avatar_url, first_name, last_name, uuid from users where email = $1;
 | 
					select id, email, password, avatar_url, first_name, last_name, uuid from users where email = $1;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -7,7 +7,7 @@ import (
 | 
				
			|||||||
	"errors"
 | 
						"errors"
 | 
				
			||||||
	"fmt"
 | 
						"fmt"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/dbutils"
 | 
						"github.com/abhinavxd/artemis/internal/dbutil"
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/user/models"
 | 
						"github.com/abhinavxd/artemis/internal/user/models"
 | 
				
			||||||
	"github.com/jmoiron/sqlx"
 | 
						"github.com/jmoiron/sqlx"
 | 
				
			||||||
	"github.com/zerodha/logf"
 | 
						"github.com/zerodha/logf"
 | 
				
			||||||
@@ -39,6 +39,7 @@ type Opts struct {
 | 
				
			|||||||
type queries struct {
 | 
					type queries struct {
 | 
				
			||||||
	GetUsers        *sqlx.Stmt `query:"get-users"`
 | 
						GetUsers        *sqlx.Stmt `query:"get-users"`
 | 
				
			||||||
	GetUser         *sqlx.Stmt `query:"get-user"`
 | 
						GetUser         *sqlx.Stmt `query:"get-user"`
 | 
				
			||||||
 | 
						GetEmail        *sqlx.Stmt `query:"get-email"`
 | 
				
			||||||
	GetUserByEmail  *sqlx.Stmt `query:"get-user-by-email"`
 | 
						GetUserByEmail  *sqlx.Stmt `query:"get-user-by-email"`
 | 
				
			||||||
	SetUserPassword *sqlx.Stmt `query:"set-user-password"`
 | 
						SetUserPassword *sqlx.Stmt `query:"set-user-password"`
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -46,7 +47,7 @@ type queries struct {
 | 
				
			|||||||
func New(opts Opts) (*Manager, error) {
 | 
					func New(opts Opts) (*Manager, error) {
 | 
				
			||||||
	var q queries
 | 
						var q queries
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if err := dbutils.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
 | 
						if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
 | 
				
			||||||
		return nil, err
 | 
							return nil, err
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -110,6 +111,23 @@ func (u *Manager) GetUser(id int, uuid string) (models.User, error) {
 | 
				
			|||||||
	return user, nil
 | 
						return user, nil
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (u *Manager) GetEmail(id int, uuid string) (string, error) {
 | 
				
			||||||
 | 
						var uu interface{}
 | 
				
			||||||
 | 
						if uuid != "" {
 | 
				
			||||||
 | 
							uu = uuid
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						var email string
 | 
				
			||||||
 | 
						if err := u.q.GetEmail.Get(&email, id, uu); err != nil {
 | 
				
			||||||
 | 
							if errors.Is(err, sql.ErrNoRows) {
 | 
				
			||||||
 | 
								return email, fmt.Errorf("user not found")
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							u.lo.Error("error fetching user from db", "error", err)
 | 
				
			||||||
 | 
							return email, fmt.Errorf("fetching user: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return email, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (u *Manager) verifyPassword(pwd []byte, pwdHash string) error {
 | 
					func (u *Manager) verifyPassword(pwd []byte, pwdHash string) error {
 | 
				
			||||||
	err := bcrypt.CompareHashAndPassword([]byte(pwdHash), pwd)
 | 
						err := bcrypt.CompareHashAndPassword([]byte(pwdHash), pwd)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,6 +3,7 @@ package ws
 | 
				
			|||||||
import (
 | 
					import (
 | 
				
			||||||
	"encoding/json"
 | 
						"encoding/json"
 | 
				
			||||||
	"fmt"
 | 
						"fmt"
 | 
				
			||||||
 | 
						"log"
 | 
				
			||||||
	"sync"
 | 
						"sync"
 | 
				
			||||||
	"time"
 | 
						"time"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -10,6 +11,13 @@ import (
 | 
				
			|||||||
	"github.com/fasthttp/websocket"
 | 
						"github.com/fasthttp/websocket"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const (
 | 
				
			||||||
 | 
						// SubscribeConversations to last 1000 conversations.
 | 
				
			||||||
 | 
						// TODO: Move to config.
 | 
				
			||||||
 | 
						maxConversationsPagesToSub = 10
 | 
				
			||||||
 | 
						maxConversationsPageSize   = 100
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type SafeBool struct {
 | 
					type SafeBool struct {
 | 
				
			||||||
	flag bool
 | 
						flag bool
 | 
				
			||||||
	mu   sync.Mutex
 | 
						mu   sync.Mutex
 | 
				
			||||||
@@ -58,8 +66,6 @@ Loop:
 | 
				
			|||||||
			}
 | 
								}
 | 
				
			||||||
		case o, ok := <-c.Send:
 | 
							case o, ok := <-c.Send:
 | 
				
			||||||
			if !ok {
 | 
								if !ok {
 | 
				
			||||||
				// Disconnected.
 | 
					 | 
				
			||||||
				fmt.Println("Client disconnected, breaking serve lop.")
 | 
					 | 
				
			||||||
				break Loop
 | 
									break Loop
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
			c.Conn.WriteMessage(o.messageType, o.data)
 | 
								c.Conn.WriteMessage(o.messageType, o.data)
 | 
				
			||||||
@@ -84,8 +90,6 @@ func (c *Client) Listen() {
 | 
				
			|||||||
			return
 | 
								return
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					 | 
				
			||||||
	fmt.Println("loop broke closing")
 | 
					 | 
				
			||||||
	c.Hub.RemoveClient(c)
 | 
						c.Hub.RemoveClient(c)
 | 
				
			||||||
	c.close()
 | 
						c.close()
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -99,66 +103,95 @@ func (c *Client) processIncomingMessage(b []byte) {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	switch r.Action {
 | 
						switch r.Action {
 | 
				
			||||||
	// Sub to conversation updates.
 | 
						case models.ActionConversationsSub:
 | 
				
			||||||
	case models.ActionConvSub:
 | 
							var req = models.ConversationsSubscribe{}
 | 
				
			||||||
		var subR = models.ConvSubUnsubReq{}
 | 
							if err := json.Unmarshal(b, &req); err != nil {
 | 
				
			||||||
		if err := json.Unmarshal(b, &subR); err != nil {
 | 
					 | 
				
			||||||
			return
 | 
								return
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		c.SubConv(int(c.ID), subR.UUIDs...)
 | 
					
 | 
				
			||||||
	case models.ActionConvUnsub:
 | 
							// First remove all user conversation subscriptions.
 | 
				
			||||||
		var subR = models.ConvSubUnsubReq{}
 | 
							c.RemoveAllUserConversationSubscriptions(c.ID)
 | 
				
			||||||
		if err := json.Unmarshal(b, &subR); err != nil {
 | 
					
 | 
				
			||||||
 | 
							// Add the new subcriptions.
 | 
				
			||||||
 | 
							for page := range maxConversationsPagesToSub {
 | 
				
			||||||
 | 
								page++
 | 
				
			||||||
 | 
								conversationUUIDs, err := c.Hub.conversationStore.GetConversationUUIDs(c.ID, page, maxConversationsPageSize, req.Type, req.PreDefinedFilter)
 | 
				
			||||||
 | 
								if err != nil {
 | 
				
			||||||
 | 
									log.Println("error fetching convesation ids", err)
 | 
				
			||||||
 | 
									continue
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								c.SubscribeConversations(c.ID, conversationUUIDs)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						case models.ActionConversationSub:
 | 
				
			||||||
 | 
							var req = models.ConversationSubscribe{}
 | 
				
			||||||
 | 
							if err := json.Unmarshal(b, &req); err != nil {
 | 
				
			||||||
			return
 | 
								return
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		c.UnsubConv(int(c.ID), subR.UUIDs...)
 | 
							c.SubscribeConversations(c.ID, []string{req.UUID})
 | 
				
			||||||
	case models.ActionAssignedConvSub:
 | 
						case models.ActionConversationUnSub:
 | 
				
			||||||
		// Fetch all assigned conversation & sub.
 | 
							var req = models.ConversationUnsubscribe{}
 | 
				
			||||||
	case models.ActionAssignedConvUnSub:
 | 
							if err := json.Unmarshal(b, &req); err != nil {
 | 
				
			||||||
		// Fetch all unassigned conversation and sub.
 | 
								return
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							c.UnsubscribeConversation(c.ID, req.UUID)
 | 
				
			||||||
 | 
						default:
 | 
				
			||||||
 | 
							fmt.Println("new incoming ws msg ", string(b))
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (c *Client) close() {
 | 
					func (c *Client) close() {
 | 
				
			||||||
 | 
						c.RemoveAllUserConversationSubscriptions(c.ID)
 | 
				
			||||||
	c.Closed.Set(true)
 | 
						c.Closed.Set(true)
 | 
				
			||||||
	close(c.Send)
 | 
						close(c.Send)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (c *Client) SubConv(userID int, uuids ...string) {
 | 
					func (c *Client) SubscribeConversations(userID int, conversationUUIDs []string) {
 | 
				
			||||||
	c.Hub.SubMut.Lock()
 | 
						for _, conversationUUID := range conversationUUIDs {
 | 
				
			||||||
	defer c.Hub.SubMut.Unlock()
 | 
							// Initialize the slice if it doesn't exist
 | 
				
			||||||
 | 
							if c.Hub.ConversationSubs[conversationUUID] == nil {
 | 
				
			||||||
	for _, uuid := range uuids {
 | 
								c.Hub.ConversationSubs[conversationUUID] = []int{}
 | 
				
			||||||
		// Initialize the slice if this is the first subscription for this UUID
 | 
					 | 
				
			||||||
		if _, ok := c.Hub.Csubs[uuid]; !ok {
 | 
					 | 
				
			||||||
			c.Hub.Csubs[uuid] = []int{}
 | 
					 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		// Append the user ID to the slice of subscribed user IDs
 | 
					 | 
				
			||||||
		c.Hub.Csubs[uuid] = append(c.Hub.Csubs[uuid], userID)
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (c *Client) UnsubConv(userID int, uuids ...string) {
 | 
							// Check if userID already exists
 | 
				
			||||||
	c.Hub.SubMut.Lock()
 | 
							exists := false
 | 
				
			||||||
	defer c.Hub.SubMut.Unlock()
 | 
							for _, id := range c.Hub.ConversationSubs[conversationUUID] {
 | 
				
			||||||
 | 
								if id == userID {
 | 
				
			||||||
	for _, uuid := range uuids {
 | 
									exists = true
 | 
				
			||||||
		currentSubs, ok := c.Hub.Csubs[uuid]
 | 
									break
 | 
				
			||||||
		if !ok {
 | 
					 | 
				
			||||||
			continue // No subscriptions for this UUID
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
		j := 0
 | 
					 | 
				
			||||||
		for _, sub := range currentSubs {
 | 
					 | 
				
			||||||
			if sub != userID {
 | 
					 | 
				
			||||||
				currentSubs[j] = sub
 | 
					 | 
				
			||||||
				j++
 | 
					 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		currentSubs = currentSubs[:j] // Update the slice in-place
 | 
					
 | 
				
			||||||
		if len(currentSubs) == 0 {
 | 
							// Add userID if it doesn't exist
 | 
				
			||||||
			delete(c.Hub.Csubs, uuid) // Remove key if no more subscriptions
 | 
							if !exists {
 | 
				
			||||||
		} else {
 | 
								c.Hub.ConversationSubs[conversationUUID] = append(c.Hub.ConversationSubs[conversationUUID], userID)
 | 
				
			||||||
			c.Hub.Csubs[uuid] = currentSubs
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (c *Client) UnsubscribeConversation(userID int, conversationUUID string) {
 | 
				
			||||||
 | 
						if userIDs, ok := c.Hub.ConversationSubs[conversationUUID]; ok {
 | 
				
			||||||
 | 
							for i, id := range userIDs {
 | 
				
			||||||
 | 
								if id == userID {
 | 
				
			||||||
 | 
									c.Hub.ConversationSubs[conversationUUID] = append(userIDs[:i], userIDs[i+1:]...)
 | 
				
			||||||
 | 
									break
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							if len(c.Hub.ConversationSubs[conversationUUID]) == 0 {
 | 
				
			||||||
 | 
								delete(c.Hub.ConversationSubs, conversationUUID)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (c *Client) RemoveAllUserConversationSubscriptions(userID int) {
 | 
				
			||||||
 | 
						for conversationID, userIDs := range c.Hub.ConversationSubs {
 | 
				
			||||||
 | 
							for i, id := range userIDs {
 | 
				
			||||||
 | 
								if id == userID {
 | 
				
			||||||
 | 
									c.Hub.ConversationSubs[conversationID] = append(userIDs[:i], userIDs[i+1:]...)
 | 
				
			||||||
 | 
									break
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							if len(c.Hub.ConversationSubs[conversationID]) == 0 {
 | 
				
			||||||
 | 
								delete(c.Hub.ConversationSubs, conversationID)
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,24 +1,36 @@
 | 
				
			|||||||
package models
 | 
					package models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const (
 | 
					const (
 | 
				
			||||||
	ActionConvSub           = "c_sub"
 | 
						ActionConversationsSub                = "conversations_sub"
 | 
				
			||||||
	ActionConvUnsub         = "c_unsub"
 | 
						ActionConversationSub                 = "conversation_sub"
 | 
				
			||||||
	ActionAssignedConvSub   = "a_c_sub"
 | 
						ActionConversationUnSub               = "conversation_unsub"
 | 
				
			||||||
	ActionAssignedConvUnSub = "a_c_unsub"
 | 
						MessageTypeNewMessage                 = "new_msg"
 | 
				
			||||||
 | 
						MessageTypeMessagePropUpdate          = "msg_prop_update"
 | 
				
			||||||
	EventNewMsg          = "new_msg"
 | 
						MessageTypeNewConversation            = "new_conv"
 | 
				
			||||||
	EventMsgStatusUpdate = "msg_status_update"
 | 
						MessageTypeConversationPropertyUpdate = "conv_prop_update"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type IncomingReq struct {
 | 
					type IncomingReq struct {
 | 
				
			||||||
	Action string `json:"a"`
 | 
						Action string `json:"a"`
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type ConversationsSubscribe struct {
 | 
				
			||||||
 | 
						Type             string `json:"t"`
 | 
				
			||||||
 | 
						PreDefinedFilter string `json:"pf"`
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type ConversationSubscribe struct {
 | 
				
			||||||
 | 
						UUID string `json:"uuid"`
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					type ConversationUnsubscribe struct {
 | 
				
			||||||
 | 
						UUID string `json:"uuid"`
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type ConvSubUnsubReq struct {
 | 
					type ConvSubUnsubReq struct {
 | 
				
			||||||
	UUIDs []string `json:"v"`
 | 
						UUIDs []string `json:"v"`
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type Event struct {
 | 
					type Message struct {
 | 
				
			||||||
	Type string
 | 
						Type string      `json:"typ"`
 | 
				
			||||||
	Data string
 | 
						Data interface{} `json:"d"`
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -4,27 +4,34 @@ import (
 | 
				
			|||||||
	"encoding/json"
 | 
						"encoding/json"
 | 
				
			||||||
	"fmt"
 | 
						"fmt"
 | 
				
			||||||
	"sync"
 | 
						"sync"
 | 
				
			||||||
 | 
						"time"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	"github.com/abhinavxd/artemis/internal/ws/models"
 | 
						"github.com/abhinavxd/artemis/internal/ws/models"
 | 
				
			||||||
	"github.com/fasthttp/websocket"
 | 
						"github.com/fasthttp/websocket"
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Hub maintains the set of registered clients.
 | 
					// Hub maintains the set of registered clients and their subscriptions.
 | 
				
			||||||
type Hub struct {
 | 
					type Hub struct {
 | 
				
			||||||
	clients      map[int][]*Client
 | 
						clients      map[int][]*Client
 | 
				
			||||||
	clientsMutex sync.Mutex
 | 
						clientsMutex sync.Mutex
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Map of conversation uuid to slice of subbed userids.
 | 
						// Map of conversation uuid to a set of subscribed user IDs.
 | 
				
			||||||
	Csubs  map[string][]int
 | 
						ConversationSubs map[string][]int
 | 
				
			||||||
	SubMut sync.Mutex
 | 
					
 | 
				
			||||||
 | 
						SubMut            sync.Mutex
 | 
				
			||||||
 | 
						conversationStore ConversationStore
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type ConversationStore interface {
 | 
				
			||||||
 | 
						GetConversationUUIDs(userID, page, pageSize int, typ, predefinedFilter string) ([]string, error)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func NewHub() *Hub {
 | 
					func NewHub() *Hub {
 | 
				
			||||||
	return &Hub{
 | 
						return &Hub{
 | 
				
			||||||
		clients:      make(map[int][]*Client, 100000),
 | 
							clients:          make(map[int][]*Client, 10000),
 | 
				
			||||||
		clientsMutex: sync.Mutex{},
 | 
							clientsMutex:     sync.Mutex{},
 | 
				
			||||||
		Csubs:        map[string][]int{},
 | 
							ConversationSubs: make(map[string][]int),
 | 
				
			||||||
		SubMut:       sync.Mutex{},
 | 
							SubMut:           sync.Mutex{},
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -39,6 +46,10 @@ type PushMessage struct {
 | 
				
			|||||||
	MaxUsers int    `json:"max_users"`
 | 
						MaxUsers int    `json:"max_users"`
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (h *Hub) SetConversationStore(store ConversationStore) {
 | 
				
			||||||
 | 
						h.conversationStore = store
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (h *Hub) AddClient(c *Client) {
 | 
					func (h *Hub) AddClient(c *Client) {
 | 
				
			||||||
	h.clientsMutex.Lock()
 | 
						h.clientsMutex.Lock()
 | 
				
			||||||
	defer h.clientsMutex.Unlock()
 | 
						defer h.clientsMutex.Unlock()
 | 
				
			||||||
@@ -59,7 +70,7 @@ func (h *Hub) RemoveClient(client *Client) {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// ClientAlreadyConnected checks if a user id is already connected or not.
 | 
					// ClientAlreadyConnected returns true if the client with this id is already connected else returns false.
 | 
				
			||||||
func (h *Hub) ClientAlreadyConnected(id int) bool {
 | 
					func (h *Hub) ClientAlreadyConnected(id int) bool {
 | 
				
			||||||
	h.clientsMutex.Lock()
 | 
						h.clientsMutex.Lock()
 | 
				
			||||||
	defer h.clientsMutex.Unlock()
 | 
						defer h.clientsMutex.Unlock()
 | 
				
			||||||
@@ -75,7 +86,6 @@ func (h *Hub) PushMessage(m PushMessage) {
 | 
				
			|||||||
		h.clientsMutex.Lock()
 | 
							h.clientsMutex.Lock()
 | 
				
			||||||
		for _, userID := range m.Users {
 | 
							for _, userID := range m.Users {
 | 
				
			||||||
			for _, c := range h.clients[userID] {
 | 
								for _, c := range h.clients[userID] {
 | 
				
			||||||
				fmt.Printf("Pushing msg to %d", userID)
 | 
					 | 
				
			||||||
				c.Conn.WriteMessage(websocket.TextMessage, m.Data)
 | 
									c.Conn.WriteMessage(websocket.TextMessage, m.Data)
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
@@ -97,52 +107,89 @@ func (h *Hub) PushMessage(m PushMessage) {
 | 
				
			|||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
func (c *Hub) BroadcastNewMsg(convUUID string, msg map[string]interface{}) {
 | 
					func (c *Hub) BroadcastNewConversationMessage(conversationUUID, trimmedMessage, messageUUID, lastMessageAt string, private bool) {
 | 
				
			||||||
	// clientIDs, ok := c.Csubs[convUUID]
 | 
						userIDs, ok := c.ConversationSubs[conversationUUID]
 | 
				
			||||||
	// if !ok || len(clientIDs) == 0 {
 | 
						if !ok || len(userIDs) == 0 {
 | 
				
			||||||
	// 	return
 | 
							return
 | 
				
			||||||
	// }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	clientIDs := []int{1, 2}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	data := map[string]interface{}{
 | 
					 | 
				
			||||||
		"ev": models.EventNewMsg,
 | 
					 | 
				
			||||||
		"d":  msg,
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	// Marshal.
 | 
						message := models.Message{
 | 
				
			||||||
	dataB, err := json.Marshal(data)
 | 
							Type: models.MessageTypeNewMessage,
 | 
				
			||||||
 | 
							Data: map[string]interface{}{
 | 
				
			||||||
 | 
								"conversation_uuid": conversationUUID,
 | 
				
			||||||
 | 
								"last_message":      trimmedMessage,
 | 
				
			||||||
 | 
								"uuid":              messageUUID,
 | 
				
			||||||
 | 
								"last_message_at":   lastMessageAt,
 | 
				
			||||||
 | 
								"private":           private,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						c.marshalAndPush(message, userIDs)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (c *Hub) BroadcastMessagePropUpdate(conversationUUID, messageUUID, prop, value string) {
 | 
				
			||||||
 | 
						userIDs, ok := c.ConversationSubs[conversationUUID]
 | 
				
			||||||
 | 
						if !ok || len(userIDs) == 0 {
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						message := models.Message{
 | 
				
			||||||
 | 
							Type: models.MessageTypeMessagePropUpdate,
 | 
				
			||||||
 | 
							Data: map[string]interface{}{
 | 
				
			||||||
 | 
								"uuid": messageUUID,
 | 
				
			||||||
 | 
								"prop": prop,
 | 
				
			||||||
 | 
								"val":  value,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						c.marshalAndPush(message, userIDs)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (c *Hub) BroadcastConversationAssignment(userID int, conversationUUID string, avatarUrl string, firstName, lastName, lastMessage, inboxName string, lastMessageAt time.Time, unreadMessageCount int) {
 | 
				
			||||||
 | 
						message := models.Message{
 | 
				
			||||||
 | 
							Type: models.MessageTypeNewConversation,
 | 
				
			||||||
 | 
							Data: map[string]interface{}{
 | 
				
			||||||
 | 
								"uuid":                 conversationUUID,
 | 
				
			||||||
 | 
								"avatar_url":           avatarUrl,
 | 
				
			||||||
 | 
								"first_name":           firstName,
 | 
				
			||||||
 | 
								"last_name":            lastName,
 | 
				
			||||||
 | 
								"last_message":         lastMessage,
 | 
				
			||||||
 | 
								"last_message_at":      time.Now().Format(time.RFC3339),
 | 
				
			||||||
 | 
								"inbox_name":           inboxName,
 | 
				
			||||||
 | 
								"unread_message_count": unreadMessageCount,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						c.marshalAndPush(message, []int{userID})
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (c *Hub) BroadcastConversationPropertyUpdate(conversationUUID string, prop, val string) {
 | 
				
			||||||
 | 
						userIDs, ok := c.ConversationSubs[conversationUUID]
 | 
				
			||||||
 | 
						if !ok || len(userIDs) == 0 {
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						message := models.Message{
 | 
				
			||||||
 | 
							Type: models.MessageTypeConversationPropertyUpdate,
 | 
				
			||||||
 | 
							Data: map[string]interface{}{
 | 
				
			||||||
 | 
								"uuid": conversationUUID,
 | 
				
			||||||
 | 
								"prop": prop,
 | 
				
			||||||
 | 
								"val":  val,
 | 
				
			||||||
 | 
							},
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						c.marshalAndPush(message, userIDs)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (c *Hub) marshalAndPush(message models.Message, userIDs []int) {
 | 
				
			||||||
 | 
						messageB, err := json.Marshal(message)
 | 
				
			||||||
	if err != nil {
 | 
						if err != nil {
 | 
				
			||||||
		return
 | 
							return
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	c.PushMessage(PushMessage{
 | 
						fmt.Println("pushing msg", string(messageB), "type", message.Type, "to_user_ids", userIDs, "connected_userIds", len(c.clients))
 | 
				
			||||||
		Data:  dataB,
 | 
					 | 
				
			||||||
		Users: clientIDs,
 | 
					 | 
				
			||||||
	})
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
func (c *Hub) BroadcastMsgStatus(convUUID string, msg map[string]interface{}) {
 | 
					 | 
				
			||||||
	// clientIDs, ok := c.Csubs[convUUID]
 | 
					 | 
				
			||||||
	// if !ok || len(clientIDs) == 0 {
 | 
					 | 
				
			||||||
	// 	return
 | 
					 | 
				
			||||||
	// }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	clientIDs := []int{1, 2}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	data := map[string]interface{}{
 | 
					 | 
				
			||||||
		"ev": models.EventMsgStatusUpdate,
 | 
					 | 
				
			||||||
		"d":  msg,
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	// Marshal.
 | 
					 | 
				
			||||||
	dataB, err := json.Marshal(data)
 | 
					 | 
				
			||||||
	if err != nil {
 | 
					 | 
				
			||||||
		return
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
	c.PushMessage(PushMessage{
 | 
						c.PushMessage(PushMessage{
 | 
				
			||||||
		Data:  dataB,
 | 
							Data:  messageB,
 | 
				
			||||||
		Users: clientIDs,
 | 
							Users: userIDs,
 | 
				
			||||||
	})
 | 
						})
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user