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:
2026-08-19 17:55:27 +02:00
commit 3897113313
534 changed files with 64050 additions and 0 deletions

View File

@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Models\Revision;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
/**
* Which translations have fallen behind the language they were made from.
*
* The existing check finds the hole — published in Spanish, absent in Catalan.
* This finds the subtler one, which is also the one that actually happens: the
* Catalan text is *there*, so nothing complains, and it has been saying
* something the Spanish stopped saying two months ago. A translation nobody
* knows is out of date is worse than one that is missing, because the missing
* one is at least honest about it.
*
* Worked out from the change history rather than from a column, because the
* history already knows: every revision records the whole locale map of the
* field it touched, before and after, so "when did the Spanish body last
* change, and when did the Catalan" is a question it can already answer. A
* column would be a second copy of that, kept in step by hand.
*/
final class TranslationFreshness
{
/**
* Locales whose translation is older than the source text, mapped to when
* the source moved ahead of them.
*
* Cached against the record's `updated_at`, so the answer is recomputed
* the moment anything about the record changes and never before. The
* panel reads this once per row of a listing.
*
* @return array<string, CarbonInterface>
*/
public static function for(Model $record): array
{
return Cache::remember(
'translation-freshness:'.$record->getMorphClass().':'.$record->getKey()
.':'.($record->updated_at?->getTimestamp() ?? 0),
now()->addHour(),
fn () => self::compute($record),
);
}
/**
* The ids of the records in a query whose translations have fallen behind.
*
* For the panel's filter. This cannot be expressed as a condition — the
* answer lives in the change history, one record at a time — so it is
* worked out here and handed to the query as a list, the same way the
* media screen filters on files that are no longer on disk.
*
* @param Builder<covariant Model> $query
* @return array<int, int|string>
*/
public static function staleIds(Builder $query): array
{
return $query
->with('revisions')
->get()
->filter(fn (Model $record) => self::for($record) !== [])
->modelKeys();
}
/**
* @return array<string, CarbonInterface>
*/
protected static function compute(Model $record): array
{
$source = (string) config('locales.default');
$fields = self::fields($record);
if ($fields === []) {
return [];
}
$touched = self::lastTouched($record, $fields);
$stale = [];
foreach (array_keys((array) config('locales.supported')) as $locale) {
if ($locale === $source) {
continue;
}
foreach ($fields as $field) {
// A language with nothing in this field is not out of date, it
// is untranslated — which the other check already reports, and
// saying both about the same record helps nobody.
if (blank($record->getTranslation($field, $locale, false))) {
continue;
}
$sourceAt = $touched[$field][$source] ?? null;
if ($sourceAt === null) {
continue;
}
// Never edited on its own since the record was created, so the
// day it was written is the day it was last true.
$translatedAt = $touched[$field][$locale] ?? $record->created_at;
if ($translatedAt !== null && $sourceAt->gt($translatedAt)) {
// The oldest complaint wins: it says how long this has
// been drifting, which is the part worth acting on.
$stale[$locale] = isset($stale[$locale]) && $stale[$locale]->lt($sourceAt)
? $stale[$locale]
: $sourceAt;
}
}
}
return $stale;
}
/**
* When each field last changed, in each language.
*
* @param array<int, string> $fields
* @return array<string, array<string, CarbonInterface>>
*/
protected static function lastTouched(Model $record, array $fields): array
{
$touched = [];
// Oldest first, so the last write wins without comparing timestamps.
$revisions = $record->relationLoaded('revisions')
? $record->getRelation('revisions')->sortBy('id')
: $record->revisions()->reorder()->orderBy('id')->get();
foreach ($revisions as $revision) {
/** @var Revision $revision */
foreach ($fields as $field) {
$change = $revision->changeset[$field] ?? null;
if (! is_array($change)) {
continue;
}
$before = (array) ($change['old'] ?? []);
$after = (array) ($change['new'] ?? []);
foreach (array_keys((array) config('locales.supported')) as $locale) {
if (($before[$locale] ?? null) !== ($after[$locale] ?? null)) {
$touched[$field][$locale] = $revision->created_at;
}
}
}
}
return $touched;
}
/**
* The translatable fields worth comparing.
*
* `slug` is left out deliberately: renaming the Spanish address does not
* make the Catalan text any less true, and it is the one translatable
* field that routinely changes on its own — counting it would mark half
* the site out of date the first time somebody tidied a URL.
*
* @return array<int, string>
*/
protected static function fields(Model $record): array
{
return array_values(array_diff(
method_exists($record, 'getTranslatableAttributes')
? $record->getTranslatableAttributes()
: [],
['slug'],
));
}
}