From a4b5aee790af1afa1430d09bede6ed68a5a4d338 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sun, 10 Aug 2025 16:18:09 +0200 Subject: [PATCH] Add keywords to front-end --- .../Controllers/Api/V1/KeywordsController.php | 143 ++++++++++ .../Api/V1/OnboardingController.php | 2 - .../Controllers/Api/V1/RoutingController.php | 10 +- backend/app/Http/Resources/RouteResource.php | 9 + backend/app/Models/Feed.php | 2 +- backend/app/Models/PlatformChannel.php | 2 +- backend/app/Models/Route.php | 7 +- .../Publishing/ArticlePublishingService.php | 63 ++++- ...00000_remove_filters_from_routes_table.php | 28 ++ backend/routes/api.php | 8 + .../Api/V1/KeywordsControllerTest.php | 189 +++++++++++++ .../Api/V1/OnboardingControllerTest.php | 1 - .../ArticlePublishingServiceTest.php | 194 +++++++------ .../Publishing/KeywordFilteringTest.php | 263 ++++++++++++++++++ frontend/src/components/KeywordManager.tsx | 170 +++++++++++ frontend/src/lib/api.ts | 39 ++- frontend/src/pages/Routes.tsx | 47 +++- 17 files changed, 1050 insertions(+), 127 deletions(-) create mode 100644 backend/app/Http/Controllers/Api/V1/KeywordsController.php create mode 100644 backend/database/migrations/2025_08_10_000000_remove_filters_from_routes_table.php create mode 100644 backend/tests/Feature/Http/Controllers/Api/V1/KeywordsControllerTest.php create mode 100644 backend/tests/Unit/Services/Publishing/KeywordFilteringTest.php create mode 100644 frontend/src/components/KeywordManager.tsx diff --git a/backend/app/Http/Controllers/Api/V1/KeywordsController.php b/backend/app/Http/Controllers/Api/V1/KeywordsController.php new file mode 100644 index 0000000..e120b41 --- /dev/null +++ b/backend/app/Http/Controllers/Api/V1/KeywordsController.php @@ -0,0 +1,143 @@ +id) + ->where('platform_channel_id', $channel->id) + ->orderBy('keyword') + ->get(); + + return $this->sendResponse( + $keywords->toArray(), + 'Keywords retrieved successfully.' + ); + } + + /** + * Store a new keyword for a route + */ + public function store(Request $request, Feed $feed, PlatformChannel $channel): JsonResponse + { + try { + $validated = $request->validate([ + 'keyword' => 'required|string|max:255', + 'is_active' => 'boolean', + ]); + + $validated['feed_id'] = $feed->id; + $validated['platform_channel_id'] = $channel->id; + $validated['is_active'] = $validated['is_active'] ?? true; + + // Check if keyword already exists for this route + $existingKeyword = Keyword::where('feed_id', $feed->id) + ->where('platform_channel_id', $channel->id) + ->where('keyword', $validated['keyword']) + ->first(); + + if ($existingKeyword) { + return $this->sendError('Keyword already exists for this route.', [], 409); + } + + $keyword = Keyword::create($validated); + + return $this->sendResponse( + $keyword->toArray(), + 'Keyword created successfully!', + 201 + ); + } catch (ValidationException $e) { + return $this->sendValidationError($e->errors()); + } catch (\Exception $e) { + return $this->sendError('Failed to create keyword: ' . $e->getMessage(), [], 500); + } + } + + /** + * Update a keyword's status + */ + public function update(Request $request, Feed $feed, PlatformChannel $channel, Keyword $keyword): JsonResponse + { + try { + // Verify the keyword belongs to this route + if ($keyword->feed_id !== $feed->id || $keyword->platform_channel_id !== $channel->id) { + return $this->sendNotFound('Keyword not found for this route.'); + } + + $validated = $request->validate([ + 'is_active' => 'boolean', + ]); + + $keyword->update($validated); + + return $this->sendResponse( + $keyword->fresh()->toArray(), + 'Keyword updated successfully!' + ); + } catch (ValidationException $e) { + return $this->sendValidationError($e->errors()); + } catch (\Exception $e) { + return $this->sendError('Failed to update keyword: ' . $e->getMessage(), [], 500); + } + } + + /** + * Remove a keyword from a route + */ + public function destroy(Feed $feed, PlatformChannel $channel, Keyword $keyword): JsonResponse + { + try { + // Verify the keyword belongs to this route + if ($keyword->feed_id !== $feed->id || $keyword->platform_channel_id !== $channel->id) { + return $this->sendNotFound('Keyword not found for this route.'); + } + + $keyword->delete(); + + return $this->sendResponse( + null, + 'Keyword deleted successfully!' + ); + } catch (\Exception $e) { + return $this->sendError('Failed to delete keyword: ' . $e->getMessage(), [], 500); + } + } + + /** + * Toggle keyword active status + */ + public function toggle(Feed $feed, PlatformChannel $channel, Keyword $keyword): JsonResponse + { + try { + // Verify the keyword belongs to this route + if ($keyword->feed_id !== $feed->id || $keyword->platform_channel_id !== $channel->id) { + return $this->sendNotFound('Keyword not found for this route.'); + } + + $newStatus = !$keyword->is_active; + $keyword->update(['is_active' => $newStatus]); + + $status = $newStatus ? 'activated' : 'deactivated'; + + return $this->sendResponse( + $keyword->fresh()->toArray(), + "Keyword {$status} successfully!" + ); + } catch (\Exception $e) { + return $this->sendError('Failed to toggle keyword status: ' . $e->getMessage(), [], 500); + } + } +} \ No newline at end of file diff --git a/backend/app/Http/Controllers/Api/V1/OnboardingController.php b/backend/app/Http/Controllers/Api/V1/OnboardingController.php index a07e1e7..ad891e4 100644 --- a/backend/app/Http/Controllers/Api/V1/OnboardingController.php +++ b/backend/app/Http/Controllers/Api/V1/OnboardingController.php @@ -269,7 +269,6 @@ public function createRoute(Request $request): JsonResponse 'feed_id' => 'required|exists:feeds,id', 'platform_channel_id' => 'required|exists:platform_channels,id', 'priority' => 'nullable|integer|min:1|max:100', - 'filters' => 'nullable|array', ]); if ($validator->fails()) { @@ -282,7 +281,6 @@ public function createRoute(Request $request): JsonResponse 'feed_id' => $validated['feed_id'], 'platform_channel_id' => $validated['platform_channel_id'], 'priority' => $validated['priority'] ?? 50, - 'filters' => $validated['filters'] ?? [], 'is_active' => true, ]); diff --git a/backend/app/Http/Controllers/Api/V1/RoutingController.php b/backend/app/Http/Controllers/Api/V1/RoutingController.php index 05555ac..1693cd8 100644 --- a/backend/app/Http/Controllers/Api/V1/RoutingController.php +++ b/backend/app/Http/Controllers/Api/V1/RoutingController.php @@ -17,7 +17,7 @@ class RoutingController extends BaseController */ public function index(): JsonResponse { - $routes = Route::with(['feed', 'platformChannel']) + $routes = Route::with(['feed', 'platformChannel', 'keywords']) ->orderBy('is_active', 'desc') ->orderBy('priority', 'asc') ->get(); @@ -47,7 +47,7 @@ public function store(Request $request): JsonResponse $route = Route::create($validated); return $this->sendResponse( - new RouteResource($route->load(['feed', 'platformChannel'])), + new RouteResource($route->load(['feed', 'platformChannel', 'keywords'])), 'Routing configuration created successfully!', 201 ); @@ -69,7 +69,7 @@ public function show(Feed $feed, PlatformChannel $channel): JsonResponse return $this->sendNotFound('Routing configuration not found.'); } - $route->load(['feed', 'platformChannel']); + $route->load(['feed', 'platformChannel', 'keywords']); return $this->sendResponse( new RouteResource($route), @@ -99,7 +99,7 @@ public function update(Request $request, Feed $feed, PlatformChannel $channel): ->update($validated); return $this->sendResponse( - new RouteResource($route->fresh(['feed', 'platformChannel'])), + new RouteResource($route->fresh(['feed', 'platformChannel', 'keywords'])), 'Routing configuration updated successfully!' ); } catch (ValidationException $e) { @@ -154,7 +154,7 @@ public function toggle(Feed $feed, PlatformChannel $channel): JsonResponse $status = $newStatus ? 'activated' : 'deactivated'; return $this->sendResponse( - new RouteResource($route->fresh(['feed', 'platformChannel'])), + new RouteResource($route->fresh(['feed', 'platformChannel', 'keywords'])), "Routing configuration {$status} successfully!" ); } catch (\Exception $e) { diff --git a/backend/app/Http/Resources/RouteResource.php b/backend/app/Http/Resources/RouteResource.php index 08d38af..6a02c8d 100644 --- a/backend/app/Http/Resources/RouteResource.php +++ b/backend/app/Http/Resources/RouteResource.php @@ -24,6 +24,15 @@ public function toArray(Request $request): array 'updated_at' => $this->updated_at->toISOString(), 'feed' => new FeedResource($this->whenLoaded('feed')), 'platform_channel' => new PlatformChannelResource($this->whenLoaded('platformChannel')), + 'keywords' => $this->whenLoaded('keywords', function () { + return $this->keywords->map(function ($keyword) { + return [ + 'id' => $keyword->id, + 'keyword' => $keyword->keyword, + 'is_active' => $keyword->is_active, + ]; + }); + }), ]; } } \ No newline at end of file diff --git a/backend/app/Models/Feed.php b/backend/app/Models/Feed.php index 2b8ce2e..ef53672 100644 --- a/backend/app/Models/Feed.php +++ b/backend/app/Models/Feed.php @@ -87,7 +87,7 @@ public function getStatusAttribute(): string public function channels(): BelongsToMany { return $this->belongsToMany(PlatformChannel::class, 'routes') - ->withPivot(['is_active', 'priority', 'filters']) + ->withPivot(['is_active', 'priority']) ->withTimestamps(); } diff --git a/backend/app/Models/PlatformChannel.php b/backend/app/Models/PlatformChannel.php index 4740440..ea38936 100644 --- a/backend/app/Models/PlatformChannel.php +++ b/backend/app/Models/PlatformChannel.php @@ -78,7 +78,7 @@ public function getFullNameAttribute(): string public function feeds(): BelongsToMany { return $this->belongsToMany(Feed::class, 'routes') - ->withPivot(['is_active', 'priority', 'filters']) + ->withPivot(['is_active', 'priority']) ->withTimestamps(); } diff --git a/backend/app/Models/Route.php b/backend/app/Models/Route.php index c18ed38..b5ee7d0 100644 --- a/backend/app/Models/Route.php +++ b/backend/app/Models/Route.php @@ -14,7 +14,6 @@ * @property int $platform_channel_id * @property bool $is_active * @property int $priority - * @property array $filters * @property Carbon $created_at * @property Carbon $updated_at */ @@ -33,13 +32,11 @@ class Route extends Model 'feed_id', 'platform_channel_id', 'is_active', - 'priority', - 'filters' + 'priority' ]; protected $casts = [ - 'is_active' => 'boolean', - 'filters' => 'array' + 'is_active' => 'boolean' ]; /** diff --git a/backend/app/Services/Publishing/ArticlePublishingService.php b/backend/app/Services/Publishing/ArticlePublishingService.php index e4ac99f..fa1d0ce 100644 --- a/backend/app/Services/Publishing/ArticlePublishingService.php +++ b/backend/app/Services/Publishing/ArticlePublishingService.php @@ -7,6 +7,7 @@ use App\Models\Article; use App\Models\ArticlePublication; use App\Models\PlatformChannel; +use App\Models\Route; use App\Modules\Lemmy\Services\LemmyPublisher; use App\Services\Log\LogSaver; use Exception; @@ -36,15 +37,26 @@ public function publishToRoutedChannels(Article $article, array $extractedData): $feed = $article->feed; - /** @var EloquentCollection $activeChannels */ - $activeChannels = $feed->activeChannels()->with(['platformInstance', 'activePlatformAccounts'])->get(); + // Get active routes with keywords instead of just channels + $activeRoutes = Route::where('feed_id', $feed->id) + ->where('is_active', true) + ->with(['platformChannel.platformInstance', 'platformChannel.activePlatformAccounts', 'keywords']) + ->orderBy('priority', 'desc') + ->get(); - return $activeChannels->map(function (PlatformChannel $channel) use ($article, $extractedData) { + // Filter routes based on keyword matches + $matchingRoutes = $activeRoutes->filter(function (Route $route) use ($extractedData) { + return $this->routeMatchesArticle($route, $extractedData); + }); + + return $matchingRoutes->map(function (Route $route) use ($article, $extractedData) { + $channel = $route->platformChannel; $account = $channel->activePlatformAccounts()->first(); if (! $account) { LogSaver::warning('No active account for channel', $channel, [ - 'article_id' => $article->id + 'article_id' => $article->id, + 'route_priority' => $route->priority ]); return null; @@ -55,6 +67,43 @@ public function publishToRoutedChannels(Article $article, array $extractedData): ->filter(); } + /** + * Check if a route matches an article based on keywords + * @param array $extractedData + */ + private function routeMatchesArticle(Route $route, array $extractedData): bool + { + // Get active keywords for this route + $activeKeywords = $route->keywords->where('is_active', true); + + // If no keywords are defined for this route, the route matches any article + if ($activeKeywords->isEmpty()) { + return true; + } + + // Get article content for keyword matching + $articleContent = ''; + if (isset($extractedData['full_article'])) { + $articleContent = $extractedData['full_article']; + } + if (isset($extractedData['title'])) { + $articleContent .= ' ' . $extractedData['title']; + } + if (isset($extractedData['description'])) { + $articleContent .= ' ' . $extractedData['description']; + } + + // Check if any of the route's keywords match the article content + foreach ($activeKeywords as $keywordModel) { + $keyword = $keywordModel->keyword; + if (stripos($articleContent, $keyword) !== false) { + return true; + } + } + + return false; + } + /** * @param array $extractedData */ @@ -74,10 +123,8 @@ private function publishToChannel(Article $article, array $extractedData, Platfo 'publication_data' => $postData, ]); - LogSaver::info('Published to channel via routing', $channel, [ - 'article_id' => $article->id, - // Use nullsafe operator in case pivot is not loaded in tests - 'priority' => $channel->pivot?->priority + LogSaver::info('Published to channel via keyword-filtered routing', $channel, [ + 'article_id' => $article->id ]); return $publication; diff --git a/backend/database/migrations/2025_08_10_000000_remove_filters_from_routes_table.php b/backend/database/migrations/2025_08_10_000000_remove_filters_from_routes_table.php new file mode 100644 index 0000000..8de17f1 --- /dev/null +++ b/backend/database/migrations/2025_08_10_000000_remove_filters_from_routes_table.php @@ -0,0 +1,28 @@ +dropColumn('filters'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('routes', function (Blueprint $table) { + $table->json('filters')->nullable(); + }); + } +}; \ No newline at end of file diff --git a/backend/routes/api.php b/backend/routes/api.php index 582b6f4..b54f639 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -9,6 +9,7 @@ use App\Http\Controllers\Api\V1\PlatformAccountsController; use App\Http\Controllers\Api\V1\PlatformChannelsController; use App\Http\Controllers\Api\V1\RoutingController; +use App\Http\Controllers\Api\V1\KeywordsController; use App\Http\Controllers\Api\V1\SettingsController; use Illuminate\Support\Facades\Route; @@ -100,6 +101,13 @@ Route::delete('/routing/{feed}/{channel}', [RoutingController::class, 'destroy'])->name('api.routing.destroy'); Route::post('/routing/{feed}/{channel}/toggle', [RoutingController::class, 'toggle'])->name('api.routing.toggle'); + // Keywords + Route::get('/routing/{feed}/{channel}/keywords', [KeywordsController::class, 'index'])->name('api.keywords.index'); + Route::post('/routing/{feed}/{channel}/keywords', [KeywordsController::class, 'store'])->name('api.keywords.store'); + Route::put('/routing/{feed}/{channel}/keywords/{keyword}', [KeywordsController::class, 'update'])->name('api.keywords.update'); + Route::delete('/routing/{feed}/{channel}/keywords/{keyword}', [KeywordsController::class, 'destroy'])->name('api.keywords.destroy'); + Route::post('/routing/{feed}/{channel}/keywords/{keyword}/toggle', [KeywordsController::class, 'toggle'])->name('api.keywords.toggle'); + // Settings Route::get('/settings', [SettingsController::class, 'index'])->name('api.settings.index'); Route::put('/settings', [SettingsController::class, 'update'])->name('api.settings.update'); diff --git a/backend/tests/Feature/Http/Controllers/Api/V1/KeywordsControllerTest.php b/backend/tests/Feature/Http/Controllers/Api/V1/KeywordsControllerTest.php new file mode 100644 index 0000000..3934a15 --- /dev/null +++ b/backend/tests/Feature/Http/Controllers/Api/V1/KeywordsControllerTest.php @@ -0,0 +1,189 @@ +feed = Feed::factory()->create(); + $this->channel = PlatformChannel::factory()->create(); + + $this->route = Route::create([ + 'feed_id' => $this->feed->id, + 'platform_channel_id' => $this->channel->id, + 'is_active' => true, + 'priority' => 50 + ]); + } + + public function test_can_get_keywords_for_route(): void + { + $keyword = Keyword::factory()->create([ + 'feed_id' => $this->feed->id, + 'platform_channel_id' => $this->channel->id, + 'keyword' => 'test keyword', + 'is_active' => true + ]); + + $response = $this->getJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords"); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'success', + 'data' => [ + '*' => [ + 'id', + 'keyword', + 'is_active', + 'feed_id', + 'platform_channel_id' + ] + ] + ]) + ->assertJsonPath('data.0.keyword', 'test keyword'); + } + + public function test_can_create_keyword_for_route(): void + { + $keywordData = [ + 'keyword' => 'new keyword', + 'is_active' => true + ]; + + $response = $this->postJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords", $keywordData); + + $response->assertStatus(201) + ->assertJsonStructure([ + 'success', + 'data' => [ + 'id', + 'keyword', + 'is_active', + 'feed_id', + 'platform_channel_id' + ] + ]) + ->assertJsonPath('data.keyword', 'new keyword') + ->assertJsonPath('data.is_active', true); + + $this->assertDatabaseHas('keywords', [ + 'keyword' => 'new keyword', + 'feed_id' => $this->feed->id, + 'platform_channel_id' => $this->channel->id, + 'is_active' => true + ]); + } + + public function test_cannot_create_duplicate_keyword_for_route(): void + { + Keyword::factory()->create([ + 'feed_id' => $this->feed->id, + 'platform_channel_id' => $this->channel->id, + 'keyword' => 'duplicate keyword' + ]); + + $response = $this->postJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords", [ + 'keyword' => 'duplicate keyword' + ]); + + $response->assertStatus(409) + ->assertJsonPath('success', false) + ->assertJsonPath('message', 'Keyword already exists for this route.'); + } + + public function test_can_update_keyword(): void + { + $keyword = Keyword::factory()->create([ + 'feed_id' => $this->feed->id, + 'platform_channel_id' => $this->channel->id, + 'is_active' => true + ]); + + $response = $this->putJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords/{$keyword->id}", [ + 'is_active' => false + ]); + + $response->assertStatus(200) + ->assertJsonPath('data.is_active', false); + + $this->assertDatabaseHas('keywords', [ + 'id' => $keyword->id, + 'is_active' => false + ]); + } + + public function test_can_delete_keyword(): void + { + $keyword = Keyword::factory()->create([ + 'feed_id' => $this->feed->id, + 'platform_channel_id' => $this->channel->id + ]); + + $response = $this->deleteJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords/{$keyword->id}"); + + $response->assertStatus(200); + + $this->assertDatabaseMissing('keywords', [ + 'id' => $keyword->id + ]); + } + + public function test_can_toggle_keyword(): void + { + $keyword = Keyword::factory()->create([ + 'feed_id' => $this->feed->id, + 'platform_channel_id' => $this->channel->id, + 'is_active' => true + ]); + + $response = $this->postJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords/{$keyword->id}/toggle"); + + $response->assertStatus(200) + ->assertJsonPath('data.is_active', false); + + $this->assertDatabaseHas('keywords', [ + 'id' => $keyword->id, + 'is_active' => false + ]); + } + + public function test_cannot_access_keyword_from_different_route(): void + { + $otherFeed = Feed::factory()->create(); + $otherChannel = PlatformChannel::factory()->create(); + + $keyword = Keyword::factory()->create([ + 'feed_id' => $otherFeed->id, + 'platform_channel_id' => $otherChannel->id + ]); + + $response = $this->deleteJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords/{$keyword->id}"); + + $response->assertStatus(404) + ->assertJsonPath('message', 'Keyword not found for this route.'); + } + + public function test_validates_required_fields(): void + { + $response = $this->postJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords", []); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['keyword']); + } +} \ No newline at end of file diff --git a/backend/tests/Feature/Http/Controllers/Api/V1/OnboardingControllerTest.php b/backend/tests/Feature/Http/Controllers/Api/V1/OnboardingControllerTest.php index 9e98416..994b509 100644 --- a/backend/tests/Feature/Http/Controllers/Api/V1/OnboardingControllerTest.php +++ b/backend/tests/Feature/Http/Controllers/Api/V1/OnboardingControllerTest.php @@ -333,7 +333,6 @@ public function test_create_route_creates_route_successfully() 'feed_id' => $feed->id, 'platform_channel_id' => $platformChannel->id, 'priority' => 75, - 'filters' => ['keyword' => 'test'], ]; $response = $this->postJson('/api/v1/onboarding/route', $routeData); diff --git a/backend/tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php b/backend/tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php index 060ac26..83d2691 100644 --- a/backend/tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php +++ b/backend/tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php @@ -10,6 +10,7 @@ use App\Models\PlatformAccount; use App\Models\PlatformChannel; use App\Models\PlatformInstance; +use App\Models\Route; use App\Modules\Lemmy\Services\LemmyPublisher; use App\Services\Log\LogSaver; use App\Services\Publishing\ArticlePublishingService; @@ -49,7 +50,7 @@ public function test_publish_to_routed_channels_throws_exception_for_invalid_art $this->service->publishToRoutedChannels($article, $extractedData); } - public function test_publish_to_routed_channels_returns_empty_collection_when_no_active_channels(): void + public function test_publish_to_routed_channels_returns_empty_collection_when_no_active_routes(): void { $feed = Feed::factory()->create(); $article = Article::factory()->create([ @@ -64,7 +65,7 @@ public function test_publish_to_routed_channels_returns_empty_collection_when_no $this->assertTrue($result->isEmpty()); } - public function test_publish_to_routed_channels_skips_channels_without_active_accounts(): void + public function test_publish_to_routed_channels_skips_routes_without_active_accounts(): void { // Arrange: valid article $feed = Feed::factory()->create(); @@ -73,32 +74,17 @@ public function test_publish_to_routed_channels_skips_channels_without_active_ac 'is_valid' => true, ]); - // Create an active channel with no active accounts + // Create a route with a channel but no active accounts $channel = PlatformChannel::factory()->create(); - $channel->load('platformInstance'); - // Mock feed->activeChannels()->with()->get() chain to return our channel - $relationMock = \Mockery::mock(\Illuminate\Database\Eloquent\Relations\BelongsToMany::class); - $relationMock->shouldReceive('with')->andReturnSelf(); - $relationMock->shouldReceive('get')->andReturn(new EloquentCollection([$channel])); + Route::create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + 'is_active' => true, + 'priority' => 50 + ]); - $feedMock = \Mockery::mock(Feed::class)->makePartial(); - $feedMock->setRawAttributes($feed->getAttributes()); - $feedMock->shouldReceive('activeChannels')->andReturn($relationMock); - - // Attach mocked feed to the article relation - $article->setRelation('feed', $feedMock); - - // No publisher should be constructed because there are no active accounts - - // Also ensure channel->activePlatformAccounts() returns no accounts via relation mock - $channelPartial = \Mockery::mock($channel)->makePartial(); - $accountsRelation = \Mockery::mock(\Illuminate\Database\Eloquent\Relations\BelongsToMany::class); - $accountsRelation->shouldReceive('first')->andReturn(null); - $channelPartial->shouldReceive('activePlatformAccounts')->andReturn($accountsRelation); - - // Replace channel in relation return with the partial mock - $relationMock->shouldReceive('get')->andReturn(new EloquentCollection([$channelPartial])); + // Don't create any platform accounts for the channel // Act $result = $this->service->publishToRoutedChannels($article, ['title' => 'Test']); @@ -113,25 +99,24 @@ public function test_publish_to_routed_channels_successfully_publishes_to_channe // Arrange $feed = Feed::factory()->create(); $article = Article::factory()->create(['feed_id' => $feed->id, 'is_valid' => true]); - - $channel = PlatformChannel::factory()->create(); - $channel->load('platformInstance'); - - // Create an active account and pretend it's active for the channel via relation mock + + $platformInstance = PlatformInstance::factory()->create(); + $channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); $account = PlatformAccount::factory()->create(); - $channelMock = \Mockery::mock($channel)->makePartial(); - $accountsRelation = \Mockery::mock(\Illuminate\Database\Eloquent\Relations\BelongsToMany::class); - $accountsRelation->shouldReceive('first')->andReturn($account); - $channelMock->shouldReceive('activePlatformAccounts')->andReturn($accountsRelation); - // Mock feed activeChannels chain - $relationMock = \Mockery::mock(\Illuminate\Database\Eloquent\Relations\BelongsToMany::class); - $relationMock->shouldReceive('with')->andReturnSelf(); - $relationMock->shouldReceive('get')->andReturn(new EloquentCollection([$channelMock])); - $feedMock = \Mockery::mock(Feed::class)->makePartial(); - $feedMock->setRawAttributes($feed->getAttributes()); - $feedMock->shouldReceive('activeChannels')->andReturn($relationMock); - $article->setRelation('feed', $feedMock); + // Create route + Route::create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + 'is_active' => true, + 'priority' => 50 + ]); + + // Attach account to channel as active + $channel->platformAccounts()->attach($account->id, [ + 'is_active' => true, + 'priority' => 50 + ]); // Mock publisher via service seam $publisherDouble = \Mockery::mock(LemmyPublisher::class); @@ -160,23 +145,24 @@ public function test_publish_to_routed_channels_handles_publishing_failure_grace // Arrange $feed = Feed::factory()->create(); $article = Article::factory()->create(['feed_id' => $feed->id, 'is_valid' => true]); - - $channel = PlatformChannel::factory()->create(); - $channel->load('platformInstance'); - + + $platformInstance = PlatformInstance::factory()->create(); + $channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); $account = PlatformAccount::factory()->create(); - $channelMock = \Mockery::mock($channel)->makePartial(); - $accountsRelation = \Mockery::mock(\Illuminate\Database\Eloquent\Relations\BelongsToMany::class); - $accountsRelation->shouldReceive('first')->andReturn($account); - $channelMock->shouldReceive('activePlatformAccounts')->andReturn($accountsRelation); - $relationMock = \Mockery::mock(\Illuminate\Database\Eloquent\Relations\BelongsToMany::class); - $relationMock->shouldReceive('with')->andReturnSelf(); - $relationMock->shouldReceive('get')->andReturn(new EloquentCollection([$channelMock])); - $feedMock = \Mockery::mock(Feed::class)->makePartial(); - $feedMock->setRawAttributes($feed->getAttributes()); - $feedMock->shouldReceive('activeChannels')->andReturn($relationMock); - $article->setRelation('feed', $feedMock); + // Create route + Route::create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + 'is_active' => true, + 'priority' => 50 + ]); + + // Attach account to channel as active + $channel->platformAccounts()->attach($account->id, [ + 'is_active' => true, + 'priority' => 50 + ]); // Publisher throws an exception via service seam $publisherDouble = \Mockery::mock(LemmyPublisher::class); @@ -195,36 +181,42 @@ public function test_publish_to_routed_channels_handles_publishing_failure_grace $this->assertDatabaseCount('article_publications', 0); } - public function test_publish_to_routed_channels_publishes_to_multiple_channels(): void + public function test_publish_to_routed_channels_publishes_to_multiple_routes(): void { // Arrange $feed = Feed::factory()->create(); $article = Article::factory()->create(['feed_id' => $feed->id, 'is_valid' => true]); - $channel1 = PlatformChannel::factory()->create(); - $channel2 = PlatformChannel::factory()->create(); - $channel1->load('platformInstance'); - $channel2->load('platformInstance'); - + $platformInstance = PlatformInstance::factory()->create(); + $channel1 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); + $channel2 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); $account1 = PlatformAccount::factory()->create(); $account2 = PlatformAccount::factory()->create(); - $channelMock1 = \Mockery::mock($channel1)->makePartial(); - $accountsRelation1 = \Mockery::mock(\Illuminate\Database\Eloquent\Relations\BelongsToMany::class); - $accountsRelation1->shouldReceive('first')->andReturn($account1); - $channelMock1->shouldReceive('activePlatformAccounts')->andReturn($accountsRelation1); - $channelMock2 = \Mockery::mock($channel2)->makePartial(); - $accountsRelation2 = \Mockery::mock(\Illuminate\Database\Eloquent\Relations\BelongsToMany::class); - $accountsRelation2->shouldReceive('first')->andReturn($account2); - $channelMock2->shouldReceive('activePlatformAccounts')->andReturn($accountsRelation2); + // Create routes + Route::create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel1->id, + 'is_active' => true, + 'priority' => 100 + ]); - $relationMock = \Mockery::mock(\Illuminate\Database\Eloquent\Relations\BelongsToMany::class); - $relationMock->shouldReceive('with')->andReturnSelf(); - $relationMock->shouldReceive('get')->andReturn(new EloquentCollection([$channelMock1, $channelMock2])); - $feedMock = \Mockery::mock(Feed::class)->makePartial(); - $feedMock->setRawAttributes($feed->getAttributes()); - $feedMock->shouldReceive('activeChannels')->andReturn($relationMock); - $article->setRelation('feed', $feedMock); + Route::create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel2->id, + 'is_active' => true, + 'priority' => 50 + ]); + + // Attach accounts to channels as active + $channel1->platformAccounts()->attach($account1->id, [ + 'is_active' => true, + 'priority' => 50 + ]); + $channel2->platformAccounts()->attach($account2->id, [ + 'is_active' => true, + 'priority' => 50 + ]); $publisherDouble = \Mockery::mock(LemmyPublisher::class); $publisherDouble->shouldReceive('publishToChannel') @@ -250,30 +242,36 @@ public function test_publish_to_routed_channels_filters_out_failed_publications( $feed = Feed::factory()->create(); $article = Article::factory()->create(['feed_id' => $feed->id, 'is_valid' => true]); - $channel1 = PlatformChannel::factory()->create(); - $channel2 = PlatformChannel::factory()->create(); - $channel1->load('platformInstance'); - $channel2->load('platformInstance'); - + $platformInstance = PlatformInstance::factory()->create(); + $channel1 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); + $channel2 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); $account1 = PlatformAccount::factory()->create(); $account2 = PlatformAccount::factory()->create(); - $channelMock1 = \Mockery::mock($channel1)->makePartial(); - $accountsRelation1 = \Mockery::mock(\Illuminate\Database\Eloquent\Relations\BelongsToMany::class); - $accountsRelation1->shouldReceive('first')->andReturn($account1); - $channelMock1->shouldReceive('activePlatformAccounts')->andReturn($accountsRelation1); - $channelMock2 = \Mockery::mock($channel2)->makePartial(); - $accountsRelation2 = \Mockery::mock(\Illuminate\Database\Eloquent\Relations\BelongsToMany::class); - $accountsRelation2->shouldReceive('first')->andReturn($account2); - $channelMock2->shouldReceive('activePlatformAccounts')->andReturn($accountsRelation2); + // Create routes + Route::create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel1->id, + 'is_active' => true, + 'priority' => 100 + ]); - $relationMock = \Mockery::mock(\Illuminate\Database\Eloquent\Relations\BelongsToMany::class); - $relationMock->shouldReceive('with')->andReturnSelf(); - $relationMock->shouldReceive('get')->andReturn(new EloquentCollection([$channelMock1, $channelMock2])); - $feedMock = \Mockery::mock(Feed::class)->makePartial(); - $feedMock->setRawAttributes($feed->getAttributes()); - $feedMock->shouldReceive('activeChannels')->andReturn($relationMock); - $article->setRelation('feed', $feedMock); + Route::create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel2->id, + 'is_active' => true, + 'priority' => 50 + ]); + + // Attach accounts to channels as active + $channel1->platformAccounts()->attach($account1->id, [ + 'is_active' => true, + 'priority' => 50 + ]); + $channel2->platformAccounts()->attach($account2->id, [ + 'is_active' => true, + 'priority' => 50 + ]); $publisherDouble = \Mockery::mock(LemmyPublisher::class); $publisherDouble->shouldReceive('publishToChannel') diff --git a/backend/tests/Unit/Services/Publishing/KeywordFilteringTest.php b/backend/tests/Unit/Services/Publishing/KeywordFilteringTest.php new file mode 100644 index 0000000..225764a --- /dev/null +++ b/backend/tests/Unit/Services/Publishing/KeywordFilteringTest.php @@ -0,0 +1,263 @@ +service = new ArticlePublishingService(); + $this->feed = Feed::factory()->create(); + $this->channel1 = PlatformChannel::factory()->create(); + $this->channel2 = PlatformChannel::factory()->create(); + + // Create routes + $this->route1 = Route::create([ + 'feed_id' => $this->feed->id, + 'platform_channel_id' => $this->channel1->id, + 'is_active' => true, + 'priority' => 100 + ]); + + $this->route2 = Route::create([ + 'feed_id' => $this->feed->id, + 'platform_channel_id' => $this->channel2->id, + 'is_active' => true, + 'priority' => 50 + ]); + } + + public function test_route_with_no_keywords_matches_all_articles(): void + { + $article = Article::factory()->create([ + 'feed_id' => $this->feed->id, + 'is_valid' => true + ]); + + $extractedData = [ + 'title' => 'Some random article', + 'description' => 'This is about something', + 'full_article' => 'The content talks about various topics' + ]; + + // Use reflection to test private method + $reflection = new \ReflectionClass($this->service); + $method = $reflection->getMethod('routeMatchesArticle'); + $method->setAccessible(true); + + $result = $method->invokeArgs($this->service, [$this->route1, $extractedData]); + + $this->assertTrue($result, 'Route with no keywords should match any article'); + } + + public function test_route_with_keywords_matches_article_containing_keyword(): void + { + // Add keywords to route1 + Keyword::factory()->create([ + 'feed_id' => $this->feed->id, + 'platform_channel_id' => $this->channel1->id, + 'keyword' => 'Belgium', + 'is_active' => true + ]); + + Keyword::factory()->create([ + 'feed_id' => $this->feed->id, + 'platform_channel_id' => $this->channel1->id, + 'keyword' => 'politics', + 'is_active' => true + ]); + + $article = Article::factory()->create([ + 'feed_id' => $this->feed->id, + 'is_valid' => true + ]); + + $extractedData = [ + 'title' => 'Belgium announces new policy', + 'description' => 'The government makes changes', + 'full_article' => 'The Belgian government announced today...' + ]; + + // Use reflection to test private method + $reflection = new \ReflectionClass($this->service); + $method = $reflection->getMethod('routeMatchesArticle'); + $method->setAccessible(true); + + $result = $method->invokeArgs($this->service, [$this->route1, $extractedData]); + + $this->assertTrue($result, 'Route should match article containing keyword "Belgium"'); + } + + public function test_route_with_keywords_does_not_match_article_without_keywords(): void + { + // Add keywords to route1 + Keyword::factory()->create([ + 'feed_id' => $this->feed->id, + 'platform_channel_id' => $this->channel1->id, + 'keyword' => 'sports', + 'is_active' => true + ]); + + Keyword::factory()->create([ + 'feed_id' => $this->feed->id, + 'platform_channel_id' => $this->channel1->id, + 'keyword' => 'football', + 'is_active' => true + ]); + + $article = Article::factory()->create([ + 'feed_id' => $this->feed->id, + 'is_valid' => true + ]); + + $extractedData = [ + 'title' => 'Economic news update', + 'description' => 'Markets are doing well', + 'full_article' => 'The economy is showing strong growth this quarter...' + ]; + + // Use reflection to test private method + $reflection = new \ReflectionClass($this->service); + $method = $reflection->getMethod('routeMatchesArticle'); + $method->setAccessible(true); + + $result = $method->invokeArgs($this->service, [$this->route1, $extractedData]); + + $this->assertFalse($result, 'Route should not match article without any keywords'); + } + + public function test_inactive_keywords_are_ignored(): void + { + // Add active and inactive keywords to route1 + Keyword::factory()->create([ + 'feed_id' => $this->feed->id, + 'platform_channel_id' => $this->channel1->id, + 'keyword' => 'Belgium', + 'is_active' => false // Inactive + ]); + + Keyword::factory()->create([ + 'feed_id' => $this->feed->id, + 'platform_channel_id' => $this->channel1->id, + 'keyword' => 'politics', + 'is_active' => true // Active + ]); + + $article = Article::factory()->create([ + 'feed_id' => $this->feed->id, + 'is_valid' => true + ]); + + $extractedDataWithInactiveKeyword = [ + 'title' => 'Belgium announces new policy', + 'description' => 'The government makes changes', + 'full_article' => 'The Belgian government announced today...' + ]; + + $extractedDataWithActiveKeyword = [ + 'title' => 'Political changes ahead', + 'description' => 'Politics is changing', + 'full_article' => 'The political landscape is shifting...' + ]; + + // Use reflection to test private method + $reflection = new \ReflectionClass($this->service); + $method = $reflection->getMethod('routeMatchesArticle'); + $method->setAccessible(true); + + $result1 = $method->invokeArgs($this->service, [$this->route1, $extractedDataWithInactiveKeyword]); + $result2 = $method->invokeArgs($this->service, [$this->route1, $extractedDataWithActiveKeyword]); + + $this->assertFalse($result1, 'Route should not match article with inactive keyword'); + $this->assertTrue($result2, 'Route should match article with active keyword'); + } + + public function test_keyword_matching_is_case_insensitive(): void + { + Keyword::factory()->create([ + 'feed_id' => $this->feed->id, + 'platform_channel_id' => $this->channel1->id, + 'keyword' => 'BELGIUM', + 'is_active' => true + ]); + + $article = Article::factory()->create([ + 'feed_id' => $this->feed->id, + 'is_valid' => true + ]); + + $extractedData = [ + 'title' => 'belgium news', + 'description' => 'About Belgium', + 'full_article' => 'News from belgium today...' + ]; + + // Use reflection to test private method + $reflection = new \ReflectionClass($this->service); + $method = $reflection->getMethod('routeMatchesArticle'); + $method->setAccessible(true); + + $result = $method->invokeArgs($this->service, [$this->route1, $extractedData]); + + $this->assertTrue($result, 'Keyword matching should be case insensitive'); + } + + public function test_keywords_match_in_title_description_and_content(): void + { + $keywordInTitle = Keyword::factory()->create([ + 'feed_id' => $this->feed->id, + 'platform_channel_id' => $this->channel1->id, + 'keyword' => 'title-word', + 'is_active' => true + ]); + + $keywordInDescription = Keyword::factory()->create([ + 'feed_id' => $this->feed->id, + 'platform_channel_id' => $this->channel2->id, + 'keyword' => 'desc-word', + 'is_active' => true + ]); + + $article = Article::factory()->create([ + 'feed_id' => $this->feed->id, + 'is_valid' => true + ]); + + $extractedData = [ + 'title' => 'This contains title-word', + 'description' => 'This has desc-word in it', + 'full_article' => 'The content has no special words' + ]; + + // Use reflection to test private method + $reflection = new \ReflectionClass($this->service); + $method = $reflection->getMethod('routeMatchesArticle'); + $method->setAccessible(true); + + $result1 = $method->invokeArgs($this->service, [$this->route1, $extractedData]); + $result2 = $method->invokeArgs($this->service, [$this->route2, $extractedData]); + + $this->assertTrue($result1, 'Should match keyword in title'); + $this->assertTrue($result2, 'Should match keyword in description'); + } +} \ No newline at end of file diff --git a/frontend/src/components/KeywordManager.tsx b/frontend/src/components/KeywordManager.tsx new file mode 100644 index 0000000..a8c0923 --- /dev/null +++ b/frontend/src/components/KeywordManager.tsx @@ -0,0 +1,170 @@ +import React, { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Plus, X, Tag } from 'lucide-react'; +import { apiClient, type Keyword, type KeywordRequest } from '../lib/api'; + +interface KeywordManagerProps { + feedId: number; + channelId: number; + keywords: Keyword[]; + onKeywordChange?: () => void; +} + +const KeywordManager: React.FC = ({ + feedId, + channelId, + keywords = [], + onKeywordChange +}) => { + const [newKeyword, setNewKeyword] = useState(''); + const [isAddingKeyword, setIsAddingKeyword] = useState(false); + const queryClient = useQueryClient(); + + const createKeywordMutation = useMutation({ + mutationFn: (data: KeywordRequest) => apiClient.createKeyword(feedId, channelId, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['routes'] }); + setNewKeyword(''); + setIsAddingKeyword(false); + onKeywordChange?.(); + }, + }); + + const deleteKeywordMutation = useMutation({ + mutationFn: (keywordId: number) => apiClient.deleteKeyword(feedId, channelId, keywordId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['routes'] }); + onKeywordChange?.(); + }, + }); + + const toggleKeywordMutation = useMutation({ + mutationFn: (keywordId: number) => apiClient.toggleKeyword(feedId, channelId, keywordId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['routes'] }); + onKeywordChange?.(); + }, + }); + + const handleAddKeyword = (e: React.FormEvent) => { + e.preventDefault(); + if (newKeyword.trim()) { + createKeywordMutation.mutate({ keyword: newKeyword.trim() }); + } + }; + + const handleDeleteKeyword = (keywordId: number) => { + if (confirm('Are you sure you want to delete this keyword?')) { + deleteKeywordMutation.mutate(keywordId); + } + }; + + const handleToggleKeyword = (keywordId: number) => { + toggleKeywordMutation.mutate(keywordId); + }; + + return ( +
+
+ + Keywords + +
+ + {isAddingKeyword && ( +
+ setNewKeyword(e.target.value)} + placeholder="Enter keyword..." + className="flex-1 px-2 py-1 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + autoFocus + /> + + +
+ )} + + {keywords.length > 0 ? ( +
+ {keywords.map((keyword) => ( +
+
+ + {keyword.keyword} + + + {keyword.is_active ? 'Active' : 'Inactive'} + +
+
+ + +
+
+ ))} +
+ ) : ( + !isAddingKeyword && ( +
+ No keywords defined. This route will match all articles. +
+ ) + )} +
+ ); +}; + +export default KeywordManager; \ No newline at end of file diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 3f43723..e21133d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -190,24 +190,34 @@ export interface ChannelRequest { description?: string; } +export interface Keyword { + id: number; + keyword: string; + is_active: boolean; +} + export interface Route { id?: number; feed_id: number; platform_channel_id: number; is_active: boolean; priority: number; - filters: Record; created_at: string; updated_at: string; feed?: Feed; platform_channel?: PlatformChannel; + keywords?: Keyword[]; } export interface RouteRequest { feed_id: number; platform_channel_id: number; priority?: number; - filters?: Record; +} + +export interface KeywordRequest { + keyword: string; + is_active?: boolean; } // API Client class @@ -362,6 +372,31 @@ class ApiClient { return response.data.data; } + // Keywords endpoints + async getKeywords(feedId: number, channelId: number): Promise { + const response = await axios.get>(`/routing/${feedId}/${channelId}/keywords`); + return response.data.data; + } + + async createKeyword(feedId: number, channelId: number, data: KeywordRequest): Promise { + const response = await axios.post>(`/routing/${feedId}/${channelId}/keywords`, data); + return response.data.data; + } + + async updateKeyword(feedId: number, channelId: number, keywordId: number, data: Partial): Promise { + const response = await axios.put>(`/routing/${feedId}/${channelId}/keywords/${keywordId}`, data); + return response.data.data; + } + + async deleteKeyword(feedId: number, channelId: number, keywordId: number): Promise { + await axios.delete(`/routing/${feedId}/${channelId}/keywords/${keywordId}`); + } + + async toggleKeyword(feedId: number, channelId: number, keywordId: number): Promise { + const response = await axios.post>(`/routing/${feedId}/${channelId}/keywords/${keywordId}/toggle`); + return response.data.data; + } + // Platform Channels endpoints async getPlatformChannels(): Promise { const response = await axios.get>('/platform-channels'); diff --git a/frontend/src/pages/Routes.tsx b/frontend/src/pages/Routes.tsx index b4dd684..49e00b4 100644 --- a/frontend/src/pages/Routes.tsx +++ b/frontend/src/pages/Routes.tsx @@ -1,7 +1,8 @@ import React, { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Plus, Edit2, Trash2, ToggleLeft, ToggleRight, ExternalLink, CheckCircle, XCircle } from 'lucide-react'; -import { apiClient, type Route, type RouteRequest, type Feed, type PlatformChannel } from '../lib/api'; +import { Plus, Edit2, Trash2, ToggleLeft, ToggleRight, ExternalLink, CheckCircle, XCircle, Tag } from 'lucide-react'; +import { apiClient, type Route, type RouteRequest, type Feed, type PlatformChannel, type Keyword } from '../lib/api'; +import KeywordManager from '../components/KeywordManager'; const Routes: React.FC = () => { const [showCreateModal, setShowCreateModal] = useState(false); @@ -148,6 +149,33 @@ const Routes: React.FC = () => { {route.platform_channel.description}

)} + {route.keywords && route.keywords.length > 0 && ( +
+
+ + Keywords +
+
+ {route.keywords.map((keyword) => ( + + {keyword.keyword} + + ))} +
+
+ )} + {(!route.keywords || route.keywords.length === 0) && ( +
+ No keyword filters - matches all articles +
+ )}