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:
73
app/Support/Presence.php
Normal file
73
app/Support/Presence.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Who else has a record's edit screen open right now.
|
||||
*
|
||||
* Advisory, not authoritative — there is no signal at all for "closed the
|
||||
* tab", so presence really means "opened this within the last few minutes",
|
||||
* which fades out on its own once nobody touches the screen again. That
|
||||
* heuristic is the right shape for the problem: knowing that two people are
|
||||
* mid-edit matters; an exact headcount does not.
|
||||
*/
|
||||
final class Presence
|
||||
{
|
||||
/**
|
||||
* How long somebody stays "here" after they were last seen. Long enough
|
||||
* to survive reading a long field without typing; short enough that a
|
||||
* closed tab does not haunt a record for the rest of the day.
|
||||
*/
|
||||
protected const TTL_MINUTES = 10;
|
||||
|
||||
/**
|
||||
* Record this user as here now, and answer who else already was.
|
||||
*
|
||||
* Reading and writing happen in one call because that is the one moment
|
||||
* presence is ever needed: opening the screen. There is no separate
|
||||
* "who is here" query anywhere else, so there is nothing for the two to
|
||||
* disagree about.
|
||||
*
|
||||
* @return Collection<int, User>
|
||||
*/
|
||||
public static function touch(Model $record, User $user): Collection
|
||||
{
|
||||
$key = self::key($record);
|
||||
$cutoff = now()->subMinutes(self::TTL_MINUTES)->timestamp;
|
||||
|
||||
$present = collect(self::entries($key))
|
||||
->filter(fn (int $seenAt) => $seenAt >= $cutoff);
|
||||
|
||||
// Answered before this visit is recorded: nobody is ever told they
|
||||
// are keeping themselves company.
|
||||
$others = $present->keys()->reject(fn (int $id) => $id === $user->getKey());
|
||||
|
||||
$present[$user->getKey()] = now()->timestamp;
|
||||
|
||||
Cache::put($key, $present->all(), now()->addMinutes(self::TTL_MINUTES));
|
||||
|
||||
return $others->isEmpty()
|
||||
? collect()
|
||||
: User::query()->whereKey($others)->get();
|
||||
}
|
||||
|
||||
protected static function key(Model $record): string
|
||||
{
|
||||
return 'presence:'.$record->getMorphClass().':'.$record->getKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
protected static function entries(string $key): array
|
||||
{
|
||||
return (array) Cache::get($key, []);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user