mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-11-02 21:13:47 +00:00
some more commits.
This commit is contained in:
@@ -70,7 +70,7 @@ func handleUpdateAssignee(r *fastglue.Request) error {
|
|||||||
uuid = r.RequestCtx.UserValue("conversation_uuid").(string)
|
uuid = r.RequestCtx.UserValue("conversation_uuid").(string)
|
||||||
assigneeType = r.RequestCtx.UserValue("assignee_type").(string)
|
assigneeType = r.RequestCtx.UserValue("assignee_type").(string)
|
||||||
userUUID = r.RequestCtx.UserValue("user_uuid").(string)
|
userUUID = r.RequestCtx.UserValue("user_uuid").(string)
|
||||||
userID = r.RequestCtx.UserValue("user_id").(int64)
|
userID = r.RequestCtx.UserValue("user_id").(int)
|
||||||
)
|
)
|
||||||
|
|
||||||
if err := app.conversationMgr.UpdateAssignee(uuid, assigneeUUID, assigneeType); err != nil {
|
if err := app.conversationMgr.UpdateAssignee(uuid, assigneeUUID, assigneeType); err != nil {
|
||||||
@@ -115,7 +115,7 @@ func handleUpdatePriority(r *fastglue.Request) error {
|
|||||||
priority = p.Peek("priority")
|
priority = p.Peek("priority")
|
||||||
uuid = r.RequestCtx.UserValue("conversation_uuid").(string)
|
uuid = r.RequestCtx.UserValue("conversation_uuid").(string)
|
||||||
userUUID = r.RequestCtx.UserValue("user_uuid").(string)
|
userUUID = r.RequestCtx.UserValue("user_uuid").(string)
|
||||||
userID = r.RequestCtx.UserValue("user_id").(int64)
|
userID = r.RequestCtx.UserValue("user_id").(int)
|
||||||
)
|
)
|
||||||
if err := app.conversationMgr.UpdatePriority(uuid, priority); err != nil {
|
if err := app.conversationMgr.UpdatePriority(uuid, priority); err != nil {
|
||||||
return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "")
|
return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "")
|
||||||
@@ -139,7 +139,7 @@ func handleUpdateStatus(r *fastglue.Request) error {
|
|||||||
status = p.Peek("status")
|
status = p.Peek("status")
|
||||||
uuid = r.RequestCtx.UserValue("conversation_uuid").(string)
|
uuid = r.RequestCtx.UserValue("conversation_uuid").(string)
|
||||||
userUUID = r.RequestCtx.UserValue("user_uuid").(string)
|
userUUID = r.RequestCtx.UserValue("user_uuid").(string)
|
||||||
userID = r.RequestCtx.UserValue("user_id").(int64)
|
userID = r.RequestCtx.UserValue("user_id").(int)
|
||||||
)
|
)
|
||||||
if err := app.conversationMgr.UpdateStatus(uuid, status); err != nil {
|
if err := app.conversationMgr.UpdateStatus(uuid, status); err != nil {
|
||||||
return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "")
|
return r.SendErrorEnvelope(http.StatusInternalServerError, err.Error(), nil, "")
|
||||||
|
|||||||
26
cmd/init.go
26
cmd/init.go
@@ -8,6 +8,8 @@ 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"
|
||||||
|
"github.com/abhinavxd/artemis/internal/autoassigner"
|
||||||
|
"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"
|
||||||
@@ -164,8 +166,9 @@ func initContactManager(db *sqlx.DB, lo *logf.Logger) *contact.Manager {
|
|||||||
return m
|
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 {
|
func initMessages(db *sqlx.DB, lo *logf.Logger, incomingMsgQ chan mmodels.IncomingMessage, wsHub *ws.Hub, contactMgr *contact.Manager, attachmentMgr *attachment.Manager,
|
||||||
mgr, err := message.New(incomingMsgQ, wsHub, contactMgr, attachmentMgr, inboxMgr, conversationMgr, message.Opts{
|
conversationMgr *conversation.Manager, inboxMgr *inbox.Manager, automationEngine *automation.Engine) *message.Manager {
|
||||||
|
mgr, err := message.New(incomingMsgQ, wsHub, contactMgr, attachmentMgr, inboxMgr, conversationMgr, automationEngine, message.Opts{
|
||||||
DB: db,
|
DB: db,
|
||||||
Lo: lo,
|
Lo: lo,
|
||||||
})
|
})
|
||||||
@@ -252,6 +255,25 @@ func initInboxManager(db *sqlx.DB, lo *logf.Logger, incomingMsgQ chan mmodels.In
|
|||||||
return mgr
|
return mgr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func initAutomationEngine(convMgr *conversation.Manager, db *sqlx.DB, lo *logf.Logger) *automation.Engine {
|
||||||
|
engine, err := automation.New(convMgr, automation.Opts{
|
||||||
|
DB: db,
|
||||||
|
Lo: lo,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("error initializing automation engine: %v", err)
|
||||||
|
}
|
||||||
|
return engine
|
||||||
|
}
|
||||||
|
|
||||||
|
func initAutoAssignmentEngine(teamMgr *team.Manager, userMgr *user.Manager, convMgr *conversation.Manager, lo *logf.Logger) *autoassigner.Engine {
|
||||||
|
engine, err := autoassigner.New(teamMgr, userMgr, convMgr, lo)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("error initializing auto assignment engine: %v", err)
|
||||||
|
}
|
||||||
|
return engine
|
||||||
|
}
|
||||||
|
|
||||||
// initEmailInbox initializes the email inbox.
|
// initEmailInbox initializes the email inbox.
|
||||||
func initEmailInbox(inboxRecord inbox.InboxRecord) (inbox.Inbox, error) {
|
func initEmailInbox(inboxRecord inbox.InboxRecord) (inbox.Inbox, error) {
|
||||||
var config email.Config
|
var config email.Config
|
||||||
|
|||||||
48
cmd/main.go
48
cmd/main.go
@@ -6,6 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/abhinavxd/artemis/internal/attachment"
|
"github.com/abhinavxd/artemis/internal/attachment"
|
||||||
"github.com/abhinavxd/artemis/internal/cannedresp"
|
"github.com/abhinavxd/artemis/internal/cannedresp"
|
||||||
@@ -50,7 +51,7 @@ func main() {
|
|||||||
// Load command line flags into Koanf.
|
// Load command line flags into Koanf.
|
||||||
initFlags()
|
initFlags()
|
||||||
|
|
||||||
// Load the config file into Koanf.
|
// Load the config files into Koanf.
|
||||||
initz.Config(ko)
|
initz.Config(ko)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -64,27 +65,30 @@ func main() {
|
|||||||
rd = initz.Redis(ko)
|
rd = initz.Redis(ko)
|
||||||
db = initz.DB(ko)
|
db = initz.DB(ko)
|
||||||
|
|
||||||
attachmentMgr = initAttachmentsManager(db, &lo)
|
attachmentMgr = initAttachmentsManager(db, &lo)
|
||||||
cntctMgr = initContactManager(db, &lo)
|
cntctMgr = initContactManager(db, &lo)
|
||||||
conversationMgr = initConversations(db, &lo)
|
conversationMgr = initConversations(db, &lo)
|
||||||
inboxMgr = initInboxManager(db, &lo, incomingMsgQ)
|
inboxMgr = initInboxManager(db, &lo, incomingMsgQ)
|
||||||
|
automationEngine = initAutomationEngine(conversationMgr, db, &lo)
|
||||||
|
teamMgr = initTeamMgr(db, &lo)
|
||||||
|
userMgr = initUserDB(db, &lo)
|
||||||
|
autoAssignerEngine = initAutoAssignmentEngine(teamMgr, userMgr, conversationMgr, &lo)
|
||||||
|
|
||||||
// Websocket hub.
|
// Init Websocket hub.
|
||||||
wsHub = ws.NewHub()
|
wsHub = ws.NewHub()
|
||||||
)
|
)
|
||||||
|
|
||||||
// Init the app.
|
// Init the app
|
||||||
var app = &App{
|
var app = &App{
|
||||||
lo: &lo,
|
lo: &lo,
|
||||||
cntctMgr: cntctMgr,
|
cntctMgr: cntctMgr,
|
||||||
inboxMgr: inboxMgr,
|
inboxMgr: inboxMgr,
|
||||||
attachmentMgr: attachmentMgr,
|
attachmentMgr: attachmentMgr,
|
||||||
conversationMgr: conversationMgr,
|
conversationMgr: conversationMgr,
|
||||||
constants: initConstants(),
|
constants: initConstants(),
|
||||||
msgMgr: initMessages(db, &lo, incomingMsgQ, wsHub, cntctMgr, attachmentMgr, conversationMgr, inboxMgr),
|
msgMgr: initMessages(db, &lo, incomingMsgQ, wsHub, cntctMgr, attachmentMgr, conversationMgr, inboxMgr, automationEngine),
|
||||||
sessMgr: initSessionManager(rd),
|
sessMgr: initSessionManager(rd),
|
||||||
userMgr: initUserDB(db, &lo),
|
|
||||||
teamMgr: initTeamMgr(db, &lo),
|
|
||||||
tagMgr: initTags(db, &lo),
|
tagMgr: initTags(db, &lo),
|
||||||
cannedRespMgr: initCannedResponse(db, &lo),
|
cannedRespMgr: initCannedResponse(db, &lo),
|
||||||
conversationTagsMgr: initConversationTags(db, &lo),
|
conversationTagsMgr: initConversationTags(db, &lo),
|
||||||
@@ -93,10 +97,16 @@ func main() {
|
|||||||
// Start receivers for all active inboxes.
|
// Start receivers for all active inboxes.
|
||||||
inboxMgr.Receive()
|
inboxMgr.Receive()
|
||||||
|
|
||||||
// Start message inserter and dispatchers.
|
// Start incoming msg inserter and outgoing msg dispatchers.
|
||||||
go app.msgMgr.StartDBInserts(ctx, ko.MustInt("message.reader_concurrency"))
|
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"))
|
go app.msgMgr.StartDispatcher(ctx, ko.MustInt("message.dispatch_concurrency"), ko.MustDuration("message.dispatch_read_interval"))
|
||||||
|
|
||||||
|
// Start automation rule engine.
|
||||||
|
go automationEngine.Serve()
|
||||||
|
|
||||||
|
// Start auto assigner enginer.
|
||||||
|
go autoAssignerEngine.Serve(ctx, 10*time.Second)
|
||||||
|
|
||||||
// Init fastglue http server.
|
// Init fastglue http server.
|
||||||
g := fastglue.NewGlue()
|
g := fastglue.NewGlue()
|
||||||
|
|
||||||
@@ -123,7 +133,7 @@ func main() {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
// Start the HTTP server.
|
// Start the HTTP server.
|
||||||
log.Printf("server listening on %s %s", 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ func handleSendMessage(r *fastglue.Request) error {
|
|||||||
_, _, err := app.msgMgr.RecordMessage(
|
_, _, err := app.msgMgr.RecordMessage(
|
||||||
mmodels.Message{
|
mmodels.Message{
|
||||||
ConversationUUID: conversationUUID,
|
ConversationUUID: conversationUUID,
|
||||||
SenderID: int64(userID),
|
SenderID: userID,
|
||||||
Type: message.TypeOutgoing,
|
Type: message.TypeOutgoing,
|
||||||
SenderType: "user",
|
SenderType: "user",
|
||||||
Status: status,
|
Status: status,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<Toaster />
|
||||||
<TooltipProvider :delay-duration=200>
|
<TooltipProvider :delay-duration=200>
|
||||||
<div class="bg-background text-foreground">
|
<div class="bg-background text-foreground">
|
||||||
<div v-if="$route.path !== '/login'">
|
<div v-if="$route.path !== '/login'">
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
<RouterView />
|
<RouterView />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -31,6 +33,7 @@ import { useUserStore } from '@/stores/user'
|
|||||||
import { initWS } from "./websocket.js"
|
import { initWS } from "./websocket.js"
|
||||||
import api from '@/api';
|
import api from '@/api';
|
||||||
|
|
||||||
|
import { Toaster } from '@/components/ui/toast'
|
||||||
import NavBar from './components/NavBar.vue'
|
import NavBar from './components/NavBar.vue'
|
||||||
import {
|
import {
|
||||||
ResizableHandle,
|
ResizableHandle,
|
||||||
@@ -42,6 +45,7 @@ import {
|
|||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const isCollapsed = ref(false)
|
const isCollapsed = ref(false)
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// App default font-size.
|
// App default font-size.
|
||||||
// Default: 16px, 15px looked very wide.
|
// Default: 16px, 15px looked very wide.
|
||||||
:root {
|
:root {
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-container-default {
|
.tab-container-default {
|
||||||
@@ -25,66 +27,67 @@ $editorContainerId: 'editor-container';
|
|||||||
// --primary: 217 88.1% 60.4%;
|
// --primary: 217 88.1% 60.4%;
|
||||||
|
|
||||||
// Theme.
|
// Theme.
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 100%;
|
--background: 0 0% 100%;
|
||||||
--foreground: 222.2 84% 4.9%;
|
--foreground: 0 0% 3.9%;
|
||||||
|
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
--card-foreground: 222.2 84% 4.9%;
|
--card-foreground: 0 0% 3.9%;
|
||||||
|
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 222.2 84% 4.9%;
|
--popover-foreground: 0 0% 3.9%;
|
||||||
|
|
||||||
--primary: 221.2 83.2% 53.3%;
|
--primary: 0 72.2% 50.6%;
|
||||||
--primary-foreground: 210 40% 98%;
|
--primary-foreground: 0 85.7% 97.3%;
|
||||||
|
|
||||||
--secondary: 210 40% 96.1%;
|
--secondary: 0 0% 96.1%;
|
||||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
--secondary-foreground: 0 0% 9%;
|
||||||
|
|
||||||
--muted: 210 40% 96.1%;
|
--muted: 0 0% 96.1%;
|
||||||
--muted-foreground: 215.4 16.3% 46.9%;
|
--muted-foreground: 0 0% 45.1%;
|
||||||
|
|
||||||
--accent: 210 40% 96.1%;
|
--accent: 0 0% 96.1%;
|
||||||
--accent-foreground: 222.2 47.4% 11.2%;
|
--accent-foreground: 0 0% 9%;
|
||||||
|
|
||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 0 84.2% 60.2%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
|
||||||
--border: 214.3 31.8% 91.4%;
|
--border:0 0% 89.8%;
|
||||||
--input: 214.3 31.8% 91.4%;
|
--input:0 0% 89.8%;
|
||||||
--ring: 221.2 83.2% 53.3%;
|
--ring:0 72.2% 50.6%;
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: 222.2 84% 4.9%;
|
--background:0 0% 3.9%;
|
||||||
--foreground: 210 40% 98%;
|
--foreground:0 0% 98%;
|
||||||
|
|
||||||
--card: 222.2 84% 4.9%;
|
--card:0 0% 3.9%;
|
||||||
--card-foreground: 210 40% 98%;
|
--card-foreground:0 0% 98%;
|
||||||
|
|
||||||
--popover: 222.2 84% 4.9%;
|
--popover:0 0% 3.9%;
|
||||||
--popover-foreground: 210 40% 98%;
|
--popover-foreground:0 0% 98%;
|
||||||
|
|
||||||
--primary: 217.2 91.2% 59.8%;
|
--primary:0 72.2% 50.6%;
|
||||||
--primary-foreground: 222.2 47.4% 11.2%;
|
--primary-foreground:0 85.7% 97.3%;
|
||||||
|
|
||||||
--secondary: 217.2 32.6% 17.5%;
|
--secondary:0 0% 14.9%;
|
||||||
--secondary-foreground: 210 40% 98%;
|
--secondary-foreground:0 0% 98%;
|
||||||
|
|
||||||
--muted: 217.2 32.6% 17.5%;
|
--muted:0 0% 14.9%;
|
||||||
--muted-foreground: 215 20.2% 65.1%;
|
--muted-foreground:0 0% 63.9%;
|
||||||
|
|
||||||
--accent: 217.2 32.6% 17.5%;
|
--accent:0 0% 14.9%;
|
||||||
--accent-foreground: 210 40% 98%;
|
--accent-foreground:0 0% 98%;
|
||||||
|
|
||||||
--destructive: 0 62.8% 30.6%;
|
--destructive:0 62.8% 30.6%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground:0 0% 98%;
|
||||||
|
|
||||||
--border: 217.2 32.6% 17.5%;
|
--border:0 0% 14.9%;
|
||||||
--input: 217.2 32.6% 17.5%;
|
--input:0 0% 14.9%;
|
||||||
--ring: 224.3 76.3% 48%;
|
--ring:0 72.2% 50.6%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,8 +180,8 @@ $editorContainerId: 'editor-container';
|
|||||||
}
|
}
|
||||||
|
|
||||||
.box {
|
.box {
|
||||||
box-shadow: rgb(243, 243, 243) 2px 2px 0px 0px;
|
box-shadow: rgb(243, 243, 243) 1px 1px 0px 0px;
|
||||||
-webkit-box-shadow: rgb(243, 243, 243) 2px 2px 0px 0px;
|
-webkit-box-shadow: rgb(243, 243, 243) 1px 1px 0px 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
[id^='radix-vue-splitter-resize-handle'] {
|
[id^='radix-vue-splitter-resize-handle'] {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
<div class="pr-[47px] mb-1">
|
<div class="pr-[47px] mb-1">
|
||||||
<p class="text-muted-foreground text-sm">
|
<p class="text-muted-foreground text-sm">
|
||||||
{{ getFullName(message) }}
|
{{ getFullName }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
<Spinner v-if="message.status === 'pending'" />
|
<Spinner v-if="message.status === 'pending'" />
|
||||||
<div class="flex items-center space-x-2 mt-2">
|
<div class="flex items-center space-x-2 mt-2">
|
||||||
<span class="text-slate-500 capitalize text-xs" v-if="message.status != 'pending'">{{
|
<span class="text-slate-500 capitalize text-xs" v-if="message.status != 'pending'">{{
|
||||||
message.status}}</span>
|
message.status }}</span>
|
||||||
<RotateCcw size="10" @click="retryMessage(message)" class="cursor-pointer"
|
<RotateCcw size="10" @click="retryMessage(message)" class="cursor-pointer"
|
||||||
v-if="message.status === 'failed'"></RotateCcw>
|
v-if="message.status === 'failed'"></RotateCcw>
|
||||||
</div>
|
</div>
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
<Avatar class="cursor-pointer">
|
<Avatar class="cursor-pointer">
|
||||||
<AvatarImage :src=getAvatar />
|
<AvatarImage :src=getAvatar />
|
||||||
<AvatarFallback>
|
<AvatarFallback>
|
||||||
{{ avatarFallback(message) }}
|
{{ avatarFallback }}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
||||||
@@ -56,9 +56,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { useConversationStore } from '@/stores/conversation'
|
import { useConversationStore } from '@/stores/conversation'
|
||||||
import api from '@/api';
|
import api from '@/api'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -71,34 +72,26 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
|||||||
import MessageAttachmentPreview from "./MessageAttachmentPreview.vue"
|
import MessageAttachmentPreview from "./MessageAttachmentPreview.vue"
|
||||||
|
|
||||||
|
|
||||||
defineProps({
|
const props = defineProps({
|
||||||
message: Object,
|
message: Object,
|
||||||
})
|
})
|
||||||
const convStore = useConversationStore()
|
const convStore = useConversationStore()
|
||||||
|
|
||||||
const getAvatar = (msg) => {
|
const participant = computed(() => {
|
||||||
if (msg.sender_uuid && convStore.conversation.participants) {
|
return convStore.conversation?.participants[props.message.sender_uuid] || {};
|
||||||
let participant = convStore.conversation.participants[msg.sender_uuid]
|
});
|
||||||
return participant.avatar_url ? participant.avatar_url : ''
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const getFullName = (msg) => {
|
const getFullName = computed(() => {
|
||||||
if (msg.sender_uuid && convStore.conversation.participants) {
|
return `${participant.value?.first_name} ${participant.value.last_name}`;
|
||||||
let participant = convStore.conversation.participants[msg.sender_uuid]
|
});
|
||||||
return participant.first_name + ' ' + participant.last_name
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const avatarFallback = (msg) => {
|
const getAvatar = computed(() => {
|
||||||
if (msg.sender_uuid && convStore.conversation.participants) {
|
return participant.value.avatar_url || '';
|
||||||
let participant = convStore.conversation.participants[msg.sender_uuid]
|
});
|
||||||
return participant.first_name.toUpperCase().substring(0, 2)
|
|
||||||
}
|
const avatarFallback = computed(() => {
|
||||||
return ''
|
return participant.value?.first_name.toUpperCase().substring(0, 2);
|
||||||
}
|
});
|
||||||
|
|
||||||
const retryMessage = (msg) => {
|
const retryMessage = (msg) => {
|
||||||
api.retryMessage(msg.uuid)
|
api.retryMessage(msg.uuid)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<h1 class="h-screen flex items-center justify-center">
|
<h1 class="h-screen flex items-center justify-center">
|
||||||
<div class="flex flex-row items-center justify-center">
|
<div class="flex flex-row items-center justify-center">
|
||||||
<p>Select a conversation from the left panel.</p>
|
<p>Select a conversation.</p>
|
||||||
</div>
|
</div>
|
||||||
</h1>
|
</h1>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import { useCannedResponses } from '@/stores/canned_responses'
|
|||||||
const conversationStore = useConversationStore()
|
const conversationStore = useConversationStore()
|
||||||
const cannedResponsesStore = useCannedResponses()
|
const cannedResponsesStore = useCannedResponses()
|
||||||
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
cannedResponsesStore.fetchAll()
|
cannedResponsesStore.fetchAll()
|
||||||
})
|
})
|
||||||
@@ -34,7 +33,7 @@ const sendMessage = async (message) => {
|
|||||||
message: message.html,
|
message: message.html,
|
||||||
attachments: JSON.stringify(message.attachments),
|
attachments: JSON.stringify(message.attachments),
|
||||||
})
|
})
|
||||||
conversationStore.fetchMessages(conversationStore.conversation.data.uuid)
|
api.updateAssigneeLastSeen(conversationStore.conversation.data.uuid)
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ToastProvider, type ToastProviderProps } from 'radix-vue'
|
import { ToastProvider, type ToastProviderProps } from 'radix-vue'
|
||||||
|
|
||||||
const props = defineProps<ToastProviderProps>()
|
const props = defineProps<ToastProviderProps>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -160,10 +160,14 @@ export const useConversationStore = defineStore('conversation', () => {
|
|||||||
|
|
||||||
// Websocket updates.
|
// Websocket updates.
|
||||||
function updateConversationList (msg) {
|
function updateConversationList (msg) {
|
||||||
const conversation = conversations.value.data.find(c => c.uuid === msg.conversation_uuid);
|
const updatedConversation = conversations.value.data.find(c => c.uuid === msg.conversation_uuid);
|
||||||
if (conversation) {
|
if (updatedConversation) {
|
||||||
conversation.last_message = msg.last_message;
|
updatedConversation.last_message = msg.last_message;
|
||||||
conversation.last_message_at = msg.last_message_at;
|
updatedConversation.last_message_at = msg.last_message_at;
|
||||||
|
// If updated conversation is open do not increment the count.
|
||||||
|
if (updatedConversation.uuid !== conversation.value.data.uuid) {
|
||||||
|
updatedConversation.unread_message_count += 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function updateMessageList (msg) {
|
function updateMessageList (msg) {
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -18,6 +18,7 @@ require (
|
|||||||
github.com/knadh/koanf/v2 v2.1.1
|
github.com/knadh/koanf/v2 v2.1.1
|
||||||
github.com/knadh/smtppool v1.1.0
|
github.com/knadh/smtppool v1.1.0
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
|
github.com/mr-karan/balance v0.0.0-20230131075323-e0d55eb3e4b9
|
||||||
github.com/rhnvrm/simples3 v0.8.3
|
github.com/rhnvrm/simples3 v0.8.3
|
||||||
github.com/spf13/pflag v1.0.5
|
github.com/spf13/pflag v1.0.5
|
||||||
github.com/valyala/fasthttp v1.54.0
|
github.com/valyala/fasthttp v1.54.0
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -120,6 +120,8 @@ github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa1
|
|||||||
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||||
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||||
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||||
|
github.com/mr-karan/balance v0.0.0-20230131075323-e0d55eb3e4b9 h1:mQECODpWykYPwBg+ELr04Lwm/kSP6+2LPWmvVTwrTPo=
|
||||||
|
github.com/mr-karan/balance v0.0.0-20230131075323-e0d55eb3e4b9/go.mod h1:YMjMm+2l1ye+v1MeuUJ1QPxXKzWp+x8iqg4vWuKB3Ao=
|
||||||
github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78=
|
github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78=
|
||||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||||
|
|||||||
@@ -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/utils"
|
"github.com/abhinavxd/artemis/internal/dbutils"
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
"github.com/zerodha/logf"
|
"github.com/zerodha/logf"
|
||||||
)
|
)
|
||||||
@@ -55,7 +55,8 @@ func New(opt Opts) (*Manager, error) {
|
|||||||
var q queries
|
var q queries
|
||||||
|
|
||||||
// Scan SQL file
|
// Scan SQL file
|
||||||
if err := utils.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
|
||||||
}
|
}
|
||||||
return &Manager{
|
return &Manager{
|
||||||
@@ -93,7 +94,7 @@ func (m *Manager) Upload(msgUUID, fileName, contentType, contentDisposition, fil
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AttachMessage attaches given attachments to a message.
|
// AttachMessage attaches given attachments to a message.
|
||||||
func (m *Manager) AttachMessage(attachments models.Attachments, msgID int64) error {
|
func (m *Manager) AttachMessage(attachments models.Attachments, msgID int) error {
|
||||||
var err error
|
var err error
|
||||||
for _, attachment := range attachments {
|
for _, attachment := range attachments {
|
||||||
if attachment.UUID == "" {
|
if attachment.UUID == "" {
|
||||||
@@ -111,7 +112,7 @@ func (m *Manager) AttachMessage(attachments models.Attachments, msgID int64) err
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) GetMessageAttachments(msgID int64) (models.Attachments, error) {
|
func (m *Manager) GetMessageAttachments(msgID int) (models.Attachments, error) {
|
||||||
var attachments models.Attachments
|
var attachments models.Attachments
|
||||||
if err := m.queries.GetMessageAttachments.Select(&attachments, msgID); err != nil {
|
if err := m.queries.GetMessageAttachments.Select(&attachments, msgID); err != nil {
|
||||||
m.lo.Error("error fetching message attachments", "error", err)
|
m.lo.Error("error fetching message attachments", "error", err)
|
||||||
|
|||||||
1
internal/auditlog/auditlog.go
Normal file
1
internal/auditlog/auditlog.go
Normal file
@@ -0,0 +1 @@
|
|||||||
|
package auditlog
|
||||||
96
internal/autoassigner/autoassigner.go
Normal file
96
internal/autoassigner/autoassigner.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package autoassigner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/abhinavxd/artemis/internal/conversation"
|
||||||
|
"github.com/abhinavxd/artemis/internal/team"
|
||||||
|
"github.com/abhinavxd/artemis/internal/user"
|
||||||
|
"github.com/mr-karan/balance"
|
||||||
|
"github.com/zerodha/logf"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
roundRobinDefaultWeight = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// Engine handles the assignment of unassigned conversations to agents using a round-robin strategy.
|
||||||
|
type Engine struct {
|
||||||
|
// Smooth Weighted Round Robin.
|
||||||
|
teamRoundRobinBalancer map[int]*balance.Balance
|
||||||
|
convMgr *conversation.Manager
|
||||||
|
userMgr *user.Manager
|
||||||
|
teamMgr *team.Manager
|
||||||
|
lo *logf.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new instance of the Engine.
|
||||||
|
func New(teamMgr *team.Manager, userMgr *user.Manager, convMgr *conversation.Manager, lo *logf.Logger) (*Engine, error) {
|
||||||
|
// Get all teams and add users of each them to their respective round robin balancer.
|
||||||
|
teams, err := teamMgr.GetAll()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var balancer = make(map[int]*balance.Balance)
|
||||||
|
for _, team := range teams {
|
||||||
|
// Fetch all users in the team.
|
||||||
|
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()
|
||||||
|
} else {
|
||||||
|
balancer[team.ID].Add(strconv.Itoa(user.ID), roundRobinDefaultWeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &Engine{
|
||||||
|
teamRoundRobinBalancer: balancer,
|
||||||
|
userMgr: userMgr,
|
||||||
|
teamMgr: teamMgr,
|
||||||
|
lo: lo,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssignConversations processes unassigned conversations and assigns them to agents.
|
||||||
|
func (e *Engine) AssignConversations() error {
|
||||||
|
unassignedConv, err := e.convMgr.GetUnassigned()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, conv := range unassignedConv {
|
||||||
|
// Fetch an agent from the team balancer pool and assign.
|
||||||
|
pool, ok := e.teamRoundRobinBalancer[conv.AssignedTeamID.Int]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
userID := pool.Get()
|
||||||
|
|
||||||
|
if userID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
e.convMgr.UpdateAssignee(conv.UUID, []byte("88be466f-adf3-427e-af6a-88df2d3fbb01"), "agent")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Serve(ctx context.Context, interval time.Duration) {
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
e.AssignConversations()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
internal/automation/automation.go
Normal file
73
internal/automation/automation.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package automation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/abhinavxd/artemis/internal/automation/models"
|
||||||
|
"github.com/abhinavxd/artemis/internal/conversation"
|
||||||
|
cmodels "github.com/abhinavxd/artemis/internal/conversation/models"
|
||||||
|
"github.com/abhinavxd/artemis/internal/dbutils"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
"github.com/zerodha/logf"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
//go:embed queries.sql
|
||||||
|
efs embed.FS
|
||||||
|
)
|
||||||
|
|
||||||
|
type queries struct {
|
||||||
|
GetNewConversationRules *sqlx.Stmt `query:"get-rules"`
|
||||||
|
GetRuleActions *sqlx.Stmt `query:"get-rule-actions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Engine struct {
|
||||||
|
q queries
|
||||||
|
lo *logf.Logger
|
||||||
|
convMgr *conversation.Manager
|
||||||
|
newConversationQ chan cmodels.Conversation
|
||||||
|
rules []models.Rule
|
||||||
|
actions []models.Action
|
||||||
|
}
|
||||||
|
|
||||||
|
type Opts struct {
|
||||||
|
DB *sqlx.DB
|
||||||
|
Lo *logf.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(convMgr *conversation.Manager, opt Opts) (*Engine, error) {
|
||||||
|
var (
|
||||||
|
q queries
|
||||||
|
e = &Engine{
|
||||||
|
lo: opt.Lo,
|
||||||
|
convMgr: convMgr,
|
||||||
|
newConversationQ: make(chan cmodels.Conversation, 10000),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := dbutils.ScanSQLFile("queries.sql", &q, opt.DB, efs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch applicable rules & actions.
|
||||||
|
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
|
||||||
|
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Serve() {
|
||||||
|
for conv := range e.newConversationQ {
|
||||||
|
e.processConversations(conv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) ProcessConversation(c cmodels.Conversation) {
|
||||||
|
e.newConversationQ <- c
|
||||||
|
}
|
||||||
24
internal/automation/models/models.go
Normal file
24
internal/automation/models/models.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
const (
|
||||||
|
ActionAssignTeam = "assign_team"
|
||||||
|
ActionAssignAgent = "assign_agent"
|
||||||
|
|
||||||
|
RuleTypeNewConversation = "new_conversation"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Rule struct {
|
||||||
|
ID int `db:"id"`
|
||||||
|
Type string `db:"type"`
|
||||||
|
Field string `db:"field"`
|
||||||
|
Operator string `db:"operator"`
|
||||||
|
Value string `db:"value"`
|
||||||
|
GroupID int `db:"group_id"`
|
||||||
|
LogicalOp string `db:"logical_op"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Action struct {
|
||||||
|
RuleID int `db:"rule_id"`
|
||||||
|
Type string `db:"action_type"`
|
||||||
|
Action string `db:"action"`
|
||||||
|
}
|
||||||
130
internal/automation/preprocessor.go
Normal file
130
internal/automation/preprocessor.go
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
package automation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/abhinavxd/artemis/internal/automation/models"
|
||||||
|
cmodels "github.com/abhinavxd/artemis/internal/conversation/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
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:
|
||||||
|
return e.convMgr.UpdateAssignee(conv.UUID, []byte(action.Action), "team")
|
||||||
|
case models.ActionAssignAgent:
|
||||||
|
return e.convMgr.UpdateStatus(conv.UUID, []byte(action.Action))
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("rule action not recognized: %s", action.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
6
internal/automation/queries.sql
Normal file
6
internal/automation/queries.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
-- name: get-rules
|
||||||
|
select er.id, er.type, ec.field, ec."operator", ec.value, ec.group_id, ecg.logical_op from engine_rules er inner join engine_conditions ec on ec.rule_id = er.id
|
||||||
|
inner join engine_condition_groups ecg on ecg.id = ec.group_id;
|
||||||
|
|
||||||
|
-- name: get-rule-actions
|
||||||
|
select rule_id, action_type, action from engine_actions;
|
||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/abhinavxd/artemis/internal/utils"
|
"github.com/abhinavxd/artemis/internal/dbutils"
|
||||||
"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 := utils.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
|
if err := dbutils.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ package contact
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/abhinavxd/artemis/internal/contact/models"
|
"github.com/abhinavxd/artemis/internal/contact/models"
|
||||||
"github.com/abhinavxd/artemis/internal/utils"
|
"github.com/abhinavxd/artemis/internal/dbutils"
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
"github.com/zerodha/logf"
|
"github.com/zerodha/logf"
|
||||||
)
|
)
|
||||||
@@ -32,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 := utils.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
|
if err := dbutils.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,9 +41,8 @@ func New(opts Opts) (*Manager, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Upsert(con models.Contact) (int64, error) {
|
func (m *Manager) Upsert(con models.Contact) (int, error) {
|
||||||
fmt.Println("con em", con.Email)
|
var contactID int
|
||||||
var contactID int64
|
|
||||||
if err := m.q.InsertContact.QueryRow(con.Source, con.SourceID, con.InboxID,
|
if err := m.q.InsertContact.QueryRow(con.Source, con.SourceID, con.InboxID,
|
||||||
con.FirstName, con.LastName, con.Email, con.PhoneNumber, con.AvatarURL).Scan(&contactID); err != nil {
|
con.FirstName, con.LastName, con.Email, con.PhoneNumber, con.AvatarURL).Scan(&contactID); err != nil {
|
||||||
m.lo.Error("inserting contact", "error", err)
|
m.lo.Error("inserting contact", "error", err)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package models
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type Contact struct {
|
type Contact struct {
|
||||||
ID int64 `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"`
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
"github.com/abhinavxd/artemis/internal/conversation/models"
|
"github.com/abhinavxd/artemis/internal/conversation/models"
|
||||||
"github.com/abhinavxd/artemis/internal/utils"
|
"github.com/abhinavxd/artemis/internal/dbutils"
|
||||||
|
"github.com/abhinavxd/artemis/internal/stringutils"
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
"github.com/lib/pq"
|
"github.com/lib/pq"
|
||||||
"github.com/zerodha/logf"
|
"github.com/zerodha/logf"
|
||||||
@@ -50,6 +51,7 @@ type queries struct {
|
|||||||
GetUUID *sqlx.Stmt `query:"get-uuid"`
|
GetUUID *sqlx.Stmt `query:"get-uuid"`
|
||||||
GetInboxID *sqlx.Stmt `query:"get-inbox-id"`
|
GetInboxID *sqlx.Stmt `query:"get-inbox-id"`
|
||||||
GetConversation *sqlx.Stmt `query:"get-conversation"`
|
GetConversation *sqlx.Stmt `query:"get-conversation"`
|
||||||
|
GetUnassigned *sqlx.Stmt `query:"get-unassigned"`
|
||||||
GetConversationParticipants *sqlx.Stmt `query:"get-conversation-participants"`
|
GetConversationParticipants *sqlx.Stmt `query:"get-conversation-participants"`
|
||||||
GetConversations *sqlx.Stmt `query:"get-conversations"`
|
GetConversations *sqlx.Stmt `query:"get-conversations"`
|
||||||
GetAssignedConversations *sqlx.Stmt `query:"get-assigned-conversations"`
|
GetAssignedConversations *sqlx.Stmt `query:"get-assigned-conversations"`
|
||||||
@@ -64,7 +66,7 @@ type queries struct {
|
|||||||
|
|
||||||
func New(opts Opts) (*Manager, error) {
|
func New(opts Opts) (*Manager, error) {
|
||||||
var q queries
|
var q queries
|
||||||
if err := utils.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
|
if err := dbutils.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
c := &Manager{
|
c := &Manager{
|
||||||
@@ -76,9 +78,9 @@ func New(opts Opts) (*Manager, error) {
|
|||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Manager) Create(contactID int64, inboxID int, meta string) (int64, error) {
|
func (c *Manager) Create(contactID int, inboxID int, meta string) (int, error) {
|
||||||
var (
|
var (
|
||||||
id int64
|
id int
|
||||||
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); err != nil {
|
||||||
@@ -127,8 +129,18 @@ func (c *Manager) AddParticipant(userID int, convUUID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Manager) GetID(uuid string) (int64, error) {
|
func (c *Manager) GetUnassigned() ([]models.Conversation, error) {
|
||||||
var id int64
|
var conv []models.Conversation
|
||||||
|
if err := c.q.GetUnassigned.Get(&conv); err != nil {
|
||||||
|
if err != sql.ErrNoRows {
|
||||||
|
return conv, fmt.Errorf("conversation not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return conv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Manager) GetID(uuid string) (int, error) {
|
||||||
|
var id int
|
||||||
if err := c.q.GetID.QueryRow(uuid).Scan(&id); err != nil {
|
if err := c.q.GetID.QueryRow(uuid).Scan(&id); err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return id, fmt.Errorf("conversation not found: %w", err)
|
return id, fmt.Errorf("conversation not found: %w", err)
|
||||||
@@ -139,7 +151,7 @@ func (c *Manager) GetID(uuid string) (int64, error) {
|
|||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Manager) GetUUID(id int64) (string, error) {
|
func (c *Manager) GetUUID(id int) (string, error) {
|
||||||
var uuid string
|
var uuid string
|
||||||
if err := c.q.GetUUID.QueryRow(id).Scan(&uuid); err != nil {
|
if err := c.q.GetUUID.QueryRow(id).Scan(&uuid); err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
@@ -223,11 +235,10 @@ func (c *Manager) generateRefNum(pattern string) (string, error) {
|
|||||||
if len(pattern) <= 5 {
|
if len(pattern) <= 5 {
|
||||||
pattern = "01234567890"
|
pattern = "01234567890"
|
||||||
}
|
}
|
||||||
randomNumbers, err := utils.GenerateRandomNumericString(len(pattern))
|
randomNumbers, err := stringutils.RandomNumericString(len(pattern))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
result := []byte(pattern)
|
result := []byte(pattern)
|
||||||
randomIndex := 0
|
randomIndex := 0
|
||||||
for i := range result {
|
for i := range result {
|
||||||
@@ -236,6 +247,5 @@ func (c *Manager) generateRefNum(pattern string) (string, error) {
|
|||||||
randomIndex++
|
randomIndex++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(result), nil
|
return string(result), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,23 +5,26 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/abhinavxd/artemis/internal/contact/models"
|
"github.com/abhinavxd/artemis/internal/contact/models"
|
||||||
|
"github.com/volatiletech/null/v9"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Conversation struct {
|
type Conversation struct {
|
||||||
ID int64 `db:"id" json:"-"`
|
ID int `db:"id" json:"-"`
|
||||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||||
UUID string `db:"uuid" json:"uuid"`
|
UUID string `db:"uuid" json:"uuid"`
|
||||||
ClosedAt *time.Time `db:"closed_at" json:"closed_at,omitempty"`
|
ClosedAt null.Time `db:"closed_at" json:"closed_at,omitempty"`
|
||||||
ResolvedAt *time.Time `db:"resolved_at" json:"resolved_at,omitempty"`
|
ResolvedAt null.Time `db:"resolved_at" json:"resolved_at,omitempty"`
|
||||||
ReferenceNumber *string `db:"reference_number" json:"reference_number,omitempty"`
|
ReferenceNumber null.String `db:"reference_number" json:"reference_number,omitempty"`
|
||||||
Priority *string `db:"priority" json:"priority"`
|
Priority null.String `db:"priority" json:"priority"`
|
||||||
Status *string `db:"status" json:"status"`
|
Status null.String `db:"status" json:"status"`
|
||||||
AssigneeLastSeenAt *time.Time `db:"assignee_last_seen_at" json:"assignee_last_seen_at"`
|
AssignedUserID null.Int `db:"assigned_user_id" json:"-"`
|
||||||
|
AssignedTeamID null.Int `db:"assigned_team_id" json:"-"`
|
||||||
|
AssigneeLastSeenAt *time.Time `db:"assignee_last_seen_at" json:"assignee_last_seen_at"`
|
||||||
models.Contact
|
models.Contact
|
||||||
|
// Psuedo fields.
|
||||||
// Fields not in schema.
|
FirstMessage string
|
||||||
|
Subject string `db:"subject" json:"subject"`
|
||||||
UnreadMessageCount int `db:"unread_message_count" json:"unread_message_count"`
|
UnreadMessageCount int `db:"unread_message_count" json:"unread_message_count"`
|
||||||
InboxName string `db:"inbox_name" json:"inbox_name"`
|
InboxName string `db:"inbox_name" json:"inbox_name"`
|
||||||
InboxChannel string `db:"inbox_channel" json:"inbox_channel"`
|
InboxChannel string `db:"inbox_channel" json:"inbox_channel"`
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ SELECT
|
|||||||
(
|
(
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM messages m
|
FROM messages m
|
||||||
WHERE m.conversation_id = c.id AND m.created_at > c.assignee_last_seen_at
|
WHERE m.conversation_id = c.id AND m.created_at > c.assignee_last_seen_at AND m.type = 'incoming'
|
||||||
) AS unread_message_count
|
) AS unread_message_count
|
||||||
FROM conversations c
|
FROM conversations c
|
||||||
JOIN contacts ct ON c.contact_id = ct.id
|
JOIN contacts ct ON c.contact_id = ct.id
|
||||||
@@ -137,4 +137,7 @@ INSERT INTO conversation_participants
|
|||||||
VALUES($1, (select id from conversations where uuid = $2));
|
VALUES($1, (select id from conversations where uuid = $2));
|
||||||
|
|
||||||
-- name: get-assigned-uuids
|
-- name: get-assigned-uuids
|
||||||
select uuids from conversations where assigned_user_id = $1;
|
select uuids from conversations where assigned_user_id = $1;
|
||||||
|
|
||||||
|
-- name: get-unassigned
|
||||||
|
SELECT id, uuid, assigned_team_id from conversations where assigned_user_id is NULL and assigned_team_id is not null;
|
||||||
@@ -2,9 +2,8 @@ package tag
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/abhinavxd/artemis/internal/utils"
|
"github.com/abhinavxd/artemis/internal/dbutils"
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
"github.com/lib/pq"
|
"github.com/lib/pq"
|
||||||
"github.com/zerodha/logf"
|
"github.com/zerodha/logf"
|
||||||
@@ -33,7 +32,7 @@ type queries struct {
|
|||||||
func New(opts Opts) (*Manager, error) {
|
func New(opts Opts) (*Manager, error) {
|
||||||
var q queries
|
var q queries
|
||||||
|
|
||||||
if err := utils.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
|
if err := dbutils.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,14 +46,12 @@ func (t *Manager) AddTags(convUUID string, tagIDs []int) error {
|
|||||||
// Delete tags that have been removed.
|
// Delete tags that have been removed.
|
||||||
if _, err := t.q.DeleteTags.Exec(convUUID, pq.Array(tagIDs)); err != nil {
|
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)
|
t.lo.Error("inserting tag for conversation", "error", err, "converastion_uuid", convUUID, "tag_id", tagIDs)
|
||||||
return fmt.Errorf("error updating tags")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add new tags one by one.
|
// Add new tags one by one.
|
||||||
for _, tagID := range tagIDs {
|
for _, tagID := range tagIDs {
|
||||||
if _, err := t.q.AddTag.Exec(convUUID, tagID); err != nil {
|
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)
|
t.lo.Error("inserting tag for conversation", "error", err, "converastion_uuid", convUUID, "tag_id", tagID)
|
||||||
return fmt.Errorf("error updating tags")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package utils
|
package dbutils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io/fs"
|
"io/fs"
|
||||||
@@ -100,7 +100,6 @@ func (i *IMAP) ReadIncomingMessages(inboxID int, incomingMsgQ chan<- models.Inco
|
|||||||
ReturnCount: true,
|
ReturnCount: true,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
searchData, err := searchCMD.Wait()
|
searchData, err := searchCMD.Wait()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/abhinavxd/artemis/internal/dbutils"
|
||||||
"github.com/abhinavxd/artemis/internal/message/models"
|
"github.com/abhinavxd/artemis/internal/message/models"
|
||||||
"github.com/abhinavxd/artemis/internal/utils"
|
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
"github.com/zerodha/logf"
|
"github.com/zerodha/logf"
|
||||||
)
|
)
|
||||||
@@ -85,7 +84,7 @@ func New(lo *logf.Logger, db *sqlx.DB, incomingMsgQ chan models.IncomingMessage)
|
|||||||
var q queries
|
var q queries
|
||||||
|
|
||||||
// Scan the sql file into the queries struct.
|
// Scan the sql file into the queries struct.
|
||||||
if err := utils.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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/abhinavxd/artemis/internal/attachment"
|
"github.com/abhinavxd/artemis/internal/attachment"
|
||||||
|
"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"
|
||||||
|
cmodels "github.com/abhinavxd/artemis/internal/conversation/models"
|
||||||
|
"github.com/abhinavxd/artemis/internal/dbutils"
|
||||||
"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/utils"
|
|
||||||
"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,6 +68,7 @@ type Manager struct {
|
|||||||
attachmentMgr *attachment.Manager
|
attachmentMgr *attachment.Manager
|
||||||
conversationMgr *conversation.Manager
|
conversationMgr *conversation.Manager
|
||||||
inboxMgr *inbox.Manager
|
inboxMgr *inbox.Manager
|
||||||
|
automationEngine *automation.Engine
|
||||||
wsHub *ws.Hub
|
wsHub *ws.Hub
|
||||||
incomingMsgQ chan models.IncomingMessage
|
incomingMsgQ chan models.IncomingMessage
|
||||||
outgoingMsgQ chan models.Message
|
outgoingMsgQ chan models.Message
|
||||||
@@ -92,10 +95,11 @@ type queries struct {
|
|||||||
MessageExists *sqlx.Stmt `query:"message-exists"`
|
MessageExists *sqlx.Stmt `query:"message-exists"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(incomingMsgQ chan models.IncomingMessage, wsHub *ws.Hub, contactMgr *contact.Manager, attachmentMgr *attachment.Manager, inboxMgr *inbox.Manager, conversationMgr *conversation.Manager, opts Opts) (*Manager, error) {
|
func New(incomingMsgQ chan models.IncomingMessage, wsHub *ws.Hub, contactMgr *contact.Manager, attachmentMgr *attachment.Manager, inboxMgr *inbox.Manager,
|
||||||
|
conversationMgr *conversation.Manager, automationEngine *automation.Engine, opts Opts) (*Manager, error) {
|
||||||
var q queries
|
var q queries
|
||||||
|
|
||||||
if err := utils.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
|
if err := dbutils.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &Manager{
|
return &Manager{
|
||||||
@@ -106,6 +110,7 @@ func New(incomingMsgQ chan models.IncomingMessage, wsHub *ws.Hub, contactMgr *co
|
|||||||
attachmentMgr: attachmentMgr,
|
attachmentMgr: attachmentMgr,
|
||||||
conversationMgr: conversationMgr,
|
conversationMgr: conversationMgr,
|
||||||
inboxMgr: inboxMgr,
|
inboxMgr: inboxMgr,
|
||||||
|
automationEngine: automationEngine,
|
||||||
incomingMsgQ: incomingMsgQ,
|
incomingMsgQ: incomingMsgQ,
|
||||||
outgoingMsgQ: make(chan models.Message, opts.OutgoingMsgQueueSize),
|
outgoingMsgQ: make(chan models.Message, opts.OutgoingMsgQueueSize),
|
||||||
outgoingProcessingMsgs: sync.Map{},
|
outgoingProcessingMsgs: sync.Map{},
|
||||||
@@ -139,9 +144,9 @@ func (m *Manager) UpdateMessageStatus(uuid string, status string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RecordMessage inserts a message and attaches the attachments to the message.
|
// RecordMessage inserts a message and attaches the attachments to the message.
|
||||||
func (m *Manager) RecordMessage(msg models.Message) (int64, string, error) {
|
func (m *Manager) RecordMessage(msg models.Message) (int, string, error) {
|
||||||
var (
|
var (
|
||||||
msgID int64
|
msgID int
|
||||||
msgUUID string
|
msgUUID string
|
||||||
query *sqlx.Stmt
|
query *sqlx.Stmt
|
||||||
convIdentifier interface{}
|
convIdentifier interface{}
|
||||||
@@ -172,7 +177,7 @@ func (m *Manager) RecordMessage(msg models.Message) (int64, string, error) {
|
|||||||
return msgID, msgUUID, nil
|
return msgID, msgUUID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) RecordActivity(activityType, value, conversationUUID, userName string, userID int64) error {
|
func (m *Manager) RecordActivity(activityType, value, conversationUUID, userName string, userID int) error {
|
||||||
var content = m.getActivityContent(activityType, value, userName)
|
var content = m.getActivityContent(activityType, value, userName)
|
||||||
if content == "" {
|
if content == "" {
|
||||||
m.lo.Error("invalid activity for inserting message", "activity", activityType)
|
m.lo.Error("invalid activity for inserting message", "activity", activityType)
|
||||||
@@ -214,7 +219,7 @@ func (m *Manager) StartDispatcher(ctx context.Context, concurrency int, readInte
|
|||||||
case <-dbScanner.C:
|
case <-dbScanner.C:
|
||||||
var (
|
var (
|
||||||
pendingMsgs = []models.Message{}
|
pendingMsgs = []models.Message{}
|
||||||
msgIDs = m.getProcessingMsgIDs()
|
msgIDs = m.getOutgoingProcessingMsgIDs()
|
||||||
)
|
)
|
||||||
|
|
||||||
// Skip the currently processing msg ids.
|
// Skip the currently processing msg ids.
|
||||||
@@ -222,8 +227,6 @@ func (m *Manager) StartDispatcher(ctx context.Context, concurrency int, readInte
|
|||||||
m.lo.Error("error fetching pending messages from db", "error", err)
|
m.lo.Error("error fetching pending messages from db", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("pendings msg %+v \n", pendingMsgs)
|
|
||||||
|
|
||||||
// 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)
|
m.outgoingProcessingMsgs.Store(msg.ID, msg.ID)
|
||||||
@@ -254,7 +257,7 @@ func (m *Manager) DispatchWorker() {
|
|||||||
|
|
||||||
if inbox.Channel() == "email" {
|
if inbox.Channel() == "email" {
|
||||||
msg.InReplyTo, _ = m.GetInReplyTo(msg.ConversationID)
|
msg.InReplyTo, _ = m.GetInReplyTo(msg.ConversationID)
|
||||||
m.lo.Debug("set in reply to", "in_reply_to", msg.InReplyTo)
|
m.lo.Debug("set in reply to for outgoing email message", "in_reply_to", msg.InReplyTo)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = inbox.Send(msg)
|
err = inbox.Send(msg)
|
||||||
@@ -279,7 +282,7 @@ func (m *Manager) DispatchWorker() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) GetToAddress(convID int64, 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, "conv_id", convID)
|
||||||
@@ -288,7 +291,7 @@ func (m *Manager) GetToAddress(convID int64, channel string) ([]string, error) {
|
|||||||
return addr, nil
|
return addr, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) GetInReplyTo(convID int64) (string, error) {
|
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 {
|
||||||
@@ -339,13 +342,11 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// This message already exists?
|
// This message already exists?
|
||||||
m.lo.Debug("searching for message with id", "source_id", in.Message.SourceID.String)
|
|
||||||
conversationID, err := m.findConversationID([]string{in.Message.SourceID.String})
|
conversationID, err := m.findConversationID([]string{in.Message.SourceID.String})
|
||||||
if err != nil && err != ErrConversationNotFound {
|
if err != nil && err != ErrConversationNotFound {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if conversationID > 0 {
|
if conversationID > 0 {
|
||||||
m.lo.Debug("conversation already exists for message", "source_id", in.Message.SourceID.String)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,7 +368,7 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error {
|
|||||||
return fmt.Errorf("uploading message attachments: %w", err)
|
return fmt.Errorf("uploading message attachments: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WS update.
|
// Send WS update.
|
||||||
cuuid, err := m.conversationMgr.GetUUID(in.Message.ConversationID)
|
cuuid, err := m.conversationMgr.GetUUID(in.Message.ConversationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.lo.Error("error fetching uuid for conversation", "conversation_id", in.Message.ConversationID, "error", err)
|
m.lo.Error("error fetching uuid for conversation", "conversation_id", in.Message.ConversationID, "error", err)
|
||||||
@@ -390,6 +391,15 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pass this conversation for evaluating automation rules.
|
||||||
|
if newConv {
|
||||||
|
m.automationEngine.ProcessConversation(cmodels.Conversation{
|
||||||
|
UUID: cuuid,
|
||||||
|
FirstMessage: in.Message.Content,
|
||||||
|
Subject: in.Message.Subject,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,9 +414,9 @@ func (m *Manager) TrimMsg(msg string) string {
|
|||||||
|
|
||||||
func (m *Manager) uploadAttachments(in *models.Message) error {
|
func (m *Manager) uploadAttachments(in *models.Message) error {
|
||||||
var (
|
var (
|
||||||
inlineAttachments = false
|
hasInline = false
|
||||||
msgID = in.ID
|
msgID = in.ID
|
||||||
msgUUID = in.UUID
|
msgUUID = in.UUID
|
||||||
)
|
)
|
||||||
for _, att := range in.Attachments {
|
for _, att := range in.Attachments {
|
||||||
reader := bytes.NewReader(att.Content)
|
reader := bytes.NewReader(att.Content)
|
||||||
@@ -416,13 +426,13 @@ func (m *Manager) uploadAttachments(in *models.Message) error {
|
|||||||
return errors.New("error uploading attachments for incoming message")
|
return errors.New("error uploading attachments for incoming message")
|
||||||
}
|
}
|
||||||
if att.ContentDisposition == attachment.DispositionInline {
|
if att.ContentDisposition == attachment.DispositionInline {
|
||||||
inlineAttachments = true
|
hasInline = true
|
||||||
in.Content = strings.ReplaceAll(in.Content, "cid:"+att.ContentID, url)
|
in.Content = strings.ReplaceAll(in.Content, "cid:"+att.ContentID, url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the msg content the `cid:content_id` urls have been replaced.
|
// Update the msg content the `cid:content_id` urls have been replaced.
|
||||||
if inlineAttachments {
|
if hasInline {
|
||||||
if _, err := m.q.UpdateMessageContent.Exec(in.Content, msgID); err != nil {
|
if _, err := m.q.UpdateMessageContent.Exec(in.Content, msgID); err != nil {
|
||||||
m.lo.Error("error updating message content", "message_uuid", msgUUID)
|
m.lo.Error("error updating message content", "message_uuid", msgUUID)
|
||||||
return fmt.Errorf("updating msg content: %w", err)
|
return fmt.Errorf("updating msg content: %w", err)
|
||||||
@@ -431,23 +441,21 @@ func (m *Manager) uploadAttachments(in *models.Message) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) findOrCreateConversation(in *models.Message, inboxID int, contactID int64, conversationMeta string) (int64, bool, error) {
|
func (m *Manager) findOrCreateConversation(in *models.Message, inboxID int, contactID int, conversationMeta string) (int, bool, error) {
|
||||||
var (
|
var (
|
||||||
conversationID int64
|
conversationID int
|
||||||
newConv bool
|
newConv bool
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
|
|
||||||
// Search for existing conversation.
|
// Search for existing conversation.
|
||||||
if conversationID == 0 && in.InReplyTo > "" {
|
if conversationID == 0 && in.InReplyTo > "" {
|
||||||
m.lo.Debug("searching for message with id", "source_id", in.InReplyTo)
|
|
||||||
conversationID, err = m.findConversationID([]string{in.InReplyTo})
|
conversationID, err = m.findConversationID([]string{in.InReplyTo})
|
||||||
if err != nil && err != ErrConversationNotFound {
|
if err != nil && err != ErrConversationNotFound {
|
||||||
return conversationID, newConv, err
|
return conversationID, newConv, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if conversationID == 0 && len(in.References) > 0 {
|
if conversationID == 0 && len(in.References) > 0 {
|
||||||
m.lo.Debug("searching for message with id", "source_id", in.References)
|
|
||||||
conversationID, err = m.findConversationID(in.References)
|
conversationID, err = m.findConversationID(in.References)
|
||||||
if err != nil && err != ErrConversationNotFound {
|
if err != nil && err != ErrConversationNotFound {
|
||||||
return conversationID, newConv, err
|
return conversationID, newConv, err
|
||||||
@@ -485,8 +493,8 @@ func (m *Manager) getActivityContent(activityType, value, userName string) strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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) (int64, error) {
|
func (m *Manager) findConversationID(sourceIDs []string) (int, error) {
|
||||||
var conversationID int64
|
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 {
|
||||||
return conversationID, ErrConversationNotFound
|
return conversationID, ErrConversationNotFound
|
||||||
@@ -518,11 +526,11 @@ func (m *Manager) attachAttachments(msg *models.Message) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getProcessingMsgIDs returns outgoing msg ids currently being processed.
|
// getOutgoingProcessingMsgIDs returns outgoing msg ids currently being processed.
|
||||||
func (m *Manager) getProcessingMsgIDs() []int64 {
|
func (m *Manager) getOutgoingProcessingMsgIDs() []int {
|
||||||
var out = make([]int64, 0)
|
var out = make([]int, 0)
|
||||||
m.outgoingProcessingMsgs.Range(func(key, _ any) bool {
|
m.outgoingProcessingMsgs.Range(func(key, _ any) bool {
|
||||||
if k, ok := key.(int64); ok {
|
if k, ok := key.(int); ok {
|
||||||
out = append(out, k)
|
out = append(out, k)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -12,18 +12,18 @@ import (
|
|||||||
|
|
||||||
// Message represents a message in the database.
|
// Message represents a message in the database.
|
||||||
type Message struct {
|
type Message struct {
|
||||||
ID int64 `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"`
|
||||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||||
UUID string `db:"uuid" json:"uuid"`
|
UUID string `db:"uuid" json:"uuid"`
|
||||||
Type string `db:"type" json:"type"`
|
Type string `db:"type" json:"type"`
|
||||||
Status string `db:"status" json:"status"`
|
Status string `db:"status" json:"status"`
|
||||||
ConversationID int64 `db:"conversation_id" json:"conversation_id"`
|
ConversationID int `db:"conversation_id" json:"conversation_id"`
|
||||||
Content string `db:"content" json:"content"`
|
Content string `db:"content" json:"content"`
|
||||||
ContentType string `db:"content_type" json:"content_type"`
|
ContentType string `db:"content_type" json:"content_type"`
|
||||||
Private bool `db:"private" json:"private"`
|
Private bool `db:"private" json:"private"`
|
||||||
SourceID null.String `db:"source_id" json:"-"`
|
SourceID null.String `db:"source_id" json:"-"`
|
||||||
SenderID int64 `db:"sender_id" json:"sender_id"`
|
SenderID int `db:"sender_id" json:"sender_id"`
|
||||||
SenderType string `db:"sender_type" json:"sender_type"`
|
SenderType string `db:"sender_type" json:"sender_type"`
|
||||||
InboxID int `db:"inbox_id" json:"-"`
|
InboxID int `db:"inbox_id" json:"-"`
|
||||||
Meta string `db:"meta" json:"meta"`
|
Meta string `db:"meta" json:"meta"`
|
||||||
|
|||||||
43
internal/stringutils/strings.go
Normal file
43
internal/stringutils/strings.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package stringutils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RandomAlNumString generates a random alphanumeric string of length n.
|
||||||
|
func RandomAlNumString(n int) (string, error) {
|
||||||
|
const (
|
||||||
|
dictionary = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
|
)
|
||||||
|
|
||||||
|
var bytes = make([]byte, n)
|
||||||
|
|
||||||
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range bytes {
|
||||||
|
bytes[k] = dictionary[v%byte(len(dictionary))]
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(bytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RandomNumericString generates a random digit string of length n.
|
||||||
|
func RandomNumericString(n int) (string, error) {
|
||||||
|
const (
|
||||||
|
dictionary = "0123456789"
|
||||||
|
)
|
||||||
|
|
||||||
|
var bytes = make([]byte, n)
|
||||||
|
|
||||||
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range bytes {
|
||||||
|
bytes[k] = dictionary[v%byte(len(dictionary))]
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(bytes), nil
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/abhinavxd/artemis/internal/utils"
|
"github.com/abhinavxd/artemis/internal/dbutils"
|
||||||
"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 int64 `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 := utils.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
|
if err := dbutils.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,4 +2,12 @@
|
|||||||
SELECT name, uuid from teams where disabled is not true;
|
SELECT name, uuid from teams where disabled is not true;
|
||||||
|
|
||||||
-- name: get-team
|
-- name: get-team
|
||||||
SELECT name, uuid from teams where disabled is not true and uuid = $1;
|
SELECT name, uuid from teams where disabled is not true and uuid = $1;
|
||||||
|
|
||||||
|
-- name: get-team-members
|
||||||
|
SELECT u.id, t.id as team_id
|
||||||
|
FROM users u
|
||||||
|
JOIN team_members tm ON tm.user_id = u.id
|
||||||
|
JOIN teams t ON t.id = tm.team_id
|
||||||
|
WHERE t.name = $1;
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/abhinavxd/artemis/internal/utils"
|
"github.com/abhinavxd/artemis/internal/dbutils"
|
||||||
|
umodels "github.com/abhinavxd/artemis/internal/user/models"
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
"github.com/zerodha/logf"
|
"github.com/zerodha/logf"
|
||||||
)
|
)
|
||||||
@@ -17,7 +18,7 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Team struct {
|
type Team struct {
|
||||||
ID string `db:"id" json:"-"`
|
ID int `db:"id" json:"-"`
|
||||||
Name string `db:"name" json:"name"`
|
Name string `db:"name" json:"name"`
|
||||||
UUID string `db:"uuid" json:"uuid"`
|
UUID string `db:"uuid" json:"uuid"`
|
||||||
}
|
}
|
||||||
@@ -33,14 +34,15 @@ type Opts struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type queries struct {
|
type queries struct {
|
||||||
GetTeams *sqlx.Stmt `query:"get-teams"`
|
GetTeams *sqlx.Stmt `query:"get-teams"`
|
||||||
GetTeam *sqlx.Stmt `query:"get-team"`
|
GetTeam *sqlx.Stmt `query:"get-team"`
|
||||||
|
GetTeamMembers *sqlx.Stmt `query:"get-team-members"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(opts Opts) (*Manager, error) {
|
func New(opts Opts) (*Manager, error) {
|
||||||
var q queries
|
var q queries
|
||||||
|
|
||||||
if err := utils.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
|
if err := dbutils.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,3 +75,15 @@ func (u *Manager) GetTeam(uuid string) (Team, error) {
|
|||||||
}
|
}
|
||||||
return team, nil
|
return team, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *Manager) GetTeamMembers(name string) ([]umodels.User, error) {
|
||||||
|
var users []umodels.User
|
||||||
|
if err := u.q.GetTeamMembers.Select(&users, name); err != nil {
|
||||||
|
if errors.Is(sql.ErrNoRows, err) {
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
u.lo.Error("error fetching team members from db", "team_name", name, "error", err)
|
||||||
|
return users, fmt.Errorf("fetching team members: %w", err)
|
||||||
|
}
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int64 `db:"id" json:"-"`
|
ID int `db:"id" json:"-"`
|
||||||
UUID string `db:"uuid" json:"uuid"`
|
UUID string `db:"uuid" json:"uuid"`
|
||||||
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"`
|
||||||
@@ -9,6 +9,7 @@ type User struct {
|
|||||||
AvatarURL *string `db:"avatar_url" json:"avatar_url"`
|
AvatarURL *string `db:"avatar_url" json:"avatar_url"`
|
||||||
Disabled string `db:"disabled" json:"disabled"`
|
Disabled string `db:"disabled" json:"disabled"`
|
||||||
Password string `db:"password" json:"-"`
|
Password string `db:"password" json:"-"`
|
||||||
|
TeamID int `db:"team_id" json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *User) FullName() string {
|
func (u *User) FullName() string {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package user provides functions to login, logout and fetch user details.
|
// Package user handles user login, logout and provides functions to fetch user details.
|
||||||
package user
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/abhinavxd/artemis/internal/dbutils"
|
||||||
"github.com/abhinavxd/artemis/internal/user/models"
|
"github.com/abhinavxd/artemis/internal/user/models"
|
||||||
"github.com/abhinavxd/artemis/internal/utils"
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
"github.com/zerodha/logf"
|
"github.com/zerodha/logf"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
@@ -46,7 +46,7 @@ type queries struct {
|
|||||||
func New(opts Opts) (*Manager, error) {
|
func New(opts Opts) (*Manager, error) {
|
||||||
var q queries
|
var q queries
|
||||||
|
|
||||||
if err := utils.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
|
if err := dbutils.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
package utils
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"fmt"
|
|
||||||
"net/textproto"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
reSpaces = regexp.MustCompile(`[\s]+`)
|
|
||||||
)
|
|
||||||
|
|
||||||
// RandomAlNumString generates a random alphanumeric string of length n.
|
|
||||||
func RandomAlNumString(n int) (string, error) {
|
|
||||||
const (
|
|
||||||
dictionary = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
||||||
)
|
|
||||||
|
|
||||||
var bytes = make([]byte, n)
|
|
||||||
|
|
||||||
if _, err := rand.Read(bytes); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
for k, v := range bytes {
|
|
||||||
bytes[k] = dictionary[v%byte(len(dictionary))]
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(bytes), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateRandomNumericString generates a random digit string of length n.
|
|
||||||
func GenerateRandomNumericString(n int) (string, error) {
|
|
||||||
const (
|
|
||||||
dictionary = "0123456789"
|
|
||||||
)
|
|
||||||
|
|
||||||
var bytes = make([]byte, n)
|
|
||||||
|
|
||||||
if _, err := rand.Read(bytes); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
for k, v := range bytes {
|
|
||||||
bytes[k] = dictionary[v%byte(len(dictionary))]
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(bytes), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GeneratePassword generates a secure password of specified length.
|
|
||||||
func GeneratePassword(len int) ([]byte, error) {
|
|
||||||
randomString, err := RandomAlNumString(len)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(randomString), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return hashedPassword, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// InArray checks if an element of type T is present in a slice of type T.
|
|
||||||
func InArray[T comparable](val T, vals []T) bool {
|
|
||||||
for _, v := range vals {
|
|
||||||
if v == val {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// MakeFilename makes a filename from the given string.
|
|
||||||
func MakeFilename(fName string) string {
|
|
||||||
name := strings.TrimSpace(fName)
|
|
||||||
if name == "" {
|
|
||||||
name, _ = RandomAlNumString(10)
|
|
||||||
}
|
|
||||||
// replace whitespace with "-"
|
|
||||||
name = reSpaces.ReplaceAllString(name, "-")
|
|
||||||
return filepath.Base(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MakeAttachmentHeader
|
|
||||||
func MakeAttachmentHeader(filename, encoding, contentType string) textproto.MIMEHeader {
|
|
||||||
if encoding == "" {
|
|
||||||
encoding = "base64"
|
|
||||||
}
|
|
||||||
if contentType == "" {
|
|
||||||
contentType = "application/octet-stream"
|
|
||||||
}
|
|
||||||
|
|
||||||
h := textproto.MIMEHeader{}
|
|
||||||
h.Set("Content-Disposition", "attachment; filename="+filename)
|
|
||||||
h.Set("Content-Type", fmt.Sprintf("%s; name=\""+filename+"\"", contentType))
|
|
||||||
h.Set("Content-Transfer-Encoding", encoding)
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
// SplitName splits a full name into first name and last name.
|
|
||||||
func SplitName(fullName string) (firstName string, lastName string) {
|
|
||||||
parts := strings.Fields(fullName)
|
|
||||||
if len(parts) > 1 {
|
|
||||||
lastName = parts[len(parts)-1]
|
|
||||||
firstName = strings.Join(parts[:len(parts)-1], " ")
|
|
||||||
} else if len(parts) == 1 {
|
|
||||||
firstName = parts[0]
|
|
||||||
}
|
|
||||||
return firstName, lastName
|
|
||||||
}
|
|
||||||
|
|
||||||
// BackoffDelay introduces a delay between actions with backoff behavior.
|
|
||||||
func BackoffDelay(try int, dur time.Duration) {
|
|
||||||
if try > 0 {
|
|
||||||
<-time.After(time.Duration(try) * time.Duration(dur))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
25
package-lock.json
generated
Normal file
25
package-lock.json
generated
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "artemis",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.4.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
|
||||||
|
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
|
||||||
|
"dev": true,
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
package.json
Normal file
5
package.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.4.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user