mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-10-23 05:11:57 +00:00
feat(wip): activity log / audit log
- single table stores acitivites against entities, actors, timestamps, ip addresses and activity description. - admin page to view, sort and filter activity logs. - new `activity_logs:manage` permission
This commit is contained in:
36
cmd/actvity_log.go
Normal file
36
cmd/actvity_log.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||||
|
"github.com/zerodha/fastglue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleGetActivityLogs returns activity logs from the database.
|
||||||
|
func handleGetActivityLogs(r *fastglue.Request) error {
|
||||||
|
var (
|
||||||
|
app = r.Context.(*App)
|
||||||
|
order = string(r.RequestCtx.QueryArgs().Peek("order"))
|
||||||
|
orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by"))
|
||||||
|
filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
|
||||||
|
page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
|
||||||
|
pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size")))
|
||||||
|
total = 0
|
||||||
|
)
|
||||||
|
logs, err := app.activityLog.GetAll(order, orderBy, filters, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
return sendErrorEnvelope(r, err)
|
||||||
|
}
|
||||||
|
if len(logs) > 0 {
|
||||||
|
total = logs[0].Total
|
||||||
|
}
|
||||||
|
return r.SendEnvelope(envelope.PageResults{
|
||||||
|
Results: logs,
|
||||||
|
Total: total,
|
||||||
|
PerPage: pageSize,
|
||||||
|
TotalPages: (total + pageSize - 1) / pageSize,
|
||||||
|
Page: page,
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
@@ -16,7 +16,7 @@ import (
|
|||||||
func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
||||||
// Authentication.
|
// Authentication.
|
||||||
g.POST("/api/v1/login", handleLogin)
|
g.POST("/api/v1/login", handleLogin)
|
||||||
g.GET("/logout", handleLogout)
|
g.GET("/logout", auth(handleLogout))
|
||||||
g.GET("/api/v1/oidc/{id}/login", handleOIDCLogin)
|
g.GET("/api/v1/oidc/{id}/login", handleOIDCLogin)
|
||||||
g.GET("/api/v1/oidc/{id}/finish", handleOIDCCallback)
|
g.GET("/api/v1/oidc/{id}/finish", handleOIDCCallback)
|
||||||
|
|
||||||
@@ -195,6 +195,9 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
|
|||||||
g.PUT("/api/v1/custom-attributes/{id}", perm(handleUpdateCustomAttribute, "custom_attributes:manage"))
|
g.PUT("/api/v1/custom-attributes/{id}", perm(handleUpdateCustomAttribute, "custom_attributes:manage"))
|
||||||
g.DELETE("/api/v1/custom-attributes/{id}", perm(handleDeleteCustomAttribute, "custom_attributes:manage"))
|
g.DELETE("/api/v1/custom-attributes/{id}", perm(handleDeleteCustomAttribute, "custom_attributes:manage"))
|
||||||
|
|
||||||
|
// Actvity logs.
|
||||||
|
g.GET("/api/v1/activity-logs", perm(handleGetActivityLogs, "activity_logs:manage"))
|
||||||
|
|
||||||
// WebSocket.
|
// WebSocket.
|
||||||
g.GET("/ws", auth(func(r *fastglue.Request) error {
|
g.GET("/ws", auth(func(r *fastglue.Request) error {
|
||||||
return handleWS(r, hub)
|
return handleWS(r, hub)
|
||||||
|
15
cmd/init.go
15
cmd/init.go
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"html/template"
|
"html/template"
|
||||||
|
|
||||||
|
activitylog "github.com/abhinavxd/libredesk/internal/activity_log"
|
||||||
"github.com/abhinavxd/libredesk/internal/ai"
|
"github.com/abhinavxd/libredesk/internal/ai"
|
||||||
auth_ "github.com/abhinavxd/libredesk/internal/auth"
|
auth_ "github.com/abhinavxd/libredesk/internal/auth"
|
||||||
"github.com/abhinavxd/libredesk/internal/authz"
|
"github.com/abhinavxd/libredesk/internal/authz"
|
||||||
@@ -808,6 +809,20 @@ func initCustomAttribute(db *sqlx.DB, i18n *i18n.I18n) *customAttribute.Manager
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initActivityLog inits activity log manager.
|
||||||
|
func initActivityLog(db *sqlx.DB, i18n *i18n.I18n) *activitylog.Manager {
|
||||||
|
lo := initLogger("activity-log")
|
||||||
|
m, err := activitylog.New(activitylog.Opts{
|
||||||
|
DB: db,
|
||||||
|
Lo: lo,
|
||||||
|
I18n: i18n,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("error initializing activity log manager: %v", err)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
// initLogger initializes a logf logger.
|
// initLogger initializes a logf logger.
|
||||||
func initLogger(src string) *logf.Logger {
|
func initLogger(src string) *logf.Logger {
|
||||||
lvl, env := ko.MustString("app.log_level"), ko.MustString("app.env")
|
lvl, env := ko.MustString("app.log_level"), ko.MustString("app.env")
|
||||||
|
18
cmd/login.go
18
cmd/login.go
@@ -14,6 +14,7 @@ func handleLogin(r *fastglue.Request) error {
|
|||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
email = string(r.RequestCtx.PostArgs().Peek("email"))
|
email = string(r.RequestCtx.PostArgs().Peek("email"))
|
||||||
password = r.RequestCtx.PostArgs().Peek("password")
|
password = r.RequestCtx.PostArgs().Peek("password")
|
||||||
|
ip = r.RequestCtx.RemoteIP().String()
|
||||||
)
|
)
|
||||||
|
|
||||||
// Verify email and password.
|
// Verify email and password.
|
||||||
@@ -53,12 +54,27 @@ func handleLogin(r *fastglue.Request) error {
|
|||||||
return sendErrorEnvelope(r, err)
|
return sendErrorEnvelope(r, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Insert activity log.
|
||||||
|
if err := app.activityLog.Login(user.ID, user.Email.String, ip); err != nil {
|
||||||
|
app.lo.Error("error creating login activity log", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
return r.SendEnvelope(user)
|
return r.SendEnvelope(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleLogout logs out the user and redirects to the dashboard.
|
// handleLogout logs out the user and redirects to the dashboard.
|
||||||
func handleLogout(r *fastglue.Request) error {
|
func handleLogout(r *fastglue.Request) error {
|
||||||
var app = r.Context.(*App)
|
var (
|
||||||
|
app = r.Context.(*App)
|
||||||
|
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||||
|
ip = r.RequestCtx.RemoteIP().String()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Insert activity log.
|
||||||
|
if err := app.activityLog.Logout(auser.ID, auser.Email, ip); err != nil {
|
||||||
|
app.lo.Error("error creating logout activity log", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := app.auth.DestroySession(r); err != nil {
|
if err := app.auth.DestroySession(r); err != nil {
|
||||||
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorDestroying", "name", "{globals.terms.session}"), nil))
|
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorDestroying", "name", "{globals.terms.session}"), nil))
|
||||||
}
|
}
|
||||||
|
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
_ "time/tzdata"
|
_ "time/tzdata"
|
||||||
|
|
||||||
|
activitylog "github.com/abhinavxd/libredesk/internal/activity_log"
|
||||||
"github.com/abhinavxd/libredesk/internal/ai"
|
"github.com/abhinavxd/libredesk/internal/ai"
|
||||||
auth_ "github.com/abhinavxd/libredesk/internal/auth"
|
auth_ "github.com/abhinavxd/libredesk/internal/auth"
|
||||||
"github.com/abhinavxd/libredesk/internal/authz"
|
"github.com/abhinavxd/libredesk/internal/authz"
|
||||||
@@ -86,6 +87,7 @@ type App struct {
|
|||||||
view *view.Manager
|
view *view.Manager
|
||||||
ai *ai.Manager
|
ai *ai.Manager
|
||||||
search *search.Manager
|
search *search.Manager
|
||||||
|
activityLog *activitylog.Manager
|
||||||
notifier *notifier.Service
|
notifier *notifier.Service
|
||||||
customAttribute *customAttribute.Manager
|
customAttribute *customAttribute.Manager
|
||||||
|
|
||||||
@@ -218,6 +220,7 @@ func main() {
|
|||||||
conversation: conversation,
|
conversation: conversation,
|
||||||
automation: automation,
|
automation: automation,
|
||||||
businessHours: businessHours,
|
businessHours: businessHours,
|
||||||
|
activityLog: initActivityLog(db, i18n),
|
||||||
customAttribute: initCustomAttribute(db, i18n),
|
customAttribute: initCustomAttribute(db, i18n),
|
||||||
authz: initAuthz(i18n),
|
authz: initAuthz(i18n),
|
||||||
view: initView(db),
|
view: initView(db),
|
||||||
|
28
cmd/users.go
28
cmd/users.go
@@ -69,10 +69,19 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error {
|
|||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||||
status = string(r.RequestCtx.PostArgs().Peek("status"))
|
status = string(r.RequestCtx.PostArgs().Peek("status"))
|
||||||
|
ip = r.RequestCtx.RemoteIP().String()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Update availability status.
|
||||||
if err := app.user.UpdateAvailability(auser.ID, status); err != nil {
|
if err := app.user.UpdateAvailability(auser.ID, status); err != nil {
|
||||||
return sendErrorEnvelope(r, err)
|
return sendErrorEnvelope(r, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create activity log.
|
||||||
|
if err := app.activityLog.UserAvailability(auser.ID, auser.Email, status, ip, "" /*peformedByEmail*/, 0 /*peformedByID*/); err != nil {
|
||||||
|
app.lo.Error("error creating activity log", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
return r.SendEnvelope(true)
|
return r.SendEnvelope(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,8 +198,10 @@ func handleCreateAgent(r *fastglue.Request) error {
|
|||||||
// handleUpdateAgent updates an agent.
|
// handleUpdateAgent updates an agent.
|
||||||
func handleUpdateAgent(r *fastglue.Request) error {
|
func handleUpdateAgent(r *fastglue.Request) error {
|
||||||
var (
|
var (
|
||||||
app = r.Context.(*App)
|
app = r.Context.(*App)
|
||||||
user = models.User{}
|
user = models.User{}
|
||||||
|
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||||
|
ip = r.RequestCtx.RemoteIP().String()
|
||||||
)
|
)
|
||||||
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||||
if err != nil || id == 0 {
|
if err != nil || id == 0 {
|
||||||
@@ -213,11 +224,24 @@ func handleUpdateAgent(r *fastglue.Request) error {
|
|||||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError)
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
agent, err := app.user.GetAgent(id, "")
|
||||||
|
if err != nil {
|
||||||
|
return sendErrorEnvelope(r, err)
|
||||||
|
}
|
||||||
|
oldAvailabilityStatus := agent.AvailabilityStatus
|
||||||
|
|
||||||
// Update agent.
|
// Update agent.
|
||||||
if err = app.user.UpdateAgent(id, user); err != nil {
|
if err = app.user.UpdateAgent(id, user); err != nil {
|
||||||
return sendErrorEnvelope(r, err)
|
return sendErrorEnvelope(r, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create activity log if user availability status changed.
|
||||||
|
if oldAvailabilityStatus != user.AvailabilityStatus {
|
||||||
|
if err := app.activityLog.UserAvailability(id, user.Email.String, user.AvailabilityStatus, ip, auser.Email, auser.ID); err != nil {
|
||||||
|
app.lo.Error("error creating activity log", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Upsert agent teams.
|
// Upsert agent teams.
|
||||||
if err := app.team.UpsertUserTeams(id, user.Teams.Names()); err != nil {
|
if err := app.team.UpsertUserTeams(id, user.Teams.Names()); err != nil {
|
||||||
return sendErrorEnvelope(r, err)
|
return sendErrorEnvelope(r, err)
|
||||||
|
@@ -315,6 +315,7 @@ const updateAIProvider = (data) => http.put('/api/v1/ai/provider', data)
|
|||||||
const getContactNotes = (id) => http.get(`/api/v1/contacts/${id}/notes`)
|
const getContactNotes = (id) => http.get(`/api/v1/contacts/${id}/notes`)
|
||||||
const createContactNote = (id, data) => http.post(`/api/v1/contacts/${id}/notes`, data)
|
const createContactNote = (id, data) => http.post(`/api/v1/contacts/${id}/notes`, data)
|
||||||
const deleteContactNote = (id, noteId) => http.delete(`/api/v1/contacts/${id}/notes/${noteId}`)
|
const deleteContactNote = (id, noteId) => http.delete(`/api/v1/contacts/${id}/notes/${noteId}`)
|
||||||
|
const getActivityLogs = (params) => http.get('/api/v1/activity-logs', { params })
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
login,
|
login,
|
||||||
@@ -442,5 +443,6 @@ export default {
|
|||||||
getCustomAttribute,
|
getCustomAttribute,
|
||||||
getContactNotes,
|
getContactNotes,
|
||||||
createContactNote,
|
createContactNote,
|
||||||
deleteContactNote
|
deleteContactNote,
|
||||||
|
getActivityLogs
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="min-w-full table-fixed divide-y divide-gray-200">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<th
|
||||||
@@ -34,11 +34,11 @@
|
|||||||
<td
|
<td
|
||||||
v-for="key in keys"
|
v-for="key in keys"
|
||||||
:key="key"
|
:key="key"
|
||||||
class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900"
|
class="px-6 py-4 text-sm font-medium text-gray-900 whitespace-normal break-words"
|
||||||
>
|
>
|
||||||
{{ item[key] }}
|
{{ item[key] }}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td class="px-6 py-4 text-sm text-gray-500" v-if="showDelete">
|
||||||
<Button size="xs" variant="ghost" @click.prevent="deleteItem(item)">
|
<Button size="xs" variant="ghost" @click.prevent="deleteItem(item)">
|
||||||
<Trash2 class="h-4 w-4" />
|
<Trash2 class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -69,6 +69,10 @@ defineProps({
|
|||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
required: true,
|
||||||
default: () => []
|
default: () => []
|
||||||
|
},
|
||||||
|
showDelete: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
39
frontend/src/composables/useActivityLogFilters.js
Normal file
39
frontend/src/composables/useActivityLogFilters.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { computed } from 'vue'
|
||||||
|
import { useUsersStore } from '@/stores/users'
|
||||||
|
import { FIELD_TYPE, FIELD_OPERATORS } from '@/constants/filterConfig'
|
||||||
|
|
||||||
|
export function useActivityLogFilters () {
|
||||||
|
const uStore = useUsersStore()
|
||||||
|
const activityLogListFilters = computed(() => ({
|
||||||
|
actor_id: {
|
||||||
|
label: 'Actor',
|
||||||
|
type: FIELD_TYPE.SELECT,
|
||||||
|
operators: FIELD_OPERATORS.SELECT,
|
||||||
|
options: uStore.options
|
||||||
|
},
|
||||||
|
activity_type: {
|
||||||
|
label: 'Activity type',
|
||||||
|
type: FIELD_TYPE.SELECT,
|
||||||
|
operators: FIELD_OPERATORS.SELECT,
|
||||||
|
options: [{
|
||||||
|
label: 'Login',
|
||||||
|
value: 'login'
|
||||||
|
}, {
|
||||||
|
label: 'Logout',
|
||||||
|
value: 'logout'
|
||||||
|
}, {
|
||||||
|
label: 'Away',
|
||||||
|
value: 'away'
|
||||||
|
}, {
|
||||||
|
label: 'Away Reassigned',
|
||||||
|
value: 'away_reassigned'
|
||||||
|
}, {
|
||||||
|
label: 'Online',
|
||||||
|
value: 'online'
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
return {
|
||||||
|
activityLogListFilters
|
||||||
|
}
|
||||||
|
}
|
@@ -74,6 +74,11 @@ export const adminNavItems = [
|
|||||||
titleKey: 'navigation.roles',
|
titleKey: 'navigation.roles',
|
||||||
href: '/admin/teams/roles',
|
href: '/admin/teams/roles',
|
||||||
permission: 'roles:manage'
|
permission: 'roles:manage'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titleKey: 'navigation.activityLog',
|
||||||
|
href: '/admin/teams/activity-log',
|
||||||
|
permission: 'activity_logs:manage'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@@ -36,5 +36,6 @@ export const permissions = {
|
|||||||
CONTACTS_BLOCK: 'contacts:block',
|
CONTACTS_BLOCK: 'contacts:block',
|
||||||
CONTACT_NOTES_READ: 'contact_notes:read',
|
CONTACT_NOTES_READ: 'contact_notes:read',
|
||||||
CONTACT_NOTES_WRITE: 'contact_notes:write',
|
CONTACT_NOTES_WRITE: 'contact_notes:write',
|
||||||
CONTACT_NOTES_DELETE: 'contact_notes:delete'
|
CONTACT_NOTES_DELETE: 'contact_notes:delete',
|
||||||
|
ACTIVITY_LOGS_MANAGE: 'activity_logs:manage',
|
||||||
};
|
};
|
246
frontend/src/features/admin/activity-log/ActivityLog.vue
Normal file
246
frontend/src/features/admin/activity-log/ActivityLog.vue
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen flex flex-col w-full">
|
||||||
|
<div class="flex flex-wrap gap-4 pb-4">
|
||||||
|
<div class="flex items-center gap-4 mb-4">
|
||||||
|
<!-- Filter Popover -->
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger>
|
||||||
|
<ListFilter size="18" class="text-muted-foreground cursor-pointer" />
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent class="w-full p-4 flex flex-col gap-4">
|
||||||
|
<FilterBuilder
|
||||||
|
:fields="filterFields"
|
||||||
|
:showButtons="true"
|
||||||
|
v-model="filters"
|
||||||
|
@apply="fetchActivityLogs"
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<!-- Order By Popover -->
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger>
|
||||||
|
<ArrowDownUp size="18" class="text-muted-foreground cursor-pointer" />
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent class="w-[200px] p-4 flex flex-col gap-4">
|
||||||
|
<!-- order by field -->
|
||||||
|
<Select v-model="orderByField" @update:model-value="fetchActivityLogs">
|
||||||
|
<SelectTrigger class="h-8 w-full">
|
||||||
|
<SelectValue :placeholder="orderByField" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem :value="'activity_logs.created_at'">
|
||||||
|
{{ t('form.field.createdAt') }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<!-- order by direction -->
|
||||||
|
<Select v-model="orderByDirection" @update:model-value="fetchActivityLogs">
|
||||||
|
<SelectTrigger class="h-8 w-full">
|
||||||
|
<SelectValue :placeholder="orderByDirection" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem :value="'asc'">Ascending</SelectItem>
|
||||||
|
<SelectItem :value="'desc'">Descending</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="flex flex-col gap-4 w-full">
|
||||||
|
<Card v-for="i in perPage" :key="i" class="p-4 flex-shrink-0">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<Skeleton class="h-10 w-10 rounded-full" />
|
||||||
|
<div class="space-y-2 w-full">
|
||||||
|
<Skeleton class="h-3 w-[160px]" />
|
||||||
|
<Skeleton class="h-3 w-[140px]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="w-full overflow-x-auto">
|
||||||
|
<SimpleTable
|
||||||
|
:headers="[t('form.field.name'), t('form.field.date'), t('globals.terms.ipAddress')]"
|
||||||
|
:keys="['activity_description', 'created_at', 'ip']"
|
||||||
|
:data="activityLogs"
|
||||||
|
:showDelete="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="activityLogs.length === 0" class="flex items-center justify-center w-full h-32">
|
||||||
|
<p class="text-lg text-muted-foreground">{{ t('globals.states.noResults') }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sticky bottom-0 bg-background p-4 mt-auto">
|
||||||
|
<div class="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm text-muted-foreground">
|
||||||
|
{{ t('globals.terms.page') }} {{ page }} of {{ totalPages }}
|
||||||
|
</span>
|
||||||
|
<Select v-model="perPage" @update:model-value="handlePerPageChange">
|
||||||
|
<SelectTrigger class="h-8 w-[70px]">
|
||||||
|
<SelectValue :placeholder="perPage" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem :value="10">10</SelectItem>
|
||||||
|
<SelectItem :value="15">15</SelectItem>
|
||||||
|
<SelectItem :value="30">30</SelectItem>
|
||||||
|
<SelectItem :value="50">50</SelectItem>
|
||||||
|
<SelectItem :value="100">100</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination>
|
||||||
|
<PaginationList class="flex items-center gap-1">
|
||||||
|
<PaginationListItem>
|
||||||
|
<PaginationFirst
|
||||||
|
:class="{ 'cursor-not-allowed opacity-50': page === 1 }"
|
||||||
|
@click.prevent="page > 1 ? goToPage(1) : null"
|
||||||
|
/>
|
||||||
|
</PaginationListItem>
|
||||||
|
<PaginationListItem>
|
||||||
|
<PaginationPrev
|
||||||
|
:class="{ 'cursor-not-allowed opacity-50': page === 1 }"
|
||||||
|
@click.prevent="page > 1 ? goToPage(page - 1) : null"
|
||||||
|
/>
|
||||||
|
</PaginationListItem>
|
||||||
|
<template v-for="pageNumber in visiblePages" :key="pageNumber">
|
||||||
|
<PaginationListItem v-if="pageNumber === '...'">
|
||||||
|
<PaginationEllipsis />
|
||||||
|
</PaginationListItem>
|
||||||
|
<PaginationListItem v-else>
|
||||||
|
<Button
|
||||||
|
:is-active="pageNumber === page"
|
||||||
|
@click.prevent="goToPage(pageNumber)"
|
||||||
|
:variant="pageNumber === page ? 'default' : 'outline'"
|
||||||
|
>
|
||||||
|
{{ pageNumber }}
|
||||||
|
</Button>
|
||||||
|
</PaginationListItem>
|
||||||
|
</template>
|
||||||
|
<PaginationListItem>
|
||||||
|
<PaginationNext
|
||||||
|
:class="{ 'cursor-not-allowed opacity-50': page === totalPages }"
|
||||||
|
@click.prevent="page < totalPages ? goToPage(page + 1) : null"
|
||||||
|
/>
|
||||||
|
</PaginationListItem>
|
||||||
|
<PaginationListItem>
|
||||||
|
<PaginationLast
|
||||||
|
:class="{ 'cursor-not-allowed opacity-50': page === totalPages }"
|
||||||
|
@click.prevent="page < totalPages ? goToPage(totalPages) : null"
|
||||||
|
/>
|
||||||
|
</PaginationListItem>
|
||||||
|
</PaginationList>
|
||||||
|
</Pagination>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, h, watch } from 'vue'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import SimpleTable from '@/components/table/SimpleTable.vue'
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationFirst,
|
||||||
|
PaginationLast,
|
||||||
|
PaginationList,
|
||||||
|
PaginationListItem,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrev
|
||||||
|
} from '@/components/ui/pagination'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import FilterBuilder from '@/features/filter/FilterBuilder.vue'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { ArrowDownUp, ListFilter } from 'lucide-vue-next'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
|
import api from '@/api'
|
||||||
|
import { useActivityLogFilters } from '@/composables/useActivityLogFilters'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
import { getVisiblePages } from '@/utils/pagination'
|
||||||
|
|
||||||
|
|
||||||
|
const activityLogs = ref([])
|
||||||
|
const { t } = useI18n()
|
||||||
|
const loading = ref(true)
|
||||||
|
const page = ref(1)
|
||||||
|
const perPage = ref(10)
|
||||||
|
const orderByField = ref('activity_logs.created_at')
|
||||||
|
const orderByDirection = ref('desc')
|
||||||
|
const totalCount = ref(0)
|
||||||
|
const totalPages = ref(0)
|
||||||
|
const filters = ref([])
|
||||||
|
const { activityLogListFilters } = useActivityLogFilters()
|
||||||
|
|
||||||
|
const filterFields = computed(() =>
|
||||||
|
Object.entries(activityLogListFilters.value).map(([field, value]) => ({
|
||||||
|
model: 'activity_logs',
|
||||||
|
label: value.label,
|
||||||
|
field,
|
||||||
|
type: value.type,
|
||||||
|
operators: value.operators,
|
||||||
|
options: value.options ?? []
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
const visiblePages = computed(() => getVisiblePages(page.value, totalPages.value))
|
||||||
|
|
||||||
|
async function fetchActivityLogs() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const resp = await api.getActivityLogs({
|
||||||
|
page: page.value,
|
||||||
|
page_size: perPage.value,
|
||||||
|
filters: JSON.stringify(filters.value),
|
||||||
|
order: orderByDirection.value,
|
||||||
|
order_by: orderByField.value
|
||||||
|
})
|
||||||
|
activityLogs.value = resp.data.data.results
|
||||||
|
totalCount.value = resp.data.data.count
|
||||||
|
totalPages.value = resp.data.data.total_pages
|
||||||
|
|
||||||
|
// Format the created_at field
|
||||||
|
activityLogs.value = activityLogs.value.map((log) => ({
|
||||||
|
...log,
|
||||||
|
created_at: format(new Date(log.created_at), 'PPpp')
|
||||||
|
}))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching activity logs:', err)
|
||||||
|
activityLogs.value = []
|
||||||
|
totalCount.value = 0
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToPage(p) {
|
||||||
|
if (p >= 1 && p <= totalPages.value && p !== page.value) {
|
||||||
|
page.value = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePerPageChange() {
|
||||||
|
page.value = 1
|
||||||
|
fetchActivityLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([page, perPage, orderByField, orderByDirection], fetchActivityLogs)
|
||||||
|
|
||||||
|
onMounted(fetchActivityLogs)
|
||||||
|
</script>
|
@@ -165,7 +165,8 @@ const permissions = ref([
|
|||||||
{ name: perms.BUSINESS_HOURS_MANAGE, label: t('admin.role.businessHours.manage') },
|
{ name: perms.BUSINESS_HOURS_MANAGE, label: t('admin.role.businessHours.manage') },
|
||||||
{ name: perms.SLA_MANAGE, label: t('admin.role.sla.manage') },
|
{ name: perms.SLA_MANAGE, label: t('admin.role.sla.manage') },
|
||||||
{ name: perms.AI_MANAGE, label: t('admin.role.ai.manage') },
|
{ name: perms.AI_MANAGE, label: t('admin.role.ai.manage') },
|
||||||
{ name: perms.CUSTOM_ATTRIBUTES_MANAGE, label: t('admin.role.customAttributes.manage') }
|
{ name: perms.CUSTOM_ATTRIBUTES_MANAGE, label: t('admin.role.customAttributes.manage') },
|
||||||
|
{ name: perms.ACTIVITY_LOGS_MANAGE, label: t('admin.role.activityLog.manage') }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@@ -186,6 +186,7 @@ import { debounce } from '@/utils/debounce'
|
|||||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||||
import { useEmitter } from '@/composables/useEmitter'
|
import { useEmitter } from '@/composables/useEmitter'
|
||||||
import { handleHTTPError } from '@/utils/http'
|
import { handleHTTPError } from '@/utils/http'
|
||||||
|
import { getVisiblePages } from '@/utils/pagination'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
|
|
||||||
const contacts = ref([])
|
const contacts = ref([])
|
||||||
@@ -200,21 +201,7 @@ const total = ref(0)
|
|||||||
const emitter = useEmitter()
|
const emitter = useEmitter()
|
||||||
|
|
||||||
// Google-style pagination
|
// Google-style pagination
|
||||||
const visiblePages = computed(() => {
|
const visiblePages = computed(() => getVisiblePages(page.value, totalPages.value))
|
||||||
const pages = []
|
|
||||||
const maxVisible = 5
|
|
||||||
let start = Math.max(1, page.value - Math.floor(maxVisible / 2))
|
|
||||||
let end = Math.min(totalPages.value, start + maxVisible - 1)
|
|
||||||
|
|
||||||
if (end - start < maxVisible - 1) {
|
|
||||||
start = Math.max(1, end - maxVisible + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = start; i <= end; i++) {
|
|
||||||
pages.push(i)
|
|
||||||
}
|
|
||||||
return pages
|
|
||||||
})
|
|
||||||
|
|
||||||
const fetchContactsDebounced = debounce(() => {
|
const fetchContactsDebounced = debounce(() => {
|
||||||
fetchContacts()
|
fetchContacts()
|
||||||
|
@@ -74,7 +74,7 @@ import {
|
|||||||
FormMessage
|
FormMessage
|
||||||
} from '@/components/ui/form'
|
} from '@/components/ui/form'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import FilterBuilder from '@/features/view/FilterBuilder.vue'
|
import FilterBuilder from '@/features/filter/FilterBuilder.vue'
|
||||||
import { useConversationFilters } from '@/composables/useConversationFilters'
|
import { useConversationFilters } from '@/composables/useConversationFilters'
|
||||||
import { toTypedSchema } from '@vee-validate/zod'
|
import { toTypedSchema } from '@vee-validate/zod'
|
||||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||||
|
5
frontend/src/layouts/admin/ActivityLog.vue
Normal file
5
frontend/src/layouts/admin/ActivityLog.vue
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full h-screen px-8 sm:px-12 md:px-16 lg:px-32 xl:px-56">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-screen overflow-y-auto px-6 sm:px-6 md:px-6 lg:px-24 xl:px-72 pt-6">
|
<div class="w-full h-screen overflow-y-auto px-6 sm:px-6 md:px-6 lg:px-24 xl:px-72 pt-6">
|
||||||
<slot />
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@@ -341,6 +341,12 @@ const routes = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'activity-log',
|
||||||
|
name: 'activity-log',
|
||||||
|
component: () => import('@/views/admin/activity-log/ActivityLog.vue'),
|
||||||
|
meta: { title: 'Activity Log' },
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
15
frontend/src/utils/pagination.js
Normal file
15
frontend/src/utils/pagination.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export function getVisiblePages (current, total) {
|
||||||
|
const pages = []
|
||||||
|
if (total <= 7) {
|
||||||
|
for (let i = 1; i <= total; i++) pages.push(i)
|
||||||
|
} else {
|
||||||
|
if (current <= 4) {
|
||||||
|
pages.push(1, 2, 3, 4, 5, '...', total)
|
||||||
|
} else if (current >= total - 3) {
|
||||||
|
pages.push(1, '...', total - 4, total - 3, total - 2, total - 1, total)
|
||||||
|
} else {
|
||||||
|
pages.push(1, '...', current - 1, current, current + 1, '...', total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pages
|
||||||
|
}
|
9
frontend/src/views/admin/activity-log/ActivityLog.vue
Normal file
9
frontend/src/views/admin/activity-log/ActivityLog.vue
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<template>
|
||||||
|
<ActivityLogLayout>
|
||||||
|
<ActivityLog />
|
||||||
|
</ActivityLogLayout>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import ActivityLog from '@/features/admin/activity-log/ActivityLog.vue'
|
||||||
|
import ActivityLogLayout from '@/layouts/admin/ActivityLog.vue'
|
||||||
|
</script>
|
@@ -16,6 +16,7 @@
|
|||||||
"globals.terms.request": "Request | Requests",
|
"globals.terms.request": "Request | Requests",
|
||||||
"globals.terms.file": "File | Files",
|
"globals.terms.file": "File | Files",
|
||||||
"globals.terms.page": "Page | Pages",
|
"globals.terms.page": "Page | Pages",
|
||||||
|
"globals.terms.activityLog": "Activity log | Activity logs",
|
||||||
"globals.terms.name": "Name | Names",
|
"globals.terms.name": "Name | Names",
|
||||||
"globals.terms.image": "Image | Images",
|
"globals.terms.image": "Image | Images",
|
||||||
"globals.terms.thumbnail": "Thumbnail | Thumbnails",
|
"globals.terms.thumbnail": "Thumbnail | Thumbnails",
|
||||||
@@ -79,6 +80,7 @@
|
|||||||
"globals.terms.url": "URL | URLs",
|
"globals.terms.url": "URL | URLs",
|
||||||
"globals.terms.key": "Key | Keys",
|
"globals.terms.key": "Key | Keys",
|
||||||
"globals.terms.note": "Note | Notes",
|
"globals.terms.note": "Note | Notes",
|
||||||
|
"globals.terms.ipAddress": "IP Address | IP Addresses",
|
||||||
"globals.messages.badRequest": "Bad request",
|
"globals.messages.badRequest": "Bad request",
|
||||||
"globals.messages.adjustFilters": "Try adjusting filters",
|
"globals.messages.adjustFilters": "Try adjusting filters",
|
||||||
"globals.messages.errorUpdating": "Error updating {name}",
|
"globals.messages.errorUpdating": "Error updating {name}",
|
||||||
@@ -251,6 +253,7 @@
|
|||||||
"navigation.reassignReplies": "Reassign replies",
|
"navigation.reassignReplies": "Reassign replies",
|
||||||
"navigation.allContacts": "All Contacts",
|
"navigation.allContacts": "All Contacts",
|
||||||
"navigation.customAttributes": "Custom Attributes",
|
"navigation.customAttributes": "Custom Attributes",
|
||||||
|
"navigation.activityLog": "Activity Log",
|
||||||
"form.field.name": "Name",
|
"form.field.name": "Name",
|
||||||
"form.field.regex": "Regex",
|
"form.field.regex": "Regex",
|
||||||
"form.field.regexHint": "Regex hint",
|
"form.field.regexHint": "Regex hint",
|
||||||
@@ -492,6 +495,7 @@
|
|||||||
"admin.role.contactNotes.write": "Add Contact Notes",
|
"admin.role.contactNotes.write": "Add Contact Notes",
|
||||||
"admin.role.contactNotes.delete": "Delete Contact Notes",
|
"admin.role.contactNotes.delete": "Delete Contact Notes",
|
||||||
"admin.role.customAttributes.manage": "Manage Custom Attributes",
|
"admin.role.customAttributes.manage": "Manage Custom Attributes",
|
||||||
|
"admin.role.activityLog.manage": "Manage Activity Log",
|
||||||
"admin.automation.newConversation.description": "Rules that run when a new conversation is created, drag and drop to reorder rules.",
|
"admin.automation.newConversation.description": "Rules that run when a new conversation is created, drag and drop to reorder rules.",
|
||||||
"admin.automation.conversationUpdate": "Conversation Update",
|
"admin.automation.conversationUpdate": "Conversation Update",
|
||||||
"admin.automation.conversationUpdate.description": "Rules that run when a conversation is updated.",
|
"admin.automation.conversationUpdate.description": "Rules that run when a conversation is updated.",
|
||||||
|
210
internal/activity_log/activity_log.go
Normal file
210
internal/activity_log/activity_log.go
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
// Package activity manages activity logs for all users.
|
||||||
|
package activitylog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/abhinavxd/libredesk/internal/activity_log/models"
|
||||||
|
"github.com/abhinavxd/libredesk/internal/dbutil"
|
||||||
|
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||||
|
umodels "github.com/abhinavxd/libredesk/internal/user/models"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
"github.com/knadh/go-i18n"
|
||||||
|
"github.com/zerodha/logf"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
//go:embed queries.sql
|
||||||
|
efs embed.FS
|
||||||
|
)
|
||||||
|
|
||||||
|
// Manager manages activity logs.
|
||||||
|
type Manager struct {
|
||||||
|
q queries
|
||||||
|
lo *logf.Logger
|
||||||
|
i18n *i18n.I18n
|
||||||
|
db *sqlx.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opts contains options for initializing the Manager.
|
||||||
|
type Opts struct {
|
||||||
|
DB *sqlx.DB
|
||||||
|
Lo *logf.Logger
|
||||||
|
I18n *i18n.I18n
|
||||||
|
}
|
||||||
|
|
||||||
|
// queries contains prepared SQL queries.
|
||||||
|
type queries struct {
|
||||||
|
GetAllActivities string `query:"get-all-activities"`
|
||||||
|
InsertActivity *sqlx.Stmt `query:"insert-activity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates and returns a new instance of the Manager.
|
||||||
|
func New(opts Opts) (*Manager, error) {
|
||||||
|
var q queries
|
||||||
|
if err := dbutil.ScanSQLFile("queries.sql", &q, opts.DB, efs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Manager{
|
||||||
|
q: q,
|
||||||
|
lo: opts.Lo,
|
||||||
|
i18n: opts.I18n,
|
||||||
|
db: opts.DB,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAll retrieves all activity logs.
|
||||||
|
func (m *Manager) GetAll(order, orderBy, filtersJSON string, page, pageSize int) ([]models.ActivityLog, error) {
|
||||||
|
query, qArgs, err := m.makeQuery(page, pageSize, order, orderBy, filtersJSON)
|
||||||
|
if err != nil {
|
||||||
|
m.lo.Error("error creating activity log list query", "error", err)
|
||||||
|
return nil, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.activityLog}"), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start a read-only txn.
|
||||||
|
tx, err := m.db.BeginTxx(context.Background(), &sql.TxOptions{
|
||||||
|
ReadOnly: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
m.lo.Error("error starting read-only transaction", "error", err)
|
||||||
|
return nil, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.activityLog}"), nil)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// Execute query
|
||||||
|
var activityLogs = make([]models.ActivityLog, 0)
|
||||||
|
fmt.Println("QUERY", query)
|
||||||
|
fmt.Println("ARGS", qArgs)
|
||||||
|
if err := tx.Select(&activityLogs, query, qArgs...); err != nil {
|
||||||
|
m.lo.Error("error fetching activity logs", "error", err)
|
||||||
|
return nil, envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.activityLog}"), nil)
|
||||||
|
}
|
||||||
|
return activityLogs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create adds a new activity log.
|
||||||
|
func (m *Manager) Create(activityType, activityDescription string, actorID int, targetModelType string, targetModelID int, ip string) error {
|
||||||
|
if _, err := m.q.InsertActivity.Exec(activityType, activityDescription, actorID, targetModelType, targetModelID, ip); err != nil {
|
||||||
|
m.lo.Error("error inserting activity", "error", err)
|
||||||
|
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.activityLog}"), nil)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login records a login event for the given user.
|
||||||
|
func (al *Manager) Login(userID int, email, ip string) error {
|
||||||
|
return al.Create(
|
||||||
|
models.Login,
|
||||||
|
fmt.Sprintf("%s (#%d) logged in", email, userID),
|
||||||
|
userID,
|
||||||
|
umodels.UserModel,
|
||||||
|
userID,
|
||||||
|
ip,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout records a logout event for the given user.
|
||||||
|
func (al *Manager) Logout(userID int, email, ip string) error {
|
||||||
|
return al.Create(
|
||||||
|
models.Logout,
|
||||||
|
fmt.Sprintf("%s (#%d) logged out", email, userID),
|
||||||
|
userID,
|
||||||
|
umodels.UserModel,
|
||||||
|
userID,
|
||||||
|
ip,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Away records an away event for the given user.
|
||||||
|
func (al *Manager) Away(userID int, email, ip string, performedById int, performedByEmail string) error {
|
||||||
|
var description string
|
||||||
|
if performedById != 0 && performedByEmail != "" && (performedById != userID || performedByEmail != email) {
|
||||||
|
description = fmt.Sprintf("%s (#%d) changed %s (#%d) status to away", performedByEmail, performedById, email, userID)
|
||||||
|
} else {
|
||||||
|
description = fmt.Sprintf("%s (#%d) is away", email, userID)
|
||||||
|
}
|
||||||
|
return al.Create(
|
||||||
|
models.Away,
|
||||||
|
description,
|
||||||
|
userID,
|
||||||
|
umodels.UserModel,
|
||||||
|
userID,
|
||||||
|
ip,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AwayReassigned records an away and reassigned event for the given user.
|
||||||
|
func (al *Manager) AwayReassigned(userID int, email, ip string, performedById int, performedByEmail string) error {
|
||||||
|
var description string
|
||||||
|
if performedById != 0 && performedByEmail != "" && (performedById != userID || performedByEmail != email) {
|
||||||
|
description = fmt.Sprintf("%s (#%d) changed %s (#%d) status to away and reassigning", performedByEmail, performedById, email, userID)
|
||||||
|
} else {
|
||||||
|
description = fmt.Sprintf("%s (#%d) is away and reassigning", email, userID)
|
||||||
|
}
|
||||||
|
return al.Create(
|
||||||
|
models.AwayReassigned,
|
||||||
|
description,
|
||||||
|
userID,
|
||||||
|
umodels.UserModel,
|
||||||
|
userID,
|
||||||
|
ip,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Online records an online event for the given user.
|
||||||
|
func (al *Manager) Online(userID int, email, ip string, performedById int, performedByEmail string) error {
|
||||||
|
var description string
|
||||||
|
if performedById != 0 && performedByEmail != "" && (performedById != userID || performedByEmail != email) {
|
||||||
|
description = fmt.Sprintf("%s (#%d) changed %s (#%d) status to online", performedByEmail, performedById, email, userID)
|
||||||
|
} else {
|
||||||
|
description = fmt.Sprintf("%s (#%d) is online", email, userID)
|
||||||
|
}
|
||||||
|
return al.Create(
|
||||||
|
models.Online,
|
||||||
|
description,
|
||||||
|
userID,
|
||||||
|
umodels.UserModel,
|
||||||
|
userID,
|
||||||
|
ip,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserAvailability records a user availability event for the given user.
|
||||||
|
func (al *Manager) UserAvailability(userID int, email, status, ip, performedByEmail string, performedById int) error {
|
||||||
|
switch status {
|
||||||
|
case umodels.Online:
|
||||||
|
if err := al.Online(userID, email, ip, performedById, performedByEmail); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case umodels.AwayManual:
|
||||||
|
if err := al.Away(userID, email, ip, performedById, performedByEmail); err != nil {
|
||||||
|
al.lo.Error("error logging away activity", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case umodels.AwayAndReassigning:
|
||||||
|
if err := al.AwayReassigned(userID, email, ip, performedById, performedByEmail); err != nil {
|
||||||
|
al.lo.Error("error logging away and reassigning activity", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeQuery constructs the SQL query for fetching activity logs with filters and pagination.
|
||||||
|
func (m *Manager) makeQuery(page, pageSize int, order, orderBy, filtersJSON string) (string, []any, error) {
|
||||||
|
var (
|
||||||
|
baseQuery = m.q.GetAllActivities
|
||||||
|
qArgs []any
|
||||||
|
)
|
||||||
|
return dbutil.BuildPaginatedQuery(baseQuery, qArgs, dbutil.PaginationOptions{
|
||||||
|
Order: order,
|
||||||
|
OrderBy: orderBy,
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
}, filtersJSON, dbutil.AllowedFields{
|
||||||
|
"activity_logs": {"activity_type", "actor_id", "ip", "created_at"},
|
||||||
|
})
|
||||||
|
}
|
28
internal/activity_log/models/models.go
Normal file
28
internal/activity_log/models/models.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
Login = "login"
|
||||||
|
Logout = "logout"
|
||||||
|
Away = "away"
|
||||||
|
AwayManual = "away_manual"
|
||||||
|
AwayReassigned = "away_reassigned"
|
||||||
|
Online = "online"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ActivityLog struct {
|
||||||
|
ID int64 `db:"id" json:"id"`
|
||||||
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||||
|
ActivityType string `db:"activity_type" json:"activity_type"`
|
||||||
|
ActivityDescription string `db:"activity_description" json:"activity_description"`
|
||||||
|
ActorID int `db:"actor_id" json:"actor_id"`
|
||||||
|
TargetModelType string `db:"target_model_type" json:"target_model_type"`
|
||||||
|
TargetModelID int `db:"target_model_id" json:"target_model_id"`
|
||||||
|
IP string `db:"ip" json:"ip"`
|
||||||
|
|
||||||
|
Total int `db:"total" json:"total"`
|
||||||
|
}
|
26
internal/activity_log/queries.sql
Normal file
26
internal/activity_log/queries.sql
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
-- name: get-all-activities
|
||||||
|
SELECT
|
||||||
|
COUNT(*) OVER() as total,
|
||||||
|
id,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
activity_type,
|
||||||
|
activity_description,
|
||||||
|
actor_id,
|
||||||
|
target_model_type,
|
||||||
|
target_model_id,
|
||||||
|
ip
|
||||||
|
FROM
|
||||||
|
activity_logs WHERE 1=1
|
||||||
|
|
||||||
|
-- name: insert-activity
|
||||||
|
INSERT INTO activity_logs (
|
||||||
|
activity_type,
|
||||||
|
activity_description,
|
||||||
|
actor_id,
|
||||||
|
target_model_type,
|
||||||
|
target_model_id,
|
||||||
|
ip
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6
|
||||||
|
);
|
@@ -80,6 +80,9 @@ const (
|
|||||||
|
|
||||||
// Custom attributes
|
// Custom attributes
|
||||||
PermCustomAttributesManage = "custom_attributes:manage"
|
PermCustomAttributesManage = "custom_attributes:manage"
|
||||||
|
|
||||||
|
// Activity log
|
||||||
|
PermActivityLogsManage = "activity_logs:manage"
|
||||||
)
|
)
|
||||||
|
|
||||||
var validPermissions = map[string]struct{}{
|
var validPermissions = map[string]struct{}{
|
||||||
@@ -121,6 +124,7 @@ var validPermissions = map[string]struct{}{
|
|||||||
PermContactNotesRead: {},
|
PermContactNotesRead: {},
|
||||||
PermContactNotesWrite: {},
|
PermContactNotesWrite: {},
|
||||||
PermContactNotesDelete: {},
|
PermContactNotesDelete: {},
|
||||||
|
PermActivityLogsManage: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
// PermissionExists returns true if the permission exists else false
|
// PermissionExists returns true if the permission exists else false
|
||||||
|
@@ -160,5 +160,51 @@ func V0_6_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create activity_log_type enum if not exists
|
||||||
|
_, err = db.Exec(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_type WHERE typname = 'activity_log_type'
|
||||||
|
) THEN
|
||||||
|
CREATE TYPE activity_log_type AS ENUM ('login', 'logout', 'away', 'away_reassigned', 'online');
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create activity_logs table if not exists
|
||||||
|
_, err = db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS activity_logs (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
activity_type activity_log_type NOT NULL,
|
||||||
|
activity_description TEXT NOT NULL,
|
||||||
|
actor_id INT REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
|
||||||
|
target_model_type TEXT NOT NULL,
|
||||||
|
target_model_id BIGINT NOT NULL,
|
||||||
|
ip INET
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS index_activity_logs_on_actor_id ON activity_logs (actor_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS index_activity_logs_on_activity_type ON activity_logs (activity_type);
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add `activity_logs:manage` permission to Admin role
|
||||||
|
_, err = db.Exec(`
|
||||||
|
UPDATE roles
|
||||||
|
SET permissions = array_append(permissions, 'activity_logs:manage')
|
||||||
|
WHERE name = 'Admin' AND NOT ('activity_logs:manage' = ANY(permissions));
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@@ -82,7 +82,7 @@ func (u *Manager) UpdateAgent(id int, user models.User) error {
|
|||||||
u.lo.Error("error generating bcrypt password", "error", err)
|
u.lo.Error("error generating bcrypt password", "error", err)
|
||||||
return envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}"), nil)
|
return envelope.NewError(envelope.GeneralError, u.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}"), nil)
|
||||||
}
|
}
|
||||||
u.lo.Debug("setting new password for user", "user_id", id)
|
u.lo.Info("setting new password for user", "user_id", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update user in the database.
|
// Update user in the database.
|
||||||
|
@@ -12,6 +12,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
UserModel = "user"
|
||||||
|
|
||||||
SystemUserEmail = "System"
|
SystemUserEmail = "System"
|
||||||
|
|
||||||
// User types
|
// User types
|
||||||
|
16
schema.sql
16
schema.sql
@@ -17,6 +17,7 @@ DROP TYPE IF EXISTS "user_availability_status" CASCADE; CREATE TYPE "user_availa
|
|||||||
DROP TYPE IF EXISTS "applied_sla_status" CASCADE; CREATE TYPE "applied_sla_status" AS ENUM ('pending', 'breached', 'met', 'partially_met');
|
DROP TYPE IF EXISTS "applied_sla_status" CASCADE; CREATE TYPE "applied_sla_status" AS ENUM ('pending', 'breached', 'met', 'partially_met');
|
||||||
DROP TYPE IF EXISTS "sla_metric" CASCADE; CREATE TYPE "sla_metric" AS ENUM ('first_response', 'resolution');
|
DROP TYPE IF EXISTS "sla_metric" CASCADE; CREATE TYPE "sla_metric" AS ENUM ('first_response', 'resolution');
|
||||||
DROP TYPE IF EXISTS "sla_notification_type" CASCADE; CREATE TYPE "sla_notification_type" AS ENUM ('warning', 'breach');
|
DROP TYPE IF EXISTS "sla_notification_type" CASCADE; CREATE TYPE "sla_notification_type" AS ENUM ('warning', 'breach');
|
||||||
|
DROP TYPE IF EXISTS "activity_log_type" CASCADE; CREATE TYPE "activity_log_type" AS ENUM ('login', 'logout', 'away', 'away_reassigned', 'online');
|
||||||
|
|
||||||
-- Sequence to generate reference number for conversations.
|
-- Sequence to generate reference number for conversations.
|
||||||
DROP SEQUENCE IF EXISTS conversation_reference_number_sequence; CREATE SEQUENCE conversation_reference_number_sequence START 100;
|
DROP SEQUENCE IF EXISTS conversation_reference_number_sequence; CREATE SEQUENCE conversation_reference_number_sequence START 100;
|
||||||
@@ -531,6 +532,21 @@ CREATE TABLE contact_notes (
|
|||||||
);
|
);
|
||||||
CREATE INDEX index_contact_notes_on_contact_id_created_at ON contact_notes (contact_id, created_at);
|
CREATE INDEX index_contact_notes_on_contact_id_created_at ON contact_notes (contact_id, created_at);
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS activity_logs CASCADE;
|
||||||
|
CREATE TABLE activity_logs (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
activity_type activity_log_type NOT NULL,
|
||||||
|
activity_description TEXT NOT NULL,
|
||||||
|
actor_id INT REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
|
||||||
|
target_model_type TEXT NOT NULL,
|
||||||
|
target_model_id BIGINT NOT NULL,
|
||||||
|
ip INET,
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS index_activity_logs_on_actor_id ON activity_logs (actor_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS index_activity_logs_on_activity_type ON activity_logs (activity_type);
|
||||||
|
|
||||||
INSERT INTO ai_providers
|
INSERT INTO ai_providers
|
||||||
("name", provider, config, is_default)
|
("name", provider, config, is_default)
|
||||||
VALUES('openai', 'openai', '{"api_key": ""}'::jsonb, true);
|
VALUES('openai', 'openai', '{"api_key": ""}'::jsonb, true);
|
||||||
|
Reference in New Issue
Block a user