mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-11-03 05:23:48 +00:00
feat: dashboard.
This commit is contained in:
10
cmd/auth.go
10
cmd/auth.go
@@ -1,8 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/abhinavxd/artemis/internal/envelope"
|
||||
"github.com/abhinavxd/artemis/internal/stringutil"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
@@ -14,9 +13,12 @@ func handleOIDCLogin(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
)
|
||||
state, _ := stringutil.RandomAlNumString(30)
|
||||
state, err := stringutil.RandomAlNumString(30)
|
||||
if err != nil {
|
||||
app.lo.Error("error generating random string", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Something went wrong, Please try again.", nil, envelope.GeneralError)
|
||||
}
|
||||
authURL := app.auth.LoginURL(state)
|
||||
fmt.Println("url ", authURL)
|
||||
return r.Redirect(authURL, fasthttp.StatusFound, nil, "")
|
||||
}
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@ func handleUserDashboardCounts(r *fastglue.Request) error {
|
||||
user = r.RequestCtx.UserValue("user").(umodels.User)
|
||||
)
|
||||
|
||||
stats, err := app.conversation.GetConversationAssigneeStats(user.ID)
|
||||
stats, err := app.conversation.GetDashboardCounts(user.ID, 0)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -189,8 +189,62 @@ func handleUserDashboardCounts(r *fastglue.Request) error {
|
||||
}
|
||||
|
||||
func handleUserDashboardCharts(r *fastglue.Request) error {
|
||||
var app = r.Context.(*App)
|
||||
stats, err := app.conversation.GetNewConversationsStats()
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
user = r.RequestCtx.UserValue("user").(umodels.User)
|
||||
)
|
||||
|
||||
stats, err := app.conversation.GetDashboardChartData(user.ID, 0)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(stats)
|
||||
}
|
||||
|
||||
func handleDashboardCounts(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
)
|
||||
|
||||
stats, err := app.conversation.GetDashboardCounts(0, 0)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(stats)
|
||||
}
|
||||
|
||||
func handleDashboardCharts(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
)
|
||||
|
||||
stats, err := app.conversation.GetDashboardChartData(0, 0)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(stats)
|
||||
}
|
||||
|
||||
func handleTeamDashboardCounts(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
teamID = r.RequestCtx.UserValue("team_id").(int)
|
||||
)
|
||||
|
||||
stats, err := app.conversation.GetDashboardCounts(0, teamID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
return r.SendEnvelope(stats)
|
||||
}
|
||||
|
||||
func handleTeamDashboardCharts(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
teamID = r.RequestCtx.UserValue("team_id").(int)
|
||||
)
|
||||
|
||||
stats, err := app.conversation.GetDashboardChartData(0, teamID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
137
cmd/handlers.go
137
cmd/handlers.go
@@ -14,88 +14,92 @@ import (
|
||||
func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
||||
g.POST("/api/login", handleLogin)
|
||||
g.GET("/api/logout", handleLogout)
|
||||
g.GET("/auth/oidc/login", handleOIDCLogin)
|
||||
g.GET("/auth/oidc/finish", handleOIDCCallback)
|
||||
g.GET("/api/oidc/login", handleOIDCLogin)
|
||||
g.GET("/api/oidc/finish", handleOIDCCallback)
|
||||
|
||||
g.GET("/api/settings", aauth(handleGetSettings))
|
||||
// Health check.
|
||||
g.GET("/api/health", handleHealthCheck)
|
||||
|
||||
// Settings.
|
||||
g.GET("/api/settings", perm(handleGetSettings))
|
||||
|
||||
// Conversation.
|
||||
g.GET("/api/conversations/all", aauth(handleGetAllConversations, "conversation:all"))
|
||||
g.GET("/api/conversations/team", aauth(handleGetTeamConversations, "conversation:team"))
|
||||
g.GET("/api/conversations/assigned", aauth(handleGetAssignedConversations, "conversation:assigned"))
|
||||
g.GET("/api/conversations/{uuid}", aauth(handleGetConversation))
|
||||
g.GET("/api/conversations/{uuid}/participants", aauth(handleGetConversationParticipants))
|
||||
g.PUT("/api/conversations/{uuid}/last-seen", aauth(handleUpdateAssigneeLastSeen))
|
||||
g.PUT("/api/conversations/{uuid}/assignee/user", aauth(handleUpdateUserAssignee))
|
||||
g.PUT("/api/conversations/{uuid}/assignee/team", aauth(handleUpdateTeamAssignee))
|
||||
g.PUT("/api/conversations/{uuid}/priority", aauth(handleUpdatePriority, "conversation:edit_priority"))
|
||||
g.PUT("/api/conversations/{uuid}/status", aauth(handleUpdateStatus, "conversation:edit_status"))
|
||||
g.POST("/api/conversations/{uuid}/tags", aauth(handleAddConversationTags))
|
||||
g.GET("/api/conversations/{uuid}/messages", aauth(handleGetMessages))
|
||||
g.POST("/api/conversations/{uuid}/messages", aauth(handleSendMessage))
|
||||
g.GET("/api/message/{uuid}/retry", aauth(handleRetryMessage))
|
||||
g.GET("/api/message/{uuid}", aauth(handleGetMessage))
|
||||
g.GET("/api/conversations/all", perm(handleGetAllConversations, "conversation:all"))
|
||||
g.GET("/api/conversations/team", perm(handleGetTeamConversations, "conversation:team"))
|
||||
g.GET("/api/conversations/assigned", perm(handleGetAssignedConversations, "conversation:assigned"))
|
||||
g.GET("/api/conversations/{uuid}", perm(handleGetConversation))
|
||||
g.GET("/api/conversations/{uuid}/participants", perm(handleGetConversationParticipants))
|
||||
g.PUT("/api/conversations/{uuid}/last-seen", perm(handleUpdateAssigneeLastSeen))
|
||||
g.PUT("/api/conversations/{uuid}/assignee/user", perm(handleUpdateUserAssignee))
|
||||
g.PUT("/api/conversations/{uuid}/assignee/team", perm(handleUpdateTeamAssignee))
|
||||
g.PUT("/api/conversations/{uuid}/priority", perm(handleUpdatePriority, "conversation:edit_priority"))
|
||||
g.PUT("/api/conversations/{uuid}/status", perm(handleUpdateStatus, "conversation:edit_status"))
|
||||
g.POST("/api/conversations/{uuid}/tags", perm(handleAddConversationTags))
|
||||
g.GET("/api/conversations/{uuid}/messages", perm(handleGetMessages))
|
||||
g.POST("/api/conversations/{uuid}/messages", perm(handleSendMessage))
|
||||
g.GET("/api/message/{uuid}/retry", perm(handleRetryMessage))
|
||||
g.GET("/api/message/{uuid}", perm(handleGetMessage))
|
||||
|
||||
// Media.
|
||||
g.POST("/api/media", aauth(handleMediaUpload))
|
||||
g.POST("/api/media", perm(handleMediaUpload))
|
||||
|
||||
// Canned response.
|
||||
g.GET("/api/canned-responses", aauth(handleGetCannedResponses))
|
||||
g.GET("/api/canned-responses", perm(handleGetCannedResponses))
|
||||
|
||||
// User.
|
||||
g.GET("/api/users/me", aauth(handleGetCurrentUser, "users:manage"))
|
||||
g.GET("/api/users", aauth(handleGetUsers, "users:manage"))
|
||||
g.GET("/api/users/{id}", aauth(handleGetUser, "users:manage"))
|
||||
g.PUT("/api/users/{id}", aauth(handleUpdateUser, "users:manage"))
|
||||
g.POST("/api/users", aauth(handleCreateUser, "users:manage"))
|
||||
g.GET("/api/users/me", perm(handleGetCurrentUser, "users:manage"))
|
||||
g.GET("/api/users", perm(handleGetUsers, "users:manage"))
|
||||
g.GET("/api/users/{id}", perm(handleGetUser, "users:manage"))
|
||||
g.PUT("/api/users/{id}", perm(handleUpdateUser, "users:manage"))
|
||||
g.POST("/api/users", perm(handleCreateUser, "users:manage"))
|
||||
|
||||
// Team.
|
||||
g.GET("/api/teams", aauth(handleGetTeams, "teams:manage"))
|
||||
g.GET("/api/teams/{id}", aauth(handleGetTeam, "teams:manage"))
|
||||
g.PUT("/api/teams/{id}", aauth(handleUpdateTeam, "teams:manage"))
|
||||
g.POST("/api/teams", aauth(handleCreateTeam, "teams:manage"))
|
||||
g.GET("/api/teams", perm(handleGetTeams, "teams:manage"))
|
||||
g.GET("/api/teams/{id}", perm(handleGetTeam, "teams:manage"))
|
||||
g.PUT("/api/teams/{id}", perm(handleUpdateTeam, "teams:manage"))
|
||||
g.POST("/api/teams", perm(handleCreateTeam, "teams:manage"))
|
||||
|
||||
// Tags.
|
||||
g.GET("/api/tags", aauth(handleGetTags))
|
||||
g.GET("/api/tags", perm(handleGetTags))
|
||||
|
||||
// i18n.
|
||||
g.GET("/api/lang/{lang}", handleGetI18nLang)
|
||||
|
||||
// Websocket.
|
||||
g.GET("/api/ws", aauth(func(r *fastglue.Request) error {
|
||||
return handleWS(r, hub)
|
||||
}))
|
||||
|
||||
// Automation rules.
|
||||
g.GET("/api/automation/rules", aauth(handleGetAutomationRules, "automations:manage"))
|
||||
g.GET("/api/automation/rules/{id}", aauth(handleGetAutomationRule, "automations:manage"))
|
||||
g.POST("/api/automation/rules", aauth(handleCreateAutomationRule, "automations:manage"))
|
||||
g.PUT("/api/automation/rules/{id}/toggle", aauth(handleToggleAutomationRule, "automations:manage"))
|
||||
g.PUT("/api/automation/rules/{id}", aauth(handleUpdateAutomationRule, "automations:manage"))
|
||||
g.DELETE("/api/automation/rules/{id}", aauth(handleDeleteAutomationRule, "automations:manage"))
|
||||
g.GET("/api/automation/rules", perm(handleGetAutomationRules, "automations:manage"))
|
||||
g.GET("/api/automation/rules/{id}", perm(handleGetAutomationRule, "automations:manage"))
|
||||
g.POST("/api/automation/rules", perm(handleCreateAutomationRule, "automations:manage"))
|
||||
g.PUT("/api/automation/rules/{id}/toggle", perm(handleToggleAutomationRule, "automations:manage"))
|
||||
g.PUT("/api/automation/rules/{id}", perm(handleUpdateAutomationRule, "automations:manage"))
|
||||
g.DELETE("/api/automation/rules/{id}", perm(handleDeleteAutomationRule, "automations:manage"))
|
||||
|
||||
// Inboxes.
|
||||
g.GET("/api/inboxes", aauth(handleGetInboxes, "inboxes:manage"))
|
||||
g.GET("/api/inboxes/{id}", aauth(handleGetInbox, "inboxes:manage"))
|
||||
g.POST("/api/inboxes", aauth(handleCreateInbox, "inboxes:manage"))
|
||||
g.PUT("/api/inboxes/{id}/toggle", aauth(handleToggleInbox, "inboxes:manage"))
|
||||
g.PUT("/api/inboxes/{id}", aauth(handleUpdateInbox, "inboxes:manage"))
|
||||
g.DELETE("/api/inboxes/{id}", aauth(handleDeleteInbox, "inboxes:manage"))
|
||||
g.GET("/api/inboxes", perm(handleGetInboxes, "inboxes:manage"))
|
||||
g.GET("/api/inboxes/{id}", perm(handleGetInbox, "inboxes:manage"))
|
||||
g.POST("/api/inboxes", perm(handleCreateInbox, "inboxes:manage"))
|
||||
g.PUT("/api/inboxes/{id}/toggle", perm(handleToggleInbox, "inboxes:manage"))
|
||||
g.PUT("/api/inboxes/{id}", perm(handleUpdateInbox, "inboxes:manage"))
|
||||
g.DELETE("/api/inboxes/{id}", perm(handleDeleteInbox, "inboxes:manage"))
|
||||
|
||||
// Roles.
|
||||
g.GET("/api/roles", aauth(handleGetRoles, "roles:manage"))
|
||||
g.GET("/api/roles/{id}", aauth(handleGetRole, "roles:manage"))
|
||||
g.POST("/api/roles", aauth(handleCreateRole, "roles:manage"))
|
||||
g.PUT("/api/roles/{id}", aauth(handleUpdateRole, "roles:manage"))
|
||||
g.DELETE("/api/roles/{id}", aauth(handleDeleteRole, "roles:manage"))
|
||||
g.GET("/api/roles", perm(handleGetRoles, "roles:manage"))
|
||||
g.GET("/api/roles/{id}", perm(handleGetRole, "roles:manage"))
|
||||
g.POST("/api/roles", perm(handleCreateRole, "roles:manage"))
|
||||
g.PUT("/api/roles/{id}", perm(handleUpdateRole, "roles:manage"))
|
||||
g.DELETE("/api/roles/{id}", perm(handleDeleteRole, "roles:manage"))
|
||||
|
||||
// Dashboard.
|
||||
g.GET("/api/dashboard/me/counts", aauth(handleUserDashboardCounts))
|
||||
g.GET("/api/dashboard/me/charts", aauth(handleUserDashboardCharts))
|
||||
// g.GET("/api/dashboard/team/:teamName/counts", aauth(handleTeamCounts))
|
||||
// g.GET("/api/dashboard/team/:teamName/charts", aauth(handleTeamCharts))
|
||||
// g.GET("/api/dashboard/global/counts", aauth(handleGlobalCounts))
|
||||
// g.GET("/api/dashboard/global/charts", aauth(handleGlobalCharts))
|
||||
g.GET("/api/dashboard/global/counts", perm(handleDashboardCounts))
|
||||
g.GET("/api/dashboard/global/charts", perm(handleDashboardCharts))
|
||||
g.GET("/api/dashboard/team/{team_id}/counts", perm(handleTeamDashboardCounts))
|
||||
g.GET("/api/dashboard/team/{team_id}/charts", perm(handleTeamDashboardCharts))
|
||||
g.GET("/api/dashboard/me/counts", perm(handleUserDashboardCounts))
|
||||
g.GET("/api/dashboard/me/charts", perm(handleUserDashboardCharts))
|
||||
|
||||
// Websocket.
|
||||
g.GET("/api/ws", perm(func(r *fastglue.Request) error {
|
||||
return handleWS(r, hub)
|
||||
}))
|
||||
|
||||
// Frontend pages.
|
||||
g.GET("/", sess(noAuthPage(serveIndexPage)))
|
||||
@@ -158,3 +162,20 @@ func sendErrorEnvelope(r *fastglue.Request, err error) error {
|
||||
}
|
||||
return r.SendErrorEnvelope(e.Code, e.Error(), e.Data, fastglue.ErrorType(e.ErrorType))
|
||||
}
|
||||
|
||||
// handleHealthCheck handles the health check endpoint by pinging the PostgreSQL and Redis.
|
||||
func handleHealthCheck(r *fastglue.Request) error {
|
||||
var app = r.Context.(*App)
|
||||
|
||||
// Ping DB.
|
||||
if err := app.db.Ping(); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "DB ping failed.", nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
// Ping Redis.
|
||||
if err := app.rdb.Ping(r.RequestCtx).Err(); err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Redis ping failed.", nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
return r.SendEnvelope(true)
|
||||
}
|
||||
|
||||
16
cmd/main.go
16
cmd/main.go
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/abhinavxd/artemis/internal/team"
|
||||
"github.com/abhinavxd/artemis/internal/user"
|
||||
"github.com/abhinavxd/artemis/internal/ws"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/go-i18n"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/stuffbin"
|
||||
@@ -34,18 +35,14 @@ var (
|
||||
ko = koanf.New(".")
|
||||
)
|
||||
|
||||
const (
|
||||
// ANSI escape colour codes.
|
||||
colourRed = "\x1b[31m"
|
||||
colourGreen = "\x1b[32m"
|
||||
)
|
||||
|
||||
// App is the global app context which is passed and injected in the http handlers.
|
||||
type App struct {
|
||||
constant constants
|
||||
db *sqlx.DB
|
||||
rdb *redis.Client
|
||||
auth *auth.Auth
|
||||
fs stuffbin.FileSystem
|
||||
rdb *redis.Client
|
||||
i18n *i18n.I18n
|
||||
lo *logf.Logger
|
||||
media *media.Manager
|
||||
@@ -112,6 +109,9 @@ func main() {
|
||||
// Listen to incoming messages and dispatch pending outgoing messages.
|
||||
go conversation.ListenAndDispatch(ctx, ko.MustInt("message.dispatch_concurrency"), ko.MustInt("message.reader_concurrency"), ko.MustDuration("message.dispatch_read_interval"))
|
||||
|
||||
// CleanMedia deletes media not linked to any model at regular intervals.
|
||||
go media.CleanMedia(ctx)
|
||||
|
||||
// Init the app
|
||||
var app = &App{
|
||||
lo: lo,
|
||||
@@ -156,7 +156,7 @@ func main() {
|
||||
// Wait for the interruption signal
|
||||
<-ctx.Done()
|
||||
|
||||
log.Printf("%sShutting down the server. Please wait.\x1b[0m", colourRed)
|
||||
log.Printf("%sShutting down the server. Please wait.\x1b[0m", "\x1b[31m")
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
@@ -165,7 +165,7 @@ func main() {
|
||||
stop()
|
||||
}()
|
||||
|
||||
log.Printf("%s🚀 server listening on %s %s\x1b[0m", colourGreen, ko.String("app.server.address"), ko.String("app.server.socket"))
|
||||
log.Printf("%s🚀 server listening on %s %s\x1b[0m", "\x1b[32m", ko.String("app.server.address"), ko.String("app.server.socket"))
|
||||
|
||||
if err := g.ListenServeAndWaitGracefully(ko.String("app.server.address"), ko.String("server.socket"), s, shutdownCh); err != nil {
|
||||
log.Fatalf("error starting frontend server: %v", err)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/abhinavxd/artemis/internal/envelope"
|
||||
@@ -10,16 +9,13 @@ import (
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
func aauth(handler fastglue.FastRequestHandler, requiredPerms ...string) fastglue.FastRequestHandler {
|
||||
func perm(handler fastglue.FastRequestHandler, requiredPerms ...string) fastglue.FastRequestHandler {
|
||||
return func(r *fastglue.Request) error {
|
||||
var app = r.Context.(*App)
|
||||
user, err := app.auth.ValidateSession(r)
|
||||
if err != nil {
|
||||
return r.SendErrorEnvelope(http.StatusUnauthorized, "Invalid or expired session", nil, envelope.PermissionError)
|
||||
}
|
||||
|
||||
fmt.Println("req ", requiredPerms)
|
||||
|
||||
// User is loggedin, Set user in the request context.
|
||||
r.RequestCtx.SetUserValue("user", user)
|
||||
return handler(r)
|
||||
|
||||
@@ -18,7 +18,7 @@ var upgrader = websocket.FastHTTPUpgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(ctx *fasthttp.RequestCtx) bool {
|
||||
return true // Allow all origins in development
|
||||
return true
|
||||
},
|
||||
Error: ErrHandler,
|
||||
}
|
||||
@@ -34,7 +34,7 @@ func handleWS(r *fastglue.Request, hub *ws.Hub) error {
|
||||
ID: user.ID,
|
||||
Hub: hub,
|
||||
Conn: conn,
|
||||
Send: make(chan ws.Message, 10000),
|
||||
Send: make(chan ws.Message, 1000),
|
||||
}
|
||||
hub.AddClient(&c)
|
||||
go c.Listen()
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap"
|
||||
rel="stylesheet">
|
||||
<title>Artemis App</title>
|
||||
<title>Vite App</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -28,8 +28,8 @@
|
||||
"@tiptap/starter-kit": "^2.4.0",
|
||||
"@tiptap/suggestion": "^2.4.0",
|
||||
"@tiptap/vue-3": "^2.4.0",
|
||||
"@unovis/ts": "^1.4.1",
|
||||
"@unovis/vue": "^1.4.1",
|
||||
"@unovis/ts": "^1.4.3",
|
||||
"@unovis/vue": "^1.4.3",
|
||||
"@vee-validate/zod": "^4.13.2",
|
||||
"@vue/reactivity": "^3.4.15",
|
||||
"@vue/runtime-core": "^3.4.15",
|
||||
|
||||
@@ -97,8 +97,12 @@ const uploadMedia = data =>
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
const getUserDashboardCounts = () => http.get('/api/dashboard/me/counts');
|
||||
const getUserDashoardCharts = () => http.get('/api/dashboard/me/charts');
|
||||
const getGlobalDashboardCounts = () => http.get('/api/dashboard/global/counts');
|
||||
const getGlobalDashboardCharts = () => http.get('/api/dashboard/global/charts');
|
||||
const getTeamDashboardCounts = (teamID) => http.get(`/api/dashboard/${teamID}/counts`);
|
||||
const getTeamDashboardCharts = (teamID) => http.get(`/api/dashboard/${teamID}/charts`);
|
||||
const getUserDashboardCounts = () => http.get(`/api/dashboard/me/counts`);
|
||||
const getUserDashboardCharts = () => http.get(`/api/dashboard/me/charts`);
|
||||
const getLanguage = lang => http.get(`/api/lang/${lang}`);
|
||||
const createUser = data => http.post('/api/users', data, {
|
||||
headers: {
|
||||
@@ -150,11 +154,15 @@ export default {
|
||||
getAssignedConversations,
|
||||
getTeamConversations,
|
||||
getAllConversations,
|
||||
getTeamDashboardCounts,
|
||||
getTeamDashboardCharts,
|
||||
getGlobalDashboardCharts,
|
||||
getGlobalDashboardCounts,
|
||||
getUserDashboardCounts,
|
||||
getUserDashboardCharts,
|
||||
getConversationParticipants,
|
||||
getMessage,
|
||||
getMessages,
|
||||
getUserDashoardCharts,
|
||||
getCurrentUser,
|
||||
getCannedResponses,
|
||||
updateAssignee,
|
||||
|
||||
@@ -10,75 +10,74 @@
|
||||
|
||||
.page-content {
|
||||
padding: 1rem 1rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
// Theme.
|
||||
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 20 14.3% 4.1%;
|
||||
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 20 14.3% 4.1%;
|
||||
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 20 14.3% 4.1%;
|
||||
|
||||
|
||||
--primary: 24 9.8% 10%;
|
||||
--primary-foreground: 60 9.1% 97.8%;
|
||||
|
||||
|
||||
--secondary: 60 4.8% 95.9%;
|
||||
--secondary-foreground: 24 9.8% 10%;
|
||||
|
||||
|
||||
--muted: 60 4.8% 95.9%;
|
||||
--muted-foreground: 25 5.3% 44.7%;
|
||||
|
||||
|
||||
--accent: 60 4.8% 95.9%;
|
||||
--accent-foreground: 24 9.8% 10%;
|
||||
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--border:20 5.9% 90%;
|
||||
--input:20 5.9% 90%;
|
||||
--ring:20 14.3% 4.1%;
|
||||
|
||||
--border: 20 5.9% 90%;
|
||||
--input: 20 5.9% 90%;
|
||||
--ring: 20 14.3% 4.1%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.dark {
|
||||
--background:20 14.3% 4.1%;
|
||||
--foreground:60 9.1% 97.8%;
|
||||
|
||||
--card:20 14.3% 4.1%;
|
||||
--card-foreground:60 9.1% 97.8%;
|
||||
|
||||
--popover:20 14.3% 4.1%;
|
||||
--popover-foreground:60 9.1% 97.8%;
|
||||
|
||||
--primary:60 9.1% 97.8%;
|
||||
--primary-foreground:24 9.8% 10%;
|
||||
|
||||
--secondary:12 6.5% 15.1%;
|
||||
--secondary-foreground:60 9.1% 97.8%;
|
||||
|
||||
--muted:12 6.5% 15.1%;
|
||||
--muted-foreground:24 5.4% 63.9%;
|
||||
|
||||
--accent:12 6.5% 15.1%;
|
||||
--accent-foreground:60 9.1% 97.8%;
|
||||
|
||||
--destructive:0 62.8% 30.6%;
|
||||
--destructive-foreground:60 9.1% 97.8%;
|
||||
|
||||
--border:12 6.5% 15.1%;
|
||||
--input:12 6.5% 15.1%;
|
||||
--ring:24 5.7% 82.9%;
|
||||
--background: 20 14.3% 4.1%;
|
||||
--foreground: 60 9.1% 97.8%;
|
||||
|
||||
--card: 20 14.3% 4.1%;
|
||||
--card-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--popover: 20 14.3% 4.1%;
|
||||
--popover-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--primary: 60 9.1% 97.8%;
|
||||
--primary-foreground: 24 9.8% 10%;
|
||||
|
||||
--secondary: 12 6.5% 15.1%;
|
||||
--secondary-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--muted: 12 6.5% 15.1%;
|
||||
--muted-foreground: 24 5.4% 63.9%;
|
||||
|
||||
--accent: 12 6.5% 15.1%;
|
||||
--accent-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 60 9.1% 97.8%;
|
||||
|
||||
--border: 12 6.5% 15.1%;
|
||||
--input: 12 6.5% 15.1%;
|
||||
--ring: 24 5.7% 82.9%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--vis-tooltip-background-color: none !important;
|
||||
@@ -185,3 +184,11 @@ h4 {
|
||||
.admin-subtitle {
|
||||
@apply text-sm font-medium;
|
||||
}
|
||||
|
||||
.box-shadow {
|
||||
box-shadow: 2px 2px 0 #f3f3f3;
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
@apply border bg-white rounded-xl box-shadow;
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
<script setup>
|
||||
import { DonutChart } from '@/components/ui/chart-donut'
|
||||
const valueFormatter = (tick) => typeof tick === 'number' ? `$ ${new Intl.NumberFormat('us').format(tick).toString()}` : ''
|
||||
const dataDonut = [
|
||||
{ name: 'Jan', total: Math.floor(Math.random() * 2000) + 500, predicted: Math.floor(Math.random() * 2000) + 500 },
|
||||
{ name: 'Feb', total: Math.floor(Math.random() * 2000) + 500, predicted: Math.floor(Math.random() * 2000) + 500 },
|
||||
{ name: 'Mar', total: Math.floor(Math.random() * 2000) + 500, predicted: Math.floor(Math.random() * 2000) + 500 },
|
||||
{ name: 'Apr', total: Math.floor(Math.random() * 2000) + 500, predicted: Math.floor(Math.random() * 2000) + 500 },
|
||||
{ name: 'May', total: Math.floor(Math.random() * 2000) + 500, predicted: Math.floor(Math.random() * 2000) + 500 },
|
||||
{ name: 'Jun', total: Math.floor(Math.random() * 2000) + 500, predicted: Math.floor(Math.random() * 2000) + 500 },
|
||||
]
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DonutChart index="name" :category="'total'" :data="dataDonut" :value-formatter="valueFormatter" type="pie" />
|
||||
</template>
|
||||
@@ -1,20 +0,0 @@
|
||||
<script setup>
|
||||
import { LineChart } from '@/components/ui/chart-line'
|
||||
|
||||
defineProps({
|
||||
data: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LineChart :data="data" index="date" :categories="['new_conversations']" :y-formatter="(tick, i) => {
|
||||
return typeof tick === 'number'
|
||||
? `${new Intl.NumberFormat('us').format(tick).toString()}`
|
||||
: ''
|
||||
}" />
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<BarChart :data="data" index="status" :categories="['Low', 'Medium', 'High']" :show-grid-line="true" :y-formatter="(tick) => {
|
||||
return tick
|
||||
}" :margin="{ top: 0, bottom: 0, left: 20, right: 20 }" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { BarChart } from '@/components/ui/chart-bar'
|
||||
|
||||
defineProps({
|
||||
data: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="flex mt-5 gap-x-7">
|
||||
<Card class="w-1/6" v-for="(value, key) in counts" :key="key">
|
||||
<div class="flex gap-x-5">
|
||||
<Card class="w-1/6 dashboard-card" v-for="(value, key) in counts" :key="key">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-4xl">
|
||||
<CardTitle class="text-2xl">
|
||||
{{ value }}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -12,6 +12,7 @@
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
Card,
|
||||
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<LineChart :data="renamedData" index="date" :categories="['New conversations']" :y-formatter="(tick) => {
|
||||
return tick
|
||||
}" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { LineChart } from '@/components/ui/chart-line'
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
const renamedData = computed(() =>
|
||||
props.data.map(item => ({
|
||||
...item,
|
||||
'New conversations': item.new_conversations
|
||||
}))
|
||||
);
|
||||
</script>
|
||||
135
frontend/src/components/ui/chart-bar/BarChart.vue
Normal file
135
frontend/src/components/ui/chart-bar/BarChart.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<script setup>
|
||||
import {
|
||||
VisAxis,
|
||||
VisGroupedBar,
|
||||
VisStackedBar,
|
||||
VisXYContainer,
|
||||
} from "@unovis/vue";
|
||||
import { Axis, GroupedBar, StackedBar } from "@unovis/ts";
|
||||
import { computed, ref } from "vue";
|
||||
import { useMounted } from "@vueuse/core";
|
||||
import {
|
||||
ChartCrosshair,
|
||||
ChartLegend,
|
||||
defaultColors,
|
||||
} from "@/components/ui/chart";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
data: { type: Array, required: true },
|
||||
categories: { type: Array, required: true },
|
||||
index: { type: null, required: true },
|
||||
colors: { type: Array, required: false },
|
||||
margin: {
|
||||
type: null,
|
||||
required: false,
|
||||
default: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
|
||||
},
|
||||
filterOpacity: { type: Number, required: false, default: 0.2 },
|
||||
xFormatter: { type: Function, required: false },
|
||||
yFormatter: { type: Function, required: false },
|
||||
showXAxis: { type: Boolean, required: false, default: true },
|
||||
showYAxis: { type: Boolean, required: false, default: true },
|
||||
showTooltip: { type: Boolean, required: false, default: true },
|
||||
showLegend: { type: Boolean, required: false, default: true },
|
||||
showGridLine: { type: Boolean, required: false, default: true },
|
||||
customTooltip: { type: null, required: false },
|
||||
type: { type: String, required: false, default: "grouped" },
|
||||
roundedCorners: { type: Number, required: false, default: 0 },
|
||||
});
|
||||
const emits = defineEmits(["legendItemClick"]);
|
||||
|
||||
const index = computed(() => props.index);
|
||||
const colors = computed(() =>
|
||||
props.colors?.length ? props.colors : defaultColors(props.categories.length),
|
||||
);
|
||||
const legendItems = ref(
|
||||
props.categories.map((category, i) => ({
|
||||
name: category,
|
||||
color: colors.value[i],
|
||||
inactive: false,
|
||||
})),
|
||||
);
|
||||
|
||||
const isMounted = useMounted();
|
||||
|
||||
function handleLegendItemClick(d, i) {
|
||||
emits("legendItemClick", d, i);
|
||||
}
|
||||
|
||||
const VisBarComponent = computed(() =>
|
||||
props.type === "grouped" ? VisGroupedBar : VisStackedBar,
|
||||
);
|
||||
const selectorsBar = computed(() =>
|
||||
props.type === "grouped"
|
||||
? GroupedBar.selectors.bar
|
||||
: StackedBar.selectors.bar,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="cn('w-full h-[400px] flex flex-col items-end', $attrs.class ?? '')"
|
||||
>
|
||||
<ChartLegend
|
||||
v-if="showLegend"
|
||||
v-model:items="legendItems"
|
||||
@legend-item-click="handleLegendItemClick"
|
||||
/>
|
||||
|
||||
<VisXYContainer
|
||||
:data="data"
|
||||
:style="{ height: isMounted ? '100%' : 'auto' }"
|
||||
:margin="margin"
|
||||
>
|
||||
<ChartCrosshair
|
||||
v-if="showTooltip"
|
||||
:colors="colors"
|
||||
:items="legendItems"
|
||||
:custom-tooltip="customTooltip"
|
||||
:index="index"
|
||||
/>
|
||||
|
||||
<VisBarComponent
|
||||
:x="(d, i) => i"
|
||||
:y="categories.map((category) => (d) => d[category])"
|
||||
:color="colors"
|
||||
:rounded-corners="roundedCorners"
|
||||
:bar-padding="0.05"
|
||||
:attributes="{
|
||||
[selectorsBar]: {
|
||||
opacity: (d, i) => {
|
||||
const pos = i % categories.length;
|
||||
return legendItems[pos]?.inactive ? filterOpacity : 1;
|
||||
},
|
||||
},
|
||||
}"
|
||||
/>
|
||||
|
||||
<VisAxis
|
||||
v-if="showXAxis"
|
||||
type="x"
|
||||
:tick-format="xFormatter ?? ((v) => data[v]?.[index])"
|
||||
:grid-line="false"
|
||||
:tick-line="false"
|
||||
tick-text-color="hsl(var(--vis-text-color))"
|
||||
/>
|
||||
<VisAxis
|
||||
v-if="showYAxis"
|
||||
type="y"
|
||||
:tick-line="false"
|
||||
:tick-format="yFormatter"
|
||||
:domain-line="false"
|
||||
:grid-line="showGridLine"
|
||||
:attributes="{
|
||||
[Axis.selectors.grid]: {
|
||||
class: 'text-muted',
|
||||
},
|
||||
}"
|
||||
tick-text-color="hsl(var(--vis-text-color))"
|
||||
/>
|
||||
|
||||
<slot />
|
||||
</VisXYContainer>
|
||||
</div>
|
||||
</template>
|
||||
1
frontend/src/components/ui/chart-bar/index.js
Normal file
1
frontend/src/components/ui/chart-bar/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default as BarChart } from "./BarChart.vue";
|
||||
@@ -1,82 +1,141 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<div v-if="userStore.getFullName">
|
||||
<span class="scroll-m-20 text-xl font-medium">
|
||||
<p>Hi, {{ userStore.getFullName }}</p>
|
||||
<p class="text-sm text-muted-foreground">🌤️ {{ format(new Date(), "EEEE, MMMM d HH:mm a") }}</p>
|
||||
</span>
|
||||
<div class="page-content w-11/12">
|
||||
<div class="flex flex-col space-y-5">
|
||||
<div>
|
||||
<span class="font-semibold text-xl" v-if="userStore.getFullName">
|
||||
<p>Hi, {{ userStore.getFullName }}</p>
|
||||
<p class="text-sm text-muted-foreground">🌤️ {{ format(new Date(), "EEEE, MMMM d, HH:mm a") }}</p>
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<Select :model-value="filter" @update:model-value="onFilterChange">
|
||||
<SelectTrigger class="w-[130px]">
|
||||
<SelectValue placeholder="Select a filter" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="me">
|
||||
Mine
|
||||
</SelectItem>
|
||||
<SelectItem value="global">
|
||||
Global
|
||||
</SelectItem>
|
||||
<SelectItem value="3">
|
||||
Funds team
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<CountCards :counts="counts" :labels="agentCountCardsLabels" />
|
||||
<!-- <AssignedByStatusDonut /> -->
|
||||
<div class="flex my-10">
|
||||
<ConversationsOverTime class="flex-1" :data=newConversationsStats />
|
||||
<div class="mt-7">
|
||||
<Card :counts="cardCounts" :labels="agentCountCardsLabels" />
|
||||
</div>
|
||||
<div class="flex my-7 justify-between items-center space-x-5">
|
||||
<LineChart :data="chartData.new_conversations" class="dashboard-card p-5"></LineChart>
|
||||
<BarChart :data="chartData.status_summary" class="dashboard-card p-5"></BarChart>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { format } from 'date-fns'
|
||||
import api from '@/api';
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
|
||||
import CountCards from '@/components/dashboard/agent/CountCards.vue'
|
||||
import ConversationsOverTime from '@/components/dashboard/agent/ConversationsOverTime.vue';
|
||||
import Card from '@/components/dashboard/agent/DashboardCard.vue'
|
||||
import LineChart from '@/components/dashboard/agent/DashboardLineChart.vue';
|
||||
import BarChart from '@/components/dashboard/agent/DashboardBarChart.vue';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
|
||||
const { toast } = useToast()
|
||||
const counts = ref({})
|
||||
const newConversationsStats = ref([])
|
||||
const cardCounts = ref({})
|
||||
const chartData = ref({})
|
||||
const filter = ref("me")
|
||||
const userStore = useUserStore()
|
||||
const agentCountCardsLabels = {
|
||||
total_assigned: "Total Assigned",
|
||||
total_count: "Total",
|
||||
resolved_count: "Resolved",
|
||||
unresolved_count: "Unresolved",
|
||||
awaiting_response_count: "Awaiting Response",
|
||||
created_today_count: "Created Today"
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
getCardStats()
|
||||
getUserDashoardChartsStats()
|
||||
getDashboardCharts()
|
||||
})
|
||||
|
||||
watch(filter, () => {
|
||||
getDashboardCharts()
|
||||
getCardStats()
|
||||
})
|
||||
|
||||
const onFilterChange = (v) => {
|
||||
filter.value = v
|
||||
}
|
||||
|
||||
const getCardStats = () => {
|
||||
api.getUserDashboardCounts().then((resp) => {
|
||||
counts.value = resp.data.data
|
||||
}).catch((err) => {
|
||||
toast({
|
||||
title: 'Uh oh! Something went wrong.',
|
||||
description: err.response.data.message,
|
||||
variant: 'destructive',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const getUserDashoardChartsStats = () => {
|
||||
api.getUserDashoardCharts().then((resp) => {
|
||||
newConversationsStats.value = resp.data.data
|
||||
}).catch((err) => {
|
||||
toast({
|
||||
title: 'Uh oh! Something went wrong.',
|
||||
description: err.response.data.message,
|
||||
variant: 'destructive',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function getGreeting () {
|
||||
const now = new Date();
|
||||
const hours = now.getHours();
|
||||
|
||||
if (hours >= 5 && hours < 12) {
|
||||
return "Good morning";
|
||||
} else if (hours >= 12 && hours < 17) {
|
||||
return "Good afternoon";
|
||||
} else if (hours >= 17 && hours < 21) {
|
||||
return "Good evening";
|
||||
} else {
|
||||
return "Good night";
|
||||
let apiCall;
|
||||
switch (filter.value) {
|
||||
case "global":
|
||||
apiCall = api.getGlobalDashboardCounts;
|
||||
break;
|
||||
case "me":
|
||||
apiCall = api.getUserDashboardCounts;
|
||||
break;
|
||||
case "team":
|
||||
apiCall = api.getTeamDashboardCounts;
|
||||
break;
|
||||
default:
|
||||
throw new Error("Invalid filter value");
|
||||
}
|
||||
}
|
||||
|
||||
apiCall().then((resp) => {
|
||||
cardCounts.value = resp.data.data;
|
||||
}).catch((err) => {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: err.response.data.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const getDashboardCharts = () => {
|
||||
let apiCall;
|
||||
switch (filter.value) {
|
||||
case "global":
|
||||
apiCall = api.getGlobalDashboardCharts;
|
||||
break;
|
||||
case "me":
|
||||
apiCall = api.getUserDashboardCharts;
|
||||
break;
|
||||
case "team":
|
||||
apiCall = api.getTeamDashboardCharts;
|
||||
break;
|
||||
default:
|
||||
throw new Error("Invalid filter value");
|
||||
}
|
||||
|
||||
apiCall().then((resp) => {
|
||||
chartData.value = resp.data.data;
|
||||
console.log("chart data ->", chartData.value);
|
||||
}).catch((err) => {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: err.response.data.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
@@ -1838,10 +1838,10 @@
|
||||
dependencies:
|
||||
lodash-es "^4.17.21"
|
||||
|
||||
"@unovis/ts@^1.4.1":
|
||||
version "1.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@unovis/ts/-/ts-1.4.1.tgz#dbb0cbadfe0d369fe7eac61d58b7746d37f1e882"
|
||||
integrity sha512-U0CoVWmLFTU/olNWNQT7Q9Ws0nTQRwd7jimITs7xxrKKj0M4ZHMHl4YaMTe6dY7UIhhxSSOh8K4LPEy6lCo1bg==
|
||||
"@unovis/ts@^1.4.3":
|
||||
version "1.4.3"
|
||||
resolved "https://registry.yarnpkg.com/@unovis/ts/-/ts-1.4.3.tgz#3e176e72d0db0c1c48ed5217bd35d32a082e37ba"
|
||||
integrity sha512-QYh1Qot1N9L6ZQg+uuhcsI3iuic9c6VVjlex3ipRqYDvrDDN6N+SG2555+9z+yAV6cbVsD1/EkMfK+o84PPjSw==
|
||||
dependencies:
|
||||
"@emotion/css" "^11.7.1"
|
||||
"@juggle/resize-observer" "^3.3.1"
|
||||
@@ -1876,10 +1876,10 @@
|
||||
topojson-client "^3.1.0"
|
||||
tslib "^2.3.1"
|
||||
|
||||
"@unovis/vue@^1.4.1":
|
||||
version "1.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@unovis/vue/-/vue-1.4.1.tgz#8ad86d51dd024a02ec6f3e7d9181088a91a18276"
|
||||
integrity sha512-LtsG7MsoUuPxsVDxx9zUDmswgVlUlLCJaxkf3qOr1Aowol0No9fwO0srv0BNd3kJpSx5iI+FnWye2QUXZE2QGA==
|
||||
"@unovis/vue@^1.4.3":
|
||||
version "1.4.3"
|
||||
resolved "https://registry.yarnpkg.com/@unovis/vue/-/vue-1.4.3.tgz#ddf2a8ef56cd6e7fce371a6ef0f220a72d26ff23"
|
||||
integrity sha512-L45ncN+e1dynA2cvHyq2ss+hLNBBPC1bl+i9JNveLufl+k7qybbt2IrioKBGQEbVmzXuJ80r2f3cD64i6ca9jg==
|
||||
|
||||
"@vee-validate/zod@^4.13.2":
|
||||
version "4.13.2"
|
||||
|
||||
@@ -46,12 +46,6 @@ type Auth struct {
|
||||
lo *logf.Logger
|
||||
}
|
||||
|
||||
// Callbacks takes two callback functions required by simplesessions.
|
||||
type Callbacks struct {
|
||||
SetCookie func(cookie *http.Cookie, w interface{}) error
|
||||
GetCookie func(name string, r interface{}) (*http.Cookie, error)
|
||||
}
|
||||
|
||||
// New inits a OIDC configuration
|
||||
func New(cfg Config, rd *redis.Client, logger *logf.Logger) (*Auth, error) {
|
||||
provider, err := oidc.NewProvider(context.Background(), cfg.OIDC.ProviderURL)
|
||||
@@ -74,7 +68,7 @@ func New(cfg Config, rd *redis.Client, logger *logf.Logger) (*Auth, error) {
|
||||
maxAge = time.Hour * 12
|
||||
}
|
||||
sess := simplesessions.New(simplesessions.Options{
|
||||
EnableAutoCreate: true,
|
||||
EnableAutoCreate: false,
|
||||
SessionIDLength: 64,
|
||||
Cookie: simplesessions.CookieOptions{
|
||||
IsHTTPOnly: true,
|
||||
@@ -128,7 +122,7 @@ func (a *Auth) ExchangeOIDCToken(ctx context.Context, code string) (string, OIDC
|
||||
|
||||
// SaveSession creates and sets a session (post successful login/auth).
|
||||
func (o *Auth) SaveSession(user models.User, r *fastglue.Request) error {
|
||||
sess, err := o.sess.NewSession(r.RequestCtx, r.RequestCtx)
|
||||
sess, err := o.sess.NewSession(r, r)
|
||||
if err != nil {
|
||||
o.lo.Error("error creating login session", "error", err)
|
||||
return err
|
||||
@@ -149,7 +143,7 @@ func (o *Auth) SaveSession(user models.User, r *fastglue.Request) error {
|
||||
|
||||
// ValidateSession validates session and returns the user.
|
||||
func (o *Auth) ValidateSession(r *fastglue.Request) (models.User, error) {
|
||||
sess, err := o.sess.Acquire(r.RequestCtx, r.RequestCtx, r.RequestCtx)
|
||||
sess, err := o.sess.Acquire(r.RequestCtx, r, r)
|
||||
if err != nil {
|
||||
return models.User{}, err
|
||||
}
|
||||
|
||||
@@ -138,19 +138,21 @@ func New(
|
||||
|
||||
type queries struct {
|
||||
// Conversation queries.
|
||||
GetLatestReceivedMessageSourceID *sqlx.Stmt `query:"get-latest-received-message-source-id"`
|
||||
GetToAddress *sqlx.Stmt `query:"get-to-address"`
|
||||
GetConversationID *sqlx.Stmt `query:"get-conversation-id"`
|
||||
GetConversationUUID *sqlx.Stmt `query:"get-conversation-uuid"`
|
||||
GetConversation *sqlx.Stmt `query:"get-conversation"`
|
||||
GetRecentConversations *sqlx.Stmt `query:"get-recent-conversations"`
|
||||
GetUnassignedConversations *sqlx.Stmt `query:"get-unassigned-conversations"`
|
||||
GetConversations string `query:"get-conversations"`
|
||||
GetConversationsUUIDs string `query:"get-conversations-uuids"`
|
||||
GetConversationParticipants *sqlx.Stmt `query:"get-conversation-participants"`
|
||||
GetAssignedConversations *sqlx.Stmt `query:"get-assigned-conversations"`
|
||||
GetConversationAssigneeStats *sqlx.Stmt `query:"get-assignee-stats"`
|
||||
GetNewConversationsStats *sqlx.Stmt `query:"get-new-conversations-stats"`
|
||||
GetLatestReceivedMessageSourceID *sqlx.Stmt `query:"get-latest-received-message-source-id"`
|
||||
GetToAddress *sqlx.Stmt `query:"get-to-address"`
|
||||
GetConversationID *sqlx.Stmt `query:"get-conversation-id"`
|
||||
GetConversationUUID *sqlx.Stmt `query:"get-conversation-uuid"`
|
||||
GetConversation *sqlx.Stmt `query:"get-conversation"`
|
||||
GetRecentConversations *sqlx.Stmt `query:"get-recent-conversations"`
|
||||
GetUnassignedConversations *sqlx.Stmt `query:"get-unassigned-conversations"`
|
||||
GetConversations string `query:"get-conversations"`
|
||||
GetConversationsUUIDs string `query:"get-conversations-uuids"`
|
||||
GetConversationParticipants *sqlx.Stmt `query:"get-conversation-participants"`
|
||||
GetAssignedConversations *sqlx.Stmt `query:"get-assigned-conversations"`
|
||||
|
||||
GetDashboardCharts string `query:"get-dashboard-charts"`
|
||||
GetDashboardCounts string `query:"get-dashboard-counts"`
|
||||
|
||||
UpdateConversationFirstReplyAt *sqlx.Stmt `query:"update-conversation-first-reply-at"`
|
||||
UpdateConversationAssigneeLastSeen *sqlx.Stmt `query:"update-conversation-assignee-last-seen"`
|
||||
UpdateConversationAssignedUser *sqlx.Stmt `query:"update-conversation-assigned-user"`
|
||||
@@ -471,28 +473,69 @@ func (c *Manager) UpdateConversationStatus(uuid string, status []byte, actor umo
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetConversationAssigneeStats retrieves the stats of conversations assigned to a specific user.
|
||||
func (c *Manager) GetConversationAssigneeStats(userID int) (models.ConversationCounts, error) {
|
||||
var counts = models.ConversationCounts{}
|
||||
if err := c.q.GetConversationAssigneeStats.Get(&counts, userID); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return counts, err
|
||||
}
|
||||
c.lo.Error("error fetching assignee conversation stats", "user_id", userID, "error", err)
|
||||
return counts, err
|
||||
// GetDashboardCounts returns dashboard counts
|
||||
func (c *Manager) GetDashboardCounts(userID, teamID int) (json.RawMessage, error) {
|
||||
var counts = json.RawMessage{}
|
||||
tx, err := c.db.BeginTxx(context.Background(), nil)
|
||||
if err != nil {
|
||||
c.lo.Error("error starting db txn", "error", err)
|
||||
return nil, envelope.NewError(envelope.GeneralError, "Error fetching dashboard counts", nil)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var (
|
||||
cond string
|
||||
qArgs []interface{}
|
||||
)
|
||||
if userID > 0 {
|
||||
cond = " AND assigned_user_id = $1"
|
||||
qArgs = append(qArgs, userID)
|
||||
} else if teamID > 0 {
|
||||
cond = " AND assigned_team_id = $1"
|
||||
qArgs = append(qArgs, teamID)
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(c.q.GetDashboardCounts, cond)
|
||||
if err := tx.Get(&counts, query, qArgs...); err != nil {
|
||||
c.lo.Error("error fetching dashboard counts", "error", err)
|
||||
return nil, envelope.NewError(envelope.GeneralError, "Error fetching dashboard counts", nil)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
c.lo.Error("error committing db txn", "error", err)
|
||||
return nil, envelope.NewError(envelope.GeneralError, "Error fetching dashboard counts", nil)
|
||||
}
|
||||
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
// GetNewConversationsStats retrieves the statistics of new conversations.
|
||||
func (c *Manager) GetNewConversationsStats() ([]models.NewConversationsStats, error) {
|
||||
var stats = make([]models.NewConversationsStats, 0)
|
||||
if err := c.q.GetNewConversationsStats.Select(&stats); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return stats, err
|
||||
}
|
||||
c.lo.Error("error fetching new conversation stats", "error", err)
|
||||
return stats, err
|
||||
// GetDashboardChartData returns dashboard chart data
|
||||
func (c *Manager) GetDashboardChartData(userID, teamID int) (json.RawMessage, error) {
|
||||
var stats = json.RawMessage{}
|
||||
tx, err := c.db.BeginTxx(context.Background(), nil)
|
||||
if err != nil {
|
||||
c.lo.Error("error starting db txn", "error", err)
|
||||
return nil, envelope.NewError(envelope.GeneralError, "Error fetching dashboard chart data", nil)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var (
|
||||
cond string
|
||||
qArgs []interface{}
|
||||
)
|
||||
if userID > 0 {
|
||||
cond = " AND assigned_user_id = $1"
|
||||
qArgs = append(qArgs, userID)
|
||||
} else if teamID > 0 {
|
||||
cond = " AND assigned_team_id = $1"
|
||||
qArgs = append(qArgs, teamID)
|
||||
}
|
||||
|
||||
// Apply the same condition across queries.
|
||||
query := fmt.Sprintf(c.q.GetDashboardCharts, cond, cond)
|
||||
if err := tx.Get(&stats, query, qArgs...); err != nil {
|
||||
c.lo.Error("error fetching dashboard charts", "error", err)
|
||||
return nil, envelope.NewError(envelope.GeneralError, "Error fetching dashboard charts", nil)
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
@@ -93,8 +93,8 @@ type ConversationCounts struct {
|
||||
}
|
||||
|
||||
type NewConversationsStats struct {
|
||||
Date time.Time `db:"date" json:"date"`
|
||||
NewConversations int `db:"new_conversations" json:"new_conversations"`
|
||||
Date string `db:"date" json:"date"`
|
||||
NewConversations int `db:"new_conversations" json:"new_conversations"`
|
||||
}
|
||||
|
||||
// Message represents a message in the database.
|
||||
|
||||
@@ -179,27 +179,52 @@ FROM conversations c
|
||||
JOIN contacts ct ON c.contact_id = ct.id
|
||||
JOIN inboxes inb on c.inbox_id = inb.id where assigned_user_id is NULL and assigned_team_id is not null;
|
||||
|
||||
-- name: get-assignee-stats
|
||||
SELECT
|
||||
COUNT(*) AS total_assigned,
|
||||
COUNT(CASE WHEN status NOT IN ('Resolved', 'Closed') THEN 1 END) AS unresolved_count,
|
||||
COUNT(CASE WHEN first_reply_at IS NULL THEN 1 END) AS awaiting_response_count,
|
||||
COUNT(CASE WHEN created_at::date = now()::date THEN 1 END) AS created_today_count
|
||||
FROM
|
||||
conversations
|
||||
WHERE
|
||||
assigned_user_id = $1;
|
||||
-- name: get-dashboard-counts
|
||||
SELECT json_build_object(
|
||||
'resolved_count', COUNT(CASE WHEN status IN ('Resolved') THEN 1 END),
|
||||
'unresolved_count', COUNT(CASE WHEN status NOT IN ('Resolved', 'Closed') THEN 1 END),
|
||||
'awaiting_response_count', COUNT(CASE WHEN first_reply_at IS NULL THEN 1 END),
|
||||
'total_count', COUNT(*)
|
||||
)
|
||||
FROM conversations
|
||||
WHERE 1=1 %s;
|
||||
|
||||
-- name: get-dashboard-charts
|
||||
WITH new_conversations AS (
|
||||
SELECT json_agg(row_to_json(agg)) AS data
|
||||
FROM (
|
||||
SELECT
|
||||
TO_CHAR(created_at::date, 'YYYY-MM-DD') AS date,
|
||||
COUNT(*) AS new_conversations
|
||||
FROM
|
||||
conversations
|
||||
WHERE 1=1 %s
|
||||
GROUP BY
|
||||
date
|
||||
ORDER BY
|
||||
date
|
||||
) agg
|
||||
),
|
||||
status_summary AS (
|
||||
SELECT json_agg(row_to_json(agg)) AS data
|
||||
FROM (
|
||||
SELECT
|
||||
status,
|
||||
COUNT(*) FILTER (WHERE priority = 'Low') AS "Low",
|
||||
COUNT(*) FILTER (WHERE priority = 'Medium') AS "Medium",
|
||||
COUNT(*) FILTER (WHERE priority = 'High') AS "High"
|
||||
FROM
|
||||
conversations
|
||||
WHERE 1=1 %s
|
||||
GROUP BY
|
||||
status
|
||||
) agg
|
||||
)
|
||||
SELECT json_build_object(
|
||||
'new_conversations', (SELECT data FROM new_conversations),
|
||||
'status_summary', (SELECT data FROM status_summary)
|
||||
) AS result;
|
||||
|
||||
-- name: get-new-conversations-stats
|
||||
SELECT
|
||||
created_at::date AS date,
|
||||
COUNT(*) AS new_conversations
|
||||
FROM
|
||||
conversations
|
||||
GROUP BY
|
||||
date
|
||||
ORDER BY
|
||||
date;
|
||||
|
||||
-- name: update-conversation-first-reply-at
|
||||
UPDATE conversations
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"io"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/abhinavxd/artemis/internal/dbutil"
|
||||
"github.com/abhinavxd/artemis/internal/envelope"
|
||||
@@ -68,11 +70,12 @@ func New(opt Opts) (*Manager, error) {
|
||||
|
||||
// queries holds the prepared SQL statements for media operations.
|
||||
type queries struct {
|
||||
InsertMedia *sqlx.Stmt `query:"insert-media"`
|
||||
GetMedia *sqlx.Stmt `query:"get-media"`
|
||||
DeleteMedia *sqlx.Stmt `query:"delete-media"`
|
||||
Attach *sqlx.Stmt `query:"attach-to-model"`
|
||||
GetModelMedia *sqlx.Stmt `query:"get-model-media"`
|
||||
InsertMedia *sqlx.Stmt `query:"insert-media"`
|
||||
GetMedia *sqlx.Stmt `query:"get-media"`
|
||||
DeleteMedia *sqlx.Stmt `query:"delete-media"`
|
||||
Attach *sqlx.Stmt `query:"attach-to-model"`
|
||||
GetModelMedia *sqlx.Stmt `query:"get-model-media"`
|
||||
GetUnlinkedMedia *sqlx.Stmt `query:"get-unlinked-media"`
|
||||
}
|
||||
|
||||
func (m *Manager) UploadAndInsert(fileName, contentType string, content io.ReadSeeker, fileSize int, meta []byte) (models.Media, error) {
|
||||
@@ -150,3 +153,38 @@ func (m *Manager) Delete(uuid string) {
|
||||
m.queries.DeleteMedia.Exec(uuid)
|
||||
m.Store.Delete(uuid)
|
||||
}
|
||||
|
||||
// CleanMedia periodically deletes media entries that are not linked to any model.
|
||||
// This function should be run as a goroutine to avoid blocking. It uses a ticker to
|
||||
// trigger the deletion process every 12 hours.
|
||||
func (m *Manager) CleanMedia(ctx context.Context) {
|
||||
ticker := time.NewTicker(12 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
m.deleteUnlinkedMedia()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// deleteUnlinkedMedia fetches media entries that are not linked to any model
|
||||
// and are older than 3 days, then deletes them.
|
||||
func (m *Manager) deleteUnlinkedMedia() {
|
||||
var unlinkedMedia []int
|
||||
if err := m.queries.GetUnlinkedMedia.Select(&unlinkedMedia, time.Now().AddDate(0, 0, -3)); err != nil {
|
||||
m.lo.Error("error fetching unlinked media", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
m.lo.Info("found unlinked media to delete", "count", len(unlinkedMedia))
|
||||
|
||||
for _, id := range unlinkedMedia {
|
||||
_, err := m.queries.DeleteMedia.Exec(id)
|
||||
if err != nil {
|
||||
m.lo.Error("error deleting unlinked media", "ID", id, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,34 @@
|
||||
-- name: insert-media
|
||||
INSERT INTO media
|
||||
(store, filename, content_type, size, meta)
|
||||
INSERT INTO media (store, filename, content_type, size, meta)
|
||||
VALUES($1, $2, $3, $4, $5)
|
||||
RETURNING id;
|
||||
|
||||
-- name: get-media
|
||||
SELECT * from media where id = $1;
|
||||
SELECT *
|
||||
from media
|
||||
where id = $1;
|
||||
|
||||
-- name: delete-media
|
||||
DELETE from media where id = $1;
|
||||
DELETE from media
|
||||
where id = $1;
|
||||
|
||||
-- name: attach-to-model
|
||||
UPDATE media SET model_type = $2, model_id = $3 WHERE id = $1;
|
||||
UPDATE media
|
||||
SET model_type = $2,
|
||||
model_id = $3
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: get-model-media
|
||||
SELECT * FROM media WHERE model_type = $1 AND model_id = $2;
|
||||
SELECT *
|
||||
FROM media
|
||||
WHERE model_type = $1
|
||||
AND model_id = $2;
|
||||
|
||||
-- name: get-unlinked-media
|
||||
SELECT id
|
||||
FROM media
|
||||
WHERE (
|
||||
model_type IS NULL
|
||||
OR model_id IS NULL
|
||||
)
|
||||
AND created_at > $1;
|
||||
Reference in New Issue
Block a user