93 - Add feed staleness detection with configurable threshold and alerts

This commit is contained in:
myrmidex 2026-03-09 17:32:55 +01:00
parent 4feab96765
commit 062b00d01c
10 changed files with 407 additions and 0 deletions

View file

@ -0,0 +1,51 @@
<?php
namespace App\Jobs;
use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum;
use App\Models\Feed;
use App\Models\Notification;
use App\Models\Setting;
use App\Services\Notification\NotificationService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class CheckFeedStalenessJob implements ShouldQueue
{
use Queueable;
public function handle(NotificationService $notificationService): void
{
$thresholdHours = Setting::getFeedStalenessThreshold();
if ($thresholdHours === 0) {
return;
}
$staleFeeds = Feed::stale($thresholdHours)->get();
foreach ($staleFeeds as $feed) {
$alreadyNotified = Notification::query()
->where('type', NotificationTypeEnum::FEED_STALE)
->where('notifiable_type', $feed->getMorphClass())
->where('notifiable_id', $feed->getKey())
->unread()
->exists();
if ($alreadyNotified) {
continue;
}
$notificationService->send(
type: NotificationTypeEnum::FEED_STALE,
severity: NotificationSeverityEnum::WARNING,
title: "Feed \"{$feed->name}\" is stale",
message: $feed->last_fetched_at
? "Last fetched {$feed->last_fetched_at->diffForHumans()}. Threshold is {$thresholdHours} hours."
: "This feed has never been fetched. Threshold is {$thresholdHours} hours.",
notifiable: $feed,
);
}
}
}

View file

@ -13,6 +13,8 @@ class Settings extends Component
public int $articlePublishingInterval = 5;
public int $feedStalenessThreshold = 48;
public ?string $successMessage = null;
public ?string $errorMessage = null;
@ -22,6 +24,7 @@ public function mount(): void
$this->articleProcessingEnabled = Setting::isArticleProcessingEnabled();
$this->publishingApprovalsEnabled = Setting::isPublishingApprovalsEnabled();
$this->articlePublishingInterval = Setting::getArticlePublishingInterval();
$this->feedStalenessThreshold = Setting::getFeedStalenessThreshold();
}
public function toggleArticleProcessing(): void
@ -48,6 +51,16 @@ public function updateArticlePublishingInterval(): void
$this->showSuccess();
}
public function updateFeedStalenessThreshold(): void
{
$this->validate([
'feedStalenessThreshold' => 'required|integer|min:0',
]);
Setting::setFeedStalenessThreshold($this->feedStalenessThreshold);
$this->showSuccess();
}
protected function showSuccess(): void
{
$this->successMessage = 'Settings updated successfully!';

View file

@ -3,6 +3,7 @@
namespace App\Models;
use Database\Factories\FeedFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -86,6 +87,19 @@ public function getStatusAttribute(): string
}
}
/**
* @param Builder<Feed> $query
* @return Builder<Feed>
*/
public function scopeStale(Builder $query, int $thresholdHours): Builder
{
return $query->where('is_active', true)
->where(function (Builder $query) use ($thresholdHours) {
$query->whereNull('last_fetched_at')
->orWhere('last_fetched_at', '<', now()->subHours($thresholdHours));
});
}
/**
* @return BelongsToMany<PlatformChannel, $this>
*/

View file

@ -71,4 +71,14 @@ public static function setArticlePublishingInterval(int $minutes): void
{
static::set('article_publishing_interval', (string) $minutes);
}
public static function getFeedStalenessThreshold(): int
{
return (int) static::get('feed_staleness_threshold', 48);
}
public static function setFeedStalenessThreshold(int $hours): void
{
static::set('feed_staleness_threshold', (string) $hours);
}
}

View file

@ -104,6 +104,51 @@ class="flex-shrink-0"
</div>
</div>
<!-- Feed Monitoring Settings -->
<div class="bg-white rounded-lg shadow">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900 flex items-center">
<svg class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
Feed Monitoring
</h2>
<p class="mt-1 text-sm text-gray-500">
Configure alerts for feeds that stop returning articles
</p>
</div>
<div class="px-6 py-4 space-y-4">
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-medium text-gray-900">
Staleness Threshold (hours)
</h3>
<p class="text-sm text-gray-500">
Alert when a feed hasn't been fetched for this many hours. Set to 0 to disable.
</p>
</div>
<div class="flex items-center space-x-2">
<input
type="number"
wire:model="feedStalenessThreshold"
min="0"
step="1"
class="w-20 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
/>
<button
wire:click="updateFeedStalenessThreshold"
class="inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
Save
</button>
</div>
</div>
@error('feedStalenessThreshold')
<p class="text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
</div>
<!-- Status Messages -->
@if ($successMessage)
<div class="bg-green-50 border border-green-200 rounded-md p-4">

View file

@ -1,6 +1,7 @@
<?php
use App\Jobs\ArticleDiscoveryJob;
use App\Jobs\CheckFeedStalenessJob;
use App\Jobs\PublishNextArticleJob;
use App\Jobs\SyncChannelPostsJob;
use Illuminate\Support\Facades\Schedule;
@ -20,3 +21,9 @@
->name('refresh-articles')
->withoutOverlapping()
->onOneServer();
Schedule::job(new CheckFeedStalenessJob)
->hourly()
->name('check-feed-staleness')
->withoutOverlapping()
->onOneServer();

View file

@ -0,0 +1,133 @@
<?php
namespace Tests\Feature\Jobs;
use App\Enums\NotificationSeverityEnum;
use App\Enums\NotificationTypeEnum;
use App\Jobs\CheckFeedStalenessJob;
use App\Models\Feed;
use App\Models\Notification;
use App\Models\Setting;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CheckFeedStalenessJobTest extends TestCase
{
use RefreshDatabase;
public function test_stale_feed_creates_notification(): void
{
$feed = Feed::factory()->create([
'is_active' => true,
'last_fetched_at' => now()->subHours(50),
]);
$this->dispatch();
$this->assertDatabaseHas('notifications', [
'type' => NotificationTypeEnum::FEED_STALE->value,
'severity' => NotificationSeverityEnum::WARNING->value,
'notifiable_type' => $feed->getMorphClass(),
'notifiable_id' => $feed->id,
]);
}
public function test_fresh_feed_does_not_create_notification(): void
{
Feed::factory()->create([
'is_active' => true,
'last_fetched_at' => now()->subHours(10),
]);
$this->dispatch();
$this->assertDatabaseCount('notifications', 0);
}
public function test_inactive_feed_does_not_create_notification(): void
{
Feed::factory()->create([
'is_active' => false,
'last_fetched_at' => now()->subHours(100),
]);
$this->dispatch();
$this->assertDatabaseCount('notifications', 0);
}
public function test_does_not_create_duplicate_notification_when_unread_exists(): void
{
$feed = Feed::factory()->create([
'is_active' => true,
'last_fetched_at' => now()->subHours(50),
]);
Notification::factory()
->type(NotificationTypeEnum::FEED_STALE)
->unread()
->create([
'notifiable_type' => $feed->getMorphClass(),
'notifiable_id' => $feed->id,
]);
$this->dispatch();
$this->assertDatabaseCount('notifications', 1);
}
public function test_creates_new_notification_when_previous_is_read(): void
{
$feed = Feed::factory()->create([
'is_active' => true,
'last_fetched_at' => now()->subHours(50),
]);
Notification::factory()
->type(NotificationTypeEnum::FEED_STALE)
->read()
->create([
'notifiable_type' => $feed->getMorphClass(),
'notifiable_id' => $feed->id,
]);
$this->dispatch();
$this->assertDatabaseCount('notifications', 2);
}
public function test_threshold_zero_disables_check(): void
{
Setting::setFeedStalenessThreshold(0);
Feed::factory()->create([
'is_active' => true,
'last_fetched_at' => now()->subHours(100),
]);
$this->dispatch();
$this->assertDatabaseCount('notifications', 0);
}
public function test_never_fetched_feed_creates_notification(): void
{
$feed = Feed::factory()->create([
'is_active' => true,
'last_fetched_at' => null,
]);
$this->dispatch();
$this->assertDatabaseHas('notifications', [
'type' => NotificationTypeEnum::FEED_STALE->value,
'notifiable_type' => $feed->getMorphClass(),
'notifiable_id' => $feed->id,
]);
}
private function dispatch(): void
{
(new CheckFeedStalenessJob)->handle(app(\App\Services\Notification\NotificationService::class));
}
}

View file

@ -0,0 +1,54 @@
<?php
namespace Tests\Feature\Livewire;
use App\Livewire\Settings;
use App\Models\Setting;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class SettingsTest extends TestCase
{
use RefreshDatabase;
public function test_mount_loads_feed_staleness_threshold(): void
{
Setting::setFeedStalenessThreshold(72);
Livewire::test(Settings::class)
->assertSet('feedStalenessThreshold', 72);
}
public function test_mount_loads_default_feed_staleness_threshold(): void
{
Livewire::test(Settings::class)
->assertSet('feedStalenessThreshold', 48);
}
public function test_update_feed_staleness_threshold_saves_value(): void
{
Livewire::test(Settings::class)
->set('feedStalenessThreshold', 24)
->call('updateFeedStalenessThreshold')
->assertHasNoErrors();
$this->assertSame(24, Setting::getFeedStalenessThreshold());
}
public function test_update_feed_staleness_threshold_validates_minimum(): void
{
Livewire::test(Settings::class)
->set('feedStalenessThreshold', -1)
->call('updateFeedStalenessThreshold')
->assertHasErrors(['feedStalenessThreshold' => 'min']);
}
public function test_update_feed_staleness_threshold_shows_success_message(): void
{
Livewire::test(Settings::class)
->set('feedStalenessThreshold', 24)
->call('updateFeedStalenessThreshold')
->assertSet('successMessage', 'Settings updated successfully!');
}
}

View file

@ -313,6 +313,56 @@ public function test_feed_settings_can_be_complex_structure(): void
$this->assertTrue($feed->settings['schedule']['enabled']);
}
public function test_scope_stale_includes_active_feeds_with_old_last_fetched_at(): void
{
$staleFeed = Feed::factory()->create([
'is_active' => true,
'last_fetched_at' => now()->subHours(50),
]);
$staleFeeds = Feed::stale(48)->get();
$this->assertCount(1, $staleFeeds);
$this->assertTrue($staleFeeds->contains('id', $staleFeed->id));
}
public function test_scope_stale_includes_active_feeds_never_fetched(): void
{
$neverFetchedFeed = Feed::factory()->create([
'is_active' => true,
'last_fetched_at' => null,
]);
$staleFeeds = Feed::stale(48)->get();
$this->assertCount(1, $staleFeeds);
$this->assertTrue($staleFeeds->contains('id', $neverFetchedFeed->id));
}
public function test_scope_stale_excludes_fresh_feeds(): void
{
Feed::factory()->create([
'is_active' => true,
'last_fetched_at' => now()->subHours(10),
]);
$staleFeeds = Feed::stale(48)->get();
$this->assertCount(0, $staleFeeds);
}
public function test_scope_stale_excludes_inactive_feeds(): void
{
Feed::factory()->create([
'is_active' => false,
'last_fetched_at' => now()->subHours(100),
]);
$staleFeeds = Feed::stale(48)->get();
$this->assertCount(0, $staleFeeds);
}
public function test_feed_can_have_null_last_fetched_at(): void
{
$feed = Feed::factory()->create(['last_fetched_at' => null]);

View file

@ -39,4 +39,34 @@ public function test_set_article_publishing_interval_zero(): void
$this->assertSame(0, Setting::getArticlePublishingInterval());
}
public function test_get_feed_staleness_threshold_returns_default_when_not_set(): void
{
$this->assertSame(48, Setting::getFeedStalenessThreshold());
}
public function test_get_feed_staleness_threshold_returns_stored_value(): void
{
Setting::set('feed_staleness_threshold', '72');
$this->assertSame(72, Setting::getFeedStalenessThreshold());
}
public function test_set_feed_staleness_threshold_persists_value(): void
{
Setting::setFeedStalenessThreshold(24);
$this->assertSame(24, Setting::getFeedStalenessThreshold());
$this->assertDatabaseHas('settings', [
'key' => 'feed_staleness_threshold',
'value' => '24',
]);
}
public function test_set_feed_staleness_threshold_zero(): void
{
Setting::setFeedStalenessThreshold(0);
$this->assertSame(0, Setting::getFeedStalenessThreshold());
}
}