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:
Abhinav Raut
2025-05-16 02:15:52 +05:30
parent d8a681d17e
commit 7f1c2c2f11
30 changed files with 781 additions and 28 deletions

36
cmd/actvity_log.go Normal file
View 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,
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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'
} }
] ]
}, },

View File

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

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

View File

@@ -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') }
] ]
}, },
{ {

View File

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

View File

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

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

View File

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

View File

@@ -341,6 +341,12 @@ const routes = [
} }
] ]
}, },
{
path: 'activity-log',
name: 'activity-log',
component: () => import('@/views/admin/activity-log/ActivityLog.vue'),
meta: { title: 'Activity Log' },
}
] ]
}, },
{ {

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

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

View File

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

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

View 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"`
}

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,8 @@ import (
) )
const ( const (
UserModel = "user"
SystemUserEmail = "System" SystemUserEmail = "System"
// User types // User types

View File

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