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

@@ -1165,6 +1165,16 @@ abre en cuanto se crea. Antes la copia salía sin slug —un registro sin
dirección, que la salud del sitio marcaba— y conservaba el estado: duplicar una dirección, que la salud del sitio marcaba— y conservaba el estado: duplicar una
página publicada daba otra «publicada» sin URL. página publicada daba otra «publicada» sin URL.
**Próximos vencimientos** — en el dashboard, las convocatorias que cierran y
el contenido que sale de la web solo (`unpublish_at`) en las próximas dos
semanas, a quien puede editarlos. Y `content:expiring`, cada día a las 8, avisa
en la campana del panel a esas mismas personas **una semana y un día antes**
(`site.expiry_reminders`): dos avisos por cosa, no uno cada mañana durante
quince días. Nada de esto es un error —las fechas se pusieron a propósito— y
por eso no está en la salud del sitio; pero una convocatoria que cierra el
viernes sin que nadie haya contestado al buzón se ve mejor con una semana de
margen.
**Vista previa en el formulario** — el mismo botón de vista previa está también **Vista previa en el formulario** — el mismo botón de vista previa está también
arriba de la edición de páginas, proyectos, servicios y convocatorias: se mira arriba de la edición de páginas, proyectos, servicios y convocatorias: se mira
en la web real sin salir de lo que se está escribiendo. en la web real sin salir de lo que se está escribiendo.
@@ -1688,6 +1698,7 @@ php artisan media:process # deriva lo que falte de las imáge
php artisan media:process --all # reprocesa todas php artisan media:process --all # reprocesa todas
php artisan content:schedule # cierra convocatorias vencidas php artisan content:schedule # cierra convocatorias vencidas
php artisan content:expiring --dry-run # a quién se avisaría de lo que vence
php artisan content:prune --dry-run # qué se tiraría php artisan content:prune --dry-run # qué se tiraría
php artisan content:prune --media # tira también imágenes huérfanas php artisan content:prune --media # tira también imágenes huérfanas

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

View File

@@ -165,4 +165,17 @@ return [
// the notification bell is always on, and mail needs a working mailer. // the notification bell is always on, and mail needs a working mailer.
'notify_by_mail' => (bool) env('SITE_SUBMISSION_MAIL', false), 'notify_by_mail' => (bool) env('SITE_SUBMISSION_MAIL', false),
], ],
/*
|--------------------------------------------------------------------------
| Expiry reminders
|--------------------------------------------------------------------------
|
| Days ahead of a call closing, or of published content coming off the
| site, on which `content:expiring` tells the people who edit it.
|
*/
'expiry_reminders' => [7, 1],
]; ];

View File

@@ -25,4 +25,9 @@ return [
'off_site' => 'Fora del web per haver caducat', 'off_site' => 'Fora del web per haver caducat',
'summary' => '{0}No hi havia convocatòries per tancar.|{1}S\'ha tancat 1 convocatòria vençuda.|[2,*]S\'han tancat :count convocatòries vençudes.', 'summary' => '{0}No hi havia convocatòries per tancar.|{1}S\'ha tancat 1 convocatòria vençuda.|[2,*]S\'han tancat :count convocatòries vençudes.',
], ],
'expiring' => [
'none' => 'No venç res en els dies d\'avís.',
'summary' => '{0} Ningú a qui avisar.|{1} Avisada una persona.|[2,*] Avisades :count persones.',
],
]; ];

View File

@@ -144,6 +144,25 @@ return [
'select_all' => 'Selecciona-ho tot', 'select_all' => 'Selecciona-ho tot',
], ],
'expiring' => [
'heading' => 'Pròxims venciments',
'description' => 'Convocatòries que tanquen i contingut que surt del web en els pròxims :days dies.',
'types' => [
'calls' => 'Convocatòria',
'pages' => 'Pàgina',
'projects' => 'Projecte',
'services' => 'Servei',
],
'events' => [
'closes' => 'tanca :when (:date)',
'unpublishes' => 'surt del web :when (:date)',
],
'notification' => [
'title' => '{1} Una cosa venç aviat|[2,*] :count coses vencen aviat',
'open' => 'Obrir',
],
],
'duplicate' => [ 'duplicate' => [
'label' => 'Duplicar', 'label' => 'Duplicar',
'heading' => 'Duplicar com a esborrany', 'heading' => 'Duplicar com a esborrany',

View File

@@ -25,4 +25,9 @@ return [
'off_site' => 'Off the site because they expired', 'off_site' => 'Off the site because they expired',
'summary' => '{0}No calls needed closing.|{1}Closed 1 expired call.|[2,*]Closed :count expired calls.', 'summary' => '{0}No calls needed closing.|{1}Closed 1 expired call.|[2,*]Closed :count expired calls.',
], ],
'expiring' => [
'none' => 'Nothing expires on the reminder days.',
'summary' => '{0} Nobody to tell.|{1} Told one person.|[2,*] Told :count people.',
],
]; ];

View File

@@ -144,6 +144,25 @@ return [
'select_all' => 'Select all', 'select_all' => 'Select all',
], ],
'expiring' => [
'heading' => 'Coming up',
'description' => 'Calls closing and content coming off the site in the next :days days.',
'types' => [
'calls' => 'Call',
'pages' => 'Page',
'projects' => 'Project',
'services' => 'Service',
],
'events' => [
'closes' => 'closes :when (:date)',
'unpublishes' => 'comes off the site :when (:date)',
],
'notification' => [
'title' => '{1} One thing expires soon|[2,*] :count things expire soon',
'open' => 'Open',
],
],
'duplicate' => [ 'duplicate' => [
'label' => 'Duplicate', 'label' => 'Duplicate',
'heading' => 'Duplicate as a draft', 'heading' => 'Duplicate as a draft',

View File

@@ -25,4 +25,9 @@ return [
'off_site' => 'Fuera de la web por haber caducado', 'off_site' => 'Fuera de la web por haber caducado',
'summary' => '{0}No había convocatorias que cerrar.|{1}Se cerró 1 convocatoria vencida.|[2,*]Se cerraron :count convocatorias vencidas.', 'summary' => '{0}No había convocatorias que cerrar.|{1}Se cerró 1 convocatoria vencida.|[2,*]Se cerraron :count convocatorias vencidas.',
], ],
'expiring' => [
'none' => 'Nada vence en los días de aviso.',
'summary' => '{0} Nadie a quien avisar.|{1} Avisada una persona.|[2,*] Avisadas :count personas.',
],
]; ];

View File

@@ -144,6 +144,25 @@ return [
'select_all' => 'Seleccionar todos', 'select_all' => 'Seleccionar todos',
], ],
'expiring' => [
'heading' => 'Próximos vencimientos',
'description' => 'Convocatorias que cierran y contenido que sale de la web en los próximos :days días.',
'types' => [
'calls' => 'Convocatoria',
'pages' => 'Página',
'projects' => 'Proyecto',
'services' => 'Servicio',
],
'events' => [
'closes' => 'cierra :when (:date)',
'unpublishes' => 'sale de la web :when (:date)',
],
'notification' => [
'title' => '{1} Una cosa vence pronto|[2,*] :count cosas vencen pronto',
'open' => 'Abrir',
],
],
'duplicate' => [ 'duplicate' => [
'label' => 'Duplicar', 'label' => 'Duplicar',
'heading' => 'Duplicar como borrador', 'heading' => 'Duplicar como borrador',

View File

@@ -0,0 +1,27 @@
<x-filament-widgets::widget>
<x-filament::section
:heading="__('panel.expiring.heading')"
:description="__('panel.expiring.description', ['days' => \App\Filament\Widgets\ExpiringSoon::DAYS])"
icon="heroicon-o-clock"
>
<ul class="divide-y divide-gray-100 dark:divide-white/5">
@foreach ($this->items() as $item)
<li class="flex flex-wrap items-center justify-between gap-2 py-2">
<div class="flex items-center gap-2">
<x-filament::badge :color="$item['event'] === 'closes' ? 'warning' : 'gray'">
{{ __('panel.expiring.types.'.$item['type']) }}
</x-filament::badge>
<x-filament::link :href="$item['url']">
{{ $item['title'] }}
</x-filament::link>
</div>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ __('panel.expiring.events.'.$item['event'], ['when' => $item['at']->diffForHumans(), 'date' => $item['at']->isoFormat('LL')]) }}
</span>
</li>
@endforeach
</ul>
</x-filament::section>
</x-filament-widgets::widget>

View File

@@ -46,6 +46,12 @@ Schedule::command('repos:sync')
->withoutOverlapping() ->withoutOverlapping()
->runInBackground(); ->runInBackground();
// A week and a day before a call closes or content comes off the site, the
// people who could do something about it are told.
Schedule::command('content:expiring')
->dailyAt('08:00')
->withoutOverlapping();
// Old audit entries: they name people and the addresses they worked from, so // Old audit entries: they name people and the addresses they worked from, so
// they are kept for `audit.retention_days` and no longer. // they are kept for `audit.retention_days` and no longer.
Schedule::command('model:prune', ['--model' => [AuditLog::class]]) Schedule::command('model:prune', ['--model' => [AuditLog::class]])

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Panel;
use App\Enums\CallStatus;
use App\Filament\Widgets\ExpiringSoon as ExpiringSoonWidget;
use App\Models\Call;
use App\Models\Page;
use App\Notifications\ExpiringSoon;
use App\Support\Expiring;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Livewire\Livewire;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* Calls about to close and content about to come off the site: on the
* dashboard, and as a reminder a week and a day ahead.
*/
class ExpiringSoonTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
}
#[Test]
public function it_finds_what_closes_or_comes_off_soon_and_nothing_else(): void
{
$closing = Call::factory()->create(['status' => CallStatus::Open, 'closes_at' => now()->addDays(3)]);
Call::factory()->create(['status' => CallStatus::Open, 'closes_at' => now()->addMonths(2)]);
Call::factory()->create(['status' => CallStatus::Closed, 'closes_at' => now()->addDays(3)]);
$leaving = Page::factory()->published()->create(['unpublish_at' => now()->addDays(5)]);
Page::factory()->draft()->create(['unpublish_at' => now()->addDays(5)]);
$items = Expiring::within(14);
$this->assertSame([[$closing->id, 'closes'], [$leaving->id, 'unpublishes']], $items->map(fn ($i) => [$i['record']->id, $i['event']])->all());
}
#[Test]
public function the_dashboard_shows_it_to_whoever_can_act(): void
{
Call::factory()->create(['status' => CallStatus::Open, 'closes_at' => now()->addDays(3), 'title' => ['es' => 'Backend', 'en' => 'Backend']]);
$this->actingAs($this->userWithRoles('editor'));
$this->assertTrue(ExpiringSoonWidget::canView());
Livewire::test(ExpiringSoonWidget::class)->assertSee('Backend');
$this->actingAs($this->userWithRoles('viewer'));
$this->assertFalse(ExpiringSoonWidget::canView());
}
#[Test]
public function editors_are_reminded_a_week_and_a_day_ahead_only(): void
{
Notification::fake();
$editor = $this->userWithRoles('editor');
$viewer = $this->userWithRoles('viewer');
Call::factory()->create(['status' => CallStatus::Open, 'closes_at' => now()->addDays(7)]);
Call::factory()->create(['status' => CallStatus::Open, 'closes_at' => now()->addDays(4)]);
$this->artisan('content:expiring')->assertSuccessful();
Notification::assertSentTo($editor, ExpiringSoon::class, fn (ExpiringSoon $n) => $n->items->count() === 1);
Notification::assertNotSentTo($viewer, ExpiringSoon::class);
}
}