From ed8a971b6ccd60c429c95f7dfc6a8d93c07f7872 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 18:10:30 +0300 Subject: [PATCH] feat(audit): ProjectService writes tenant_operations_log on create/update/delete/bulk Co-Authored-By: Claude Opus 4.7 --- app/app/Services/Project/ProjectService.php | 65 +++++++++++- .../Projects/ProjectMutationsAuditTest.php | 99 +++++++++++++++++++ 2 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 app/tests/Feature/Projects/ProjectMutationsAuditTest.php diff --git a/app/app/Services/Project/ProjectService.php b/app/app/Services/Project/ProjectService.php index c93d5cfb..97617aff 100644 --- a/app/app/Services/Project/ProjectService.php +++ b/app/app/Services/Project/ProjectService.php @@ -9,12 +9,17 @@ use App\Jobs\SyncSupplierProjectJob; use App\Models\Project; use App\Models\SupplierProject; use App\Models\Tenant; +use App\Services\Audit\OperationsLogger; use App\Services\Supplier\SupplierProjectGrouping; use Illuminate\Http\Exceptions\HttpResponseException; use Illuminate\Support\Facades\DB; class ProjectService { + public function __construct( + private readonly OperationsLogger $ops = new OperationsLogger(), + ) {} + public function update(Project $project, array $data): Project { // Immutable fields — silently drop (don't 422) @@ -63,6 +68,9 @@ class ProjectService || array_key_exists('sms_keyword', $data); $oldAgnostic = $identifierFieldsTouched ? SupplierProjectGrouping::buildUniqueKeyAgnostic($project) : null; + // Snapshot before mutation for audit diff + $before = array_intersect_key($project->toArray(), $data); + $project->update($data); if ($oldAgnostic !== null) { @@ -72,6 +80,18 @@ class ProjectService } } + $this->ops->record( + tenantId: $project->tenant_id, + userId: auth()->id(), + entityType: 'project', + entityId: $project->id, + event: 'project.updated', + payloadBefore: $before, + payloadAfter: $data, + ip: request()->ip(), + userAgent: request()->userAgent(), + ); + if ($needsResync) { SyncSupplierProjectJob::dispatch($project->id); } @@ -142,8 +162,25 @@ class ProjectService ->pluck('supplier_project_id') ->all(); + // Snapshot ДО удаления — после delete() модель недоступна. + $tenantId = $project->tenant_id; + $projectId = $project->id; + $snapshot = $project->toArray(); + $project->delete(); // hard delete (Project без SoftDeletes); cascade чистит pivot + служебные. + $this->ops->record( + tenantId: $tenantId, + userId: auth()->id(), + entityType: 'project', + entityId: $projectId, + event: 'project.deleted', + payloadBefore: $snapshot, + payloadAfter: null, + ip: request()->ip(), + userAgent: request()->userAgent(), + ); + if ($supplierProjectIds !== []) { DeleteSupplierProjectJob::dispatch(array_map('intval', $supplierProjectIds)); } @@ -190,7 +227,7 @@ class ProjectService $query = Project::where('tenant_id', $tenantId)->whereIn('id', $ids); - return match ($action) { + $result = match ($action) { 'pause' => $this->bulkPauseResume($query, false), 'resume' => $this->bulkPauseResume($query, true), 'delete' => $this->bulkDelete($query), @@ -198,6 +235,20 @@ class ProjectService 'update_days' => $this->bulkUpdateDays($query, $payload), 'update_limit' => $this->bulkUpdateLimit($query, $payload), }; + + $this->ops->record( + tenantId: $tenantId, + userId: auth()->id(), + entityType: 'project', + entityId: null, + event: 'project.bulk_' . $action, + payloadBefore: null, + payloadAfter: array_merge(['ids' => $ids], $result), + ip: request()->ip(), + userAgent: request()->userAgent(), + ); + + return $result; } /** @@ -412,6 +463,18 @@ class ProjectService $project = Project::create($data); + $this->ops->record( + tenantId: $project->tenant_id, + userId: auth()->id(), + entityType: 'project', + entityId: $project->id, + event: 'project.created', + payloadBefore: null, + payloadAfter: $project->toArray(), + ip: request()->ip(), + userAgent: request()->userAgent(), + ); + SyncSupplierProjectJob::dispatch($project->id); return $project->fresh(); diff --git a/app/tests/Feature/Projects/ProjectMutationsAuditTest.php b/app/tests/Feature/Projects/ProjectMutationsAuditTest.php new file mode 100644 index 00000000..369dccda --- /dev/null +++ b/app/tests/Feature/Projects/ProjectMutationsAuditTest.php @@ -0,0 +1,99 @@ +tenant = Tenant::factory()->create(); + $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); + + DB::statement('SET app.current_tenant_id = ' . $this->tenant->id); +}); + +it('logs project.created when a project is stored', function () { + $this->actingAs($this->user)->postJson('/api/projects', [ + 'name' => 'Окна СПб', + 'signal_type' => 'site', + 'signal_identifier' => 'okna-spb.ru', + 'daily_limit_target' => 50, + 'regions' => [], + 'delivery_days_mask' => 127, + ])->assertStatus(201); + + $row = DB::table('tenant_operations_log') + ->where('tenant_id', $this->tenant->id) + ->where('event', 'project.created') + ->first(); + + expect($row)->not->toBeNull(); + expect($row->entity_type)->toBe('project'); +}); + +it('logs project.updated with before/after diff when a project is patched', function () { + $project = Project::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'daily_limit_target' => 10, + ]); + + $this->actingAs($this->user)->patchJson("/api/projects/{$project->id}", [ + 'daily_limit_target' => 20, + ])->assertOk(); + + $row = DB::table('tenant_operations_log') + ->where('tenant_id', $this->tenant->id) + ->where('event', 'project.updated') + ->where('entity_id', $project->id) + ->first(); + + expect($row)->not->toBeNull(); + + $before = json_decode($row->payload_before, true); + $after = json_decode($row->payload_after, true); + + expect($before)->toHaveKey('daily_limit_target'); + expect($after)->toHaveKey('daily_limit_target'); + expect((int) $after['daily_limit_target'])->toBe(20); +}); + +it('logs project.deleted when a project is destroyed', function () { + $project = Project::factory()->create(['tenant_id' => $this->tenant->id]); + + $this->actingAs($this->user)->deleteJson("/api/projects/{$project->id}") + ->assertNoContent(); + + $row = DB::table('tenant_operations_log') + ->where('tenant_id', $this->tenant->id) + ->where('event', 'project.deleted') + ->where('entity_id', $project->id) + ->first(); + + expect($row)->not->toBeNull(); + expect($row->entity_type)->toBe('project'); +}); + +it('logs project.bulk_ when a bulk action is executed', function () { + $p1 = Project::factory()->create(['tenant_id' => $this->tenant->id, 'is_active' => true]); + $p2 = Project::factory()->create(['tenant_id' => $this->tenant->id, 'is_active' => true]); + + $this->actingAs($this->user)->postJson('/api/projects/bulk', [ + 'action' => 'pause', + 'ids' => [$p1->id, $p2->id], + ])->assertOk()->assertJsonPath('updated', 2); + + $row = DB::table('tenant_operations_log') + ->where('tenant_id', $this->tenant->id) + ->where('event', 'like', 'project.bulk_%') + ->first(); + + expect($row)->not->toBeNull(); + expect($row->entity_type)->toBe('project'); +});