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) {
|
||||
// Authentication.
|
||||
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}/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.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.
|
||||
g.GET("/ws", auth(func(r *fastglue.Request) error {
|
||||
return handleWS(r, hub)
|
||||
|
15
cmd/init.go
15
cmd/init.go
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"html/template"
|
||||
|
||||
activitylog "github.com/abhinavxd/libredesk/internal/activity_log"
|
||||
"github.com/abhinavxd/libredesk/internal/ai"
|
||||
auth_ "github.com/abhinavxd/libredesk/internal/auth"
|
||||
"github.com/abhinavxd/libredesk/internal/authz"
|
||||
@@ -808,6 +809,20 @@ func initCustomAttribute(db *sqlx.DB, i18n *i18n.I18n) *customAttribute.Manager
|
||||
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.
|
||||
func initLogger(src string) *logf.Logger {
|
||||
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)
|
||||
email = string(r.RequestCtx.PostArgs().Peek("email"))
|
||||
password = r.RequestCtx.PostArgs().Peek("password")
|
||||
ip = r.RequestCtx.RemoteIP().String()
|
||||
)
|
||||
|
||||
// Verify email and password.
|
||||
@@ -53,12 +54,27 @@ func handleLogin(r *fastglue.Request) error {
|
||||
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)
|
||||
}
|
||||
|
||||
// handleLogout logs out the user and redirects to the dashboard.
|
||||
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 {
|
||||
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"
|
||||
|
||||
activitylog "github.com/abhinavxd/libredesk/internal/activity_log"
|
||||
"github.com/abhinavxd/libredesk/internal/ai"
|
||||
auth_ "github.com/abhinavxd/libredesk/internal/auth"
|
||||
"github.com/abhinavxd/libredesk/internal/authz"
|
||||
@@ -86,6 +87,7 @@ type App struct {
|
||||
view *view.Manager
|
||||
ai *ai.Manager
|
||||
search *search.Manager
|
||||
activityLog *activitylog.Manager
|
||||
notifier *notifier.Service
|
||||
customAttribute *customAttribute.Manager
|
||||
|
||||
@@ -218,6 +220,7 @@ func main() {
|
||||
conversation: conversation,
|
||||
automation: automation,
|
||||
businessHours: businessHours,
|
||||
activityLog: initActivityLog(db, i18n),
|
||||
customAttribute: initCustomAttribute(db, i18n),
|
||||
authz: initAuthz(i18n),
|
||||
view: initView(db),
|
||||
|
24
cmd/users.go
24
cmd/users.go
@@ -69,10 +69,19 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error {
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -191,6 +200,8 @@ func handleUpdateAgent(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
user = models.User{}
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
ip = r.RequestCtx.RemoteIP().String()
|
||||
)
|
||||
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
||||
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)
|
||||
}
|
||||
|
||||
agent, err := app.user.GetAgent(id, "")
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
oldAvailabilityStatus := agent.AvailabilityStatus
|
||||
|
||||
// Update agent.
|
||||
if err = app.user.UpdateAgent(id, user); err != nil {
|
||||
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.
|
||||
if err := app.team.UpsertUserTeams(id, user.Teams.Names()); err != nil {
|
||||
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 createContactNote = (id, data) => http.post(`/api/v1/contacts/${id}/notes`, data)
|
||||
const deleteContactNote = (id, noteId) => http.delete(`/api/v1/contacts/${id}/notes/${noteId}`)
|
||||
const getActivityLogs = (params) => http.get('/api/v1/activity-logs', { params })
|
||||
|
||||
export default {
|
||||
login,
|
||||
@@ -442,5 +443,6 @@ export default {
|
||||
getCustomAttribute,
|
||||
getContactNotes,
|
||||
createContactNote,
|
||||
deleteContactNote
|
||||
deleteContactNote,
|
||||
getActivityLogs
|
||||
}
|
||||
|
@@ -1,5 +1,5 @@
|
||||
<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">
|
||||
<tr>
|
||||
<th
|
||||
@@ -34,11 +34,11 @@
|
||||
<td
|
||||
v-for="key in keys"
|
||||
: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] }}
|
||||
</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)">
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -69,6 +69,10 @@ defineProps({
|
||||
type: Array,
|
||||
required: true,
|
||||
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',
|
||||
href: '/admin/teams/roles',
|
||||
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',
|
||||
CONTACT_NOTES_READ: 'contact_notes:read',
|
||||
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.SLA_MANAGE, label: t('admin.role.sla.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 { useEmitter } from '@/composables/useEmitter'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import { getVisiblePages } from '@/utils/pagination'
|
||||
import api from '@/api'
|
||||
|
||||
const contacts = ref([])
|
||||
@@ -200,21 +201,7 @@ const total = ref(0)
|
||||
const emitter = useEmitter()
|
||||
|
||||
// Google-style pagination
|
||||
const visiblePages = computed(() => {
|
||||
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 visiblePages = computed(() => getVisiblePages(page.value, totalPages.value))
|
||||
|
||||
const fetchContactsDebounced = debounce(() => {
|
||||
fetchContacts()
|
||||
|
@@ -74,7 +74,7 @@ import {
|
||||
FormMessage
|
||||
} from '@/components/ui/form'
|
||||
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 { toTypedSchema } from '@vee-validate/zod'
|
||||
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>
|
||||
<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>
|
||||
</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.file": "File | Files",
|
||||
"globals.terms.page": "Page | Pages",
|
||||
"globals.terms.activityLog": "Activity log | Activity logs",
|
||||
"globals.terms.name": "Name | Names",
|
||||
"globals.terms.image": "Image | Images",
|
||||
"globals.terms.thumbnail": "Thumbnail | Thumbnails",
|
||||
@@ -79,6 +80,7 @@
|
||||
"globals.terms.url": "URL | URLs",
|
||||
"globals.terms.key": "Key | Keys",
|
||||
"globals.terms.note": "Note | Notes",
|
||||
"globals.terms.ipAddress": "IP Address | IP Addresses",
|
||||
"globals.messages.badRequest": "Bad request",
|
||||
"globals.messages.adjustFilters": "Try adjusting filters",
|
||||
"globals.messages.errorUpdating": "Error updating {name}",
|
||||
@@ -251,6 +253,7 @@
|
||||
"navigation.reassignReplies": "Reassign replies",
|
||||
"navigation.allContacts": "All Contacts",
|
||||
"navigation.customAttributes": "Custom Attributes",
|
||||
"navigation.activityLog": "Activity Log",
|
||||
"form.field.name": "Name",
|
||||
"form.field.regex": "Regex",
|
||||
"form.field.regexHint": "Regex hint",
|
||||
@@ -492,6 +495,7 @@
|
||||
"admin.role.contactNotes.write": "Add Contact Notes",
|
||||
"admin.role.contactNotes.delete": "Delete Contact Notes",
|
||||
"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.conversationUpdate": "Conversation Update",
|
||||
"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
|
||||
PermCustomAttributesManage = "custom_attributes:manage"
|
||||
|
||||
// Activity log
|
||||
PermActivityLogsManage = "activity_logs:manage"
|
||||
)
|
||||
|
||||
var validPermissions = map[string]struct{}{
|
||||
@@ -121,6 +124,7 @@ var validPermissions = map[string]struct{}{
|
||||
PermContactNotesRead: {},
|
||||
PermContactNotesWrite: {},
|
||||
PermContactNotesDelete: {},
|
||||
PermActivityLogsManage: {},
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
@@ -82,7 +82,7 @@ func (u *Manager) UpdateAgent(id int, user models.User) error {
|
||||
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)
|
||||
}
|
||||
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.
|
||||
|
@@ -12,6 +12,8 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
UserModel = "user"
|
||||
|
||||
SystemUserEmail = "System"
|
||||
|
||||
// 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 "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 "activity_log_type" CASCADE; CREATE TYPE "activity_log_type" AS ENUM ('login', 'logout', 'away', 'away_reassigned', 'online');
|
||||
|
||||
-- Sequence to generate reference number for conversations.
|
||||
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);
|
||||
|
||||
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
|
||||
("name", provider, config, is_default)
|
||||
VALUES('openai', 'openai', '{"api_key": ""}'::jsonb, true);
|
||||
|
Reference in New Issue
Block a user