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:
60
app/Console/Commands/NotifyExpiringCommand.php
Normal file
60
app/Console/Commands/NotifyExpiringCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
40
app/Filament/Widgets/ExpiringSoon.php
Normal file
40
app/Filament/Widgets/ExpiringSoon.php
Normal 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());
|
||||
}
|
||||
}
|
||||
50
app/Notifications/ExpiringSoon.php
Normal file
50
app/Notifications/ExpiringSoon.php
Normal 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
83
app/Support/Expiring.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user