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:
2026-08-19 17:55:27 +02:00
commit 3897113313
534 changed files with 64050 additions and 0 deletions

View 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,
};
}
}