refactor.

This commit is contained in:
Abhinav Raut
2024-07-24 03:00:54 +05:30
parent 5e00558e9b
commit 8b763fb167
37 changed files with 457 additions and 285 deletions

View File

@@ -1,8 +1,6 @@
package main
import (
"net/http"
"github.com/zerodha/fastglue"
)
@@ -12,7 +10,7 @@ func handleGetCannedResponses(r *fastglue.Request) error {
)
c, err := app.cannedRespManager.GetAll()
if err != nil {
return r.SendErrorEnvelope(http.StatusInternalServerError, "Error fetching canned responses", nil, "")
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(c)
}

View File

@@ -15,18 +15,20 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.POST("/api/login", handleLogin)
g.GET("/api/logout", handleLogout)
g.GET("/api/settings", auth(handleGetSettings))
// Conversation.
g.GET("/api/conversations/all", auth(handleGetAllConversations, "conversations:all"))
g.GET("/api/conversations/team", auth(handleGetTeamConversations, "conversations:team"))
g.GET("/api/conversations/assigned", auth(handleGetAssignedConversations, "conversations:assigned"))
g.GET("/api/conversations/all", auth(handleGetAllConversations, "conversation:all"))
g.GET("/api/conversations/team", auth(handleGetTeamConversations, "conversation:team"))
g.GET("/api/conversations/assigned", auth(handleGetAssignedConversations, "conversation:assigned"))
g.GET("/api/conversations/{uuid}", auth(handleGetConversation))
g.GET("/api/conversations/{uuid}/participants", auth(handleGetConversationParticipants))
g.PUT("/api/conversations/{uuid}/last-seen", auth(handleUpdateAssigneeLastSeen))
g.PUT("/api/conversations/{uuid}/assignee/user", auth(handleUpdateUserAssignee))
g.PUT("/api/conversations/{uuid}/assignee/team", auth(handleUpdateTeamAssignee))
g.PUT("/api/conversations/{uuid}/priority", auth(handleUpdatePriority))
g.PUT("/api/conversations/{uuid}/status", auth(handleUpdateStatus))
g.PUT("/api/conversations/{uuid}/priority", auth(handleUpdatePriority, "conversation:edit_priority"))
g.PUT("/api/conversations/{uuid}/status", auth(handleUpdateStatus, "conversation:edit_status"))
g.POST("/api/conversations/{uuid}/tags", auth(handleAddConversationTags))
// Message.
@@ -45,23 +47,23 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.POST("/api/file/upload", auth(handleFileUpload))
// User.
g.GET("/api/users/me", auth(handleGetCurrentUser))
g.GET("/api/users", auth(handleGetUsers))
g.GET("/api/users/{id}", auth(handleGetUser))
g.PUT("/api/users/{id}", auth(handleUpdateUser))
g.POST("/api/users", auth(handleCreateUser))
g.GET("/api/users/me", auth(handleGetCurrentUser, "users:manage"))
g.GET("/api/users", auth(handleGetUsers, "users:manage"))
g.GET("/api/users/{id}", auth(handleGetUser, "users:manage"))
g.PUT("/api/users/{id}", auth(handleUpdateUser, "users:manage"))
g.POST("/api/users", auth(handleCreateUser, "users:manage"))
// Team.
g.GET("/api/teams", auth(handleGetTeams))
g.GET("/api/teams/{id}", auth(handleGetTeam))
g.PUT("/api/teams/{id}", auth(handleUpdateTeam))
g.POST("/api/teams", auth(handleCreateTeam))
g.GET("/api/teams", auth(handleGetTeams, "teams:manage"))
g.GET("/api/teams/{id}", auth(handleGetTeam, "teams:manage"))
g.PUT("/api/teams/{id}", auth(handleUpdateTeam, "teams:manage"))
g.POST("/api/teams", auth(handleCreateTeam, "teams:manage"))
// Tags.
g.GET("/api/tags", auth(handleGetTags))
// i18n.
g.GET("/api/lang/{lang}", handleGetI18nLang)
g.GET("/api/lang/{lang}", auth(handleGetI18nLang))
// Websocket.
g.GET("/api/ws", auth(func(r *fastglue.Request) error {
@@ -69,27 +71,27 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
}))
// Automation rules.
g.GET("/api/automation/rules", handleGetAutomationRules)
g.GET("/api/automation/rules/{id}", handleGetAutomationRule)
g.POST("/api/automation/rules", handleCreateAutomationRule)
g.PUT("/api/automation/rules/{id}/toggle", handleToggleAutomationRule)
g.PUT("/api/automation/rules/{id}", handleUpdateAutomationRule)
g.DELETE("/api/automation/rules/{id}", handleDeleteAutomationRule)
g.GET("/api/automation/rules", auth(handleGetAutomationRules, "automations:manage"))
g.GET("/api/automation/rules/{id}", auth(handleGetAutomationRule, "automations:manage"))
g.POST("/api/automation/rules", auth(handleCreateAutomationRule, "automations:manage"))
g.PUT("/api/automation/rules/{id}/toggle", auth(handleToggleAutomationRule, "automations:manage"))
g.PUT("/api/automation/rules/{id}", auth(handleUpdateAutomationRule, "automations:manage"))
g.DELETE("/api/automation/rules/{id}", auth(handleDeleteAutomationRule, "automations:manage"))
// Inboxes.
g.GET("/api/inboxes", handleGetInboxes)
g.GET("/api/inboxes/{id}", handleGetInbox)
g.POST("/api/inboxes", handleCreateInbox)
g.PUT("/api/inboxes/{id}/toggle", handleToggleInbox)
g.PUT("/api/inboxes/{id}", handleUpdateInbox)
g.DELETE("/api/inboxes/{id}", handleDeleteInbox)
g.GET("/api/inboxes", auth(handleGetInboxes, "inboxes:manage"))
g.GET("/api/inboxes/{id}", auth(handleGetInbox, "inboxes:manage"))
g.POST("/api/inboxes", auth(handleCreateInbox, "inboxes:manage"))
g.PUT("/api/inboxes/{id}/toggle", auth(handleToggleInbox, "inboxes:manage"))
g.PUT("/api/inboxes/{id}", auth(handleUpdateInbox, "inboxes:manage"))
g.DELETE("/api/inboxes/{id}", auth(handleDeleteInbox, "inboxes:manage"))
// Roles.
g.GET("/api/roles", handleGetRoles)
g.GET("/api/roles/{id}", handleGetRole)
g.POST("/api/roles", handleCreateRole)
g.PUT("/api/roles/{id}", handleUpdateRole)
g.DELETE("/api/roles/{id}", handleDeleteRole)
g.GET("/api/roles", auth(handleGetRoles, "roles:manage"))
g.GET("/api/roles/{id}", auth(handleGetRole, "roles:manage"))
g.POST("/api/roles", auth(handleCreateRole, "roles:manage"))
g.PUT("/api/roles/{id}", auth(handleUpdateRole, "roles:manage"))
g.DELETE("/api/roles/{id}", auth(handleDeleteRole, "roles:manage"))
// Dashboard.
g.GET("/api/dashboard/me/counts", auth(handleUserDashboardCounts))

View File

@@ -3,6 +3,7 @@ package main
import (
"cmp"
"context"
"encoding/json"
"fmt"
"log"
"os"
@@ -10,7 +11,6 @@ import (
"github.com/abhinavxd/artemis/internal/attachment"
"github.com/abhinavxd/artemis/internal/attachment/stores/s3"
uauth "github.com/abhinavxd/artemis/internal/auth"
"github.com/abhinavxd/artemis/internal/autoassigner"
"github.com/abhinavxd/artemis/internal/automation"
"github.com/abhinavxd/artemis/internal/cannedresp"
@@ -23,6 +23,7 @@ import (
notifier "github.com/abhinavxd/artemis/internal/notification"
emailnotifier "github.com/abhinavxd/artemis/internal/notification/providers/email"
"github.com/abhinavxd/artemis/internal/role"
"github.com/abhinavxd/artemis/internal/setting"
"github.com/abhinavxd/artemis/internal/tag"
"github.com/abhinavxd/artemis/internal/team"
"github.com/abhinavxd/artemis/internal/template"
@@ -30,8 +31,9 @@ import (
"github.com/abhinavxd/artemis/internal/ws"
"github.com/jmoiron/sqlx"
"github.com/knadh/go-i18n"
"github.com/knadh/koanf/parsers/json"
kjson "github.com/knadh/koanf/parsers/json"
"github.com/knadh/koanf/parsers/toml"
"github.com/knadh/koanf/providers/confmap"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/providers/posflag"
"github.com/knadh/koanf/providers/rawbytes"
@@ -122,10 +124,38 @@ func initFS() stuffbin.FileSystem {
log.Fatalf("error initializing FS: %v", err)
}
}
fmt.Println(fs.List())
return fs
}
// loadSettings loads settings from the DB into the given Koanf map.
func loadSettings(m *setting.Manager) {
j, err := m.GetAllJSON()
if err != nil {
log.Fatalf("error parsing settings from DB: %v", err)
}
// Setting keys are dot separated, eg: app.favicon_url. Unflatten them into
// nested maps {app: {favicon_url}}.
var out map[string]interface{}
if err := json.Unmarshal(j, &out); err != nil {
log.Fatalf("error unmarshalling settings from DB: %v", err)
}
if err := ko.Load(confmap.Provider(out, "."), nil); err != nil {
log.Fatalf("error parsing settings from DB: %v", err)
}
}
func initSettingsManager(db *sqlx.DB) *setting.Manager {
s, err := setting.New(setting.Opts{
DB: db,
})
if err != nil {
log.Fatalf("error initializing setting manager: %v", err)
}
return s
}
// initSessionManager initializes and returns a simplesessions.Manager instance.
func initSessionManager(rd *redis.Client) *simplesessions.Manager {
maxAge := ko.Duration("app.session.cookie_max_age")
@@ -159,8 +189,8 @@ func initUserManager(i18n *i18n.I18n, DB *sqlx.DB) *user.Manager {
return mgr
}
func initConversations(i18n *i18n.I18n, hub *ws.Hub, db *sqlx.DB) *conversation.Manager {
c, err := conversation.New(hub, i18n, conversation.Opts{
func initConversations(i18n *i18n.I18n, hub *ws.Hub, n notifier.Notifier, db *sqlx.DB) *conversation.Manager {
c, err := conversation.New(hub, i18n, n, conversation.Opts{
DB: db,
Lo: initLogger("conversation_manager"),
ReferenceNumPattern: ko.String("app.constants.conversation_reference_number_pattern"),
@@ -328,22 +358,12 @@ func initAutomationEngine(db *sqlx.DB, userManager *user.Manager) *automation.En
return engine
}
func initAutoAssignmentEngine(teamMgr *team.Manager, userMgr *user.Manager, convMgr *conversation.Manager, msgMgr *message.Manager,
notifier notifier.Notifier, hub *ws.Hub) *autoassigner.Engine {
var lo = initLogger("auto_assignment_engine")
engine, err := autoassigner.New(teamMgr, userMgr, convMgr, msgMgr, notifier, hub, lo)
func initAutoAssigner(teamManager *team.Manager, conversationManager *conversation.Manager) *autoassigner.Engine {
e, err := autoassigner.New(teamManager, conversationManager, initLogger("autoassigner"))
if err != nil {
log.Fatalf("error initializing auto assignment engine: %v", err)
log.Fatalf("error initializing auto assigner engine: %v", err)
}
return engine
}
func initAuthManager(db *sqlx.DB) *uauth.Manager {
manager, err := uauth.New(db, &logf.Logger{})
if err != nil {
log.Fatalf("error initializing rbac enginer: %v", err)
}
return manager
return e
}
func initNotifier(userStore notifier.UserStore, templateRenderer notifier.TemplateRenderer) notifier.Notifier {
@@ -366,7 +386,7 @@ func initEmailInbox(inboxRecord imodels.Inbox, store inbox.MessageStore) (inbox.
var config email.Config
// Load JSON data into Koanf.
if err := ko.Load(rawbytes.Provider([]byte(inboxRecord.Config)), json.Parser()); err != nil {
if err := ko.Load(rawbytes.Provider([]byte(inboxRecord.Config)), kjson.Parser()); err != nil {
log.Fatalf("error loading config: %v", err)
}

View File

@@ -32,7 +32,6 @@ func handleLogin(r *fastglue.Request) error {
"first_name": user.FirstName,
"last_name": user.LastName,
"team_id": user.TeamID,
"permissions": user.Permissions,
}); err != nil {
app.lo.Error("error setting values in session", "error", err)
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("user.errorAcquiringSession"), nil))

View File

@@ -9,7 +9,6 @@ import (
"time"
"github.com/abhinavxd/artemis/internal/attachment"
uauth "github.com/abhinavxd/artemis/internal/auth"
"github.com/abhinavxd/artemis/internal/automation"
"github.com/abhinavxd/artemis/internal/cannedresp"
"github.com/abhinavxd/artemis/internal/contact"
@@ -17,6 +16,7 @@ import (
"github.com/abhinavxd/artemis/internal/inbox"
"github.com/abhinavxd/artemis/internal/message"
"github.com/abhinavxd/artemis/internal/role"
"github.com/abhinavxd/artemis/internal/setting"
"github.com/abhinavxd/artemis/internal/tag"
"github.com/abhinavxd/artemis/internal/team"
"github.com/abhinavxd/artemis/internal/upload"
@@ -25,6 +25,7 @@ import (
"github.com/knadh/go-i18n"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
"github.com/redis/go-redis/v9"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
"github.com/zerodha/logf"
@@ -45,8 +46,10 @@ const (
type App struct {
constants consts
fs stuffbin.FileSystem
rdb *redis.Client
i18n *i18n.I18n
lo *logf.Logger
settingsManager *setting.Manager
roleManager *role.Manager
contactManager *contact.Manager
userManager *user.Manager
@@ -54,7 +57,6 @@ type App struct {
sessManager *simplesessions.Manager
tagManager *tag.Manager
messageManager *message.Manager
auth *uauth.Manager
inboxManager *inbox.Manager
uploadManager *upload.Manager
attachmentManager *attachment.Manager
@@ -70,6 +72,11 @@ func main() {
// Load the config files into Koanf.
initConfig(ko)
// Load app settings into Koanf.
db := initDB()
settingManager := initSettingsManager(db)
loadSettings(settingManager)
var (
shutdownCh = make(chan struct{})
ctx, stop = signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
@@ -77,8 +84,7 @@ func main() {
fs = initFS()
i18n = initI18n(fs)
lo = initLogger("artemis")
rd = initRedis()
db = initDB()
rdb = initRedis()
templateManager = initTemplateManager(db)
attachmentManager = initAttachmentsManager(db)
contactManager = initContactManager(db)
@@ -86,10 +92,10 @@ func main() {
teamManager = initTeamManager(db)
userManager = initUserManager(i18n, db)
notifier = initNotifier(userManager, templateManager)
conversationManager = initConversations(i18n, wsHub, db)
conversationManager = initConversations(i18n, wsHub, notifier, db)
automationEngine = initAutomationEngine(db, userManager)
messageManager = initMessages(db, wsHub, userManager, teamManager, contactManager, attachmentManager, conversationManager, inboxManager, automationEngine, templateManager)
autoAssignerEngine = initAutoAssignmentEngine(teamManager, userManager, conversationManager, messageManager, notifier, wsHub)
autoassigner = initAutoAssigner(teamManager, conversationManager)
)
// Set message store for conversation manager.
@@ -106,8 +112,8 @@ func main() {
automationEngine.SetConversationStore(conversationManager)
go automationEngine.Serve(ctx)
// Start conversation auto assigner engine.
go autoAssignerEngine.Serve(ctx, ko.MustDuration("autoassigner.assign_interval"))
// Start conversation auto assigner.
go autoassigner.Serve(ctx, ko.MustDuration("autoassigner.assign_interval"))
// Start inserting incoming messages from all active inboxes and dispatch pending outgoing messages.
go messageManager.StartDBInserts(ctx, ko.MustInt("message.reader_concurrency"))
@@ -116,8 +122,10 @@ func main() {
// Init the app
var app = &App{
lo: lo,
rdb: rdb,
fs: fs,
i18n: i18n,
settingsManager: settingManager,
contactManager: contactManager,
inboxManager: inboxManager,
userManager: userManager,
@@ -126,11 +134,10 @@ func main() {
conversationManager: conversationManager,
messageManager: messageManager,
automationEngine: automationEngine,
constants: initConstants(),
roleManager: initRoleManager(db),
auth: initAuthManager(db),
constants: initConstants(),
tagManager: initTags(db),
sessManager: initSessionManager(rd),
sessManager: initSessionManager(rdb),
cannedRespManager: initCannedResponse(db),
}
@@ -159,7 +166,7 @@ func main() {
log.Printf("%sShutting down the server. Please wait.\x1b[0m", colourRed)
time.Sleep(5 * time.Second)
time.Sleep(1 * time.Second)
// Signal to shutdown the server
shutdownCh <- struct{}{}

View File

@@ -16,11 +16,12 @@ func handleGetMessages(r *fastglue.Request) error {
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
)
msgs, err := app.messageManager.GetConversationMessages(uuid)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Generate URLs for all attachments.
for i := range msgs {
for j := range msgs[i].Attachments {
msgs[i].Attachments[j].URL = app.attachmentManager.Store.GetURL(msgs[i].Attachments[j].UUID)
@@ -34,18 +35,17 @@ func handleGetMessage(r *fastglue.Request) error {
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
)
msgs, err := app.messageManager.GetMessage(uuid)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Generate URLs for each of the attachments.
for i := range msgs {
for j := range msgs[i].Attachments {
msgs[i].Attachments[j].URL = app.attachmentManager.Store.GetURL(msgs[i].Attachments[j].UUID)
}
}
return r.SendEnvelope(msgs)
}
@@ -93,7 +93,6 @@ func handleSendMessage(r *fastglue.Request) error {
Content: string(content),
ContentType: message.ContentTypeHTML,
Private: private,
Meta: "{}",
Attachments: attachments,
}
@@ -110,5 +109,5 @@ func handleSendMessage(r *fastglue.Request) error {
// Send WS update.
app.messageManager.BroadcastNewConversationMessage(msg, trimmedMessage)
return r.SendEnvelope("Message sent")
return r.SendEnvelope(true)
}

View File

@@ -1,7 +1,6 @@
package main
import (
"fmt"
"net/http"
"github.com/abhinavxd/artemis/internal/envelope"
@@ -11,7 +10,7 @@ import (
"github.com/zerodha/simplesessions/v3"
)
func auth(handler fastglue.FastRequestHandler, perms ...string) fastglue.FastRequestHandler {
func auth(handler fastglue.FastRequestHandler, requiredPerms ...string) fastglue.FastRequestHandler {
return func(r *fastglue.Request) error {
var (
app = r.Context.(*App)
@@ -37,14 +36,20 @@ func auth(handler fastglue.FastRequestHandler, perms ...string) fastglue.FastReq
firstName, _ = sess.String(sessVals["first_name"], nil)
lastName, _ = sess.String(sessVals["last_name"], nil)
teamID, _ = sess.Int(sessVals["team_id"], nil)
// TODO: FIX.
p, _ = sess.Bytes(sessVals["permissions"], nil)
)
fmt.Printf("%+v perms ", p)
if userID > 0 {
// Set user in the request context.
// Fetch user perms.
userPerms, err := app.userManager.GetPermissions(userID)
if err != nil {
return sendErrorEnvelope(r, err)
}
if !hasPerms(userPerms, requiredPerms) {
return r.SendErrorEnvelope(http.StatusUnauthorized, "You don't have permissions to access this page.", nil, envelope.PermissionError)
}
// User is loggedin, Set user in the request context.
r.RequestCtx.SetUserValue("user", umodels.User{
ID: userID,
Email: email,
@@ -53,14 +58,6 @@ func auth(handler fastglue.FastRequestHandler, perms ...string) fastglue.FastReq
TeamID: teamID,
})
// Check permission.
for _, perm := range perms {
hasPerm, err := app.auth.HasPermission(userID, perm)
if err != nil || !hasPerm {
return r.SendErrorEnvelope(http.StatusUnauthorized, "You don't have permission to access this page.", nil, envelope.PermissionError)
}
}
return handler(r)
}
@@ -71,6 +68,25 @@ func auth(handler fastglue.FastRequestHandler, perms ...string) fastglue.FastReq
}
}
// hasPerms checks if all requiredPerms exist in userPerms.
func hasPerms(userPerms []string, requiredPerms []string) bool {
userPermMap := make(map[string]bool)
// make map for user's permissions for quick look up
for _, perm := range userPerms {
userPermMap[perm] = true
}
// iterate through required perms and if not found in userPermMap return false
for _, requiredPerm := range requiredPerms {
if _, ok := userPermMap[requiredPerm]; !ok {
return false
}
}
return true
}
// authPage middleware makes sure user is logged in to access the page
// else redirects to login page.
func authPage(handler fastglue.FastRequestHandler) fastglue.FastRequestHandler {

14
cmd/settings.go Normal file
View File

@@ -0,0 +1,14 @@
package main
import "github.com/zerodha/fastglue"
func handleGetSettings(r *fastglue.Request) error {
var (
app = r.Context.(*App)
)
teams, err := app.settingsManager.GetAll()
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(teams)
}

View File

@@ -17,6 +17,11 @@ const sidebarNavItems = [
title: 'Automations',
href: '/admin/automations',
description: 'Create automations and time triggers'
},
{
title: 'Notifications',
href: '/admin/notifications',
description: 'Manage notifications for your agents'
}
]
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div v-if="showTable">
<div>
<div class="flex justify-between mb-5">
<div>
<span class="admin-title">Inboxes</span>
@@ -13,7 +13,7 @@
<DataTable :columns="columns" :data="data" />
</div>
</div>
<div v-else>
<div>
<router-view></router-view>
</div>
</template>

View File

@@ -0,0 +1,23 @@
<template>
<div>
<div class="flex flex-col box border p-5 mb-5" v-for="notification in notifications" :key="notification">
<div class="flex items-center space-x-2">
<Switch id="airplane-mode" />
<Label for="airplane-mode">{{ notification.name }}</Label>
</div>
</div>
</div>
</template>
<script setup>
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
defineProps({
notifications: {
type: Array,
required: true,
},
})
</script>

View File

@@ -0,0 +1,23 @@
<template>
<div class="flex justify-between mb-5">
<div>
<span class="admin-title">Notifications</span>
<p class="text-muted-foreground text-sm">Manage notifications.</p>
</div>
</div>
<div>
<List :notifications="notifications" />
</div>
</template>
<script setup>
import List from './NotificationList.vue'
const notifications = [
{
name: "New conversation created"
},
{
name: "New conversation created"
}
]
</script>

View File

@@ -54,7 +54,7 @@
<FormItem>
<FormLabel>Select role</FormLabel>
<FormControl>
<TagsInput v-model="roles" class="px-0 gap-0">
<TagsInput v-model="roles" class="px-0 gap-0 shadow-sm">
<div class="flex gap-2 flex-wrap items-center px-3">
<TagsInputItem v-for="item in roles" :key="item" :value="item">
<TagsInputItemText />
@@ -73,17 +73,17 @@
<CommandList position="popper" class="w-[--radix-popper-anchor-width] rounded-md mt-2 border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2">
<CommandEmpty />
<CommandGroup>
<CommandItem v-for="framework in filteredFrameworks" :key="framework.value" :value="framework.label" @select.prevent="(ev) => {
<CommandItem v-for="user in filteredUsers" :key="user.value" :value="user.label" @select.prevent="(ev) => {
if (typeof ev.detail.value === 'string') {
searchTerm = ''
roles.push(ev.detail.value)
}
if (filteredFrameworks.length === 0) {
if (filteredUsers.length === 0) {
open = false
}
}">
{{ framework.label }}
{{ user.label }}
</CommandItem>
</CommandGroup>
</CommandList>
@@ -146,7 +146,7 @@ const roles = ref([])
const open = ref(false)
const searchTerm = ref('')
const filteredFrameworks = computed(() => frameworks.filter(i => !roles.value.includes(i.label)))
const filteredUsers = computed(() => frameworks.filter(i => !roles.value.includes(i.label)))
const props = defineProps({
initialValues: {

View File

@@ -9,6 +9,7 @@ import Team from '@/components/admin/team/TeamSection.vue'
import Teams from '@/components/admin/team/teams/TeamsCard.vue'
import Users from '@/components/admin/team/users/UsersCard.vue'
import Automation from '@/components/admin/automation/Automation.vue'
import NotificationTab from '@/components/admin/notification/NotificationTab.vue'
const routes = [
{
@@ -131,6 +132,27 @@ const routes = [
},
]
},
{
path: '/admin/notifications',
name: 'notifications',
component: AdminView,
children: [
{
path: '',
component: NotificationTab
},
{
path: ':id/edit',
props: true,
component: () => import('@/components/admin/automation/CreateOrEditRule.vue')
},
{
path: 'new',
props: true,
component: () => import('@/components/admin/automation/CreateOrEditRule.vue')
},
]
},
// Fallback to dashboard.
{
path: '/:pathMatch(.*)*',

View File

@@ -1,7 +1,7 @@
<template>
<!-- Resizable panel last resize value is stored in the localstorage -->
<ResizablePanelGroup direction="horizontal" auto-save-id="conversation.vue.resizable.panel">
<ResizablePanel :min-size="20" :default-size="23" :max-size="23">
<ResizablePanel :min-size="23" :default-size="23" :max-size="40">
<ConversationList></ConversationList>
</ResizablePanel>
<ResizableHandle />

1
go.mod
View File

@@ -47,6 +47,7 @@ require (
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect
github.com/klauspost/compress v1.17.8 // indirect
github.com/knadh/koanf/maps v0.1.1 // indirect
github.com/knadh/koanf/providers/confmap v0.1.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect

2
go.sum
View File

@@ -75,6 +75,8 @@ github.com/knadh/koanf/parsers/json v0.1.0 h1:dzSZl5pf5bBcW0Acnu20Djleto19T0CfHc
github.com/knadh/koanf/parsers/json v0.1.0/go.mod h1:ll2/MlXcZ2BfXD6YJcjVFzhG9P0TdJ207aIBKQhV2hY=
github.com/knadh/koanf/parsers/toml v0.1.0 h1:S2hLqS4TgWZYj4/7mI5m1CQQcWurxUz6ODgOub/6LCI=
github.com/knadh/koanf/parsers/toml v0.1.0/go.mod h1:yUprhq6eo3GbyVXFFMdbfZSo928ksS+uo0FFqNMnO18=
github.com/knadh/koanf/providers/confmap v0.1.0 h1:gOkxhHkemwG4LezxxN8DMOFopOPghxRVp7JbIvdvqzU=
github.com/knadh/koanf/providers/confmap v0.1.0/go.mod h1:2uLhxQzJnyHKfxG927awZC7+fyHFdQkd697K4MdLnIU=
github.com/knadh/koanf/providers/file v0.1.0 h1:fs6U7nrV58d3CFAFh8VTde8TM262ObYf3ODrc//Lp+c=
github.com/knadh/koanf/providers/file v0.1.0/go.mod h1:rjJ/nHQl64iYCtAW2QQnF0eSmDEX/YZ/eNFj5yR6BvA=
github.com/knadh/koanf/providers/posflag v0.1.0 h1:mKJlLrKPcAP7Ootf4pBZWJ6J+4wHYujwipe7Ie3qW6U=

View File

@@ -1,19 +0,0 @@
{
"_.code": "fr",
"_.name": "Français (fr)",
"globals.entities.user": "utilisateur",
"globals.entities.conversations": "conversations",
"user.invalidEmailPassword": "Email ou mot de passe invalide.",
"user.errorAcquiringSession": "Erreur lors de l'acquisition de la session",
"user.errorSettingSession": "Erreur lors de la définition de la session",
"conversations.emptyState": "Aucune conversation trouvée.",
"conversatons.adjustFilters": "Essayez de modifier vos filtres.",
"globals.messages.errorCreating": "Erreur lors de la création de {name}",
"globals.messages.errorDeleting": "Erreur lors de la suppression de {name}",
"globals.messages.errorFetching": "Erreur lors de la récupération de {name}",
"globals.messages.notFound": "Non trouvé",
"globals.messages.internalError": "Erreur interne du serveur",
"globals.messages.done": "Fait",
"globals.messages.emptyState": "Rien ici"
}

View File

@@ -1,21 +0,0 @@
{
"_.code": "hi",
"_.name": "हिन्दी (hi)",
"globals.entities.user": "उपयोगकर्ता",
"globals.entities.conversations": "वार्तालाप",
"navbar.dashboard": "डैशबोर्ड",
"navbar.conversations": "वार्तालाप",
"navbar.account": "खाता",
"user.invalidEmailPassword": "अमान्य ईमेल या पासवर्ड।",
"user.errorAcquiringSession": "सत्र प्राप्त करने में त्रुटि",
"user.errorSettingSession": "सत्र सेट करने में त्रुटि",
"conversations.emptyState": "कोई वार्तालाप नहीं मिला।",
"globals.messages.adjustFilters": "अपने फ़िल्टर समायोजित करने का प्रयास करें।",
"globals.messages.errorCreating": "{name} बनाने में त्रुटि",
"globals.messages.errorDeleting": "{name} हटाने में त्रुटि",
"globals.messages.errorFetching": "{name} लाने में त्रुटि",
"globals.messages.notFound": "नहीं मिला",
"globals.messages.internalError": "आंतरिक सर्वर त्रुटि",
"globals.messages.done": "समाप्त",
"globals.messages.emptyState": "यहाँ कुछ भी नहीं है"
}

View File

@@ -1,24 +0,0 @@
package auth
import (
"github.com/jmoiron/sqlx"
"github.com/zerodha/logf"
)
type Manager struct {
lo *logf.Logger
}
type ConversationStore interface {
GetAssigneedUserID(conversationID int) (int, error)
}
func New(db *sqlx.DB, lo *logf.Logger) (*Manager, error) {
return &Manager{
lo: lo,
}, nil
}
func (e *Manager) HasPermission(userID int, perm string) (bool, error) {
return true, nil
}

View File

@@ -1,8 +0,0 @@
package models
type AuthUser struct {
ID int
FirstName string
LastName string
Email string
}

View File

@@ -1,64 +1,56 @@
// Package autoassigner automatically assigning unassigned conversations to team agents in a round-robin fashion.
// Continuously assigns conversations at regular intervals.
package autoassigner
import (
"context"
"errors"
"strconv"
"sync"
"time"
"github.com/abhinavxd/artemis/internal/conversation"
"github.com/abhinavxd/artemis/internal/conversation/models"
"github.com/abhinavxd/artemis/internal/message"
notifier "github.com/abhinavxd/artemis/internal/notification"
"github.com/abhinavxd/artemis/internal/systeminfo"
"github.com/abhinavxd/artemis/internal/team"
"github.com/abhinavxd/artemis/internal/user"
"github.com/abhinavxd/artemis/internal/ws"
"github.com/mr-karan/balance"
"github.com/zerodha/logf"
)
const (
roundRobinDefaultWeight = 1
var (
ErrTeamNotFound = errors.New("team not found")
)
// Engine handles the assignment of unassigned conversations to agents of a team using a round-robin strategy.
// Engine represents a manager for assigning unassigned conversations
// to team agents in a round-robin pattern.
type Engine struct {
teamRoundRobinBalancer map[int]*balance.Balance
roundRobinBalancer map[int]*balance.Balance
// Mutex to protect the balancer map
mu sync.Mutex
userIDMap map[string]int
convMgr *conversation.Manager
teamMgr *team.Manager
userMgr *user.Manager
msgMgr *message.Manager
conversationManager *conversation.Manager
teamManager *team.Manager
lo *logf.Logger
hub *ws.Hub
notifier notifier.Notifier
}
// New creates a new instance of the Engine.
func New(teamMgr *team.Manager, userMgr *user.Manager, convMgr *conversation.Manager, msgMgr *message.Manager,
notifier notifier.Notifier, hub *ws.Hub, lo *logf.Logger) (*Engine, error) {
// New initializes a new Engine instance, set up with the provided team manager,
// conversation manager, and logger.
func New(teamManager *team.Manager, conversationManager *conversation.Manager, lo *logf.Logger) (*Engine, error) {
var e = Engine{
notifier: notifier,
convMgr: convMgr,
teamMgr: teamMgr,
msgMgr: msgMgr,
userMgr: userMgr,
conversationManager: conversationManager,
teamManager: teamManager,
lo: lo,
hub: hub,
mu: sync.Mutex{},
userIDMap: map[string]int{},
}
balancer, err := e.populateBalancerPool()
balancer, err := e.populateTeamBalancer()
if err != nil {
return nil, err
}
e.teamRoundRobinBalancer = balancer
e.roundRobinBalancer = balancer
return &e, nil
}
// Serve initiates the conversation assignment process and is to be invoked as a goroutine.
// This function continuously assigns unassigned conversations to agents at regular intervals.
func (e *Engine) Serve(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
@@ -74,32 +66,33 @@ func (e *Engine) Serve(ctx context.Context, interval time.Duration) {
}
}
// RefreshBalancer updates the round-robin balancer with the latest user and team data.
func (e *Engine) RefreshBalancer() error {
e.mu.Lock()
defer e.mu.Unlock()
balancer, err := e.populateBalancerPool()
balancer, err := e.populateTeamBalancer()
if err != nil {
e.lo.Error("Error updating team balancer pool", "error", err)
return err
}
e.teamRoundRobinBalancer = balancer
e.roundRobinBalancer = balancer
return nil
}
// populateBalancerPool populates the team balancer bool with the team members.
func (e *Engine) populateBalancerPool() (map[int]*balance.Balance, error) {
// populateTeamBalancer populates the team balancer pool with the team members.
func (e *Engine) populateTeamBalancer() (map[int]*balance.Balance, error) {
var (
balancer = make(map[int]*balance.Balance)
teams, err = e.teamMgr.GetAll()
)
teams, err := e.teamManager.GetAll()
if err != nil {
return nil, err
}
for _, team := range teams {
users, err := e.teamMgr.GetTeamMembers(team.Name)
users, err := e.teamManager.GetTeamMembers(team.Name)
if err != nil {
return nil, err
}
@@ -109,17 +102,16 @@ func (e *Engine) populateBalancerPool() (map[int]*balance.Balance, error) {
if _, ok := balancer[team.ID]; !ok {
balancer[team.ID] = balance.NewBalance()
}
// FIXME: Balancer only supports strings, using a map to store DB ids.
balancer[team.ID].Add(user.UUID, roundRobinDefaultWeight)
e.userIDMap[user.UUID] = user.ID
balancer[team.ID].Add(strconv.Itoa(user.ID), 1)
}
}
return balancer, nil
}
// assignConversations fetches unassigned conversations and assigns them to users.
// assignConversations function fetches conversations that have been assigned to teams but not to any individual user,
// and then proceeds to assign them to team members based on a round-robin strategy.
func (e *Engine) assignConversations() error {
unassigned, err := e.convMgr.GetUnassigned()
unassigned, err := e.conversationManager.GetUnassigned()
if err != nil {
return err
}
@@ -128,46 +120,34 @@ func (e *Engine) assignConversations() error {
e.lo.Debug("found unassigned conversations", "count", len(unassigned))
}
// Get system user, all actions here are done on behalf of the system user.
systemUser, err := e.userMgr.GetUser(0, systeminfo.SystemUserUUID)
if err != nil {
return err
}
for _, conversation := range unassigned {
// Get user uuid from the pool.
userUUID := e.getUser(conversation)
if userUUID == "" {
e.lo.Warn("user uuid not found for round robin assignment", "team_id", conversation.AssignedTeamID.Int)
uid, err := e.getUserFromPool(conversation)
if err != nil {
e.lo.Error("error fetching user from balancer pool", "error", err)
continue
}
// Get user ID from the map.
// FIXME: Balance only supports strings.
userID, ok := e.userIDMap[userUUID]
if !ok {
e.lo.Warn("user id not found for user uuid", "uuid", userUUID, "team_id", conversation.AssignedTeamID.Int)
continue
// Convert to int.
userID, err := strconv.Atoi(uid)
if err != nil {
e.lo.Error("error converting user id from string to int", "error", err)
}
// Update assignee and record the assigne change message.
if err := e.convMgr.UpdateUserAssignee(conversation.UUID, userID, systemUser); err != nil {
continue
}
// Send notification to the assignee.
e.notifier.SendAssignedConversationNotification([]string{userUUID}, conversation.UUID)
// Assign conversation to this user.
e.conversationManager.UpdateUserAssigneeBySystem(conversation.UUID, userID)
}
return nil
}
// getUser returns user uuid from the team balancer pool.
func (e *Engine) getUser(conversation models.Conversation) string {
pool, ok := e.teamRoundRobinBalancer[conversation.AssignedTeamID.Int]
// getUserFromPool returns user ID from the team balancer pool.
func (e *Engine) getUserFromPool(conversation models.Conversation) (string, error) {
e.mu.Lock()
defer e.mu.Unlock()
pool, ok := e.roundRobinBalancer[conversation.AssignedTeamID.Int]
if !ok {
e.lo.Warn("team not found in balancer", "id", conversation.AssignedTeamID.Int)
return ""
return "", ErrTeamNotFound
}
return pool.Get()
return pool.Get(), nil
}

View File

@@ -1,3 +1,5 @@
// Package automation provides a framework for automatically evaluating and applying
// rules to conversations based on events like new conversations, updates, and time triggers.
package automation
import (
@@ -23,12 +25,13 @@ var (
)
type Engine struct {
rules []models.Rule
rulesMu *sync.RWMutex
q queries
lo *logf.Logger
conversationStore ConversationStore
systemUser umodels.User
rulesMu *sync.RWMutex
rules []models.Rule
newConversationQ chan string
updateConversationQ chan string
}
@@ -79,20 +82,19 @@ func New(systemUser umodels.User, opt Opts) (*Engine, error) {
return e, nil
}
func (e *Engine) ReloadRules() {
e.lo.Debug("reloading automation engine rules")
e.rulesMu.Lock()
defer e.rulesMu.Unlock()
e.rules = e.queryRules()
}
func (e *Engine) SetConversationStore(store ConversationStore) {
e.conversationStore = store
}
func (e *Engine) ReloadRules() {
e.rulesMu.Lock()
defer e.rulesMu.Unlock()
e.lo.Debug("reloading automation engine rules")
e.rules = e.queryRules()
}
func (e *Engine) Serve(ctx context.Context) {
// TODO: Change to 1 hour.
ticker := time.NewTicker(30 * time.Second)
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
// Create separate semaphores for each channel
@@ -184,7 +186,6 @@ func (e *Engine) handleNewConversation(conversationUUID string, semaphore chan s
defer func() { <-semaphore }()
conversation, err := e.conversationStore.Get(conversationUUID)
if err != nil {
e.lo.Error("error could not fetch conversations to evaluate new conversation rules", "conversation_uuid", conversationUUID)
return
}
rules := e.filterRulesByType(models.RuleTypeNewConversation)
@@ -207,7 +208,6 @@ func (e *Engine) handleTimeTrigger(semaphore chan struct{}) {
thirtyDaysAgo := time.Now().Add(-30 * 24 * time.Hour)
conversations, err := e.conversationStore.GetRecentConversations(thirtyDaysAgo)
if err != nil {
e.lo.Error("error could not fetch conversations to evaluate time triggers")
return
}
rules := e.filterRulesByType(models.RuleTypeTimeTrigger)
@@ -257,6 +257,9 @@ func (e *Engine) queryRules() []models.Rule {
}
func (e *Engine) filterRulesByType(ruleType string) []models.Rule {
e.rulesMu.RLock()
defer e.rulesMu.RUnlock()
var filteredRules []models.Rule
for _, rule := range e.rules {
if rule.Type == ruleType {

View File

@@ -15,6 +15,7 @@ func (e *Engine) evalConversationRules(rules []models.Rule, conversation cmodels
e.lo.Debug("eval rule", "groups", len(rule.Groups), "rule", rule)
// At max there can be only 2 groups.
if len(rule.Groups) > 2 {
e.lo.Warn("more than 2 groups found for rules")
continue
}
var results []bool

View File

@@ -2,9 +2,9 @@ package cannedresp
import (
"embed"
"fmt"
"github.com/abhinavxd/artemis/internal/dbutil"
"github.com/abhinavxd/artemis/internal/envelope"
"github.com/jmoiron/sqlx"
"github.com/zerodha/logf"
)
@@ -50,8 +50,8 @@ func New(opts Opts) (*Manager, error) {
func (t *Manager) GetAll() ([]CannedResponse, error) {
var c []CannedResponse
if err := t.q.GetAll.Select(&c); err != nil {
t.lo.Error("fetching canned responses", "error", err)
return c, fmt.Errorf("error fetching canned responses")
t.lo.Error("error fetching canned responses", "error", err)
return c, envelope.NewError(envelope.GeneralError, "Error fetching canned responses", nil)
}
return c, nil
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/abhinavxd/artemis/internal/conversation/models"
"github.com/abhinavxd/artemis/internal/dbutil"
"github.com/abhinavxd/artemis/internal/envelope"
notifier "github.com/abhinavxd/artemis/internal/notification"
"github.com/abhinavxd/artemis/internal/stringutil"
umodels "github.com/abhinavxd/artemis/internal/user/models"
"github.com/abhinavxd/artemis/internal/ws"
@@ -74,6 +75,7 @@ const (
type MessageStore interface {
RecordAssigneeUserChange(conversationUUID string, assigneeID int, actor umodels.User) error
RecordAssigneeUserChangeBySystem(conversationUUID string, assigneeID int) error
RecordAssigneeTeamChange(conversationUUID string, teamID int, actor umodels.User) error
RecordPriorityChange(priority, conversationUUID string, actor umodels.User) error
RecordStatusChange(status, conversationUUID string, actor umodels.User) error
@@ -84,6 +86,7 @@ type Manager struct {
db *sqlx.DB
hub *ws.Hub
i18n *i18n.I18n
notifier notifier.Notifier
messageStore MessageStore
q queries
ReferenceNumPattern string
@@ -121,7 +124,7 @@ type queries struct {
DeleteTags *sqlx.Stmt `query:"delete-tags"`
}
func New(hub *ws.Hub, i18n *i18n.I18n, opts Opts) (*Manager, error) {
func New(hub *ws.Hub, i18n *i18n.I18n, notfier notifier.Notifier, opts Opts) (*Manager, error) {
var q queries
if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
return nil, err
@@ -130,6 +133,7 @@ func New(hub *ws.Hub, i18n *i18n.I18n, opts Opts) (*Manager, error) {
q: q,
hub: hub,
i18n: i18n,
notifier: notfier,
db: opts.DB,
lo: opts.Lo,
ReferenceNumPattern: opts.ReferenceNumPattern,
@@ -363,12 +367,30 @@ func (c *Manager) UpdateUserAssignee(uuid string, assigneeID int, actor umodels.
if err := c.UpdateAssignee(uuid, assigneeID, assigneeTypeUser); err != nil {
return envelope.NewError(envelope.GeneralError, "Error updating assignee", nil)
}
// Send notification to assignee.
c.notifier.SendAssignedConversationNotification([]int{assigneeID}, uuid)
if err := c.messageStore.RecordAssigneeUserChange(uuid, assigneeID, actor); err != nil {
return envelope.NewError(envelope.GeneralError, "Error recording assignee change", nil)
}
return nil
}
func (c *Manager) UpdateUserAssigneeBySystem(uuid string, assigneeID int) error {
if err := c.UpdateAssignee(uuid, assigneeID, assigneeTypeUser); err != nil {
return envelope.NewError(envelope.GeneralError, "Error updating assignee", nil)
}
// Send notification to assignee.
c.notifier.SendAssignedConversationNotification([]int{assigneeID}, uuid)
if err := c.messageStore.RecordAssigneeUserChangeBySystem(uuid, assigneeID); err != nil {
return envelope.NewError(envelope.GeneralError, "Error recording assignee change", nil)
}
return nil
}
func (c *Manager) UpdateTeamAssignee(uuid string, teamID int, actor umodels.User) error {
if err := c.UpdateAssignee(uuid, teamID, assigneeTypeTeam); err != nil {
return envelope.NewError(envelope.GeneralError, "Error updating assignee", nil)
@@ -386,13 +408,13 @@ func (c *Manager) UpdateAssignee(uuid string, assigneeID int, assigneeType strin
c.lo.Error("error updating conversation assignee", "error", err)
return fmt.Errorf("error updating assignee")
}
c.hub.BroadcastConversationPropertyUpdate(uuid, "assigned_user_uuid", strconv.Itoa(assigneeID))
c.hub.BroadcastConversationPropertyUpdate(uuid, "assigned_user_id", strconv.Itoa(assigneeID))
case assigneeTypeTeam:
if _, err := c.q.UpdateAssignedTeam.Exec(uuid, assigneeID); err != nil {
c.lo.Error("error updating conversation assignee", "error", err)
return fmt.Errorf("error updating assignee")
}
c.hub.BroadcastConversationPropertyUpdate(uuid, "assigned_team_uuid", strconv.Itoa(assigneeID))
c.hub.BroadcastConversationPropertyUpdate(uuid, "assigned_team_id", strconv.Itoa(assigneeID))
default:
return errors.New("invalid assignee type")
}

View File

@@ -17,8 +17,10 @@ import (
"github.com/abhinavxd/artemis/internal/contact"
"github.com/abhinavxd/artemis/internal/conversation"
"github.com/abhinavxd/artemis/internal/dbutil"
"github.com/abhinavxd/artemis/internal/envelope"
"github.com/abhinavxd/artemis/internal/inbox"
"github.com/abhinavxd/artemis/internal/message/models"
"github.com/abhinavxd/artemis/internal/systeminfo"
"github.com/abhinavxd/artemis/internal/team"
"github.com/abhinavxd/artemis/internal/template"
"github.com/abhinavxd/artemis/internal/user"
@@ -140,7 +142,7 @@ func (m *Manager) GetConversationMessages(uuid string) ([]models.Message, error)
var messages []models.Message
if err := m.q.GetMessages.Select(&messages, uuid); err != nil {
m.lo.Error("fetching messages from DB", "conversation_uuid", uuid, "error", err)
return nil, fmt.Errorf("error fetching messages")
return nil, envelope.NewError(envelope.GeneralError, "Error fetching messages", nil)
}
return messages, nil
}
@@ -149,7 +151,7 @@ func (m *Manager) GetMessage(uuid string) ([]models.Message, error) {
var messages []models.Message
if err := m.q.GetMessage.Select(&messages, uuid); err != nil {
m.lo.Error("fetching messages from DB", "conversation_uuid", uuid, "error", err)
return nil, fmt.Errorf("error fetching messages")
return nil, envelope.NewError(envelope.GeneralError, "Error fetching message", nil)
}
return messages, nil
}
@@ -337,6 +339,18 @@ func (m *Manager) RecordAssigneeUserChange(conversationUUID string, assigneeID i
return m.RecordActivity(ActivityAssignedUserChange, conversationUUID, assignee.FullName(), actor)
}
func (m *Manager) RecordAssigneeUserChangeBySystem(conversationUUID string, assigneeID int) error {
assignee, err := m.userMgr.GetUser(assigneeID, "")
if err != nil {
return err
}
system, err := m.userMgr.GetUser(0, systeminfo.SystemUserUUID)
if err != nil {
return err
}
return m.RecordActivity(ActivityAssignedUserChange, conversationUUID, assignee.FullName(), system)
}
func (m *Manager) RecordAssigneeTeamChange(conversationUUID string, teamID int, actor umodels.User) error {
team, err := m.teamMgr.GetTeam(teamID)
if err != nil {

View File

@@ -2,8 +2,8 @@ package notifier
// Notifier defines the interface for sending notifications.
type Notifier interface {
SendMessage(userUUIDs []string, subject, content string) error
SendAssignedConversationNotification(userUUIDs []string, convUUID string) error
SendMessage(userID []int, subject, content string) error
SendAssignedConversationNotification(userID []int, convUUID string) error
}
// TemplateRenderer defines the interface for rendering templates.

View File

@@ -12,8 +12,8 @@ import (
"github.com/zerodha/logf"
)
// Notifier handles email notifications.
type Notifier struct {
// Email
type Email struct {
lo *logf.Logger
from string
smtpPools []*smtppool.Pool
@@ -27,12 +27,12 @@ type Opts struct {
}
// New creates a new instance of email Notifier.
func New(smtpConfig []email.SMTPConfig, userStore notifier.UserStore, TemplateRenderer notifier.TemplateRenderer, opts Opts) (*Notifier, error) {
func New(smtpConfig []email.SMTPConfig, userStore notifier.UserStore, TemplateRenderer notifier.TemplateRenderer, opts Opts) (*Email, error) {
pools, err := email.NewSmtpPool(smtpConfig)
if err != nil {
return nil, err
}
return &Notifier{
return &Email{
lo: opts.Lo,
smtpPools: pools,
from: opts.FromEmail,
@@ -42,12 +42,12 @@ func New(smtpConfig []email.SMTPConfig, userStore notifier.UserStore, TemplateRe
}
// SendMessage sends an email using the default template to multiple users.
func (e *Notifier) SendMessage(userUUIDs []string, subject, content string) error {
func (e *Email) SendMessage(userIDs []int, subject, content string) error {
var recipientEmails []string
for i := 0; i < len(userUUIDs); i++ {
userEmail, err := e.userStore.GetEmail(0, userUUIDs[i])
for i := 0; i < len(userIDs); i++ {
userEmail, err := e.userStore.GetEmail(userIDs[i], "")
if err != nil {
e.lo.Error("error fetching user email for user uuid", "error", err)
e.lo.Error("error fetching user email", "error", err)
return err
}
recipientEmails = append(recipientEmails, userEmail)
@@ -80,15 +80,15 @@ func (e *Notifier) SendMessage(userUUIDs []string, subject, content string) erro
return nil
}
func (e *Notifier) SendAssignedConversationNotification(userUUIDs []string, convUUID string) error {
func (e *Email) SendAssignedConversationNotification(userIDs []int, convUUID string) error {
subject := "New conversation assigned to you"
link := fmt.Sprintf("http://localhost:5173/conversations/%s", convUUID)
content := fmt.Sprintf("A new conversation has been assigned to you. <br>Please review the details and take necessary action by following this link: %s", link)
return e.SendMessage(userUUIDs, subject, content)
return e.SendMessage(userIDs, subject, content)
}
// Send sends an email message using one of the SMTP pools.
func (e *Notifier) Send(m models.Message) error {
// Send sends an email message.
func (e *Email) Send(m models.Message) error {
var (
ln = len(e.smtpPools)
srv *smtppool.Pool

View File

@@ -0,0 +1,6 @@
package models
type Settings struct {
AppSiteName string `json:"app.site_name"`
AppLang string `json:"app.lang"`
}

View File

@@ -0,0 +1,2 @@
-- name: get-all
SELECT JSON_OBJECT_AGG(key, value) AS settings FROM (SELECT * FROM settings ORDER BY key) t;

View File

@@ -0,0 +1,69 @@
package setting
import (
"embed"
"encoding/json"
"github.com/abhinavxd/artemis/internal/dbutil"
"github.com/abhinavxd/artemis/internal/setting/models"
"github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx/types"
)
var (
//go:embed queries.sql
efs embed.FS
)
type Manager struct {
q queries
}
type Opts struct {
DB *sqlx.DB
}
type queries struct {
GetAll *sqlx.Stmt `query:"get-all"`
}
func New(opts Opts) (*Manager, error) {
var q queries
if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
return nil, err
}
return &Manager{
q: q,
}, nil
}
func (m *Manager) GetAll() (models.Settings, error) {
var (
b types.JSONText
out models.Settings
)
if err := m.q.GetAll.Get(&b); err != nil {
return out, err
}
if err := json.Unmarshal([]byte(b), &out); err != nil {
return out, err
}
return out, nil
}
func (m *Manager) GetAllJSON() (types.JSONText, error) {
var (
b types.JSONText
)
if err := m.q.GetAll.Get(&b); err != nil {
return b, err
}
return b, nil
}

View File

@@ -16,7 +16,7 @@ type User struct {
TeamID int `db:"team_id" json:"team_id"`
Password string `db:"password" json:"-"`
TeamName null.String `db:"team_name" json:"team_name"`
Roles []string `db:"roles" json:"roles"`
Roles pq.StringArray `db:"roles" json:"roles"`
SendWelcomeEmail bool `db:"-" json:"send_welcome_email"`
Permissions pq.StringArray `db:"permissions" json:"permissions"`
}

View File

@@ -14,7 +14,7 @@ JOIN roles r ON r.name = ANY(u.roles)
WHERE u.email = $1;
-- name: get-user
SELECT id, email, avatar_url, first_name, last_name, team_id
SELECT id, email, avatar_url, first_name, last_name, team_id, roles
FROM users
WHERE
CASE
@@ -34,3 +34,9 @@ VALUES($1, $2, $3, $4, $5, $6, $7);
UPDATE users
set first_name = $2, last_name = $3, email = $4, team_id = $5, roles = $6, updated_at = now()
where id = $1
-- name: get-permissions
SELECT unnest(r.permissions)
FROM users u
JOIN roles r ON r.name = ANY(u.roles)
WHERE u.id = $1

View File

@@ -51,6 +51,7 @@ type queries struct {
GetUsers *sqlx.Stmt `query:"get-users"`
GetUser *sqlx.Stmt `query:"get-user"`
GetEmail *sqlx.Stmt `query:"get-email"`
GetPermissions *sqlx.Stmt `query:"get-permissions"`
GetUserByEmail *sqlx.Stmt `query:"get-user-by-email"`
UpdateUser *sqlx.Stmt `query:"update-user"`
SetUserPassword *sqlx.Stmt `query:"set-user-password"`
@@ -160,6 +161,15 @@ func (u *Manager) GetEmail(id int, uuid string) (string, error) {
return email, nil
}
func (u *Manager) GetPermissions(id int) ([]string, error) {
var permissions []string
if err := u.q.GetPermissions.Select(&permissions, id); err != nil {
u.lo.Error("error fetching user permissions", "error", err)
return permissions, envelope.NewError(envelope.GeneralError, "Error fetching user permissions", nil)
}
return permissions, nil
}
func (u *Manager) verifyPassword(pwd []byte, pwdHash string) error {
err := bcrypt.CompareHashAndPassword([]byte(pwdHash), pwd)
if err != nil {

View File

@@ -162,7 +162,7 @@ func (c *Hub) BroadcastConversationAssignment(userID int, conversationUUID strin
c.marshalAndPush(message, []int{userID})
}
func (c *Hub) BroadcastConversationPropertyUpdate(conversationUUID, prop string, val string) {
func (c *Hub) BroadcastConversationPropertyUpdate(conversationUUID, prop string, value string) {
userIDs, ok := c.ConversationSubs[conversationUUID]
if !ok || len(userIDs) == 0 {
return
@@ -173,7 +173,7 @@ func (c *Hub) BroadcastConversationPropertyUpdate(conversationUUID, prop string,
Data: map[string]interface{}{
"uuid": conversationUUID,
"prop": prop,
"val": val,
"val": value,
},
}