From c7de5bc3dfd5b34c790a8c39095284cb31f409b5 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Mon, 10 Aug 2026 20:44:30 +0200 Subject: [PATCH] 91 - Add activity log table, model and event pipeline --- app/Enums/ActivityTypeEnum.php | 39 +++++++++ app/Events/ActivityLogged.php | 20 +++++ app/Listeners/RecordActivityListener.php | 29 +++++++ app/Models/ActivityLog.php | 80 +++++++++++++++++++ database/factories/ActivityLogFactory.php | 50 ++++++++++++ ...1_01_000019_create_activity_logs_table.php | 31 +++++++ 6 files changed, 249 insertions(+) create mode 100644 app/Enums/ActivityTypeEnum.php create mode 100644 app/Events/ActivityLogged.php create mode 100644 app/Listeners/RecordActivityListener.php create mode 100644 app/Models/ActivityLog.php create mode 100644 database/factories/ActivityLogFactory.php create mode 100644 database/migrations/2024_01_01_000019_create_activity_logs_table.php diff --git a/app/Enums/ActivityTypeEnum.php b/app/Enums/ActivityTypeEnum.php new file mode 100644 index 00000000..bf659704 --- /dev/null +++ b/app/Enums/ActivityTypeEnum.php @@ -0,0 +1,39 @@ + 'Fetched', + self::VALIDATE => 'Validated', + self::APPROVE => 'Approved', + self::REJECT => 'Rejected', + self::PUBLISH => 'Published', + self::ERROR => 'Error', + }; + } + + /** + * @return array + */ + public static function options(): array + { + $options = []; + + foreach (self::cases() as $case) { + $options[$case->value] = $case->label(); + } + + return $options; + } +} diff --git a/app/Events/ActivityLogged.php b/app/Events/ActivityLogged.php new file mode 100644 index 00000000..79dd60fb --- /dev/null +++ b/app/Events/ActivityLogged.php @@ -0,0 +1,20 @@ + */ + public array $context = [], + public ?Model $subject = null, + ) {} +} diff --git a/app/Listeners/RecordActivityListener.php b/app/Listeners/RecordActivityListener.php new file mode 100644 index 00000000..e72f6637 --- /dev/null +++ b/app/Listeners/RecordActivityListener.php @@ -0,0 +1,29 @@ + $event->type, + 'message' => Str::limit($event->message, self::MESSAGE_LIMIT, ''), + 'context' => $event->context === [] ? null : $event->context, + 'subject_type' => $event->subject?->getMorphClass(), + 'subject_id' => $event->subject?->getKey(), + 'logged_at' => now(), + ]); + } catch (Throwable $e) { + error_log('Failed to record activity: '.$e->getMessage()); + } + } +} diff --git a/app/Models/ActivityLog.php b/app/Models/ActivityLog.php new file mode 100644 index 00000000..60b626a4 --- /dev/null +++ b/app/Models/ActivityLog.php @@ -0,0 +1,80 @@ +|null $context + * @property string|null $subject_type + * @property int|null $subject_id + * @property Carbon $logged_at + * @property Carbon $created_at + * @property Carbon $updated_at + */ +class ActivityLog extends Model +{ + /** @use HasFactory */ + use HasFactory; + + protected $fillable = [ + 'type', + 'message', + 'context', + 'subject_type', + 'subject_id', + 'logged_at', + ]; + + protected $casts = [ + 'type' => ActivityTypeEnum::class, + 'context' => 'array', + 'logged_at' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + /** + * @return MorphTo + */ + public function subject(): MorphTo + { + return $this->morphTo(); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeOfType(Builder $query, ActivityTypeEnum $type): Builder + { + return $query->where('type', $type); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeSince(Builder $query, Carbon $since): Builder + { + return $query->where('logged_at', '>=', $since); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeLatestFirst(Builder $query): Builder + { + return $query->orderByDesc('logged_at')->orderByDesc('id'); + } +} diff --git a/database/factories/ActivityLogFactory.php b/database/factories/ActivityLogFactory.php new file mode 100644 index 00000000..32b447dd --- /dev/null +++ b/database/factories/ActivityLogFactory.php @@ -0,0 +1,50 @@ + + */ +class ActivityLogFactory extends Factory +{ + protected $model = ActivityLog::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'type' => fake()->randomElement(ActivityTypeEnum::cases()), + 'message' => fake()->sentence(4), + 'context' => null, + 'subject_type' => null, + 'subject_id' => null, + 'logged_at' => now(), + ]; + } + + public function type(ActivityTypeEnum $type): static + { + return $this->state(['type' => $type]); + } + + public function loggedAt(Carbon $loggedAt): static + { + return $this->state(['logged_at' => $loggedAt]); + } + + public function for_subject(Model $subject): static + { + return $this->state([ + 'subject_type' => $subject->getMorphClass(), + 'subject_id' => $subject->getKey(), + ]); + } +} diff --git a/database/migrations/2024_01_01_000019_create_activity_logs_table.php b/database/migrations/2024_01_01_000019_create_activity_logs_table.php new file mode 100644 index 00000000..8fa63380 --- /dev/null +++ b/database/migrations/2024_01_01_000019_create_activity_logs_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('type'); + $table->string('message'); + $table->json('context')->nullable(); + $table->string('subject_type')->nullable(); + $table->unsignedBigInteger('subject_id')->nullable(); + $table->timestamp('logged_at')->useCurrent(); + $table->timestamps(); + + $table->index(['type', 'logged_at']); + $table->index('logged_at'); + $table->index(['subject_type', 'subject_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('activity_logs'); + } +};