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