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>
155 lines
5.1 KiB
PHP
155 lines
5.1 KiB
PHP
<?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');
|
|
}
|
|
}
|