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:
154
app/Filament/Concerns/WarnsOfConcurrentEditing.php
Normal file
154
app/Filament/Concerns/WarnsOfConcurrentEditing.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Concerns;
|
||||
|
||||
use App\Support\Presence;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Support\Exceptions\Halt;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Attributes\Locked;
|
||||
|
||||
/**
|
||||
* Two people can open the same record's edit screen at once, and nothing in
|
||||
* Filament's own EditRecord page says so. This is the two things that fact
|
||||
* calls for.
|
||||
*
|
||||
* The soft warning: whoever opens the screen second is told, right away, who
|
||||
* else already has it open. Only ever shown to the person arriving later —
|
||||
* the first person has no reason to expect company, and there is nothing this
|
||||
* screen could tell them that would help.
|
||||
*
|
||||
* The hard guard: whoever *saves* second is refused, if the record changed
|
||||
* underneath them since their screen was opened. This is the one that
|
||||
* actually prevents losing work, and it does not depend on the warning above
|
||||
* having been seen or even shown — a closed tab, a second monitor, a
|
||||
* teammate ten minutes ago all end up caught the same way, because it checks
|
||||
* the database at the moment of saving rather than trusting what the warning
|
||||
* said earlier.
|
||||
*
|
||||
* Applied to every Edit page rather than picked per resource: a collision is
|
||||
* a property of two people and one record, not of what the record happens to
|
||||
* be.
|
||||
*/
|
||||
trait WarnsOfConcurrentEditing
|
||||
{
|
||||
/**
|
||||
* `updated_at` as read straight from the database when this screen was
|
||||
* opened — not through the model, so a cast or a mutator cannot make two
|
||||
* identical instants compare unequal.
|
||||
*
|
||||
* Public and Locked, both on purpose and for different reasons. Public
|
||||
* because Livewire only carries public properties across requests — a
|
||||
* protected one resets to null on every hydration, which silently
|
||||
* disables the whole guard. Locked because a plain public property can be
|
||||
* overwritten by whatever a request claims it should be; the one thing
|
||||
* this value must never be is something the browser gets to supply.
|
||||
*/
|
||||
#[Locked]
|
||||
public ?string $loadedUpdatedAt = null;
|
||||
|
||||
public function mount(int|string $record): void
|
||||
{
|
||||
parent::mount($record);
|
||||
|
||||
$this->loadedUpdatedAt = $this->currentUpdatedAt();
|
||||
|
||||
$this->warnOfOtherEditors();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
protected function handleRecordUpdate(Model $record, array $data): Model
|
||||
{
|
||||
$current = $this->currentUpdatedAt();
|
||||
|
||||
if ($this->loadedUpdatedAt !== null && $current !== null && $current !== $this->loadedUpdatedAt) {
|
||||
$this->warnOfConflict();
|
||||
|
||||
throw (new Halt)->rollBackDatabaseTransaction();
|
||||
}
|
||||
|
||||
$record = parent::handleRecordUpdate($record, $data);
|
||||
|
||||
// What was just written is the new baseline: saving again without
|
||||
// reloading is judged against what this screen itself just wrote,
|
||||
// not against the moment it was first opened.
|
||||
$this->loadedUpdatedAt = $this->currentUpdatedAt();
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
protected function warnOfOtherEditors(): void
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if ($user === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$others = Presence::touch($this->getRecord(), $user);
|
||||
|
||||
if ($others->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->warning()
|
||||
->title(__('panel.presence.title'))
|
||||
->body(__('panel.presence.body', ['names' => $others->pluck('name')->implode(', ')]))
|
||||
->persistent()
|
||||
->send();
|
||||
}
|
||||
|
||||
protected function warnOfConflict(): void
|
||||
{
|
||||
Notification::make()
|
||||
->danger()
|
||||
->title(__('panel.concurrency.title'))
|
||||
->body($this->conflictMessage())
|
||||
->persistent()
|
||||
->send();
|
||||
}
|
||||
|
||||
/**
|
||||
* Named when the history knows who — which is most of the time, since
|
||||
* every content model records who changed what. Without a name the
|
||||
* message still says everything that matters: it changed, and saving now
|
||||
* would have erased that.
|
||||
*/
|
||||
protected function conflictMessage(): string
|
||||
{
|
||||
$record = $this->getRecord();
|
||||
|
||||
$author = method_exists($record, 'revisions')
|
||||
? $record->revisions()->value('user_name')
|
||||
: null;
|
||||
|
||||
return $author !== null
|
||||
? __('panel.concurrency.body_with_author', ['name' => $author])
|
||||
: __('panel.concurrency.body');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read straight from the table: one column, no relations, no casts to
|
||||
* disagree about, and no risk of a stale in-memory copy answering for the
|
||||
* database.
|
||||
*
|
||||
* Precision is whole seconds, which is what `timestamps()` stores here —
|
||||
* two saves inside the same second are a genuine race even in
|
||||
* production, and this guard is honest about not catching that one.
|
||||
*/
|
||||
protected function currentUpdatedAt(): ?string
|
||||
{
|
||||
$record = $this->getRecord();
|
||||
|
||||
return DB::table($record->getTable())
|
||||
->where($record->getKeyName(), $record->getKey())
|
||||
->value('updated_at');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user