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 && (
+
+ )}
+
+ {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
+
+ )}