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

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

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

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

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

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

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

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

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

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

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

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

View File

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