From 6a27b2fa4b42ce56835cc6192a93ac56aa3db3d9 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Mon, 10 Jun 2024 03:45:48 +0530 Subject: [PATCH] refactor --- cmd/agents.go | 32 -- cmd/attachment.go | 82 +++ cmd/canned_responses.go | 2 +- cmd/conversation.go | 142 ++++- cmd/handlers.go | 41 +- cmd/init.go | 195 +++++-- cmd/login.go | 26 +- cmd/main.go | 119 ++-- cmd/media.go | 55 -- cmd/messages.go | 108 +++- cmd/middlewares.go | 17 +- cmd/tags.go | 26 +- cmd/teams.go | 2 +- cmd/users.go | 30 + cmd/websocket.go | 61 ++ frontend/index.html | 4 +- frontend/package.json | 7 + frontend/src/App.vue | 62 +- frontend/src/api/index.js | 48 +- frontend/src/assets/styles/main.scss | 191 +++++-- .../src/components/ActivityMessageBubble.vue | 30 + .../src/components/AgentMessageBubble.vue | 109 ++++ .../src/components/AttachmentsPreview.vue | 47 ++ .../src/components/ContactMessageBubble.vue | 68 +++ frontend/src/components/ConversationList.vue | 93 +-- ...nEmpty.vue => ConversationPlaceholder.vue} | 0 .../src/components/ConversationSideBar.vue | 105 ++-- .../src/components/ConversationThread.vue | 109 ++-- .../src/components/FileAttachmentPreview.vue | 36 ++ .../src/components/ImageAttachmentPreview.vue | 34 ++ .../components/MessageAttachmentPreview.vue | 26 + frontend/src/components/MessageBubble.vue | 67 --- frontend/src/components/MessageList.vue | 30 + frontend/src/components/NavBar.vue | 6 +- frontend/src/components/ReplyBox.vue | 40 ++ frontend/src/components/TextEditor.vue | 221 +++++--- frontend/src/components/ui/button/index.js | 12 +- frontend/src/components/ui/error/Error.vue | 3 +- frontend/src/components/ui/loader/Loader.vue | 21 + frontend/src/components/ui/loader/index.js | 1 + .../src/components/ui/spinner/Spinner.vue | 30 + frontend/src/components/ui/spinner/index.js | 1 + .../ui/toggle-group/ToggleGroup.vue | 43 ++ .../ui/toggle-group/ToggleGroupItem.vue | 44 ++ .../src/components/ui/toggle-group/index.js | 2 + frontend/src/composables/useTemporaryClass.js | 2 +- frontend/src/main.js | 6 +- frontend/src/router/index.js | 6 + frontend/src/stores/agents.js | 20 + frontend/src/stores/conversation.js | 119 +++- frontend/src/utils/file.js | 7 + frontend/src/views/AccountView.vue | 8 + frontend/src/views/ConversationView.vue | 25 +- frontend/src/views/DashboardView.vue | 2 +- frontend/src/views/UserLoginView.vue | 3 +- frontend/src/websocket.js | 52 ++ frontend/yarn.lock | 90 ++- go.mod | 11 +- go.sum | 36 +- i18n/en.json | 4 + internal/attachment/attachment.go | 143 +++++ internal/attachment/models/models.go | 33 ++ internal/attachment/queries.sql | 14 + .../{media => attachment}/stores/s3/s3.go | 33 +- internal/cannedresp/cannedresp.go | 12 +- internal/cannedresp/queries.sql | 2 +- internal/contact/contact.go | 54 ++ internal/contact/models/models.go | 16 + internal/contact/queries.sql | 35 ++ internal/conversation/conversations.go | 241 ++++++++ internal/conversation/models/models.go | 41 ++ internal/conversation/queries.sql | 140 +++++ .../{tags => conversation/tag}/queries.sql | 10 +- internal/conversation/tag/tag.go | 61 ++ internal/conversations/conversations.go | 131 ----- internal/conversations/models/models.go | 62 -- internal/conversations/queries.sql | 115 ---- .../channels => inbox/channel}/email/email.go | 74 ++- internal/inbox/channel/email/imap.go | 235 ++++++++ .../channels => inbox/channel}/email/smtp.go | 51 +- internal/inbox/inbox.go | 130 +++++ internal/{inboxes => inbox}/queries.sql | 0 internal/inboxes/channels/email/imap.go | 209 ------- internal/inboxes/inboxes.go | 144 ----- internal/initz/initz.go | 10 +- internal/media/media.go | 63 --- internal/media/queries.sql | 5 - internal/message/messages.go | 531 ++++++++++++++++++ internal/message/models/models.go | 52 ++ internal/message/queries.sql | 193 +++++++ internal/tag/queries.sql | 11 + internal/tag/tag.go | 76 +++ internal/tags/tags.go | 78 --- internal/team/queries.sql | 5 + internal/team/team.go | 75 +++ internal/user/models/models.go | 16 + internal/user/queries.sql | 14 + internal/user/user.go | 134 +++++ internal/userdb/models/models.go | 17 - internal/userdb/queries.sql | 11 - internal/userdb/userdb.go | 146 ----- internal/ws/client.go | 164 ++++++ internal/ws/models/models.go | 24 + internal/ws/ws.go | 148 +++++ 104 files changed, 4786 insertions(+), 1792 deletions(-) delete mode 100644 cmd/agents.go create mode 100644 cmd/attachment.go delete mode 100644 cmd/media.go create mode 100644 cmd/users.go create mode 100644 cmd/websocket.go create mode 100644 frontend/src/components/ActivityMessageBubble.vue create mode 100644 frontend/src/components/AgentMessageBubble.vue create mode 100644 frontend/src/components/AttachmentsPreview.vue create mode 100644 frontend/src/components/ContactMessageBubble.vue rename frontend/src/components/{ConversationEmpty.vue => ConversationPlaceholder.vue} (100%) create mode 100644 frontend/src/components/FileAttachmentPreview.vue create mode 100644 frontend/src/components/ImageAttachmentPreview.vue create mode 100644 frontend/src/components/MessageAttachmentPreview.vue delete mode 100644 frontend/src/components/MessageBubble.vue create mode 100644 frontend/src/components/MessageList.vue create mode 100644 frontend/src/components/ReplyBox.vue create mode 100644 frontend/src/components/ui/loader/Loader.vue create mode 100644 frontend/src/components/ui/loader/index.js create mode 100644 frontend/src/components/ui/spinner/Spinner.vue create mode 100644 frontend/src/components/ui/spinner/index.js create mode 100644 frontend/src/components/ui/toggle-group/ToggleGroup.vue create mode 100644 frontend/src/components/ui/toggle-group/ToggleGroupItem.vue create mode 100644 frontend/src/components/ui/toggle-group/index.js create mode 100644 frontend/src/stores/agents.js create mode 100644 frontend/src/utils/file.js create mode 100644 frontend/src/views/AccountView.vue create mode 100644 frontend/src/websocket.js create mode 100644 i18n/en.json create mode 100644 internal/attachment/attachment.go create mode 100644 internal/attachment/models/models.go create mode 100644 internal/attachment/queries.sql rename internal/{media => attachment}/stores/s3/s3.go (82%) create mode 100644 internal/contact/contact.go create mode 100644 internal/contact/models/models.go create mode 100644 internal/contact/queries.sql create mode 100644 internal/conversation/conversations.go create mode 100644 internal/conversation/models/models.go create mode 100644 internal/conversation/queries.sql rename internal/{tags => conversation/tag}/queries.sql (71%) create mode 100644 internal/conversation/tag/tag.go delete mode 100644 internal/conversations/conversations.go delete mode 100644 internal/conversations/models/models.go delete mode 100644 internal/conversations/queries.sql rename internal/{inboxes/channels => inbox/channel}/email/email.go (55%) create mode 100644 internal/inbox/channel/email/imap.go rename internal/{inboxes/channels => inbox/channel}/email/smtp.go (68%) create mode 100644 internal/inbox/inbox.go rename internal/{inboxes => inbox}/queries.sql (100%) delete mode 100644 internal/inboxes/channels/email/imap.go delete mode 100644 internal/inboxes/inboxes.go delete mode 100644 internal/media/media.go delete mode 100644 internal/media/queries.sql create mode 100644 internal/message/messages.go create mode 100644 internal/message/models/models.go create mode 100644 internal/message/queries.sql create mode 100644 internal/tag/queries.sql create mode 100644 internal/tag/tag.go delete mode 100644 internal/tags/tags.go create mode 100644 internal/team/queries.sql create mode 100644 internal/team/team.go create mode 100644 internal/user/models/models.go create mode 100644 internal/user/queries.sql create mode 100644 internal/user/user.go delete mode 100644 internal/userdb/models/models.go delete mode 100644 internal/userdb/queries.sql delete mode 100644 internal/userdb/userdb.go create mode 100644 internal/ws/client.go create mode 100644 internal/ws/models/models.go create mode 100644 internal/ws/ws.go diff --git a/cmd/agents.go b/cmd/agents.go deleted file mode 100644 index a293c3d..0000000 --- a/cmd/agents.go +++ /dev/null @@ -1,32 +0,0 @@ -package main - -import ( - "net/http" - - "github.com/zerodha/fastglue" -) - -func handleGetAgents(r *fastglue.Request) error { - var ( - app = r.Context.(*App) - ) - agents, err := app.userDB.GetAgents() - if err != nil { - return r.SendErrorEnvelope(http.StatusInternalServerError, "Something went wrong, try again later.", nil, "") - } - - return r.SendEnvelope(agents) -} - -func handleGetAgentProfile(r *fastglue.Request) error { - var ( - app = r.Context.(*App) - userEmail, _ = r.RequestCtx.UserValue("user_email").(string) - ) - agents, err := app.userDB.GetAgent(userEmail) - if err != nil { - return r.SendErrorEnvelope(http.StatusInternalServerError, "Something went wrong, try again later.", nil, "") - } - - return r.SendEnvelope(agents) -} diff --git a/cmd/attachment.go b/cmd/attachment.go new file mode 100644 index 0000000..044bcfd --- /dev/null +++ b/cmd/attachment.go @@ -0,0 +1,82 @@ +package main + +import ( + "net/http" + "path/filepath" + "slices" + "strconv" + "strings" + + "github.com/abhinavxd/artemis/internal/attachment" + "github.com/zerodha/fastglue" +) + +func handleAttachmentUpload(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + form, err = r.RequestCtx.MultipartForm() + ) + + if err != nil { + app.lo.Error("error parsing media form data.", "error", err) + return r.SendErrorEnvelope(http.StatusInternalServerError, "Error parsing data", nil, "GeneralException") + } + + if files, ok := form.File["files"]; !ok || len(files) == 0 { + return r.SendErrorEnvelope(http.StatusBadRequest, "File not found", nil, "InputException") + } + + if _, ok := form.Value["disposition"]; !ok || len(form.Value["disposition"]) == 0 { + return r.SendErrorEnvelope(http.StatusBadRequest, "Disposition required", nil, "InputException") + } + + if form.Value["disposition"][0] != attachment.DispositionAttachment && form.Value["disposition"][0] != attachment.DispositionInline { + return r.SendErrorEnvelope(http.StatusBadRequest, "Invalid disposition", nil, "InputException") + } + + // Read file into the memory + file, err := form.File["files"][0].Open() + srcFileName := form.File["files"][0].Filename + srcContentType := form.File["files"][0].Header.Get("Content-Type") + srcFileSize := form.File["files"][0].Size + srcDisposition := form.Value["disposition"][0] + if err != nil { + app.lo.Error("reading file into the memory", "error", err) + return r.SendErrorEnvelope(http.StatusInternalServerError, "Error reading file", nil, "GeneralException") + } + defer file.Close() + + ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(srcFileName)), ".") + + // Checking if file type is allowed. + if !slices.Contains(app.constants.AllowedFileUploadExtensions, "*") { + if !slices.Contains(app.constants.AllowedFileUploadExtensions, ext) { + return r.SendErrorEnvelope(http.StatusBadRequest, "Unsupported file type", nil, "GeneralException") + } + } + + // Reset the ptr. + file.Seek(0, 0) + url, mediaUUID, _, err := app.attachmentMgr.Upload("" /**message uuid**/, srcFileName, srcContentType, srcDisposition, strconv.FormatInt(srcFileSize, 10), file) + if err != nil { + app.lo.Error("error uploading file", "error", err) + return r.SendErrorEnvelope(http.StatusInternalServerError, "Error uploading file", nil, "GeneralException") + } + + return r.SendEnvelope(map[string]string{ + "url": url, + "uuid": mediaUUID, + "content_type": srcContentType, + "name": srcFileName, + "size": strconv.FormatInt(srcFileSize, 10), + }) +} + +func handleGetAttachment(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + uuid = r.RequestCtx.UserValue("conversation_uuid").(string) + ) + url := app.attachmentMgr.Store.GetURL(uuid) + return r.Redirect(url, http.StatusFound, nil, "") +} diff --git a/cmd/canned_responses.go b/cmd/canned_responses.go index f64214d..9f578d0 100644 --- a/cmd/canned_responses.go +++ b/cmd/canned_responses.go @@ -10,7 +10,7 @@ func handleGetCannedResponses(r *fastglue.Request) error { var ( app = r.Context.(*App) ) - c, err := app.cannedResp.GetAllCannedResponses() + c, err := app.cannedRespMgr.GetAll() if err != nil { return r.SendErrorEnvelope(http.StatusInternalServerError, "Error fetching canned responses", nil, "") } diff --git a/cmd/conversation.go b/cmd/conversation.go index 6ed3182..02f1adb 100644 --- a/cmd/conversation.go +++ b/cmd/conversation.go @@ -1,8 +1,10 @@ package main import ( + "encoding/json" "net/http" + "github.com/abhinavxd/artemis/internal/message" "github.com/zerodha/fastglue" ) @@ -10,37 +12,97 @@ func handleGetConversations(r *fastglue.Request) error { var ( app = r.Context.(*App) ) - conversations, err := app.conversations.GetConversations() + + c, err := app.conversationMgr.GetConversations() + + // Strip html from the last message and truncate. + for i := range c { + c[i].LastMessage = app.msgMgr.TrimMsg(c[i].LastMessage) + } if err != nil { - return r.SendErrorEnvelope(http.StatusInternalServerError, "Something went wrong, try again later.", nil, "") + return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "") } - return r.SendEnvelope(conversations) + return r.SendEnvelope(c) } func handleGetConversation(r *fastglue.Request) error { var ( app = r.Context.(*App) - uuid = r.RequestCtx.UserValue("uuid").(string) + uuid = r.RequestCtx.UserValue("conversation_uuid").(string) ) - conversation, err := app.conversations.GetConversation(uuid) + c, err := app.conversationMgr.Get(uuid) if err != nil { - return r.SendErrorEnvelope(http.StatusInternalServerError, "Something went wrong, try again later.", nil, "") + return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "") } + return r.SendEnvelope(c) +} - return r.SendEnvelope(conversation) +func handleUpdateAssigneeLastSeen(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + uuid = r.RequestCtx.UserValue("conversation_uuid").(string) + ) + err := app.conversationMgr.UpdateAssigneeLastSeen(uuid) + if err != nil { + return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "") + } + return r.SendEnvelope("ok") +} + +func handleGetConversationParticipants(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + uuid = r.RequestCtx.UserValue("conversation_uuid").(string) + ) + p, err := app.conversationMgr.GetParticipants(uuid) + if err != nil { + return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "") + } + return r.SendEnvelope(p) } func handleUpdateAssignee(r *fastglue.Request) error { var ( app = r.Context.(*App) p = r.RequestCtx.PostArgs() - uuid = r.RequestCtx.UserValue("uuid").(string) - assigneeType = r.RequestCtx.UserValue("assignee_type").(string) assigneeUUID = p.Peek("assignee_uuid") + uuid = r.RequestCtx.UserValue("conversation_uuid").(string) + assigneeType = r.RequestCtx.UserValue("assignee_type").(string) + userUUID = r.RequestCtx.UserValue("user_uuid").(string) + userID = r.RequestCtx.UserValue("user_id").(int64) ) - if err := app.conversations.UpdateAssignee(uuid, assigneeUUID, assigneeType); err != nil { - return r.SendErrorEnvelope(http.StatusInternalServerError, "Something went wrong, try again later.", nil, "") + + if err := app.conversationMgr.UpdateAssignee(uuid, assigneeUUID, assigneeType); err != nil { + return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "") + } + + // Insert the activity message. + actorAgent, err := app.userMgr.GetUser(userUUID) + if err != nil { + app.lo.Warn("fetching agent details from uuid", "uuid", userUUID) + return r.SendEnvelope("ok") + } + + if assigneeType == "agent" { + assigneeAgent, err := app.userMgr.GetUser(userUUID) + if err != nil { + app.lo.Warn("fetching agent details from uuid", "uuid", string(assigneeUUID)) + return r.SendEnvelope("ok") + } + activityType := message.ActivityAssignedAgentChange + if string(assigneeUUID) == userUUID { + activityType = message.ActivitySelfAssign + } + app.msgMgr.RecordActivity(activityType, assigneeAgent.FullName(), uuid, actorAgent.FullName(), userID) + + } else if assigneeType == "team" { + team, err := app.teamMgr.GetTeam(string(assigneeUUID)) + if err != nil { + app.lo.Warn("fetching team details from uuid", "uuid", string(assigneeUUID)) + return r.SendEnvelope("ok") + } + app.msgMgr.RecordActivity(message.ActivityAssignedTeamChange, team.Name, uuid, actorAgent.FullName(), userID) } return r.SendEnvelope("ok") @@ -50,26 +112,66 @@ func handleUpdatePriority(r *fastglue.Request) error { var ( app = r.Context.(*App) p = r.RequestCtx.PostArgs() - uuid = r.RequestCtx.UserValue("uuid").(string) priority = p.Peek("priority") + uuid = r.RequestCtx.UserValue("conversation_uuid").(string) + userUUID = r.RequestCtx.UserValue("user_uuid").(string) + userID = r.RequestCtx.UserValue("user_id").(int64) ) - if err := app.conversations.UpdatePriority(uuid, priority); err != nil { - return r.SendErrorEnvelope(http.StatusInternalServerError, "Something went wrong, try again later.", nil, "") + if err := app.conversationMgr.UpdatePriority(uuid, priority); err != nil { + return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "") } + actorAgent, err := app.userMgr.GetUser(userUUID) + if err != nil { + app.lo.Warn("fetching agent details from uuid", "uuid", string(userUUID)) + return r.SendEnvelope("ok") + } + + app.msgMgr.RecordActivity(message.ActivityPriorityChange, string(priority), uuid, actorAgent.FullName(), userID) + return r.SendEnvelope("ok") } func handleUpdateStatus(r *fastglue.Request) error { var ( - app = r.Context.(*App) - p = r.RequestCtx.PostArgs() - uuid = r.RequestCtx.UserValue("uuid").(string) - status = p.Peek("status") + app = r.Context.(*App) + p = r.RequestCtx.PostArgs() + status = p.Peek("status") + uuid = r.RequestCtx.UserValue("conversation_uuid").(string) + userUUID = r.RequestCtx.UserValue("user_uuid").(string) + userID = r.RequestCtx.UserValue("user_id").(int64) ) - if err := app.conversations.UpdateStatus(uuid, status); err != nil { - return r.SendErrorEnvelope(http.StatusInternalServerError, "Something went wrong, try again later.", nil, "") + if err := app.conversationMgr.UpdateStatus(uuid, status); err != nil { + return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "") } + actorAgent, err := app.userMgr.GetUser(userUUID) + if err != nil { + app.lo.Warn("fetching agent details from uuid", "uuid", string(userUUID)) + return r.SendEnvelope("ok") + } + + app.msgMgr.RecordActivity(message.ActivityStatusChange, string(status), uuid, actorAgent.FullName(), userID) + + return r.SendEnvelope("ok") +} + +func handlAddConversationTags(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + tagIDs = []int{} + p = r.RequestCtx.PostArgs() + tagJSON = p.Peek("tag_ids") + uuid = r.RequestCtx.UserValue("conversation_uuid").(string) + ) + err := json.Unmarshal(tagJSON, &tagIDs) + if err != nil { + app.lo.Error("unmarshalling tag ids", "error", err) + return r.SendErrorEnvelope(http.StatusInternalServerError, "error adding tags", nil, "") + } + + if err := app.conversationTagsMgr.AddTags(uuid, tagIDs); err != nil { + return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "") + } return r.SendEnvelope("ok") } diff --git a/cmd/handlers.go b/cmd/handlers.go index dc86a4d..76ae486 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -1,26 +1,33 @@ package main import ( - "github.com/knadh/koanf/v2" + "github.com/abhinavxd/artemis/internal/ws" "github.com/zerodha/fastglue" ) -func initHandlers(g *fastglue.Fastglue, app *App, ko *koanf.Koanf) { +func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { g.POST("/api/login", handleLogin) g.GET("/api/logout", handleLogout) - g.GET("/api/conversations", authSession(handleGetConversations)) - g.GET("/api/conversation/{uuid}", authSession(handleGetConversation)) - g.GET("/api/conversation/{uuid}/messages", authSession(handleGetMessages)) - g.PUT("/api/conversation/{uuid}/assignee/{assignee_type}", authSession(handleUpdateAssignee)) - g.PUT("/api/conversation/{uuid}/priority", authSession(handleUpdatePriority)) - g.PUT("/api/conversation/{uuid}/status", authSession(handleUpdateStatus)) - - g.POST("/api/conversation/{uuid}/tags", authSession(handleUpsertConvTag)) - - g.GET("/api/profile", authSession(handleGetAgentProfile)) - g.GET("/api/canned_responses", authSession(handleGetCannedResponses)) - g.POST("/api/media", authSession(handleMediaUpload)) - g.GET("/api/agents", authSession(handleGetAgents)) - g.GET("/api/teams", authSession(handleGetTeams)) - g.GET("/api/tags", authSession(handleGetTags)) + g.GET("/api/conversations", auth(handleGetConversations)) + g.GET("/api/conversation/{conversation_uuid}", auth(handleGetConversation)) + g.PUT("/api/conversation/{conversation_uuid}/last-seen", auth(handleUpdateAssigneeLastSeen)) + g.GET("/api/conversation/{conversation_uuid}/participants", auth(handleGetConversationParticipants)) + g.PUT("/api/conversation/{conversation_uuid}/assignee/{assignee_type}", auth(handleUpdateAssignee)) + g.PUT("/api/conversation/{conversation_uuid}/priority", auth(handleUpdatePriority)) + g.PUT("/api/conversation/{conversation_uuid}/status", auth(handleUpdateStatus)) + g.POST("/api/conversation/{conversation_uuid}/tags", auth(handlAddConversationTags)) + g.GET("/api/conversation/{conversation_uuid}/messages", auth(handleGetMessages)) + g.GET("/api/message/{message_uuid}", auth(handleGetMessage)) + g.GET("/api/message/{message_uuid}/retry", auth(handleRetryMessage)) + g.POST("/api/conversation/{conversation_uuid}/message", auth(handleSendMessage)) + g.GET("/api/canned-responses", auth(handleGetCannedResponses)) + g.POST("/api/attachment", auth(handleAttachmentUpload)) + g.GET("/api/attachment/{conversation_uuid}", auth(handleGetAttachment)) + g.GET("/api/users/me", auth(handleGetCurrentUser)) + g.GET("/api/users", auth(handleGetUsers)) + g.GET("/api/teams", auth(handleGetTeams)) + g.GET("/api/tags", auth(handleGetTags)) + g.GET("/api/ws", auth(func(r *fastglue.Request) error { + return handleWS(r, hub) + })) } diff --git a/cmd/init.go b/cmd/init.go index 3c123d8..d26ebe9 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -6,15 +6,25 @@ import ( "log" "os" + "github.com/abhinavxd/artemis/internal/attachment" + "github.com/abhinavxd/artemis/internal/attachment/stores/s3" "github.com/abhinavxd/artemis/internal/cannedresp" - "github.com/abhinavxd/artemis/internal/conversations" - "github.com/abhinavxd/artemis/internal/media" - "github.com/abhinavxd/artemis/internal/media/stores/s3" - "github.com/abhinavxd/artemis/internal/tags" - user "github.com/abhinavxd/artemis/internal/userdb" + "github.com/abhinavxd/artemis/internal/contact" + "github.com/abhinavxd/artemis/internal/conversation" + convtag "github.com/abhinavxd/artemis/internal/conversation/tag" + "github.com/abhinavxd/artemis/internal/inbox" + "github.com/abhinavxd/artemis/internal/inbox/channel/email" + "github.com/abhinavxd/artemis/internal/message" + mmodels "github.com/abhinavxd/artemis/internal/message/models" + "github.com/abhinavxd/artemis/internal/tag" + "github.com/abhinavxd/artemis/internal/team" + "github.com/abhinavxd/artemis/internal/user" + "github.com/abhinavxd/artemis/internal/ws" "github.com/go-redis/redis/v8" "github.com/jmoiron/sqlx" + "github.com/knadh/koanf/parsers/json" "github.com/knadh/koanf/providers/posflag" + "github.com/knadh/koanf/providers/rawbytes" "github.com/knadh/koanf/v2" flag "github.com/spf13/pflag" "github.com/vividvilla/simplesessions" @@ -24,8 +34,8 @@ import ( // consts holds the app constants. type consts struct { - ChatReferenceNumberPattern string - AllowedMediaUploadExtensions []string + AppBaseURL string + AllowedFileUploadExtensions []string } func initFlags() { @@ -51,15 +61,15 @@ func initFlags() { } } -func initConstants(ko *koanf.Koanf) consts { +func initConstants() consts { return consts{ - ChatReferenceNumberPattern: ko.String("app.constants.chat_reference_number_pattern"), - AllowedMediaUploadExtensions: ko.Strings("app.constants.allowed_media_upload_extensions"), + AppBaseURL: ko.String("app.constants.base_url"), + AllowedFileUploadExtensions: ko.Strings("app.constants.allowed_file_upload_extensions"), } } // initSessionManager initializes and returns a simplesessions.Manager instance. -func initSessionManager(rd *redis.Client, ko *koanf.Koanf) *simplesessions.Manager { +func initSessionManager(rd *redis.Client) *simplesessions.Manager { ttl := ko.Duration("app.session.cookie_ttl") s := simplesessions.New(simplesessions.Options{ CookieName: ko.MustString("app.session.cookie_name"), @@ -86,59 +96,103 @@ func initSessionManager(rd *redis.Client, ko *koanf.Koanf) *simplesessions.Manag return s } -func initUserDB(DB *sqlx.DB, lo *logf.Logger, ko *koanf.Koanf) *user.UserDB { - udb, err := user.New(user.Opts{ +func initUserDB(DB *sqlx.DB, lo *logf.Logger) *user.Manager { + mgr, err := user.New(user.Opts{ DB: DB, Lo: lo, BcryptCost: ko.MustInt("app.user.password_bcypt_cost"), }) if err != nil { - log.Fatalf("error initializing userdb: %v", err) + log.Fatalf("error initializing user manager: %v", err) } - return udb + return mgr } -func initConversations(db *sqlx.DB, lo *logf.Logger, ko *koanf.Koanf) *conversations.Conversations { - c, err := conversations.New(conversations.Opts{ - DB: db, - Lo: lo, +func initConversations(db *sqlx.DB, lo *logf.Logger) *conversation.Manager { + c, err := conversation.New(conversation.Opts{ + DB: db, + Lo: lo, + ReferenceNumPattern: ko.String("app.constants.conversation_reference_number_pattern"), }) if err != nil { - log.Fatalf("error initializing conversations: %v", err) + log.Fatalf("error initializing conversation manager: %v", err) } return c } -func initTags(db *sqlx.DB, lo *logf.Logger) *tags.Tags { - t, err := tags.New(tags.Opts{ +func initConversationTags(db *sqlx.DB, lo *logf.Logger) *convtag.Manager { + mgr, err := convtag.New(convtag.Opts{ + DB: db, + Lo: lo, + }) + if err != nil { + log.Fatalf("error initializing conversation tags: %v", err) + } + return mgr +} + +func initTags(db *sqlx.DB, lo *logf.Logger) *tag.Manager { + mgr, err := tag.New(tag.Opts{ DB: db, Lo: lo, }) if err != nil { log.Fatalf("error initializing tags: %v", err) } - return t + return mgr } -func initCannedResponse(db *sqlx.DB, lo *logf.Logger) *cannedresp.CannedResp { +func initCannedResponse(db *sqlx.DB, lo *logf.Logger) *cannedresp.Manager { c, err := cannedresp.New(cannedresp.Opts{ DB: db, Lo: lo, }) if err != nil { - log.Fatalf("error initializing canned responses: %v", err) + log.Fatalf("error initializing canned responses manager: %v", err) } return c } -func initMediaManager(ko *koanf.Koanf, db *sqlx.DB) *media.Manager { +func initContactManager(db *sqlx.DB, lo *logf.Logger) *contact.Manager { + m, err := contact.New(contact.Opts{ + DB: db, + Lo: lo, + }) + if err != nil { + log.Fatalf("error initializing contact manager: %v", err) + } + return m +} + +func initMessages(db *sqlx.DB, lo *logf.Logger, incomingMsgQ chan mmodels.IncomingMessage, wsHub *ws.Hub, contactMgr *contact.Manager, attachmentMgr *attachment.Manager, conversationMgr *conversation.Manager, inboxMgr *inbox.Manager) *message.Manager { + mgr, err := message.New(incomingMsgQ, wsHub, contactMgr, attachmentMgr, inboxMgr, conversationMgr, message.Opts{ + DB: db, + Lo: lo, + }) + if err != nil { + log.Fatalf("error initializing message manager: %v", err) + } + return mgr +} + +func initTeamMgr(db *sqlx.DB, lo *logf.Logger) *team.Manager { + mgr, err := team.New(team.Opts{ + DB: db, + Lo: lo, + }) + if err != nil { + log.Fatalf("error initializing team manager: %v", err) + } + return mgr +} + +func initAttachmentsManager(db *sqlx.DB, lo *logf.Logger) *attachment.Manager { var ( - manager *media.Manager - store media.Store - err error + mgr *attachment.Manager + store attachment.Store + err error ) - // First init the store. - switch s := ko.MustString("app.media_store"); s { + switch s := ko.MustString("app.attachment_store"); s { case "s3": store, err = s3.New(s3.Opt{ URL: ko.String("s3.url"), @@ -155,13 +209,84 @@ func initMediaManager(ko *koanf.Koanf, db *sqlx.DB) *media.Manager { log.Fatalf("error initializing s3 %v", err) } default: - log.Fatal("media store not available.") + log.Fatalf("media store: %s not available", s) } - manager, err = media.New(store, db) + mgr, err = attachment.New(attachment.Opts{ + Store: store, + Lo: lo, + DB: db, + AppBaseURL: ko.String("app.constants.base_url"), + }) if err != nil { - log.Fatalf("initializing media manager %v", err) + log.Fatalf("initializing attachments manager %v", err) + } + return mgr +} + +// initInboxManager initializes the inbox manager and the `active` inboxes. +func initInboxManager(db *sqlx.DB, lo *logf.Logger, incomingMsgQ chan mmodels.IncomingMessage) *inbox.Manager { + mgr, err := inbox.New(lo, db, incomingMsgQ) + if err != nil { + log.Fatalf("error initializing inbox manager: %v", err) } - return manager + inboxRecords, err := mgr.GetActiveInboxes() + if err != nil { + log.Fatalf("error fetching active inboxes %v", err) + } + + for _, inboxR := range inboxRecords { + switch inboxR.Channel { + case "email": + log.Printf("initializing `Email` inbox: %s", inboxR.Name) + inbox, err := initEmailInbox(inboxR) + if err != nil { + log.Fatalf("error initializing email inbox %v", err) + } + mgr.Register(inbox) + default: + log.Printf("WARNING: Unknown inbox channel: %s", inboxR.Name) + } + } + return mgr +} + +// initEmailInbox initializes the email inbox. +func initEmailInbox(inboxRecord inbox.InboxRecord) (inbox.Inbox, error) { + var config email.Config + + // Load JSON data into Koanf. + if err := ko.Load(rawbytes.Provider([]byte(inboxRecord.Config)), json.Parser()); err != nil { + log.Fatalf("error loading config: %v", err) + } + + if err := ko.UnmarshalWithConf("", &config, koanf.UnmarshalConf{Tag: "json"}); err != nil { + log.Fatalf("error unmarshalling `%s` %s config: %v", inboxRecord.Channel, inboxRecord.Name, err) + } + + if len(config.SMTP) == 0 { + log.Printf("WARNING: Zero SMTP servers configured for `%s` inbox: Name: `%s`", inboxRecord.Channel, inboxRecord.Name) + } + + if len(config.IMAP) == 0 { + log.Printf("WARNING: Zero IMAP clients configured for `%s` inbox: Name: `%s`", inboxRecord.Channel, inboxRecord.Name) + } + + // Set from addr. + config.From = inboxRecord.From + + inbox, err := email.New(email.Opts{ + ID: inboxRecord.ID, + Config: config, + }) + + if err != nil { + log.Fatalf("ERROR: initalizing `%s` inbox: `%s` error : %v", inboxRecord.Channel, inboxRecord.Name, 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)) + + return inbox, nil } diff --git a/cmd/login.go b/cmd/login.go index a31ef64..2b326de 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -14,12 +14,13 @@ func handleLogin(r *fastglue.Request) error { email = string(p.Peek("email")) password = p.Peek("password") ) - user, err := app.userDB.Login(email, password) + + user, err := app.userMgr.Login(email, password) if err != nil { return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "GeneralException") } - sess, err := app.sess.Acquire(r, r, nil) + sess, err := app.sessMgr.Acquire(r, r, nil) if err != nil { app.lo.Error("error acquiring session", "error", err) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, @@ -40,36 +41,45 @@ func handleLogin(r *fastglue.Request) error { "Error setting session.", nil, "GeneralException") } + // Set user UUID in the session. + if err := sess.Set("user_uuid", user.UUID); err != nil { + app.lo.Error("error setting session", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, + "Error setting session.", nil, "GeneralException") + } + + // Commit session. if err := sess.Commit(); err != nil { app.lo.Error("error comitting session", "error", err) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error commiting session.", nil, "GeneralException") } - agent, err := app.userDB.GetAgent(email) + // Return the user details. + user, err = app.userMgr.GetUser(user.UUID) if err != nil { - app.lo.Error("fetching agent", "error", err) + app.lo.Error("fetching user", "error", err) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error fetching agent.", nil, "GeneralException") } - return r.SendEnvelope(agent) + return r.SendEnvelope(user) } func handleLogout(r *fastglue.Request) error { var ( app = r.Context.(*App) ) - - sess, err := app.sess.Acquire(r, r, nil) + sess, err := app.sessMgr.Acquire(r, r, nil) if err != nil { app.lo.Error("error acquiring session", "error", err) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error acquiring session.", nil, "GeneralException") } - if err := sess.Clear(); err != nil { app.lo.Error("error clearing session", "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, + "Error clearing session.", nil, "GeneralException") } return r.SendEnvelope("ok") } diff --git a/cmd/main.go b/cmd/main.go index 2ea623c..c0a8382 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,68 +1,110 @@ package main import ( + "context" "log" + "os" + "os/signal" + "syscall" + "github.com/abhinavxd/artemis/internal/attachment" "github.com/abhinavxd/artemis/internal/cannedresp" - "github.com/abhinavxd/artemis/internal/conversations" + "github.com/abhinavxd/artemis/internal/contact" + "github.com/abhinavxd/artemis/internal/conversation" + convtag "github.com/abhinavxd/artemis/internal/conversation/tag" + "github.com/abhinavxd/artemis/internal/inbox" "github.com/abhinavxd/artemis/internal/initz" - "github.com/abhinavxd/artemis/internal/media" - "github.com/abhinavxd/artemis/internal/tags" - user "github.com/abhinavxd/artemis/internal/userdb" + "github.com/abhinavxd/artemis/internal/message" + "github.com/abhinavxd/artemis/internal/message/models" + "github.com/abhinavxd/artemis/internal/tag" + "github.com/abhinavxd/artemis/internal/team" + "github.com/abhinavxd/artemis/internal/user" + "github.com/abhinavxd/artemis/internal/ws" "github.com/knadh/koanf/v2" "github.com/valyala/fasthttp" "github.com/vividvilla/simplesessions" - "github.com/zerodha/fastcache/v4" "github.com/zerodha/fastglue" "github.com/zerodha/logf" ) -var ( - ko = koanf.New(".") -) +var ko = koanf.New(".") -// App is the global app context which is passed and injected everywhere. +// App is the global app context which is passed and injected in the http handlers. type App struct { - constants consts - lo *logf.Logger - conversations *conversations.Conversations - userDB *user.UserDB - sess *simplesessions.Manager - mediaManager *media.Manager - tags *tags.Tags - cannedResp *cannedresp.CannedResp - fc *fastcache.FastCache + constants consts + lo *logf.Logger + cntctMgr *contact.Manager + userMgr *user.Manager + teamMgr *team.Manager + sessMgr *simplesessions.Manager + tagMgr *tag.Manager + msgMgr *message.Manager + inboxMgr *inbox.Manager + attachmentMgr *attachment.Manager + cannedRespMgr *cannedresp.Manager + conversationMgr *conversation.Manager + conversationTagsMgr *convtag.Manager } func main() { - // Command line flags. + // Load command line flags into Koanf. initFlags() // Load the config file into Koanf. initz.Config(ko) - lo := initz.Logger(ko.MustString("app.log_level"), ko.MustString("app.env")) - rd := initz.Redis(ko) - db := initz.DB(ko) + var ( + shutdownCh = make(chan struct{}) + ctx, stop = signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT, syscall.SIGTERM) + + // Incoming messages from all inboxes are pushed to this queue. + incomingMsgQ = make(chan models.IncomingMessage, ko.MustInt("message.incoming_queue_size")) + + lo = initz.Logger(ko.MustString("app.log_level"), ko.MustString("app.env"), "artemis") + rd = initz.Redis(ko) + db = initz.DB(ko) + + attachmentMgr = initAttachmentsManager(db, &lo) + cntctMgr = initContactManager(db, &lo) + conversationMgr = initConversations(db, &lo) + inboxMgr = initInboxManager(db, &lo, incomingMsgQ) + + // Websocket hub. + wsHub = ws.NewHub() + ) // Init the app. var app = &App{ - lo: &lo, - constants: initConstants(ko), - conversations: initConversations(db, &lo, ko), - sess: initSessionManager(rd, ko), - userDB: initUserDB(db, &lo, ko), - mediaManager: initMediaManager(ko, db), - tags: initTags(db, &lo), - cannedResp: initCannedResponse(db, &lo), + lo: &lo, + cntctMgr: cntctMgr, + inboxMgr: inboxMgr, + attachmentMgr: attachmentMgr, + conversationMgr: conversationMgr, + constants: initConstants(), + msgMgr: initMessages(db, &lo, incomingMsgQ, wsHub, cntctMgr, attachmentMgr, conversationMgr, inboxMgr), + sessMgr: initSessionManager(rd), + userMgr: initUserDB(db, &lo), + teamMgr: initTeamMgr(db, &lo), + tagMgr: initTags(db, &lo), + cannedRespMgr: initCannedResponse(db, &lo), + conversationTagsMgr: initConversationTags(db, &lo), } - // HTTP server. + // Start receivers for all active inboxes. + inboxMgr.Receive() + + // Start message inserter and dispatchers. + 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")) + + // Init fastglue http server. g := fastglue.NewGlue() + + // Add app the request context. g.SetContext(app) - // Handlers. - initHandlers(g, app, ko) + // Init the handlers. + initHandlers(g, wsHub) s := &fasthttp.Server{ Name: ko.MustString("app.server.name"), @@ -73,9 +115,16 @@ func main() { ReadBufferSize: ko.MustInt("app.server.max_body_size"), } - // Start the HTTP server + // Goroutine for handling interrupt signals & gracefully shutting down the server. + go func() { + <-ctx.Done() + shutdownCh <- struct{}{} + stop() + }() + + // Start the HTTP server. log.Printf("server listening on %s %s", ko.String("app.server.address"), ko.String("app.server.socket")) - if err := g.ListenAndServe(ko.String("app.server.address"), ko.String("server.socket"), s); 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.Println("bye") diff --git a/cmd/media.go b/cmd/media.go deleted file mode 100644 index aad0717..0000000 --- a/cmd/media.go +++ /dev/null @@ -1,55 +0,0 @@ -package main - -import ( - "net/http" - "path/filepath" - "slices" - "strings" - - "github.com/zerodha/fastglue" -) - -func handleMediaUpload(r *fastglue.Request) error { - var ( - app = r.Context.(*App) - form, err = r.RequestCtx.MultipartForm() - ) - - if err != nil { - app.lo.Error("error parsing media form data.", "error", err) - return r.SendErrorEnvelope(http.StatusInternalServerError, "Error parsing data", nil, "GeneralException") - } - - if files, ok := form.File["files"]; !ok || len(files) == 0 { - return r.SendErrorEnvelope(http.StatusBadRequest, "File not found", nil, "InputException") - } - - // Read file into the memory - file, err := form.File["files"][0].Open() - srcFileName := form.File["files"][0].Filename - srcContentType := form.File["files"][0].Header.Get("Content-Type") - - if err != nil { - app.lo.Error("reading file into the memory", "error", err) - return r.SendErrorEnvelope(http.StatusInternalServerError, "Error reading file", nil, "GeneralException") - } - defer file.Close() - - ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(srcFileName)), ".") - - // Checking if file type is allowed. - if !slices.Contains(app.constants.AllowedMediaUploadExtensions, "*") { - if !slices.Contains(app.constants.AllowedMediaUploadExtensions, ext) { - return r.SendErrorEnvelope(http.StatusBadRequest, "Unsupported file type", nil, "GeneralException") - } - } - - // Reset the ptr. - file.Seek(0, 0) - fileURL, err := app.mediaManager.UploadMedia(srcFileName, srcContentType, file) - if err != nil { - app.lo.Error("error uploading file", "error", err) - return r.SendErrorEnvelope(http.StatusInternalServerError, "Error uploading file", nil, "GeneralException") - } - return r.SendEnvelope(fileURL) -} diff --git a/cmd/messages.go b/cmd/messages.go index 021d368..a855489 100644 --- a/cmd/messages.go +++ b/cmd/messages.go @@ -1,20 +1,118 @@ package main import ( + "encoding/json" "net/http" + "github.com/abhinavxd/artemis/internal/attachment/models" + "github.com/abhinavxd/artemis/internal/message" + mmodels "github.com/abhinavxd/artemis/internal/message/models" "github.com/zerodha/fastglue" ) func handleGetMessages(r *fastglue.Request) error { var ( - app = r.Context.(*App) - uuid = r.RequestCtx.UserValue("uuid").(string) + app = r.Context.(*App) + uuid = r.RequestCtx.UserValue("conversation_uuid").(string) ) - messages, err := app.conversations.GetMessages(uuid) + msgs, err := app.msgMgr.GetConvMessages(uuid) if err != nil { - return r.SendErrorEnvelope(http.StatusInternalServerError, "Something went wrong, try again later.", nil, "") + return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "") } - return r.SendEnvelope(messages) + // Generate URLs for each of the attachments. + for i := range msgs { + for j := range msgs[i].Attachments { + msgs[i].Attachments[j].URL = app.attachmentMgr.Store.GetURL(msgs[i].Attachments[j].UUID) + } + } + + return r.SendEnvelope(msgs) +} + +func handleGetMessage(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + muuid = r.RequestCtx.UserValue("message_uuid").(string) + ) + msgs, err := app.msgMgr.GetMessage(muuid) + if err != nil { + return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "") + } + + // Generate URLs for each of the attachments. + for i := range msgs { + for j := range msgs[i].Attachments { + msgs[i].Attachments[j].URL = app.attachmentMgr.Store.GetURL(msgs[i].Attachments[j].UUID) + } + } + + return r.SendEnvelope(msgs) +} + +func handleRetryMessage(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + muuid = r.RequestCtx.UserValue("message_uuid").(string) + ) + // Change status to pending so this message can be retried again. + err := app.msgMgr.UpdateMessageStatus(muuid, message.StatusPending) + if err != nil { + return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "") + } + return r.SendEnvelope("ok") +} + +func handleSendMessage(r *fastglue.Request) error { + var ( + p = r.RequestCtx.PostArgs() + app = r.Context.(*App) + msgContent = p.Peek("message") + private = p.GetBool("private") + attachmentsJSON = p.Peek("attachments") + attachmentdsUUIDs = []string{} + userID = r.RequestCtx.UserValue("user_id").(int) + conversationUUID = r.RequestCtx.UserValue("conversation_uuid").(string) + ) + + if err := json.Unmarshal(attachmentsJSON, &attachmentdsUUIDs); err != nil { + app.lo.Error("error unmarshalling attachments uuids", "error", err) + return r.SendErrorEnvelope(http.StatusInternalServerError, "error parsing attachments", nil, "") + } + + var attachments = make(models.Attachments, 0, len(attachmentdsUUIDs)) + for _, attUUID := range attachmentdsUUIDs { + attachments = append(attachments, models.Attachment{ + UUID: attUUID, + }) + } + + var status = message.StatusPending + if private { + status = message.StatusSent + } + + _, _, err := app.msgMgr.RecordMessage( + mmodels.Message{ + ConversationUUID: conversationUUID, + SenderID: int64(userID), + Type: message.TypeOutgoing, + SenderType: "user", + Status: status, + Content: string(msgContent), + ContentType: message.ContentTypeHTML, + Private: private, + Meta: "{}", + Attachments: attachments, + }, + ) + + if err != nil { + return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "") + } + + // Add this user as a participant to the conversation. + app.conversationMgr.AddParticipant(userID, conversationUUID) + + return r.SendEnvelope("Message sent") } diff --git a/cmd/middlewares.go b/cmd/middlewares.go index 8317624..31f9ad2 100644 --- a/cmd/middlewares.go +++ b/cmd/middlewares.go @@ -7,11 +7,11 @@ import ( "github.com/zerodha/fastglue" ) -func authSession(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler { +func auth(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler { return func(r *fastglue.Request) error { var ( app = r.Context.(*App) - sess, err = app.sess.Acquire(r, r, nil) + sess, err = app.sessMgr.Acquire(r, r, nil) ) if err != nil { @@ -27,16 +27,23 @@ func authSession(handler fastglue.FastRequestHandler) fastglue.FastRequestHandle } // User ID in session? - userID, err := sess.String(sess.Get("user_id")) + userID, err := sess.Int(sess.Get("user_id")) if err != nil && (err != simplesessions.ErrInvalidSession && err != simplesessions.ErrFieldNotFound) { app.lo.Error("error fetching session session", "error", err) return r.SendErrorEnvelope(http.StatusUnauthorized, "invalid or expired session", nil, "PermissionException") } - if email != "" && userID != ""{ - // Set both in request context so they can be accessed in the handlers later. + userUUID, err := sess.String(sess.Get("user_uuid")) + if err != nil && (err != simplesessions.ErrInvalidSession && err != simplesessions.ErrFieldNotFound) { + app.lo.Error("error fetching session session", "error", err) + return r.SendErrorEnvelope(http.StatusUnauthorized, "invalid or expired session", nil, "PermissionException") + } + + if email != "" && userID > 0 { + // Set both in request context so they can be accessed in the handlers. r.RequestCtx.SetUserValue("user_email", email) r.RequestCtx.SetUserValue("user_id", userID) + r.RequestCtx.SetUserValue("user_uuid", userUUID) return handler(r) } diff --git a/cmd/tags.go b/cmd/tags.go index cab296a..5d40a9d 100644 --- a/cmd/tags.go +++ b/cmd/tags.go @@ -1,40 +1,18 @@ package main import ( - "encoding/json" "net/http" "github.com/zerodha/fastglue" ) -func handleUpsertConvTag(r *fastglue.Request) error { - var ( - app = r.Context.(*App) - p = r.RequestCtx.PostArgs() - uuid = r.RequestCtx.UserValue("uuid").(string) - tagJSON = p.Peek("tag_ids") - tagIDs = []int{} - ) - err := json.Unmarshal(tagJSON, &tagIDs) - if err != nil { - app.lo.Error("unmarshalling tag ids", "error", err) - return r.SendErrorEnvelope(http.StatusInternalServerError, "Something went wrong, try again later.", nil, "") - } - - if err := app.tags.UpsertConvTag(uuid, tagIDs); err != nil { - return r.SendErrorEnvelope(http.StatusInternalServerError, "Something went wrong, try again later.", nil, "") - } - - return r.SendEnvelope("ok") -} - func handleGetTags(r *fastglue.Request) error { var ( app = r.Context.(*App) ) - t, err := app.tags.GetAllTags() + t, err := app.tagMgr.GetAll() if err != nil { - return r.SendErrorEnvelope(http.StatusInternalServerError, "Something went wrong, try again later.", nil, "") + return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "") } return r.SendEnvelope(t) } diff --git a/cmd/teams.go b/cmd/teams.go index 0e929ad..3f0cdf1 100644 --- a/cmd/teams.go +++ b/cmd/teams.go @@ -10,7 +10,7 @@ func handleGetTeams(r *fastglue.Request) error { var ( app = r.Context.(*App) ) - teams, err := app.userDB.GetTeams() + teams, err := app.teamMgr.GetAll() if err != nil { return r.SendErrorEnvelope(http.StatusInternalServerError, "Something went wrong, try again later.", nil, "") } diff --git a/cmd/users.go b/cmd/users.go new file mode 100644 index 0000000..1c5a864 --- /dev/null +++ b/cmd/users.go @@ -0,0 +1,30 @@ +package main + +import ( + "net/http" + + "github.com/zerodha/fastglue" +) + +func handleGetUsers(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + ) + agents, err := app.userMgr.GetUsers() + if err != nil { + return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "") + } + return r.SendEnvelope(agents) +} + +func handleGetCurrentUser(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + userUUID = r.RequestCtx.UserValue("user_uuid").(string) + ) + u, err := app.userMgr.GetUser(userUUID) + if err != nil { + return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "") + } + return r.SendEnvelope(u) +} diff --git a/cmd/websocket.go b/cmd/websocket.go new file mode 100644 index 0000000..7ed7661 --- /dev/null +++ b/cmd/websocket.go @@ -0,0 +1,61 @@ +package main + +import ( + "fmt" + "time" + + "github.com/abhinavxd/artemis/internal/ws" + "github.com/fasthttp/websocket" + "github.com/valyala/fasthttp" + "github.com/zerodha/fastglue" +) + +func ErrHandler(ctx *fasthttp.RequestCtx, status int, reason error) { + fmt.Printf("error status %d - error %d", status, reason) +} + +var upgrader = websocket.FastHTTPUpgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(ctx *fasthttp.RequestCtx) bool { + return true // Allow all origins in development + }, + Error: ErrHandler, +} + +func handleWS(r *fastglue.Request, hub *ws.Hub) error { + var ( + userID = r.RequestCtx.UserValue("user_id").(int) + app = r.Context.(*App) + ) + + err := upgrader.Upgrade(r.RequestCtx, func(conn *websocket.Conn) { + c := ws.Client{ + ID: userID, + Hub: hub, + Conn: conn, + Send: make(chan ws.Message, 100000), + } + + // Sub this client to all assigned conversations. + convs, err := app.conversationMgr.GetAssignedConversations(userID) + if err != nil { + return + } + // Extract uuids. + uuids := make([]string, len(convs)) + for i, conv := range convs { + uuids[i] = conv.UUID + } + c.SubConv(userID, uuids...) + + hub.AddClient(&c) + + go c.Listen() + c.Serve(2 * time.Second) + }) + if err != nil { + fmt.Println("upgrade error:", err) + } + return nil +} diff --git a/frontend/index.html b/frontend/index.html index 16b2e79..48c76f5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,10 +5,10 @@ - + - + Vite App diff --git a/frontend/package.json b/frontend/package.json index 09bd709..1e60c1b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,9 +18,13 @@ "@heroicons/vue": "^2.1.1", "@radix-icons/vue": "^1.0.0", "@tailwindcss/typography": "^0.5.10", + "@tiptap/extension-image": "^2.4.0", + "@tiptap/extension-list-item": "^2.4.0", + "@tiptap/extension-ordered-list": "^2.4.0", "@tiptap/extension-placeholder": "^2.4.0", "@tiptap/pm": "^2.4.0", "@tiptap/starter-kit": "^2.4.0", + "@tiptap/suggestion": "^2.4.0", "@tiptap/vue-3": "^2.4.0", "@unovis/ts": "^1.4.1", "@unovis/vue": "^1.4.1", @@ -31,6 +35,7 @@ "clsx": "^2.1.1", "codeflask": "^1.4.1", "date-fns": "^3.6.0", + "install": "^0.13.0", "lucide-vue-next": "^0.378.0", "npm": "^10.4.0", "pinia": "^2.1.7", @@ -38,7 +43,9 @@ "radix-vue": "^1.8.0", "tailwind-merge": "^2.3.0", "tailwindcss-animate": "^1.0.7", + "tiptap-extension-resize-image": "^1.1.5", "vue": "^3.4.15", + "vue-i18n": "9", "vue-letter": "^0.2.0", "vue-router": "^4.2.5" }, diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 881abb7..103d438 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,33 +1,35 @@ diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 38a68f4..7d7be28 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -1,10 +1,10 @@ -import axios from 'axios'; -import qs from 'qs'; +import axios from 'axios' +import qs from 'qs' const http = axios.create({ timeout: 10000, responseType: "json", -}); +}) // Request interceptor. http.interceptors.request.use((request) => { @@ -16,32 +16,48 @@ http.interceptors.request.use((request) => { return request }) -const login = (data) => http.post(`/api/login`, data); +const login = (data) => http.post(`/api/login`, data) const getTeams = () => http.get("/api/teams") -const getAgents = () => http.get("/api/agents") -const getAgentProfile = () => http.get("/api/profile") +const getUsers = () => http.get("/api/users") +const getCurrentUser = () => http.get("/api/users/me") const getTags = () => http.get("/api/tags") -const upsertTags = (uuid, data) => http.post(`/api/conversation/${uuid}/tags`, data); -const updateAssignee = (uuid, assignee_type, data) => http.put(`/api/conversation/${uuid}/assignee/${assignee_type}`, data); -const updateStatus = (uuid, data) => http.put(`/api/conversation/${uuid}/status`, data); -const updatePriority = (uuid, data) => http.put(`/api/conversation/${uuid}/priority`, data); -const getMessages = (uuid) => http.get(`/api/conversation/${uuid}/messages`); -const getConversation = (uuid) => http.get(`/api/conversation/${uuid}`); -const getConversations = () => http.get('/api/conversations'); -const getCannedResponses = () => http.get('/api/canned_responses'); +const upsertTags = (uuid, data) => http.post(`/api/conversation/${uuid}/tags`, data) +const updateAssignee = (uuid, assignee_type, data) => http.put(`/api/conversation/${uuid}/assignee/${assignee_type}`, data) +const updateStatus = (uuid, data) => http.put(`/api/conversation/${uuid}/status`, data) +const updatePriority = (uuid, data) => http.put(`/api/conversation/${uuid}/priority`, data) +const updateAssigneeLastSeen = (uuid) => http.put(`/api/conversation/${uuid}/last-seen`) +const getMessage = (uuid) => http.get(`/api/message/${uuid}`) +const retryMessage = (uuid) => http.get(`/api/message/${uuid}/retry`) +const getMessages = (uuid) => http.get(`/api/conversation/${uuid}/messages`) +const sendMessage = (uuid, data) => http.post(`/api/conversation/${uuid}/message`, data) +const getConversation = (uuid) => http.get(`/api/conversation/${uuid}`) +const getConversationParticipants = (uuid) => http.get(`/api/conversation/${uuid}/participants`) +const getConversations = () => http.get('/api/conversations') +const getCannedResponses = () => http.get('/api/canned-responses') +const uploadAttachment = (data) => http.post('/api/attachment', data, { + headers: { + 'Content-Type': 'multipart/form-data' + } +}) export default { login, getTags, getTeams, - getAgents, + getUsers, getConversation, getConversations, + getConversationParticipants, + getMessage, getMessages, - getAgentProfile, + sendMessage, + getCurrentUser, updateAssignee, updateStatus, updatePriority, upsertTags, + retryMessage, + updateAssigneeLastSeen, getCannedResponses, + uploadAttachment, } diff --git a/frontend/src/assets/styles/main.scss b/frontend/src/assets/styles/main.scss index b188cc1..c6d5ab2 100644 --- a/frontend/src/assets/styles/main.scss +++ b/frontend/src/assets/styles/main.scss @@ -12,71 +12,82 @@ padding: 20px 20px; } +.bg-success { + background-color: #28a745; /* example success color */ +} + +.text-success-foreground { + color: #fff; /* example text color for success */ +} + +$editorContainerId: 'editor-container'; + +// --primary: 217 88.1% 60.4%; + // Theme. @layer base { :root { --background: 0 0% 100%; - --foreground: 20 14.3% 4.1%; - + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; - --card-foreground: 20 14.3% 4.1%; - + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; - --popover-foreground: 20 14.3% 4.1%; - - --primary: 24 9.8% 10%; - --primary-foreground: 60 9.1% 97.8%; - - --secondary: 60 4.8% 95.9%; - --secondary-foreground: 24 9.8% 10%; - - --muted: 60 4.8% 95.9%; - --muted-foreground: 25 5.3% 44.7%; - - --accent: 60 4.8% 95.9%; - --accent-foreground: 24 9.8% 10%; - + --popover-foreground: 222.2 84% 4.9%; + + --primary: 221.2 83.2% 53.3%; + --primary-foreground: 210 40% 98%; + + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; - --destructive-foreground: 60 9.1% 97.8%; - - --border:20 5.9% 90%; - --input:20 5.9% 90%; - --ring:20 14.3% 4.1%; - --radius: 0.25rem; + --destructive-foreground: 210 40% 98%; + + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 221.2 83.2% 53.3%; + --radius: 0.5rem; } - + .dark { - --background:20 14.3% 4.1%; - --foreground:60 9.1% 97.8%; - - --card:20 14.3% 4.1%; - --card-foreground:60 9.1% 97.8%; - - --popover:20 14.3% 4.1%; - --popover-foreground:60 9.1% 97.8%; - - --primary:60 9.1% 97.8%; - --primary-foreground:24 9.8% 10%; - - --secondary:12 6.5% 15.1%; - --secondary-foreground:60 9.1% 97.8%; - - --muted:12 6.5% 15.1%; - --muted-foreground:24 5.4% 63.9%; - - --accent:12 6.5% 15.1%; - --accent-foreground:60 9.1% 97.8%; - - --destructive:0 62.8% 30.6%; - --destructive-foreground:60 9.1% 97.8%; - - --border:12 6.5% 15.1%; - --input:12 6.5% 15.1%; - --ring:24 5.7% 82.9%; + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + + --primary: 217.2 91.2% 59.8%; + --primary-foreground: 222.2 47.4% 11.2%; + + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 224.3 76.3% 48%; } } - @layer base { * { @apply border-border; @@ -141,3 +152,77 @@ .animate-shake { animation: shake 0.5s infinite; } + +.message-bubble { + @apply flex + flex-col + px-4 + pt-2 + pb-3 + min-w-[30%] max-w-[70%] + border + rounded-2xl; + + box-shadow: rgb(243, 243, 243) 2px 2px 0px 0px; + + // Making email tables fit. + table { + width: 100% !important; + } +} + +.tiptap-editor-image { + width: 100px; + height: 200px; +} + +.box { + box-shadow: rgb(243, 243, 243) 2px 2px 0px 0px; + -webkit-box-shadow: rgb(243, 243, 243) 2px 2px 0px 0px; +} + +[id^='radix-vue-splitter-resize-handle'] { + position: relative; + box-shadow: rgb(243, 243, 243) 2px 2px 0px 0px; + -webkit-box-shadow: rgb(243, 243, 243) 2px 2px 0px 0px; +} + +[id^='resize-panel'] { + position: relative; + box-shadow: 12px -6px 14px -14px rgba(0, 0, 0, 0.1); + -webkit-box-shadow: 12px -6px 14px -14px rgba(0, 0, 0, 0.1); +} + +.overlay { + background: rgba(0, 0, 0, 0.6); +} + +/* Styles for the scrollbar track (part the thumb slides within) */ +::-webkit-scrollbar { + width: 8px; /* for vertical scrollbars */ + height: 8px; /* for horizontal scrollbars */ +} + +/* Styles for the draggable part of the scrollbar */ +::-webkit-scrollbar-thumb { + background-color: darkgrey; + border-radius: 10px; +} + +/* Styles for the scrollbar itself on hover */ +::-webkit-scrollbar-thumb:hover { + background: #555; +} + +* { + scrollbar-width: thin; + scrollbar-color: darkgrey transparent; /* thumb and track color */ +} + +[class$='gmail_quote'] { + display: none !important; +} + +blockquote { + display: none !important; +} diff --git a/frontend/src/components/ActivityMessageBubble.vue b/frontend/src/components/ActivityMessageBubble.vue new file mode 100644 index 0000000..f507350 --- /dev/null +++ b/frontend/src/components/ActivityMessageBubble.vue @@ -0,0 +1,30 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/AgentMessageBubble.vue b/frontend/src/components/AgentMessageBubble.vue new file mode 100644 index 0000000..9002642 --- /dev/null +++ b/frontend/src/components/AgentMessageBubble.vue @@ -0,0 +1,109 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/AttachmentsPreview.vue b/frontend/src/components/AttachmentsPreview.vue new file mode 100644 index 0000000..8d26bec --- /dev/null +++ b/frontend/src/components/AttachmentsPreview.vue @@ -0,0 +1,47 @@ + + + diff --git a/frontend/src/components/ContactMessageBubble.vue b/frontend/src/components/ContactMessageBubble.vue new file mode 100644 index 0000000..03062a0 --- /dev/null +++ b/frontend/src/components/ContactMessageBubble.vue @@ -0,0 +1,68 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/ConversationList.vue b/frontend/src/components/ConversationList.vue index 5ace6be..32cc3f1 100644 --- a/frontend/src/components/ConversationList.vue +++ b/frontend/src/components/ConversationList.vue @@ -1,40 +1,62 @@ + + \ No newline at end of file diff --git a/frontend/src/components/TextEditor.vue b/frontend/src/components/TextEditor.vue index 3ad9900..27f38cf 100644 --- a/frontend/src/components/TextEditor.vue +++ b/frontend/src/components/TextEditor.vue @@ -1,46 +1,30 @@