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:
29
app/Models/Concerns/BumpsContentVersion.php
Normal file
29
app/Models/Concerns/BumpsContentVersion.php
Normal 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);
|
||||
}
|
||||
}
|
||||
50
app/Models/Concerns/HasContentViews.php
Normal file
50
app/Models/Concerns/HasContentViews.php
Normal 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
54
app/Models/Concerns/HasImages.php
Normal file
54
app/Models/Concerns/HasImages.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
63
app/Models/Concerns/HasPublicationWindow.php
Normal file
63
app/Models/Concerns/HasPublicationWindow.php
Normal 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());
|
||||
}
|
||||
}
|
||||
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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
93
app/Models/Concerns/RecordsSlugChanges.php
Normal file
93
app/Models/Concerns/RecordsSlugChanges.php
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user