Warn about calls closing and content coming off the site

A dashboard widget lists what expires in the next two weeks, and a daily
command reminds whoever edits it a week and a day ahead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 10:16:24 +02:00
parent 366568b793
commit 773d3901c6
15 changed files with 442 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\User;
use App\Notifications\ExpiringSoon;
use App\Support\Expiring;
use Illuminate\Console\Command;
/**
* Tells editors, once a day, about what closes or comes off the site on one
* of the reminder days ahead (a week and a day, by default) — so each thing
* is mentioned a couple of times rather than every morning for a fortnight.
*/
class NotifyExpiringCommand extends Command
{
protected $signature = 'content:expiring
{--dry-run : List who would be told what, without telling them}';
protected $description = 'Remind editors of calls closing and content coming off the site soon';
public function handle(): int
{
$days = array_map('intval', (array) config('site.expiry_reminders', [7, 1]));
$due = Expiring::within(max($days))
->filter(fn (array $item) => in_array((int) now()->startOfDay()->diffInDays($item['at']->copy()->startOfDay()), $days, true))
->values();
if ($due->isEmpty()) {
$this->components->info(__('console.expiring.none'));
return self::SUCCESS;
}
$told = 0;
User::query()->where('is_active', true)->get()->each(function (User $user) use ($due, &$told): void {
$mine = Expiring::for($due, $user);
if ($mine->isEmpty()) {
return;
}
$this->components->twoColumnDetail($user->name, (string) $mine->count());
if (! $this->option('dry-run')) {
$user->notify(new ExpiringSoon($mine));
}
$told++;
});
$this->components->info(trans_choice('console.expiring.summary', $told, ['count' => $told]));
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets;
use App\Support\Expiring;
use Filament\Widgets\Widget;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
/**
* "Coming up": calls closing and content coming off the site in the next two
* weeks, for whoever can do something about them.
*/
class ExpiringSoon extends Widget
{
public const DAYS = 14;
protected static ?int $sort = 1;
protected string $view = 'filament.widgets.expiring-soon';
protected int|string|array $columnSpan = 'full';
public static function canView(): bool
{
$user = Auth::user();
return $user !== null && Expiring::for(Expiring::within(self::DAYS), $user)->isNotEmpty();
}
/**
* @return Collection<int, array<string, mixed>>
*/
public function items(): Collection
{
return Expiring::for(Expiring::within(self::DAYS), Auth::user());
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Notifications;
use Filament\Actions\Action;
use Filament\Notifications\Notification as FilamentNotification;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Collection;
/**
* A digest: what this person looks after that is about to close or come off
* the site.
*/
class ExpiringSoon extends Notification
{
/**
* @param Collection<int, array<string, mixed>> $items
*/
public function __construct(public readonly Collection $items) {}
public function via(object $notifiable): array
{
return ['database'];
}
public function toDatabase(object $notifiable): array
{
$first = $this->items->first();
return FilamentNotification::make()
->title(trans_choice('panel.expiring.notification.title', $this->items->count(), ['count' => $this->items->count()]))
->body($this->items
->map(fn (array $item) => $item['title'].' — '.__('panel.expiring.events.'.$item['event'], [
'when' => $item['at']->diffForHumans(),
'date' => $item['at']->isoFormat('LL'),
]))
->implode("\n"))
->icon('heroicon-o-clock')
->iconColor('warning')
->actions($this->items->count() === 1 ? [
Action::make('open')
->label(__('panel.expiring.notification.open'))
->url($first['url'])
->markAsRead(),
] : [])
->getDatabaseMessage();
}
}

83
app/Support/Expiring.php Normal file
View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Enums\CallStatus;
use App\Filament\Resources\Calls\CallResource;
use App\Models\Call;
use App\Models\Page;
use App\Models\Project;
use App\Models\Service;
use App\Models\User;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
/**
* What is about to stop being true on the site: calls about to close, and
* published content about to come off by itself.
*
* Neither is a mistake — both were set on purpose — which is why the health
* check does not flag them. But a call that closes on Friday with nobody
* having replied, or a page that vanishes on the first of the month, is better
* known about a week ahead than found out after.
*/
final class Expiring
{
/**
* @return Collection<int, array{type: string, record: Model, title: string, event: string, at: CarbonInterface, url: string}>
*/
public static function within(int $days): Collection
{
$until = now()->addDays($days)->endOfDay();
$items = collect();
Call::query()
->where('status', CallStatus::Open)
->whereNotNull('closes_at')
->whereBetween('closes_at', [now()->startOfDay(), $until])
->get()
->each(fn (Call $call) => $items->push(self::item('calls', $call, 'closes', $call->closes_at)));
foreach (['pages' => Page::class, 'projects' => Project::class, 'services' => Service::class] as $type => $model) {
$model::query()
->published()
->whereNotNull('unpublish_at')
->whereBetween('unpublish_at', [now(), $until])
->get()
->each(fn (Model $record) => $items->push(self::item($type, $record, 'unpublishes', $record->unpublish_at)));
}
return $items->sortBy(fn (array $item) => $item['at']->getTimestamp())->values();
}
/**
* Only those this person may do something about.
*
* @param Collection<int, array<string, mixed>> $items
* @return Collection<int, array<string, mixed>>
*/
public static function for(Collection $items, User $user): Collection
{
return $items->filter(fn (array $item) => $user->can('update', $item['record']))->values();
}
/**
* @return array{type: string, record: Model, title: string, event: string, at: CarbonInterface, url: string}
*/
protected static function item(string $type, Model $record, string $event, CarbonInterface $at): array
{
return [
'type' => $type,
'record' => $record,
'title' => ContentTypes::title($record),
'event' => $event,
'at' => $at,
'url' => $record instanceof Call
? CallResource::getUrl('edit', ['record' => $record])
: ReviewTarget::url($record),
];
}
}