Skip to content

Commit 9fde9fa

Browse files
committed
feat(phase-30): activity feed api with stats and filtering
- ActivityFeedController: paginated feed of all model changes - Enriches each audit log with model_type (basename), description, changed_fields - Supports filters: ?action=created, ?module=Contact, ?user_id=N, ?from=date, ?to=date - GET /api/v1/activity/stats: total events, recent_7_days, by_action breakdown, most_active_users - buildDescription() produces human-readable summary: "Alice created Contact Jane Doe" - extractChangedFields() returns only the keys that actually changed - 7 feature tests: pagination, enrichment, action filter, module filter, stats, isolation, auth Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 321ecf4 commit 9fde9fa

3 files changed

Lines changed: 231 additions & 0 deletions

File tree

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1;
4+
5+
use App\Modules\Core\Models\AuditLog;
6+
use Illuminate\Http\JsonResponse;
7+
use Illuminate\Http\Request;
8+
use Illuminate\Support\Str;
9+
10+
class ActivityFeedController extends ApiController
11+
{
12+
public function index(Request $request): JsonResponse
13+
{
14+
$tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id;
15+
16+
$query = AuditLog::where('tenant_id', $tenantId)
17+
->with('user:id,name')
18+
->latest('created_at');
19+
20+
// Filter by action/event
21+
if ($action = $request->action) {
22+
$query->where(function ($q) use ($action) {
23+
$q->where('action', $action)->orWhere('event', $action);
24+
});
25+
}
26+
27+
// Filter by module/model type
28+
if ($module = $request->module) {
29+
$query->where('auditable_type', 'like', "%{$module}%");
30+
}
31+
32+
// Filter by user
33+
if ($userId = $request->user_id) {
34+
$query->where('user_id', $userId);
35+
}
36+
37+
// Filter by date range
38+
if ($from = $request->from) {
39+
$query->where('created_at', '>=', $from);
40+
}
41+
if ($to = $request->to) {
42+
$query->where('created_at', '<=', $to);
43+
}
44+
45+
$logs = $query->paginate($request->integer('per_page', 20));
46+
47+
// Enrich each log with human-readable info
48+
$logs->getCollection()->transform(function (AuditLog $log) {
49+
return [
50+
'id' => $log->id,
51+
'action' => $log->action ?? $log->event,
52+
'model_type' => $log->auditable_type ? class_basename($log->auditable_type) : null,
53+
'model_id' => $log->auditable_id,
54+
'model_label' => $log->auditable_label,
55+
'description' => $this->buildDescription($log),
56+
'old_values' => $log->old_values,
57+
'new_values' => $log->new_values,
58+
'changed_fields' => $this->extractChangedFields($log),
59+
'performed_by' => $log->user ? ['id' => $log->user->id, 'name' => $log->user->name] : null,
60+
'ip_address' => $log->ip_address,
61+
'created_at' => $log->created_at,
62+
];
63+
});
64+
65+
return $this->paginated($logs);
66+
}
67+
68+
public function stats(Request $request): JsonResponse
69+
{
70+
$tenantId = app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id;
71+
72+
$actionCounts = AuditLog::where('tenant_id', $tenantId)
73+
->selectRaw('COALESCE(action, event) as act, COUNT(*) as count')
74+
->groupBy('act')
75+
->orderByDesc('count')
76+
->limit(10)
77+
->get()
78+
->map(fn ($r) => ['action' => $r->act, 'count' => $r->count]);
79+
80+
$activeUsers = AuditLog::where('tenant_id', $tenantId)
81+
->whereNotNull('user_id')
82+
->selectRaw('user_id, COUNT(*) as count')
83+
->groupBy('user_id')
84+
->orderByDesc('count')
85+
->with('user:id,name')
86+
->limit(5)
87+
->get()
88+
->map(fn ($r) => [
89+
'user_id' => $r->user_id,
90+
'name' => $r->user?->name ?? 'Unknown',
91+
'count' => $r->count,
92+
]);
93+
94+
$recentActivity = AuditLog::where('tenant_id', $tenantId)
95+
->whereRaw("created_at >= datetime('now', '-7 days')")
96+
->count();
97+
98+
return $this->success([
99+
'total_events' => AuditLog::where('tenant_id', $tenantId)->count(),
100+
'recent_7_days' => $recentActivity,
101+
'by_action' => $actionCounts,
102+
'most_active_users' => $activeUsers,
103+
]);
104+
}
105+
106+
private function buildDescription(AuditLog $log): string
107+
{
108+
$action = $log->action ?? $log->event ?? 'acted on';
109+
$modelType = $log->auditable_type ? class_basename($log->auditable_type) : 'record';
110+
$label = $log->auditable_label ?? "#{$log->auditable_id}";
111+
$user = $log->user?->name ?? 'System';
112+
113+
return "{$user} {$action} {$modelType} {$label}";
114+
}
115+
116+
private function extractChangedFields(AuditLog $log): array
117+
{
118+
if (! $log->old_values || ! $log->new_values) {
119+
return [];
120+
}
121+
122+
return array_keys(array_diff_assoc(
123+
(array) $log->new_values,
124+
(array) $log->old_values
125+
));
126+
}
127+
}

erp/routes/api.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,12 @@
329329
// Audit Logs
330330
Route::get('/audit-logs', [\App\Http\Controllers\Api\V1\AuditLogController::class, 'index']);
331331

332+
// Activity Feed
333+
Route::prefix('activity')->group(function () {
334+
Route::get('/', [\App\Http\Controllers\Api\V1\ActivityFeedController::class, 'index']);
335+
Route::get('/stats', [\App\Http\Controllers\Api\V1\ActivityFeedController::class, 'stats']);
336+
});
337+
332338
// Notifications
333339
Route::prefix('notifications')->group(function () {
334340
Route::get('/', [\App\Http\Controllers\Api\V1\NotificationController::class, 'index']);
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
<?php
2+
3+
use App\Models\User;
4+
use App\Modules\Core\Models\AuditLog;
5+
use App\Modules\Core\Models\Tenant;
6+
use App\Modules\Finance\Models\Contact;
7+
use Database\Seeders\RolePermissionSeeder;
8+
9+
beforeEach(function () {
10+
$this->seed(RolePermissionSeeder::class);
11+
$this->tenant = Tenant::create(['name' => 'Activity Co', 'slug' => 'activity-co-' . uniqid()]);
12+
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
13+
$this->user->assignRole('super-admin');
14+
$this->token = $this->user->createToken('test')->plainTextToken;
15+
app()->instance('tenant', $this->tenant);
16+
});
17+
18+
test('activity feed returns paginated logs with enriched data', function () {
19+
$this->actingAs($this->user);
20+
Contact::create(['tenant_id' => $this->tenant->id, 'name' => 'Jane Doe', 'type' => 'customer']);
21+
22+
$response = $this->withToken($this->token)->getJson('/api/v1/activity');
23+
$response->assertStatus(200);
24+
25+
expect($response->json('data'))->not->toBeEmpty();
26+
$item = $response->json('data.0');
27+
expect($item)->toHaveKeys(['action', 'model_type', 'model_id', 'description', 'created_at']);
28+
});
29+
30+
test('activity feed enriches description with model type', function () {
31+
$this->actingAs($this->user);
32+
Contact::create(['tenant_id' => $this->tenant->id, 'name' => 'Test Contact', 'type' => 'vendor']);
33+
34+
$response = $this->withToken($this->token)->getJson('/api/v1/activity?module=Contact');
35+
$item = $response->json('data.0');
36+
37+
expect($item['description'])->toContain('Contact');
38+
expect($item['description'])->toContain('created');
39+
});
40+
41+
test('activity feed can filter by action', function () {
42+
$this->actingAs($this->user);
43+
$contact = Contact::create(['tenant_id' => $this->tenant->id, 'name' => 'Filterable', 'type' => 'customer']);
44+
$contact->update(['name' => 'Updated Filterable']);
45+
46+
$response = $this->withToken($this->token)->getJson('/api/v1/activity?action=created');
47+
$response->assertStatus(200);
48+
49+
foreach ($response->json('data') as $item) {
50+
expect($item['action'])->toBe('created');
51+
}
52+
});
53+
54+
test('activity feed can filter by module type', function () {
55+
$this->actingAs($this->user);
56+
Contact::create(['tenant_id' => $this->tenant->id, 'name' => 'Module Test', 'type' => 'customer']);
57+
58+
$response = $this->withToken($this->token)->getJson('/api/v1/activity?module=Contact');
59+
$response->assertStatus(200);
60+
61+
foreach ($response->json('data') as $item) {
62+
expect($item['model_type'])->toBe('Contact');
63+
}
64+
});
65+
66+
test('activity stats returns summary data', function () {
67+
$this->actingAs($this->user);
68+
Contact::create(['tenant_id' => $this->tenant->id, 'name' => 'Stats Test', 'type' => 'customer']);
69+
70+
$response = $this->withToken($this->token)->getJson('/api/v1/activity/stats');
71+
$response->assertStatus(200);
72+
73+
$data = $response->json('data');
74+
expect($data)->toHaveKeys(['total_events', 'recent_7_days', 'by_action', 'most_active_users']);
75+
expect($data['total_events'])->toBeGreaterThan(0);
76+
});
77+
78+
test('activity feed does not expose other tenant data', function () {
79+
$otherTenant = Tenant::create(['name' => 'Other Co', 'slug' => 'other-co-' . uniqid()]);
80+
$otherUser = User::factory()->create(['tenant_id' => $otherTenant->id]);
81+
82+
app()->instance('tenant', $otherTenant);
83+
$this->actingAs($otherUser);
84+
Contact::create(['tenant_id' => $otherTenant->id, 'name' => 'Other Contact', 'type' => 'customer']);
85+
86+
app()->instance('tenant', $this->tenant);
87+
$response = $this->withToken($this->token)->getJson('/api/v1/activity');
88+
$response->assertStatus(200);
89+
90+
foreach ($response->json('data') as $item) {
91+
expect($item['model_type'])->not->toBeNull();
92+
}
93+
});
94+
95+
test('activity feed requires authentication', function () {
96+
$this->getJson('/api/v1/activity')->assertStatus(401);
97+
$this->getJson('/api/v1/activity/stats')->assertStatus(401);
98+
});

0 commit comments

Comments
 (0)