Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer collective, backed by the Pireon OAuth identity server. The public site is a separate, server-rendered application consuming /api/public/v1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
146 lines
4.7 KiB
PHP
146 lines
4.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Models\Concerns;
|
|
|
|
use App\Models\Revision;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
|
use Illuminate\Support\Facades\Auth;
|
|
|
|
/**
|
|
* Records who changed what.
|
|
*
|
|
* Only what actually changed is kept, old value and new. Storing whole records
|
|
* would make the history larger than the content it describes, and reading a
|
|
* diff is the point — "María changed the Catalan title" is useful, "María
|
|
* saved the page" is not.
|
|
*/
|
|
trait HasRevisions
|
|
{
|
|
/**
|
|
* Attributes never worth recording: they change on every save and say
|
|
* nothing about what anybody did.
|
|
*
|
|
* @var array<int, string>
|
|
*/
|
|
protected array $revisionIgnores = ['updated_at', 'created_at', 'remember_token'];
|
|
|
|
public static function bootHasRevisions(): void
|
|
{
|
|
static::created(fn (Model $model) => $model->recordRevision('created'));
|
|
|
|
static::updated(function (Model $model): void {
|
|
$changes = $model->revisionChanges();
|
|
|
|
if ($changes === []) {
|
|
return;
|
|
}
|
|
|
|
// Publishing gets its own name in the history. It is a separate
|
|
// permission because it is a bigger act than editing, and a list
|
|
// where it reads the same as any other change hides exactly the
|
|
// thing somebody came looking for.
|
|
$model->recordRevision($model->revisionEvent($changes), $changes);
|
|
});
|
|
|
|
static::deleted(fn (Model $model) => $model->recordRevision(
|
|
method_exists($model, 'isForceDeleting') && $model->isForceDeleting() ? 'erased' : 'deleted',
|
|
));
|
|
|
|
if (method_exists(static::class, 'restored')) {
|
|
static::restored(fn (Model $model) => $model->recordRevision('restored'));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return MorphMany<Revision, $this>
|
|
*/
|
|
public function revisions(): MorphMany
|
|
{
|
|
// Ordered by id as well as by time: two revisions of the same
|
|
// record routinely land in the same second — create, then edit — and
|
|
// a timestamp alone leaves "latest" up to the database. SQLite and
|
|
// Postgres disagreed, which is how this was found.
|
|
return $this->morphMany(Revision::class, 'revisionable')
|
|
->orderByDesc('created_at')
|
|
->orderByDesc('id');
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed>|null $changes
|
|
*/
|
|
public function recordRevision(string $event, ?array $changes = null): void
|
|
{
|
|
$user = Auth::user();
|
|
|
|
$this->revisions()->create([
|
|
'user_id' => $user?->getAuthIdentifier(),
|
|
|
|
// Kept alongside the id because people leave and accounts get
|
|
// deleted, and "somebody published this" is worth less than a name.
|
|
'user_name' => $user?->name,
|
|
|
|
'event' => $event,
|
|
'changeset' => $changes,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* What changed on this save, old value beside new.
|
|
*
|
|
* @return array<string, array{old: mixed, new: mixed}>
|
|
*/
|
|
public function revisionChanges(): array
|
|
{
|
|
$changes = [];
|
|
|
|
foreach ($this->getChanges() as $attribute => $new) {
|
|
if (in_array($attribute, $this->revisionIgnores, true)) {
|
|
continue;
|
|
}
|
|
|
|
$old = $this->getOriginal($attribute);
|
|
|
|
// Casts turn these into objects and enums; the history is read as
|
|
// text, and JSON is the only shape both ends agree on.
|
|
$changes[$attribute] = [
|
|
'old' => $this->revisionValue($old),
|
|
'new' => $this->revisionValue($new),
|
|
];
|
|
}
|
|
|
|
return $changes;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, array{old: mixed, new: mixed}> $changes
|
|
*/
|
|
protected function revisionEvent(array $changes): string
|
|
{
|
|
if (! array_key_exists('published_at', $changes) && ! array_key_exists('status', $changes)) {
|
|
return 'updated';
|
|
}
|
|
|
|
// Ask the model itself: "published" means different things to a page
|
|
// with a status and a project with only a date.
|
|
if (method_exists($this, 'isPublishedNow')) {
|
|
return $this->isPublishedNow() ? 'published' : 'unpublished';
|
|
}
|
|
|
|
return 'updated';
|
|
}
|
|
|
|
protected function revisionValue(mixed $value): mixed
|
|
{
|
|
return match (true) {
|
|
$value instanceof \BackedEnum => $value->value,
|
|
$value instanceof \DateTimeInterface => $value->format('c'),
|
|
is_object($value) => json_decode((string) json_encode($value), true),
|
|
is_string($value) && str_starts_with($value, '{') => json_decode($value, true) ?? $value,
|
|
default => $value,
|
|
};
|
|
}
|
|
}
|