From df5013f86d84f649d31b1451a84e7fb279746dd5 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Thu, 13 Aug 2026 00:40:21 +0200 Subject: [PATCH] 83 - Record approval decisions and rework the dashboard layout --- .../Api/V1/RouteArticlesController.php | 10 +- app/Livewire/Articles.php | 10 +- app/Livewire/Dashboard.php | 2 +- app/Models/RouteArticle.php | 10 +- app/Services/Article/ValidationService.php | 1 + database/factories/RouteArticleFactory.php | 3 + resources/views/livewire/dashboard.blade.php | 60 +++--- tests/Feature/Livewire/DashboardTest.php | 10 +- .../RouteArticleDecisionStampingTest.php | 174 ++++++++++++++++++ .../Unit/Models/RouteArticleDecisionTest.php | 86 +++++++++ 10 files changed, 324 insertions(+), 42 deletions(-) create mode 100644 tests/Feature/RouteArticleDecisionStampingTest.php create mode 100644 tests/Unit/Models/RouteArticleDecisionTest.php diff --git a/app/Http/Controllers/Api/V1/RouteArticlesController.php b/app/Http/Controllers/Api/V1/RouteArticlesController.php index b41763b3..cbe111e6 100644 --- a/app/Http/Controllers/Api/V1/RouteArticlesController.php +++ b/app/Http/Controllers/Api/V1/RouteArticlesController.php @@ -71,7 +71,10 @@ public function reject(RouteArticle $routeArticle): JsonResponse public function restore(RouteArticle $routeArticle): JsonResponse { try { - $routeArticle->update(['approval_status' => ApprovalStatusEnum::PENDING]); + $routeArticle->update([ + 'approval_status' => ApprovalStatusEnum::PENDING, + 'decided_at' => null, + ]); return $this->sendResponse( new RouteArticleResource($routeArticle->fresh(['article.feed', 'feed', 'platformChannel'])), @@ -88,7 +91,10 @@ public function clear(): JsonResponse $count = RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)->count(); RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING) - ->update(['approval_status' => ApprovalStatusEnum::REJECTED]); + ->update([ + 'approval_status' => ApprovalStatusEnum::REJECTED, + 'decided_at' => now(), + ]); return $this->sendResponse( ['rejected_count' => $count], diff --git a/app/Livewire/Articles.php b/app/Livewire/Articles.php index 1f999708..ea21884d 100644 --- a/app/Livewire/Articles.php +++ b/app/Livewire/Articles.php @@ -74,13 +74,19 @@ public function reject(int $routeArticleId): void public function restore(int $routeArticleId): void { $routeArticle = RouteArticle::findOrFail($routeArticleId); - $routeArticle->update(['approval_status' => ApprovalStatusEnum::PENDING]); + $routeArticle->update([ + 'approval_status' => ApprovalStatusEnum::PENDING, + 'decided_at' => null, + ]); } public function clear(): void { $feed = $this->feedId !== null ? Feed::find($this->feedId) : null; - $cleared = $this->clearableQuery()->update(['approval_status' => ApprovalStatusEnum::REJECTED]); + $cleared = $this->clearableQuery()->update([ + 'approval_status' => ApprovalStatusEnum::REJECTED, + 'decided_at' => now(), + ]); if ($cleared > 0) { ActivityLogged::dispatch( diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index 0d4c1723..eb520405 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -24,7 +24,7 @@ class Dashboard extends Component public function mount(): void { - $this->applyPreset('today'); + $this->applyPreset('month'); } /** diff --git a/app/Models/RouteArticle.php b/app/Models/RouteArticle.php index 885cd3ad..2af93d95 100644 --- a/app/Models/RouteArticle.php +++ b/app/Models/RouteArticle.php @@ -108,7 +108,10 @@ public function approve(): void return; } - $this->update(['approval_status' => ApprovalStatusEnum::APPROVED]); + $this->update([ + 'approval_status' => ApprovalStatusEnum::APPROVED, + 'decided_at' => now(), + ]); ActivityLogged::dispatch( ActivityTypeEnum::APPROVE, @@ -126,7 +129,10 @@ public function reject(): void return; } - $this->update(['approval_status' => ApprovalStatusEnum::REJECTED]); + $this->update([ + 'approval_status' => ApprovalStatusEnum::REJECTED, + 'decided_at' => now(), + ]); ActivityLogged::dispatch( ActivityTypeEnum::REJECT, diff --git a/app/Services/Article/ValidationService.php b/app/Services/Article/ValidationService.php index b79359c3..1387ce92 100644 --- a/app/Services/Article/ValidationService.php +++ b/app/Services/Article/ValidationService.php @@ -83,6 +83,7 @@ private function createRouteArticles(Article $article, string $content): void [ 'approval_status' => $status, 'validated_at' => now(), + 'decided_at' => $status === ApprovalStatusEnum::PENDING ? null : now(), ] ); } diff --git a/database/factories/RouteArticleFactory.php b/database/factories/RouteArticleFactory.php index 0230d5a7..4b3e7b2f 100644 --- a/database/factories/RouteArticleFactory.php +++ b/database/factories/RouteArticleFactory.php @@ -62,6 +62,7 @@ public function pending(): static { return $this->state(fn (array $attributes) => [ 'approval_status' => ApprovalStatusEnum::PENDING, + 'decided_at' => null, ]); } @@ -70,6 +71,7 @@ public function approved(): static return $this->state(fn (array $attributes) => [ 'approval_status' => ApprovalStatusEnum::APPROVED, 'validated_at' => now(), + 'decided_at' => now(), ]); } @@ -78,6 +80,7 @@ public function rejected(): static return $this->state(fn (array $attributes) => [ 'approval_status' => ApprovalStatusEnum::REJECTED, 'validated_at' => now(), + 'decided_at' => now(), ]); } } diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index d018fb1e..96e8b52e 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -183,40 +183,40 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin @endisland - -
-

Fetched vs Published

+ +
+
+

Fetched vs Published

- @island('articles-trend') - @include('livewire.partials.trend-panel', [ - 'result' => $this->articlesTrend, - 'emptyMessage' => 'No articles or publications in this range.', - 'tooWideMessage' => 'This range is too wide to chart by day. Pick a range under two years.', - ]) - @endisland -
+ @island('articles-trend') + @include('livewire.partials.trend-panel', [ + 'result' => $this->articlesTrend, + 'emptyMessage' => 'No articles or publications in this range.', + 'tooWideMessage' => 'This range is too wide to chart by day. Pick a range under two years.', + ]) + @endisland +
- -
-

Articles per Feed

+
+

Articles per Feed

- @island('articles-per-feed') - @include('livewire.partials.breakdown-panel', [ - 'result' => $this->articlesPerFeed, - 'emptyMessage' => 'No feeds are configured yet.', - ]) - @endisland -
+ @island('articles-per-feed') + @include('livewire.partials.breakdown-panel', [ + 'result' => $this->articlesPerFeed, + 'emptyMessage' => 'No feeds are configured yet.', + ]) + @endisland +
- -
-

Publications per Channel

+
+

Publications per Channel

- @island('publications-per-channel') - @include('livewire.partials.breakdown-panel', [ - 'result' => $this->publicationsPerChannel, - 'emptyMessage' => 'No channels are configured yet.', - ]) - @endisland + @island('publications-per-channel') + @include('livewire.partials.breakdown-panel', [ + 'result' => $this->publicationsPerChannel, + 'emptyMessage' => 'No channels are configured yet.', + ]) + @endisland +
diff --git a/tests/Feature/Livewire/DashboardTest.php b/tests/Feature/Livewire/DashboardTest.php index 4ec2b437..83044f18 100644 --- a/tests/Feature/Livewire/DashboardTest.php +++ b/tests/Feature/Livewire/DashboardTest.php @@ -16,13 +16,13 @@ class DashboardTest extends TestCase { use RefreshDatabase; - public function test_it_defaults_to_the_current_day(): void + public function test_it_defaults_to_the_current_month(): void { Carbon::setTestNow('2026-07-15 13:45:00'); Livewire::test(Dashboard::class) - ->assertSet('from', '2026-07-15') - ->assertSet('to', '2026-07-15'); + ->assertSet('from', '2026-07-01') + ->assertSet('to', '2026-07-31'); } public function test_it_recomputes_stats_when_the_range_changes(): void @@ -63,8 +63,8 @@ public function test_it_ignores_an_unknown_preset(): void Livewire::test(Dashboard::class) ->call('applyPreset', 'fortnight') - ->assertSet('from', '2026-07-15') - ->assertSet('to', '2026-07-15') + ->assertSet('from', '2026-07-01') + ->assertSet('to', '2026-07-31') ->assertHasNoErrors(); } diff --git a/tests/Feature/RouteArticleDecisionStampingTest.php b/tests/Feature/RouteArticleDecisionStampingTest.php new file mode 100644 index 00000000..8fffadc2 --- /dev/null +++ b/tests/Feature/RouteArticleDecisionStampingTest.php @@ -0,0 +1,174 @@ +count(3)->create([ + 'approval_status' => ApprovalStatusEnum::PENDING, + 'decided_at' => null, + ]); + + Livewire::test(Articles::class)->call('clear'); + + foreach ($pending as $routeArticle) { + /** @var RouteArticle $fresh */ + $fresh = $routeArticle->fresh(); + + $this->assertNotNull($fresh->decided_at); + } + } + + public function test_the_api_clear_endpoint_stamps_every_rejected_row(): void + { + $pending = RouteArticle::factory()->count(3)->create([ + 'approval_status' => ApprovalStatusEnum::PENDING, + 'decided_at' => null, + ]); + + $this->actingAs(User::factory()->create()) + ->postJson('/api/v1/route-articles/clear') + ->assertOk(); + + foreach ($pending as $routeArticle) { + /** @var RouteArticle $fresh */ + $fresh = $routeArticle->fresh(); + + $this->assertNotNull($fresh->decided_at); + } + } + + public function test_restoring_to_pending_clears_the_decision_time(): void + { + /** @var RouteArticle $routeArticle */ + $routeArticle = RouteArticle::factory()->create([ + 'approval_status' => ApprovalStatusEnum::REJECTED, + 'decided_at' => now(), + ]); + + Livewire::test(Articles::class)->call('restore', $routeArticle->id); + + $this->assertNull($routeArticle->fresh()->decided_at); + } + + public function test_the_api_restore_endpoint_clears_the_decision_time(): void + { + /** @var RouteArticle $routeArticle */ + $routeArticle = RouteArticle::factory()->create([ + 'approval_status' => ApprovalStatusEnum::REJECTED, + 'decided_at' => now(), + ]); + + $this->actingAs(User::factory()->create()) + ->postJson("/api/v1/route-articles/{$routeArticle->id}/restore") + ->assertOk(); + + $this->assertNull($routeArticle->fresh()->decided_at); + } + + public function test_auto_approved_articles_are_stamped_on_creation(): void + { + $article = $this->articleOnRouteWithoutKeywords(autoApprove: true); + + $this->validate($article); + + $routeArticle = RouteArticle::where('article_id', $article->id)->firstOrFail(); + + $this->assertSame(ApprovalStatusEnum::APPROVED, $routeArticle->approval_status); + $this->assertNotNull($routeArticle->decided_at); + } + + public function test_keyword_rejected_articles_are_stamped_on_creation(): void + { + $article = $this->articleOnRouteWithKeyword('nothing-that-matches'); + + $this->validate($article); + + $routeArticle = RouteArticle::where('article_id', $article->id)->firstOrFail(); + + $this->assertSame(ApprovalStatusEnum::REJECTED, $routeArticle->approval_status); + $this->assertNotNull($routeArticle->decided_at); + } + + public function test_pending_articles_are_not_stamped_on_creation(): void + { + $article = $this->articleOnRouteWithoutKeywords(autoApprove: false); + + $this->validate($article); + + $routeArticle = RouteArticle::where('article_id', $article->id)->firstOrFail(); + + $this->assertSame(ApprovalStatusEnum::PENDING, $routeArticle->approval_status); + $this->assertNull($routeArticle->decided_at); + } + + private function validate(Article $article): void + { + $fetcher = Mockery::mock(ArticleFetcher::class); + $fetcher->shouldReceive('fetchArticleData') + ->with($article) + ->once() + ->andReturn([ + 'title' => 'Test Title', + 'description' => 'Test description', + 'full_article' => 'Body text', + ]); + + (new ValidationService($fetcher))->validate($article); + } + + private function articleOnRouteWithoutKeywords(bool $autoApprove): Article + { + $feed = Feed::factory()->create(); + $channel = PlatformChannel::factory()->create(); + + Route::factory()->create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + 'is_active' => true, + 'auto_approve' => $autoApprove, + ]); + + return Article::factory()->create(['feed_id' => $feed->id, 'validated_at' => null]); + } + + private function articleOnRouteWithKeyword(string $keyword): Article + { + $feed = Feed::factory()->create(); + $channel = PlatformChannel::factory()->create(); + + Route::factory()->create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + 'is_active' => true, + ]); + + Keyword::factory()->active()->create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + 'keyword' => $keyword, + ]); + + return Article::factory()->create(['feed_id' => $feed->id, 'validated_at' => null]); + } +} diff --git a/tests/Unit/Models/RouteArticleDecisionTest.php b/tests/Unit/Models/RouteArticleDecisionTest.php new file mode 100644 index 00000000..7c4f89e5 --- /dev/null +++ b/tests/Unit/Models/RouteArticleDecisionTest.php @@ -0,0 +1,86 @@ +create([ + 'approval_status' => ApprovalStatusEnum::PENDING, + 'decided_at' => null, + ]); + + $this->freezeTime(function () use ($routeArticle) { + $routeArticle->approve(); + + $this->assertSame( + now()->format('Y-m-d H:i:s'), + $routeArticle->fresh()->decided_at->format('Y-m-d H:i:s') + ); + }); + } + + public function test_rejecting_stamps_the_decision_time(): void + { + /** @var RouteArticle $routeArticle */ + $routeArticle = RouteArticle::factory()->create([ + 'approval_status' => ApprovalStatusEnum::PENDING, + 'decided_at' => null, + ]); + + $this->freezeTime(function () use ($routeArticle) { + $routeArticle->reject(); + + $this->assertSame( + now()->format('Y-m-d H:i:s'), + $routeArticle->fresh()->decided_at->format('Y-m-d H:i:s') + ); + }); + } + + public function test_re_approving_does_not_move_the_decision_time(): void + { + /** @var RouteArticle $routeArticle */ + $routeArticle = RouteArticle::factory()->create([ + 'approval_status' => ApprovalStatusEnum::PENDING, + 'decided_at' => null, + ]); + + $routeArticle->approve(); + $first = $routeArticle->fresh()->decided_at; + + $this->travel(1)->hours(); + $routeArticle->fresh()->approve(); + + $this->assertSame( + $first->format('Y-m-d H:i:s'), + $routeArticle->fresh()->decided_at->format('Y-m-d H:i:s') + ); + } + + public function test_rejecting_an_approved_article_moves_the_decision_time(): void + { + /** @var RouteArticle $routeArticle */ + $routeArticle = RouteArticle::factory()->create([ + 'approval_status' => ApprovalStatusEnum::PENDING, + 'decided_at' => null, + ]); + + $routeArticle->approve(); + $first = $routeArticle->fresh()->decided_at; + + $this->travel(1)->hours(); + $routeArticle->fresh()->reject(); + + $this->assertTrue($routeArticle->fresh()->decided_at->greaterThan($first)); + } +}