diff --git a/README.md b/README.md index 8166ac2..4c954d9 100644 --- a/README.md +++ b/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 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 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. @@ -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 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 --media # tira también imágenes huérfanas diff --git a/app/Console/Commands/NotifyExpiringCommand.php b/app/Console/Commands/NotifyExpiringCommand.php new file mode 100644 index 0000000..0522c04 --- /dev/null +++ b/app/Console/Commands/NotifyExpiringCommand.php @@ -0,0 +1,60 @@ +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; + } +} diff --git a/app/Filament/Widgets/ExpiringSoon.php b/app/Filament/Widgets/ExpiringSoon.php new file mode 100644 index 0000000..b71344b --- /dev/null +++ b/app/Filament/Widgets/ExpiringSoon.php @@ -0,0 +1,40 @@ +isNotEmpty(); + } + + /** + * @return Collection> + */ + public function items(): Collection + { + return Expiring::for(Expiring::within(self::DAYS), Auth::user()); + } +} diff --git a/app/Notifications/ExpiringSoon.php b/app/Notifications/ExpiringSoon.php new file mode 100644 index 0000000..d5b57b7 --- /dev/null +++ b/app/Notifications/ExpiringSoon.php @@ -0,0 +1,50 @@ +> $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(); + } +} diff --git a/app/Support/Expiring.php b/app/Support/Expiring.php new file mode 100644 index 0000000..96056c4 --- /dev/null +++ b/app/Support/Expiring.php @@ -0,0 +1,83 @@ + + */ + 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> $items + * @return Collection> + */ + 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), + ]; + } +} diff --git a/config/site.php b/config/site.php index 7dc0ded..d09db4a 100644 --- a/config/site.php +++ b/config/site.php @@ -165,4 +165,17 @@ return [ // the notification bell is always on, and mail needs a working mailer. '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], + ]; diff --git a/lang/ca/console.php b/lang/ca/console.php index f14fbb2..bde0148 100644 --- a/lang/ca/console.php +++ b/lang/ca/console.php @@ -25,4 +25,9 @@ return [ '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.', ], + + '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.', + ], ]; diff --git a/lang/ca/panel.php b/lang/ca/panel.php index c755e58..445ef91 100644 --- a/lang/ca/panel.php +++ b/lang/ca/panel.php @@ -144,6 +144,25 @@ return [ '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' => [ 'label' => 'Duplicar', 'heading' => 'Duplicar com a esborrany', diff --git a/lang/en/console.php b/lang/en/console.php index 2a19b86..4ea6d8e 100644 --- a/lang/en/console.php +++ b/lang/en/console.php @@ -25,4 +25,9 @@ return [ 'off_site' => 'Off the site because they expired', '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.', + ], ]; diff --git a/lang/en/panel.php b/lang/en/panel.php index 7ab8977..0b5e837 100644 --- a/lang/en/panel.php +++ b/lang/en/panel.php @@ -144,6 +144,25 @@ return [ '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' => [ 'label' => 'Duplicate', 'heading' => 'Duplicate as a draft', diff --git a/lang/es/console.php b/lang/es/console.php index 5b8a070..4fe0cb1 100644 --- a/lang/es/console.php +++ b/lang/es/console.php @@ -25,4 +25,9 @@ return [ '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.', ], + + 'expiring' => [ + 'none' => 'Nada vence en los días de aviso.', + 'summary' => '{0} Nadie a quien avisar.|{1} Avisada una persona.|[2,*] Avisadas :count personas.', + ], ]; diff --git a/lang/es/panel.php b/lang/es/panel.php index bebb2fc..698bbae 100644 --- a/lang/es/panel.php +++ b/lang/es/panel.php @@ -144,6 +144,25 @@ return [ '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' => [ 'label' => 'Duplicar', 'heading' => 'Duplicar como borrador', diff --git a/resources/views/filament/widgets/expiring-soon.blade.php b/resources/views/filament/widgets/expiring-soon.blade.php new file mode 100644 index 0000000..5226904 --- /dev/null +++ b/resources/views/filament/widgets/expiring-soon.blade.php @@ -0,0 +1,27 @@ + + +
    + @foreach ($this->items() as $item) +
  • +
    + + {{ __('panel.expiring.types.'.$item['type']) }} + + + + {{ $item['title'] }} + +
    + + + {{ __('panel.expiring.events.'.$item['event'], ['when' => $item['at']->diffForHumans(), 'date' => $item['at']->isoFormat('LL')]) }} + +
  • + @endforeach +
+
+
diff --git a/routes/console.php b/routes/console.php index 7159266..50b2a83 100644 --- a/routes/console.php +++ b/routes/console.php @@ -46,6 +46,12 @@ Schedule::command('repos:sync') ->withoutOverlapping() ->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 // they are kept for `audit.retention_days` and no longer. Schedule::command('model:prune', ['--model' => [AuditLog::class]]) diff --git a/tests/Feature/Panel/ExpiringSoonTest.php b/tests/Feature/Panel/ExpiringSoonTest.php new file mode 100644 index 0000000..46a6b0a --- /dev/null +++ b/tests/Feature/Panel/ExpiringSoonTest.php @@ -0,0 +1,80 @@ +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); + } +}