mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-11-14 10:55:48 +00:00
feat: dashboard.
This commit is contained in:
10
cmd/auth.go
10
cmd/auth.go
@@ -1,8 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"github.com/abhinavxd/artemis/internal/envelope"
|
||||||
|
|
||||||
"github.com/abhinavxd/artemis/internal/stringutil"
|
"github.com/abhinavxd/artemis/internal/stringutil"
|
||||||
|
|
||||||
"github.com/valyala/fasthttp"
|
"github.com/valyala/fasthttp"
|
||||||
@@ -14,9 +13,12 @@ func handleOIDCLogin(r *fastglue.Request) error {
|
|||||||
var (
|
var (
|
||||||
app = r.Context.(*App)
|
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)
|
authURL := app.auth.LoginURL(state)
|
||||||
fmt.Println("url ", authURL)
|
|
||||||
return r.Redirect(authURL, fasthttp.StatusFound, nil, "")
|
return r.Redirect(authURL, fasthttp.StatusFound, nil, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ func handleUserDashboardCounts(r *fastglue.Request) error {
|
|||||||
user = r.RequestCtx.UserValue("user").(umodels.User)
|
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 {
|
if err != nil {
|
||||||
return sendErrorEnvelope(r, err)
|
return sendErrorEnvelope(r, err)
|
||||||
}
|
}
|
||||||
@@ -189,8 +189,62 @@ func handleUserDashboardCounts(r *fastglue.Request) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleUserDashboardCharts(r *fastglue.Request) error {
|
func handleUserDashboardCharts(r *fastglue.Request) error {
|
||||||
var app = r.Context.(*App)
|
var (
|
||||||
stats, err := app.conversation.GetNewConversationsStats()
|
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 {
|
if err != nil {
|
||||||
return sendErrorEnvelope(r, err)
|
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) {
|
func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
||||||
g.POST("/api/login", handleLogin)
|
g.POST("/api/login", handleLogin)
|
||||||
g.GET("/api/logout", handleLogout)
|
g.GET("/api/logout", handleLogout)
|
||||||
g.GET("/auth/oidc/login", handleOIDCLogin)
|
g.GET("/api/oidc/login", handleOIDCLogin)
|
||||||
g.GET("/auth/oidc/finish", handleOIDCCallback)
|
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.
|
// Conversation.
|
||||||
g.GET("/api/conversations/all", aauth(handleGetAllConversations, "conversation:all"))
|
g.GET("/api/conversations/all", perm(handleGetAllConversations, "conversation:all"))
|
||||||
g.GET("/api/conversations/team", aauth(handleGetTeamConversations, "conversation:team"))
|
g.GET("/api/conversations/team", perm(handleGetTeamConversations, "conversation:team"))
|
||||||
g.GET("/api/conversations/assigned", aauth(handleGetAssignedConversations, "conversation:assigned"))
|
g.GET("/api/conversations/assigned", perm(handleGetAssignedConversations, "conversation:assigned"))
|
||||||
g.GET("/api/conversations/{uuid}", aauth(handleGetConversation))
|
g.GET("/api/conversations/{uuid}", perm(handleGetConversation))
|
||||||
g.GET("/api/conversations/{uuid}/participants", aauth(handleGetConversationParticipants))
|
g.GET("/api/conversations/{uuid}/participants", perm(handleGetConversationParticipants))
|
||||||
g.PUT("/api/conversations/{uuid}/last-seen", aauth(handleUpdateAssigneeLastSeen))
|
g.PUT("/api/conversations/{uuid}/last-seen", perm(handleUpdateAssigneeLastSeen))
|
||||||
g.PUT("/api/conversations/{uuid}/assignee/user", aauth(handleUpdateUserAssignee))
|
g.PUT("/api/conversations/{uuid}/assignee/user", perm(handleUpdateUserAssignee))
|
||||||
g.PUT("/api/conversations/{uuid}/assignee/team", aauth(handleUpdateTeamAssignee))
|
g.PUT("/api/conversations/{uuid}/assignee/team", perm(handleUpdateTeamAssignee))
|
||||||
g.PUT("/api/conversations/{uuid}/priority", aauth(handleUpdatePriority, "conversation:edit_priority"))
|
g.PUT("/api/conversations/{uuid}/priority", perm(handleUpdatePriority, "conversation:edit_priority"))
|
||||||
g.PUT("/api/conversations/{uuid}/status", aauth(handleUpdateStatus, "conversation:edit_status"))
|
g.PUT("/api/conversations/{uuid}/status", perm(handleUpdateStatus, "conversation:edit_status"))
|
||||||
g.POST("/api/conversations/{uuid}/tags", aauth(handleAddConversationTags))
|
g.POST("/api/conversations/{uuid}/tags", perm(handleAddConversationTags))
|
||||||
g.GET("/api/conversations/{uuid}/messages", aauth(handleGetMessages))
|
g.GET("/api/conversations/{uuid}/messages", perm(handleGetMessages))
|
||||||
g.POST("/api/conversations/{uuid}/messages", aauth(handleSendMessage))
|
g.POST("/api/conversations/{uuid}/messages", perm(handleSendMessage))
|
||||||
g.GET("/api/message/{uuid}/retry", aauth(handleRetryMessage))
|
g.GET("/api/message/{uuid}/retry", perm(handleRetryMessage))
|
||||||
g.GET("/api/message/{uuid}", aauth(handleGetMessage))
|
g.GET("/api/message/{uuid}", perm(handleGetMessage))
|
||||||
|
|
||||||
// Media.
|
// Media.
|
||||||
g.POST("/api/media", aauth(handleMediaUpload))
|
g.POST("/api/media", perm(handleMediaUpload))
|
||||||
|
|
||||||
// Canned response.
|
// Canned response.
|
||||||
g.GET("/api/canned-responses", aauth(handleGetCannedResponses))
|
g.GET("/api/canned-responses", perm(handleGetCannedResponses))
|
||||||
|
|
||||||
// User.
|
// User.
|
||||||
g.GET("/api/users/me", aauth(handleGetCurrentUser, "users:manage"))
|
g.GET("/api/users/me", perm(handleGetCurrentUser, "users:manage"))
|
||||||
g.GET("/api/users", aauth(handleGetUsers, "users:manage"))
|
g.GET("/api/users", perm(handleGetUsers, "users:manage"))
|
||||||
g.GET("/api/users/{id}", aauth(handleGetUser, "users:manage"))
|
g.GET("/api/users/{id}", perm(handleGetUser, "users:manage"))
|
||||||
g.PUT("/api/users/{id}", aauth(handleUpdateUser, "users:manage"))
|
g.PUT("/api/users/{id}", perm(handleUpdateUser, "users:manage"))
|
||||||
g.POST("/api/users", aauth(handleCreateUser, "users:manage"))
|
g.POST("/api/users", perm(handleCreateUser, "users:manage"))
|
||||||
|
|
||||||
// Team.
|
// Team.
|
||||||
g.GET("/api/teams", aauth(handleGetTeams, "teams:manage"))
|
g.GET("/api/teams", perm(handleGetTeams, "teams:manage"))
|
||||||
g.GET("/api/teams/{id}", aauth(handleGetTeam, "teams:manage"))
|
g.GET("/api/teams/{id}", perm(handleGetTeam, "teams:manage"))
|
||||||
g.PUT("/api/teams/{id}", aauth(handleUpdateTeam, "teams:manage"))
|
g.PUT("/api/teams/{id}", perm(handleUpdateTeam, "teams:manage"))
|
||||||
g.POST("/api/teams", aauth(handleCreateTeam, "teams:manage"))
|
g.POST("/api/teams", perm(handleCreateTeam, "teams:manage"))
|
||||||
|
|
||||||
// Tags.
|
// Tags.
|
||||||
g.GET("/api/tags", aauth(handleGetTags))
|
g.GET("/api/tags", perm(handleGetTags))
|
||||||
|
|
||||||
// i18n.
|
// i18n.
|
||||||
g.GET("/api/lang/{lang}", handleGetI18nLang)
|
g.GET("/api/lang/{lang}", handleGetI18nLang)
|
||||||
|
|
||||||
// Websocket.
|
|
||||||
g.GET("/api/ws", aauth(func(r *fastglue.Request) error {
|
|
||||||
return handleWS(r, hub)
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Automation rules.
|
// Automation rules.
|
||||||
g.GET("/api/automation/rules", aauth(handleGetAutomationRules, "automations:manage"))
|
g.GET("/api/automation/rules", perm(handleGetAutomationRules, "automations:manage"))
|
||||||
g.GET("/api/automation/rules/{id}", aauth(handleGetAutomationRule, "automations:manage"))
|
g.GET("/api/automation/rules/{id}", perm(handleGetAutomationRule, "automations:manage"))
|
||||||
g.POST("/api/automation/rules", aauth(handleCreateAutomationRule, "automations:manage"))
|
g.POST("/api/automation/rules", perm(handleCreateAutomationRule, "automations:manage"))
|
||||||
g.PUT("/api/automation/rules/{id}/toggle", aauth(handleToggleAutomationRule, "automations:manage"))
|
g.PUT("/api/automation/rules/{id}/toggle", perm(handleToggleAutomationRule, "automations:manage"))
|
||||||
g.PUT("/api/automation/rules/{id}", aauth(handleUpdateAutomationRule, "automations:manage"))
|
g.PUT("/api/automation/rules/{id}", perm(handleUpdateAutomationRule, "automations:manage"))
|
||||||
g.DELETE("/api/automation/rules/{id}", aauth(handleDeleteAutomationRule, "automations:manage"))
|
g.DELETE("/api/automation/rules/{id}", perm(handleDeleteAutomationRule, "automations:manage"))
|
||||||
|
|
||||||
// Inboxes.
|
// Inboxes.
|
||||||
g.GET("/api/inboxes", aauth(handleGetInboxes, "inboxes:manage"))
|
g.GET("/api/inboxes", perm(handleGetInboxes, "inboxes:manage"))
|
||||||
g.GET("/api/inboxes/{id}", aauth(handleGetInbox, "inboxes:manage"))
|
g.GET("/api/inboxes/{id}", perm(handleGetInbox, "inboxes:manage"))
|
||||||
g.POST("/api/inboxes", aauth(handleCreateInbox, "inboxes:manage"))
|
g.POST("/api/inboxes", perm(handleCreateInbox, "inboxes:manage"))
|
||||||
g.PUT("/api/inboxes/{id}/toggle", aauth(handleToggleInbox, "inboxes:manage"))
|
g.PUT("/api/inboxes/{id}/toggle", perm(handleToggleInbox, "inboxes:manage"))
|
||||||
g.PUT("/api/inboxes/{id}", aauth(handleUpdateInbox, "inboxes:manage"))
|
g.PUT("/api/inboxes/{id}", perm(handleUpdateInbox, "inboxes:manage"))
|
||||||
g.DELETE("/api/inboxes/{id}", aauth(handleDeleteInbox, "inboxes:manage"))
|
g.DELETE("/api/inboxes/{id}", perm(handleDeleteInbox, "inboxes:manage"))
|
||||||
|
|
||||||
// Roles.
|
// Roles.
|
||||||
g.GET("/api/roles", aauth(handleGetRoles, "roles:manage"))
|
g.GET("/api/roles", perm(handleGetRoles, "roles:manage"))
|
||||||
g.GET("/api/roles/{id}", aauth(handleGetRole, "roles:manage"))
|
g.GET("/api/roles/{id}", perm(handleGetRole, "roles:manage"))
|
||||||
g.POST("/api/roles", aauth(handleCreateRole, "roles:manage"))
|
g.POST("/api/roles", perm(handleCreateRole, "roles:manage"))
|
||||||
g.PUT("/api/roles/{id}", aauth(handleUpdateRole, "roles:manage"))
|
g.PUT("/api/roles/{id}", perm(handleUpdateRole, "roles:manage"))
|
||||||
g.DELETE("/api/roles/{id}", aauth(handleDeleteRole, "roles:manage"))
|
g.DELETE("/api/roles/{id}", perm(handleDeleteRole, "roles:manage"))
|
||||||
|
|
||||||
// Dashboard.
|
// Dashboard.
|
||||||
g.GET("/api/dashboard/me/counts", aauth(handleUserDashboardCounts))
|
g.GET("/api/dashboard/global/counts", perm(handleDashboardCounts))
|
||||||
g.GET("/api/dashboard/me/charts", aauth(handleUserDashboardCharts))
|
g.GET("/api/dashboard/global/charts", perm(handleDashboardCharts))
|
||||||
// g.GET("/api/dashboard/team/:teamName/counts", aauth(handleTeamCounts))
|
g.GET("/api/dashboard/team/{team_id}/counts", perm(handleTeamDashboardCounts))
|
||||||
// g.GET("/api/dashboard/team/:teamName/charts", aauth(handleTeamCharts))
|
g.GET("/api/dashboard/team/{team_id}/charts", perm(handleTeamDashboardCharts))
|
||||||
// g.GET("/api/dashboard/global/counts", aauth(handleGlobalCounts))
|
g.GET("/api/dashboard/me/counts", perm(handleUserDashboardCounts))
|
||||||
// g.GET("/api/dashboard/global/charts", aauth(handleGlobalCharts))
|
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.
|
// Frontend pages.
|
||||||
g.GET("/", sess(noAuthPage(serveIndexPage)))
|
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))
|
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/team"
|
||||||
"github.com/abhinavxd/artemis/internal/user"
|
"github.com/abhinavxd/artemis/internal/user"
|
||||||
"github.com/abhinavxd/artemis/internal/ws"
|
"github.com/abhinavxd/artemis/internal/ws"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
"github.com/knadh/go-i18n"
|
"github.com/knadh/go-i18n"
|
||||||
"github.com/knadh/koanf/v2"
|
"github.com/knadh/koanf/v2"
|
||||||
"github.com/knadh/stuffbin"
|
"github.com/knadh/stuffbin"
|
||||||
@@ -34,18 +35,14 @@ var (
|
|||||||
ko = koanf.New(".")
|
ko = koanf.New(".")
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
// ANSI escape colour codes.
|
|
||||||
colourRed = "\x1b[31m"
|
|
||||||
colourGreen = "\x1b[32m"
|
|
||||||
)
|
|
||||||
|
|
||||||
// App is the global app context which is passed and injected in the http handlers.
|
// App is the global app context which is passed and injected in the http handlers.
|
||||||
type App struct {
|
type App struct {
|
||||||
constant constants
|
constant constants
|
||||||
|
db *sqlx.DB
|
||||||
|
rdb *redis.Client
|
||||||
auth *auth.Auth
|
auth *auth.Auth
|
||||||
fs stuffbin.FileSystem
|
fs stuffbin.FileSystem
|
||||||
rdb *redis.Client
|
|
||||||
i18n *i18n.I18n
|
i18n *i18n.I18n
|
||||||
lo *logf.Logger
|
lo *logf.Logger
|
||||||
media *media.Manager
|
media *media.Manager
|
||||||
@@ -112,6 +109,9 @@ func main() {
|
|||||||
// Listen to incoming messages and dispatch pending outgoing messages.
|
// 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"))
|
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
|
// Init the app
|
||||||
var app = &App{
|
var app = &App{
|
||||||
lo: lo,
|
lo: lo,
|
||||||
@@ -156,7 +156,7 @@ func main() {
|
|||||||
// Wait for the interruption signal
|
// Wait for the interruption signal
|
||||||
<-ctx.Done()
|
<-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)
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ func main() {
|
|||||||
stop()
|
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 {
|
if err := g.ListenServeAndWaitGracefully(ko.String("app.server.address"), ko.String("server.socket"), s, shutdownCh); err != nil {
|
||||||
log.Fatalf("error starting frontend server: %v", err)
|
log.Fatalf("error starting frontend server: %v", err)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/abhinavxd/artemis/internal/envelope"
|
"github.com/abhinavxd/artemis/internal/envelope"
|
||||||
@@ -10,16 +9,13 @@ import (
|
|||||||
"github.com/zerodha/fastglue"
|
"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 {
|
return func(r *fastglue.Request) error {
|
||||||
var app = r.Context.(*App)
|
var app = r.Context.(*App)
|
||||||
user, err := app.auth.ValidateSession(r)
|
user, err := app.auth.ValidateSession(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.SendErrorEnvelope(http.StatusUnauthorized, "Invalid or expired session", nil, envelope.PermissionError)
|
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.
|
// User is loggedin, Set user in the request context.
|
||||||
r.RequestCtx.SetUserValue("user", user)
|
r.RequestCtx.SetUserValue("user", user)
|
||||||
return handler(r)
|
return handler(r)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ var upgrader = websocket.FastHTTPUpgrader{
|
|||||||
ReadBufferSize: 1024,
|
ReadBufferSize: 1024,
|
||||||
WriteBufferSize: 1024,
|
WriteBufferSize: 1024,
|
||||||
CheckOrigin: func(ctx *fasthttp.RequestCtx) bool {
|
CheckOrigin: func(ctx *fasthttp.RequestCtx) bool {
|
||||||
return true // Allow all origins in development
|
return true
|
||||||
},
|
},
|
||||||
Error: ErrHandler,
|
Error: ErrHandler,
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@ func handleWS(r *fastglue.Request, hub *ws.Hub) error {
|
|||||||
ID: user.ID,
|
ID: user.ID,
|
||||||
Hub: hub,
|
Hub: hub,
|
||||||
Conn: conn,
|
Conn: conn,
|
||||||
Send: make(chan ws.Message, 10000),
|
Send: make(chan ws.Message, 1000),
|
||||||
}
|
}
|
||||||
hub.AddClient(&c)
|
hub.AddClient(&c)
|
||||||
go c.Listen()
|
go c.Listen()
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<link
|
<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"
|
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">
|
rel="stylesheet">
|
||||||
<title>Artemis App</title>
|
<title>Vite App</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -28,8 +28,8 @@
|
|||||||
"@tiptap/starter-kit": "^2.4.0",
|
"@tiptap/starter-kit": "^2.4.0",
|
||||||
"@tiptap/suggestion": "^2.4.0",
|
"@tiptap/suggestion": "^2.4.0",
|
||||||
"@tiptap/vue-3": "^2.4.0",
|
"@tiptap/vue-3": "^2.4.0",
|
||||||
"@unovis/ts": "^1.4.1",
|
"@unovis/ts": "^1.4.3",
|
||||||
"@unovis/vue": "^1.4.1",
|
"@unovis/vue": "^1.4.3",
|
||||||
"@vee-validate/zod": "^4.13.2",
|
"@vee-validate/zod": "^4.13.2",
|
||||||
"@vue/reactivity": "^3.4.15",
|
"@vue/reactivity": "^3.4.15",
|
||||||
"@vue/runtime-core": "^3.4.15",
|
"@vue/runtime-core": "^3.4.15",
|
||||||
|
|||||||
@@ -97,8 +97,12 @@ const uploadMedia = data =>
|
|||||||
'Content-Type': 'multipart/form-data',
|
'Content-Type': 'multipart/form-data',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const getUserDashboardCounts = () => http.get('/api/dashboard/me/counts');
|
const getGlobalDashboardCounts = () => http.get('/api/dashboard/global/counts');
|
||||||
const getUserDashoardCharts = () => http.get('/api/dashboard/me/charts');
|
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 getLanguage = lang => http.get(`/api/lang/${lang}`);
|
||||||
const createUser = data => http.post('/api/users', data, {
|
const createUser = data => http.post('/api/users', data, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -150,11 +154,15 @@ export default {
|
|||||||
getAssignedConversations,
|
getAssignedConversations,
|
||||||
getTeamConversations,
|
getTeamConversations,
|
||||||
getAllConversations,
|
getAllConversations,
|
||||||
|
getTeamDashboardCounts,
|
||||||
|
getTeamDashboardCharts,
|
||||||
|
getGlobalDashboardCharts,
|
||||||
|
getGlobalDashboardCounts,
|
||||||
getUserDashboardCounts,
|
getUserDashboardCounts,
|
||||||
|
getUserDashboardCharts,
|
||||||
getConversationParticipants,
|
getConversationParticipants,
|
||||||
getMessage,
|
getMessage,
|
||||||
getMessages,
|
getMessages,
|
||||||
getUserDashoardCharts,
|
|
||||||
getCurrentUser,
|
getCurrentUser,
|
||||||
getCannedResponses,
|
getCannedResponses,
|
||||||
updateAssignee,
|
updateAssignee,
|
||||||
|
|||||||
@@ -10,75 +10,74 @@
|
|||||||
|
|
||||||
.page-content {
|
.page-content {
|
||||||
padding: 1rem 1rem;
|
padding: 1rem 1rem;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Theme.
|
// Theme.
|
||||||
|
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 100%;
|
--background: 0 0% 100%;
|
||||||
--foreground: 20 14.3% 4.1%;
|
--foreground: 20 14.3% 4.1%;
|
||||||
|
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
--card-foreground: 20 14.3% 4.1%;
|
--card-foreground: 20 14.3% 4.1%;
|
||||||
|
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 20 14.3% 4.1%;
|
--popover-foreground: 20 14.3% 4.1%;
|
||||||
|
|
||||||
--primary: 24 9.8% 10%;
|
--primary: 24 9.8% 10%;
|
||||||
--primary-foreground: 60 9.1% 97.8%;
|
--primary-foreground: 60 9.1% 97.8%;
|
||||||
|
|
||||||
--secondary: 60 4.8% 95.9%;
|
--secondary: 60 4.8% 95.9%;
|
||||||
--secondary-foreground: 24 9.8% 10%;
|
--secondary-foreground: 24 9.8% 10%;
|
||||||
|
|
||||||
--muted: 60 4.8% 95.9%;
|
--muted: 60 4.8% 95.9%;
|
||||||
--muted-foreground: 25 5.3% 44.7%;
|
--muted-foreground: 25 5.3% 44.7%;
|
||||||
|
|
||||||
--accent: 60 4.8% 95.9%;
|
--accent: 60 4.8% 95.9%;
|
||||||
--accent-foreground: 24 9.8% 10%;
|
--accent-foreground: 24 9.8% 10%;
|
||||||
|
|
||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 0 84.2% 60.2%;
|
||||||
--destructive-foreground: 60 9.1% 97.8%;
|
--destructive-foreground: 60 9.1% 97.8%;
|
||||||
|
|
||||||
--border:20 5.9% 90%;
|
--border: 20 5.9% 90%;
|
||||||
--input:20 5.9% 90%;
|
--input: 20 5.9% 90%;
|
||||||
--ring:20 14.3% 4.1%;
|
--ring: 20 14.3% 4.1%;
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background:20 14.3% 4.1%;
|
--background: 20 14.3% 4.1%;
|
||||||
--foreground:60 9.1% 97.8%;
|
--foreground: 60 9.1% 97.8%;
|
||||||
|
|
||||||
--card:20 14.3% 4.1%;
|
--card: 20 14.3% 4.1%;
|
||||||
--card-foreground:60 9.1% 97.8%;
|
--card-foreground: 60 9.1% 97.8%;
|
||||||
|
|
||||||
--popover:20 14.3% 4.1%;
|
--popover: 20 14.3% 4.1%;
|
||||||
--popover-foreground:60 9.1% 97.8%;
|
--popover-foreground: 60 9.1% 97.8%;
|
||||||
|
|
||||||
--primary:60 9.1% 97.8%;
|
--primary: 60 9.1% 97.8%;
|
||||||
--primary-foreground:24 9.8% 10%;
|
--primary-foreground: 24 9.8% 10%;
|
||||||
|
|
||||||
--secondary:12 6.5% 15.1%;
|
--secondary: 12 6.5% 15.1%;
|
||||||
--secondary-foreground:60 9.1% 97.8%;
|
--secondary-foreground: 60 9.1% 97.8%;
|
||||||
|
|
||||||
--muted:12 6.5% 15.1%;
|
--muted: 12 6.5% 15.1%;
|
||||||
--muted-foreground:24 5.4% 63.9%;
|
--muted-foreground: 24 5.4% 63.9%;
|
||||||
|
|
||||||
--accent:12 6.5% 15.1%;
|
--accent: 12 6.5% 15.1%;
|
||||||
--accent-foreground:60 9.1% 97.8%;
|
--accent-foreground: 60 9.1% 97.8%;
|
||||||
|
|
||||||
--destructive:0 62.8% 30.6%;
|
--destructive: 0 62.8% 30.6%;
|
||||||
--destructive-foreground:60 9.1% 97.8%;
|
--destructive-foreground: 60 9.1% 97.8%;
|
||||||
|
|
||||||
--border:12 6.5% 15.1%;
|
--border: 12 6.5% 15.1%;
|
||||||
--input:12 6.5% 15.1%;
|
--input: 12 6.5% 15.1%;
|
||||||
--ring:24 5.7% 82.9%;
|
--ring: 24 5.7% 82.9%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--vis-tooltip-background-color: none !important;
|
--vis-tooltip-background-color: none !important;
|
||||||
@@ -185,3 +184,11 @@ h4 {
|
|||||||
.admin-subtitle {
|
.admin-subtitle {
|
||||||
@apply text-sm font-medium;
|
@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>
|
<template>
|
||||||
<div class="flex mt-5 gap-x-7">
|
<div class="flex gap-x-5">
|
||||||
<Card class="w-1/6" v-for="(value, key) in counts" :key="key">
|
<Card class="w-1/6 dashboard-card" v-for="(value, key) in counts" :key="key">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle class="text-4xl">
|
<CardTitle class="text-2xl">
|
||||||
{{ value }}
|
{{ value }}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
Card,
|
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>
|
<template>
|
||||||
<div class="page-content">
|
<div class="page-content w-11/12">
|
||||||
<div v-if="userStore.getFullName">
|
<div class="flex flex-col space-y-5">
|
||||||
<span class="scroll-m-20 text-xl font-medium">
|
<div>
|
||||||
<p>Hi, {{ userStore.getFullName }}</p>
|
<span class="font-semibold text-xl" v-if="userStore.getFullName">
|
||||||
<p class="text-sm text-muted-foreground">🌤️ {{ format(new Date(), "EEEE, MMMM d HH:mm a") }}</p>
|
<p>Hi, {{ userStore.getFullName }}</p>
|
||||||
</span>
|
<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>
|
</div>
|
||||||
<CountCards :counts="counts" :labels="agentCountCardsLabels" />
|
<div class="mt-7">
|
||||||
<!-- <AssignedByStatusDonut /> -->
|
<Card :counts="cardCounts" :labels="agentCountCardsLabels" />
|
||||||
<div class="flex my-10">
|
</div>
|
||||||
<ConversationsOverTime class="flex-1" :data=newConversationsStats />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref, watch } from 'vue';
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import api from '@/api';
|
import api from '@/api';
|
||||||
import { useToast } from '@/components/ui/toast/use-toast'
|
import { useToast } from '@/components/ui/toast/use-toast'
|
||||||
|
|
||||||
import CountCards from '@/components/dashboard/agent/CountCards.vue'
|
import Card from '@/components/dashboard/agent/DashboardCard.vue'
|
||||||
import ConversationsOverTime from '@/components/dashboard/agent/ConversationsOverTime.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 { toast } = useToast()
|
||||||
const counts = ref({})
|
const cardCounts = ref({})
|
||||||
const newConversationsStats = ref([])
|
const chartData = ref({})
|
||||||
|
const filter = ref("me")
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const agentCountCardsLabels = {
|
const agentCountCardsLabels = {
|
||||||
total_assigned: "Total Assigned",
|
total_count: "Total",
|
||||||
|
resolved_count: "Resolved",
|
||||||
unresolved_count: "Unresolved",
|
unresolved_count: "Unresolved",
|
||||||
awaiting_response_count: "Awaiting Response",
|
awaiting_response_count: "Awaiting Response",
|
||||||
created_today_count: "Created Today"
|
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
getCardStats()
|
getCardStats()
|
||||||
getUserDashoardChartsStats()
|
getDashboardCharts()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(filter, () => {
|
||||||
|
getDashboardCharts()
|
||||||
|
getCardStats()
|
||||||
|
})
|
||||||
|
|
||||||
|
const onFilterChange = (v) => {
|
||||||
|
filter.value = v
|
||||||
|
}
|
||||||
|
|
||||||
const getCardStats = () => {
|
const getCardStats = () => {
|
||||||
api.getUserDashboardCounts().then((resp) => {
|
let apiCall;
|
||||||
counts.value = resp.data.data
|
switch (filter.value) {
|
||||||
}).catch((err) => {
|
case "global":
|
||||||
toast({
|
apiCall = api.getGlobalDashboardCounts;
|
||||||
title: 'Uh oh! Something went wrong.',
|
break;
|
||||||
description: err.response.data.message,
|
case "me":
|
||||||
variant: 'destructive',
|
apiCall = api.getUserDashboardCounts;
|
||||||
})
|
break;
|
||||||
})
|
case "team":
|
||||||
}
|
apiCall = api.getTeamDashboardCounts;
|
||||||
|
break;
|
||||||
const getUserDashoardChartsStats = () => {
|
default:
|
||||||
api.getUserDashoardCharts().then((resp) => {
|
throw new Error("Invalid filter value");
|
||||||
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";
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
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>
|
</script>
|
||||||
|
|||||||
@@ -1838,10 +1838,10 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
lodash-es "^4.17.21"
|
lodash-es "^4.17.21"
|
||||||
|
|
||||||
"@unovis/ts@^1.4.1":
|
"@unovis/ts@^1.4.3":
|
||||||
version "1.4.1"
|
version "1.4.3"
|
||||||
resolved "https://registry.yarnpkg.com/@unovis/ts/-/ts-1.4.1.tgz#dbb0cbadfe0d369fe7eac61d58b7746d37f1e882"
|
resolved "https://registry.yarnpkg.com/@unovis/ts/-/ts-1.4.3.tgz#3e176e72d0db0c1c48ed5217bd35d32a082e37ba"
|
||||||
integrity sha512-U0CoVWmLFTU/olNWNQT7Q9Ws0nTQRwd7jimITs7xxrKKj0M4ZHMHl4YaMTe6dY7UIhhxSSOh8K4LPEy6lCo1bg==
|
integrity sha512-QYh1Qot1N9L6ZQg+uuhcsI3iuic9c6VVjlex3ipRqYDvrDDN6N+SG2555+9z+yAV6cbVsD1/EkMfK+o84PPjSw==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@emotion/css" "^11.7.1"
|
"@emotion/css" "^11.7.1"
|
||||||
"@juggle/resize-observer" "^3.3.1"
|
"@juggle/resize-observer" "^3.3.1"
|
||||||
@@ -1876,10 +1876,10 @@
|
|||||||
topojson-client "^3.1.0"
|
topojson-client "^3.1.0"
|
||||||
tslib "^2.3.1"
|
tslib "^2.3.1"
|
||||||
|
|
||||||
"@unovis/vue@^1.4.1":
|
"@unovis/vue@^1.4.3":
|
||||||
version "1.4.1"
|
version "1.4.3"
|
||||||
resolved "https://registry.yarnpkg.com/@unovis/vue/-/vue-1.4.1.tgz#8ad86d51dd024a02ec6f3e7d9181088a91a18276"
|
resolved "https://registry.yarnpkg.com/@unovis/vue/-/vue-1.4.3.tgz#ddf2a8ef56cd6e7fce371a6ef0f220a72d26ff23"
|
||||||
integrity sha512-LtsG7MsoUuPxsVDxx9zUDmswgVlUlLCJaxkf3qOr1Aowol0No9fwO0srv0BNd3kJpSx5iI+FnWye2QUXZE2QGA==
|
integrity sha512-L45ncN+e1dynA2cvHyq2ss+hLNBBPC1bl+i9JNveLufl+k7qybbt2IrioKBGQEbVmzXuJ80r2f3cD64i6ca9jg==
|
||||||
|
|
||||||
"@vee-validate/zod@^4.13.2":
|
"@vee-validate/zod@^4.13.2":
|
||||||
version "4.13.2"
|
version "4.13.2"
|
||||||
|
|||||||
@@ -46,12 +46,6 @@ type Auth struct {
|
|||||||
lo *logf.Logger
|
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
|
// New inits a OIDC configuration
|
||||||
func New(cfg Config, rd *redis.Client, logger *logf.Logger) (*Auth, error) {
|
func New(cfg Config, rd *redis.Client, logger *logf.Logger) (*Auth, error) {
|
||||||
provider, err := oidc.NewProvider(context.Background(), cfg.OIDC.ProviderURL)
|
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
|
maxAge = time.Hour * 12
|
||||||
}
|
}
|
||||||
sess := simplesessions.New(simplesessions.Options{
|
sess := simplesessions.New(simplesessions.Options{
|
||||||
EnableAutoCreate: true,
|
EnableAutoCreate: false,
|
||||||
SessionIDLength: 64,
|
SessionIDLength: 64,
|
||||||
Cookie: simplesessions.CookieOptions{
|
Cookie: simplesessions.CookieOptions{
|
||||||
IsHTTPOnly: true,
|
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).
|
// SaveSession creates and sets a session (post successful login/auth).
|
||||||
func (o *Auth) SaveSession(user models.User, r *fastglue.Request) error {
|
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 {
|
if err != nil {
|
||||||
o.lo.Error("error creating login session", "error", err)
|
o.lo.Error("error creating login session", "error", err)
|
||||||
return 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.
|
// ValidateSession validates session and returns the user.
|
||||||
func (o *Auth) ValidateSession(r *fastglue.Request) (models.User, error) {
|
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 {
|
if err != nil {
|
||||||
return models.User{}, err
|
return models.User{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,19 +138,21 @@ func New(
|
|||||||
|
|
||||||
type queries struct {
|
type queries struct {
|
||||||
// Conversation queries.
|
// Conversation queries.
|
||||||
GetLatestReceivedMessageSourceID *sqlx.Stmt `query:"get-latest-received-message-source-id"`
|
GetLatestReceivedMessageSourceID *sqlx.Stmt `query:"get-latest-received-message-source-id"`
|
||||||
GetToAddress *sqlx.Stmt `query:"get-to-address"`
|
GetToAddress *sqlx.Stmt `query:"get-to-address"`
|
||||||
GetConversationID *sqlx.Stmt `query:"get-conversation-id"`
|
GetConversationID *sqlx.Stmt `query:"get-conversation-id"`
|
||||||
GetConversationUUID *sqlx.Stmt `query:"get-conversation-uuid"`
|
GetConversationUUID *sqlx.Stmt `query:"get-conversation-uuid"`
|
||||||
GetConversation *sqlx.Stmt `query:"get-conversation"`
|
GetConversation *sqlx.Stmt `query:"get-conversation"`
|
||||||
GetRecentConversations *sqlx.Stmt `query:"get-recent-conversations"`
|
GetRecentConversations *sqlx.Stmt `query:"get-recent-conversations"`
|
||||||
GetUnassignedConversations *sqlx.Stmt `query:"get-unassigned-conversations"`
|
GetUnassignedConversations *sqlx.Stmt `query:"get-unassigned-conversations"`
|
||||||
GetConversations string `query:"get-conversations"`
|
GetConversations string `query:"get-conversations"`
|
||||||
GetConversationsUUIDs string `query:"get-conversations-uuids"`
|
GetConversationsUUIDs string `query:"get-conversations-uuids"`
|
||||||
GetConversationParticipants *sqlx.Stmt `query:"get-conversation-participants"`
|
GetConversationParticipants *sqlx.Stmt `query:"get-conversation-participants"`
|
||||||
GetAssignedConversations *sqlx.Stmt `query:"get-assigned-conversations"`
|
GetAssignedConversations *sqlx.Stmt `query:"get-assigned-conversations"`
|
||||||
GetConversationAssigneeStats *sqlx.Stmt `query:"get-assignee-stats"`
|
|
||||||
GetNewConversationsStats *sqlx.Stmt `query:"get-new-conversations-stats"`
|
GetDashboardCharts string `query:"get-dashboard-charts"`
|
||||||
|
GetDashboardCounts string `query:"get-dashboard-counts"`
|
||||||
|
|
||||||
UpdateConversationFirstReplyAt *sqlx.Stmt `query:"update-conversation-first-reply-at"`
|
UpdateConversationFirstReplyAt *sqlx.Stmt `query:"update-conversation-first-reply-at"`
|
||||||
UpdateConversationAssigneeLastSeen *sqlx.Stmt `query:"update-conversation-assignee-last-seen"`
|
UpdateConversationAssigneeLastSeen *sqlx.Stmt `query:"update-conversation-assignee-last-seen"`
|
||||||
UpdateConversationAssignedUser *sqlx.Stmt `query:"update-conversation-assigned-user"`
|
UpdateConversationAssignedUser *sqlx.Stmt `query:"update-conversation-assigned-user"`
|
||||||
@@ -471,28 +473,69 @@ func (c *Manager) UpdateConversationStatus(uuid string, status []byte, actor umo
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetConversationAssigneeStats retrieves the stats of conversations assigned to a specific user.
|
// GetDashboardCounts returns dashboard counts
|
||||||
func (c *Manager) GetConversationAssigneeStats(userID int) (models.ConversationCounts, error) {
|
func (c *Manager) GetDashboardCounts(userID, teamID int) (json.RawMessage, error) {
|
||||||
var counts = models.ConversationCounts{}
|
var counts = json.RawMessage{}
|
||||||
if err := c.q.GetConversationAssigneeStats.Get(&counts, userID); err != nil {
|
tx, err := c.db.BeginTxx(context.Background(), nil)
|
||||||
if err == sql.ErrNoRows {
|
if err != nil {
|
||||||
return counts, err
|
c.lo.Error("error starting db txn", "error", err)
|
||||||
}
|
return nil, envelope.NewError(envelope.GeneralError, "Error fetching dashboard counts", nil)
|
||||||
c.lo.Error("error fetching assignee conversation stats", "user_id", userID, "error", err)
|
|
||||||
return counts, err
|
|
||||||
}
|
}
|
||||||
|
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
|
return counts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetNewConversationsStats retrieves the statistics of new conversations.
|
// GetDashboardChartData returns dashboard chart data
|
||||||
func (c *Manager) GetNewConversationsStats() ([]models.NewConversationsStats, error) {
|
func (c *Manager) GetDashboardChartData(userID, teamID int) (json.RawMessage, error) {
|
||||||
var stats = make([]models.NewConversationsStats, 0)
|
var stats = json.RawMessage{}
|
||||||
if err := c.q.GetNewConversationsStats.Select(&stats); err != nil {
|
tx, err := c.db.BeginTxx(context.Background(), nil)
|
||||||
if err == sql.ErrNoRows {
|
if err != nil {
|
||||||
return stats, err
|
c.lo.Error("error starting db txn", "error", err)
|
||||||
}
|
return nil, envelope.NewError(envelope.GeneralError, "Error fetching dashboard chart data", nil)
|
||||||
c.lo.Error("error fetching new conversation stats", "error", err)
|
}
|
||||||
return stats, err
|
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
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,8 +93,8 @@ type ConversationCounts struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type NewConversationsStats struct {
|
type NewConversationsStats struct {
|
||||||
Date time.Time `db:"date" json:"date"`
|
Date string `db:"date" json:"date"`
|
||||||
NewConversations int `db:"new_conversations" json:"new_conversations"`
|
NewConversations int `db:"new_conversations" json:"new_conversations"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Message represents a message in the database.
|
// Message represents a message in the database.
|
||||||
|
|||||||
@@ -179,27 +179,52 @@ FROM conversations c
|
|||||||
JOIN contacts ct ON c.contact_id = ct.id
|
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;
|
JOIN inboxes inb on c.inbox_id = inb.id where assigned_user_id is NULL and assigned_team_id is not null;
|
||||||
|
|
||||||
-- name: get-assignee-stats
|
-- name: get-dashboard-counts
|
||||||
SELECT
|
SELECT json_build_object(
|
||||||
COUNT(*) AS total_assigned,
|
'resolved_count', COUNT(CASE WHEN status IN ('Resolved') THEN 1 END),
|
||||||
COUNT(CASE WHEN status NOT IN ('Resolved', 'Closed') THEN 1 END) AS unresolved_count,
|
'unresolved_count', COUNT(CASE WHEN status NOT IN ('Resolved', 'Closed') THEN 1 END),
|
||||||
COUNT(CASE WHEN first_reply_at IS NULL THEN 1 END) AS awaiting_response_count,
|
'awaiting_response_count', COUNT(CASE WHEN first_reply_at IS NULL THEN 1 END),
|
||||||
COUNT(CASE WHEN created_at::date = now()::date THEN 1 END) AS created_today_count
|
'total_count', COUNT(*)
|
||||||
FROM
|
)
|
||||||
conversations
|
FROM conversations
|
||||||
WHERE
|
WHERE 1=1 %s;
|
||||||
assigned_user_id = $1;
|
|
||||||
|
-- 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
|
-- name: update-conversation-first-reply-at
|
||||||
UPDATE conversations
|
UPDATE conversations
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
package media
|
package media
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"embed"
|
"embed"
|
||||||
"io"
|
"io"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/abhinavxd/artemis/internal/dbutil"
|
"github.com/abhinavxd/artemis/internal/dbutil"
|
||||||
"github.com/abhinavxd/artemis/internal/envelope"
|
"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.
|
// queries holds the prepared SQL statements for media operations.
|
||||||
type queries struct {
|
type queries struct {
|
||||||
InsertMedia *sqlx.Stmt `query:"insert-media"`
|
InsertMedia *sqlx.Stmt `query:"insert-media"`
|
||||||
GetMedia *sqlx.Stmt `query:"get-media"`
|
GetMedia *sqlx.Stmt `query:"get-media"`
|
||||||
DeleteMedia *sqlx.Stmt `query:"delete-media"`
|
DeleteMedia *sqlx.Stmt `query:"delete-media"`
|
||||||
Attach *sqlx.Stmt `query:"attach-to-model"`
|
Attach *sqlx.Stmt `query:"attach-to-model"`
|
||||||
GetModelMedia *sqlx.Stmt `query:"get-model-media"`
|
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) {
|
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.queries.DeleteMedia.Exec(uuid)
|
||||||
m.Store.Delete(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
|
-- name: insert-media
|
||||||
INSERT INTO media
|
INSERT INTO media (store, filename, content_type, size, meta)
|
||||||
(store, filename, content_type, size, meta)
|
|
||||||
VALUES($1, $2, $3, $4, $5)
|
VALUES($1, $2, $3, $4, $5)
|
||||||
RETURNING id;
|
RETURNING id;
|
||||||
|
|
||||||
-- name: get-media
|
-- name: get-media
|
||||||
SELECT * from media where id = $1;
|
SELECT *
|
||||||
|
from media
|
||||||
|
where id = $1;
|
||||||
|
|
||||||
-- name: delete-media
|
-- name: delete-media
|
||||||
DELETE from media where id = $1;
|
DELETE from media
|
||||||
|
where id = $1;
|
||||||
|
|
||||||
-- name: attach-to-model
|
-- 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
|
-- 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