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
{
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],

View file

@ -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(

View file

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

View file

@ -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,

View file

@ -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(),
]
);
}

View file

@ -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(),
]);
}
}

View file

@ -183,8 +183,9 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin
@endisland
</div>
<!-- Articles Fetched vs Published -->
<div class="mt-8">
<!-- Trend and breakdowns -->
<div class="mt-8 grid grid-cols-1 gap-6 lg:grid-cols-4">
<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')
@ -196,8 +197,7 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin
@endisland
</div>
<!-- Articles per Feed -->
<div class="mt-8">
<div>
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Articles per Feed</h2>
@island('articles-per-feed')
@ -208,8 +208,7 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin
@endisland
</div>
<!-- Publications per Channel -->
<div class="mt-8">
<div>
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Publications per Channel</h2>
@island('publications-per-channel')
@ -219,4 +218,5 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin
])
@endisland
</div>
</div>
</div>

View file

@ -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();
}

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));
}
}