From a9a655ee465bcb343d3d05ab6ccd582e4a5fc5c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Fri, 22 May 2026 17:21:53 +0300 Subject: [PATCH] =?UTF-8?q?feat(audit):=20activity=5Flog=20attribution=20?= =?UTF-8?q?=E2=80=94=20user=5Fid/ip/ua=20for=20all=204=20DealController=20?= =?UTF-8?q?events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- .../Http/Controllers/Api/DealController.php | 16 ++- .../Deals/ActivityLogAttributionTest.php | 111 ++++++++++++++++++ 2 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 app/tests/Feature/Deals/ActivityLogAttributionTest.php diff --git a/app/app/Http/Controllers/Api/DealController.php b/app/app/Http/Controllers/Api/DealController.php index cdd3e915..36f8e6bf 100644 --- a/app/app/Http/Controllers/Api/DealController.php +++ b/app/app/Http/Controllers/Api/DealController.php @@ -398,10 +398,12 @@ class DealController extends Controller $deal->comment = $validated['comment']; ActivityLog::create([ 'tenant_id' => $tenantId, - 'user_id' => null, + 'user_id' => request()->user()?->id, 'deal_id' => $deal->id, 'event' => 'deal.commented', 'context' => ['text' => $validated['comment'] ?? ''], + 'ip_address' => request()->ip(), + 'user_agent' => request()->userAgent(), ]); } @@ -411,10 +413,12 @@ class DealController extends Controller $deal->assigned_at = $validated['manager_id'] !== null ? now() : null; ActivityLog::create([ 'tenant_id' => $tenantId, - 'user_id' => null, + 'user_id' => request()->user()?->id, 'deal_id' => $deal->id, 'event' => ActivityLog::EVENT_DEAL_ASSIGNED, 'context' => ['from' => $previousManager, 'to' => $validated['manager_id']], + 'ip_address' => request()->ip(), + 'user_agent' => request()->userAgent(), ]); } @@ -423,10 +427,12 @@ class DealController extends Controller $deal->status = $validated['status']; ActivityLog::create([ 'tenant_id' => $tenantId, - 'user_id' => null, + 'user_id' => request()->user()?->id, 'deal_id' => $deal->id, 'event' => ActivityLog::EVENT_DEAL_STATUS_CHANGED, 'context' => ['from' => $previousStatus, 'to' => $validated['status'], 'source' => 'manual'], + 'ip_address' => request()->ip(), + 'user_agent' => request()->userAgent(), ]); } @@ -534,10 +540,12 @@ class DealController extends Controller ActivityLog::create([ 'tenant_id' => $tenantId, - 'user_id' => null, // на prod — request()->user()->id + 'user_id' => request()->user()?->id, 'deal_id' => $deal->id, 'event' => ActivityLog::EVENT_DEAL_CREATED, 'context' => ['source' => 'manual'], + 'ip_address' => request()->ip(), + 'user_agent' => request()->userAgent(), ]); return $deal; diff --git a/app/tests/Feature/Deals/ActivityLogAttributionTest.php b/app/tests/Feature/Deals/ActivityLogAttributionTest.php new file mode 100644 index 00000000..c8d494b8 --- /dev/null +++ b/app/tests/Feature/Deals/ActivityLogAttributionTest.php @@ -0,0 +1,111 @@ +tenant = Tenant::factory()->create([ + 'balance_leads' => 100, + ]); + $this->user = User::factory()->for($this->tenant)->create(); + $this->actingAs($this->user); + + DB::statement('SET app.current_tenant_id = '.$this->tenant->id); + $this->project = Project::factory()->for($this->tenant)->create(); +}); + +test('ActivityLog deal.created содержит user_id, ip_address, user_agent актора', function () { + $r = $this->withServerVariables(['REMOTE_ADDR' => '10.1.2.3', 'HTTP_USER_AGENT' => 'TestBrowser/1.0']) + ->postJson('/api/deals', [ + 'project_name' => 'Тест Attribution', + 'phone' => '+7 (999) 000-11-22', + ]); + + $r->assertStatus(201); + + DB::statement('SET app.current_tenant_id = '.$this->tenant->id); + $row = ActivityLog::where('deal_id', $r->json('deal.id')) + ->where('event', ActivityLog::EVENT_DEAL_CREATED) + ->first(); + + expect($row)->not->toBeNull(); + expect($row->user_id)->toBe($this->user->id); + expect($row->ip_address)->toBe('10.1.2.3'); + expect($row->user_agent)->toBe('TestBrowser/1.0'); +}); + +test('ActivityLog deal.commented содержит user_id, ip_address, user_agent актора', function () { + $deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['comment' => 'old']); + + $r = $this->withServerVariables(['REMOTE_ADDR' => '10.1.2.4', 'HTTP_USER_AGENT' => 'TestBrowser/2.0']) + ->patchJson('/api/deals/'.$deal->id, [ + 'comment' => 'Новый комментарий', + ]); + + $r->assertStatus(200); + + DB::statement('SET app.current_tenant_id = '.$this->tenant->id); + $row = ActivityLog::where('deal_id', $deal->id) + ->where('event', 'deal.commented') + ->first(); + + expect($row)->not->toBeNull(); + expect($row->user_id)->toBe($this->user->id); + expect($row->ip_address)->toBe('10.1.2.4'); + expect($row->user_agent)->toBe('TestBrowser/2.0'); +}); + +test('ActivityLog deal.assigned содержит user_id, ip_address, user_agent актора', function () { + $manager = User::factory()->for($this->tenant)->create(['is_active' => true]); + $deal = Deal::factory()->for($this->tenant)->for($this->project)->create([ + 'manager_id' => null, + 'assigned_at' => null, + ]); + + $r = $this->withServerVariables(['REMOTE_ADDR' => '10.1.2.5', 'HTTP_USER_AGENT' => 'TestBrowser/3.0']) + ->patchJson('/api/deals/'.$deal->id, [ + 'manager_id' => $manager->id, + ]); + + $r->assertStatus(200); + + DB::statement('SET app.current_tenant_id = '.$this->tenant->id); + $row = ActivityLog::where('deal_id', $deal->id) + ->where('event', ActivityLog::EVENT_DEAL_ASSIGNED) + ->first(); + + expect($row)->not->toBeNull(); + expect($row->user_id)->toBe($this->user->id); + expect($row->ip_address)->toBe('10.1.2.5'); + expect($row->user_agent)->toBe('TestBrowser/3.0'); +}); + +test('ActivityLog deal.status_changed содержит user_id, ip_address, user_agent актора', function () { + $deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']); + + $r = $this->withServerVariables(['REMOTE_ADDR' => '10.1.2.6', 'HTTP_USER_AGENT' => 'TestBrowser/4.0']) + ->patchJson('/api/deals/'.$deal->id, [ + 'status' => 'won', + ]); + + $r->assertStatus(200); + + DB::statement('SET app.current_tenant_id = '.$this->tenant->id); + $row = ActivityLog::where('deal_id', $deal->id) + ->where('event', ActivityLog::EVENT_DEAL_STATUS_CHANGED) + ->first(); + + expect($row)->not->toBeNull(); + expect($row->user_id)->toBe($this->user->id); + expect($row->ip_address)->toBe('10.1.2.6'); + expect($row->user_agent)->toBe('TestBrowser/4.0'); +});