Initial commit: Pireon Admin — headless admin panel and public API

Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer
collective, backed by the Pireon OAuth identity server. The public site is a
separate, server-rendered application consuming /api/public/v1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 17:55:27 +02:00
commit 3897113313
534 changed files with 64050 additions and 0 deletions

View File

@@ -0,0 +1,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);
}
}

View 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,
);
}
}

View 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(),
);
}
}

View 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);
}
}

View 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(),
);
}
}

View 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)));
}
}

View 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);
}
}

View 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']),
};
}
}

View 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,
);
}
}

View 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;
}
}

View 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(),
);
}
}

View 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(),
);
}
}

View 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,
);
}
}

View 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);
}
}

View 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(),
);
}
}

View 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(),
);
}
}

View 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(),
);
}
}

View 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(),
);
}
}

View 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(),
);
}
}