Add keywords to front-end

This commit is contained in:
myrmidex 2025-08-10 16:18:09 +02:00
parent 54abf52e20
commit a4b5aee790
17 changed files with 1050 additions and 127 deletions

View file

@ -0,0 +1,143 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Models\Feed;
use App\Models\Keyword;
use App\Models\PlatformChannel;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class KeywordsController extends BaseController
{
/**
* Display keywords for a specific route
*/
public function index(Feed $feed, PlatformChannel $channel): JsonResponse
{
$keywords = Keyword::where('feed_id', $feed->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);
}
}
}

View file

@ -269,7 +269,6 @@ public function createRoute(Request $request): JsonResponse
'feed_id' => 'required|exists:feeds,id', 'feed_id' => 'required|exists:feeds,id',
'platform_channel_id' => 'required|exists:platform_channels,id', 'platform_channel_id' => 'required|exists:platform_channels,id',
'priority' => 'nullable|integer|min:1|max:100', 'priority' => 'nullable|integer|min:1|max:100',
'filters' => 'nullable|array',
]); ]);
if ($validator->fails()) { if ($validator->fails()) {
@ -282,7 +281,6 @@ public function createRoute(Request $request): JsonResponse
'feed_id' => $validated['feed_id'], 'feed_id' => $validated['feed_id'],
'platform_channel_id' => $validated['platform_channel_id'], 'platform_channel_id' => $validated['platform_channel_id'],
'priority' => $validated['priority'] ?? 50, 'priority' => $validated['priority'] ?? 50,
'filters' => $validated['filters'] ?? [],
'is_active' => true, 'is_active' => true,
]); ]);

View file

@ -17,7 +17,7 @@ class RoutingController extends BaseController
*/ */
public function index(): JsonResponse public function index(): JsonResponse
{ {
$routes = Route::with(['feed', 'platformChannel']) $routes = Route::with(['feed', 'platformChannel', 'keywords'])
->orderBy('is_active', 'desc') ->orderBy('is_active', 'desc')
->orderBy('priority', 'asc') ->orderBy('priority', 'asc')
->get(); ->get();
@ -47,7 +47,7 @@ public function store(Request $request): JsonResponse
$route = Route::create($validated); $route = Route::create($validated);
return $this->sendResponse( return $this->sendResponse(
new RouteResource($route->load(['feed', 'platformChannel'])), new RouteResource($route->load(['feed', 'platformChannel', 'keywords'])),
'Routing configuration created successfully!', 'Routing configuration created successfully!',
201 201
); );
@ -69,7 +69,7 @@ public function show(Feed $feed, PlatformChannel $channel): JsonResponse
return $this->sendNotFound('Routing configuration not found.'); return $this->sendNotFound('Routing configuration not found.');
} }
$route->load(['feed', 'platformChannel']); $route->load(['feed', 'platformChannel', 'keywords']);
return $this->sendResponse( return $this->sendResponse(
new RouteResource($route), new RouteResource($route),
@ -99,7 +99,7 @@ public function update(Request $request, Feed $feed, PlatformChannel $channel):
->update($validated); ->update($validated);
return $this->sendResponse( return $this->sendResponse(
new RouteResource($route->fresh(['feed', 'platformChannel'])), new RouteResource($route->fresh(['feed', 'platformChannel', 'keywords'])),
'Routing configuration updated successfully!' 'Routing configuration updated successfully!'
); );
} catch (ValidationException $e) { } catch (ValidationException $e) {
@ -154,7 +154,7 @@ public function toggle(Feed $feed, PlatformChannel $channel): JsonResponse
$status = $newStatus ? 'activated' : 'deactivated'; $status = $newStatus ? 'activated' : 'deactivated';
return $this->sendResponse( return $this->sendResponse(
new RouteResource($route->fresh(['feed', 'platformChannel'])), new RouteResource($route->fresh(['feed', 'platformChannel', 'keywords'])),
"Routing configuration {$status} successfully!" "Routing configuration {$status} successfully!"
); );
} catch (\Exception $e) { } catch (\Exception $e) {

View file

@ -24,6 +24,15 @@ public function toArray(Request $request): array
'updated_at' => $this->updated_at->toISOString(), 'updated_at' => $this->updated_at->toISOString(),
'feed' => new FeedResource($this->whenLoaded('feed')), 'feed' => new FeedResource($this->whenLoaded('feed')),
'platform_channel' => new PlatformChannelResource($this->whenLoaded('platformChannel')), '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,
];
});
}),
]; ];
} }
} }

View file

@ -87,7 +87,7 @@ public function getStatusAttribute(): string
public function channels(): BelongsToMany public function channels(): BelongsToMany
{ {
return $this->belongsToMany(PlatformChannel::class, 'routes') return $this->belongsToMany(PlatformChannel::class, 'routes')
->withPivot(['is_active', 'priority', 'filters']) ->withPivot(['is_active', 'priority'])
->withTimestamps(); ->withTimestamps();
} }

View file

@ -78,7 +78,7 @@ public function getFullNameAttribute(): string
public function feeds(): BelongsToMany public function feeds(): BelongsToMany
{ {
return $this->belongsToMany(Feed::class, 'routes') return $this->belongsToMany(Feed::class, 'routes')
->withPivot(['is_active', 'priority', 'filters']) ->withPivot(['is_active', 'priority'])
->withTimestamps(); ->withTimestamps();
} }

View file

@ -14,7 +14,6 @@
* @property int $platform_channel_id * @property int $platform_channel_id
* @property bool $is_active * @property bool $is_active
* @property int $priority * @property int $priority
* @property array<string, mixed> $filters
* @property Carbon $created_at * @property Carbon $created_at
* @property Carbon $updated_at * @property Carbon $updated_at
*/ */
@ -33,13 +32,11 @@ class Route extends Model
'feed_id', 'feed_id',
'platform_channel_id', 'platform_channel_id',
'is_active', 'is_active',
'priority', 'priority'
'filters'
]; ];
protected $casts = [ protected $casts = [
'is_active' => 'boolean', 'is_active' => 'boolean'
'filters' => 'array'
]; ];
/** /**

View file

@ -7,6 +7,7 @@
use App\Models\Article; use App\Models\Article;
use App\Models\ArticlePublication; use App\Models\ArticlePublication;
use App\Models\PlatformChannel; use App\Models\PlatformChannel;
use App\Models\Route;
use App\Modules\Lemmy\Services\LemmyPublisher; use App\Modules\Lemmy\Services\LemmyPublisher;
use App\Services\Log\LogSaver; use App\Services\Log\LogSaver;
use Exception; use Exception;
@ -36,15 +37,26 @@ public function publishToRoutedChannels(Article $article, array $extractedData):
$feed = $article->feed; $feed = $article->feed;
/** @var EloquentCollection<int, PlatformChannel> $activeChannels */ // Get active routes with keywords instead of just channels
$activeChannels = $feed->activeChannels()->with(['platformInstance', 'activePlatformAccounts'])->get(); $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(); $account = $channel->activePlatformAccounts()->first();
if (! $account) { if (! $account) {
LogSaver::warning('No active account for channel', $channel, [ LogSaver::warning('No active account for channel', $channel, [
'article_id' => $article->id 'article_id' => $article->id,
'route_priority' => $route->priority
]); ]);
return null; return null;
@ -55,6 +67,43 @@ public function publishToRoutedChannels(Article $article, array $extractedData):
->filter(); ->filter();
} }
/**
* Check if a route matches an article based on keywords
* @param array<string, mixed> $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<string, mixed> $extractedData * @param array<string, mixed> $extractedData
*/ */
@ -74,10 +123,8 @@ private function publishToChannel(Article $article, array $extractedData, Platfo
'publication_data' => $postData, 'publication_data' => $postData,
]); ]);
LogSaver::info('Published to channel via routing', $channel, [ LogSaver::info('Published to channel via keyword-filtered routing', $channel, [
'article_id' => $article->id, 'article_id' => $article->id
// Use nullsafe operator in case pivot is not loaded in tests
'priority' => $channel->pivot?->priority
]); ]);
return $publication; return $publication;

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('routes', function (Blueprint $table) {
$table->dropColumn('filters');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('routes', function (Blueprint $table) {
$table->json('filters')->nullable();
});
}
};

View file

@ -9,6 +9,7 @@
use App\Http\Controllers\Api\V1\PlatformAccountsController; use App\Http\Controllers\Api\V1\PlatformAccountsController;
use App\Http\Controllers\Api\V1\PlatformChannelsController; use App\Http\Controllers\Api\V1\PlatformChannelsController;
use App\Http\Controllers\Api\V1\RoutingController; use App\Http\Controllers\Api\V1\RoutingController;
use App\Http\Controllers\Api\V1\KeywordsController;
use App\Http\Controllers\Api\V1\SettingsController; use App\Http\Controllers\Api\V1\SettingsController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@ -100,6 +101,13 @@
Route::delete('/routing/{feed}/{channel}', [RoutingController::class, 'destroy'])->name('api.routing.destroy'); 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'); 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 // Settings
Route::get('/settings', [SettingsController::class, 'index'])->name('api.settings.index'); Route::get('/settings', [SettingsController::class, 'index'])->name('api.settings.index');
Route::put('/settings', [SettingsController::class, 'update'])->name('api.settings.update'); Route::put('/settings', [SettingsController::class, 'update'])->name('api.settings.update');

View file

@ -0,0 +1,189 @@
<?php
namespace Tests\Feature\Http\Controllers\Api\V1;
use App\Models\Feed;
use App\Models\Keyword;
use App\Models\PlatformChannel;
use App\Models\Route;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class KeywordsControllerTest extends TestCase
{
use RefreshDatabase;
protected Feed $feed;
protected PlatformChannel $channel;
protected Route $route;
protected function setUp(): void
{
parent::setUp();
$this->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']);
}
}

View file

@ -333,7 +333,6 @@ public function test_create_route_creates_route_successfully()
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'platform_channel_id' => $platformChannel->id, 'platform_channel_id' => $platformChannel->id,
'priority' => 75, 'priority' => 75,
'filters' => ['keyword' => 'test'],
]; ];
$response = $this->postJson('/api/v1/onboarding/route', $routeData); $response = $this->postJson('/api/v1/onboarding/route', $routeData);

View file

@ -10,6 +10,7 @@
use App\Models\PlatformAccount; use App\Models\PlatformAccount;
use App\Models\PlatformChannel; use App\Models\PlatformChannel;
use App\Models\PlatformInstance; use App\Models\PlatformInstance;
use App\Models\Route;
use App\Modules\Lemmy\Services\LemmyPublisher; use App\Modules\Lemmy\Services\LemmyPublisher;
use App\Services\Log\LogSaver; use App\Services\Log\LogSaver;
use App\Services\Publishing\ArticlePublishingService; 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); $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(); $feed = Feed::factory()->create();
$article = Article::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()); $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 // Arrange: valid article
$feed = Feed::factory()->create(); $feed = Feed::factory()->create();
@ -73,32 +74,17 @@ public function test_publish_to_routed_channels_skips_channels_without_active_ac
'is_valid' => true, '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 = PlatformChannel::factory()->create();
$channel->load('platformInstance');
// Mock feed->activeChannels()->with()->get() chain to return our channel Route::create([
$relationMock = \Mockery::mock(\Illuminate\Database\Eloquent\Relations\BelongsToMany::class); 'feed_id' => $feed->id,
$relationMock->shouldReceive('with')->andReturnSelf(); 'platform_channel_id' => $channel->id,
$relationMock->shouldReceive('get')->andReturn(new EloquentCollection([$channel])); 'is_active' => true,
'priority' => 50
]);
$feedMock = \Mockery::mock(Feed::class)->makePartial(); // Don't create any platform accounts for the channel
$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]));
// Act // Act
$result = $this->service->publishToRoutedChannels($article, ['title' => 'Test']); $result = $this->service->publishToRoutedChannels($article, ['title' => 'Test']);
@ -113,25 +99,24 @@ public function test_publish_to_routed_channels_successfully_publishes_to_channe
// Arrange // Arrange
$feed = Feed::factory()->create(); $feed = Feed::factory()->create();
$article = Article::factory()->create(['feed_id' => $feed->id, 'is_valid' => true]); $article = Article::factory()->create(['feed_id' => $feed->id, 'is_valid' => true]);
$channel = PlatformChannel::factory()->create(); $platformInstance = PlatformInstance::factory()->create();
$channel->load('platformInstance'); $channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]);
// Create an active account and pretend it's active for the channel via relation mock
$account = PlatformAccount::factory()->create(); $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 // Create route
$relationMock = \Mockery::mock(\Illuminate\Database\Eloquent\Relations\BelongsToMany::class); Route::create([
$relationMock->shouldReceive('with')->andReturnSelf(); 'feed_id' => $feed->id,
$relationMock->shouldReceive('get')->andReturn(new EloquentCollection([$channelMock])); 'platform_channel_id' => $channel->id,
$feedMock = \Mockery::mock(Feed::class)->makePartial(); 'is_active' => true,
$feedMock->setRawAttributes($feed->getAttributes()); 'priority' => 50
$feedMock->shouldReceive('activeChannels')->andReturn($relationMock); ]);
$article->setRelation('feed', $feedMock);
// Attach account to channel as active
$channel->platformAccounts()->attach($account->id, [
'is_active' => true,
'priority' => 50
]);
// Mock publisher via service seam // Mock publisher via service seam
$publisherDouble = \Mockery::mock(LemmyPublisher::class); $publisherDouble = \Mockery::mock(LemmyPublisher::class);
@ -160,23 +145,24 @@ public function test_publish_to_routed_channels_handles_publishing_failure_grace
// Arrange // Arrange
$feed = Feed::factory()->create(); $feed = Feed::factory()->create();
$article = Article::factory()->create(['feed_id' => $feed->id, 'is_valid' => true]); $article = Article::factory()->create(['feed_id' => $feed->id, 'is_valid' => true]);
$channel = PlatformChannel::factory()->create(); $platformInstance = PlatformInstance::factory()->create();
$channel->load('platformInstance'); $channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]);
$account = PlatformAccount::factory()->create(); $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); // Create route
$relationMock->shouldReceive('with')->andReturnSelf(); Route::create([
$relationMock->shouldReceive('get')->andReturn(new EloquentCollection([$channelMock])); 'feed_id' => $feed->id,
$feedMock = \Mockery::mock(Feed::class)->makePartial(); 'platform_channel_id' => $channel->id,
$feedMock->setRawAttributes($feed->getAttributes()); 'is_active' => true,
$feedMock->shouldReceive('activeChannels')->andReturn($relationMock); 'priority' => 50
$article->setRelation('feed', $feedMock); ]);
// Attach account to channel as active
$channel->platformAccounts()->attach($account->id, [
'is_active' => true,
'priority' => 50
]);
// Publisher throws an exception via service seam // Publisher throws an exception via service seam
$publisherDouble = \Mockery::mock(LemmyPublisher::class); $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); $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 // Arrange
$feed = Feed::factory()->create(); $feed = Feed::factory()->create();
$article = Article::factory()->create(['feed_id' => $feed->id, 'is_valid' => true]); $article = Article::factory()->create(['feed_id' => $feed->id, 'is_valid' => true]);
$channel1 = PlatformChannel::factory()->create(); $platformInstance = PlatformInstance::factory()->create();
$channel2 = PlatformChannel::factory()->create(); $channel1 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]);
$channel1->load('platformInstance'); $channel2 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]);
$channel2->load('platformInstance');
$account1 = PlatformAccount::factory()->create(); $account1 = PlatformAccount::factory()->create();
$account2 = PlatformAccount::factory()->create(); $account2 = PlatformAccount::factory()->create();
$channelMock1 = \Mockery::mock($channel1)->makePartial(); // Create routes
$accountsRelation1 = \Mockery::mock(\Illuminate\Database\Eloquent\Relations\BelongsToMany::class); Route::create([
$accountsRelation1->shouldReceive('first')->andReturn($account1); 'feed_id' => $feed->id,
$channelMock1->shouldReceive('activePlatformAccounts')->andReturn($accountsRelation1); 'platform_channel_id' => $channel1->id,
$channelMock2 = \Mockery::mock($channel2)->makePartial(); 'is_active' => true,
$accountsRelation2 = \Mockery::mock(\Illuminate\Database\Eloquent\Relations\BelongsToMany::class); 'priority' => 100
$accountsRelation2->shouldReceive('first')->andReturn($account2); ]);
$channelMock2->shouldReceive('activePlatformAccounts')->andReturn($accountsRelation2);
$relationMock = \Mockery::mock(\Illuminate\Database\Eloquent\Relations\BelongsToMany::class); Route::create([
$relationMock->shouldReceive('with')->andReturnSelf(); 'feed_id' => $feed->id,
$relationMock->shouldReceive('get')->andReturn(new EloquentCollection([$channelMock1, $channelMock2])); 'platform_channel_id' => $channel2->id,
$feedMock = \Mockery::mock(Feed::class)->makePartial(); 'is_active' => true,
$feedMock->setRawAttributes($feed->getAttributes()); 'priority' => 50
$feedMock->shouldReceive('activeChannels')->andReturn($relationMock); ]);
$article->setRelation('feed', $feedMock);
// 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 = \Mockery::mock(LemmyPublisher::class);
$publisherDouble->shouldReceive('publishToChannel') $publisherDouble->shouldReceive('publishToChannel')
@ -250,30 +242,36 @@ public function test_publish_to_routed_channels_filters_out_failed_publications(
$feed = Feed::factory()->create(); $feed = Feed::factory()->create();
$article = Article::factory()->create(['feed_id' => $feed->id, 'is_valid' => true]); $article = Article::factory()->create(['feed_id' => $feed->id, 'is_valid' => true]);
$channel1 = PlatformChannel::factory()->create(); $platformInstance = PlatformInstance::factory()->create();
$channel2 = PlatformChannel::factory()->create(); $channel1 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]);
$channel1->load('platformInstance'); $channel2 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]);
$channel2->load('platformInstance');
$account1 = PlatformAccount::factory()->create(); $account1 = PlatformAccount::factory()->create();
$account2 = PlatformAccount::factory()->create(); $account2 = PlatformAccount::factory()->create();
$channelMock1 = \Mockery::mock($channel1)->makePartial(); // Create routes
$accountsRelation1 = \Mockery::mock(\Illuminate\Database\Eloquent\Relations\BelongsToMany::class); Route::create([
$accountsRelation1->shouldReceive('first')->andReturn($account1); 'feed_id' => $feed->id,
$channelMock1->shouldReceive('activePlatformAccounts')->andReturn($accountsRelation1); 'platform_channel_id' => $channel1->id,
$channelMock2 = \Mockery::mock($channel2)->makePartial(); 'is_active' => true,
$accountsRelation2 = \Mockery::mock(\Illuminate\Database\Eloquent\Relations\BelongsToMany::class); 'priority' => 100
$accountsRelation2->shouldReceive('first')->andReturn($account2); ]);
$channelMock2->shouldReceive('activePlatformAccounts')->andReturn($accountsRelation2);
$relationMock = \Mockery::mock(\Illuminate\Database\Eloquent\Relations\BelongsToMany::class); Route::create([
$relationMock->shouldReceive('with')->andReturnSelf(); 'feed_id' => $feed->id,
$relationMock->shouldReceive('get')->andReturn(new EloquentCollection([$channelMock1, $channelMock2])); 'platform_channel_id' => $channel2->id,
$feedMock = \Mockery::mock(Feed::class)->makePartial(); 'is_active' => true,
$feedMock->setRawAttributes($feed->getAttributes()); 'priority' => 50
$feedMock->shouldReceive('activeChannels')->andReturn($relationMock); ]);
$article->setRelation('feed', $feedMock);
// 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 = \Mockery::mock(LemmyPublisher::class);
$publisherDouble->shouldReceive('publishToChannel') $publisherDouble->shouldReceive('publishToChannel')

View file

@ -0,0 +1,263 @@
<?php
namespace Tests\Unit\Services\Publishing;
use App\Models\Article;
use App\Models\Feed;
use App\Models\Keyword;
use App\Models\PlatformChannel;
use App\Models\Route;
use App\Services\Publishing\ArticlePublishingService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class KeywordFilteringTest extends TestCase
{
use RefreshDatabase;
private ArticlePublishingService $service;
private Feed $feed;
private PlatformChannel $channel1;
private PlatformChannel $channel2;
private Route $route1;
private Route $route2;
protected function setUp(): void
{
parent::setUp();
$this->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');
}
}

View file

@ -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<KeywordManagerProps> = ({
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 (
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Tag className="h-4 w-4 text-gray-500" />
<span className="text-sm font-medium text-gray-700">Keywords</span>
<button
type="button"
onClick={() => setIsAddingKeyword(true)}
className="inline-flex items-center p-1 text-gray-400 hover:text-gray-600 rounded"
title="Add keyword"
>
<Plus className="h-4 w-4" />
</button>
</div>
{isAddingKeyword && (
<form onSubmit={handleAddKeyword} className="flex space-x-2">
<input
type="text"
value={newKeyword}
onChange={(e) => 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
/>
<button
type="submit"
disabled={createKeywordMutation.isPending || !newKeyword.trim()}
className="px-3 py-1 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 disabled:opacity-50"
>
Add
</button>
<button
type="button"
onClick={() => {
setIsAddingKeyword(false);
setNewKeyword('');
}}
className="px-3 py-1 bg-gray-200 text-gray-700 text-sm rounded hover:bg-gray-300"
>
Cancel
</button>
</form>
)}
{keywords.length > 0 ? (
<div className="space-y-2">
{keywords.map((keyword) => (
<div
key={keyword.id}
className={`flex items-center justify-between px-3 py-2 rounded border ${
keyword.is_active
? 'border-blue-200 bg-blue-50'
: 'border-gray-200 bg-gray-50'
}`}
>
<div className="flex items-center space-x-2">
<span
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
keyword.is_active
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-500'
}`}
>
{keyword.keyword}
</span>
<span className="text-xs text-gray-500">
{keyword.is_active ? 'Active' : 'Inactive'}
</span>
</div>
<div className="flex space-x-1">
<button
type="button"
onClick={() => handleToggleKeyword(keyword.id)}
disabled={toggleKeywordMutation.isPending}
className="text-sm text-blue-600 hover:text-blue-800 disabled:opacity-50"
title={keyword.is_active ? 'Deactivate keyword' : 'Activate keyword'}
>
{keyword.is_active ? 'Deactivate' : 'Activate'}
</button>
<button
type="button"
onClick={() => handleDeleteKeyword(keyword.id)}
disabled={deleteKeywordMutation.isPending}
className="text-red-600 hover:text-red-800 disabled:opacity-50"
title="Delete keyword"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
) : (
!isAddingKeyword && (
<div className="text-sm text-gray-500 italic p-2 border border-gray-200 rounded">
No keywords defined. This route will match all articles.
</div>
)
)}
</div>
);
};
export default KeywordManager;

View file

@ -190,24 +190,34 @@ export interface ChannelRequest {
description?: string; description?: string;
} }
export interface Keyword {
id: number;
keyword: string;
is_active: boolean;
}
export interface Route { export interface Route {
id?: number; id?: number;
feed_id: number; feed_id: number;
platform_channel_id: number; platform_channel_id: number;
is_active: boolean; is_active: boolean;
priority: number; priority: number;
filters: Record<string, any>;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
feed?: Feed; feed?: Feed;
platform_channel?: PlatformChannel; platform_channel?: PlatformChannel;
keywords?: Keyword[];
} }
export interface RouteRequest { export interface RouteRequest {
feed_id: number; feed_id: number;
platform_channel_id: number; platform_channel_id: number;
priority?: number; priority?: number;
filters?: Record<string, any>; }
export interface KeywordRequest {
keyword: string;
is_active?: boolean;
} }
// API Client class // API Client class
@ -362,6 +372,31 @@ class ApiClient {
return response.data.data; return response.data.data;
} }
// Keywords endpoints
async getKeywords(feedId: number, channelId: number): Promise<Keyword[]> {
const response = await axios.get<ApiResponse<Keyword[]>>(`/routing/${feedId}/${channelId}/keywords`);
return response.data.data;
}
async createKeyword(feedId: number, channelId: number, data: KeywordRequest): Promise<Keyword> {
const response = await axios.post<ApiResponse<Keyword>>(`/routing/${feedId}/${channelId}/keywords`, data);
return response.data.data;
}
async updateKeyword(feedId: number, channelId: number, keywordId: number, data: Partial<KeywordRequest>): Promise<Keyword> {
const response = await axios.put<ApiResponse<Keyword>>(`/routing/${feedId}/${channelId}/keywords/${keywordId}`, data);
return response.data.data;
}
async deleteKeyword(feedId: number, channelId: number, keywordId: number): Promise<void> {
await axios.delete(`/routing/${feedId}/${channelId}/keywords/${keywordId}`);
}
async toggleKeyword(feedId: number, channelId: number, keywordId: number): Promise<Keyword> {
const response = await axios.post<ApiResponse<Keyword>>(`/routing/${feedId}/${channelId}/keywords/${keywordId}/toggle`);
return response.data.data;
}
// Platform Channels endpoints // Platform Channels endpoints
async getPlatformChannels(): Promise<PlatformChannel[]> { async getPlatformChannels(): Promise<PlatformChannel[]> {
const response = await axios.get<ApiResponse<PlatformChannel[]>>('/platform-channels'); const response = await axios.get<ApiResponse<PlatformChannel[]>>('/platform-channels');

View file

@ -1,7 +1,8 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit2, Trash2, ToggleLeft, ToggleRight, ExternalLink, CheckCircle, XCircle } from 'lucide-react'; import { Plus, Edit2, Trash2, ToggleLeft, ToggleRight, ExternalLink, CheckCircle, XCircle, Tag } from 'lucide-react';
import { apiClient, type Route, type RouteRequest, type Feed, type PlatformChannel } from '../lib/api'; 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 Routes: React.FC = () => {
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
@ -148,6 +149,33 @@ const Routes: React.FC = () => {
{route.platform_channel.description} {route.platform_channel.description}
</p> </p>
)} )}
{route.keywords && route.keywords.length > 0 && (
<div className="mt-3">
<div className="flex items-center space-x-2 mb-2">
<Tag className="h-4 w-4 text-gray-500" />
<span className="text-sm font-medium text-gray-700">Keywords</span>
</div>
<div className="flex flex-wrap gap-2">
{route.keywords.map((keyword) => (
<span
key={keyword.id}
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
keyword.is_active
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-500'
}`}
>
{keyword.keyword}
</span>
))}
</div>
</div>
)}
{(!route.keywords || route.keywords.length === 0) && (
<div className="mt-3 text-sm text-gray-400 italic">
No keyword filters - matches all articles
</div>
)}
</div> </div>
<div className="flex items-center space-x-2 ml-4"> <div className="flex items-center space-x-2 ml-4">
<button <button
@ -352,7 +380,7 @@ const EditRouteModal: React.FC<EditRouteModalProps> = ({ route, onClose, onSubmi
return ( return (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50"> <div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white"> <div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white max-h-[80vh] overflow-y-auto">
<div className="mt-3"> <div className="mt-3">
<h3 className="text-lg font-medium text-gray-900 mb-4">Edit Route</h3> <h3 className="text-lg font-medium text-gray-900 mb-4">Edit Route</h3>
<div className="mb-4 p-3 bg-gray-50 rounded-md"> <div className="mb-4 p-3 bg-gray-50 rounded-md">
@ -379,7 +407,18 @@ const EditRouteModal: React.FC<EditRouteModalProps> = ({ route, onClose, onSubmi
<p className="text-sm text-gray-500 mt-1">Higher priority routes are processed first</p> <p className="text-sm text-gray-500 mt-1">Higher priority routes are processed first</p>
</div> </div>
<div className="flex justify-end space-x-3 pt-4"> <div className="border-t pt-4">
<KeywordManager
feedId={route.feed_id}
channelId={route.platform_channel_id}
keywords={route.keywords || []}
onKeywordChange={() => {
// Keywords will be refreshed via React Query invalidation
}}
/>
</div>
<div className="flex justify-end space-x-3 pt-4 border-t">
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}