Initial commit: Pireon Admin — headless admin panel and public API
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>
This commit is contained in:
145
app/Models/Concerns/HasRevisions.php
Normal file
145
app/Models/Concerns/HasRevisions.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user