release/v1.4.2 #159

Merged
myrmidex merged 2 commits from release/v1.4.2 into main 2026-08-16 23:36:24 +02:00
10 changed files with 424 additions and 25 deletions
Showing only changes of commit aa00950543 - Show all commits

View file

@ -0,0 +1,35 @@
<?php
namespace App\Actions;
use App\Models\Article;
use App\Models\Route;
class BackfillRouteArticlesAction
{
public function __construct(
private CreateRouteArticlesAction $createRouteArticles,
) {}
public function execute(Route $route): void
{
if (! $route->is_active) {
return;
}
// Articles already validated with content, but never routed to this
// route. Uses the stored content rather than re-fetching, so a large
// backlog cannot trigger a network storm (#157).
Article::query()
->where('feed_id', $route->feed_id)
->whereNotNull('content')
->whereDoesntHave('routeArticles', function ($query) use ($route) {
$query->where('feed_id', $route->feed_id)
->where('platform_channel_id', $route->platform_channel_id);
})
->lazy()
->each(function (Article $article) use ($route) {
$this->createRouteArticles->createForRoute($article, $route, (string) $article->content);
});
}
}

View file

@ -2,6 +2,7 @@
namespace App\Actions; namespace App\Actions;
use App\Events\RouteActivated;
use App\Models\Route; use App\Models\Route;
class CreateRouteAction class CreateRouteAction
@ -12,7 +13,7 @@ class CreateRouteAction
*/ */
public function execute(int $feedId, int $platformChannelId, int $priority = 0, bool $isActive = true): Route public function execute(int $feedId, int $platformChannelId, int $priority = 0, bool $isActive = true): Route
{ {
return Route::firstOrCreate( $route = Route::firstOrCreate(
[ [
'feed_id' => $feedId, 'feed_id' => $feedId,
'platform_channel_id' => $platformChannelId, 'platform_channel_id' => $platformChannelId,
@ -22,5 +23,11 @@ public function execute(int $feedId, int $platformChannelId, int $priority = 0,
'is_active' => $isActive, 'is_active' => $isActive,
] ]
); );
if ($route->wasRecentlyCreated && $route->is_active) {
RouteActivated::dispatch($route->feed_id, $route->platform_channel_id);
}
return $route;
} }
} }

View file

@ -18,36 +18,38 @@ public function execute(Article $article, string $content): void
->where('is_active', true) ->where('is_active', true)
->get(); ->get();
// Batch-load all active keywords for this feed, grouped by channel foreach ($activeRoutes as $route) {
$keywordsByChannel = Keyword::where('feed_id', $article->feed_id) $this->createForRoute($article, $route, $content);
}
}
public function createForRoute(Article $article, Route $route, string $content): void
{
$routeKeywords = Keyword::where('feed_id', $route->feed_id)
->where('platform_channel_id', $route->platform_channel_id)
->where('is_active', true) ->where('is_active', true)
->get() ->get();
->groupBy('platform_channel_id');
// Match keywords against full article content, title, and description // Match keywords against full article content, title, and description
$searchableContent = $content.' '.$article->title.' '.$article->description; $searchableContent = $content.' '.$article->title.' '.$article->description;
$status = $this->evaluateKeywords($routeKeywords, $searchableContent);
foreach ($activeRoutes as $route) { if ($status === ApprovalStatusEnum::PENDING && $this->shouldAutoApprove($route)) {
$routeKeywords = $keywordsByChannel->get($route->platform_channel_id, collect()); $status = ApprovalStatusEnum::APPROVED;
$status = $this->evaluateKeywords($routeKeywords, $searchableContent);
if ($status === ApprovalStatusEnum::PENDING && $this->shouldAutoApprove($route)) {
$status = ApprovalStatusEnum::APPROVED;
}
RouteArticle::firstOrCreate(
[
'feed_id' => $route->feed_id,
'platform_channel_id' => $route->platform_channel_id,
'article_id' => $article->id,
],
[
'approval_status' => $status,
'validated_at' => now(),
'decided_at' => $status === ApprovalStatusEnum::PENDING ? null : now(),
]
);
} }
RouteArticle::firstOrCreate(
[
'feed_id' => $route->feed_id,
'platform_channel_id' => $route->platform_channel_id,
'article_id' => $article->id,
],
[
'approval_status' => $status,
'validated_at' => now(),
'decided_at' => $status === ApprovalStatusEnum::PENDING ? null : now(),
]
);
} }
/** /**

View file

@ -0,0 +1,17 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class RouteActivated
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public int $feedId,
public int $platformChannelId,
) {}
}

View file

@ -0,0 +1,31 @@
<?php
namespace App\Listeners;
use App\Actions\BackfillRouteArticlesAction;
use App\Events\RouteActivated;
use App\Models\Route;
use Illuminate\Contracts\Queue\ShouldQueue;
class BackfillRouteArticlesListener implements ShouldQueue
{
public string $queue = 'default';
public function __construct(
private BackfillRouteArticlesAction $backfillRouteArticles,
) {}
public function handle(RouteActivated $event): void
{
$route = Route::query()
->where('feed_id', $event->feedId)
->where('platform_channel_id', $event->platformChannelId)
->first();
if ($route === null || ! $route->is_active) {
return;
}
$this->backfillRouteArticles->execute($route);
}
}

View file

@ -2,6 +2,7 @@
namespace App\Livewire; namespace App\Livewire;
use App\Events\RouteActivated;
use App\Models\Feed; use App\Models\Feed;
use App\Models\Keyword; use App\Models\Keyword;
use App\Models\PlatformChannel; use App\Models\PlatformChannel;
@ -72,6 +73,8 @@ public function createRoute(): void
'is_active' => true, 'is_active' => true,
]); ]);
RouteActivated::dispatch($this->newFeedId, $this->newChannelId);
$this->closeCreateModal(); $this->closeCreateModal();
} }
@ -129,6 +132,10 @@ public function toggle(int $feedId, int $channelId): void
$route->is_active = ! $route->is_active; $route->is_active = ! $route->is_active;
$route->save(); $route->save();
if ($route->is_active) {
RouteActivated::dispatch($route->feed_id, $route->platform_channel_id);
}
} }
public function delete(int $feedId, int $channelId): void public function delete(int $feedId, int $channelId): void

View file

@ -22,6 +22,7 @@
* @property string $url * @property string $url
* @property string $title * @property string $title
* @property string|null $description * @property string|null $description
* @property string|null $content
* @property Carbon|null $validated_at * @property Carbon|null $validated_at
* @property Carbon $created_at * @property Carbon $created_at
* @property Carbon $updated_at * @property Carbon $updated_at

View file

@ -0,0 +1,143 @@
<?php
namespace Tests\Feature;
use App\Actions\BackfillRouteArticlesAction;
use App\Actions\CreateRouteAction;
use App\Actions\CreateRouteArticlesAction;
use App\Events\RouteActivated;
use App\Listeners\BackfillRouteArticlesListener;
use App\Livewire\Routes;
use App\Models\Article;
use App\Models\Feed;
use App\Models\PlatformChannel;
use App\Models\Route;
use App\Models\RouteArticle;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Livewire\Livewire;
use Tests\TestCase;
class BackfillRouteArticlesTest extends TestCase
{
use RefreshDatabase;
private function listener(): BackfillRouteArticlesListener
{
return new BackfillRouteArticlesListener(new BackfillRouteArticlesAction(new CreateRouteArticlesAction));
}
private function strandedArticle(Route $route): Article
{
return Article::factory()->create([
'feed_id' => $route->feed_id,
'content' => 'Some article content',
]);
}
private function makeRoute(bool $isActive): Route
{
$feed = Feed::factory()->create();
$channel = PlatformChannel::factory()->create();
return Route::create([
'feed_id' => $feed->id,
'platform_channel_id' => $channel->id,
'priority' => 50,
'is_active' => $isActive,
]);
}
public function test_listener_backfills_stranded_articles(): void
{
$route = $this->makeRoute(true);
$article = $this->strandedArticle($route);
$this->listener()->handle(new RouteActivated($route->feed_id, $route->platform_channel_id));
$this->assertSame(1, RouteArticle::where('article_id', $article->id)->count());
}
public function test_listener_does_nothing_when_the_route_is_missing(): void
{
$this->listener()->handle(new RouteActivated(99999, 99999));
$this->assertSame(0, RouteArticle::count());
}
public function test_listener_does_nothing_when_the_route_is_inactive(): void
{
$route = $this->makeRoute(false);
$this->strandedArticle($route);
$this->listener()->handle(new RouteActivated($route->feed_id, $route->platform_channel_id));
$this->assertSame(0, RouteArticle::count());
}
public function test_creating_a_route_backfills_articles_that_were_validated_with_no_route(): void
{
$feed = Feed::factory()->create();
$channel = PlatformChannel::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'content' => 'Some article content',
]);
(new CreateRouteAction)->execute($feed->id, $channel->id);
$this->assertSame(1, RouteArticle::where('article_id', $article->id)->count());
}
public function test_toggling_a_route_inactive_then_active_backfills(): void
{
$route = $this->makeRoute(false);
$article = $this->strandedArticle($route);
Livewire::test(Routes::class)
->call('toggle', $route->feed_id, $route->platform_channel_id);
$this->assertTrue($route->fresh()->is_active);
$this->assertSame(1, RouteArticle::where('article_id', $article->id)->count());
}
public function test_create_route_action_dispatches_route_activated(): void
{
Event::fake([RouteActivated::class]);
$feed = Feed::factory()->create();
$channel = PlatformChannel::factory()->create();
(new CreateRouteAction)->execute($feed->id, $channel->id);
Event::assertDispatched(
RouteActivated::class,
fn (RouteActivated $event) => $event->feedId === $feed->id && $event->platformChannelId === $channel->id
);
}
public function test_create_route_action_does_not_dispatch_for_an_existing_route(): void
{
Event::fake([RouteActivated::class]);
$feed = Feed::factory()->create();
$channel = PlatformChannel::factory()->create();
(new CreateRouteAction)->execute($feed->id, $channel->id);
(new CreateRouteAction)->execute($feed->id, $channel->id);
Event::assertDispatchedTimes(RouteActivated::class, 1);
}
public function test_create_route_action_does_not_dispatch_for_an_inactive_route(): void
{
Event::fake([RouteActivated::class]);
$feed = Feed::factory()->create();
$channel = PlatformChannel::factory()->create();
(new CreateRouteAction)->execute($feed->id, $channel->id, 0, false);
Event::assertNotDispatched(RouteActivated::class);
}
}

View file

@ -6,6 +6,7 @@
use App\Events\ActivityLogged; use App\Events\ActivityLogged;
use App\Events\ExceptionOccurred; use App\Events\ExceptionOccurred;
use App\Events\NewArticleFetched; use App\Events\NewArticleFetched;
use App\Events\RouteActivated;
use App\Events\RouteArticleApproved; use App\Events\RouteArticleApproved;
use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
@ -25,6 +26,7 @@ public static function eventProvider(): array
'ActivityLogged' => [ActivityLogged::class], 'ActivityLogged' => [ActivityLogged::class],
'ExceptionOccurred' => [ExceptionOccurred::class], 'ExceptionOccurred' => [ExceptionOccurred::class],
'NewArticleFetched' => [NewArticleFetched::class], 'NewArticleFetched' => [NewArticleFetched::class],
'RouteActivated' => [RouteActivated::class],
'RouteArticleApproved' => [RouteArticleApproved::class], 'RouteArticleApproved' => [RouteArticleApproved::class],
]; ];
} }

View file

@ -0,0 +1,154 @@
<?php
namespace Tests\Unit\Actions;
use App\Actions\BackfillRouteArticlesAction;
use App\Actions\CreateRouteArticlesAction;
use App\Enums\ApprovalStatusEnum;
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\Setting;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class BackfillRouteArticlesActionTest extends TestCase
{
use RefreshDatabase;
private function action(): BackfillRouteArticlesAction
{
return new BackfillRouteArticlesAction(new CreateRouteArticlesAction);
}
private function route(bool $isActive = true): Route
{
$feed = Feed::factory()->create();
$channel = PlatformChannel::factory()->create();
return Route::create([
'feed_id' => $feed->id,
'platform_channel_id' => $channel->id,
'priority' => 50,
'is_active' => $isActive,
]);
}
private function strandedArticle(Route $route): Article
{
return Article::factory()->create([
'feed_id' => $route->feed_id,
'title' => 'A title',
'description' => 'A description',
'content' => 'Some article content',
]);
}
public function test_it_backfills_articles_missing_a_route_article(): void
{
$route = $this->route();
$article = $this->strandedArticle($route);
$this->action()->execute($route);
$this->assertDatabaseHas('route_articles', [
'article_id' => $article->id,
'feed_id' => $route->feed_id,
'platform_channel_id' => $route->platform_channel_id,
]);
}
public function test_it_is_idempotent(): void
{
$route = $this->route();
$this->strandedArticle($route);
$this->action()->execute($route);
$this->action()->execute($route);
$this->assertSame(1, RouteArticle::count());
}
public function test_it_skips_articles_already_routed_to_this_route(): void
{
$route = $this->route();
$article = $this->strandedArticle($route);
RouteArticle::create([
'feed_id' => $route->feed_id,
'platform_channel_id' => $route->platform_channel_id,
'article_id' => $article->id,
'approval_status' => ApprovalStatusEnum::APPROVED,
]);
$this->action()->execute($route);
$this->assertSame(1, RouteArticle::count());
$this->assertSame(ApprovalStatusEnum::APPROVED, RouteArticle::first()->approval_status);
}
public function test_it_skips_articles_without_stored_content(): void
{
$route = $this->route();
Article::factory()->create([
'feed_id' => $route->feed_id,
'content' => null,
]);
$this->action()->execute($route);
$this->assertSame(0, RouteArticle::count());
}
public function test_it_does_nothing_for_an_inactive_route(): void
{
$route = $this->route(isActive: false);
$this->strandedArticle($route);
$this->action()->execute($route);
$this->assertSame(0, RouteArticle::count());
}
public function test_it_does_not_touch_articles_in_other_feeds(): void
{
$route = $this->route();
$this->strandedArticle($route);
$otherFeed = Feed::factory()->create();
$otherArticle = Article::factory()->create([
'feed_id' => $otherFeed->id,
'content' => 'Other content',
]);
$this->action()->execute($route);
$this->assertSame(0, RouteArticle::where('article_id', $otherArticle->id)->count());
}
public function test_keyword_matching_uses_the_stored_content_without_refetching(): void
{
Setting::setBool('enable_publishing_approvals', true);
$route = $this->route();
Article::factory()->create([
'feed_id' => $route->feed_id,
'title' => 'A title',
'description' => 'A description',
'content' => 'news from Brussels today',
]);
Keyword::create([
'feed_id' => $route->feed_id,
'platform_channel_id' => $route->platform_channel_id,
'keyword' => 'brussels',
'is_active' => true,
]);
$this->action()->execute($route);
$this->assertSame(ApprovalStatusEnum::PENDING, RouteArticle::first()->approval_status);
}
}