feat: dashboard.

This commit is contained in:
Abhinav Raut
2024-08-02 04:50:47 +05:30
parent ead54665fb
commit 4c52af8f62
25 changed files with 708 additions and 302 deletions

View File

@@ -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, "")
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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>

View File

@@ -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",

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,

View File

@@ -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>

View 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>

View File

@@ -0,0 +1 @@
export { default as BarChart } from "./BarChart.vue";

View File

@@ -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>

View File

@@ -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"

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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.

View File

@@ -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

View File

@@ -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)
}
}
}

View File

@@ -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;