audit logic
This commit is contained in:
3611
pnpm-lock.yaml
generated
Normal file
3611
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import ProjectsPage from "./pages/projects/ProjectsPage";
|
||||
import UsersPage from "./pages/UsersPage";
|
||||
import RolesPage from "./pages/RolesPage";
|
||||
import ConsumptionPage from "./pages/consumption/ConsumptionPage";
|
||||
import AuditoriaPage from "./pages/AuditoriaPage";
|
||||
import ProfileModal from "./components/layout/common/ProfileModal";
|
||||
import { updateMyProfile } from "./api/me";
|
||||
|
||||
@@ -37,6 +38,7 @@ export type Page =
|
||||
| "meters"
|
||||
| "concentrators"
|
||||
| "consumption"
|
||||
| "auditoria"
|
||||
| "users"
|
||||
| "roles";
|
||||
|
||||
@@ -175,6 +177,8 @@ export default function App() {
|
||||
return <ConcentratorsPage />;
|
||||
case "consumption":
|
||||
return <ConsumptionPage />;
|
||||
case "auditoria":
|
||||
return <AuditoriaPage />;
|
||||
case "users":
|
||||
return <UsersPage />;
|
||||
case "roles":
|
||||
|
||||
151
src/api/audit.ts
Normal file
151
src/api/audit.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Audit API Client
|
||||
* Functions to interact with audit log endpoints
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
export type AuditAction =
|
||||
| 'CREATE'
|
||||
| 'UPDATE'
|
||||
| 'DELETE'
|
||||
| 'LOGIN'
|
||||
| 'LOGOUT'
|
||||
| 'READ'
|
||||
| 'EXPORT'
|
||||
| 'BULK_UPLOAD'
|
||||
| 'STATUS_CHANGE'
|
||||
| 'PERMISSION_CHANGE';
|
||||
|
||||
export interface AuditLog {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
user_email: string;
|
||||
user_name: string;
|
||||
action: AuditAction;
|
||||
table_name: string;
|
||||
record_id: string | null;
|
||||
old_values: Record<string, any> | null;
|
||||
new_values: Record<string, any> | null;
|
||||
description: string | null;
|
||||
ip_address: string | null;
|
||||
user_agent: string | null;
|
||||
success: boolean;
|
||||
error_message: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AuditLogFilters {
|
||||
userId?: string;
|
||||
action?: AuditAction;
|
||||
tableName?: string;
|
||||
recordId?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
success?: boolean;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface AuditLogListResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: AuditLog[];
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuditLogResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: AuditLog;
|
||||
}
|
||||
|
||||
export interface AuditStatistic {
|
||||
date: string;
|
||||
action: AuditAction;
|
||||
table_name: string;
|
||||
action_count: number;
|
||||
unique_users: number;
|
||||
successful_actions: number;
|
||||
failed_actions: number;
|
||||
}
|
||||
|
||||
export interface AuditStatisticsResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: AuditStatistic[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all audit logs with filters (admin only)
|
||||
*/
|
||||
export async function getAuditLogs(
|
||||
filters?: AuditLogFilters
|
||||
): Promise<AuditLogListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters?.userId) params.append('userId', filters.userId);
|
||||
if (filters?.action) params.append('action', filters.action);
|
||||
if (filters?.tableName) params.append('tableName', filters.tableName);
|
||||
if (filters?.recordId) params.append('recordId', filters.recordId);
|
||||
if (filters?.startDate) params.append('startDate', filters.startDate);
|
||||
if (filters?.endDate) params.append('endDate', filters.endDate);
|
||||
if (filters?.success !== undefined)
|
||||
params.append('success', String(filters.success));
|
||||
if (filters?.page) params.append('page', String(filters.page));
|
||||
if (filters?.limit) params.append('limit', String(filters.limit));
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = queryString ? `/api/audit-logs?${queryString}` : '/api/audit-logs';
|
||||
|
||||
return apiClient.get<AuditLogListResponse>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single audit log by ID (admin only)
|
||||
*/
|
||||
export async function getAuditLogById(id: string): Promise<AuditLogResponse> {
|
||||
return apiClient.get<AuditLogResponse>(`/api/audit-logs/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit logs for a specific record (admin only)
|
||||
*/
|
||||
export async function getAuditLogsForRecord(
|
||||
tableName: string,
|
||||
recordId: string
|
||||
): Promise<AuditLogListResponse> {
|
||||
return apiClient.get<AuditLogListResponse>(
|
||||
`/api/audit-logs/record/${tableName}/${recordId}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit statistics (admin only)
|
||||
*/
|
||||
export async function getAuditStatistics(
|
||||
days: number = 30
|
||||
): Promise<AuditStatisticsResponse> {
|
||||
return apiClient.get<AuditStatisticsResponse>(
|
||||
`/api/audit-logs/statistics?days=${days}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user's activity logs
|
||||
*/
|
||||
export async function getMyActivity(
|
||||
page: number = 1,
|
||||
limit: number = 50
|
||||
): Promise<AuditLogListResponse> {
|
||||
return apiClient.get<AuditLogListResponse>(
|
||||
`/api/audit-logs/my-activity?page=${page}&limit=${limit}`
|
||||
);
|
||||
}
|
||||
@@ -114,6 +114,15 @@ export default function Sidebar({ setPage }: SidebarProps) {
|
||||
Consumo
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<button
|
||||
onClick={() => setPage("auditoria")}
|
||||
className="pl-10 w-full text-left px-2 py-1.5 rounded-md hover:bg-white/10"
|
||||
>
|
||||
Auditoría
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
|
||||
460
src/pages/AuditoriaPage.tsx
Normal file
460
src/pages/AuditoriaPage.tsx
Normal file
@@ -0,0 +1,460 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Search, Filter, RefreshCw, Download, Eye } from "lucide-react";
|
||||
import {
|
||||
getAuditLogs,
|
||||
type AuditLog,
|
||||
type AuditAction,
|
||||
} from "../api/audit";
|
||||
|
||||
export default function AuditoriaPage() {
|
||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedAction, setSelectedAction] = useState<AuditAction | "">("");
|
||||
const [selectedTable, setSelectedTable] = useState("");
|
||||
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
|
||||
// Pagination
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const limit = 50;
|
||||
|
||||
const actions: AuditAction[] = [
|
||||
"CREATE",
|
||||
"UPDATE",
|
||||
"DELETE",
|
||||
"LOGIN",
|
||||
"LOGOUT",
|
||||
"READ",
|
||||
"EXPORT",
|
||||
"BULK_UPLOAD",
|
||||
"STATUS_CHANGE",
|
||||
"PERMISSION_CHANGE",
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
fetchAuditLogs();
|
||||
}, [currentPage, selectedAction, selectedTable]);
|
||||
|
||||
const fetchAuditLogs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const filters: any = {
|
||||
page: currentPage,
|
||||
limit,
|
||||
};
|
||||
|
||||
if (selectedAction) filters.action = selectedAction;
|
||||
if (selectedTable) filters.tableName = selectedTable;
|
||||
|
||||
const response = await getAuditLogs(filters);
|
||||
setLogs(response.data);
|
||||
setTotal(response.pagination.total);
|
||||
setTotalPages(response.pagination.totalPages);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch audit logs:", error);
|
||||
setLogs([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
setCurrentPage(1);
|
||||
fetchAuditLogs();
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setSelectedAction("");
|
||||
setSelectedTable("");
|
||||
setSearch("");
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handleViewDetails = (log: AuditLog) => {
|
||||
setSelectedLog(log);
|
||||
setShowDetails(true);
|
||||
};
|
||||
|
||||
const getActionColor = (action: AuditAction) => {
|
||||
const colors: Record<AuditAction, string> = {
|
||||
CREATE: "bg-green-100 text-green-800",
|
||||
UPDATE: "bg-blue-100 text-blue-800",
|
||||
DELETE: "bg-red-100 text-red-800",
|
||||
LOGIN: "bg-purple-100 text-purple-800",
|
||||
LOGOUT: "bg-gray-100 text-gray-800",
|
||||
READ: "bg-cyan-100 text-cyan-800",
|
||||
EXPORT: "bg-yellow-100 text-yellow-800",
|
||||
BULK_UPLOAD: "bg-orange-100 text-orange-800",
|
||||
STATUS_CHANGE: "bg-indigo-100 text-indigo-800",
|
||||
PERMISSION_CHANGE: "bg-pink-100 text-pink-800",
|
||||
};
|
||||
return colors[action] || "bg-gray-100 text-gray-800";
|
||||
};
|
||||
|
||||
const filteredLogs = logs.filter((log) => {
|
||||
if (!search) return true;
|
||||
const searchLower = search.toLowerCase();
|
||||
return (
|
||||
log.user_email.toLowerCase().includes(searchLower) ||
|
||||
log.user_name.toLowerCase().includes(searchLower) ||
|
||||
log.table_name.toLowerCase().includes(searchLower) ||
|
||||
log.description?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
|
||||
const uniqueTables = Array.from(new Set(logs.map((log) => log.table_name)));
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 border-r border-gray-200 bg-white p-4">
|
||||
<h2 className="text-lg font-semibold mb-4">Filtros</h2>
|
||||
|
||||
{/* Action Filter */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Acción
|
||||
</label>
|
||||
<select
|
||||
value={selectedAction}
|
||||
onChange={(e) => setSelectedAction(e.target.value as AuditAction | "")}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Todas las acciones</option>
|
||||
{actions.map((action) => (
|
||||
<option key={action} value={action}>
|
||||
{action}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table Filter */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Tabla
|
||||
</label>
|
||||
<select
|
||||
value={selectedTable}
|
||||
onChange={(e) => setSelectedTable(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Todas las tablas</option>
|
||||
{uniqueTables.map((table) => (
|
||||
<option key={table} value={table}>
|
||||
{table}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Clear Filters */}
|
||||
<button
|
||||
onClick={handleClearFilters}
|
||||
className="w-full bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-2 rounded-md text-sm"
|
||||
>
|
||||
Limpiar filtros
|
||||
</button>
|
||||
|
||||
{/* Statistics */}
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-2">
|
||||
Estadísticas
|
||||
</h3>
|
||||
<div className="text-sm text-gray-600">
|
||||
<p>Total de registros: {total}</p>
|
||||
<p>Página actual: {currentPage} de {totalPages}</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-200 bg-white px-6 py-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
Auditoría del Sistema
|
||||
</h1>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white px-4 py-2 rounded-md flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
|
||||
Actualizar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por usuario, email, tabla o descripción..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
Cargando registros...
|
||||
</div>
|
||||
) : filteredLogs.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
No se encontraron registros de auditoría
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<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">
|
||||
Fecha/Hora
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Usuario
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Acción
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Tabla
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Descripción
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Estado
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Acciones
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredLogs.map((log) => (
|
||||
<tr key={log.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm text-gray-900 whitespace-nowrap">
|
||||
{new Date(log.created_at).toLocaleString("es-MX")}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<div className="font-medium text-gray-900">
|
||||
{log.user_name}
|
||||
</div>
|
||||
<div className="text-gray-500">{log.user_email}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<span
|
||||
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getActionColor(
|
||||
log.action
|
||||
)}`}
|
||||
>
|
||||
{log.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900">
|
||||
{log.table_name}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">
|
||||
{log.description || "-"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<span
|
||||
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
log.success
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-red-100 text-red-800"
|
||||
}`}
|
||||
>
|
||||
{log.success ? "Éxito" : "Fallo"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<button
|
||||
onClick={() => handleViewDetails(log)}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 flex justify-center gap-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-4 py-2 border border-gray-300 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Anterior
|
||||
</button>
|
||||
<span className="px-4 py-2">
|
||||
Página {currentPage} de {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-4 py-2 border border-gray-300 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Siguiente
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Details Modal */}
|
||||
{showDetails && selectedLog && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] overflow-auto m-4">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-xl font-semibold">Detalles del Registro</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
ID
|
||||
</label>
|
||||
<p className="text-sm text-gray-900 font-mono">
|
||||
{selectedLog.id}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Fecha/Hora
|
||||
</label>
|
||||
<p className="text-sm text-gray-900">
|
||||
{new Date(selectedLog.created_at).toLocaleString("es-MX")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Usuario
|
||||
</label>
|
||||
<p className="text-sm text-gray-900">{selectedLog.user_name}</p>
|
||||
<p className="text-xs text-gray-500">{selectedLog.user_email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Acción
|
||||
</label>
|
||||
<span
|
||||
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getActionColor(
|
||||
selectedLog.action
|
||||
)}`}
|
||||
>
|
||||
{selectedLog.action}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Tabla
|
||||
</label>
|
||||
<p className="text-sm text-gray-900">{selectedLog.table_name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Record ID
|
||||
</label>
|
||||
<p className="text-sm text-gray-900 font-mono">
|
||||
{selectedLog.record_id || "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
IP Address
|
||||
</label>
|
||||
<p className="text-sm text-gray-900">
|
||||
{selectedLog.ip_address || "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Estado
|
||||
</label>
|
||||
<span
|
||||
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
selectedLog.success
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-red-100 text-red-800"
|
||||
}`}
|
||||
>
|
||||
{selectedLog.success ? "Éxito" : "Fallo"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedLog.description && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Descripción
|
||||
</label>
|
||||
<p className="text-sm text-gray-900">{selectedLog.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedLog.old_values && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Valores Anteriores
|
||||
</label>
|
||||
<pre className="bg-gray-50 p-3 rounded text-xs overflow-auto">
|
||||
{JSON.stringify(selectedLog.old_values, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedLog.new_values && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Valores Nuevos
|
||||
</label>
|
||||
<pre className="bg-gray-50 p-3 rounded text-xs overflow-auto">
|
||||
{JSON.stringify(selectedLog.new_values, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedLog.error_message && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-red-700 mb-2">
|
||||
Mensaje de Error
|
||||
</label>
|
||||
<p className="text-sm text-red-900 bg-red-50 p-3 rounded">
|
||||
{selectedLog.error_message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-6 border-t border-gray-200 flex justify-end">
|
||||
<button
|
||||
onClick={() => setShowDetails(false)}
|
||||
className="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 rounded-md"
|
||||
>
|
||||
Cerrar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
207
water-api/sql/add_audit_logs.sql
Normal file
207
water-api/sql/add_audit_logs.sql
Normal file
@@ -0,0 +1,207 @@
|
||||
-- ============================================================================
|
||||
-- Audit Logs Migration
|
||||
-- Add audit logging table to track user actions and system changes
|
||||
-- ============================================================================
|
||||
|
||||
-- ============================================================================
|
||||
-- ENUM TYPE: audit_action
|
||||
-- ============================================================================
|
||||
CREATE TYPE audit_action AS ENUM (
|
||||
'CREATE',
|
||||
'UPDATE',
|
||||
'DELETE',
|
||||
'LOGIN',
|
||||
'LOGOUT',
|
||||
'READ',
|
||||
'EXPORT',
|
||||
'BULK_UPLOAD',
|
||||
'STATUS_CHANGE',
|
||||
'PERMISSION_CHANGE'
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- TABLE: audit_logs
|
||||
-- ============================================================================
|
||||
CREATE TABLE audit_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
-- User information
|
||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
user_email VARCHAR(255) NOT NULL,
|
||||
user_name VARCHAR(255) NOT NULL,
|
||||
|
||||
-- Action details
|
||||
action audit_action NOT NULL,
|
||||
table_name VARCHAR(100) NOT NULL,
|
||||
record_id UUID,
|
||||
|
||||
-- Change tracking
|
||||
old_values JSONB,
|
||||
new_values JSONB,
|
||||
description TEXT,
|
||||
|
||||
-- Request metadata
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
|
||||
-- Status
|
||||
success BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
error_message TEXT,
|
||||
|
||||
-- Timestamp
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- INDEXES
|
||||
-- ============================================================================
|
||||
CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id);
|
||||
CREATE INDEX idx_audit_logs_action ON audit_logs(action);
|
||||
CREATE INDEX idx_audit_logs_table_name ON audit_logs(table_name);
|
||||
CREATE INDEX idx_audit_logs_record_id ON audit_logs(record_id);
|
||||
CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at DESC);
|
||||
CREATE INDEX idx_audit_logs_user_id_created_at ON audit_logs(user_id, created_at DESC);
|
||||
CREATE INDEX idx_audit_logs_table_name_record_id ON audit_logs(table_name, record_id);
|
||||
|
||||
-- Index for JSON queries on old_values and new_values
|
||||
CREATE INDEX idx_audit_logs_old_values ON audit_logs USING GIN (old_values);
|
||||
CREATE INDEX idx_audit_logs_new_values ON audit_logs USING GIN (new_values);
|
||||
|
||||
-- ============================================================================
|
||||
-- COMMENTS
|
||||
-- ============================================================================
|
||||
COMMENT ON TABLE audit_logs IS 'System audit log tracking all user actions and data changes';
|
||||
COMMENT ON COLUMN audit_logs.user_id IS 'Reference to user who performed the action (nullable if user deleted)';
|
||||
COMMENT ON COLUMN audit_logs.user_email IS 'Email snapshot at time of action';
|
||||
COMMENT ON COLUMN audit_logs.user_name IS 'Name snapshot at time of action';
|
||||
COMMENT ON COLUMN audit_logs.action IS 'Type of action performed';
|
||||
COMMENT ON COLUMN audit_logs.table_name IS 'Database table affected by the action';
|
||||
COMMENT ON COLUMN audit_logs.record_id IS 'ID of the specific record affected';
|
||||
COMMENT ON COLUMN audit_logs.old_values IS 'JSON snapshot of values before change';
|
||||
COMMENT ON COLUMN audit_logs.new_values IS 'JSON snapshot of values after change';
|
||||
COMMENT ON COLUMN audit_logs.description IS 'Human-readable description of the action';
|
||||
COMMENT ON COLUMN audit_logs.ip_address IS 'IP address of the user';
|
||||
COMMENT ON COLUMN audit_logs.user_agent IS 'Browser/client user agent string';
|
||||
COMMENT ON COLUMN audit_logs.success IS 'Whether the action completed successfully';
|
||||
COMMENT ON COLUMN audit_logs.error_message IS 'Error message if action failed';
|
||||
|
||||
-- ============================================================================
|
||||
-- HELPER FUNCTION: Get current user info from request context
|
||||
-- ============================================================================
|
||||
CREATE OR REPLACE FUNCTION get_current_user_info()
|
||||
RETURNS TABLE (
|
||||
user_id UUID,
|
||||
user_email VARCHAR(255),
|
||||
user_name VARCHAR(255)
|
||||
) AS $$
|
||||
BEGIN
|
||||
-- This will be called from application code with current_setting
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
NULLIF(current_setting('app.current_user_id', true), '')::UUID,
|
||||
NULLIF(current_setting('app.current_user_email', true), ''),
|
||||
NULLIF(current_setting('app.current_user_name', true), '');
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- ============================================================================
|
||||
-- HELPER FUNCTION: Log audit entry
|
||||
-- ============================================================================
|
||||
CREATE OR REPLACE FUNCTION log_audit(
|
||||
p_user_id UUID,
|
||||
p_user_email VARCHAR(255),
|
||||
p_user_name VARCHAR(255),
|
||||
p_action audit_action,
|
||||
p_table_name VARCHAR(100),
|
||||
p_record_id UUID DEFAULT NULL,
|
||||
p_old_values JSONB DEFAULT NULL,
|
||||
p_new_values JSONB DEFAULT NULL,
|
||||
p_description TEXT DEFAULT NULL,
|
||||
p_ip_address INET DEFAULT NULL,
|
||||
p_user_agent TEXT DEFAULT NULL,
|
||||
p_success BOOLEAN DEFAULT TRUE,
|
||||
p_error_message TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS UUID AS $$
|
||||
DECLARE
|
||||
v_log_id UUID;
|
||||
BEGIN
|
||||
INSERT INTO audit_logs (
|
||||
user_id,
|
||||
user_email,
|
||||
user_name,
|
||||
action,
|
||||
table_name,
|
||||
record_id,
|
||||
old_values,
|
||||
new_values,
|
||||
description,
|
||||
ip_address,
|
||||
user_agent,
|
||||
success,
|
||||
error_message
|
||||
) VALUES (
|
||||
p_user_id,
|
||||
p_user_email,
|
||||
p_user_name,
|
||||
p_action,
|
||||
p_table_name,
|
||||
p_record_id,
|
||||
p_old_values,
|
||||
p_new_values,
|
||||
p_description,
|
||||
p_ip_address,
|
||||
p_user_agent,
|
||||
p_success,
|
||||
p_error_message
|
||||
) RETURNING id INTO v_log_id;
|
||||
|
||||
RETURN v_log_id;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- ============================================================================
|
||||
-- VIEW: audit_logs_summary
|
||||
-- ============================================================================
|
||||
CREATE OR REPLACE VIEW audit_logs_summary AS
|
||||
SELECT
|
||||
al.id,
|
||||
al.user_email,
|
||||
al.user_name,
|
||||
al.action,
|
||||
al.table_name,
|
||||
al.record_id,
|
||||
al.description,
|
||||
al.success,
|
||||
al.created_at,
|
||||
al.ip_address,
|
||||
-- User reference (may be null if user deleted)
|
||||
u.id AS current_user_id,
|
||||
u.is_active AS user_is_active
|
||||
FROM audit_logs al
|
||||
LEFT JOIN users u ON al.user_id = u.id
|
||||
ORDER BY al.created_at DESC;
|
||||
|
||||
COMMENT ON VIEW audit_logs_summary IS 'Audit logs with user status information';
|
||||
|
||||
-- ============================================================================
|
||||
-- VIEW: audit_statistics
|
||||
-- ============================================================================
|
||||
CREATE OR REPLACE VIEW audit_statistics AS
|
||||
SELECT
|
||||
DATE(created_at) AS date,
|
||||
action,
|
||||
table_name,
|
||||
COUNT(*) AS action_count,
|
||||
COUNT(DISTINCT user_id) AS unique_users,
|
||||
SUM(CASE WHEN success THEN 1 ELSE 0 END) AS successful_actions,
|
||||
SUM(CASE WHEN NOT success THEN 1 ELSE 0 END) AS failed_actions
|
||||
FROM audit_logs
|
||||
GROUP BY DATE(created_at), action, table_name
|
||||
ORDER BY date DESC, action_count DESC;
|
||||
|
||||
COMMENT ON VIEW audit_statistics IS 'Daily statistics of audit log actions';
|
||||
|
||||
-- ============================================================================
|
||||
-- END OF MIGRATION
|
||||
-- ============================================================================
|
||||
213
water-api/src/controllers/audit.controller.ts
Normal file
213
water-api/src/controllers/audit.controller.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Audit Controller
|
||||
* Handles HTTP requests for audit log operations
|
||||
*/
|
||||
|
||||
import { Response } from 'express';
|
||||
import { AuthenticatedRequest } from '../types';
|
||||
import * as auditService from '../services/audit.service';
|
||||
|
||||
/**
|
||||
* GET /audit-logs
|
||||
* Get audit logs with filters and pagination (admin only)
|
||||
*/
|
||||
export async function getAuditLogs(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const {
|
||||
userId,
|
||||
action,
|
||||
tableName,
|
||||
recordId,
|
||||
startDate,
|
||||
endDate,
|
||||
success,
|
||||
page = '1',
|
||||
limit = '50',
|
||||
} = req.query;
|
||||
|
||||
const filters: auditService.AuditLogFilters = {
|
||||
userId: userId as string,
|
||||
action: action as auditService.AuditAction,
|
||||
tableName: tableName as string,
|
||||
recordId: recordId as string,
|
||||
startDate: startDate ? new Date(startDate as string) : undefined,
|
||||
endDate: endDate ? new Date(endDate as string) : undefined,
|
||||
success: success === 'true' ? true : success === 'false' ? false : undefined,
|
||||
page: parseInt(page as string, 10),
|
||||
limit: parseInt(limit as string, 10),
|
||||
};
|
||||
|
||||
const result = await auditService.getAuditLogs(filters);
|
||||
|
||||
const totalPages = Math.ceil(result.total / filters.limit!);
|
||||
const hasNextPage = filters.page! < totalPages;
|
||||
const hasPreviousPage = filters.page! > 1;
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Audit logs retrieved successfully',
|
||||
data: result.logs,
|
||||
pagination: {
|
||||
page: filters.page,
|
||||
limit: filters.limit,
|
||||
total: result.total,
|
||||
totalPages,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching audit logs:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message || 'Failed to fetch audit logs',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /audit-logs/:id
|
||||
* Get a single audit log by ID (admin only)
|
||||
*/
|
||||
export async function getAuditLogById(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const log = await auditService.getAuditLogById(id);
|
||||
|
||||
if (!log) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Audit log not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Audit log retrieved successfully',
|
||||
data: log,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching audit log:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message || 'Failed to fetch audit log',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /audit-logs/record/:tableName/:recordId
|
||||
* Get audit logs for a specific record (admin only)
|
||||
*/
|
||||
export async function getAuditLogsForRecord(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName, recordId } = req.params;
|
||||
|
||||
const logs = await auditService.getAuditLogsForRecord(tableName, recordId);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Audit logs retrieved successfully',
|
||||
data: logs,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching audit logs for record:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message || 'Failed to fetch audit logs for record',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /audit-logs/statistics
|
||||
* Get audit statistics (admin only)
|
||||
*/
|
||||
export async function getAuditStatistics(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { days = '30' } = req.query;
|
||||
const daysNum = parseInt(days as string, 10);
|
||||
|
||||
const stats = await auditService.getAuditStatistics(daysNum);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Audit statistics retrieved successfully',
|
||||
data: stats,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching audit statistics:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message || 'Failed to fetch audit statistics',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /audit-logs/my-activity
|
||||
* Get current user's own audit logs
|
||||
*/
|
||||
export async function getMyActivity(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const user = req.user;
|
||||
if (!user) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: 'User not authenticated',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { page = '1', limit = '50' } = req.query;
|
||||
|
||||
const filters: auditService.AuditLogFilters = {
|
||||
userId: user.userId,
|
||||
page: parseInt(page as string, 10),
|
||||
limit: parseInt(limit as string, 10),
|
||||
};
|
||||
|
||||
const result = await auditService.getAuditLogs(filters);
|
||||
|
||||
const totalPages = Math.ceil(result.total / filters.limit!);
|
||||
const hasNextPage = filters.page! < totalPages;
|
||||
const hasPreviousPage = filters.page! > 1;
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Your activity logs retrieved successfully',
|
||||
data: result.logs,
|
||||
pagination: {
|
||||
page: filters.page,
|
||||
limit: filters.limit,
|
||||
total: result.total,
|
||||
totalPages,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching user activity:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message || 'Failed to fetch activity logs',
|
||||
});
|
||||
}
|
||||
}
|
||||
69
water-api/src/routes/audit.routes.ts
Normal file
69
water-api/src/routes/audit.routes.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Audit Routes
|
||||
* Defines API endpoints for audit log operations
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import * as auditController from '../controllers/audit.controller';
|
||||
import { authenticateToken, requireRole } from '../middleware/auth.middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /audit-logs
|
||||
* Get all audit logs with filters (admin only)
|
||||
* Query params: userId, action, tableName, recordId, startDate, endDate, success, page, limit
|
||||
*/
|
||||
router.get(
|
||||
'/',
|
||||
authenticateToken,
|
||||
requireRole('ADMIN'),
|
||||
auditController.getAuditLogs
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /audit-logs/my-activity
|
||||
* Get current user's own audit logs
|
||||
* Query params: page, limit
|
||||
*/
|
||||
router.get(
|
||||
'/my-activity',
|
||||
authenticateToken,
|
||||
auditController.getMyActivity
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /audit-logs/statistics
|
||||
* Get audit statistics (admin only)
|
||||
* Query params: days
|
||||
*/
|
||||
router.get(
|
||||
'/statistics',
|
||||
authenticateToken,
|
||||
requireRole('ADMIN'),
|
||||
auditController.getAuditStatistics
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /audit-logs/:id
|
||||
* Get a single audit log by ID (admin only)
|
||||
*/
|
||||
router.get(
|
||||
'/:id',
|
||||
authenticateToken,
|
||||
requireRole('ADMIN'),
|
||||
auditController.getAuditLogById
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /audit-logs/record/:tableName/:recordId
|
||||
* Get audit logs for a specific record (admin only)
|
||||
*/
|
||||
router.get(
|
||||
'/record/:tableName/:recordId',
|
||||
authenticateToken,
|
||||
requireRole('ADMIN'),
|
||||
auditController.getAuditLogsForRecord
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -12,6 +12,7 @@ import roleRoutes from './role.routes';
|
||||
import ttsRoutes from './tts.routes';
|
||||
import readingRoutes from './reading.routes';
|
||||
import bulkUploadRoutes from './bulk-upload.routes';
|
||||
import auditRoutes from './audit.routes';
|
||||
|
||||
// Create main router
|
||||
const router = Router();
|
||||
@@ -130,4 +131,14 @@ router.use('/readings', readingRoutes);
|
||||
*/
|
||||
router.use('/bulk-upload', bulkUploadRoutes);
|
||||
|
||||
/**
|
||||
* Audit routes:
|
||||
* - GET /audit-logs - List all audit logs (admin only)
|
||||
* - GET /audit-logs/my-activity - Get current user's activity
|
||||
* - GET /audit-logs/statistics - Get audit statistics (admin only)
|
||||
* - GET /audit-logs/:id - Get audit log by ID (admin only)
|
||||
* - GET /audit-logs/record/:tableName/:recordId - Get logs for specific record (admin only)
|
||||
*/
|
||||
router.use('/audit-logs', auditRoutes);
|
||||
|
||||
export default router;
|
||||
|
||||
298
water-api/src/services/audit.service.ts
Normal file
298
water-api/src/services/audit.service.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Audit Service
|
||||
* Handles logging of user actions and system changes
|
||||
*/
|
||||
|
||||
import { query } from '../config/database';
|
||||
import type { Request } from 'express';
|
||||
|
||||
export type AuditAction =
|
||||
| 'CREATE'
|
||||
| 'UPDATE'
|
||||
| 'DELETE'
|
||||
| 'LOGIN'
|
||||
| 'LOGOUT'
|
||||
| 'READ'
|
||||
| 'EXPORT'
|
||||
| 'BULK_UPLOAD'
|
||||
| 'STATUS_CHANGE'
|
||||
| 'PERMISSION_CHANGE';
|
||||
|
||||
export interface AuditLogData {
|
||||
userId: string;
|
||||
userEmail: string;
|
||||
userName: string;
|
||||
action: AuditAction;
|
||||
tableName: string;
|
||||
recordId?: string;
|
||||
oldValues?: Record<string, any>;
|
||||
newValues?: Record<string, any>;
|
||||
description?: string;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
success?: boolean;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface AuditLog {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
user_email: string;
|
||||
user_name: string;
|
||||
action: AuditAction;
|
||||
table_name: string;
|
||||
record_id: string | null;
|
||||
old_values: Record<string, any> | null;
|
||||
new_values: Record<string, any> | null;
|
||||
description: string | null;
|
||||
ip_address: string | null;
|
||||
user_agent: string | null;
|
||||
success: boolean;
|
||||
error_message: string | null;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface AuditLogFilters {
|
||||
userId?: string;
|
||||
action?: AuditAction;
|
||||
tableName?: string;
|
||||
recordId?: string;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
success?: boolean;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an audit log entry
|
||||
*/
|
||||
export async function createAuditLog(data: AuditLogData): Promise<string> {
|
||||
const sql = `
|
||||
SELECT log_audit(
|
||||
$1::UUID,
|
||||
$2,
|
||||
$3,
|
||||
$4::audit_action,
|
||||
$5,
|
||||
$6::UUID,
|
||||
$7::JSONB,
|
||||
$8::JSONB,
|
||||
$9,
|
||||
$10::INET,
|
||||
$11,
|
||||
$12,
|
||||
$13
|
||||
) as log_id
|
||||
`;
|
||||
|
||||
const result = await query(sql, [
|
||||
data.userId,
|
||||
data.userEmail,
|
||||
data.userName,
|
||||
data.action,
|
||||
data.tableName,
|
||||
data.recordId || null,
|
||||
data.oldValues ? JSON.stringify(data.oldValues) : null,
|
||||
data.newValues ? JSON.stringify(data.newValues) : null,
|
||||
data.description || null,
|
||||
data.ipAddress || null,
|
||||
data.userAgent || null,
|
||||
data.success !== undefined ? data.success : true,
|
||||
data.errorMessage || null,
|
||||
]);
|
||||
|
||||
return result.rows[0].log_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit logs with filters and pagination
|
||||
*/
|
||||
export async function getAuditLogs(
|
||||
filters: AuditLogFilters = {}
|
||||
): Promise<{ logs: AuditLog[]; total: number }> {
|
||||
const {
|
||||
userId,
|
||||
action,
|
||||
tableName,
|
||||
recordId,
|
||||
startDate,
|
||||
endDate,
|
||||
success,
|
||||
page = 1,
|
||||
limit = 50,
|
||||
} = filters;
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (userId) {
|
||||
conditions.push(`user_id = $${paramIndex++}`);
|
||||
params.push(userId);
|
||||
}
|
||||
|
||||
if (action) {
|
||||
conditions.push(`action = $${paramIndex++}`);
|
||||
params.push(action);
|
||||
}
|
||||
|
||||
if (tableName) {
|
||||
conditions.push(`table_name = $${paramIndex++}`);
|
||||
params.push(tableName);
|
||||
}
|
||||
|
||||
if (recordId) {
|
||||
conditions.push(`record_id = $${paramIndex++}`);
|
||||
params.push(recordId);
|
||||
}
|
||||
|
||||
if (startDate) {
|
||||
conditions.push(`created_at >= $${paramIndex++}`);
|
||||
params.push(startDate);
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
conditions.push(`created_at <= $${paramIndex++}`);
|
||||
params.push(endDate);
|
||||
}
|
||||
|
||||
if (success !== undefined) {
|
||||
conditions.push(`success = $${paramIndex++}`);
|
||||
params.push(success);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
// Get total count
|
||||
const countSql = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM audit_logs
|
||||
${whereClause}
|
||||
`;
|
||||
|
||||
const countResult = await query(countSql, params);
|
||||
const total = parseInt(countResult.rows[0].total, 10);
|
||||
|
||||
// Get paginated results
|
||||
const dataSql = `
|
||||
SELECT *
|
||||
FROM audit_logs
|
||||
${whereClause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex}
|
||||
`;
|
||||
|
||||
const dataResult = await query(dataSql, [...params, limit, offset]);
|
||||
|
||||
return {
|
||||
logs: dataResult.rows as AuditLog[],
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit log by ID
|
||||
*/
|
||||
export async function getAuditLogById(id: string): Promise<AuditLog | null> {
|
||||
const sql = `
|
||||
SELECT *
|
||||
FROM audit_logs
|
||||
WHERE id = $1
|
||||
`;
|
||||
|
||||
const result = await query(sql, [id]);
|
||||
return (result.rows[0] as AuditLog) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit logs for a specific record
|
||||
*/
|
||||
export async function getAuditLogsForRecord(
|
||||
tableName: string,
|
||||
recordId: string
|
||||
): Promise<AuditLog[]> {
|
||||
const sql = `
|
||||
SELECT *
|
||||
FROM audit_logs
|
||||
WHERE table_name = $1 AND record_id = $2
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
|
||||
const result = await query(sql, [tableName, recordId]);
|
||||
return result.rows as AuditLog[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit statistics
|
||||
*/
|
||||
export async function getAuditStatistics(days: number = 30): Promise<any[]> {
|
||||
const sql = `
|
||||
SELECT *
|
||||
FROM audit_statistics
|
||||
WHERE date >= CURRENT_DATE - INTERVAL '${days} days'
|
||||
ORDER BY date DESC, action_count DESC
|
||||
`;
|
||||
|
||||
const result = await query(sql);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Extract IP address from request
|
||||
*/
|
||||
export function getIpAddress(req: Request): string | undefined {
|
||||
return (
|
||||
(req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ||
|
||||
(req.headers['x-real-ip'] as string) ||
|
||||
req.socket.remoteAddress ||
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Extract user agent from request
|
||||
*/
|
||||
export function getUserAgent(req: Request): string | undefined {
|
||||
return req.headers['user-agent'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Create audit log from authenticated request
|
||||
*/
|
||||
export async function logAction(
|
||||
req: any, // AuthenticatedRequest
|
||||
action: AuditAction,
|
||||
tableName: string,
|
||||
options: {
|
||||
recordId?: string;
|
||||
oldValues?: Record<string, any>;
|
||||
newValues?: Record<string, any>;
|
||||
description?: string;
|
||||
success?: boolean;
|
||||
errorMessage?: string;
|
||||
} = {}
|
||||
): Promise<string> {
|
||||
const user = req.user;
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found in request for audit log');
|
||||
}
|
||||
|
||||
return createAuditLog({
|
||||
userId: user.userId,
|
||||
userEmail: user.email,
|
||||
userName: user.roleName || user.email,
|
||||
action,
|
||||
tableName,
|
||||
recordId: options.recordId,
|
||||
oldValues: options.oldValues,
|
||||
newValues: options.newValues,
|
||||
description: options.description,
|
||||
ipAddress: getIpAddress(req),
|
||||
userAgent: getUserAgent(req),
|
||||
success: options.success,
|
||||
errorMessage: options.errorMessage,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user