diff --git a/app/Http/Controllers/Api/V1/SettingsController.php b/app/Http/Controllers/Api/V1/SettingsController.php
index 8dd4987e..eeeb5cb9 100644
--- a/app/Http/Controllers/Api/V1/SettingsController.php
+++ b/app/Http/Controllers/Api/V1/SettingsController.php
@@ -19,6 +19,7 @@ public function index(): JsonResponse
'article_processing_enabled' => Setting::isArticleProcessingEnabled(),
'publishing_approvals_enabled' => Setting::isPublishingApprovalsEnabled(),
'article_publishing_interval' => Setting::getArticlePublishingInterval(),
+ 'daily_publish_cap' => Setting::getDailyPublishCap(),
];
return $this->sendResponse($settings, 'Settings retrieved successfully.');
@@ -37,6 +38,7 @@ public function update(Request $request): JsonResponse
'article_processing_enabled' => 'boolean',
'publishing_approvals_enabled' => 'boolean',
'article_publishing_interval' => 'integer|min:0',
+ 'daily_publish_cap' => 'integer|min:0',
]);
if (isset($validated['article_processing_enabled'])) {
@@ -51,10 +53,15 @@ public function update(Request $request): JsonResponse
Setting::setArticlePublishingInterval($validated['article_publishing_interval']);
}
+ if (isset($validated['daily_publish_cap'])) {
+ Setting::setDailyPublishCap($validated['daily_publish_cap']);
+ }
+
$updatedSettings = [
'article_processing_enabled' => Setting::isArticleProcessingEnabled(),
'publishing_approvals_enabled' => Setting::isPublishingApprovalsEnabled(),
'article_publishing_interval' => Setting::getArticlePublishingInterval(),
+ 'daily_publish_cap' => Setting::getDailyPublishCap(),
];
return $this->sendResponse(
diff --git a/app/Jobs/PublishNextArticleJob.php b/app/Jobs/PublishNextArticleJob.php
index 868aca09..f2e22022 100644
--- a/app/Jobs/PublishNextArticleJob.php
+++ b/app/Jobs/PublishNextArticleJob.php
@@ -35,6 +35,10 @@ public function __construct()
*/
public function handle(PublishRouteArticleAction $publishRouteArticle): void
{
+ if ($this->dailyCapReached()) {
+ return;
+ }
+
$interval = Setting::getArticlePublishingInterval();
if ($interval > 0) {
@@ -70,4 +74,15 @@ public function handle(PublishRouteArticleAction $publishRouteArticle): void
$publishRouteArticle->execute($routeArticle);
}
+
+ private function dailyCapReached(): bool
+ {
+ $cap = Setting::getDailyPublishCap();
+
+ if ($cap <= 0) {
+ return false;
+ }
+
+ return ArticlePublication::where('published_at', '>=', now()->startOfDay())->count() >= $cap;
+ }
}
diff --git a/app/Livewire/Settings.php b/app/Livewire/Settings.php
index 693db055..d1fbba25 100644
--- a/app/Livewire/Settings.php
+++ b/app/Livewire/Settings.php
@@ -16,6 +16,8 @@ class Settings extends Component
public int $feedStalenessThreshold = 48;
+ public int $dailyPublishCap = 0;
+
public ?string $successMessage = null;
public ?string $errorMessage = null;
@@ -26,6 +28,7 @@ public function mount(): void
$this->publishingApprovalsEnabled = Setting::isPublishingApprovalsEnabled();
$this->articlePublishingInterval = Setting::getArticlePublishingInterval();
$this->feedStalenessThreshold = Setting::getFeedStalenessThreshold();
+ $this->dailyPublishCap = Setting::getDailyPublishCap();
}
public function toggleArticleProcessing(): void
@@ -62,6 +65,16 @@ public function updateFeedStalenessThreshold(): void
$this->showSuccess();
}
+ public function updateDailyPublishCap(): void
+ {
+ $this->validate([
+ 'dailyPublishCap' => 'required|integer|min:0',
+ ]);
+
+ Setting::setDailyPublishCap($this->dailyPublishCap);
+ $this->showSuccess();
+ }
+
protected function showSuccess(): void
{
$this->successMessage = 'Settings updated successfully!';
diff --git a/app/Models/Setting.php b/app/Models/Setting.php
index 92063227..9d155472 100644
--- a/app/Models/Setting.php
+++ b/app/Models/Setting.php
@@ -81,4 +81,14 @@ public static function setFeedStalenessThreshold(int $hours): void
{
static::set('feed_staleness_threshold', (string) $hours);
}
+
+ public static function getDailyPublishCap(): int
+ {
+ return (int) static::get('daily_publish_cap', 0);
+ }
+
+ public static function setDailyPublishCap(int $articles): void
+ {
+ static::set('daily_publish_cap', (string) $articles);
+ }
}
diff --git a/resources/views/livewire/settings.blade.php b/resources/views/livewire/settings.blade.php
index 87ca51de..bbd9cde1 100644
--- a/resources/views/livewire/settings.blade.php
+++ b/resources/views/livewire/settings.blade.php
@@ -72,6 +72,36 @@ class="inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-
{{ $message }}
@enderror
+
+
+
+ Daily Publish Cap
+
+
+ Maximum posts per UTC day, counted across all channels. An article sent to
+ three channels counts as three. Set to 0 for no limit.
+
+
+
+
+
+
+
+ @error('dailyPublishCap')
+ {{ $message }}
+ @enderror
+
diff --git a/tests/Feature/Http/Controllers/Api/V1/SettingsControllerTest.php b/tests/Feature/Http/Controllers/Api/V1/SettingsControllerTest.php
index d2a65827..6e662be1 100644
--- a/tests/Feature/Http/Controllers/Api/V1/SettingsControllerTest.php
+++ b/tests/Feature/Http/Controllers/Api/V1/SettingsControllerTest.php
@@ -114,6 +114,41 @@ public function test_index_returns_article_publishing_interval(): void
->assertJsonPath('data.article_publishing_interval', 5);
}
+ public function test_index_returns_daily_publish_cap(): void
+ {
+ $response = $this->getJson('/api/v1/settings');
+
+ $response->assertStatus(200)
+ ->assertJsonStructure([
+ 'data' => [
+ 'daily_publish_cap',
+ ],
+ ])
+ ->assertJsonPath('data.daily_publish_cap', 0);
+ }
+
+ public function test_update_accepts_valid_daily_publish_cap(): void
+ {
+ $response = $this->putJson('/api/v1/settings', [
+ 'daily_publish_cap' => 30,
+ ]);
+
+ $response->assertStatus(200)
+ ->assertJsonPath('data.daily_publish_cap', 30);
+
+ $this->assertSame(30, Setting::getDailyPublishCap());
+ }
+
+ public function test_update_rejects_negative_daily_publish_cap(): void
+ {
+ $response = $this->putJson('/api/v1/settings', [
+ 'daily_publish_cap' => -1,
+ ]);
+
+ $response->assertStatus(422)
+ ->assertJsonValidationErrors(['daily_publish_cap']);
+ }
+
public function test_update_accepts_valid_article_publishing_interval(): void
{
$response = $this->putJson('/api/v1/settings', [
diff --git a/tests/Feature/Livewire/SettingsTest.php b/tests/Feature/Livewire/SettingsTest.php
index 101673df..edcd70c3 100644
--- a/tests/Feature/Livewire/SettingsTest.php
+++ b/tests/Feature/Livewire/SettingsTest.php
@@ -51,4 +51,36 @@ public function test_update_feed_staleness_threshold_shows_success_message(): vo
->call('updateFeedStalenessThreshold')
->assertSet('successMessage', 'Settings updated successfully!');
}
+
+ public function test_mount_loads_daily_publish_cap(): void
+ {
+ Setting::setDailyPublishCap(25);
+
+ Livewire::test(Settings::class)
+ ->assertSet('dailyPublishCap', 25);
+ }
+
+ public function test_mount_defaults_daily_publish_cap_to_unlimited(): void
+ {
+ Livewire::test(Settings::class)
+ ->assertSet('dailyPublishCap', 0);
+ }
+
+ public function test_update_daily_publish_cap_saves_value(): void
+ {
+ Livewire::test(Settings::class)
+ ->set('dailyPublishCap', 40)
+ ->call('updateDailyPublishCap')
+ ->assertHasNoErrors();
+
+ $this->assertSame(40, Setting::getDailyPublishCap());
+ }
+
+ public function test_update_daily_publish_cap_validates_minimum(): void
+ {
+ Livewire::test(Settings::class)
+ ->set('dailyPublishCap', -1)
+ ->call('updateDailyPublishCap')
+ ->assertHasErrors(['dailyPublishCap' => 'min']);
+ }
}
diff --git a/tests/Unit/Jobs/PublishNextArticleJobTest.php b/tests/Unit/Jobs/PublishNextArticleJobTest.php
index 3823c7a3..b9e9979c 100644
--- a/tests/Unit/Jobs/PublishNextArticleJobTest.php
+++ b/tests/Unit/Jobs/PublishNextArticleJobTest.php
@@ -227,6 +227,122 @@ public function test_handle_skips_publishing_when_last_publication_within_interv
$this->assertTrue(true);
}
+ public function test_handle_skips_publishing_when_daily_cap_reached(): void
+ {
+ $this->createApprovedRouteArticle();
+
+ ArticlePublication::factory()->count(3)->create(['published_at' => now()->subHours(2)]);
+ Setting::setArticlePublishingInterval(0);
+ Setting::setDailyPublishCap(3);
+
+ $articleFetcherMock = Mockery::mock(ArticleFetcher::class);
+ $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
+
+ $articleFetcherMock->shouldNotReceive('fetchArticleData');
+ $publishingServiceMock->shouldNotReceive('publishRouteArticle');
+
+ $job = new PublishNextArticleJob;
+ $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
+
+ $this->assertTrue(true);
+ }
+
+ public function test_handle_publishes_when_below_daily_cap(): void
+ {
+ $this->createApprovedRouteArticle();
+
+ ArticlePublication::factory()->count(2)->create(['published_at' => now()->subHours(2)]);
+ Setting::setArticlePublishingInterval(0);
+ Setting::setDailyPublishCap(3);
+
+ $articleFetcherMock = Mockery::mock(ArticleFetcher::class);
+ $articleFetcherMock->shouldReceive('fetchArticleData')
+ ->once()
+ ->andReturn(['title' => 'Test Article', 'description' => 'Test description']);
+
+ $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
+ $publishingServiceMock->shouldReceive('publishRouteArticle')
+ ->once()
+ ->andReturn(PublishOutcome::published($this->makePublication()));
+
+ $job = new PublishNextArticleJob;
+ $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
+
+ $this->assertTrue(true);
+ }
+
+ public function test_handle_publishes_when_daily_cap_is_zero(): void
+ {
+ $this->createApprovedRouteArticle();
+
+ ArticlePublication::factory()->count(50)->create(['published_at' => now()->subHours(2)]);
+ Setting::setArticlePublishingInterval(0);
+ Setting::setDailyPublishCap(0);
+
+ $articleFetcherMock = Mockery::mock(ArticleFetcher::class);
+ $articleFetcherMock->shouldReceive('fetchArticleData')
+ ->once()
+ ->andReturn(['title' => 'Test Article', 'description' => 'Test description']);
+
+ $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
+ $publishingServiceMock->shouldReceive('publishRouteArticle')
+ ->once()
+ ->andReturn(PublishOutcome::published($this->makePublication()));
+
+ $job = new PublishNextArticleJob;
+ $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
+
+ $this->assertTrue(true);
+ }
+
+ public function test_daily_cap_counts_each_channel_publication_separately(): void
+ {
+ $this->createApprovedRouteArticle();
+
+ $article = Article::factory()->create();
+ ArticlePublication::factory()->count(3)->create([
+ 'article_id' => $article->id,
+ 'published_at' => now()->subHours(2),
+ ]);
+ Setting::setArticlePublishingInterval(0);
+ Setting::setDailyPublishCap(3);
+
+ $articleFetcherMock = Mockery::mock(ArticleFetcher::class);
+ $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
+
+ $articleFetcherMock->shouldNotReceive('fetchArticleData');
+ $publishingServiceMock->shouldNotReceive('publishRouteArticle');
+
+ $job = new PublishNextArticleJob;
+ $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
+
+ $this->assertTrue(true);
+ }
+
+ public function test_handle_ignores_publications_from_previous_days(): void
+ {
+ $this->createApprovedRouteArticle();
+
+ ArticlePublication::factory()->count(5)->create(['published_at' => now()->subDay()]);
+ Setting::setArticlePublishingInterval(0);
+ Setting::setDailyPublishCap(3);
+
+ $articleFetcherMock = Mockery::mock(ArticleFetcher::class);
+ $articleFetcherMock->shouldReceive('fetchArticleData')
+ ->once()
+ ->andReturn(['title' => 'Test Article', 'description' => 'Test description']);
+
+ $publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
+ $publishingServiceMock->shouldReceive('publishRouteArticle')
+ ->once()
+ ->andReturn(PublishOutcome::published($this->makePublication()));
+
+ $job = new PublishNextArticleJob;
+ $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
+
+ $this->assertTrue(true);
+ }
+
public function test_handle_publishes_when_last_publication_beyond_interval(): void
{
$this->createApprovedRouteArticle();