93 - Add feed staleness detection with configurable threshold and alerts
This commit is contained in:
parent
4feab96765
commit
062b00d01c
10 changed files with 407 additions and 0 deletions
51
app/Jobs/CheckFeedStalenessJob.php
Normal file
51
app/Jobs/CheckFeedStalenessJob.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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!';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
133
tests/Feature/Jobs/CheckFeedStalenessJobTest.php
Normal file
133
tests/Feature/Jobs/CheckFeedStalenessJobTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
54
tests/Feature/Livewire/SettingsTest.php
Normal file
54
tests/Feature/Livewire/SettingsTest.php
Normal 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!');
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue