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:
11
README.md
11
README.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
27
resources/views/filament/widgets/expiring-soon.blade.php
Normal file
27
resources/views/filament/widgets/expiring-soon.blade.php
Normal 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>
|
||||||
@@ -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]])
|
||||||
|
|||||||
80
tests/Feature/Panel/ExpiringSoonTest.php
Normal file
80
tests/Feature/Panel/ExpiringSoonTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user