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:
252
app/Models/Concerns/HasReviews.php
Normal file
252
app/Models/Concerns/HasReviews.php
Normal 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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user