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,29 @@
<?php
declare(strict_types=1);
namespace App\Models\Concerns;
use App\Support\ContentVersion;
use Illuminate\Database\Eloquent\Model;
/**
* Moves the content version on when something a visitor can see changes.
*
* Applied to the models the public API serves, so the frontend's cache can
* expire the moment the site really changed rather than when a timer says so.
*/
trait BumpsContentVersion
{
public static function bootBumpsContentVersion(): void
{
$bump = function (Model $model): void {
if (ContentVersion::shouldBumpFor($model)) {
ContentVersion::bump();
}
};
static::saved($bump);
static::deleted($bump);
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Models\Concerns;
use App\Models\ContentView;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\MorphMany;
/**
* A record the public site can be reading.
*
* The counts are a sum rather than a column on the record, so nothing has to
* be kept in step and no write path can forget to increment anything. What
* costs attention is reading them in a table, which is what the scope below is
* for: one grouped query for the whole page of rows instead of one per row.
*/
trait HasContentViews
{
/**
* @return MorphMany<ContentView, $this>
*/
public function views(): MorphMany
{
return $this->morphMany(ContentView::class, 'viewable');
}
/**
* How often this was read lately, in every language at once.
*/
public function viewsSince(int $days = 30): int
{
return (int) $this->views()->since($days)->sum('count');
}
/**
* Attach that number to a query, for listings.
*
* @param Builder<static> $query
* @return Builder<static>
*/
public function scopeWithRecentViews(Builder $query, int $days = 30): Builder
{
return $query->withSum(
['views as recent_views' => fn ($views) => $views->since($days)],
'count',
);
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Models\Concerns;
use App\Models\Media;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Keeps the media table in step with the image paths a record holds.
*
* The panel still uploads a file and stores its path, exactly as before; this
* is what turns that path into something the frontend can render properly.
* Models declare which of their attributes are images and expose an ordinary
* `belongsTo` relation for each, so a listing can eager-load them instead of
* asking once per row.
*/
trait HasImages
{
/**
* Attributes holding the path of an uploaded file.
*
* @return array<int, string>
*/
abstract public function imageAttributes(): array;
public static function bootHasImages(): void
{
static::saved(function (Model $model): void {
foreach ($model->imageAttributes() as $attribute) {
// Only on the way in or on a change: an unrelated save must
// not queue work for an image nobody touched.
if ($model->wasChanged($attribute) || $model->wasRecentlyCreated) {
Media::for($model->getAttribute($attribute));
}
}
});
}
/**
* Relation definition shared by every image attribute.
*
* The join is on the stored path rather than on an id, because the file is
* the identity: two records pointing at one upload share its dimensions
* and its derived versions instead of each deriving them again.
*/
protected function imageRelation(string $attribute): BelongsTo
{
return $this->belongsTo(Media::class, $attribute, 'path')
->where('media.disk', (string) config('media.disk', 'public'));
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Models\Concerns;
use Illuminate\Database\Eloquent\Builder;
/**
* A record that is only true between two dates.
*
* The window is enforced by each model's `published` scope, so nothing that
* reads public content has to remember it. What lives here are the questions
* the panel asks about that window — is this waiting for its date, has it
* already passed — because a panel that shows "Published" for something the
* site is not showing is lying to the person who wrote it.
*/
trait HasPublicationWindow
{
/**
* Waiting for its date to arrive.
*/
public function isScheduled(): bool
{
return $this->published_at !== null && $this->published_at->isFuture();
}
/**
* Past its end date, and therefore off the site.
*/
public function hasExpired(): bool
{
return $this->unpublish_at !== null && $this->unpublish_at->isPast();
}
/**
* About to go, so it can be flagged before somebody notices it vanished.
*/
public function expiresSoon(int $days = 7): bool
{
return $this->unpublish_at !== null
&& $this->unpublish_at->isFuture()
&& $this->unpublish_at->lt(now()->addDays($days));
}
/**
* @param Builder<static> $query
* @return Builder<static>
*/
public function scopeScheduled(Builder $query): Builder
{
return $query->whereNotNull('published_at')->where('published_at', '>', now());
}
/**
* @param Builder<static> $query
* @return Builder<static>
*/
public function scopeExpired(Builder $query): Builder
{
return $query->whereNotNull('unpublish_at')->where('unpublish_at', '<=', now());
}
}

View File

@@ -0,0 +1,252 @@
<?php
declare(strict_types=1);
namespace App\Models\Concerns;
use App\Enums\ReviewEvent;
use App\Models\ReviewComment;
use App\Models\User;
use App\Notifications\ReviewRequested;
use App\Notifications\ReviewReturned;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Notification as NotificationFacade;
use Throwable;
/**
* Handing work to somebody who is allowed to publish it.
*
* Used by the three models whose `publish` ability is a permission of its own.
* That separation is the whole reason this exists: it means there are people
* who can finish a piece of work and cannot put it on the site, and until now
* they had nowhere to say they were done — the status field that would have
* said it is disabled for precisely those users.
*
* `review_requested_at` is the state, and it is the only state: a review is
* either waiting for somebody or it is not. Pages also carry a `Review` value
* in their status enum, which predates this and is shown in tables and
* filters, so `syncReviewStatus()` keeps it in step — the write happens here
* and only here, so the two cannot drift.
*/
trait HasReviews
{
/**
* These change as a side effect of asking and answering, and the thread
* already records both in words. Leaving them in the change history would
* bury the edits somebody actually came looking for.
*/
public function initializeHasReviews(): void
{
// Merged rather than declared on each model: three copies of the same
// cast is three chances for one of them to be forgotten, and the
// symptom — a string where a date was expected — only appears in
// whichever screen reads it first.
$this->mergeCasts(['review_requested_at' => 'datetime']);
$this->revisionIgnores = [
...$this->revisionIgnores,
'review_requested_at', 'review_requested_by', 'reviewer_id',
];
}
public static function bootHasReviews(): void
{
static::updated(function (Model $model): void {
// Publishing is the answer a review was waiting for, whichever
// route it arrived by — the form, the table's publish button, the
// API. Hooked on the state rather than on the button so none of
// them can leave a record live and still marked as waiting.
if ($model->isAwaitingReview() && $model->isPublishedNow()) {
$model->closeReview();
}
});
}
/**
* @return MorphMany<ReviewComment, $this>
*/
public function reviewComments(): MorphMany
{
// By id as well as by time: two entries of the same thread routinely
// land in the same second, and a timestamp alone leaves the order up
// to the database.
return $this->morphMany(ReviewComment::class, 'reviewable')
->orderByDesc('created_at')
->orderByDesc('id');
}
/**
* @return BelongsTo<User, $this>
*/
public function reviewRequester(): BelongsTo
{
return $this->belongsTo(User::class, 'review_requested_by');
}
/**
* @return BelongsTo<User, $this>
*/
public function reviewer(): BelongsTo
{
return $this->belongsTo(User::class, 'reviewer_id');
}
public function isAwaitingReview(): bool
{
return $this->review_requested_at !== null;
}
/**
* Ask for a look. The reviewer is optional: naming one addresses the
* request, leaving it empty offers it to everybody who could publish.
*/
public function requestReview(?User $reviewer = null, ?string $note = null, ?User $by = null): void
{
$by ??= Auth::user();
$this->forceFill([
'review_requested_at' => now(),
'review_requested_by' => $by?->getAuthIdentifier(),
'reviewer_id' => $reviewer?->getAuthIdentifier(),
]);
$this->syncReviewStatus(true);
$this->save();
$this->recordReviewEvent(ReviewEvent::Requested, $note, $by);
$this->announce(
ReviewRequested::recipients($this),
new ReviewRequested($this, $note, $by?->name),
);
}
/**
* Send it back, with a reason. The reason is not optional anywhere this is
* called from: work returned without one is the thing that makes a review
* step resented rather than used.
*/
public function returnForChanges(string $note, ?User $by = null): void
{
$by ??= Auth::user();
$author = $this->reviewRequester()->first();
$this->forceFill([
'review_requested_at' => null,
'reviewer_id' => null,
]);
$this->syncReviewStatus(false);
$this->save();
$this->recordReviewEvent(ReviewEvent::Returned, $note, $by);
// Told to the person who asked, and not to whoever else could have
// reviewed it: a queue that announces every decision to everybody
// becomes a bell people stop reading.
if ($author !== null && $author->isNot($by)) {
$this->announce(collect([$author]), new ReviewReturned($this, $note, $by?->name));
}
}
/**
* The review is over because the thing is live.
*/
public function closeReview(?User $by = null): void
{
$this->forceFill([
'review_requested_at' => null,
'reviewer_id' => null,
]);
$this->saveQuietly();
$this->recordReviewEvent(ReviewEvent::Published, null, $by ?? Auth::user());
}
public function comment(string $body, ?User $by = null): ReviewComment
{
return $this->recordReviewEvent(ReviewEvent::Commented, $body, $by ?? Auth::user());
}
/**
* Tell the people who need to know, without letting that failure undo
* anything. The state change is already saved and is the part that
* matters; a bell that did not ring is a smaller problem than a handover
* that silently did not happen.
*
* @param Collection<int, User> $recipients
*/
protected function announce(Collection $recipients, Notification $notification): void
{
if ($recipients->isEmpty()) {
return;
}
try {
NotificationFacade::send($recipients, $notification);
} catch (Throwable $e) {
report($e);
}
}
protected function recordReviewEvent(ReviewEvent $event, ?string $body, ?User $by): ReviewComment
{
return $this->reviewComments()->create([
'user_id' => $by?->getAuthIdentifier(),
// Kept beside the id because people leave and accounts get
// deleted, and "somebody sent this back" is worth less than a name.
'user_name' => $by?->name,
'event' => $event,
'body' => $body,
]);
}
/**
* Mirror the review onto a model that also carries a status enum.
*
* Overridden by pages, whose status has said `Review` since before any of
* this existed. Projects and services have no such value — a project's
* status is its lifecycle, not its visibility — so for them this is
* nothing, which is the right answer rather than a missing feature.
*/
protected function syncReviewStatus(bool $awaiting): void {}
/**
* @param Builder<static> $query
* @return Builder<static>
*/
public function scopeAwaitingReview(Builder $query): Builder
{
return $query->whereNotNull('review_requested_at');
}
/**
* Waiting for this person specifically, or waiting for anybody.
*
* An unaddressed request is everybody's, which is deliberate: a queue
* addressed to nobody is still better than one addressed to whoever the
* author guessed was free.
*
* @param Builder<static> $query
* @return Builder<static>
*/
public function scopeAwaitingReviewBy(Builder $query, User $user): Builder
{
return $query->awaitingReview()->where(
fn (Builder $scope) => $scope
->whereNull('reviewer_id')
->orWhere('reviewer_id', $user->getAuthIdentifier()),
);
}
}

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

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Models\Concerns;
use App\Models\Redirect;
use App\Support\SiteUrls;
use Illuminate\Database\Eloquent\Model;
/**
* Leaves a redirect behind when a slug changes.
*
* Renaming a slug breaks every link anybody shared, every bookmark and every
* search result — silently, because nothing notices and the frontend only
* finds out when a visitor gets a 404. The moment the slug changes is the only
* moment the old address is still known, so that is when the note is written.
*/
trait RecordsSlugChanges
{
/**
* The `site.paths` key this model publishes under.
*/
abstract public function publicPathType(): string;
public static function bootRecordsSlugChanges(): void
{
static::updated(function (Model $model): void {
if (! $model->wasChanged('slug')) {
return;
}
$model->recordSlugChange();
});
}
protected function recordSlugChange(): void
{
$type = $this->publicPathType();
$previous = (array) json_decode((string) json_encode($this->getOriginal('slug')), true);
$current = (array) $this->getTranslations('slug');
foreach (array_keys((array) config('locales.supported')) as $locale) {
$was = (string) ($previous[$locale] ?? '');
$now = (string) ($current[$locale] ?? '');
// Nothing to redirect from when the record had no address in this
// language, and nowhere to redirect to when it no longer has one.
if (blank($was) || blank($now) || $was === $now) {
continue;
}
$from = Redirect::normalise(SiteUrls::path($type, $locale, $was));
$to = Redirect::normalise(SiteUrls::path($type, $locale, $now));
if ($from === $to) {
continue;
}
$this->writeRedirect($from, $to, $locale);
}
}
protected function writeRedirect(string $from, string $to, string $locale): void
{
$scope = fn () => Redirect::query()->where('locale', $locale);
// The new address resolves to this record again, so any rule pointing
// away from it is now wrong — this is what renaming something back to
// its original slug looks like.
$scope()->where('from', $to)->delete();
// Whoever pointed at the old address now points at the new one, so a
// frontend never has to follow two hops.
$scope()->where('to', $from)->update(['to' => $to]);
$redirect = Redirect::query()->firstOrNew(['from' => $from, 'locale' => $locale]);
// `source` is set here rather than through mass assignment: it records
// what the application did, not anything somebody submitted.
$redirect->forceFill([
'to' => $to,
'status' => 301,
'is_enabled' => true,
'source_type' => $this->getMorphClass(),
'source_id' => $this->getKey(),
])->save();
// Belt and braces: a rule pointing at its own address would send a
// browser round for ever.
Redirect::query()->whereColumn('from', 'to')->delete();
}
}