From e306f3f17318e9530c4e11d4bf099ee617fb56f7 Mon Sep 17 00:00:00 2001 From: Mehmet Faruk Demirkoparan Date: Mon, 2 Mar 2026 12:41:44 +0300 Subject: [PATCH] feat: enhance history system with polymorphic causer, runtime logging control, restored event support, query scopes, model relationship, and dontLogIfAttributesChangedOnly - Add causer_type column for polymorphic causer support (morphTo relationship) - Add LogiAuditStatus class for global enable/disable logging control - Add per-instance disableLogging()/enableLogging() and static withoutLogging(callback) - Add restored event tracking for SoftDeletes models - Add auditHistory() morphMany relationship on models using LogiAuditTrait - Add query scopes: forModel(), byUser(), forEvent(), forTable() on LogiAuditHistory - Add dontLogIfAttributesChangedOnly to skip history when only insignificant columns change - Add migration for causer_type column with index - Add comprehensive tests for all new features (8 new test cases) --- README.md | 48 ++-- composer.json | 5 +- ..._add_logged_at_to_logiaudit_logs_table.php | 32 +++ ...ss_nullable_in_logiaudit_history_table.php | 28 +++ ..._to_expires_at_in_logiaudit_logs_table.php | 32 +++ ...olumn_types_in_logiaudit_history_table.php | 30 +++ ...000005_add_indexes_to_logiaudit_tables.php | 36 +++ ...000006_add_tag_to_logiaudit_logs_table.php | 32 +++ ...causer_type_to_logiaudit_history_table.php | 26 ++ docker-compose.yml | 2 +- phpstan.neon.dist | 5 + src/Commands/PruneLogsCommand.php | 6 +- src/Events/HistoryEventObserver.php | 148 +++++++++--- src/Helpers/AddLogHelpers.php | 21 +- src/Jobs/PruneHistoryJob.php | 12 +- src/Jobs/PruneLogJob.php | 19 +- src/Jobs/StoreHistoryJob.php | 36 +-- src/Jobs/StoreLogJob.php | 43 ++-- src/Logging/LogiAuditHandler.php | 78 +++--- src/LogiAudit.php | 5 - src/LogiAuditServiceProvider.php | 17 -- src/LogiAuditStatus.php | 34 +++ src/Models/LogiAuditHistory.php | 60 ++++- src/Models/LogiAuditLog.php | 4 +- src/Traits/LogiAuditTrait.php | 37 +++ tests/AddLogHelperTest.php | 49 ++++ tests/Events/HistoryEventObserver.php | 147 +++++++++--- tests/Helpers/AddLogTHelpers.php | 22 +- tests/Jobs/StoreHistoryJob.php | 5 + tests/Jobs/StoreLogJob.php | 16 +- tests/LogChannelTest.php | 26 ++ tests/Logging/LogiAuditHandler.php | 79 +++--- tests/LogiAuditHistoryTest.php | 227 +++++++++++++++++- tests/Models/LogiAuditLog.php | 4 +- tests/Models/TestModelDontLogIfOnly.php | 23 ++ tests/Models/TestModelExcluded.php | 21 ++ tests/Models/TestModelSoftDelete.php | 22 ++ tests/PruneLogsCommandTest.php | 6 +- tests/Traits/LogiAuditTrait.php | 37 +++ 39 files changed, 1243 insertions(+), 237 deletions(-) create mode 100644 database/migrations/2025_06_01_000001_add_logged_at_to_logiaudit_logs_table.php create mode 100644 database/migrations/2025_06_01_000002_make_ip_address_nullable_in_logiaudit_history_table.php create mode 100644 database/migrations/2025_06_01_000003_rename_deleted_at_to_expires_at_in_logiaudit_logs_table.php create mode 100644 database/migrations/2025_06_01_000004_fix_column_types_in_logiaudit_history_table.php create mode 100644 database/migrations/2025_06_01_000005_add_indexes_to_logiaudit_tables.php create mode 100644 database/migrations/2025_06_01_000006_add_tag_to_logiaudit_logs_table.php create mode 100644 database/migrations/2025_06_01_000007_add_causer_type_to_logiaudit_history_table.php delete mode 100755 src/LogiAudit.php create mode 100644 src/LogiAuditStatus.php create mode 100644 tests/Models/TestModelDontLogIfOnly.php create mode 100644 tests/Models/TestModelExcluded.php create mode 100644 tests/Models/TestModelSoftDelete.php diff --git a/README.md b/README.md index b250a66..bfaf1ad 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,18 @@ # LogiAudit -LogiAudit is a Laravel package designed for structured logging with support for job-based log storage, pruning, and -customizable log levels. +LogiAudit is a Laravel package designed for structured logging and audit trail with support for job-based log storage, +pruning, and customizable log levels. ## Features - **Queue-Based Logging**: Logs are stored asynchronously using Laravel jobs. -- **Contextual Logging**: Supports logging with model associations, trace IDs, and additional metadata. +- **Contextual Logging**: Supports logging with model associations, trace IDs, tags, and additional metadata. - **Automatic Pruning**: Logs marked as deletable can be automatically removed. - **Monolog Integration**: Works seamlessly with Laravel's logging system. - **IP Tracking**: Logs IP addresses for traceability. - **Configurable Cleanup**: Define log retention periods. +- **Tag Filtering**: Tag your logs for easy filtering and categorization. +- **Audit Trail**: Automatically track model changes (create, update, delete) with old/new values. ## Installation @@ -22,7 +24,7 @@ composer require aurorawebsoftware/logiaudit ### Running Migrations -After installation, run the migration command to create the necessary database table: +After installation, run the migration command to create the necessary database tables: ```bash php artisan migrate @@ -39,6 +41,7 @@ addLog('info', 'User logged in', [ 'model_id' => $user->id, 'model_type' => get_class($user), 'trace_id' => Str::uuid(), + 'tag' => 'auth', 'context' => ['role' => 'admin'], 'ip_address' => request()->ip(), 'deletable' => true, @@ -56,6 +59,7 @@ The `addLog` function allows for flexible logging with optional parameters: - `model_id` (int, nullable): The ID of the related model (if applicable). - `model_type` (string, nullable): The model's class name. - `trace_id` (string, nullable): A unique identifier for tracing logs across multiple services. + - `tag` (string, nullable): A tag for filtering and categorizing logs (e.g., `payment`, `auth`, `api`). - `context` (array, nullable): Any extra contextual data. - `ip_address` (string, nullable): The IP address of the request. - `deletable` (bool, default: `true`): Determines if the log can be pruned. @@ -73,6 +77,7 @@ Log::channel('logiaudit')->info('Custom log message', [ 'model_id' => 1, 'model_type' => 'User', 'trace_id' => Str::uuid(), + 'tag' => 'payment', 'context' => ['key' => 'value'], 'ip_address' => request()->ip(), ]); @@ -97,22 +102,26 @@ To remove logs marked as `deletable`, run the following command: php artisan logs:prune ``` -Alternatively, you can schedule this command in your `app/Console/Kernel.php`: +Alternatively, you can schedule this command in your `bootstrap/app.php` (Laravel 11+): ```php -protected function schedule(Schedule $schedule) -{ +->withSchedule(function (Schedule $schedule) { $schedule->command('logs:prune')->daily(); -} +}) ``` # History Usage -History Log is simple to use. When you call **HistoryableTrait** into your model classes whose history you want to +History Log is simple to use. When you add **LogiAuditTrait** to your model classes whose history you want to monitor, History Log will start to keep history for your model. ```php -use LogiAuditTrait; +use AuroraWebSoftware\LogiAudit\Traits\LogiAuditTrait; + +class Order extends Model +{ + use LogiAuditTrait; +} ``` If you want to exclude some columns from this, add this variable to your model class globally and write the column names @@ -122,16 +131,19 @@ as an array. protected $excludedColumns = ['deleted_at', 'id']; ``` -If you don't want to keep history in some model events, add the following variable. Currently, this version only keeps -the history of create, update and delete events. +If you don't want to keep history in some model events, add the following variable. Both short (`create`) and full +(`created`) forms are accepted. Currently, this version only keeps the history of create, update and delete events. ```php protected $excludedEvents = ['delete', 'create']; ``` -| Id | action | table | model | model_id | column | old_value | new_value | user_id | ip_address | -|----|---------|--------|------------------|----------|--------------------------------------------|------------------------------------------------------------|-------------------------------------------------------------|---------|------------| -| 1 | created | orders | App\Models\Order | 5 | [["order_code"],["price"],["total_price"]] | [{"order_code":"ABC"},{"price":"20"},{"total_price":"20"}] | [{"order_code":"ABCD"},{"price":"30"},{"total_price":"60"}] | 2 | 177.77.0.1 | +### Example History Record + +| Id | action | table | model | model_id | column | old_value | new_value | user_id | ip_address | +|----|---------|--------|------------------|----------|--------------------------------------------|-----------|------------------------------------------------------------|---------|------------| +| 1 | created | orders | App\Models\Order | 5 | ["order_code","price","total_price"] | null | [{"order_code":"ABCD"},{"price":"30"},{"total_price":"60"}] | 2 | 177.77.0.1 | +| 2 | updated | orders | App\Models\Order | 5 | ["price"] | [{"price":"30"}] | [{"price":"50"}] | 2 | 177.77.0.1 | ## Running the History Pruning Command @@ -164,8 +176,8 @@ return [ ]; ``` If there is no config you can publish -```php -php artisan vendor:publish --tag=logiaudit-config +```bash +php artisan vendor:publish --tag=config ``` ## Environment File Setup @@ -191,5 +203,3 @@ You can keep the workers running in the background using **supervisor** or **sys ## License The LogiAudit package is open-sourced software licensed under the MIT License. - - diff --git a/composer.json b/composer.json index 9976208..f598bef 100644 --- a/composer.json +++ b/composer.json @@ -69,10 +69,7 @@ "laravel": { "providers": [ "AuroraWebSoftware\\LogiAudit\\LogiAuditServiceProvider" - ], - "aliases": { - "LogiAudit": "AuroraWebSoftware\\LogiAudit\\Facades\\LogiAudit" - } + ] } }, "minimum-stability": "dev", diff --git a/database/migrations/2025_06_01_000001_add_logged_at_to_logiaudit_logs_table.php b/database/migrations/2025_06_01_000001_add_logged_at_to_logiaudit_logs_table.php new file mode 100644 index 0000000..b5b1200 --- /dev/null +++ b/database/migrations/2025_06_01_000001_add_logged_at_to_logiaudit_logs_table.php @@ -0,0 +1,32 @@ +timestamp('logged_at')->nullable()->after('deletable'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasColumn('logiaudit_logs', 'logged_at')) { + Schema::table('logiaudit_logs', function (Blueprint $table) { + $table->dropColumn('logged_at'); + }); + } + } +}; diff --git a/database/migrations/2025_06_01_000002_make_ip_address_nullable_in_logiaudit_history_table.php b/database/migrations/2025_06_01_000002_make_ip_address_nullable_in_logiaudit_history_table.php new file mode 100644 index 0000000..135d00e --- /dev/null +++ b/database/migrations/2025_06_01_000002_make_ip_address_nullable_in_logiaudit_history_table.php @@ -0,0 +1,28 @@ +string('ip_address')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('logiaudit_history', function (Blueprint $table) { + $table->string('ip_address')->nullable(false)->change(); + }); + } +}; diff --git a/database/migrations/2025_06_01_000003_rename_deleted_at_to_expires_at_in_logiaudit_logs_table.php b/database/migrations/2025_06_01_000003_rename_deleted_at_to_expires_at_in_logiaudit_logs_table.php new file mode 100644 index 0000000..251b362 --- /dev/null +++ b/database/migrations/2025_06_01_000003_rename_deleted_at_to_expires_at_in_logiaudit_logs_table.php @@ -0,0 +1,32 @@ +renameColumn('deleted_at', 'expires_at'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('logiaudit_logs', function (Blueprint $table) { + if (Schema::hasColumn('logiaudit_logs', 'expires_at') && ! Schema::hasColumn('logiaudit_logs', 'deleted_at')) { + $table->renameColumn('expires_at', 'deleted_at'); + } + }); + } +}; diff --git a/database/migrations/2025_06_01_000004_fix_column_types_in_logiaudit_history_table.php b/database/migrations/2025_06_01_000004_fix_column_types_in_logiaudit_history_table.php new file mode 100644 index 0000000..11622fd --- /dev/null +++ b/database/migrations/2025_06_01_000004_fix_column_types_in_logiaudit_history_table.php @@ -0,0 +1,30 @@ +unsignedBigInteger('model_id')->nullable(false)->change(); + $table->unsignedBigInteger('user_id')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('logiaudit_history', function (Blueprint $table) { + $table->integer('model_id')->nullable(false)->change(); + $table->integer('user_id')->nullable()->change(); + }); + } +}; diff --git a/database/migrations/2025_06_01_000005_add_indexes_to_logiaudit_tables.php b/database/migrations/2025_06_01_000005_add_indexes_to_logiaudit_tables.php new file mode 100644 index 0000000..34d7c80 --- /dev/null +++ b/database/migrations/2025_06_01_000005_add_indexes_to_logiaudit_tables.php @@ -0,0 +1,36 @@ +index(['deletable', 'expires_at'], 'logiaudit_logs_prune_index'); + }); + + Schema::table('logiaudit_history', function (Blueprint $table) { + $table->index('created_at', 'logiaudit_history_created_at_index'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('logiaudit_logs', function (Blueprint $table) { + $table->dropIndex('logiaudit_logs_prune_index'); + }); + + Schema::table('logiaudit_history', function (Blueprint $table) { + $table->dropIndex('logiaudit_history_created_at_index'); + }); + } +}; diff --git a/database/migrations/2025_06_01_000006_add_tag_to_logiaudit_logs_table.php b/database/migrations/2025_06_01_000006_add_tag_to_logiaudit_logs_table.php new file mode 100644 index 0000000..6027434 --- /dev/null +++ b/database/migrations/2025_06_01_000006_add_tag_to_logiaudit_logs_table.php @@ -0,0 +1,32 @@ +string('tag')->nullable()->index()->after('level'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasColumn('logiaudit_logs', 'tag')) { + Schema::table('logiaudit_logs', function (Blueprint $table) { + $table->dropColumn('tag'); + }); + } + } +}; diff --git a/database/migrations/2025_06_01_000007_add_causer_type_to_logiaudit_history_table.php b/database/migrations/2025_06_01_000007_add_causer_type_to_logiaudit_history_table.php new file mode 100644 index 0000000..2959527 --- /dev/null +++ b/database/migrations/2025_06_01_000007_add_causer_type_to_logiaudit_history_table.php @@ -0,0 +1,26 @@ +string('causer_type')->nullable()->after('user_id')->index(); + } + }); + } + + public function down(): void + { + Schema::table('logiaudit_history', function (Blueprint $table) { + if (Schema::hasColumn('logiaudit_history', 'causer_type')) { + $table->dropColumn('causer_type'); + } + }); + } +}; diff --git a/docker-compose.yml b/docker-compose.yml index 9b8cd33..071548e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,4 +16,4 @@ networks: driver: bridge ipam: config: - - subnet: 172.40.10.0/24 + - subnet: 172.49.10.0/24 diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 5891856..ce48ac7 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -15,3 +15,8 @@ parameters: ignoreErrors: - '#Trait .*?LogiAuditTrait.*? is used zero times and is not analysed#' - '#Call to an undefined method Illuminate\\Database\\Eloquent\\Model::getExcludedEvents\(\).#' + - '#Call to an undefined method Illuminate\\Database\\Eloquent\\Model::getExcludedColumns\(\).#' + - '#Call to an undefined method Illuminate\\Database\\Eloquent\\Model::getDontLogIfAttributesChangedOnly\(\).#' + - + identifier: larastan.noEnvCallsOutsideOfConfig + path: config/* diff --git a/src/Commands/PruneLogsCommand.php b/src/Commands/PruneLogsCommand.php index 958b5c3..cf919bb 100644 --- a/src/Commands/PruneLogsCommand.php +++ b/src/Commands/PruneLogsCommand.php @@ -11,7 +11,7 @@ class PruneLogsCommand extends Command { protected $signature = 'logs:prune'; - protected $description = 'Delete all logs with deletable=true and deleted_at in the past.'; + protected $description = 'Delete all logs with deletable=true and expires_at in the past.'; public function handle() { @@ -22,8 +22,8 @@ public function handle() $query = DB::table('logiaudit_logs') ->where('deletable', true) - ->whereNotNull('deleted_at') - ->where('deleted_at', '<=', $now); + ->whereNotNull('expires_at') + ->where('expires_at', '<=', $now); do { $deleted = $query->take($batchSize)->delete(); diff --git a/src/Events/HistoryEventObserver.php b/src/Events/HistoryEventObserver.php index e4a4a95..f039aea 100644 --- a/src/Events/HistoryEventObserver.php +++ b/src/Events/HistoryEventObserver.php @@ -3,6 +3,7 @@ namespace AuroraWebSoftware\LogiAudit\Events; use AuroraWebSoftware\LogiAudit\Jobs\StoreHistoryJob; +use AuroraWebSoftware\LogiAudit\LogiAuditStatus; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; @@ -11,6 +12,7 @@ /** * @method array getExcludedEvents() * @method array getExcludedColumns() + * @method array getDontLogIfAttributesChangedOnly() */ class HistoryEventObserver { @@ -19,8 +21,12 @@ class HistoryEventObserver */ public function created(Model $model) { - if (! in_array('create', $model->getExcludedEvents())) { - $this->saveHistory('created', $model); + try { + if ($this->shouldLog($model) && ! $this->isEventExcluded('created', $model)) { + $this->saveHistory('created', $model); + } + } catch (\Throwable $e) { + $this->logFailure($e); } return true; @@ -31,10 +37,14 @@ public function created(Model $model) */ public function updated(Model $model) { - if (! in_array('update', $model->getExcludedEvents())) { - if ($model->getChanges()) { - $this->saveHistory('updated', $model); + try { + if ($this->shouldLog($model) && ! $this->isEventExcluded('updated', $model)) { + if ($model->getChanges() && ! $this->shouldSkipDueToOnlyIgnoredChanges($model)) { + $this->saveHistory('updated', $model); + } } + } catch (\Throwable $e) { + $this->logFailure($e); } return true; @@ -45,50 +55,128 @@ public function updated(Model $model) */ public function deleted(Model $model) { - if (! in_array('delete', $model->getExcludedEvents())) { - $this->saveHistory('deleted', $model); + try { + if ($this->shouldLog($model) && ! $this->isEventExcluded('deleted', $model)) { + $this->saveHistory('deleted', $model); + } + } catch (\Throwable $e) { + $this->logFailure($e); } } /** * @return void */ - private function saveHistory($event, $model) + public function restored(Model $model) { try { - $dirty = $model->getDirty(); + if ($this->shouldLog($model) && ! $this->isEventExcluded('restored', $model)) { + $this->saveHistory('restored', $model); + } + } catch (\Throwable $e) { + $this->logFailure($e); + } + } + + private function shouldLog(Model $model): bool + { + if (! LogiAuditStatus::isEnabled()) { + return false; + } - $oldValues = []; - $newValues = []; - $columns = []; + if (property_exists($model, 'loggingDisabled') && $model->loggingDisabled) { + return false; + } + + return true; + } - $attributes = $model->getExcludedColumns(); + private function shouldSkipDueToOnlyIgnoredChanges(Model $model): bool + { + $ignoredColumns = $model->getDontLogIfAttributesChangedOnly(); + + if (empty($ignoredColumns)) { + return false; + } - foreach ($dirty as $column => $value) { - if (! in_array($column, $attributes)) { + $changedColumns = array_keys($model->getChanges()); + + foreach ($changedColumns as $column) { + if (! in_array($column, $ignoredColumns)) { + return false; + } + } + + return true; + } + + private function isEventExcluded(string $event, Model $model): bool + { + $excluded = $model->getExcludedEvents(); + $short = rtrim($event, 'd'); // created -> create, updated -> update, deleted -> delete, restored -> restore + + return in_array($event, $excluded) || in_array($short, $excluded); + } + + /** + * @return void + */ + private function saveHistory($event, $model) + { + $excludedColumns = $model->getExcludedColumns(); + $oldValues = []; + $newValues = []; + $columns = []; + + if ($event === 'created') { + foreach ($model->getAttributes() as $column => $value) { + if (! in_array($column, $excludedColumns)) { + $newValues[] = [$column => $value]; + $columns[] = $column; + } + } + } elseif ($event === 'updated') { + foreach ($model->getChanges() as $column => $value) { + if (! in_array($column, $excludedColumns)) { $oldValues[] = [$column => $model->getOriginal($column)]; $newValues[] = [$column => $value]; $columns[] = $column; } } + } - if ((empty($columns) && $event !== 'deleted')) { - return; - } + if (empty($columns) && ! in_array($event, ['deleted', 'restored'])) { + return; + } + + $causerType = null; + $userId = null; - StoreHistoryJob::dispatch( - $event, - $model->getTable(), - $model->getMorphClass(), - $model->getKey(), - $event !== 'deleted' ? $columns : null, - $event === 'updated' ? $oldValues : null, - $event !== 'deleted' ? $newValues : null, - Auth::check() ? Auth::user()->id : null, - Request::ip(), - ); - } catch (\Exception $e) { + if (Auth::check()) { + $user = Auth::user(); + $userId = $user->id; + $causerType = get_class($user); + } + + StoreHistoryJob::dispatch( + $event, + $model->getTable(), + $model->getMorphClass(), + $model->getKey(), + $event !== 'deleted' && $event !== 'restored' ? $columns : null, + $event === 'updated' ? $oldValues : null, + $event !== 'deleted' && $event !== 'restored' ? $newValues : null, + $userId, + Request::ip(), + $causerType, + ); + } + + private function logFailure(\Throwable $e): void + { + try { Log::error('Audit history job dispatch failed: '.$e->getMessage()); + } catch (\Throwable) { } } } diff --git a/src/Helpers/AddLogHelpers.php b/src/Helpers/AddLogHelpers.php index f998b3f..4f588d5 100644 --- a/src/Helpers/AddLogHelpers.php +++ b/src/Helpers/AddLogHelpers.php @@ -1,6 +1,7 @@ getMessage()); + } catch (\Throwable) { + } } } } diff --git a/src/Jobs/PruneHistoryJob.php b/src/Jobs/PruneHistoryJob.php index 3b8acfa..b8f650c 100644 --- a/src/Jobs/PruneHistoryJob.php +++ b/src/Jobs/PruneHistoryJob.php @@ -25,9 +25,17 @@ public function __construct(int $days) public function handle(): void { $cutoff = Carbon::now()->subDays($this->days); + $batchSize = 500; + $totalDeleted = 0; - $deletedCount = LogiAuditHistory::where('created_at', '<=', $cutoff)->delete(); + do { + $deleted = LogiAuditHistory::where('created_at', '<=', $cutoff) + ->limit($batchSize) + ->delete(); - Log::info("PruneHistoryJob executed. Deleted {$deletedCount} history records older than {$this->days} days."); + $totalDeleted += $deleted; + } while ($deleted > 0); + + Log::info("PruneHistoryJob executed. Deleted {$totalDeleted} history records older than {$this->days} days."); } } diff --git a/src/Jobs/PruneLogJob.php b/src/Jobs/PruneLogJob.php index 5813850..53edd38 100644 --- a/src/Jobs/PruneLogJob.php +++ b/src/Jobs/PruneLogJob.php @@ -21,13 +21,20 @@ class PruneLogJob implements ShouldQueue public function handle(): void { $now = Carbon::now(); + $batchSize = 500; + $totalDeleted = 0; - $deletedCount = LogiAuditLog::query() - ->where('deletable', true) - ->whereNotNull('deleted_at') - ->where('deleted_at', '<=', $now) - ->delete(); + do { + $deleted = LogiAuditLog::query() + ->where('deletable', true) + ->whereNotNull('expires_at') + ->where('expires_at', '<=', $now) + ->limit($batchSize) + ->delete(); - Log::info("PruneLogJob executed. Deleted {$deletedCount} log records."); + $totalDeleted += $deleted; + } while ($deleted > 0); + + Log::info("PruneLogJob executed. Deleted {$totalDeleted} log records."); } } diff --git a/src/Jobs/StoreHistoryJob.php b/src/Jobs/StoreHistoryJob.php index 118b420..511debb 100644 --- a/src/Jobs/StoreHistoryJob.php +++ b/src/Jobs/StoreHistoryJob.php @@ -37,6 +37,8 @@ class StoreHistoryJob implements ShouldQueue public ?string $ipAddress; + public ?string $causerType = null; + public function __construct( string $action, string $table, @@ -47,6 +49,7 @@ public function __construct( ?array $newValues = null, ?int $userId = null, ?string $ipAddress = null, + ?string $causerType = null, ) { $this->action = $action; $this->table = $table; @@ -57,6 +60,7 @@ public function __construct( $this->newValues = $newValues; $this->userId = $userId; $this->ipAddress = $ipAddress; + $this->causerType = $causerType; $this->onQueue(config('logiaudit.history_queue_name')); } @@ -73,23 +77,27 @@ public function handle(): void 'old_value' => ! empty($this->oldValues) ? $this->oldValues : null, 'new_value' => ! empty($this->newValues) ? $this->newValues : null, 'user_id' => $this->userId, + 'causer_type' => $this->causerType, 'ip_address' => $this->ipAddress, ]); } catch (Throwable $e) { - LogiAuditLog::create([ - 'level' => 'error', - 'message' => 'StoreHistoryJob handle() exception: '.$e->getMessage(), - 'trace_id' => null, - 'context' => [ - 'job' => static::class, - 'action' => $this->action, - 'model' => $this->model, - 'model_id' => $this->modelId, - 'exception_class' => get_class($e), - 'code' => $e->getCode(), - ], - 'deletable' => false, - ]); + try { + LogiAuditLog::create([ + 'level' => 'error', + 'message' => 'StoreHistoryJob handle() exception: '.$e->getMessage(), + 'trace_id' => null, + 'context' => [ + 'job' => static::class, + 'action' => $this->action, + 'model' => $this->model, + 'model_id' => $this->modelId, + 'exception_class' => get_class($e), + 'code' => $e->getCode(), + ], + 'deletable' => false, + ]); + } catch (Throwable) { + } throw $e; } diff --git a/src/Jobs/StoreLogJob.php b/src/Jobs/StoreLogJob.php index a442dd7..7f33eb5 100644 --- a/src/Jobs/StoreLogJob.php +++ b/src/Jobs/StoreLogJob.php @@ -3,12 +3,12 @@ namespace AuroraWebSoftware\LogiAudit\Jobs; use AuroraWebSoftware\LogiAudit\Models\LogiAuditLog; +use Carbon\Carbon; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Illuminate\Support\Carbon; use Throwable; class StoreLogJob implements ShouldQueue @@ -37,6 +37,10 @@ class StoreLogJob implements ShouldQueue public ?int $deleteAfterDays; + public ?Carbon $loggedAt = null; + + public ?string $tag = null; + /** * Create a new job instance. */ @@ -49,7 +53,9 @@ public function __construct( ?array $context = null, ?string $ipAddress = null, bool $deletable = true, - ?int $deleteAfterDays = null + ?int $deleteAfterDays = null, + ?Carbon $loggedAt = null, + ?string $tag = null ) { $this->level = $level; $this->message = $message; @@ -60,6 +66,8 @@ public function __construct( $this->ipAddress = $ipAddress; $this->deletable = $deletable; $this->deleteAfterDays = $deleteAfterDays; + $this->loggedAt = $loggedAt; + $this->tag = $tag; $this->onQueue(config('logiaudit.log_queue_name')); } @@ -72,6 +80,7 @@ public function handle(): void try { LogiAuditLog::create([ 'level' => $this->level, + 'tag' => $this->tag, 'message' => $this->message, 'model_id' => $this->modelId, 'model_type' => $this->modelType, @@ -79,23 +88,27 @@ public function handle(): void 'context' => $this->context, 'ip_address' => $this->ipAddress, 'deletable' => $this->deletable, - 'deleted_at' => ($this->deletable && $this->deleteAfterDays) + 'expires_at' => ($this->deletable && $this->deleteAfterDays) ? Carbon::now()->addDays($this->deleteAfterDays) : null, + 'logged_at' => $this->loggedAt, ]); } catch (Throwable $e) { - LogiAuditLog::create([ - 'level' => 'error', - 'message' => 'StoreLogJob handle() exception: '.$e->getMessage(), - 'trace_id' => $this->traceId, - 'context' => [ - 'job' => static::class, - 'code' => $e->getCode(), - 'class' => get_class($e), - ], - 'ip_address' => $this->ipAddress, - 'deletable' => false, - ]); + try { + LogiAuditLog::create([ + 'level' => 'error', + 'message' => 'StoreLogJob handle() exception: '.$e->getMessage(), + 'trace_id' => $this->traceId, + 'context' => [ + 'job' => static::class, + 'code' => $e->getCode(), + 'class' => get_class($e), + ], + 'ip_address' => $this->ipAddress, + 'deletable' => false, + ]); + } catch (Throwable) { + } throw $e; } diff --git a/src/Logging/LogiAuditHandler.php b/src/Logging/LogiAuditHandler.php index 2d7bece..f09d264 100644 --- a/src/Logging/LogiAuditHandler.php +++ b/src/Logging/LogiAuditHandler.php @@ -3,6 +3,7 @@ namespace AuroraWebSoftware\LogiAudit\Logging; use AuroraWebSoftware\LogiAudit\Jobs\StoreLogJob; +use Carbon\Carbon; use Monolog\Handler\AbstractProcessingHandler; use Monolog\Logger; use Monolog\LogRecord; @@ -17,42 +18,51 @@ public function __invoke(array $config) { protected function write(LogRecord $record): void { - $context = $record->context; - - $modelId = $context['model_id'] ?? null; - $modelType = $context['model_type'] ?? null; - $traceId = $context['trace_id'] ?? null; - $ipAddress = $context['ip_address'] ?? null; - $deletable = $context['deletable'] ?? true; - $deleteAfterDays = $context['delete_after_days'] ?? null; - unset( - $context['model_id'], - $context['model_type'], - $context['trace_id'], - $context['ip_address'], - $context['deletable'], - $context['delete_after_days'] - ); - - if (isset($context['context']) && is_array($context['context'])) { - $context = $context['context']; - } + try { + $context = $record->context; - if (empty($context)) { - $context = null; - } + $modelId = $context['model_id'] ?? null; + $modelType = $context['model_type'] ?? null; + $traceId = $context['trace_id'] ?? null; + $ipAddress = $context['ip_address'] ?? null; + $deletable = $context['deletable'] ?? true; + $deleteAfterDays = $context['delete_after_days'] ?? null; + $tag = $context['tag'] ?? null; + unset( + $context['model_id'], + $context['model_type'], + $context['trace_id'], + $context['ip_address'], + $context['deletable'], + $context['delete_after_days'], + $context['tag'] + ); + + if (isset($context['context']) && is_array($context['context'])) { + $nested = $context['context']; + unset($context['context']); + $context = array_merge($nested, $context); + } - dispatch(new StoreLogJob( - strtolower($record->level->name), - $record->message, - $modelId, - $modelType, - $traceId, - $context, - $ipAddress, - $deletable, - $deleteAfterDays - )); + if (empty($context)) { + $context = null; + } + + dispatch(new StoreLogJob( + strtolower($record->level->name), + $record->message, + $modelId, + $modelType, + $traceId, + $context, + $ipAddress, + $deletable, + $deleteAfterDays, + Carbon::now(), + $tag + )); + } catch (\Throwable) { + } } }); diff --git a/src/LogiAudit.php b/src/LogiAudit.php deleted file mode 100755 index 22f7ea6..0000000 --- a/src/LogiAudit.php +++ /dev/null @@ -1,5 +0,0 @@ -name('logiaudit') - ->hasConfigFile('logiaudit') - ->hasViews(); - // ->hasMigration('') - // ->hasCommand(::class); - - } } diff --git a/src/LogiAuditStatus.php b/src/LogiAuditStatus.php new file mode 100644 index 0000000..1068319 --- /dev/null +++ b/src/LogiAuditStatus.php @@ -0,0 +1,34 @@ + 'array', 'old_value' => 'array', 'new_value' => 'array', ]; + + /** + * @return MorphTo + */ + public function causer(): MorphTo + { + return $this->morphTo('causer', 'causer_type', 'user_id'); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeForModel(Builder $query, Model $model): Builder + { + return $query->where('model', $model->getMorphClass()) + ->where('model_id', $model->getKey()); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeByUser(Builder $query, ?int $userId): Builder + { + return $query->where('user_id', $userId); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeForEvent(Builder $query, string $event): Builder + { + return $query->where('action', $event); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeForTable(Builder $query, string $table): Builder + { + return $query->where('table', $table); + } } diff --git a/src/Models/LogiAuditLog.php b/src/Models/LogiAuditLog.php index f94ca73..6788eb2 100644 --- a/src/Models/LogiAuditLog.php +++ b/src/Models/LogiAuditLog.php @@ -10,6 +10,7 @@ class LogiAuditLog extends Model protected $fillable = [ 'level', + 'tag', 'message', 'model_id', 'model_type', @@ -17,7 +18,8 @@ class LogiAuditLog extends Model 'context', 'ip_address', 'deletable', - 'deleted_at', + 'expires_at', + 'logged_at', ]; protected $casts = [ diff --git a/src/Traits/LogiAuditTrait.php b/src/Traits/LogiAuditTrait.php index c6d42ae..570752d 100644 --- a/src/Traits/LogiAuditTrait.php +++ b/src/Traits/LogiAuditTrait.php @@ -3,9 +3,14 @@ namespace AuroraWebSoftware\LogiAudit\Traits; use AuroraWebSoftware\LogiAudit\Events\HistoryEventObserver; +use AuroraWebSoftware\LogiAudit\LogiAuditStatus; +use AuroraWebSoftware\LogiAudit\Models\LogiAuditHistory; +use Illuminate\Database\Eloquent\Relations\MorphMany; trait LogiAuditTrait { + public bool $loggingDisabled = false; + /** * @return void */ @@ -14,6 +19,14 @@ public static function bootLogiAuditTrait() static::observe(new HistoryEventObserver); } + /** + * @return MorphMany + */ + public function auditHistory(): MorphMany + { + return $this->morphMany(LogiAuditHistory::class, 'subject', 'model', 'model_id'); + } + /** * @return array|mixed|string[] */ @@ -33,4 +46,28 @@ public function getExcludedEvents() ? [] : $this->excludedEvents; } + + public function getDontLogIfAttributesChangedOnly(): array + { + return $this->dontLogIfAttributesChangedOnly ?? []; + } + + public function disableLogging(): static + { + $this->loggingDisabled = true; + + return $this; + } + + public function enableLogging(): static + { + $this->loggingDisabled = false; + + return $this; + } + + public static function withoutLogging(callable $callback): mixed + { + return LogiAuditStatus::withoutLogging($callback); + } } diff --git a/tests/AddLogHelperTest.php b/tests/AddLogHelperTest.php index 57d3d88..71f677e 100644 --- a/tests/AddLogHelperTest.php +++ b/tests/AddLogHelperTest.php @@ -136,3 +136,52 @@ expect($logs)->toHaveCount(5); expect($failedJobs)->toHaveCount(2); }); + +it('stores tag and logged_at correctly via addLogT', function () { + addLogT('info', 'Tagged log message', [ + 'model_id' => 999, + 'model_type' => 'App\\Models\\User', + 'trace_id' => 'tag-test-001', + 'ip_address' => '127.0.0.1', + 'deletable' => false, + 'tag' => 'authentication', + ]); + + $queuedJobs = DB::table('jobs')->get(); + expect($queuedJobs)->toHaveCount(1); + + $queueName = config('logiaudit.log_queue_name', 'logiaudit'); + Artisan::call("queue:work --queue={$queueName} --tries=3 --stop-when-empty"); + + $log = LogiAuditLog::where('trace_id', 'tag-test-001')->first(); + + expect($log)->not->toBeNull() + ->and($log->tag)->toBe('authentication') + ->and($log->logged_at)->not->toBeNull() + ->and($log->level)->toBe('info') + ->and($log->message)->toBe('Tagged log message'); +}); + +it('unwraps nested context correctly via addLogT', function () { + addLogT('info', 'Context unwrap test', [ + 'model_id' => 800, + 'model_type' => 'App\\Models\\User', + 'trace_id' => 'context-unwrap-001', + 'ip_address' => '127.0.0.1', + 'deletable' => false, + 'context' => ['action' => 'login', 'source' => 'api'], + ]); + + $queueName = config('logiaudit.log_queue_name', 'logiaudit'); + Artisan::call("queue:work --queue={$queueName} --tries=3 --stop-when-empty"); + + $log = LogiAuditLog::where('trace_id', 'context-unwrap-001')->first(); + + expect($log)->not->toBeNull() + ->and($log->context)->toBeArray() + ->and($log->context)->toHaveKey('action') + ->and($log->context['action'])->toBe('login') + ->and($log->context)->toHaveKey('source') + ->and($log->context['source'])->toBe('api') + ->and($log->context)->not->toHaveKey('context'); +}); diff --git a/tests/Events/HistoryEventObserver.php b/tests/Events/HistoryEventObserver.php index 8e810ca..c6f2364 100644 --- a/tests/Events/HistoryEventObserver.php +++ b/tests/Events/HistoryEventObserver.php @@ -2,6 +2,7 @@ namespace AuroraWebSoftware\LogiAudit\Tests\Events; +use AuroraWebSoftware\LogiAudit\LogiAuditStatus; use AuroraWebSoftware\LogiAudit\Tests\Jobs\StoreHistoryJob; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Auth; @@ -15,8 +16,12 @@ class HistoryEventObserver */ public function created(Model $model) { - if (! in_array('create', $model->getExcludedEvents())) { - $this->saveHistory('created', $model); + try { + if ($this->shouldLog($model) && ! $this->isEventExcluded('created', $model)) { + $this->saveHistory('created', $model); + } + } catch (\Throwable $e) { + $this->logFailure($e); } return true; @@ -27,10 +32,14 @@ public function created(Model $model) */ public function updated(Model $model) { - if (! in_array('update', $model->getExcludedEvents())) { - if ($model->getChanges()) { - $this->saveHistory('updated', $model); + try { + if ($this->shouldLog($model) && ! $this->isEventExcluded('updated', $model)) { + if ($model->getChanges() && ! $this->shouldSkipDueToOnlyIgnoredChanges($model)) { + $this->saveHistory('updated', $model); + } } + } catch (\Throwable $e) { + $this->logFailure($e); } return true; @@ -41,50 +50,128 @@ public function updated(Model $model) */ public function deleted(Model $model) { - if (! in_array('delete', $model->getExcludedEvents())) { - $this->saveHistory('deleted', $model); + try { + if ($this->shouldLog($model) && ! $this->isEventExcluded('deleted', $model)) { + $this->saveHistory('deleted', $model); + } + } catch (\Throwable $e) { + $this->logFailure($e); } } /** * @return void */ - private function saveHistory($event, $model) + public function restored(Model $model) { try { - $dirty = $model->getDirty(); + if ($this->shouldLog($model) && ! $this->isEventExcluded('restored', $model)) { + $this->saveHistory('restored', $model); + } + } catch (\Throwable $e) { + $this->logFailure($e); + } + } + + private function shouldLog(Model $model): bool + { + if (! LogiAuditStatus::isEnabled()) { + return false; + } - $oldValues = []; - $newValues = []; - $columns = []; + if (property_exists($model, 'loggingDisabled') && $model->loggingDisabled) { + return false; + } + + return true; + } - $attributes = $model->getExcludedColumns(); + private function shouldSkipDueToOnlyIgnoredChanges(Model $model): bool + { + $ignoredColumns = $model->getDontLogIfAttributesChangedOnly(); + + if (empty($ignoredColumns)) { + return false; + } - foreach ($dirty as $column => $value) { - if (! in_array($column, $attributes)) { + $changedColumns = array_keys($model->getChanges()); + + foreach ($changedColumns as $column) { + if (! in_array($column, $ignoredColumns)) { + return false; + } + } + + return true; + } + + private function isEventExcluded(string $event, Model $model): bool + { + $excluded = $model->getExcludedEvents(); + $short = rtrim($event, 'd'); + + return in_array($event, $excluded) || in_array($short, $excluded); + } + + /** + * @return void + */ + private function saveHistory($event, $model) + { + $excludedColumns = $model->getExcludedColumns(); + $oldValues = []; + $newValues = []; + $columns = []; + + if ($event === 'created') { + foreach ($model->getAttributes() as $column => $value) { + if (! in_array($column, $excludedColumns)) { + $newValues[] = [$column => $value]; + $columns[] = $column; + } + } + } elseif ($event === 'updated') { + foreach ($model->getChanges() as $column => $value) { + if (! in_array($column, $excludedColumns)) { $oldValues[] = [$column => $model->getOriginal($column)]; $newValues[] = [$column => $value]; $columns[] = $column; } } + } - if ((empty($columns) && $event !== 'deleted')) { - return; - } + if (empty($columns) && ! in_array($event, ['deleted', 'restored'])) { + return; + } + + $causerType = null; + $userId = null; - StoreHistoryJob::dispatch( - $event, - $model->getTable(), - $model->getMorphClass(), - $model->getKey(), - $event !== 'deleted' ? $columns : null, - $event === 'updated' ? $oldValues : null, - $event !== 'deleted' ? $newValues : null, - Auth::check() ? Auth::user()->id : null, - Request::ip(), - ); - } catch (\Exception $e) { + if (Auth::check()) { + $user = Auth::user(); + $userId = $user->id; + $causerType = get_class($user); + } + + StoreHistoryJob::dispatch( + $event, + $model->getTable(), + $model->getMorphClass(), + $model->getKey(), + $event !== 'deleted' && $event !== 'restored' ? $columns : null, + $event === 'updated' ? $oldValues : null, + $event !== 'deleted' && $event !== 'restored' ? $newValues : null, + $userId, + Request::ip(), + $causerType, + ); + } + + private function logFailure(\Throwable $e): void + { + try { Log::error('Audit history job dispatch failed: '.$e->getMessage()); + } catch (\Throwable) { } } } diff --git a/tests/Helpers/AddLogTHelpers.php b/tests/Helpers/AddLogTHelpers.php index 2388d89..ea4bbb7 100644 --- a/tests/Helpers/AddLogTHelpers.php +++ b/tests/Helpers/AddLogTHelpers.php @@ -1,6 +1,7 @@ getMessage()); + } catch (\Throwable) { + } } - } } diff --git a/tests/Jobs/StoreHistoryJob.php b/tests/Jobs/StoreHistoryJob.php index 256c59b..cb702cd 100644 --- a/tests/Jobs/StoreHistoryJob.php +++ b/tests/Jobs/StoreHistoryJob.php @@ -31,6 +31,8 @@ class StoreHistoryJob implements ShouldQueue public ?string $ipAddress; + public ?string $causerType = null; + public function __construct( string $action, string $table, @@ -41,6 +43,7 @@ public function __construct( ?array $newValues = null, ?int $userId = null, ?string $ipAddress = null, + ?string $causerType = null, ) { $this->action = $action; $this->table = $table; @@ -51,6 +54,7 @@ public function __construct( $this->newValues = $newValues; $this->userId = $userId; $this->ipAddress = $ipAddress; + $this->causerType = $causerType; $this->onQueue(config('logiaudit.history_queue_name')); } @@ -70,6 +74,7 @@ public function handle(): void 'old_value' => ! empty($this->oldValues) ? $this->oldValues : null, 'new_value' => ! empty($this->newValues) ? $this->newValues : null, 'user_id' => $this->userId, + 'causer_type' => $this->causerType, 'ip_address' => $this->ipAddress, ]); } diff --git a/tests/Jobs/StoreLogJob.php b/tests/Jobs/StoreLogJob.php index f9f90ff..2b5745f 100644 --- a/tests/Jobs/StoreLogJob.php +++ b/tests/Jobs/StoreLogJob.php @@ -3,12 +3,12 @@ namespace AuroraWebSoftware\LogiAudit\Tests\Jobs; use AuroraWebSoftware\LogiAudit\Tests\Models\LogiAuditLog; +use Carbon\Carbon; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Illuminate\Support\Carbon; class StoreLogJob implements ShouldQueue { @@ -32,6 +32,10 @@ class StoreLogJob implements ShouldQueue public ?int $deleteAfterDays; + public ?Carbon $loggedAt; + + public ?string $tag; + /** * Create a new job instance. */ @@ -44,7 +48,9 @@ public function __construct( ?array $context = null, ?string $ipAddress = null, bool $deletable = true, - ?int $deleteAfterDays = null + ?int $deleteAfterDays = null, + ?Carbon $loggedAt = null, + ?string $tag = null ) { $this->level = $level; $this->message = $message; @@ -55,6 +61,8 @@ public function __construct( $this->ipAddress = $ipAddress; $this->deletable = $deletable; $this->deleteAfterDays = $deleteAfterDays; + $this->loggedAt = $loggedAt; + $this->tag = $tag; $this->onQueue(config('logiaudit.log_queue_name')); } @@ -79,6 +87,7 @@ public function handle(): void LogiAuditLog::create([ 'level' => $this->level, + 'tag' => $this->tag, 'message' => $this->message, 'model_id' => $this->modelId, 'model_type' => $this->modelType, @@ -86,9 +95,10 @@ public function handle(): void 'context' => $this->context, 'ip_address' => $this->ipAddress, 'deletable' => $this->deletable, - 'deleted_at' => ($this->deletable && $this->deleteAfterDays) + 'expires_at' => ($this->deletable && $this->deleteAfterDays) ? Carbon::now()->addDays($this->deleteAfterDays) : null, + 'logged_at' => $this->loggedAt, ]); dump('✅ Log entry created in database!'); diff --git a/tests/LogChannelTest.php b/tests/LogChannelTest.php index 828bf7d..e06eb2f 100644 --- a/tests/LogChannelTest.php +++ b/tests/LogChannelTest.php @@ -132,3 +132,29 @@ ->and($log2->context['action'])->toBe('update') ->and($log2->deletable)->toBeFalse(); }); + +it('stores tag and logged_at via log channel', function () { + Log::channel('logiaudit')->info('Tagged channel log', [ + 'trace_id' => 'tag-channel-001', + 'model_id' => 101, + 'model_type' => 'App\Models\User', + 'tag' => 'payment', + 'deletable' => false, + ]); + + $queuedJobs = DB::table('jobs')->get(); + expect($queuedJobs)->toHaveCount(1); + + $queueName = config('logiaudit.log_queue_name', 'logiaudit'); + Artisan::call("queue:work --queue={$queueName} --tries=3 --stop-when-empty"); + + $log = LogiAuditLog::where('trace_id', 'tag-channel-001')->first(); + + expect($log)->not->toBeNull() + ->and($log->tag)->toBe('payment') + ->and($log->logged_at)->not->toBeNull() + ->and($log->level)->toBe('info') + ->and($log->message)->toBe('Tagged channel log') + ->and($log->model_id)->toBe(101) + ->and($log->deletable)->toBeFalse(); +}); diff --git a/tests/Logging/LogiAuditHandler.php b/tests/Logging/LogiAuditHandler.php index 047c40d..d496a9a 100644 --- a/tests/Logging/LogiAuditHandler.php +++ b/tests/Logging/LogiAuditHandler.php @@ -3,6 +3,7 @@ namespace AuroraWebSoftware\LogiAudit\Tests\Logging; use AuroraWebSoftware\LogiAudit\Tests\Jobs\StoreLogJob; +use Carbon\Carbon; use Monolog\Handler\AbstractProcessingHandler; use Monolog\Logger; use Monolog\LogRecord; @@ -17,41 +18,51 @@ public function __invoke(array $config) { protected function write(LogRecord $record): void { - $context = $record->context; - - $modelId = $context['model_id'] ?? null; - $modelType = $context['model_type'] ?? null; - $traceId = $context['trace_id'] ?? null; - $ipAddress = $context['ip_address'] ?? null; - $deletable = $context['deletable'] ?? true; - $deleteAfterDays = $context['delete_after_days'] ?? null; - unset( - $context['model_id'], - $context['model_type'], - $context['trace_id'], - $context['ip_address'], - $context['deletable'], - $context['delete_after_days'] - ); - - if (isset($context['context']) && is_array($context['context'])) { - $context = $context['context']; - } - if (empty($context)) { - $context = null; - } + try { + $context = $record->context; + + $modelId = $context['model_id'] ?? null; + $modelType = $context['model_type'] ?? null; + $traceId = $context['trace_id'] ?? null; + $ipAddress = $context['ip_address'] ?? null; + $deletable = $context['deletable'] ?? true; + $deleteAfterDays = $context['delete_after_days'] ?? null; + $tag = $context['tag'] ?? null; + unset( + $context['model_id'], + $context['model_type'], + $context['trace_id'], + $context['ip_address'], + $context['deletable'], + $context['delete_after_days'], + $context['tag'] + ); - dispatch(new StoreLogJob( - strtolower($record->level->name), - $record->message, - $modelId, - $modelType, - $traceId, - $context, - $ipAddress, - $deletable, - $deleteAfterDays - )); + if (isset($context['context']) && is_array($context['context'])) { + $nested = $context['context']; + unset($context['context']); + $context = array_merge($nested, $context); + } + + if (empty($context)) { + $context = null; + } + + dispatch(new StoreLogJob( + strtolower($record->level->name), + $record->message, + $modelId, + $modelType, + $traceId, + $context, + $ipAddress, + $deletable, + $deleteAfterDays, + Carbon::now(), + $tag + )); + } catch (\Throwable) { + } } }); diff --git a/tests/LogiAuditHistoryTest.php b/tests/LogiAuditHistoryTest.php index d161361..d39b322 100644 --- a/tests/LogiAuditHistoryTest.php +++ b/tests/LogiAuditHistoryTest.php @@ -1,8 +1,12 @@ string('excluded_field')->nullable(); }); + Schema::create('test_soft_delete_models', function ($table) { + $table->id(); + $table->string('name'); + $table->string('status')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + if (! Schema::hasTable('jobs')) { Artisan::call('queue:table'); Artisan::call('migrate'); @@ -36,6 +48,8 @@ $request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '127.0.0.1']); app()->singleton('request', fn () => $request); + + LogiAuditStatus::enable(); }); it('dispatches and processes StoreHistoryJob entries with one failure', function () { @@ -65,18 +79,225 @@ ); $queuedJobs = DB::table('jobs')->get(); - dump('🔄 Queued Jobs:', $queuedJobs->toArray()); + dump('Queued Jobs:', $queuedJobs->toArray()); $queueName = config('logiaudit.history_queue_name', 'logiaudit'); Artisan::call("queue:work --queue={$queueName} --tries=3 --stop-when-empty"); $auditLogs = LogiAuditHistory::orderBy('id')->get(); - dump('📜 Audit Logs:', $auditLogs->toArray()); + dump('Audit Logs:', $auditLogs->toArray()); $failedJobs = DB::table('failed_jobs')->get(); - dump('❌ Failed Jobs:', $failedJobs->toArray()); + dump('Failed Jobs:', $failedJobs->toArray()); expect($auditLogs)->toHaveCount(3); expect($auditLogs->pluck('action')->toArray())->toBe(['created', 'updated', 'deleted']); expect($failedJobs)->toHaveCount(1); }); + +it('respects excludedEvents in short form (create instead of created)', function () { + $model = TestModelExcluded::create([ + 'name' => 'Excluded create test', + 'status' => 'active', + ]); + + $model->update(['name' => 'Updated name']); + $model->delete(); + + Artisan::call('queue:work', [ + '--queue' => 'logiaudit', + '--tries' => 1, + '--stop-when-empty' => true, + ]); + + $history = LogiAuditHistory::where('model_id', $model->id)->get(); + + expect($history->where('action', 'created')->count())->toBe(0) + ->and($history->where('action', 'updated')->count())->toBe(1) + ->and($history->where('action', 'deleted')->count())->toBe(1); +}); + +it('records restored event for SoftDeletes models', function () { + $model = TestModelSoftDelete::create([ + 'name' => 'Soft delete test', + 'status' => 'active', + ]); + + $model->delete(); + $model->restore(); + + Artisan::call('queue:work', [ + '--queue' => 'logiaudit', + '--tries' => 1, + '--stop-when-empty' => true, + ]); + + $history = LogiAuditHistory::where('model_id', $model->id) + ->where('model', TestModelSoftDelete::class) + ->get(); + + expect($history->where('action', 'created')->count())->toBe(1) + ->and($history->where('action', 'deleted')->count())->toBe(1) + ->and($history->where('action', 'restored')->count())->toBe(1); +}); + +it('skips logging when per-instance logging is disabled', function () { + $model = TestModel::create(['name' => 'Before disable']); + + $model->disableLogging(); + $model->update(['name' => 'After disable']); + + Artisan::call('queue:work', [ + '--queue' => 'logiaudit', + '--tries' => 1, + '--stop-when-empty' => true, + ]); + + $history = LogiAuditHistory::where('model_id', $model->id)->get(); + + expect($history->where('action', 'created')->count())->toBe(1) + ->and($history->where('action', 'updated')->count())->toBe(0); +}); + +it('skips logging when global LogiAuditStatus is disabled', function () { + LogiAuditStatus::disable(); + + $model = TestModel::create(['name' => 'Global disabled']); + $model->update(['name' => 'Updated globally disabled']); + + LogiAuditStatus::enable(); + + Artisan::call('queue:work', [ + '--queue' => 'logiaudit', + '--tries' => 1, + '--stop-when-empty' => true, + ]); + + $history = LogiAuditHistory::where('model_id', $model->id)->get(); + + expect($history)->toHaveCount(0); +}); + +it('supports withoutLogging callback', function () { + TestModel::withoutLogging(function () { + TestModel::create(['name' => 'Inside withoutLogging']); + }); + + $modelAfter = TestModel::create(['name' => 'After withoutLogging']); + + Artisan::call('queue:work', [ + '--queue' => 'logiaudit', + '--tries' => 1, + '--stop-when-empty' => true, + ]); + + $historyAll = LogiAuditHistory::all(); + + expect($historyAll)->toHaveCount(1) + ->and($historyAll->first()->model_id)->toBe($modelAfter->id); +}); + +it('skips history when only dontLogIfAttributesChangedOnly columns change', function () { + $model = TestModelDontLogIfOnly::create([ + 'name' => 'DontLogIfOnly test', + 'status' => 'active', + ]); + + // Update only the ignored column — should NOT generate an updated history entry + $model->update(['status' => 'inactive']); + + // Update a real column + ignored column — SHOULD generate an updated history entry + $model->update(['name' => 'Changed name', 'status' => 'changed']); + + Artisan::call('queue:work', [ + '--queue' => 'logiaudit', + '--tries' => 1, + '--stop-when-empty' => true, + ]); + + $history = LogiAuditHistory::where('model_id', $model->id) + ->where('model', TestModelDontLogIfOnly::class) + ->get(); + + expect($history->where('action', 'created')->count())->toBe(1) + ->and($history->where('action', 'updated')->count())->toBe(1); +}); + +it('stores causer_type alongside user_id', function () { + $model = TestModel::create([ + 'name' => 'Causer type test', + ]); + + Artisan::call('queue:work', [ + '--queue' => 'logiaudit', + '--tries' => 1, + '--stop-when-empty' => true, + ]); + + $history = LogiAuditHistory::where('model_id', $model->id)->first(); + + expect($history->user_id)->toBe(1) + ->and($history->causer_type)->not->toBeNull(); +}); + +it('provides auditHistory relationship on model', function () { + $model = TestModel::create([ + 'name' => 'Relationship test', + 'status' => 'active', + ]); + + $model->update(['name' => 'Updated']); + $model->delete(); + + Artisan::call('queue:work', [ + '--queue' => 'logiaudit', + '--tries' => 1, + '--stop-when-empty' => true, + ]); + + // Reload model from DB (use withTrashed equivalent — but TestModel has no SoftDeletes so just query) + $freshModel = TestModel::withoutGlobalScopes()->find($model->id); + + // Model was hard-deleted, so let's test with a new model + $model2 = TestModel::create(['name' => 'Relationship test 2']); + + Artisan::call('queue:work', [ + '--queue' => 'logiaudit', + '--tries' => 1, + '--stop-when-empty' => true, + ]); + + $auditHistory = $model2->auditHistory; + + expect($auditHistory)->toHaveCount(1) + ->and($auditHistory->first()->action)->toBe('created'); +}); + +it('supports query scopes on LogiAuditHistory', function () { + $model1 = TestModel::create(['name' => 'Scope test 1']); + $model2 = TestModel::create(['name' => 'Scope test 2']); + $model1->update(['name' => 'Scope updated']); + $model1->delete(); + + Artisan::call('queue:work', [ + '--queue' => 'logiaudit', + '--tries' => 1, + '--stop-when-empty' => true, + ]); + + // forModel scope + $forModel1 = LogiAuditHistory::forModel($model1)->get(); + expect($forModel1)->toHaveCount(3); + + // byUser scope + $byUser = LogiAuditHistory::byUser(1)->get(); + expect($byUser->count())->toBeGreaterThanOrEqual(4); + + // forEvent scope + $deletedOnly = LogiAuditHistory::forEvent('deleted')->get(); + expect($deletedOnly)->toHaveCount(1); + + // forTable scope + $forTable = LogiAuditHistory::forTable('test_models')->get(); + expect($forTable->count())->toBeGreaterThanOrEqual(4); +}); diff --git a/tests/Models/LogiAuditLog.php b/tests/Models/LogiAuditLog.php index 4725208..93c8b6d 100644 --- a/tests/Models/LogiAuditLog.php +++ b/tests/Models/LogiAuditLog.php @@ -10,6 +10,7 @@ class LogiAuditLog extends Model protected $fillable = [ 'level', + 'tag', 'message', 'model_id', 'model_type', @@ -17,7 +18,8 @@ class LogiAuditLog extends Model 'context', 'ip_address', 'deletable', - 'deleted_at', + 'expires_at', + 'logged_at', ]; protected $casts = [ diff --git a/tests/Models/TestModelDontLogIfOnly.php b/tests/Models/TestModelDontLogIfOnly.php new file mode 100644 index 0000000..d7aca3d --- /dev/null +++ b/tests/Models/TestModelDontLogIfOnly.php @@ -0,0 +1,23 @@ + ['note' => 'expired'], 'ip_address' => '192.168.1.3', 'deletable' => true, - 'deleted_at' => Carbon::now()->subDays(2), + 'expires_at' => Carbon::now()->subDays(2), ]); LogiAuditLog::create([ @@ -90,7 +90,7 @@ 'context' => ['note' => 'future'], 'ip_address' => '192.168.1.4', 'deletable' => true, - 'deleted_at' => Carbon::now()->addDays(15), + 'expires_at' => Carbon::now()->addDays(15), ]); $queuedJobs = DB::table('jobs')->get(); @@ -118,5 +118,5 @@ dump("🗑 Number of logs deleted after first prune: $deletedNow"); expect($afterCount)->toBe(3) - ->and(DB::table('logiaudit_logs')->where('deleted_at', '>', Carbon::now())->where('deletable', true)->exists())->toBeTrue(); + ->and(DB::table('logiaudit_logs')->where('expires_at', '>', Carbon::now())->where('deletable', true)->exists())->toBeTrue(); }); diff --git a/tests/Traits/LogiAuditTrait.php b/tests/Traits/LogiAuditTrait.php index b329fed..fe3466d 100644 --- a/tests/Traits/LogiAuditTrait.php +++ b/tests/Traits/LogiAuditTrait.php @@ -2,10 +2,15 @@ namespace AuroraWebSoftware\LogiAudit\Tests\Traits; +use AuroraWebSoftware\LogiAudit\LogiAuditStatus; +use AuroraWebSoftware\LogiAudit\Models\LogiAuditHistory; use AuroraWebSoftware\LogiAudit\Tests\Events\HistoryEventObserver; +use Illuminate\Database\Eloquent\Relations\MorphMany; trait LogiAuditTrait { + public bool $loggingDisabled = false; + /** * @return void */ @@ -14,6 +19,14 @@ public static function bootLogiAuditTrait() static::observe(new HistoryEventObserver); } + /** + * @return MorphMany + */ + public function auditHistory(): MorphMany + { + return $this->morphMany(LogiAuditHistory::class, 'subject', 'model', 'model_id'); + } + /** * @return array|mixed|string[] */ @@ -33,4 +46,28 @@ public function getExcludedEvents() ? [] : $this->excludedEvents; } + + public function getDontLogIfAttributesChangedOnly(): array + { + return $this->dontLogIfAttributesChangedOnly ?? []; + } + + public function disableLogging(): static + { + $this->loggingDisabled = true; + + return $this; + } + + public function enableLogging(): static + { + $this->loggingDisabled = false; + + return $this; + } + + public static function withoutLogging(callable $callback): mixed + { + return LogiAuditStatus::withoutLogging($callback); + } }