Release v1.4.0 #146

Merged
myrmidex merged 71 commits from release/v1.4.0 into main 2026-08-15 00:36:54 +02:00
6 changed files with 249 additions and 0 deletions
Showing only changes of commit c7de5bc3df - Show all commits

View file

@ -0,0 +1,39 @@
<?php
namespace App\Enums;
enum ActivityTypeEnum: string
{
case FETCH = 'fetch';
case VALIDATE = 'validate';
case APPROVE = 'approve';
case REJECT = 'reject';
case PUBLISH = 'publish';
case ERROR = 'error';
public function label(): string
{
return match ($this) {
self::FETCH => 'Fetched',
self::VALIDATE => 'Validated',
self::APPROVE => 'Approved',
self::REJECT => 'Rejected',
self::PUBLISH => 'Published',
self::ERROR => 'Error',
};
}
/**
* @return array<string, string>
*/
public static function options(): array
{
$options = [];
foreach (self::cases() as $case) {
$options[$case->value] = $case->label();
}
return $options;
}
}

View file

@ -0,0 +1,20 @@
<?php
namespace App\Events;
use App\Enums\ActivityTypeEnum;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Events\Dispatchable;
class ActivityLogged
{
use Dispatchable;
public function __construct(
public ActivityTypeEnum $type,
public string $message,
/** @var array<string, mixed> */
public array $context = [],
public ?Model $subject = null,
) {}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Listeners;
use App\Events\ActivityLogged;
use App\Models\ActivityLog;
use Illuminate\Support\Str;
use Throwable;
class RecordActivityListener
{
private const MESSAGE_LIMIT = 255;
public function handle(ActivityLogged $event): void
{
try {
ActivityLog::create([
'type' => $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());
}
}
}

View file

@ -0,0 +1,80 @@
<?php
namespace App\Models;
use App\Enums\ActivityTypeEnum;
use Database\Factories\ActivityLogFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Carbon;
/**
* @property int $id
* @property ActivityTypeEnum $type
* @property string $message
* @property array<string, mixed>|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<ActivityLogFactory> */
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<Model, $this>
*/
public function subject(): MorphTo
{
return $this->morphTo();
}
/**
* @param Builder<ActivityLog> $query
* @return Builder<ActivityLog>
*/
public function scopeOfType(Builder $query, ActivityTypeEnum $type): Builder
{
return $query->where('type', $type);
}
/**
* @param Builder<ActivityLog> $query
* @return Builder<ActivityLog>
*/
public function scopeSince(Builder $query, Carbon $since): Builder
{
return $query->where('logged_at', '>=', $since);
}
/**
* @param Builder<ActivityLog> $query
* @return Builder<ActivityLog>
*/
public function scopeLatestFirst(Builder $query): Builder
{
return $query->orderByDesc('logged_at')->orderByDesc('id');
}
}

View file

@ -0,0 +1,50 @@
<?php
namespace Database\Factories;
use App\Enums\ActivityTypeEnum;
use App\Models\ActivityLog;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* @extends Factory<ActivityLog>
*/
class ActivityLogFactory extends Factory
{
protected $model = ActivityLog::class;
/**
* @return array<string, mixed>
*/
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(),
]);
}
}

View file

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('activity_logs', function (Blueprint $table) {
$table->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');
}
};