feat: add audit log dashboard with filtering and service integration
This commit is contained in:
340
src/app/dashboard/audit-log/page.tsx
Normal file
340
src/app/dashboard/audit-log/page.tsx
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
auditLogService,
|
||||||
|
type AuditLogEntry,
|
||||||
|
type AuditLogQuery,
|
||||||
|
} from '@/services';
|
||||||
|
|
||||||
|
const ACTION_OPTIONS: { value: string; label: string; group: string }[] = [
|
||||||
|
{ value: '', label: 'All actions', group: '' },
|
||||||
|
// Auth
|
||||||
|
{ value: 'AUTH_SIGNUP', label: 'Auth: Signup', group: 'Auth' },
|
||||||
|
{ value: 'AUTH_LOGIN_SUCCESS', label: 'Auth: Login Success', group: 'Auth' },
|
||||||
|
{ value: 'AUTH_LOGIN_FAIL', label: 'Auth: Login Fail', group: 'Auth' },
|
||||||
|
{ value: 'AUTH_LOGOUT', label: 'Auth: Logout', group: 'Auth' },
|
||||||
|
{ value: 'AUTH_PASSWORD_RESET_REQUEST', label: 'Auth: Password Reset Requested', group: 'Auth' },
|
||||||
|
{ value: 'AUTH_PASSWORD_RESET_CONFIRM', label: 'Auth: Password Reset Confirmed', group: 'Auth' },
|
||||||
|
{ value: 'AUTH_PASSWORD_CHANGE', label: 'Auth: Password Change', group: 'Auth' },
|
||||||
|
{ value: 'AUTH_EMAIL_CHANGE', label: 'Auth: Email Change', group: 'Auth' },
|
||||||
|
{ value: 'AUTH_EMAIL_VERIFY', label: 'Auth: Email Verify', group: 'Auth' },
|
||||||
|
// User / role
|
||||||
|
{ value: 'USER_UPDATE', label: 'User: Update', group: 'User' },
|
||||||
|
{ value: 'USER_DELETE', label: 'User: Delete', group: 'User' },
|
||||||
|
{ value: 'USER_SUSPEND', label: 'User: Suspend', group: 'User' },
|
||||||
|
{ value: 'USER_UNSUSPEND', label: 'User: Unsuspend', group: 'User' },
|
||||||
|
{ value: 'USER_ROLE_CHANGE', label: 'User: Role Change', group: 'User' },
|
||||||
|
// Agent
|
||||||
|
{ value: 'AGENT_PROFILE_UPDATE', label: 'Agent: Profile Update', group: 'Agent' },
|
||||||
|
{ value: 'AGENT_VERIFICATION_SUBMIT', label: 'Agent: Verification Submit', group: 'Agent' },
|
||||||
|
{ value: 'AGENT_VERIFICATION_APPROVE', label: 'Agent: Verification Approve', group: 'Agent' },
|
||||||
|
{ value: 'AGENT_VERIFICATION_REJECT', label: 'Agent: Verification Reject', group: 'Agent' },
|
||||||
|
// Billing
|
||||||
|
{ value: 'SUBSCRIPTION_CREATE', label: 'Billing: Subscription Create', group: 'Billing' },
|
||||||
|
{ value: 'SUBSCRIPTION_CANCEL', label: 'Billing: Subscription Cancel', group: 'Billing' },
|
||||||
|
{ value: 'PAYMENT_SUCCESS', label: 'Billing: Payment Success', group: 'Billing' },
|
||||||
|
{ value: 'PAYMENT_FAILURE', label: 'Billing: Payment Failure', group: 'Billing' },
|
||||||
|
// Moderation
|
||||||
|
{ value: 'REPORT_STATUS_CHANGE', label: 'Moderation: Report Status Change', group: 'Moderation' },
|
||||||
|
{ value: 'CONTACT_MESSAGE_RESPOND', label: 'Moderation: Contact Message', group: 'Moderation' },
|
||||||
|
{ value: 'SUPPORT_TICKET_CLOSE', label: 'Moderation: Support Ticket Close', group: 'Moderation' },
|
||||||
|
// Schema
|
||||||
|
{ value: 'PROFILE_FIELD_UPDATE', label: 'Schema: Profile Field Update', group: 'Schema' },
|
||||||
|
{ value: 'PROFILE_SECTION_UPDATE', label: 'Schema: Profile Section Update', group: 'Schema' },
|
||||||
|
{ value: 'AGENT_TYPE_UPDATE', label: 'Schema: Agent Type Update', group: 'Schema' },
|
||||||
|
// Connections
|
||||||
|
{ value: 'CONNECTION_REQUEST_ACCEPT', label: 'Connection: Accept', group: 'Connection' },
|
||||||
|
{ value: 'CONNECTION_REQUEST_REJECT', label: 'Connection: Reject', group: 'Connection' },
|
||||||
|
{ value: 'CONNECTION_REQUEST_CANCEL', label: 'Connection: Cancel', group: 'Connection' },
|
||||||
|
// CMS
|
||||||
|
{ value: 'CMS_BLOG_PUBLISH', label: 'CMS: Publish', group: 'CMS' },
|
||||||
|
{ value: 'CMS_BLOG_UNPUBLISH', label: 'CMS: Unpublish', group: 'CMS' },
|
||||||
|
{ value: 'CMS_BLOG_DELETE', label: 'CMS: Delete', group: 'CMS' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const RESOURCE_OPTIONS = [
|
||||||
|
'', 'User', 'AgentProfile', 'AgentSubscription', 'Payment', 'UserReport',
|
||||||
|
'ContactMessage', 'SupportChat', 'CmsContent', 'ProfileField', 'ProfileSection',
|
||||||
|
'AgentType', 'ConnectionRequest',
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatActor(entry: AuditLogEntry): string {
|
||||||
|
if (!entry.actor) {
|
||||||
|
if (entry.actorRole === 'SYSTEM') return 'SYSTEM';
|
||||||
|
return entry.actorId ? `${entry.actorId.slice(0, 8)}…` : 'anonymous';
|
||||||
|
}
|
||||||
|
const profile = entry.actor.agentProfile ?? entry.actor.userProfile;
|
||||||
|
const name = [profile?.firstName, profile?.lastName].filter(Boolean).join(' ');
|
||||||
|
return name || entry.actor.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelative(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
const diff = Date.now() - d.getTime();
|
||||||
|
const sec = Math.floor(diff / 1000);
|
||||||
|
if (sec < 60) return `${sec}s ago`;
|
||||||
|
const min = Math.floor(sec / 60);
|
||||||
|
if (min < 60) return `${min}m ago`;
|
||||||
|
const hr = Math.floor(min / 60);
|
||||||
|
if (hr < 24) return `${hr}h ago`;
|
||||||
|
const day = Math.floor(hr / 24);
|
||||||
|
if (day < 30) return `${day}d ago`;
|
||||||
|
return d.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuditLogPage() {
|
||||||
|
const [items, setItems] = useState<AuditLogEntry[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const limit = 25;
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const [actionFilter, setActionFilter] = useState('');
|
||||||
|
const [resourceFilter, setResourceFilter] = useState('');
|
||||||
|
const [actorIdFilter, setActorIdFilter] = useState('');
|
||||||
|
const [fromDate, setFromDate] = useState('');
|
||||||
|
const [toDate, setToDate] = useState('');
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const query: AuditLogQuery = {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
action: actionFilter || undefined,
|
||||||
|
resourceType: resourceFilter || undefined,
|
||||||
|
actorId: actorIdFilter || undefined,
|
||||||
|
from: fromDate ? new Date(fromDate).toISOString() : undefined,
|
||||||
|
to: toDate ? new Date(toDate + 'T23:59:59').toISOString() : undefined,
|
||||||
|
};
|
||||||
|
const res = await auditLogService.list(query);
|
||||||
|
setItems(res.items);
|
||||||
|
setTotal(res.total);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch audit log:', err);
|
||||||
|
setError('Failed to load audit log');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page, actionFilter, resourceFilter, actorIdFilter, fromDate, toDate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / limit));
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
setActionFilter('');
|
||||||
|
setResourceFilter('');
|
||||||
|
setActorIdFilter('');
|
||||||
|
setFromDate('');
|
||||||
|
setToDate('');
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Audit Log</h1>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
Who did what, when, and from where. Filter to narrow the trail.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow p-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">Action</label>
|
||||||
|
<select
|
||||||
|
value={actionFilter}
|
||||||
|
onChange={(e) => { setActionFilter(e.target.value); setPage(1); }}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm text-gray-900 bg-white"
|
||||||
|
>
|
||||||
|
{ACTION_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">Resource Type</label>
|
||||||
|
<select
|
||||||
|
value={resourceFilter}
|
||||||
|
onChange={(e) => { setResourceFilter(e.target.value); setPage(1); }}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm text-gray-900 bg-white"
|
||||||
|
>
|
||||||
|
{RESOURCE_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt} value={opt}>{opt || 'All resources'}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">Actor ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={actorIdFilter}
|
||||||
|
onChange={(e) => setActorIdFilter(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && setPage(1)}
|
||||||
|
placeholder="UUID"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm text-gray-900 bg-white placeholder:text-gray-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">From</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={fromDate}
|
||||||
|
onChange={(e) => { setFromDate(e.target.value); setPage(1); }}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm text-gray-900 bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-700 mb-1">To</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={toDate}
|
||||||
|
onChange={(e) => { setToDate(e.target.value); setPage(1); }}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm text-gray-900 bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-5 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={resetFilters}
|
||||||
|
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
Reset filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||||
|
<p className="text-red-700 text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="py-12 text-center text-gray-500 text-sm">No audit entries match your filters.</div>
|
||||||
|
) : (
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">When</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actor</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Action</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Resource</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">IP</th>
|
||||||
|
<th className="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{items.map((entry) => (
|
||||||
|
<>
|
||||||
|
<tr key={entry.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-900" title={new Date(entry.createdAt).toLocaleString()}>
|
||||||
|
{formatRelative(entry.createdAt)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-900">
|
||||||
|
<div className="font-medium">{formatActor(entry)}</div>
|
||||||
|
<div className="text-xs text-gray-500">{entry.actorRole ?? 'SYSTEM'}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm">
|
||||||
|
<span className="px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">
|
||||||
|
{entry.action}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-900">
|
||||||
|
{entry.resourceType ? (
|
||||||
|
<>
|
||||||
|
<div>{entry.resourceType}</div>
|
||||||
|
{entry.resourceId && (
|
||||||
|
<div className="text-xs text-gray-500 font-mono" title={entry.resourceId}>
|
||||||
|
{entry.resourceId.slice(0, 8)}…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-600 font-mono">
|
||||||
|
{entry.ipAddress ?? '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedId(expandedId === entry.id ? null : entry.id)}
|
||||||
|
className="text-blue-600 hover:text-blue-800 text-sm"
|
||||||
|
>
|
||||||
|
{expandedId === entry.id ? 'Hide' : 'Details'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{expandedId === entry.id && (
|
||||||
|
<tr key={`${entry.id}-detail`} className="bg-gray-50">
|
||||||
|
<td colSpan={6} className="px-4 py-3">
|
||||||
|
<div className="text-xs text-gray-700 space-y-1">
|
||||||
|
<div><span className="font-semibold">Full timestamp:</span> {new Date(entry.createdAt).toLocaleString()}</div>
|
||||||
|
<div><span className="font-semibold">Actor ID:</span> <span className="font-mono">{entry.actorId ?? '(none)'}</span></div>
|
||||||
|
{entry.actor?.email && (
|
||||||
|
<div><span className="font-semibold">Actor email:</span> {entry.actor.email}</div>
|
||||||
|
)}
|
||||||
|
{entry.userAgent && (
|
||||||
|
<div><span className="font-semibold">User Agent:</span> <span className="font-mono">{entry.userAgent}</span></div>
|
||||||
|
)}
|
||||||
|
{entry.metadata && (
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">Metadata:</span>
|
||||||
|
<pre className="mt-1 p-2 bg-white border border-gray-200 rounded text-xs font-mono overflow-x-auto">
|
||||||
|
{JSON.stringify(entry.metadata, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && items.length > 0 && (
|
||||||
|
<div className="px-4 py-3 border-t border-gray-200 flex items-center justify-between">
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
Showing {(page - 1) * limit + 1}–{Math.min(page * limit, total)} of {total}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="px-3 py-1 border border-gray-300 text-gray-700 rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className="px-3 py-1 text-sm text-gray-700">
|
||||||
|
Page {page} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
className="px-3 py-1 border border-gray-300 text-gray-700 rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -134,6 +134,20 @@ const menuItems = [
|
|||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Audit Log',
|
||||||
|
href: '/dashboard/audit-log',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9h6m-6 4h6m-6-8h6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Support Chat',
|
name: 'Support Chat',
|
||||||
href: '/dashboard/support-chat',
|
href: '/dashboard/support-chat',
|
||||||
|
|||||||
60
src/services/audit-log.service.ts
Normal file
60
src/services/audit-log.service.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import api, { ApiResponse } from './api';
|
||||||
|
|
||||||
|
export interface AuditLogActor {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
userProfile?: { firstName: string | null; lastName: string | null } | null;
|
||||||
|
agentProfile?: { firstName: string | null; lastName: string | null } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogEntry {
|
||||||
|
id: string;
|
||||||
|
actorId: string | null;
|
||||||
|
actorRole: string | null;
|
||||||
|
action: string;
|
||||||
|
resourceType: string | null;
|
||||||
|
resourceId: string | null;
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
|
ipAddress: string | null;
|
||||||
|
userAgent: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
actor?: AuditLogActor | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogQuery {
|
||||||
|
actorId?: string;
|
||||||
|
action?: string;
|
||||||
|
resourceType?: string;
|
||||||
|
resourceId?: string;
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogListResponse {
|
||||||
|
items: AuditLogEntry[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuditLogService {
|
||||||
|
private basePath = '/admin/audit-log';
|
||||||
|
|
||||||
|
async list(query: AuditLogQuery = {}): Promise<AuditLogListResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
Object.entries(query).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
params.set(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const response = await api.get<ApiResponse<AuditLogListResponse>>(
|
||||||
|
`${this.basePath}?${params.toString()}`,
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const auditLogService = new AuditLogService();
|
||||||
@@ -100,3 +100,12 @@ export type {
|
|||||||
SubscriptionsListResponse,
|
SubscriptionsListResponse,
|
||||||
RevenueStats,
|
RevenueStats,
|
||||||
} from './stripe.service';
|
} from './stripe.service';
|
||||||
|
|
||||||
|
// Audit Log Service
|
||||||
|
export { auditLogService } from './audit-log.service';
|
||||||
|
export type {
|
||||||
|
AuditLogEntry,
|
||||||
|
AuditLogActor,
|
||||||
|
AuditLogQuery,
|
||||||
|
AuditLogListResponse,
|
||||||
|
} from './audit-log.service';
|
||||||
|
|||||||
Reference in New Issue
Block a user