Skip to content

Commit a24c6a8

Browse files
committed
feat(core): Phase 55 — Audit Log system-wide activity trail
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 5d42784 commit a24c6a8

9 files changed

Lines changed: 447 additions & 2 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
namespace App\Modules\Core\Http\Controllers;
4+
5+
use App\Modules\Core\Models\AuditLog;
6+
use Illuminate\Http\Request;
7+
use Illuminate\Routing\Controller;
8+
use Inertia\Inertia;
9+
use Inertia\Response;
10+
11+
class AuditLogController extends Controller
12+
{
13+
public function index(Request $request): Response
14+
{
15+
// Only super-admin can view the cross-tenant audit log
16+
if (! $request->user()->hasRole('super-admin')) {
17+
abort(403);
18+
}
19+
20+
$query = AuditLog::with('user')
21+
->orderByDesc('created_at');
22+
23+
if ($event = $request->query('event')) {
24+
$query->where('event', $event);
25+
}
26+
27+
if ($userId = $request->query('user_id')) {
28+
$query->where('user_id', $userId);
29+
}
30+
31+
if ($auditableType = $request->query('auditable_type')) {
32+
$query->where('auditable_type', 'like', "%{$auditableType}%");
33+
}
34+
35+
if ($tenantId = $request->query('tenant_id')) {
36+
$query->where('tenant_id', $tenantId);
37+
}
38+
39+
if ($dateFrom = $request->query('date_from')) {
40+
$query->whereDate('created_at', '>=', $dateFrom);
41+
}
42+
43+
if ($dateTo = $request->query('date_to')) {
44+
$query->whereDate('created_at', '<=', $dateTo);
45+
}
46+
47+
$logs = $query->paginate(30)->withQueryString();
48+
49+
$filters = $request->only([
50+
'event',
51+
'user_id',
52+
'auditable_type',
53+
'tenant_id',
54+
'date_from',
55+
'date_to',
56+
]);
57+
58+
return Inertia::render('Core/AuditLogs/Index', [
59+
'logs' => $logs,
60+
'filters' => $filters,
61+
]);
62+
}
63+
}

erp/app/Modules/Core/Models/AuditLog.php

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ class AuditLog extends Model
1010
{
1111
public const UPDATED_AT = null;
1212

13+
public $timestamps = false;
14+
1315
protected $fillable = [
1416
'user_id',
1517
'tenant_id',
@@ -20,11 +22,13 @@ class AuditLog extends Model
2022
'new_values',
2123
'ip_address',
2224
'user_agent',
25+
'created_at',
2326
];
2427

2528
protected $casts = [
26-
'old_values' => 'array',
27-
'new_values' => 'array',
29+
'old_values' => 'array',
30+
'new_values' => 'array',
31+
'created_at' => 'datetime',
2832
];
2933

3034
public function auditable(): MorphTo
@@ -41,4 +45,35 @@ public function tenant(): BelongsTo
4145
{
4246
return $this->belongsTo(Tenant::class);
4347
}
48+
49+
/**
50+
* Record an audit log entry.
51+
*
52+
* @param string $event
53+
* @param Model|null $auditable
54+
* @param array $oldValues
55+
* @param array $newValues
56+
* @param int|null $tenantId
57+
* @return static
58+
*/
59+
public static function record(
60+
string $event,
61+
?Model $auditable = null,
62+
array $oldValues = [],
63+
array $newValues = [],
64+
?int $tenantId = null
65+
): static {
66+
return static::create([
67+
'user_id' => auth()->id(),
68+
'tenant_id' => $tenantId,
69+
'event' => $event,
70+
'auditable_type' => $auditable ? get_class($auditable) : null,
71+
'auditable_id' => $auditable?->getKey(),
72+
'old_values' => $oldValues ?: null,
73+
'new_values' => $newValues ?: null,
74+
'ip_address' => request()->ip(),
75+
'user_agent' => request()->userAgent(),
76+
'created_at' => now(),
77+
]);
78+
}
4479
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
namespace App\Modules\Core\Traits;
4+
5+
use App\Modules\Core\Models\AuditLog;
6+
7+
trait Auditable
8+
{
9+
public static function bootAuditable(): void
10+
{
11+
static::created(function ($model) {
12+
AuditLog::record(
13+
'created',
14+
$model,
15+
[],
16+
$model->getDirty(),
17+
$model->tenant_id ?? null
18+
);
19+
});
20+
21+
static::updated(function ($model) {
22+
$dirty = $model->getDirty();
23+
if (empty($dirty)) {
24+
return;
25+
}
26+
AuditLog::record(
27+
'updated',
28+
$model,
29+
$model->getOriginal(),
30+
$dirty,
31+
$model->tenant_id ?? null
32+
);
33+
});
34+
35+
static::deleted(function ($model) {
36+
AuditLog::record(
37+
'deleted',
38+
$model,
39+
$model->toArray(),
40+
[],
41+
$model->tenant_id ?? null
42+
);
43+
});
44+
}
45+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
public function up(): void
10+
{
11+
// SQLite does not support ALTER COLUMN, so we recreate the table
12+
Schema::drop('audit_logs');
13+
14+
Schema::create('audit_logs', function (Blueprint $table) {
15+
$table->id();
16+
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
17+
$table->unsignedBigInteger('tenant_id')->nullable();
18+
$table->string('event', 64);
19+
$table->string('auditable_type')->nullable();
20+
$table->unsignedBigInteger('auditable_id')->nullable();
21+
$table->json('old_values')->nullable();
22+
$table->json('new_values')->nullable();
23+
$table->ipAddress('ip_address')->nullable();
24+
$table->string('user_agent')->nullable();
25+
$table->timestamp('created_at')->useCurrent();
26+
27+
$table->index(['auditable_type', 'auditable_id']);
28+
$table->index(['user_id', 'created_at']);
29+
$table->index('tenant_id');
30+
});
31+
}
32+
33+
public function down(): void
34+
{
35+
// No rollback needed for test env
36+
}
37+
};

erp/database/seeders/RolePermissionSeeder.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class RolePermissionSeeder extends Seeder
1717
'users.delete' => ['super-admin'],
1818
'roles.manage' => ['super-admin'],
1919
'tenants.manage' => ['super-admin'],
20+
'audit.view' => ['super-admin'],
2021

2122
// Inventory
2223
'inventory.view' => ['super-admin', 'admin', 'manager', 'staff'],
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { Head, Link, useForm } from '@inertiajs/react';
2+
import AppLayout from '@/Layouts/AppLayout';
3+
import type { PageProps } from '@/types';
4+
import type { Paginator } from '@/types/inventory';
5+
import type { AuditLog } from '@/types/audit';
6+
7+
interface Props extends PageProps {
8+
logs: Paginator<AuditLog>;
9+
filters: {
10+
event?: string;
11+
user_id?: string;
12+
auditable_type?: string;
13+
tenant_id?: string;
14+
date_from?: string;
15+
date_to?: string;
16+
};
17+
}
18+
19+
const EVENT_COLORS: Record<string, string> = {
20+
created: 'bg-green-100 text-green-700',
21+
updated: 'bg-blue-100 text-blue-700',
22+
deleted: 'bg-red-100 text-red-700',
23+
login: 'bg-slate-100 text-slate-700',
24+
logout: 'bg-slate-100 text-slate-700',
25+
};
26+
27+
function shortType(type: string | null): string {
28+
if (!type) return '—';
29+
return type.split('\\').pop() ?? type;
30+
}
31+
32+
export default function AuditLogsIndex({ logs, filters }: Props) {
33+
const { data, setData, get } = useForm({
34+
event: filters.event ?? '',
35+
auditable_type: filters.auditable_type ?? '',
36+
date_from: filters.date_from ?? '',
37+
date_to: filters.date_to ?? '',
38+
});
39+
40+
function applyFilter(e: React.FormEvent) {
41+
e.preventDefault();
42+
get('/audit-logs');
43+
}
44+
45+
const hasFilters = Object.values(filters).some(Boolean);
46+
47+
return (
48+
<AppLayout>
49+
<Head title="Audit Log" />
50+
<div className="space-y-6">
51+
<div>
52+
<h1 className="text-2xl font-semibold text-slate-900">Audit Log</h1>
53+
<p className="text-sm text-slate-500 mt-1">System-wide activity trail — {logs.total} entries</p>
54+
</div>
55+
56+
<form onSubmit={applyFilter} className="flex flex-wrap items-end gap-3 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
57+
<div>
58+
<label className="block text-xs font-medium text-slate-500 mb-1">Event</label>
59+
<select value={data.event} onChange={(e) => setData('event', e.target.value)}
60+
className="rounded-md border border-slate-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none">
61+
<option value="">All events</option>
62+
<option value="created">Created</option>
63+
<option value="updated">Updated</option>
64+
<option value="deleted">Deleted</option>
65+
<option value="login">Login</option>
66+
</select>
67+
</div>
68+
<div>
69+
<label className="block text-xs font-medium text-slate-500 mb-1">Model Type</label>
70+
<input type="text" placeholder="e.g. Invoice" value={data.auditable_type}
71+
onChange={(e) => setData('auditable_type', e.target.value)}
72+
className="rounded-md border border-slate-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none w-36" />
73+
</div>
74+
<div>
75+
<label className="block text-xs font-medium text-slate-500 mb-1">From</label>
76+
<input type="date" value={data.date_from} onChange={(e) => setData('date_from', e.target.value)}
77+
className="rounded-md border border-slate-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none" />
78+
</div>
79+
<div>
80+
<label className="block text-xs font-medium text-slate-500 mb-1">To</label>
81+
<input type="date" value={data.date_to} onChange={(e) => setData('date_to', e.target.value)}
82+
className="rounded-md border border-slate-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none" />
83+
</div>
84+
<button type="submit"
85+
className="rounded-md bg-indigo-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-indigo-700">
86+
Filter
87+
</button>
88+
{hasFilters && (
89+
<Link href="/audit-logs" className="text-sm text-slate-500 hover:text-slate-700">Clear</Link>
90+
)}
91+
</form>
92+
93+
<div className="rounded-lg border border-slate-200 bg-white shadow-sm overflow-hidden">
94+
<table className="w-full text-sm">
95+
<thead className="bg-slate-50 text-xs text-slate-500 uppercase border-b border-slate-200">
96+
<tr>
97+
<th className="px-4 py-2 text-left font-medium">Time</th>
98+
<th className="px-4 py-2 text-left font-medium">User</th>
99+
<th className="px-4 py-2 text-left font-medium">Event</th>
100+
<th className="px-4 py-2 text-left font-medium">Model</th>
101+
<th className="px-4 py-2 text-left font-medium">ID</th>
102+
<th className="px-4 py-2 text-left font-medium">Changes</th>
103+
<th className="px-4 py-2 text-left font-medium">IP</th>
104+
</tr>
105+
</thead>
106+
<tbody className="divide-y divide-slate-50">
107+
{logs.data.length === 0 ? (
108+
<tr><td colSpan={7} className="px-4 py-8 text-center text-slate-400">No audit entries found.</td></tr>
109+
) : logs.data.map((entry) => (
110+
<tr key={entry.id} className="hover:bg-slate-50 align-top">
111+
<td className="px-4 py-3 text-slate-500 whitespace-nowrap text-xs">
112+
{new Date(entry.created_at).toLocaleString()}
113+
</td>
114+
<td className="px-4 py-3 font-medium text-slate-800">
115+
{entry.user?.name ?? <span className="text-slate-400">System</span>}
116+
</td>
117+
<td className="px-4 py-3">
118+
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${EVENT_COLORS[entry.event] ?? 'bg-slate-100 text-slate-600'}`}>
119+
{entry.event}
120+
</span>
121+
</td>
122+
<td className="px-4 py-3 text-slate-700">{shortType(entry.auditable_type)}</td>
123+
<td className="px-4 py-3 text-slate-500">
124+
{entry.auditable_id ? `#${entry.auditable_id}` : '—'}
125+
</td>
126+
<td className="px-4 py-3 max-w-xs">
127+
{(entry.new_values && Object.keys(entry.new_values).length > 0) ? (
128+
<details className="text-xs">
129+
<summary className="cursor-pointer text-indigo-600 hover:underline">View changes</summary>
130+
<pre className="mt-1 bg-slate-50 rounded p-2 text-slate-600 overflow-x-auto text-xs max-h-32">
131+
{JSON.stringify({ old: entry.old_values, new: entry.new_values }, null, 2)}
132+
</pre>
133+
</details>
134+
) : '—'}
135+
</td>
136+
<td className="px-4 py-3 text-xs text-slate-400">{entry.ip_address ?? '—'}</td>
137+
</tr>
138+
))}
139+
</tbody>
140+
</table>
141+
</div>
142+
143+
{logs.last_page > 1 && (
144+
<div className="flex justify-center gap-2">
145+
{logs.prev_page_url && (
146+
<Link href={logs.prev_page_url} className="rounded-md border border-slate-300 px-3 py-1.5 text-sm text-slate-600 hover:bg-slate-50">
147+
&larr; Previous
148+
</Link>
149+
)}
150+
<span className="px-3 py-1.5 text-sm text-slate-500">Page {logs.current_page} of {logs.last_page}</span>
151+
{logs.next_page_url && (
152+
<Link href={logs.next_page_url} className="rounded-md border border-slate-300 px-3 py-1.5 text-sm text-slate-600 hover:bg-slate-50">
153+
Next &rarr;
154+
</Link>
155+
)}
156+
</div>
157+
)}
158+
</div>
159+
</AppLayout>
160+
);
161+
}

erp/resources/js/types/audit.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export interface AuditLog {
2+
id: number;
3+
tenant_id: number | null;
4+
user_id: number | null;
5+
event: string;
6+
auditable_type: string | null;
7+
auditable_id: number | null;
8+
old_values: Record<string, unknown> | null;
9+
new_values: Record<string, unknown> | null;
10+
ip_address: string | null;
11+
user_agent: string | null;
12+
created_at: string;
13+
user?: { id: number; name: string } | null;
14+
}

0 commit comments

Comments
 (0)