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:
82
app/Support/ContentTypes.php
Normal file
82
app/Support/ContentTypes.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Call;
|
||||
use App\Models\Member;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* The names the frontend uses for things, and what they are here.
|
||||
*
|
||||
* The keys are the ones already in `config('site.paths')`, because that is the
|
||||
* vocabulary the public API and the frontend have always shared: a URL is
|
||||
* built from a type, a locale and a slug, so a report about a URL arrives in
|
||||
* the same three parts.
|
||||
*/
|
||||
final class ContentTypes
|
||||
{
|
||||
/**
|
||||
* @var array<string, class-string<Model>>
|
||||
*/
|
||||
protected const MODELS = [
|
||||
'page' => Page::class,
|
||||
'project' => Project::class,
|
||||
'member' => Member::class,
|
||||
'call' => Call::class,
|
||||
'service' => Service::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function keys(): array
|
||||
{
|
||||
return array_keys(self::MODELS);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return class-string<Model>|null
|
||||
*/
|
||||
public static function model(string $type): ?string
|
||||
{
|
||||
return self::MODELS[$type] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What to call a record on screen: its own title in the language being
|
||||
* read, falling back to any language it does have. A notification or a
|
||||
* dashboard row that says "Untitled" helps nobody decide whether to open
|
||||
* it, and a record with a title in only one language is normal here.
|
||||
*/
|
||||
public static function title(Model $record): string
|
||||
{
|
||||
$title = $record->getTranslation('title', app()->getLocale(), false)
|
||||
?: collect((array) $record->getTranslations('title'))->filter()->first();
|
||||
|
||||
return (string) ($title ?: __('panel.untitled'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The record a type and a slug refer to, in one language.
|
||||
*
|
||||
* Null when nothing matches, which is not an error: a slug that stopped
|
||||
* resolving is a 404, and there is already an endpoint whose whole job is
|
||||
* to hear about those.
|
||||
*/
|
||||
public static function resolve(string $type, string $slug, string $locale): ?Model
|
||||
{
|
||||
$model = self::model($type);
|
||||
|
||||
if ($model === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $model::query()->where("slug->{$locale}", $slug)->first();
|
||||
}
|
||||
}
|
||||
57
app/Support/ContentVersion.php
Normal file
57
app/Support/ContentVersion.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* A number that changes whenever published content does.
|
||||
*
|
||||
* The public API is cached with `s-maxage` and a long
|
||||
* `stale-while-revalidate`, which is right for an SSR frontend and wrong for
|
||||
* an urgent correction: a fix can sit behind a stale copy for minutes. The
|
||||
* frontend cannot know something changed without asking, and asking is the
|
||||
* thing the cache exists to avoid.
|
||||
*
|
||||
* So it is told. `/site` carries this number, the frontend keeps it as part of
|
||||
* its cache key, and the moment it moves everything downstream is stale by
|
||||
* definition — no purge webhook, no shared secret, nothing to deploy.
|
||||
*/
|
||||
final class ContentVersion
|
||||
{
|
||||
public const KEY = 'content.version';
|
||||
|
||||
public static function current(): int
|
||||
{
|
||||
return (int) Setting::get(self::KEY, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move it on. Cheap on purpose: this runs on every content save.
|
||||
*/
|
||||
public static function bump(): void
|
||||
{
|
||||
Setting::set(self::KEY, self::current() + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a change to this record is one a visitor could notice.
|
||||
*
|
||||
* A draft nobody can see moving does not invalidate anybody's cache, and
|
||||
* bumping on every keystroke of a half-written page would make the number
|
||||
* meaningless.
|
||||
*/
|
||||
public static function shouldBumpFor(Model $record): bool
|
||||
{
|
||||
if (method_exists($record, 'isPublishedNow')) {
|
||||
// Either it is on the site now, or it was a moment ago — going
|
||||
// away is exactly as visible as arriving.
|
||||
return $record->isPublishedNow() || (bool) $record->getOriginal('published_at');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
121
app/Support/DuplicateTitles.php
Normal file
121
app/Support/DuplicateTitles.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* A warning, not a rule: something that already exists under almost the same
|
||||
* name.
|
||||
*
|
||||
* The slug already has a hard uniqueness rule — two records cannot answer at
|
||||
* the same address. This catches what that rule cannot: two records with
|
||||
* different addresses and nearly the same title, which is what actually
|
||||
* happens when nobody remembers a project already exists and starts a second
|
||||
* one. A soft nudge on the way past is worth more here than a validation rule
|
||||
* would be — sometimes the second one really is a different thing that
|
||||
* happens to be called almost the same.
|
||||
*/
|
||||
final class DuplicateTitles
|
||||
{
|
||||
/**
|
||||
* Below this, two titles are merely both about similar things — not the
|
||||
* near-duplicate anybody would benefit from being told about.
|
||||
*/
|
||||
protected const THRESHOLD = 80.0;
|
||||
|
||||
/**
|
||||
* Compute the match and warn about it in one call — the only way this is
|
||||
* ever used, from the title field's `afterStateUpdated` on three forms.
|
||||
*
|
||||
* @param class-string<Model> $model
|
||||
* @param class-string<resource> $resource
|
||||
*/
|
||||
public static function warnIfSimilar(
|
||||
string $model,
|
||||
string $resource,
|
||||
?string $title,
|
||||
string $locale,
|
||||
int|string|null $excludeId,
|
||||
): void {
|
||||
$matches = self::similarTo($model, (string) $title, $locale, $excludeId);
|
||||
|
||||
if ($matches->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->warning()
|
||||
->title(__('panel.duplicates.title'))
|
||||
->body(__('panel.duplicates.body', [
|
||||
'titles' => $matches
|
||||
->map(fn (Model $match) => (string) $match->getTranslation('title', $locale, false))
|
||||
->implode(' · '),
|
||||
]))
|
||||
->actions([
|
||||
Action::make('open')
|
||||
->label(__('panel.duplicates.open'))
|
||||
->url($resource::getUrl('edit', ['record' => $matches->first()]))
|
||||
->openUrlInNewTab(),
|
||||
])
|
||||
->send();
|
||||
}
|
||||
|
||||
/**
|
||||
* The closest few existing records whose title, in this language, reads
|
||||
* almost the same as this one.
|
||||
*
|
||||
* Compared in PHP over every record of the type rather than in SQL:
|
||||
* there is no "how alike are these two strings" operator that both
|
||||
* SQLite and Postgres agree on, and at the scale a collective's content
|
||||
* actually reaches — dozens to a few hundred records of one type — the
|
||||
* whole table fits in memory without anyone noticing the cost. This runs
|
||||
* once per blur of a title field, not in a loop or a background job.
|
||||
*
|
||||
* @param class-string<Model> $model
|
||||
* @return Collection<int, Model>
|
||||
*/
|
||||
public static function similarTo(string $model, string $title, string $locale, int|string|null $excludeId): Collection
|
||||
{
|
||||
$title = trim($title);
|
||||
|
||||
if ($title === '') {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return $model::query()
|
||||
->when($excludeId !== null, fn ($query) => $query->whereKeyNot($excludeId))
|
||||
->get(['id', 'title'])
|
||||
->map(fn (Model $record) => [
|
||||
$record,
|
||||
self::similarity($title, (string) $record->getTranslation('title', $locale, false)),
|
||||
])
|
||||
->filter(fn (array $pair) => $pair[1] >= self::THRESHOLD)
|
||||
->sortByDesc(fn (array $pair) => $pair[1])
|
||||
->take(3)
|
||||
->map(fn (array $pair) => $pair[0])
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Percentage of characters in common, case-insensitively. Blunt on
|
||||
* purpose — this only ever has to catch "basically the same words",
|
||||
* not near-misses a real spell-checker would.
|
||||
*/
|
||||
protected static function similarity(string $a, string $b): float
|
||||
{
|
||||
if ($b === '') {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
similar_text(mb_strtolower($a), mb_strtolower($b), $percent);
|
||||
|
||||
return $percent;
|
||||
}
|
||||
}
|
||||
25
app/Support/Health/Check.php
Normal file
25
app/Support/Health/Check.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health;
|
||||
|
||||
interface Check
|
||||
{
|
||||
/**
|
||||
* Key used for translations and for identifying the check on the command
|
||||
* line. Kebab-case.
|
||||
*/
|
||||
public function key(): string;
|
||||
|
||||
/**
|
||||
* Permission a user must hold to be shown this result, or null when the
|
||||
* result reveals nothing that needs guarding.
|
||||
*
|
||||
* The inbox check counts people waiting for a reply, and who may know that
|
||||
* is the same question as who may read the inbox.
|
||||
*/
|
||||
public function permission(): ?string;
|
||||
|
||||
public function run(): Finding;
|
||||
}
|
||||
51
app/Support/Health/Checks/BrokenImages.php
Normal file
51
app/Support/Health/Checks/BrokenImages.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Models\Media;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
use App\Support\Health\Severity;
|
||||
|
||||
/**
|
||||
* Images the site is publishing a URL for that no longer exist.
|
||||
*
|
||||
* The API keeps serving the path — it has no reason to doubt it — so the
|
||||
* result is a broken image on the public site and nothing at all in the panel,
|
||||
* where the record still looks complete. A file goes missing when a disk is
|
||||
* changed, a deploy wipes storage, or somebody tidies up by hand.
|
||||
*
|
||||
* Failures recorded by the processor count here too: an image it could not
|
||||
* read is one no visitor will see either.
|
||||
*/
|
||||
class BrokenImages implements Check
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'broken-images';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$count = 0;
|
||||
$samples = [];
|
||||
|
||||
// Only what has been looked at: an image still queued is not broken,
|
||||
// it is early, and StalledMediaQueue is the check for that.
|
||||
foreach (Media::query()->whereNotNull('processed_at')->get() as $media) {
|
||||
if ($media->error !== null || ! $media->exists()) {
|
||||
$count++;
|
||||
$samples[] = $media->path;
|
||||
}
|
||||
}
|
||||
|
||||
return Finding::problem($this->key(), $count, Severity::Error, samples: $samples);
|
||||
}
|
||||
}
|
||||
71
app/Support/Health/Checks/BrokenRedirects.php
Normal file
71
app/Support/Health/Checks/BrokenRedirects.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Filament\Resources\Redirects\RedirectResource;
|
||||
use App\Models\Redirect;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
use App\Support\Health\Severity;
|
||||
|
||||
/**
|
||||
* Redirects that make things worse rather than better.
|
||||
*
|
||||
* Two ways a rule can be wrong, and a visitor experiences both as the site
|
||||
* being broken: one that points at its own address, which a browser follows
|
||||
* until it gives up, and one that points at another rule, which costs a round
|
||||
* trip per hop and eventually loops.
|
||||
*
|
||||
* The panel writes these itself when slugs change, so nobody is watching them
|
||||
* — which is exactly why they need watching.
|
||||
*/
|
||||
class BrokenRedirects implements Check
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'broken-redirects';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
return 'redirects.view_any';
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$redirects = Redirect::query()->enabled()->get(['id', 'from', 'to', 'locale']);
|
||||
|
||||
$targets = $redirects->groupBy('locale')->map(
|
||||
fn ($group) => $group->pluck('from')->all(),
|
||||
);
|
||||
|
||||
$broken = [];
|
||||
|
||||
foreach ($redirects as $redirect) {
|
||||
if ($redirect->isCircular()) {
|
||||
$broken[] = "{$redirect->from} → ".__('health.bits.itself');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// A rule whose destination is itself a rule: the visitor is sent
|
||||
// somewhere that will send them somewhere else.
|
||||
$sameLocale = $targets[$redirect->locale] ?? [];
|
||||
$everyLocale = $targets[null] ?? [];
|
||||
|
||||
if (in_array($redirect->to, [...$sameLocale, ...$everyLocale], true)) {
|
||||
$broken[] = "{$redirect->from} → {$redirect->to} →…";
|
||||
}
|
||||
}
|
||||
|
||||
return Finding::problem(
|
||||
$this->key(),
|
||||
count($broken),
|
||||
Severity::Error,
|
||||
url: RedirectResource::getUrl('index'),
|
||||
samples: $broken,
|
||||
);
|
||||
}
|
||||
}
|
||||
48
app/Support/Health/Checks/ClientsWithoutLogo.php
Normal file
48
app/Support/Health/Checks/ClientsWithoutLogo.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Filament\Resources\Clients\ClientResource;
|
||||
use App\Models\Client;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
|
||||
/**
|
||||
* Organisations on the wall of logos with no logo.
|
||||
*
|
||||
* The frontend has to render something for them, so it renders their name in
|
||||
* a row of images — a hole in the wall that looks like a mistake, because it
|
||||
* is one.
|
||||
*/
|
||||
class ClientsWithoutLogo implements Check
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'clients-without-logo';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
return 'clients.view_any';
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$clients = Client::query()
|
||||
->public()
|
||||
->whereNull('logo')
|
||||
// Only the ones the wall actually shows: a client with nothing
|
||||
// published is not on it in the first place.
|
||||
->whereHas('projects', fn ($q) => $q->published())
|
||||
->get(['id', 'name']);
|
||||
|
||||
return Finding::problem(
|
||||
$this->key(),
|
||||
$clients->count(),
|
||||
url: ClientResource::getUrl('index'),
|
||||
samples: $clients->pluck('name')->all(),
|
||||
);
|
||||
}
|
||||
}
|
||||
74
app/Support/Health/Checks/DuplicateSlugs.php
Normal file
74
app/Support/Health/Checks/DuplicateSlugs.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Models\Call;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
use App\Support\Health\Severity;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Two records answering at the same URL in the same language.
|
||||
*
|
||||
* `UniqueTranslatedSlug` prevents this on write, but a slug lives inside a
|
||||
* JSON column, so it can never be a database constraint — which means data
|
||||
* that arrived any other way (a seeder, an import, a manual fix) can still
|
||||
* collide. When it does, the public API resolves the URL to whichever row it
|
||||
* happens to reach first.
|
||||
*
|
||||
* Detected in PHP rather than with a GROUP BY on a JSON path, because that
|
||||
* expression is written differently in every database and these tables hold
|
||||
* hundreds of rows, not millions.
|
||||
*/
|
||||
class DuplicateSlugs implements Check
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'duplicate-slugs';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$locales = array_keys((array) config('locales.supported'));
|
||||
$samples = [];
|
||||
$count = 0;
|
||||
|
||||
foreach ([Page::class, Project::class, Call::class] as $model) {
|
||||
/** @var array<int, Model> $records */
|
||||
$records = $model::query()->get(['id', 'slug'])->all();
|
||||
|
||||
foreach ($locales as $locale) {
|
||||
$seen = [];
|
||||
|
||||
foreach ($records as $record) {
|
||||
$slug = $record->getTranslation('slug', $locale, false);
|
||||
|
||||
if (blank($slug)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($seen[$slug])) {
|
||||
$count++;
|
||||
$samples[] = "{$slug} ({$locale})";
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$seen[$slug] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Finding::problem($this->key(), $count, Severity::Error, samples: $samples);
|
||||
}
|
||||
}
|
||||
50
app/Support/Health/Checks/ExpiredCalls.php
Normal file
50
app/Support/Health/Checks/ExpiredCalls.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Enums\CallStatus;
|
||||
use App\Filament\Resources\Calls\CallResource;
|
||||
use App\Models\Call;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
|
||||
/**
|
||||
* Calls whose closing date has passed but which are still marked open.
|
||||
*
|
||||
* They already drop off the public site — the `open` scope respects the date —
|
||||
* so this is not a visitor-facing fault. It is a housekeeping one: the panel
|
||||
* shows a count of open calls in the sidebar, and a number padded with
|
||||
* openings nobody is filling is a number that stops being read.
|
||||
*/
|
||||
class ExpiredCalls implements Check
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'expired-calls';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
return 'calls.view_any';
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$expired = Call::query()
|
||||
->where('status', CallStatus::Open)
|
||||
->whereNotNull('closes_at')
|
||||
->where('closes_at', '<', now())
|
||||
->get(['id', 'title', 'closes_at']);
|
||||
|
||||
return Finding::problem(
|
||||
$this->key(),
|
||||
$expired->count(),
|
||||
url: CallResource::getUrl('index'),
|
||||
samples: $expired
|
||||
->map(fn (Call $call) => $call->getTranslation('title', (string) config('locales.default'), false))
|
||||
->all(),
|
||||
);
|
||||
}
|
||||
}
|
||||
71
app/Support/Health/Checks/ImagesWithoutAlt.php
Normal file
71
app/Support/Health/Checks/ImagesWithoutAlt.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Models\ImageAttachment;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
|
||||
/**
|
||||
* Images on the public site that nobody described.
|
||||
*
|
||||
* An image with no alt text is invisible to anyone using a screen reader, and
|
||||
* to everybody when it fails to load. The API sends `alt: ""` in that case,
|
||||
* which is the correct way to say "decorative" — so the frontend cannot tell
|
||||
* a deliberate blank from a forgotten one. This can.
|
||||
*
|
||||
* Avatars are not counted: their description is the person's name, and the
|
||||
* panel fills that in itself.
|
||||
*/
|
||||
class ImagesWithoutAlt implements Check
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'images-without-alt';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$default = (string) config('locales.default');
|
||||
$count = 0;
|
||||
$samples = [];
|
||||
|
||||
foreach (Project::query()->published()->whereNotNull('cover_image')->get(['id', 'title', 'cover_image', 'cover_image_alt']) as $project) {
|
||||
if (filled($project->getTranslation('cover_image_alt', $default, false))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$count++;
|
||||
$samples[] = $project->getTranslation('title', $default, false);
|
||||
}
|
||||
|
||||
foreach (Page::query()->published()->whereNotNull('featured_image')->get(['id', 'title', 'featured_image', 'featured_image_alt']) as $page) {
|
||||
if (filled($page->getTranslation('featured_image_alt', $default, false))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$count++;
|
||||
$samples[] = $page->getTranslation('title', $default, false);
|
||||
}
|
||||
|
||||
foreach (ImageAttachment::query()->with('attachable')->get(['id', 'attachable_type', 'attachable_id', 'alt']) as $image) {
|
||||
if (filled($image->getTranslation('alt', $default, false))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$count++;
|
||||
$samples[] = __('health.bits.gallery_image');
|
||||
}
|
||||
|
||||
return Finding::problem($this->key(), $count, samples: array_values(array_unique($samples)));
|
||||
}
|
||||
}
|
||||
69
app/Support/Health/Checks/IncompleteSeo.php
Normal file
69
app/Support/Health/Checks/IncompleteSeo.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
|
||||
/**
|
||||
* Published work with nothing for a search result or a shared link to show.
|
||||
*
|
||||
* Without `meta_description` the frontend has nothing to put in the meta tag
|
||||
* and the snippet gets invented from the body; without a cover there is no
|
||||
* image when the link is pasted into a chat. For a collective whose whole
|
||||
* point is being found and shared, that is the difference between a project
|
||||
* being read and being scrolled past.
|
||||
*/
|
||||
class IncompleteSeo implements Check
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'incomplete-seo';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$default = (string) config('locales.default');
|
||||
$count = 0;
|
||||
$samples = [];
|
||||
|
||||
foreach (Project::query()->published()->get(['id', 'title', 'meta_description', 'cover_image']) as $project) {
|
||||
$missing = [];
|
||||
|
||||
if (blank($project->getTranslation('meta_description', $default, false))) {
|
||||
$missing[] = __('health.bits.description');
|
||||
}
|
||||
|
||||
if (blank($project->cover_image)) {
|
||||
$missing[] = __('health.bits.cover');
|
||||
}
|
||||
|
||||
if ($missing === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$count++;
|
||||
$samples[] = $project->getTranslation('title', $default, false).' ('.implode(', ', $missing).')';
|
||||
}
|
||||
|
||||
foreach (Page::query()->published()->get(['id', 'title', 'meta_description']) as $page) {
|
||||
if (filled($page->getTranslation('meta_description', $default, false))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$count++;
|
||||
$samples[] = $page->getTranslation('title', $default, false).' ('.__('health.bits.description').')';
|
||||
}
|
||||
|
||||
return Finding::problem($this->key(), $count, samples: $samples);
|
||||
}
|
||||
}
|
||||
79
app/Support/Health/Checks/MissingSlugs.php
Normal file
79
app/Support/Health/Checks/MissingSlugs.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Models\Call;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
use App\Support\Health\Severity;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* A record translated into a language but with no slug in it.
|
||||
*
|
||||
* This is already broken on the public site: the API builds `alternates` from
|
||||
* the slugs, so the language with the missing one is either absent from the
|
||||
* hreflang set or announced at a URL that 404s. Nothing here shows the
|
||||
* problem, which is exactly why it needs a check.
|
||||
*/
|
||||
class MissingSlugs implements Check
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'missing-slugs';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$locales = array_keys((array) config('locales.supported'));
|
||||
$samples = [];
|
||||
$count = 0;
|
||||
|
||||
foreach ([Page::class, Project::class, Call::class] as $model) {
|
||||
foreach ($this->publicRecords($model) as $record) {
|
||||
foreach ($locales as $locale) {
|
||||
$hasTitle = filled($record->getTranslation('title', $locale, false));
|
||||
$hasSlug = filled($record->getTranslation('slug', $locale, false));
|
||||
|
||||
if ($hasTitle && ! $hasSlug) {
|
||||
$count++;
|
||||
$samples[] = $record->getTranslation('title', $locale, false)." ({$locale})";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Finding::problem(
|
||||
$this->key(),
|
||||
$count,
|
||||
Severity::Error,
|
||||
samples: $samples,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only what the public can reach: a draft with half its slugs filled in is
|
||||
* work in progress, not a fault.
|
||||
*
|
||||
* @param class-string<Model> $model
|
||||
* @return iterable<int, Model>
|
||||
*/
|
||||
protected function publicRecords(string $model): iterable
|
||||
{
|
||||
$query = $model::query();
|
||||
|
||||
return match ($model) {
|
||||
Call::class => $query->open()->get(['id', 'title', 'slug']),
|
||||
default => $query->published()->get(['id', 'title', 'slug']),
|
||||
};
|
||||
}
|
||||
}
|
||||
67
app/Support/Health/Checks/MissingTranslations.php
Normal file
67
app/Support/Health/Checks/MissingTranslations.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Filament\Resources\Pages\PageResource;
|
||||
use App\Models\Call;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
/**
|
||||
* Published content with no title in one of the supported languages.
|
||||
*
|
||||
* The site does not break — the API simply omits that language from the
|
||||
* record's `alternates` — but a visitor browsing in Catalan finds the section
|
||||
* half empty and no one editing in Spanish ever sees it.
|
||||
*/
|
||||
class MissingTranslations implements Check
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'missing-translations';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$locales = array_keys((array) config('locales.supported'));
|
||||
$count = 0;
|
||||
$samples = [];
|
||||
|
||||
foreach ([Page::class, Project::class, Call::class] as $model) {
|
||||
$records = $model::query()
|
||||
->when($model === Call::class, fn (Builder $q) => $q->open())
|
||||
->unless($model === Call::class, fn (Builder $q) => $q->published())
|
||||
->get(['id', 'title']);
|
||||
|
||||
foreach ($records as $record) {
|
||||
$missing = collect($locales)
|
||||
->reject(fn (string $locale) => filled($record->getTranslation('title', $locale, false)));
|
||||
|
||||
if ($missing->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$count++;
|
||||
$samples[] = $record->getTranslation('title', (string) config('locales.default'), false)
|
||||
.' ('.$missing->implode(', ').')';
|
||||
}
|
||||
}
|
||||
|
||||
return Finding::problem(
|
||||
$this->key(),
|
||||
$count,
|
||||
url: PageResource::getUrl('index'),
|
||||
samples: $samples,
|
||||
);
|
||||
}
|
||||
}
|
||||
83
app/Support/Health/Checks/RedirectsToNowhere.php
Normal file
83
app/Support/Health/Checks/RedirectsToNowhere.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Filament\Resources\Redirects\RedirectResource;
|
||||
use App\Models\Call;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Models\Redirect;
|
||||
use App\Models\Service;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
use App\Support\SiteUrls;
|
||||
|
||||
/**
|
||||
* Redirects that send a visitor to a page that is not there.
|
||||
*
|
||||
* Worse than the 404 they were meant to prevent: the visitor is bounced, waits
|
||||
* for a second request and lands on the same nothing. It happens when the
|
||||
* record a rule was written for is later unpublished or deleted, which is a
|
||||
* perfectly ordinary thing to do and leaves no trace anywhere else.
|
||||
*/
|
||||
class RedirectsToNowhere implements Check
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'redirects-to-nowhere';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
return 'redirects.view_any';
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$live = $this->publishedPaths();
|
||||
|
||||
$orphans = Redirect::query()
|
||||
->enabled()
|
||||
->get(['id', 'from', 'to'])
|
||||
// Only destinations this application is responsible for: a rule
|
||||
// pointing at somebody else's site is not ours to verify.
|
||||
->filter(fn (Redirect $r) => ! in_array($r->to, $live, true)
|
||||
&& ! str_starts_with($r->to, 'http'));
|
||||
|
||||
return Finding::problem(
|
||||
$this->key(),
|
||||
$orphans->count(),
|
||||
url: RedirectResource::getUrl('index'),
|
||||
samples: $orphans->map(fn (Redirect $r) => "{$r->from} → {$r->to}")->values()->all(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every address the public site currently answers at.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected function publishedPaths(): array
|
||||
{
|
||||
$paths = [];
|
||||
|
||||
$sources = [
|
||||
'page' => Page::query()->published()->get(['id', 'slug']),
|
||||
'project' => Project::query()->published()->get(['id', 'slug']),
|
||||
'call' => Call::query()->open()->get(['id', 'slug']),
|
||||
'service' => Service::query()->published()->get(['id', 'slug']),
|
||||
];
|
||||
|
||||
foreach ($sources as $type => $records) {
|
||||
foreach ($records as $record) {
|
||||
foreach (SiteUrls::alternates($record, $type) as $path) {
|
||||
$paths[] = Redirect::normalise($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $paths;
|
||||
}
|
||||
}
|
||||
50
app/Support/Health/Checks/ServicesWithoutWork.php
Normal file
50
app/Support/Health/Checks/ServicesWithoutWork.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Filament\Resources\Services\ServiceResource;
|
||||
use App\Models\Service;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
|
||||
/**
|
||||
* Published services with no published work behind them.
|
||||
*
|
||||
* A service that cannot point at something real is a promise, and a landing
|
||||
* page made of promises is the kind visitors have learned to skim past. The
|
||||
* listing already flags this in orange; this is the same fact where somebody
|
||||
* will see it without going looking.
|
||||
*/
|
||||
class ServicesWithoutWork implements Check
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'services-without-work';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
return 'services.view_any';
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$default = (string) config('locales.default');
|
||||
|
||||
$services = Service::query()
|
||||
->published()
|
||||
->whereDoesntHave('projects', fn ($q) => $q->published())
|
||||
->get(['id', 'title']);
|
||||
|
||||
return Finding::problem(
|
||||
$this->key(),
|
||||
$services->count(),
|
||||
url: ServiceResource::getUrl('index'),
|
||||
samples: $services
|
||||
->map(fn (Service $s) => $s->getTranslation('title', $default, false))
|
||||
->all(),
|
||||
);
|
||||
}
|
||||
}
|
||||
43
app/Support/Health/Checks/StaleRepositories.php
Normal file
43
app/Support/Health/Checks/StaleRepositories.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Filament\Resources\Repositories\RepositoryResource;
|
||||
use App\Models\Repository;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
|
||||
/**
|
||||
* Repositories whose sync keeps failing.
|
||||
*
|
||||
* Usually a repository that was renamed, made private or deleted. The public
|
||||
* site keeps showing the last numbers it managed to fetch, which is worse than
|
||||
* showing none: the whole reason for syncing is that live figures are trusted
|
||||
* in a way hand-written ones are not.
|
||||
*/
|
||||
class StaleRepositories implements Check
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'stale-repositories';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
return 'repositories.view_any';
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$failing = Repository::query()->failing()->get(['id', 'full_name']);
|
||||
|
||||
return Finding::problem(
|
||||
$this->key(),
|
||||
$failing->count(),
|
||||
url: RepositoryResource::getUrl('index'),
|
||||
samples: $failing->pluck('full_name')->all(),
|
||||
);
|
||||
}
|
||||
}
|
||||
84
app/Support/Health/Checks/StaleReviews.php
Normal file
84
app/Support/Health/Checks/StaleReviews.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Models\Service;
|
||||
use App\Support\ContentTypes;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
use App\Support\Health\Severity;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Work that was handed over and never answered.
|
||||
*
|
||||
* The same shape as the unanswered-submissions check, and for the same reason:
|
||||
* somebody is waiting, and nothing else in the panel would ever say so. It is
|
||||
* the failure mode a review step introduces — before it existed, an editor who
|
||||
* could not publish was stuck but knew it; now they are waiting and think
|
||||
* something is happening.
|
||||
*
|
||||
* A review left for a week has stopped being a handover and become a piece of
|
||||
* work nobody owns.
|
||||
*/
|
||||
class StaleReviews implements Check
|
||||
{
|
||||
protected const DAYS = 7;
|
||||
|
||||
/**
|
||||
* The models where publishing is a permission of its own, which is exactly
|
||||
* where a review can exist.
|
||||
*
|
||||
* @var array<int, class-string<Model>>
|
||||
*/
|
||||
protected const REVIEWABLE = [Page::class, Project::class, Service::class];
|
||||
|
||||
public function key(): string
|
||||
{
|
||||
return 'stale-reviews';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whoever can publish something is who a stale review is waiting for, so
|
||||
* seeing the count is the same question as being able to end it. Pages
|
||||
* stand in for the three: a role that can publish none of them has no
|
||||
* business being told what is queued.
|
||||
*/
|
||||
public function permission(): ?string
|
||||
{
|
||||
return 'pages.publish';
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$cutoff = now()->subDays(self::DAYS);
|
||||
$samples = [];
|
||||
$count = 0;
|
||||
|
||||
foreach (self::REVIEWABLE as $model) {
|
||||
$stale = $model::query()
|
||||
->awaitingReview()
|
||||
->where('review_requested_at', '<', $cutoff)
|
||||
->orderBy('review_requested_at')
|
||||
->get();
|
||||
|
||||
$count += $stale->count();
|
||||
|
||||
foreach ($stale as $record) {
|
||||
$samples[] = ContentTypes::title($record)
|
||||
.' ('.$record->review_requested_at->diffForHumans().')';
|
||||
}
|
||||
}
|
||||
|
||||
return Finding::problem(
|
||||
$this->key(),
|
||||
$count,
|
||||
Severity::Warning,
|
||||
samples: $samples,
|
||||
);
|
||||
}
|
||||
}
|
||||
81
app/Support/Health/Checks/StaleTranslations.php
Normal file
81
app/Support/Health/Checks/StaleTranslations.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Models\Call;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Models\Service;
|
||||
use App\Support\ContentTypes;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
use App\Support\TranslationFreshness;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Translations the original has moved on without.
|
||||
*
|
||||
* The sibling of the missing-translations check, and the one that finds what
|
||||
* nobody would: a language that *has* the text, so every screen in the panel
|
||||
* calls that record finished, while the version visitors read in it says
|
||||
* something the source stopped saying months ago.
|
||||
*
|
||||
* It is the failure that only ever surfaces when somebody who speaks both
|
||||
* languages happens to compare two pages, which is to say almost never.
|
||||
*/
|
||||
class StaleTranslations implements Check
|
||||
{
|
||||
/**
|
||||
* @var array<int, class-string<Model>>
|
||||
*/
|
||||
protected const TRANSLATED = [Page::class, Project::class, Call::class, Service::class];
|
||||
|
||||
public function key(): string
|
||||
{
|
||||
return 'stale-translations';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$count = 0;
|
||||
$samples = [];
|
||||
|
||||
foreach (self::TRANSLATED as $model) {
|
||||
$records = $model::query()
|
||||
->when($model === Call::class, fn (Builder $q) => $q->open())
|
||||
->unless($model === Call::class, fn (Builder $q) => $q->published())
|
||||
// Loaded here rather than one record at a time: working this
|
||||
// out means reading the change history, and a check that costs
|
||||
// a query per record is one that gets switched off.
|
||||
->with('revisions')
|
||||
->get();
|
||||
|
||||
foreach ($records as $record) {
|
||||
$stale = TranslationFreshness::for($record);
|
||||
|
||||
if ($stale === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$count++;
|
||||
|
||||
// Which languages, and how long they have been drifting —
|
||||
// "since March" is what decides whether this is urgent.
|
||||
$samples[] = ContentTypes::title($record)
|
||||
.' ('.collect($stale)
|
||||
->map(fn ($since, string $locale) => "{$locale}, ".$since->diffForHumans())
|
||||
->implode('; ').')';
|
||||
}
|
||||
}
|
||||
|
||||
return Finding::problem($this->key(), $count, samples: $samples);
|
||||
}
|
||||
}
|
||||
44
app/Support/Health/Checks/StalledMediaQueue.php
Normal file
44
app/Support/Health/Checks/StalledMediaQueue.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Models\Media;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
|
||||
/**
|
||||
* Images that have been waiting to be processed for too long.
|
||||
*
|
||||
* Uploads queue their own work and the scheduler sweeps up every ten minutes,
|
||||
* so anything still pending an hour later means neither is running. The site
|
||||
* keeps working — images serve without dimensions or `srcset` — which is
|
||||
* precisely why nobody would notice.
|
||||
*/
|
||||
class StalledMediaQueue implements Check
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'stalled-media';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$stalled = Media::query()
|
||||
->pending()
|
||||
->where('created_at', '<', now()->subHour())
|
||||
->get(['id', 'path']);
|
||||
|
||||
return Finding::problem(
|
||||
$this->key(),
|
||||
$stalled->count(),
|
||||
samples: $stalled->pluck('path')->all(),
|
||||
);
|
||||
}
|
||||
}
|
||||
45
app/Support/Health/Checks/TalksWithoutSpeaker.php
Normal file
45
app/Support/Health/Checks/TalksWithoutSpeaker.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Filament\Resources\Talks\TalkResource;
|
||||
use App\Models\Talk;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
|
||||
/**
|
||||
* Talks nobody is credited with.
|
||||
*
|
||||
* Half the value of writing one down is that it also appears on the profile of
|
||||
* whoever gave it. Without the link it is written once and shown once, which
|
||||
* is most of the work for half the result.
|
||||
*/
|
||||
class TalksWithoutSpeaker implements Check
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'talks-without-speaker';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
return 'talks.view_any';
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$talks = Talk::query()
|
||||
->public()
|
||||
->whereNull('member_id')
|
||||
->get(['id', 'title']);
|
||||
|
||||
return Finding::problem(
|
||||
$this->key(),
|
||||
$talks->count(),
|
||||
url: TalkResource::getUrl('index'),
|
||||
samples: $talks->pluck('title')->all(),
|
||||
);
|
||||
}
|
||||
}
|
||||
51
app/Support/Health/Checks/TestimonialsMissingOriginal.php
Normal file
51
app/Support/Health/Checks/TestimonialsMissingOriginal.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Filament\Resources\Testimonials\TestimonialResource;
|
||||
use App\Models\Testimonial;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
use App\Support\Health\Severity;
|
||||
|
||||
/**
|
||||
* Quotes published without the words that were actually said.
|
||||
*
|
||||
* Every testimonial declares the language it was given in. When that language
|
||||
* is the one field left empty, the site is publishing a translation of
|
||||
* something nobody can check — which is the one way a testimonial stops being
|
||||
* evidence and starts being copywriting.
|
||||
*/
|
||||
class TestimonialsMissingOriginal implements Check
|
||||
{
|
||||
public function key(): string
|
||||
{
|
||||
return 'testimonials-missing-original';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
return 'testimonials.view_any';
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$offenders = Testimonial::query()
|
||||
->public()
|
||||
->get(['id', 'quote', 'original_locale', 'author_name'])
|
||||
->filter(fn (Testimonial $t) => blank($t->getTranslation('quote', $t->original_locale, false)));
|
||||
|
||||
return Finding::problem(
|
||||
$this->key(),
|
||||
$offenders->count(),
|
||||
Severity::Error,
|
||||
url: TestimonialResource::getUrl('index'),
|
||||
samples: $offenders
|
||||
->map(fn (Testimonial $t) => $t->author_name.' ('.$t->original_locale.')')
|
||||
->values()
|
||||
->all(),
|
||||
);
|
||||
}
|
||||
}
|
||||
54
app/Support/Health/Checks/UnansweredSubmissions.php
Normal file
54
app/Support/Health/Checks/UnansweredSubmissions.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Filament\Resources\Applications\ApplicationResource;
|
||||
use App\Models\Application;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
use App\Support\Health\Severity;
|
||||
|
||||
/**
|
||||
* People who wrote in over a week ago and have had no answer.
|
||||
*
|
||||
* The only check here that measures somebody else's time rather than ours.
|
||||
* Somebody offered to help a volunteer collective and heard nothing back,
|
||||
* which costs more than any of the other faults on this page.
|
||||
*/
|
||||
class UnansweredSubmissions implements Check
|
||||
{
|
||||
/** Days after which silence stops being a delay and starts being an answer. */
|
||||
protected const GRACE_DAYS = 7;
|
||||
|
||||
public function key(): string
|
||||
{
|
||||
return 'unanswered-submissions';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
// Counting them is knowing they exist, and who may know that is the
|
||||
// same question as who may read the inbox.
|
||||
return 'applications.view_any';
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$waiting = Application::query()
|
||||
->pending()
|
||||
->where('created_at', '<', now()->subDays(self::GRACE_DAYS))
|
||||
->get(['id', 'name', 'created_at']);
|
||||
|
||||
return Finding::problem(
|
||||
$this->key(),
|
||||
$waiting->count(),
|
||||
Severity::Error,
|
||||
url: ApplicationResource::getUrl('index'),
|
||||
samples: $waiting
|
||||
->map(fn (Application $a) => $a->name.' · '.$a->created_at->diffForHumans())
|
||||
->all(),
|
||||
);
|
||||
}
|
||||
}
|
||||
65
app/Support/Health/Checks/UnresolvedMissingUrls.php
Normal file
65
app/Support/Health/Checks/UnresolvedMissingUrls.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health\Checks;
|
||||
|
||||
use App\Filament\Resources\MissingUrls\MissingUrlResource;
|
||||
use App\Models\MissingUrl;
|
||||
use App\Support\Health\Check;
|
||||
use App\Support\Health\Finding;
|
||||
use App\Support\Health\Severity;
|
||||
|
||||
/**
|
||||
* Addresses the frontend keeps being asked for and cannot answer.
|
||||
*
|
||||
* Every other check here reasons about what is stored; this one is the only
|
||||
* one that knows what visitors actually did. A 404 is the one fault on the
|
||||
* public site that nobody on this side can discover by looking — it happens to
|
||||
* somebody else, on another machine, and then they leave.
|
||||
*
|
||||
* Counted from two hits upward. A single request is as likely to be a scanner
|
||||
* as a person, and a number that never reaches zero stops being read.
|
||||
*/
|
||||
class UnresolvedMissingUrls implements Check
|
||||
{
|
||||
/**
|
||||
* Below this, an address is a stray request rather than a broken link.
|
||||
*/
|
||||
protected const THRESHOLD = 2;
|
||||
|
||||
public function key(): string
|
||||
{
|
||||
return 'missing-urls';
|
||||
}
|
||||
|
||||
public function permission(): ?string
|
||||
{
|
||||
// The same permission that governs doing something about it.
|
||||
return 'redirects.view_any';
|
||||
}
|
||||
|
||||
public function run(): Finding
|
||||
{
|
||||
$missing = MissingUrl::query()
|
||||
->pending()
|
||||
->where('hits', '>=', self::THRESHOLD)
|
||||
->orderByDesc('hits')
|
||||
->get(['path', 'hits', 'referrer']);
|
||||
|
||||
return Finding::problem(
|
||||
$this->key(),
|
||||
$missing->count(),
|
||||
|
||||
// An address our own site links to is a fault in what we publish,
|
||||
// not somebody else's stale bookmark, and it will keep producing
|
||||
// 404s until it is fixed here.
|
||||
$missing->contains(fn (MissingUrl $url) => $url->isInternalReferrer())
|
||||
? Severity::Error
|
||||
: Severity::Warning,
|
||||
|
||||
url: MissingUrlResource::getUrl('index'),
|
||||
samples: $missing->map(fn (MissingUrl $url) => "{$url->path} ({$url->hits})")->all(),
|
||||
);
|
||||
}
|
||||
}
|
||||
66
app/Support/Health/Finding.php
Normal file
66
app/Support/Health/Finding.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health;
|
||||
|
||||
/**
|
||||
* The outcome of one check.
|
||||
*
|
||||
* Carries a few example records on purpose. "12 pages have no description" is
|
||||
* a statistic; "12, starting with *Quiénes somos*" is something somebody can
|
||||
* act on without hunting for the first one.
|
||||
*/
|
||||
final class Finding
|
||||
{
|
||||
/**
|
||||
* @param array<int, string> $samples
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $key,
|
||||
public readonly Severity $severity,
|
||||
public readonly int $count = 0,
|
||||
public readonly ?string $url = null,
|
||||
public readonly array $samples = [],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $samples
|
||||
*/
|
||||
public static function problem(
|
||||
string $key,
|
||||
int $count,
|
||||
Severity $severity = Severity::Warning,
|
||||
?string $url = null,
|
||||
array $samples = [],
|
||||
): self {
|
||||
// A check that found nothing is a pass, whatever severity it would
|
||||
// have reported — so no caller has to remember to say so.
|
||||
if ($count < 1) {
|
||||
return self::ok($key, $url);
|
||||
}
|
||||
|
||||
return new self($key, $severity, $count, $url, array_slice($samples, 0, 3));
|
||||
}
|
||||
|
||||
public static function ok(string $key, ?string $url = null): self
|
||||
{
|
||||
return new self($key, Severity::Ok, 0, $url);
|
||||
}
|
||||
|
||||
public function title(): string
|
||||
{
|
||||
return __("health.checks.{$this->key}.title");
|
||||
}
|
||||
|
||||
/**
|
||||
* What it means and what to do about it — or, when there is nothing to do,
|
||||
* a plain statement that this one is fine.
|
||||
*/
|
||||
public function description(): string
|
||||
{
|
||||
return $this->severity->isProblem()
|
||||
? trans_choice("health.checks.{$this->key}.problem", $this->count, ['count' => $this->count])
|
||||
: __("health.checks.{$this->key}.ok");
|
||||
}
|
||||
}
|
||||
160
app/Support/Health/HealthReport.php
Normal file
160
app/Support/Health/HealthReport.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Support\Health\Checks\BrokenImages;
|
||||
use App\Support\Health\Checks\BrokenRedirects;
|
||||
use App\Support\Health\Checks\ClientsWithoutLogo;
|
||||
use App\Support\Health\Checks\DuplicateSlugs;
|
||||
use App\Support\Health\Checks\ExpiredCalls;
|
||||
use App\Support\Health\Checks\ImagesWithoutAlt;
|
||||
use App\Support\Health\Checks\IncompleteSeo;
|
||||
use App\Support\Health\Checks\MissingSlugs;
|
||||
use App\Support\Health\Checks\MissingTranslations;
|
||||
use App\Support\Health\Checks\RedirectsToNowhere;
|
||||
use App\Support\Health\Checks\ServicesWithoutWork;
|
||||
use App\Support\Health\Checks\StaleRepositories;
|
||||
use App\Support\Health\Checks\StaleReviews;
|
||||
use App\Support\Health\Checks\StaleTranslations;
|
||||
use App\Support\Health\Checks\StalledMediaQueue;
|
||||
use App\Support\Health\Checks\TalksWithoutSpeaker;
|
||||
use App\Support\Health\Checks\TestimonialsMissingOriginal;
|
||||
use App\Support\Health\Checks\UnansweredSubmissions;
|
||||
use App\Support\Health\Checks\UnresolvedMissingUrls;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* What is wrong with the public site, seen from here.
|
||||
*
|
||||
* The reason this page exists is that the frontend lives in another repository
|
||||
* on another host: a missing translation, a slug that resolves in one language
|
||||
* and not another, an image whose file is gone — none of it shows up here,
|
||||
* where the data is edited. It shows up over there, usually late, usually to a
|
||||
* visitor.
|
||||
*/
|
||||
final class HealthReport
|
||||
{
|
||||
/**
|
||||
* @var array<int, class-string<Check>>
|
||||
*/
|
||||
public const CHECKS = [
|
||||
// Things already wrong on the public site come first.
|
||||
MissingSlugs::class,
|
||||
DuplicateSlugs::class,
|
||||
BrokenImages::class,
|
||||
TestimonialsMissingOriginal::class,
|
||||
BrokenRedirects::class,
|
||||
|
||||
// Then things that will bite later.
|
||||
MissingTranslations::class,
|
||||
|
||||
// The same fault one step later: translated, and left behind by
|
||||
// the language it was translated from. Nothing else in the panel
|
||||
// would ever mention it — by every other measure those records
|
||||
// are finished.
|
||||
StaleTranslations::class,
|
||||
ImagesWithoutAlt::class,
|
||||
IncompleteSeo::class,
|
||||
ExpiredCalls::class,
|
||||
UnansweredSubmissions::class,
|
||||
|
||||
// Somebody handed work over and nobody answered. The failure
|
||||
// mode the review step introduces: before it, an editor who
|
||||
// could not publish was stuck and knew it.
|
||||
StaleReviews::class,
|
||||
RedirectsToNowhere::class,
|
||||
|
||||
// What visitors could not reach. Last of the redirect family because
|
||||
// it is the one that arrives on its own: the others are found by
|
||||
// looking at what is stored, this one by somebody hitting a dead link.
|
||||
UnresolvedMissingUrls::class,
|
||||
ServicesWithoutWork::class,
|
||||
ClientsWithoutLogo::class,
|
||||
TalksWithoutSpeaker::class,
|
||||
StalledMediaQueue::class,
|
||||
StaleRepositories::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* Every check there is, worst first.
|
||||
*
|
||||
* For the command line, which runs as a deploy rather than as a person:
|
||||
* there is nobody to hide a result from, and a release that skipped the
|
||||
* checks it could not attribute to a user would be worse than useless.
|
||||
*
|
||||
* @return Collection<int, Finding>
|
||||
*/
|
||||
public function runAll(): Collection
|
||||
{
|
||||
return $this->execute(collect(self::CHECKS)->map(fn (string $check) => app($check)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the checks this user is allowed to see.
|
||||
*
|
||||
* @return Collection<int, Finding>
|
||||
*/
|
||||
public function runFor(?User $user): Collection
|
||||
{
|
||||
return $this->execute(
|
||||
collect(self::CHECKS)
|
||||
->map(fn (string $check) => app($check))
|
||||
->filter(fn (Check $check) => $this->visibleTo($check, $user)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many checks are failing, for the badge in the sidebar.
|
||||
*
|
||||
* Cached, because this is read on every panel page render and running ten
|
||||
* checks to draw a number would make every page in the panel slower.
|
||||
*/
|
||||
public function problemCount(?User $user): int
|
||||
{
|
||||
return Cache::remember(
|
||||
$this->cacheKey($user),
|
||||
now()->addMinutes(5),
|
||||
fn () => $this->runFor($user)->filter(fn (Finding $f) => $f->severity->isProblem())->count(),
|
||||
);
|
||||
}
|
||||
|
||||
public function forget(?User $user): void
|
||||
{
|
||||
Cache::forget($this->cacheKey($user));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Check> $checks
|
||||
* @return Collection<int, Finding>
|
||||
*/
|
||||
protected function execute(Collection $checks): Collection
|
||||
{
|
||||
return $checks
|
||||
->map(fn (Check $check) => $check->run())
|
||||
->sortBy([
|
||||
fn (Finding $a, Finding $b) => $a->severity->weight() <=> $b->severity->weight(),
|
||||
fn (Finding $a, Finding $b) => $b->count <=> $a->count,
|
||||
])
|
||||
->values();
|
||||
}
|
||||
|
||||
protected function visibleTo(Check $check, ?User $user): bool
|
||||
{
|
||||
$permission = $check->permission();
|
||||
|
||||
if ($permission === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $user?->can($permission) ?? false;
|
||||
}
|
||||
|
||||
protected function cacheKey(?User $user): string
|
||||
{
|
||||
return 'health:problems:'.($user?->id ?? 'guest');
|
||||
}
|
||||
}
|
||||
54
app/Support/Health/Severity.php
Normal file
54
app/Support/Health/Severity.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Health;
|
||||
|
||||
enum Severity: string
|
||||
{
|
||||
/** Nothing to do. */
|
||||
case Ok = 'ok';
|
||||
|
||||
/** Worth fixing, but the site works. */
|
||||
case Warning = 'warning';
|
||||
|
||||
/** Something on the public site is already wrong. */
|
||||
case Error = 'error';
|
||||
|
||||
public function getLabel(): string
|
||||
{
|
||||
return __("health.severity.{$this->value}");
|
||||
}
|
||||
|
||||
public function getColor(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Ok => 'success',
|
||||
self::Warning => 'warning',
|
||||
self::Error => 'danger',
|
||||
};
|
||||
}
|
||||
|
||||
public function getIcon(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Ok => 'heroicon-o-check-circle',
|
||||
self::Warning => 'heroicon-o-exclamation-triangle',
|
||||
self::Error => 'heroicon-o-x-circle',
|
||||
};
|
||||
}
|
||||
|
||||
public function isProblem(): bool
|
||||
{
|
||||
return $this !== self::Ok;
|
||||
}
|
||||
|
||||
public function weight(): int
|
||||
{
|
||||
return match ($this) {
|
||||
self::Error => 0,
|
||||
self::Warning => 1,
|
||||
self::Ok => 2,
|
||||
};
|
||||
}
|
||||
}
|
||||
71
app/Support/ImagePayload.php
Normal file
71
app/Support/ImagePayload.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Media;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* The shape every image takes in the API.
|
||||
*
|
||||
* A bare path is not enough for a server-rendered frontend: without width and
|
||||
* height the page reflows as images arrive, without a `srcset` every visitor
|
||||
* downloads the full-size original, and without alt text the page is not
|
||||
* accessible. All of that is known here and nowhere else, so all of it is
|
||||
* sent rather than left for the frontend to guess.
|
||||
*
|
||||
* Degrades on purpose: an image whose derived versions are not ready yet still
|
||||
* comes back with a URL, so the page renders a plain `<img>` instead of a gap.
|
||||
*/
|
||||
final class ImagePayload
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function make(?string $path, ?Media $media, ?string $alt = null): ?array
|
||||
{
|
||||
if (blank($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'url' => $media?->url() ?? self::url($path),
|
||||
|
||||
// Empty rather than absent: an image with no description is
|
||||
// decorative, and `alt=""` is how you say that.
|
||||
'alt' => (string) ($alt ?? ''),
|
||||
|
||||
'width' => $media?->width,
|
||||
'height' => $media?->height,
|
||||
'aspect_ratio' => $media?->aspectRatio(),
|
||||
'mime' => $media?->mime,
|
||||
|
||||
// Paint something in the right colours while the real image loads.
|
||||
'placeholder' => $media?->placeholder,
|
||||
|
||||
// Keyed by format so the frontend can build a <picture> with AVIF
|
||||
// first and WebP behind it. Only widths that actually exist are
|
||||
// listed: offering one that does not would put a broken image on
|
||||
// the page.
|
||||
'srcset' => $media?->srcset() ?: null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* URL for a path with no media row yet — an upload processed a moment ago,
|
||||
* or one whose row was pruned.
|
||||
*/
|
||||
protected static function url(string $path): string
|
||||
{
|
||||
$url = Storage::disk((string) config('media.disk', 'public'))->url($path);
|
||||
|
||||
if (Str::startsWith($url, ['http://', 'https://'])) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
return rtrim((string) config('app.url'), '/').'/'.ltrim($url, '/');
|
||||
}
|
||||
}
|
||||
138
app/Support/Maintenance.php
Normal file
138
app/Support/Maintenance.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
/**
|
||||
* Maintenance mode for the **public site only**.
|
||||
*
|
||||
* Deliberately not Laravel's `artisan down`, which would take this
|
||||
* application down too — including the panel. The whole point is that the
|
||||
* collective keeps working while visitors see a maintenance page: content can
|
||||
* still be edited, repositories still sync, and the team can preview the real
|
||||
* site through the usual bypasses.
|
||||
*
|
||||
* The flag lives in the database rather than in .env so an administrator can
|
||||
* flip it from the panel without a deploy.
|
||||
*/
|
||||
final class Maintenance
|
||||
{
|
||||
public const KEY = 'site.maintenance';
|
||||
|
||||
public static function isEnabled(): bool
|
||||
{
|
||||
$state = self::state();
|
||||
|
||||
if (! ($state['enabled'] ?? false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A window that has already passed lifts itself, so a forgotten flag
|
||||
// does not keep the site down indefinitely.
|
||||
if ($until = self::until()) {
|
||||
return ! $until->isPast();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string>|null $message Locale => message.
|
||||
*/
|
||||
public static function enable(?array $message = null, ?CarbonImmutable $until = null): void
|
||||
{
|
||||
Setting::set(self::KEY, [
|
||||
'enabled' => true,
|
||||
'message' => $message ?? self::state()['message'] ?? null,
|
||||
'until' => $until?->toIso8601String(),
|
||||
'started_at' => now()->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function disable(): void
|
||||
{
|
||||
Setting::set(self::KEY, [
|
||||
...self::state(),
|
||||
'enabled' => false,
|
||||
'until' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The message shown to visitors, resolved to the request's language and
|
||||
* falling back to a translated default when none was written.
|
||||
*/
|
||||
public static function message(?string $locale = null): string
|
||||
{
|
||||
$locale ??= app()->getLocale();
|
||||
$messages = self::state()['message'] ?? [];
|
||||
|
||||
if (is_array($messages)) {
|
||||
$written = $messages[$locale]
|
||||
?? $messages[config('locales.fallback')]
|
||||
?? null;
|
||||
|
||||
if (filled($written)) {
|
||||
return (string) $written;
|
||||
}
|
||||
}
|
||||
|
||||
return __('maintenance.default_message');
|
||||
}
|
||||
|
||||
public static function until(): ?CarbonImmutable
|
||||
{
|
||||
$until = self::state()['until'] ?? null;
|
||||
|
||||
return filled($until) ? CarbonImmutable::parse($until) : null;
|
||||
}
|
||||
|
||||
public static function startedAt(): ?CarbonImmutable
|
||||
{
|
||||
$started = self::state()['started_at'] ?? null;
|
||||
|
||||
return filled($started) ? CarbonImmutable::parse($started) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconds a caller should wait before retrying, for the `Retry-After`
|
||||
* header. Capped so a distant end date does not tell a CDN to stop asking
|
||||
* for a day.
|
||||
*/
|
||||
public static function retryAfter(): int
|
||||
{
|
||||
$until = self::until();
|
||||
|
||||
if ($until === null || $until->isPast()) {
|
||||
return 300;
|
||||
}
|
||||
|
||||
return min(3600, max(60, (int) now()->diffInSeconds($until)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every locale's message, for editing in the panel.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function messages(): array
|
||||
{
|
||||
$messages = self::state()['message'] ?? [];
|
||||
|
||||
return is_array($messages) ? $messages : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function state(): array
|
||||
{
|
||||
$state = Setting::get(self::KEY, []);
|
||||
|
||||
return is_array($state) ? $state : [];
|
||||
}
|
||||
}
|
||||
242
app/Support/Permissions.php
Normal file
242
app/Support/Permissions.php
Normal file
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* The application's permission catalogue.
|
||||
*
|
||||
* This is the single source of truth for authorisation. The identity server
|
||||
* says *who* someone is; everything they may *do* is decided from this list,
|
||||
* via the roles administrators assign in the panel.
|
||||
*
|
||||
* Adding a permission here and re-running `php artisan db:seed
|
||||
* --class=RolePermissionSeeder` (or `php artisan permissions:sync`) makes it
|
||||
* available everywhere: policies, the role editor, and the API.
|
||||
*/
|
||||
final class Permissions
|
||||
{
|
||||
public const PANEL_ACCESS = 'panel.access';
|
||||
|
||||
/**
|
||||
* The CRUD abilities a soft-deleting resource exposes.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public const CRUD_ABILITIES = [
|
||||
'view_any', 'view', 'create', 'update', 'delete', 'restore', 'force_delete',
|
||||
];
|
||||
|
||||
/**
|
||||
* Resource key => the abilities that resource actually supports.
|
||||
*
|
||||
* `users` and `roles` are hard-deleted, so they carry no restore or
|
||||
* force_delete permission; granting one would be a permission nothing ever
|
||||
* checks.
|
||||
*
|
||||
* @var array<string, array<int, string>>
|
||||
*/
|
||||
public const RESOURCES = [
|
||||
'pages' => [...self::CRUD_ABILITIES, 'publish'],
|
||||
'categories' => self::CRUD_ABILITIES,
|
||||
'projects' => [...self::CRUD_ABILITIES, 'publish'],
|
||||
'members' => self::CRUD_ABILITIES,
|
||||
|
||||
// What the collective offers, as opposed to what it has built.
|
||||
// Publishing is separate for the same reason it is on projects: an
|
||||
// editor may write the offer without putting it on the website.
|
||||
'services' => [...self::CRUD_ABILITIES, 'publish'],
|
||||
|
||||
'clients' => self::CRUD_ABILITIES,
|
||||
'testimonials' => self::CRUD_ABILITIES,
|
||||
'talks' => ['view_any', 'view', 'create', 'update', 'delete'],
|
||||
|
||||
'technologies' => ['view_any', 'view', 'create', 'update', 'delete'],
|
||||
'calls' => ['view_any', 'view', 'create', 'update', 'delete'],
|
||||
|
||||
// The inbox holds names, e-mail addresses and messages from people
|
||||
// outside the collective, so it is gated more tightly than the content
|
||||
// resources — and nobody creates one by hand: they arrive from the
|
||||
// public site, which is why there is no `create` here.
|
||||
'applications' => ['view_any', 'view', 'update', 'delete', 'restore', 'force_delete'],
|
||||
|
||||
// A broken link is a content problem, not an infrastructure one, so
|
||||
// whoever renames a slug is also who fixes what it broke.
|
||||
// Derived from the files themselves: nobody creates one by hand, and
|
||||
// "update" would mean editing something the processor owns.
|
||||
'media' => ['view_any', 'view', 'delete'],
|
||||
|
||||
'redirects' => ['view_any', 'view', 'create', 'update', 'delete'],
|
||||
|
||||
// `sync` is separate from `update` because triggering a sync spends
|
||||
// the provider's rate limit, which is a shared resource.
|
||||
'repositories' => ['view_any', 'view', 'create', 'update', 'delete', 'sync'],
|
||||
|
||||
'users' => ['view_any', 'view', 'create', 'update', 'delete'],
|
||||
'roles' => ['view_any', 'view', 'create', 'update', 'delete'],
|
||||
|
||||
// Taking the public site down is a deliberate, visible act, so it is
|
||||
// its own permission rather than part of general settings editing.
|
||||
'settings' => ['view', 'update', 'maintenance'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Every permission name the application knows about.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
return array_merge(...array_values(self::grouped()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Permissions grouped by resource, for rendering the role editor.
|
||||
*
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public static function grouped(): array
|
||||
{
|
||||
$groups = ['general' => [self::PANEL_ACCESS]];
|
||||
|
||||
foreach (self::RESOURCES as $resource => $abilities) {
|
||||
$groups[$resource] = array_map(
|
||||
fn (string $ability) => "{$resource}.{$ability}",
|
||||
$abilities,
|
||||
);
|
||||
}
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every permission belonging to one resource.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function for(string $resource): array
|
||||
{
|
||||
return self::grouped()[$resource] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The roles shipped with the application, and what each one may do.
|
||||
*
|
||||
* `super_admin` is deliberately absent: it is granted everything by a
|
||||
* Gate::before hook in AuthServiceProvider rather than by a permission list,
|
||||
* so it can never drift out of date as the catalogue grows.
|
||||
*
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public static function roleDefinitions(): array
|
||||
{
|
||||
$readOnly = fn (string $resource) => [
|
||||
"{$resource}.view_any",
|
||||
"{$resource}.view",
|
||||
];
|
||||
|
||||
// Everything you need to write something, and nothing that decides
|
||||
// whether the public sees it.
|
||||
$authoring = fn (string $resource) => array_values(array_diff(
|
||||
self::for($resource),
|
||||
["{$resource}.publish", "{$resource}.force_delete"],
|
||||
));
|
||||
|
||||
return [
|
||||
'super_admin' => [],
|
||||
|
||||
'admin' => self::all(),
|
||||
|
||||
'editor' => [
|
||||
self::PANEL_ACCESS,
|
||||
...self::for('pages'),
|
||||
...self::for('categories'),
|
||||
...self::for('projects'),
|
||||
...self::for('members'),
|
||||
...self::for('services'),
|
||||
...self::for('clients'),
|
||||
...self::for('testimonials'),
|
||||
...self::for('talks'),
|
||||
...self::for('technologies'),
|
||||
...self::for('calls'),
|
||||
...self::for('media'),
|
||||
...self::for('redirects'),
|
||||
|
||||
// Editors run the calls, so they are the ones who have to
|
||||
// answer the people who reply to them.
|
||||
...self::for('applications'),
|
||||
|
||||
...$readOnly('repositories'),
|
||||
...$readOnly('users'),
|
||||
'settings.view',
|
||||
],
|
||||
|
||||
/*
|
||||
* Writes content, cannot put it on the site.
|
||||
*
|
||||
* The role the separate `publish` permissions were always for.
|
||||
* Without one, `pages.publish` was a distinction the catalogue
|
||||
* made and no shipped role ever exercised — `editor` holds it
|
||||
* along with everything else — so the handover it implies had
|
||||
* nowhere to happen. This is the other end of it: somebody who
|
||||
* finishes a piece of work and asks for a review, because that is
|
||||
* the only way they have of putting it live.
|
||||
*
|
||||
* Also without `force_delete`: erasing something for good is not
|
||||
* part of writing it, and the bin makes an ordinary delete
|
||||
* recoverable. And without `applications`, for the reason the
|
||||
* viewer does not have it either — writing for the site and
|
||||
* reading strangers' contact details are different jobs.
|
||||
*/
|
||||
'writer' => [
|
||||
self::PANEL_ACCESS,
|
||||
...$authoring('pages'),
|
||||
...$authoring('projects'),
|
||||
...$authoring('services'),
|
||||
|
||||
// Enough to pick one on a form, not to invent one.
|
||||
...$readOnly('categories'),
|
||||
...$readOnly('members'),
|
||||
...$readOnly('clients'),
|
||||
...$readOnly('technologies'),
|
||||
...$readOnly('talks'),
|
||||
...$readOnly('testimonials'),
|
||||
...$readOnly('media'),
|
||||
...$readOnly('calls'),
|
||||
],
|
||||
|
||||
// Deliberately without `applications`: a viewer reads the site's
|
||||
// content, not the contact details of the people who wrote in.
|
||||
'viewer' => [
|
||||
self::PANEL_ACCESS,
|
||||
...$readOnly('pages'),
|
||||
...$readOnly('categories'),
|
||||
...$readOnly('projects'),
|
||||
...$readOnly('members'),
|
||||
...$readOnly('services'),
|
||||
...$readOnly('clients'),
|
||||
...$readOnly('testimonials'),
|
||||
...$readOnly('talks'),
|
||||
...$readOnly('technologies'),
|
||||
...$readOnly('media'),
|
||||
...$readOnly('calls'),
|
||||
...$readOnly('repositories'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Roles that must never be deleted or renamed from the panel, because the
|
||||
* application's own bootstrapping depends on them.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function protectedRoles(): array
|
||||
{
|
||||
return array_values(array_unique([
|
||||
'super_admin',
|
||||
(string) config('pireon.default_role'),
|
||||
]));
|
||||
}
|
||||
}
|
||||
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, []);
|
||||
}
|
||||
}
|
||||
74
app/Support/PreviewLink.php
Normal file
74
app/Support/PreviewLink.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
|
||||
/**
|
||||
* A shareable link to a draft on the public site.
|
||||
*
|
||||
* The shared `SITE_PREVIEW_TOKEN` still exists, but it is one secret, never
|
||||
* rotated, that has to be pasted into a curl command. This is the same idea
|
||||
* made usable: a link somebody can send to whoever needs to look, scoped to
|
||||
* one record and one language, and useless a few hours later.
|
||||
*
|
||||
* The token carries the record it points at rather than being a bearer of
|
||||
* "show me anything", so passing it around leaks one draft rather than every
|
||||
* unpublished thing on the site.
|
||||
*/
|
||||
final class PreviewLink
|
||||
{
|
||||
/**
|
||||
* How long a link stays good. Long enough to send it and get an answer,
|
||||
* short enough that one forwarded in an e-mail thread stops working.
|
||||
*/
|
||||
public const HOURS = 8;
|
||||
|
||||
public static function for(Model $record, string $type, string $locale): ?string
|
||||
{
|
||||
$slug = $record->getTranslation('slug', $locale, useFallbackLocale: false);
|
||||
|
||||
// No slug in this language means no address to preview.
|
||||
if (blank($slug)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = SiteUrls::path($type, $locale, (string) $slug);
|
||||
|
||||
$token = Crypt::encryptString(json_encode([
|
||||
'type' => $type,
|
||||
'id' => $record->getKey(),
|
||||
'locale' => $locale,
|
||||
'expires' => now()->addHours(self::HOURS)->timestamp,
|
||||
], JSON_THROW_ON_ERROR));
|
||||
|
||||
return rtrim((string) config('site.url'), '/').$path.'?preview='.urlencode($token);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a token says, or null when it is expired, forged or unreadable.
|
||||
*
|
||||
* @return array{type: string, id: int|string, locale: string}|null
|
||||
*/
|
||||
public static function open(string $token): ?array
|
||||
{
|
||||
try {
|
||||
$payload = json_decode(Crypt::decryptString($token), true, flags: JSON_THROW_ON_ERROR);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! is_array($payload) || ($payload['expires'] ?? 0) < now()->timestamp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => (string) $payload['type'],
|
||||
'id' => $payload['id'],
|
||||
'locale' => (string) $payload['locale'],
|
||||
];
|
||||
}
|
||||
}
|
||||
82
app/Support/ReviewTarget.php
Normal file
82
app/Support/ReviewTarget.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Filament\Resources\Pages\PageResource;
|
||||
use App\Filament\Resources\Projects\ProjectResource;
|
||||
use App\Filament\Resources\Services\ServiceResource;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Models\Service;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Where a record under review lives, in words a notification can use.
|
||||
*
|
||||
* The panel-facing half of reviews, kept out of the models on purpose: a page
|
||||
* has no business knowing which Filament resource happens to edit it. Written
|
||||
* as an explicit map rather than resolved dynamically because there are three
|
||||
* of them and there is no honest way to guess the fourth.
|
||||
*/
|
||||
final class ReviewTarget
|
||||
{
|
||||
/**
|
||||
* @var array<class-string<Model>, class-string>
|
||||
*/
|
||||
protected const RESOURCES = [
|
||||
Page::class => PageResource::class,
|
||||
Project::class => ProjectResource::class,
|
||||
Service::class => ServiceResource::class,
|
||||
];
|
||||
|
||||
public static function url(Model $record): string
|
||||
{
|
||||
$resource = self::RESOURCES[$record::class] ?? null;
|
||||
|
||||
return $resource
|
||||
? $resource::getUrl('edit', ['record' => $record])
|
||||
: url('/admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* The permission that decides who can answer a review of this record.
|
||||
*
|
||||
* Derived from the table name, which matches the permission prefix for
|
||||
* every model that has a `publish` ability — the catalogue in
|
||||
* `Permissions::RESOURCES` is keyed the same way, and the unit test over
|
||||
* it is what keeps that true.
|
||||
*/
|
||||
public static function permission(Model $record): string
|
||||
{
|
||||
return $record->getTable().'.publish';
|
||||
}
|
||||
|
||||
/**
|
||||
* How many of these are waiting for the person reading the sidebar.
|
||||
*
|
||||
* Cached for a minute because Filament asks every resource for its badge
|
||||
* on every page render, and three counting queries to draw three numbers
|
||||
* would make the whole panel slower — the same reason the health badge is
|
||||
* cached. A minute is well inside the time it takes somebody to notice.
|
||||
*
|
||||
* @param class-string<Model> $model
|
||||
*/
|
||||
public static function waitingFor(string $model, ?User $user): ?int
|
||||
{
|
||||
if ($user === null || $user->cannot((new $model)->getTable().'.publish')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$count = Cache::remember(
|
||||
"reviews:waiting:{$model}:{$user->getAuthIdentifier()}",
|
||||
now()->addMinute(),
|
||||
fn () => $model::query()->awaitingReviewBy($user)->count(),
|
||||
);
|
||||
|
||||
return $count > 0 ? $count : null;
|
||||
}
|
||||
}
|
||||
76
app/Support/SiteUrls.php
Normal file
76
app/Support/SiteUrls.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Builds the public-site URLs and hreflang alternates for a translatable
|
||||
* record.
|
||||
*
|
||||
* This lives in the API rather than the frontend for one reason: only the API
|
||||
* knows that `/ca/projectes/el-meu-projecte` and `/proyectos/mi-proyecto` are
|
||||
* the same record. Without it the frontend cannot emit correct `hreflang`
|
||||
* tags, and search engines treat the three languages as duplicates.
|
||||
*/
|
||||
final class SiteUrls
|
||||
{
|
||||
/**
|
||||
* Every locale in which a record has a slug, mapped to its public path.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function alternates(Model $record, string $type, string $slugField = 'slug'): array
|
||||
{
|
||||
$alternates = [];
|
||||
|
||||
foreach (array_keys(config('locales.supported')) as $locale) {
|
||||
$slug = $record->getTranslation($slugField, $locale, useFallbackLocale: false);
|
||||
|
||||
// A locale without its own slug has no URL of its own — emitting
|
||||
// one would advertise a page that does not exist.
|
||||
if (blank($slug)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$alternates[$locale] = self::path($type, $locale, (string) $slug);
|
||||
}
|
||||
|
||||
return $alternates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute URLs for the same set, ready to drop into `<link hreflang>`.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function absoluteAlternates(Model $record, string $type, string $slugField = 'slug'): array
|
||||
{
|
||||
return array_map(
|
||||
fn (string $path) => rtrim(config('site.url'), '/').$path,
|
||||
self::alternates($record, $type, $slugField),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The path a record occupies on the public site, e.g. "/ca/projectes/foo".
|
||||
*/
|
||||
public static function path(string $type, string $locale, string $slug): string
|
||||
{
|
||||
$segments = [];
|
||||
|
||||
if ($locale !== config('locales.default') || config('site.prefix_default_locale')) {
|
||||
$segments[] = $locale;
|
||||
}
|
||||
|
||||
if ($prefix = config("site.paths.{$type}.{$locale}")) {
|
||||
$segments[] = $prefix;
|
||||
}
|
||||
|
||||
$segments[] = $slug;
|
||||
|
||||
return '/'.implode('/', array_filter($segments, fn ($s) => $s !== ''));
|
||||
}
|
||||
}
|
||||
124
app/Support/TranslatablePayload.php
Normal file
124
app/Support/TranslatablePayload.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Translates between the API's JSON shape and the locale => value maps that
|
||||
* spatie/laravel-translatable stores.
|
||||
*
|
||||
* Responses resolve each translatable field to the request's active locale, so
|
||||
* a plain client sees plain strings. Clients that need every language ask for
|
||||
* it explicitly with `?with=translations` and get a `translations` object
|
||||
* alongside.
|
||||
*
|
||||
* Requests accept either shape: a bare string writes the active locale only
|
||||
* and leaves other languages untouched, while an object writes exactly the
|
||||
* locales it names.
|
||||
*/
|
||||
final class TranslatablePayload
|
||||
{
|
||||
/**
|
||||
* Field values resolved to a single locale, with the configured fallback
|
||||
* filling any gaps.
|
||||
*
|
||||
* @param array<int, string> $fields
|
||||
* @return array<string, string|null>
|
||||
*/
|
||||
public static function resolve(Model $model, array $fields, ?string $locale = null): array
|
||||
{
|
||||
$locale ??= app()->getLocale();
|
||||
$fallback = (string) config('locales.fallback');
|
||||
|
||||
$resolved = [];
|
||||
|
||||
foreach ($fields as $field) {
|
||||
$value = $model->getTranslation($field, $locale, useFallbackLocale: false);
|
||||
|
||||
if (blank($value) && $locale !== $fallback) {
|
||||
$value = $model->getTranslation($field, $fallback, useFallbackLocale: false);
|
||||
}
|
||||
|
||||
$resolved[$field] = blank($value) ? null : $value;
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* The full locale => value map for each field, limited to supported
|
||||
* locales so retired languages do not leak into responses.
|
||||
*
|
||||
* @param array<int, string> $fields
|
||||
* @return array<string, array<string, string>>
|
||||
*/
|
||||
public static function all(Model $model, array $fields): array
|
||||
{
|
||||
$supported = array_keys(config('locales.supported'));
|
||||
|
||||
$translations = [];
|
||||
|
||||
foreach ($fields as $field) {
|
||||
$translations[$field] = array_filter(
|
||||
$model->getTranslations($field),
|
||||
fn (string $locale) => in_array($locale, $supported, true),
|
||||
ARRAY_FILTER_USE_KEY,
|
||||
);
|
||||
}
|
||||
|
||||
return $translations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise an incoming value into a locale => value map.
|
||||
*
|
||||
* @param string|array<string, string|null> $value
|
||||
* @return array<string, string|null>
|
||||
*/
|
||||
public static function normalise(string|array $value, ?string $locale = null): array
|
||||
{
|
||||
if (is_string($value)) {
|
||||
return [$locale ?? app()->getLocale() => $value];
|
||||
}
|
||||
|
||||
$supported = array_keys(config('locales.supported'));
|
||||
|
||||
return array_filter(
|
||||
$value,
|
||||
fn (string $key) => in_array($key, $supported, true),
|
||||
ARRAY_FILTER_USE_KEY,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply validated input to a model, merging translatable fields into their
|
||||
* existing maps instead of replacing them wholesale — a request that only
|
||||
* sends Catalan must not wipe the Spanish text.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @param array<int, string> $translatable
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function merge(Model $model, array $data, array $translatable): array
|
||||
{
|
||||
foreach ($translatable as $field) {
|
||||
if (! array_key_exists($field, $data)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$incoming = self::normalise($data[$field]);
|
||||
$existing = $model->exists ? $model->getTranslations($field) : [];
|
||||
|
||||
// A null value for a locale clears that translation.
|
||||
$data[$field] = array_filter(
|
||||
array_merge($existing, $incoming),
|
||||
fn ($v) => filled($v),
|
||||
);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
180
app/Support/TranslationFreshness.php
Normal file
180
app/Support/TranslationFreshness.php
Normal 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'],
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user