83 - Record approval decisions and rework the dashboard layout

This commit is contained in:
myrmidex 2026-08-13 00:40:21 +02:00
parent 8375c948c6
commit df5013f86d
10 changed files with 324 additions and 42 deletions

View file

@ -71,7 +71,10 @@ public function reject(RouteArticle $routeArticle): JsonResponse
public function restore(RouteArticle $routeArticle): JsonResponse public function restore(RouteArticle $routeArticle): JsonResponse
{ {
try { try {
$routeArticle->update(['approval_status' => ApprovalStatusEnum::PENDING]); $routeArticle->update([
'approval_status' => ApprovalStatusEnum::PENDING,
'decided_at' => null,
]);
return $this->sendResponse( return $this->sendResponse(
new RouteArticleResource($routeArticle->fresh(['article.feed', 'feed', 'platformChannel'])), new RouteArticleResource($routeArticle->fresh(['article.feed', 'feed', 'platformChannel'])),
@ -88,7 +91,10 @@ public function clear(): JsonResponse
$count = RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)->count(); $count = RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)->count();
RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING) RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)
->update(['approval_status' => ApprovalStatusEnum::REJECTED]); ->update([
'approval_status' => ApprovalStatusEnum::REJECTED,
'decided_at' => now(),
]);
return $this->sendResponse( return $this->sendResponse(
['rejected_count' => $count], ['rejected_count' => $count],

View file

@ -74,13 +74,19 @@ public function reject(int $routeArticleId): void
public function restore(int $routeArticleId): void public function restore(int $routeArticleId): void
{ {
$routeArticle = RouteArticle::findOrFail($routeArticleId); $routeArticle = RouteArticle::findOrFail($routeArticleId);
$routeArticle->update(['approval_status' => ApprovalStatusEnum::PENDING]); $routeArticle->update([
'approval_status' => ApprovalStatusEnum::PENDING,
'decided_at' => null,
]);
} }
public function clear(): void public function clear(): void
{ {
$feed = $this->feedId !== null ? Feed::find($this->feedId) : null; $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) { if ($cleared > 0) {
ActivityLogged::dispatch( ActivityLogged::dispatch(

View file

@ -24,7 +24,7 @@ class Dashboard extends Component
public function mount(): void public function mount(): void
{ {
$this->applyPreset('today'); $this->applyPreset('month');
} }
/** /**

View file

@ -108,7 +108,10 @@ public function approve(): void
return; return;
} }
$this->update(['approval_status' => ApprovalStatusEnum::APPROVED]); $this->update([
'approval_status' => ApprovalStatusEnum::APPROVED,
'decided_at' => now(),
]);
ActivityLogged::dispatch( ActivityLogged::dispatch(
ActivityTypeEnum::APPROVE, ActivityTypeEnum::APPROVE,
@ -126,7 +129,10 @@ public function reject(): void
return; return;
} }
$this->update(['approval_status' => ApprovalStatusEnum::REJECTED]); $this->update([
'approval_status' => ApprovalStatusEnum::REJECTED,
'decided_at' => now(),
]);
ActivityLogged::dispatch( ActivityLogged::dispatch(
ActivityTypeEnum::REJECT, ActivityTypeEnum::REJECT,

View file

@ -83,6 +83,7 @@ private function createRouteArticles(Article $article, string $content): void
[ [
'approval_status' => $status, 'approval_status' => $status,
'validated_at' => now(), 'validated_at' => now(),
'decided_at' => $status === ApprovalStatusEnum::PENDING ? null : now(),
] ]
); );
} }

View file

@ -62,6 +62,7 @@ public function pending(): static
{ {
return $this->state(fn (array $attributes) => [ return $this->state(fn (array $attributes) => [
'approval_status' => ApprovalStatusEnum::PENDING, 'approval_status' => ApprovalStatusEnum::PENDING,
'decided_at' => null,
]); ]);
} }
@ -70,6 +71,7 @@ public function approved(): static
return $this->state(fn (array $attributes) => [ return $this->state(fn (array $attributes) => [
'approval_status' => ApprovalStatusEnum::APPROVED, 'approval_status' => ApprovalStatusEnum::APPROVED,
'validated_at' => now(), 'validated_at' => now(),
'decided_at' => now(),
]); ]);
} }
@ -78,6 +80,7 @@ public function rejected(): static
return $this->state(fn (array $attributes) => [ return $this->state(fn (array $attributes) => [
'approval_status' => ApprovalStatusEnum::REJECTED, 'approval_status' => ApprovalStatusEnum::REJECTED,
'validated_at' => now(), 'validated_at' => now(),
'decided_at' => now(),
]); ]);
} }
} }

View file

@ -183,40 +183,40 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin
@endisland @endisland
</div> </div>
<!-- Articles Fetched vs Published --> <!-- Trend and breakdowns -->
<div class="mt-8"> <div class="mt-8 grid grid-cols-1 gap-6 lg:grid-cols-4">
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Fetched vs Published</h2> <div class="lg:col-span-2">
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Fetched vs Published</h2>
@island('articles-trend') @island('articles-trend')
@include('livewire.partials.trend-panel', [ @include('livewire.partials.trend-panel', [
'result' => $this->articlesTrend, 'result' => $this->articlesTrend,
'emptyMessage' => 'No articles or publications in this range.', 'emptyMessage' => 'No articles or publications in this range.',
'tooWideMessage' => 'This range is too wide to chart by day. Pick a range under two years.', 'tooWideMessage' => 'This range is too wide to chart by day. Pick a range under two years.',
]) ])
@endisland @endisland
</div> </div>
<!-- Articles per Feed --> <div>
<div class="mt-8"> <h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Articles per Feed</h2>
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Articles per Feed</h2>
@island('articles-per-feed') @island('articles-per-feed')
@include('livewire.partials.breakdown-panel', [ @include('livewire.partials.breakdown-panel', [
'result' => $this->articlesPerFeed, 'result' => $this->articlesPerFeed,
'emptyMessage' => 'No feeds are configured yet.', 'emptyMessage' => 'No feeds are configured yet.',
]) ])
@endisland @endisland
</div> </div>
<!-- Publications per Channel --> <div>
<div class="mt-8"> <h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Publications per Channel</h2>
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Publications per Channel</h2>
@island('publications-per-channel') @island('publications-per-channel')
@include('livewire.partials.breakdown-panel', [ @include('livewire.partials.breakdown-panel', [
'result' => $this->publicationsPerChannel, 'result' => $this->publicationsPerChannel,
'emptyMessage' => 'No channels are configured yet.', 'emptyMessage' => 'No channels are configured yet.',
]) ])
@endisland @endisland
</div>
</div> </div>
</div> </div>

View file

@ -16,13 +16,13 @@ class DashboardTest extends TestCase
{ {
use RefreshDatabase; 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'); Carbon::setTestNow('2026-07-15 13:45:00');
Livewire::test(Dashboard::class) Livewire::test(Dashboard::class)
->assertSet('from', '2026-07-15') ->assertSet('from', '2026-07-01')
->assertSet('to', '2026-07-15'); ->assertSet('to', '2026-07-31');
} }
public function test_it_recomputes_stats_when_the_range_changes(): void 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) Livewire::test(Dashboard::class)
->call('applyPreset', 'fortnight') ->call('applyPreset', 'fortnight')
->assertSet('from', '2026-07-15') ->assertSet('from', '2026-07-01')
->assertSet('to', '2026-07-15') ->assertSet('to', '2026-07-31')
->assertHasNoErrors(); ->assertHasNoErrors();
} }

View file

@ -0,0 +1,174 @@
<?php
namespace Tests\Feature;
use App\Enums\ApprovalStatusEnum;
use App\Livewire\Articles;
use App\Models\Article;
use App\Models\Feed;
use App\Models\Keyword;
use App\Models\PlatformChannel;
use App\Models\Route;
use App\Models\RouteArticle;
use App\Models\User;
use App\Services\Article\ArticleFetcher;
use App\Services\Article\ValidationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Mockery;
use Tests\TestCase;
class RouteArticleDecisionStampingTest extends TestCase
{
use RefreshDatabase;
public function test_clearing_pending_articles_stamps_every_rejected_row(): void
{
$pending = RouteArticle::factory()->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]);
}
}

View file

@ -0,0 +1,86 @@
<?php
namespace Tests\Unit\Models;
use App\Enums\ApprovalStatusEnum;
use App\Models\RouteArticle;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class RouteArticleDecisionTest extends TestCase
{
use RefreshDatabase;
public function test_approving_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->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));
}
}