Feed languages

This commit is contained in:
myrmidex 2025-08-15 21:54:44 +02:00
parent ed93dc3630
commit e986f7871b
19 changed files with 585 additions and 93 deletions

View file

@ -19,7 +19,7 @@ public function index(Request $request): JsonResponse
{ {
$perPage = min($request->get('per_page', 15), 100); $perPage = min($request->get('per_page', 15), 100);
$feeds = Feed::with(['language']) $feeds = Feed::with(['languages'])
->withCount('articles') ->withCount('articles')
->orderBy('is_active', 'desc') ->orderBy('is_active', 'desc')
->orderBy('name') ->orderBy('name')
@ -57,7 +57,27 @@ public function store(StoreFeedRequest $request): JsonResponse
$validated['url'] = $adapter->getHomepageUrl(); $validated['url'] = $adapter->getHomepageUrl();
$validated['type'] = 'website'; $validated['type'] = 'website';
// Extract language-related data
$languageIds = $validated['language_ids'] ?? [];
$primaryLanguageId = $validated['primary_language_id'] ?? $languageIds[0] ?? null;
$languageUrls = $validated['language_urls'] ?? [];
// Remove language fields from feed data
unset($validated['language_ids'], $validated['primary_language_id'], $validated['language_urls']);
$feed = Feed::create($validated); $feed = Feed::create($validated);
// Attach languages to the feed
foreach ($languageIds as $index => $languageId) {
$pivotData = [
'url' => $languageUrls[$languageId] ?? $validated['url'],
'is_active' => true,
'is_primary' => $languageId == $primaryLanguageId,
];
$feed->languages()->attach($languageId, $pivotData);
}
$feed->load('languages');
return $this->sendResponse( return $this->sendResponse(
new FeedResource($feed), new FeedResource($feed),
@ -76,6 +96,8 @@ public function store(StoreFeedRequest $request): JsonResponse
*/ */
public function show(Feed $feed): JsonResponse public function show(Feed $feed): JsonResponse
{ {
$feed->load('languages');
return $this->sendResponse( return $this->sendResponse(
new FeedResource($feed), new FeedResource($feed),
'Feed retrieved successfully.' 'Feed retrieved successfully.'
@ -91,10 +113,34 @@ public function update(UpdateFeedRequest $request, Feed $feed): JsonResponse
$validated = $request->validated(); $validated = $request->validated();
$validated['is_active'] = $validated['is_active'] ?? $feed->is_active; $validated['is_active'] = $validated['is_active'] ?? $feed->is_active;
// Extract language-related data
$languageIds = $validated['language_ids'] ?? null;
$primaryLanguageId = $validated['primary_language_id'] ?? null;
$languageUrls = $validated['language_urls'] ?? [];
// Remove language fields from feed data
unset($validated['language_ids'], $validated['primary_language_id'], $validated['language_urls']);
$feed->update($validated); $feed->update($validated);
// Update languages if provided
if ($languageIds !== null) {
// Sync languages with the feed
$syncData = [];
foreach ($languageIds as $index => $languageId) {
$syncData[$languageId] = [
'url' => $languageUrls[$languageId] ?? $feed->url,
'is_active' => true,
'is_primary' => $primaryLanguageId ? $languageId == $primaryLanguageId : $index === 0,
];
}
$feed->languages()->sync($syncData);
}
$feed->load('languages');
return $this->sendResponse( return $this->sendResponse(
new FeedResource($feed->fresh()), new FeedResource($feed),
'Feed updated successfully!' 'Feed updated successfully!'
); );
} catch (ValidationException $e) { } catch (ValidationException $e) {

View file

@ -204,7 +204,9 @@ public function createFeed(Request $request): JsonResponse
$validator = Validator::make($request->all(), [ $validator = Validator::make($request->all(), [
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'provider' => 'required|in:belga,vrt', 'provider' => 'required|in:belga,vrt',
'language_id' => 'required|exists:languages,id', 'language_ids' => 'required|array|min:1',
'language_ids.*' => 'exists:languages,id',
'primary_language_id' => 'nullable|exists:languages,id',
'description' => 'nullable|string|max:1000', 'description' => 'nullable|string|max:1000',
]); ]);
@ -224,20 +226,34 @@ public function createFeed(Request $request): JsonResponse
$url = 'https://www.belganewsagency.eu/'; $url = 'https://www.belganewsagency.eu/';
} }
// Extract language-related data
$languageIds = $validated['language_ids'];
$primaryLanguageId = $validated['primary_language_id'] ?? $languageIds[0];
$feed = Feed::firstOrCreate( $feed = Feed::firstOrCreate(
['url' => $url], ['url' => $url],
[ [
'name' => $validated['name'], 'name' => $validated['name'],
'type' => $type, 'type' => $type,
'provider' => $provider, 'provider' => $provider,
'language_id' => $validated['language_id'],
'description' => $validated['description'] ?? null, 'description' => $validated['description'] ?? null,
'is_active' => true, 'is_active' => true,
] ]
); );
// Sync languages with the feed
$syncData = [];
foreach ($languageIds as $languageId) {
$syncData[$languageId] = [
'url' => $url,
'is_active' => true,
'is_primary' => $languageId == $primaryLanguageId,
];
}
$feed->languages()->sync($syncData);
return $this->sendResponse( return $this->sendResponse(
new FeedResource($feed->load('language')), new FeedResource($feed->load('languages')),
'Feed created successfully.' 'Feed created successfully.'
); );
} }

View file

@ -19,6 +19,16 @@
'description' => 'Belgian public broadcaster news', 'description' => 'Belgian public broadcaster news',
'type' => 'website', 'type' => 'website',
'is_active' => true, 'is_active' => true,
'supported_languages' => [
'en' => [
'url' => 'https://www.vrt.be/vrtnws/en/',
'name' => 'English',
],
'nl' => [
'url' => 'https://www.vrt.be/vrtnws/nl/',
'name' => 'Dutch',
],
],
'parsers' => [ 'parsers' => [
'homepage' => \Domains\Article\Parsers\Vrt\VrtHomepageParserAdapter::class, 'homepage' => \Domains\Article\Parsers\Vrt\VrtHomepageParserAdapter::class,
'article' => \Domains\Article\Parsers\Vrt\VrtArticleParser::class, 'article' => \Domains\Article\Parsers\Vrt\VrtArticleParser::class,
@ -31,6 +41,12 @@
'description' => 'Belgian national news agency', 'description' => 'Belgian national news agency',
'type' => 'rss', 'type' => 'rss',
'is_active' => true, 'is_active' => true,
'supported_languages' => [
'en' => [
'url' => 'https://www.belganewsagency.eu/',
'name' => 'English',
],
],
'parsers' => [ 'parsers' => [
'homepage' => \Domains\Article\Parsers\Belga\BelgaHomepageParserAdapter::class, 'homepage' => \Domains\Article\Parsers\Belga\BelgaHomepageParserAdapter::class,
'article' => \Domains\Article\Parsers\Belga\BelgaArticleParser::class, 'article' => \Domains\Article\Parsers\Belga\BelgaArticleParser::class,

View file

@ -0,0 +1,61 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Create feed_languages pivot table for many-to-many relationship
Schema::create('feed_languages', function (Blueprint $table) {
$table->id();
$table->foreignId('feed_id')->constrained()->onDelete('cascade');
$table->foreignId('language_id')->constrained()->onDelete('cascade');
$table->string('url')->nullable(); // Optional: specific URL for this feed-language combination
$table->json('settings')->nullable(); // Optional: language-specific settings
$table->boolean('is_active')->default(true);
$table->boolean('is_primary')->default(false); // To indicate primary language for the feed
$table->timestamps();
// Ensure unique combination of feed and language
$table->unique(['feed_id', 'language_id']);
// Index for performance
$table->index(['feed_id', 'is_active']);
$table->index(['language_id', 'is_active']);
});
// Migrate existing data from feeds.language_id to feed_languages
// This will preserve existing language associations
DB::table('feeds')
->whereNotNull('language_id')
->orderBy('id')
->chunk(100, function ($feeds) {
foreach ($feeds as $feed) {
DB::table('feed_languages')->insert([
'feed_id' => $feed->id,
'language_id' => $feed->language_id,
'url' => $feed->url,
'is_active' => true,
'is_primary' => true, // Mark existing languages as primary
'created_at' => now(),
'updated_at' => now(),
]);
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('feed_languages');
}
};

View file

@ -0,0 +1,33 @@
<?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('feeds', function (Blueprint $table) {
// Drop foreign key constraint first
$table->dropForeign(['language_id']);
// Then drop the column
$table->dropColumn('language_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('feeds', function (Blueprint $table) {
// Re-add the language_id column
$table->foreignId('language_id')->nullable()->after('provider');
$table->foreign('language_id')->references('id')->on('languages');
});
}
};

View file

@ -20,7 +20,6 @@ public function definition(): array
'url' => $this->faker->url(), 'url' => $this->faker->url(),
'type' => $this->faker->randomElement(['website', 'rss']), 'type' => $this->faker->randomElement(['website', 'rss']),
'provider' => $this->faker->randomElement(['vrt', 'belga']), 'provider' => $this->faker->randomElement(['vrt', 'belga']),
'language_id' => null,
'description' => $this->faker->optional()->sentence(), 'description' => $this->faker->optional()->sentence(),
'settings' => [], 'settings' => [],
'is_active' => true, 'is_active' => true,
@ -56,11 +55,30 @@ public function recentlyFetched(): static
]); ]);
} }
public function language(Language $language): static /**
* Attach languages to the feed after creation
*/
public function withLanguages(array $languageIds = [], bool $usePrimary = true): static
{ {
return $this->state(fn (array $attributes) => [ return $this->afterCreating(function (Feed $feed) use ($languageIds, $usePrimary) {
'language_id' => $language->id, if (empty($languageIds)) {
]); // Default to English if no languages specified
$language = Language::where('short_code', 'en')->first();
if ($language) {
$languageIds = [$language->id];
}
}
foreach ($languageIds as $index => $languageId) {
$feed->languages()->attach($languageId, [
'url' => $feed->url,
'is_active' => true,
'is_primary' => $usePrimary && $index === 0,
'created_at' => now(),
'updated_at' => now(),
]);
}
});
} }
public function vrt(): static public function vrt(): static

View file

@ -19,8 +19,6 @@
* @property string $url * @property string $url
* @property string $type * @property string $type
* @property string $provider * @property string $provider
* @property int $language_id
* @property Language|null $language
* @property string $description * @property string $description
* @property array<string, mixed> $settings * @property array<string, mixed> $settings
* @property bool $is_active * @property bool $is_active
@ -48,7 +46,6 @@ protected static function newFactory()
'url', 'url',
'type', 'type',
'provider', 'provider',
'language_id',
'description', 'description',
'settings', 'settings',
'is_active', 'is_active',
@ -120,10 +117,35 @@ public function articles(): HasMany
} }
/** /**
* @return BelongsTo<Language, $this> * @return BelongsToMany<Language, $this>
*/ */
public function language(): BelongsTo public function languages(): BelongsToMany
{ {
return $this->belongsTo(Language::class); return $this->belongsToMany(Language::class, 'feed_languages')
->withPivot(['url', 'settings', 'is_active', 'is_primary'])
->withTimestamps()
->wherePivot('is_active', true)
->orderByPivot('is_primary', 'desc');
}
/**
* Get the primary language for this feed
* @return Language|null
*/
public function getPrimaryLanguageAttribute(): ?Language
{
return $this->languages()->wherePivot('is_primary', true)->first();
}
/**
* Get all languages including inactive ones
* @return BelongsToMany<Language, $this>
*/
public function allLanguages(): BelongsToMany
{
return $this->belongsToMany(Language::class, 'feed_languages')
->withPivot(['url', 'settings', 'is_active', 'is_primary'])
->withTimestamps()
->orderByPivot('is_primary', 'desc');
} }
} }

View file

@ -19,9 +19,26 @@ public function rules(): array
return [ return [
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'provider' => 'required|in:vrt,belga', 'provider' => 'required|in:vrt,belga',
'language_id' => 'required|exists:languages,id', 'language_ids' => 'required|array|min:1',
'language_ids.*' => 'exists:languages,id',
'primary_language_id' => 'nullable|exists:languages,id',
'language_urls' => 'nullable|array',
'language_urls.*' => 'nullable|url',
'description' => 'nullable|string', 'description' => 'nullable|string',
'is_active' => 'boolean' 'is_active' => 'boolean'
]; ];
} }
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'language_ids.required' => 'At least one language must be selected.',
'language_ids.*.exists' => 'The selected language is invalid.',
'primary_language_id.exists' => 'The selected primary language is invalid.',
'language_urls.*.url' => 'Each language URL must be a valid URL.',
];
}
} }

View file

@ -21,9 +21,26 @@ public function rules(): array
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'url' => 'required|url|unique:feeds,url,' . ($this->route('feed') instanceof Feed ? (string)$this->route('feed')->id : (string)$this->route('feed')), 'url' => 'required|url|unique:feeds,url,' . ($this->route('feed') instanceof Feed ? (string)$this->route('feed')->id : (string)$this->route('feed')),
'type' => 'required|in:website,rss', 'type' => 'required|in:website,rss',
'language_id' => 'required|exists:languages,id', 'language_ids' => 'required|array|min:1',
'language_ids.*' => 'exists:languages,id',
'primary_language_id' => 'nullable|exists:languages,id',
'language_urls' => 'nullable|array',
'language_urls.*' => 'nullable|url',
'description' => 'nullable|string', 'description' => 'nullable|string',
'is_active' => 'boolean' 'is_active' => 'boolean'
]; ];
} }
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'language_ids.required' => 'At least one language must be selected.',
'language_ids.*.exists' => 'The selected language is invalid.',
'primary_language_id.exists' => 'The selected primary language is invalid.',
'language_urls.*.url' => 'Each language URL must be a valid URL.',
];
}
} }

View file

@ -20,7 +20,29 @@ public function toArray(Request $request): array
'url' => $this->url, 'url' => $this->url,
'type' => $this->type, 'type' => $this->type,
'provider' => $this->provider, 'provider' => $this->provider,
'language_id' => $this->language_id, 'language_ids' => $this->whenLoaded('languages', function () {
return $this->languages->pluck('id');
}),
'languages' => $this->whenLoaded('languages', function () {
return $this->languages->map(function ($language) {
return [
'id' => $language->id,
'short_code' => $language->short_code,
'name' => $language->name,
'url' => $language->pivot->url,
'is_active' => $language->pivot->is_active,
'is_primary' => $language->pivot->is_primary,
];
});
}),
'primary_language' => $this->whenLoaded('languages', function () {
$primary = $this->languages->where('pivot.is_primary', true)->first();
return $primary ? [
'id' => $primary->id,
'short_code' => $primary->short_code,
'name' => $primary->name,
] : null;
}),
'is_active' => $this->is_active, 'is_active' => $this->is_active,
'description' => $this->description, 'description' => $this->description,
'created_at' => $this->created_at->toISOString(), 'created_at' => $this->created_at->toISOString(),

View file

@ -51,10 +51,12 @@ public function platformChannels(): HasMany
} }
/** /**
* @return HasMany<Feed, $this> * @return BelongsToMany<Feed, $this>
*/ */
public function feeds(): HasMany public function feeds(): BelongsToMany
{ {
return $this->hasMany(Feed::class); return $this->belongsToMany(Feed::class, 'feed_languages')
->withPivot(['url', 'settings', 'is_active', 'is_primary'])
->withTimestamps();
} }
} }

View file

@ -108,20 +108,31 @@ public function test_feed_model_creates_successfully(): void
$language = Language::factory()->create(); $language = Language::factory()->create();
$feed = Feed::factory()->create([ $feed = Feed::factory()->create([
'language_id' => $language->id,
'name' => 'Test Feed', 'name' => 'Test Feed',
'url' => 'https://example.com/feed.rss', 'url' => 'https://example.com/feed.rss',
'is_active' => true 'is_active' => true
]); ]);
// Attach language to feed
$feed->languages()->attach($language->id, [
'url' => $feed->url,
'is_active' => true,
'is_primary' => true
]);
$this->assertDatabaseHas('feeds', [ $this->assertDatabaseHas('feeds', [
'language_id' => $language->id,
'name' => 'Test Feed', 'name' => 'Test Feed',
'url' => 'https://example.com/feed.rss', 'url' => 'https://example.com/feed.rss',
'is_active' => true 'is_active' => true
]); ]);
$this->assertEquals($language->id, $feed->language->id); $this->assertDatabaseHas('feed_languages', [
'feed_id' => $feed->id,
'language_id' => $language->id,
'is_primary' => true
]);
$this->assertTrue($feed->languages->contains('id', $language->id));
} }
public function test_article_model_creates_successfully(): void public function test_article_model_creates_successfully(): void

View file

@ -54,7 +54,8 @@ public function test_store_creates_vrt_feed_successfully(): void
$feedData = [ $feedData = [
'name' => 'VRT Test Feed', 'name' => 'VRT Test Feed',
'provider' => 'vrt', 'provider' => 'vrt',
'language_id' => $language->id, 'language_ids' => [$language->id],
'primary_language_id' => $language->id,
'is_active' => true, 'is_active' => true,
]; ];
@ -86,7 +87,8 @@ public function test_store_creates_belga_feed_successfully(): void
$feedData = [ $feedData = [
'name' => 'Belga Test Feed', 'name' => 'Belga Test Feed',
'provider' => 'belga', 'provider' => 'belga',
'language_id' => $language->id, 'language_ids' => [$language->id],
'primary_language_id' => $language->id,
'is_active' => true, 'is_active' => true,
]; ];
@ -118,7 +120,8 @@ public function test_store_sets_default_active_status(): void
$feedData = [ $feedData = [
'name' => 'Test Feed', 'name' => 'Test Feed',
'provider' => 'vrt', 'provider' => 'vrt',
'language_id' => $language->id, 'language_ids' => [$language->id],
'primary_language_id' => $language->id,
// Not setting is_active // Not setting is_active
]; ];
@ -137,7 +140,7 @@ public function test_store_validates_required_fields(): void
$response = $this->postJson('/api/v1/feeds', []); $response = $this->postJson('/api/v1/feeds', []);
$response->assertStatus(422) $response->assertStatus(422)
->assertJsonValidationErrors(['name', 'provider', 'language_id']); ->assertJsonValidationErrors(['name', 'provider', 'language_ids']);
} }
public function test_store_rejects_invalid_provider(): void public function test_store_rejects_invalid_provider(): void
@ -147,7 +150,8 @@ public function test_store_rejects_invalid_provider(): void
$feedData = [ $feedData = [
'name' => 'Invalid Feed', 'name' => 'Invalid Feed',
'provider' => 'invalid', 'provider' => 'invalid',
'language_id' => $language->id, 'language_ids' => [$language->id],
'primary_language_id' => $language->id,
]; ];
$response = $this->postJson('/api/v1/feeds', $feedData); $response = $this->postJson('/api/v1/feeds', $feedData);
@ -183,13 +187,14 @@ public function test_show_returns_404_for_nonexistent_feed(): void
public function test_update_modifies_feed_successfully(): void public function test_update_modifies_feed_successfully(): void
{ {
$language = Language::factory()->create(); $language = Language::factory()->create();
$feed = Feed::factory()->language($language)->create(['name' => 'Original Name']); $feed = Feed::factory()->withLanguages([$language->id])->create(['name' => 'Original Name']);
$updateData = [ $updateData = [
'name' => 'Updated Name', 'name' => 'Updated Name',
'url' => $feed->url, 'url' => $feed->url,
'type' => $feed->type, 'type' => $feed->type,
'language_id' => $feed->language_id, 'language_ids' => [$language->id],
'primary_language_id' => $language->id,
]; ];
$response = $this->putJson("/api/v1/feeds/{$feed->id}", $updateData); $response = $this->putJson("/api/v1/feeds/{$feed->id}", $updateData);
@ -212,13 +217,14 @@ public function test_update_modifies_feed_successfully(): void
public function test_update_preserves_active_status_when_not_provided(): void public function test_update_preserves_active_status_when_not_provided(): void
{ {
$language = Language::factory()->create(); $language = Language::factory()->create();
$feed = Feed::factory()->language($language)->create(['is_active' => false]); $feed = Feed::factory()->withLanguages([$language->id])->create(['is_active' => false]);
$updateData = [ $updateData = [
'name' => $feed->name, 'name' => $feed->name,
'url' => $feed->url, 'url' => $feed->url,
'type' => $feed->type, 'type' => $feed->type,
'language_id' => $feed->language_id, 'language_ids' => [$language->id],
'primary_language_id' => $language->id,
// Not providing is_active // Not providing is_active
]; ];

View file

@ -74,7 +74,7 @@ public function test_status_shows_channel_step_when_platform_account_and_feed_ex
{ {
$language = Language::first(); $language = Language::first();
PlatformAccount::factory()->create(['is_active' => true]); PlatformAccount::factory()->create(['is_active' => true]);
Feed::factory()->language($language)->create(['is_active' => true]); Feed::factory()->withLanguages([$language->id])->create(['is_active' => true]);
$response = $this->getJson('/api/v1/onboarding/status'); $response = $this->getJson('/api/v1/onboarding/status');
@ -96,7 +96,7 @@ public function test_status_shows_route_step_when_platform_account_feed_and_chan
{ {
$language = Language::first(); $language = Language::first();
PlatformAccount::factory()->create(['is_active' => true]); PlatformAccount::factory()->create(['is_active' => true]);
Feed::factory()->language($language)->create(['is_active' => true]); Feed::factory()->withLanguages([$language->id])->create(['is_active' => true]);
PlatformChannel::factory()->create(['is_active' => true]); PlatformChannel::factory()->create(['is_active' => true]);
$response = $this->getJson('/api/v1/onboarding/status'); $response = $this->getJson('/api/v1/onboarding/status');
@ -119,7 +119,7 @@ public function test_status_shows_no_onboarding_needed_when_all_components_exist
{ {
$language = Language::first(); $language = Language::first();
PlatformAccount::factory()->create(['is_active' => true]); PlatformAccount::factory()->create(['is_active' => true]);
Feed::factory()->language($language)->create(['is_active' => true]); Feed::factory()->withLanguages([$language->id])->create(['is_active' => true]);
PlatformChannel::factory()->create(['is_active' => true]); PlatformChannel::factory()->create(['is_active' => true]);
Route::factory()->create(['is_active' => true]); Route::factory()->create(['is_active' => true]);
@ -194,7 +194,7 @@ public function test_create_feed_validates_required_fields()
$response = $this->postJson('/api/v1/onboarding/feed', []); $response = $this->postJson('/api/v1/onboarding/feed', []);
$response->assertStatus(422) $response->assertStatus(422)
->assertJsonValidationErrors(['name', 'provider', 'language_id']); ->assertJsonValidationErrors(['name', 'provider', 'language_ids']);
} }
public function test_create_feed_creates_vrt_feed_successfully() public function test_create_feed_creates_vrt_feed_successfully()
@ -202,7 +202,8 @@ public function test_create_feed_creates_vrt_feed_successfully()
$feedData = [ $feedData = [
'name' => 'VRT Test Feed', 'name' => 'VRT Test Feed',
'provider' => 'vrt', 'provider' => 'vrt',
'language_id' => 1, 'language_ids' => [1],
'primary_language_id' => 1,
'description' => 'Test description', 'description' => 'Test description',
]; ];
@ -223,9 +224,13 @@ public function test_create_feed_creates_vrt_feed_successfully()
'name' => 'VRT Test Feed', 'name' => 'VRT Test Feed',
'url' => 'https://www.vrt.be/vrtnws/en/', 'url' => 'https://www.vrt.be/vrtnws/en/',
'type' => 'website', 'type' => 'website',
'language_id' => 1,
'is_active' => true, 'is_active' => true,
]); ]);
$this->assertDatabaseHas('feed_languages', [
'language_id' => 1,
'is_primary' => true,
]);
} }
public function test_create_feed_creates_belga_feed_successfully() public function test_create_feed_creates_belga_feed_successfully()
@ -233,7 +238,8 @@ public function test_create_feed_creates_belga_feed_successfully()
$feedData = [ $feedData = [
'name' => 'Belga Test Feed', 'name' => 'Belga Test Feed',
'provider' => 'belga', 'provider' => 'belga',
'language_id' => 1, 'language_ids' => [1],
'primary_language_id' => 1,
'description' => 'Test description', 'description' => 'Test description',
]; ];
@ -254,9 +260,13 @@ public function test_create_feed_creates_belga_feed_successfully()
'name' => 'Belga Test Feed', 'name' => 'Belga Test Feed',
'url' => 'https://www.belganewsagency.eu/', 'url' => 'https://www.belganewsagency.eu/',
'type' => 'website', 'type' => 'website',
'language_id' => 1,
'is_active' => true, 'is_active' => true,
]); ]);
$this->assertDatabaseHas('feed_languages', [
'language_id' => 1,
'is_primary' => true,
]);
} }
public function test_create_feed_rejects_invalid_provider() public function test_create_feed_rejects_invalid_provider()
@ -264,7 +274,8 @@ public function test_create_feed_rejects_invalid_provider()
$feedData = [ $feedData = [
'name' => 'Invalid Feed', 'name' => 'Invalid Feed',
'provider' => 'invalid', 'provider' => 'invalid',
'language_id' => 1, 'language_ids' => [1],
'primary_language_id' => 1,
'description' => 'Test description', 'description' => 'Test description',
]; ];
@ -333,7 +344,7 @@ public function test_create_route_validates_required_fields()
public function test_create_route_creates_route_successfully() public function test_create_route_creates_route_successfully()
{ {
$language = Language::first(); $language = Language::first();
$feed = Feed::factory()->language($language)->create(); $feed = Feed::factory()->withLanguages([$language->id])->create();
$platformChannel = PlatformChannel::factory()->create(); $platformChannel = PlatformChannel::factory()->create();
$routeData = [ $routeData = [

View file

@ -20,7 +20,15 @@ public function test_index_returns_successful_response(): void
$instance = PlatformInstance::factory()->create(); $instance = PlatformInstance::factory()->create();
// Create unique feeds and channels for this test // Create unique feeds and channels for this test
$feeds = Feed::factory()->count(3)->create(['language_id' => $language->id]); $feeds = Feed::factory()->count(3)->create();
// Attach language to each feed
foreach ($feeds as $feed) {
$feed->languages()->attach($language->id, [
'url' => $feed->url,
'is_active' => true,
'is_primary' => true
]);
}
$channels = PlatformChannel::factory()->count(3)->create([ $channels = PlatformChannel::factory()->count(3)->create([
'platform_instance_id' => $instance->id, 'platform_instance_id' => $instance->id,
'language_id' => $language->id 'language_id' => $language->id
@ -60,7 +68,12 @@ public function test_store_creates_routing_configuration_successfully(): void
{ {
$language = Language::factory()->create(); $language = Language::factory()->create();
$instance = PlatformInstance::factory()->create(); $instance = PlatformInstance::factory()->create();
$feed = Feed::factory()->create(['language_id' => $language->id]); $feed = Feed::factory()->create();
$feed->languages()->attach($language->id, [
'url' => $feed->url,
'is_active' => true,
'is_primary' => true
]);
$channel = PlatformChannel::factory()->create([ $channel = PlatformChannel::factory()->create([
'platform_instance_id' => $instance->id, 'platform_instance_id' => $instance->id,
'language_id' => $language->id 'language_id' => $language->id
@ -132,7 +145,12 @@ public function test_store_validates_feed_exists(): void
public function test_store_validates_platform_channel_exists(): void public function test_store_validates_platform_channel_exists(): void
{ {
$language = Language::factory()->create(); $language = Language::factory()->create();
$feed = Feed::factory()->create(['language_id' => $language->id]); $feed = Feed::factory()->create();
$feed->languages()->attach($language->id, [
'url' => $feed->url,
'is_active' => true,
'is_primary' => true
]);
$data = [ $data = [
'feed_id' => $feed->id, 'feed_id' => $feed->id,
@ -149,7 +167,12 @@ public function test_show_returns_routing_configuration_successfully(): void
{ {
$language = Language::factory()->create(); $language = Language::factory()->create();
$instance = PlatformInstance::factory()->create(); $instance = PlatformInstance::factory()->create();
$feed = Feed::factory()->create(['language_id' => $language->id]); $feed = Feed::factory()->create();
$feed->languages()->attach($language->id, [
'url' => $feed->url,
'is_active' => true,
'is_primary' => true
]);
$channel = PlatformChannel::factory()->create([ $channel = PlatformChannel::factory()->create([
'platform_instance_id' => $instance->id, 'platform_instance_id' => $instance->id,
'language_id' => $language->id 'language_id' => $language->id
@ -185,7 +208,12 @@ public function test_show_returns_404_for_nonexistent_routing(): void
{ {
$language = Language::factory()->create(); $language = Language::factory()->create();
$instance = PlatformInstance::factory()->create(); $instance = PlatformInstance::factory()->create();
$feed = Feed::factory()->create(['language_id' => $language->id]); $feed = Feed::factory()->create();
$feed->languages()->attach($language->id, [
'url' => $feed->url,
'is_active' => true,
'is_primary' => true
]);
$channel = PlatformChannel::factory()->create([ $channel = PlatformChannel::factory()->create([
'platform_instance_id' => $instance->id, 'platform_instance_id' => $instance->id,
'language_id' => $language->id 'language_id' => $language->id
@ -204,7 +232,12 @@ public function test_update_modifies_routing_configuration_successfully(): void
{ {
$language = Language::factory()->create(); $language = Language::factory()->create();
$instance = PlatformInstance::factory()->create(); $instance = PlatformInstance::factory()->create();
$feed = Feed::factory()->create(['language_id' => $language->id]); $feed = Feed::factory()->create();
$feed->languages()->attach($language->id, [
'url' => $feed->url,
'is_active' => true,
'is_primary' => true
]);
$channel = PlatformChannel::factory()->create([ $channel = PlatformChannel::factory()->create([
'platform_instance_id' => $instance->id, 'platform_instance_id' => $instance->id,
'language_id' => $language->id 'language_id' => $language->id
@ -242,7 +275,12 @@ public function test_update_returns_404_for_nonexistent_routing(): void
{ {
$language = Language::factory()->create(); $language = Language::factory()->create();
$instance = PlatformInstance::factory()->create(); $instance = PlatformInstance::factory()->create();
$feed = Feed::factory()->create(['language_id' => $language->id]); $feed = Feed::factory()->create();
$feed->languages()->attach($language->id, [
'url' => $feed->url,
'is_active' => true,
'is_primary' => true
]);
$channel = PlatformChannel::factory()->create([ $channel = PlatformChannel::factory()->create([
'platform_instance_id' => $instance->id, 'platform_instance_id' => $instance->id,
'language_id' => $language->id 'language_id' => $language->id
@ -263,7 +301,12 @@ public function test_destroy_deletes_routing_configuration_successfully(): void
{ {
$language = Language::factory()->create(); $language = Language::factory()->create();
$instance = PlatformInstance::factory()->create(); $instance = PlatformInstance::factory()->create();
$feed = Feed::factory()->create(['language_id' => $language->id]); $feed = Feed::factory()->create();
$feed->languages()->attach($language->id, [
'url' => $feed->url,
'is_active' => true,
'is_primary' => true
]);
$channel = PlatformChannel::factory()->create([ $channel = PlatformChannel::factory()->create([
'platform_instance_id' => $instance->id, 'platform_instance_id' => $instance->id,
'language_id' => $language->id 'language_id' => $language->id
@ -292,7 +335,12 @@ public function test_destroy_returns_404_for_nonexistent_routing(): void
{ {
$language = Language::factory()->create(); $language = Language::factory()->create();
$instance = PlatformInstance::factory()->create(); $instance = PlatformInstance::factory()->create();
$feed = Feed::factory()->create(['language_id' => $language->id]); $feed = Feed::factory()->create();
$feed->languages()->attach($language->id, [
'url' => $feed->url,
'is_active' => true,
'is_primary' => true
]);
$channel = PlatformChannel::factory()->create([ $channel = PlatformChannel::factory()->create([
'platform_instance_id' => $instance->id, 'platform_instance_id' => $instance->id,
'language_id' => $language->id 'language_id' => $language->id
@ -311,7 +359,12 @@ public function test_toggle_activates_inactive_routing(): void
{ {
$language = Language::factory()->create(); $language = Language::factory()->create();
$instance = PlatformInstance::factory()->create(); $instance = PlatformInstance::factory()->create();
$feed = Feed::factory()->create(['language_id' => $language->id]); $feed = Feed::factory()->create();
$feed->languages()->attach($language->id, [
'url' => $feed->url,
'is_active' => true,
'is_primary' => true
]);
$channel = PlatformChannel::factory()->create([ $channel = PlatformChannel::factory()->create([
'platform_instance_id' => $instance->id, 'platform_instance_id' => $instance->id,
'language_id' => $language->id 'language_id' => $language->id
@ -342,7 +395,12 @@ public function test_toggle_deactivates_active_routing(): void
{ {
$language = Language::factory()->create(); $language = Language::factory()->create();
$instance = PlatformInstance::factory()->create(); $instance = PlatformInstance::factory()->create();
$feed = Feed::factory()->create(['language_id' => $language->id]); $feed = Feed::factory()->create();
$feed->languages()->attach($language->id, [
'url' => $feed->url,
'is_active' => true,
'is_primary' => true
]);
$channel = PlatformChannel::factory()->create([ $channel = PlatformChannel::factory()->create([
'platform_instance_id' => $instance->id, 'platform_instance_id' => $instance->id,
'language_id' => $language->id 'language_id' => $language->id
@ -373,7 +431,12 @@ public function test_toggle_returns_404_for_nonexistent_routing(): void
{ {
$language = Language::factory()->create(); $language = Language::factory()->create();
$instance = PlatformInstance::factory()->create(); $instance = PlatformInstance::factory()->create();
$feed = Feed::factory()->create(['language_id' => $language->id]); $feed = Feed::factory()->create();
$feed->languages()->attach($language->id, [
'url' => $feed->url,
'is_active' => true,
'is_primary' => true
]);
$channel = PlatformChannel::factory()->create([ $channel = PlatformChannel::factory()->create([
'platform_instance_id' => $instance->id, 'platform_instance_id' => $instance->id,
'language_id' => $language->id 'language_id' => $language->id

View file

@ -16,7 +16,7 @@ class FeedTest extends TestCase
public function test_fillable_fields(): void public function test_fillable_fields(): void
{ {
$fillableFields = ['name', 'url', 'type', 'provider', 'language_id', 'description', 'settings', 'is_active', 'last_fetched_at']; $fillableFields = ['name', 'url', 'type', 'provider', 'description', 'settings', 'is_active', 'last_fetched_at'];
$feed = new Feed(); $feed = new Feed();
$this->assertEquals($fillableFields, $feed->getFillable()); $this->assertEquals($fillableFields, $feed->getFillable());
@ -113,14 +113,33 @@ public function test_status_attribute_fetched_days_ago(): void
$this->assertStringContainsString('ago', $feed->status); $this->assertStringContainsString('ago', $feed->status);
} }
public function test_belongs_to_language_relationship(): void public function test_belongs_to_many_languages_relationship(): void
{ {
$language = Language::factory()->create(); $feed = Feed::factory()->create();
$feed = Feed::factory()->create(['language_id' => $language->id]); $language1 = Language::factory()->create();
$language2 = Language::factory()->create();
$feed->languages()->attach($language1->id, [
'url' => 'https://example.com/en',
'is_active' => true,
'is_primary' => true
]);
$feed->languages()->attach($language2->id, [
'url' => 'https://example.com/nl',
'is_active' => true,
'is_primary' => false
]);
$this->assertInstanceOf(Language::class, $feed->language); $languages = $feed->languages;
$this->assertEquals($language->id, $feed->language->id);
$this->assertEquals($language->name, $feed->language->name); $this->assertCount(2, $languages);
$this->assertTrue($languages->contains('id', $language1->id));
$this->assertTrue($languages->contains('id', $language2->id));
// Test primary language
$primaryLanguage = $feed->primary_language;
$this->assertNotNull($primaryLanguage);
$this->assertEquals($language1->id, $primaryLanguage->id);
} }
public function test_has_many_articles_relationship(): void public function test_has_many_articles_relationship(): void
@ -241,7 +260,6 @@ public function test_feed_creation_with_explicit_values(): void
'url' => 'https://example.com/feed', 'url' => 'https://example.com/feed',
'type' => 'rss', 'type' => 'rss',
'provider' => 'vrt', 'provider' => 'vrt',
'language_id' => $language->id,
'description' => 'Test description', 'description' => 'Test description',
'settings' => $settings, 'settings' => $settings,
'is_active' => false 'is_active' => false
@ -250,7 +268,14 @@ public function test_feed_creation_with_explicit_values(): void
$this->assertEquals('Test Feed', $feed->name); $this->assertEquals('Test Feed', $feed->name);
$this->assertEquals('https://example.com/feed', $feed->url); $this->assertEquals('https://example.com/feed', $feed->url);
$this->assertEquals('rss', $feed->type); $this->assertEquals('rss', $feed->type);
$this->assertEquals($language->id, $feed->language_id); // Attach language to feed
$feed->languages()->attach($language->id, [
'url' => $feed->url,
'is_active' => true,
'is_primary' => true
]);
$this->assertTrue($feed->languages->contains('id', $language->id));
$this->assertEquals('Test description', $feed->description); $this->assertEquals('Test description', $feed->description);
$this->assertEquals($settings, $feed->settings); $this->assertEquals($settings, $feed->settings);
$this->assertFalse($feed->is_active); $this->assertFalse($feed->is_active);

View file

@ -80,22 +80,40 @@ public function test_has_many_platform_channels_relationship(): void
$this->assertInstanceOf(PlatformChannel::class, $channels->first()); $this->assertInstanceOf(PlatformChannel::class, $channels->first());
} }
public function test_has_many_feeds_relationship(): void public function test_belongs_to_many_feeds_relationship(): void
{ {
$language = Language::factory()->create(); $language = Language::factory()->create();
$feed1 = Feed::factory()->create(['language_id' => $language->id]); $feed1 = Feed::factory()->create();
$feed2 = Feed::factory()->create(['language_id' => $language->id]); $feed2 = Feed::factory()->create();
// Attach feeds to this language
$language->feeds()->attach($feed1->id, [
'url' => $feed1->url,
'is_active' => true,
'is_primary' => true
]);
$language->feeds()->attach($feed2->id, [
'url' => $feed2->url,
'is_active' => true,
'is_primary' => false
]);
// Create feed for different language // Create feed for different language
$otherLanguage = Language::factory()->create(); $otherLanguage = Language::factory()->create();
Feed::factory()->create(['language_id' => $otherLanguage->id]); $feed3 = Feed::factory()->create();
$otherLanguage->feeds()->attach($feed3->id, [
'url' => $feed3->url,
'is_active' => true,
'is_primary' => true
]);
$feeds = $language->feeds; $feeds = $language->feeds;
$this->assertCount(2, $feeds); $this->assertCount(2, $feeds);
$this->assertTrue($feeds->contains('id', $feed1->id)); $this->assertTrue($feeds->contains('id', $feed1->id));
$this->assertTrue($feeds->contains('id', $feed2->id)); $this->assertTrue($feeds->contains('id', $feed2->id));
$this->assertFalse($feeds->contains('id', $feed3->id));
$this->assertInstanceOf(Feed::class, $feeds->first()); $this->assertInstanceOf(Feed::class, $feeds->first());
} }
@ -295,7 +313,12 @@ public function test_language_relationships_maintain_referential_integrity(): vo
// Create related models // Create related models
$instance = PlatformInstance::factory()->create(); $instance = PlatformInstance::factory()->create();
$channel = PlatformChannel::factory()->create(['language_id' => $language->id]); $channel = PlatformChannel::factory()->create(['language_id' => $language->id]);
$feed = Feed::factory()->create(['language_id' => $language->id]); $feed = Feed::factory()->create();
$language->feeds()->attach($feed->id, [
'url' => $feed->url,
'is_active' => true,
'is_primary' => true
]);
// Attach instance // Attach instance
$language->platformInstances()->attach($instance->id, [ $language->platformInstances()->attach($instance->id, [
@ -309,7 +332,7 @@ public function test_language_relationships_maintain_referential_integrity(): vo
$this->assertCount(1, $language->feeds); $this->assertCount(1, $language->feeds);
$this->assertEquals($language->id, $channel->language_id); $this->assertEquals($language->id, $channel->language_id);
$this->assertEquals($language->id, $feed->language_id); $this->assertTrue($language->feeds->contains('id', $feed->id));
} }
public function test_language_factory_unique_constraints(): void public function test_language_factory_unique_constraints(): void

View file

@ -49,6 +49,15 @@ export interface Article {
} }
// Feed types // Feed types
export interface FeedLanguage {
id: number;
short_code: string;
name: string;
url?: string;
is_active: boolean;
is_primary: boolean;
}
export interface Feed { export interface Feed {
id: number; id: number;
name: string; name: string;
@ -56,7 +65,13 @@ export interface Feed {
type: 'website' | 'rss'; type: 'website' | 'rss';
is_active: boolean; is_active: boolean;
description: string | null; description: string | null;
language_id?: number; language_ids?: number[];
languages?: FeedLanguage[];
primary_language?: {
id: number;
short_code: string;
name: string;
};
provider?: string; provider?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@ -164,6 +179,12 @@ export interface OnboardingStatus {
export interface FeedProvider { export interface FeedProvider {
code: string; code: string;
name: string; name: string;
supported_languages?: {
[key: string]: {
url: string;
name: string;
};
};
} }
export interface OnboardingOptions { export interface OnboardingOptions {
@ -184,7 +205,9 @@ export interface PlatformAccountRequest {
export interface FeedRequest { export interface FeedRequest {
name: string; name: string;
provider: 'vrt' | 'belga'; provider: 'vrt' | 'belga';
language_id: number; language_ids: number[];
primary_language_id?: number;
language_urls?: { [key: number]: string };
description?: string; description?: string;
} }

View file

@ -8,9 +8,11 @@ const FeedStep: React.FC = () => {
const [formData, setFormData] = useState<FeedRequest>({ const [formData, setFormData] = useState<FeedRequest>({
name: '', name: '',
provider: 'vrt', provider: 'vrt',
language_id: 0, language_ids: [],
primary_language_id: undefined,
description: '' description: ''
}); });
const [selectedProvider, setSelectedProvider] = useState<FeedProvider | null>(null);
const [errors, setErrors] = useState<Record<string, string[]>>({}); const [errors, setErrors] = useState<Record<string, string[]>>({});
// Get onboarding options (languages) // Get onboarding options (languages)
@ -33,12 +35,21 @@ const FeedStep: React.FC = () => {
setFormData({ setFormData({
name: firstFeed.name || '', name: firstFeed.name || '',
provider: (firstFeed.provider === 'vrt' || firstFeed.provider === 'belga') ? firstFeed.provider : 'vrt', provider: (firstFeed.provider === 'vrt' || firstFeed.provider === 'belga') ? firstFeed.provider : 'vrt',
language_id: firstFeed.language_id ?? 0, language_ids: firstFeed.language_ids || [],
primary_language_id: firstFeed.primary_language?.id,
description: firstFeed.description || '' description: firstFeed.description || ''
}); });
} }
}, [feeds]); }, [feeds]);
// Update selected provider when provider changes
useEffect(() => {
if (options?.feed_providers && formData.provider) {
const provider = options.feed_providers.find(p => p.code === formData.provider);
setSelectedProvider(provider || null);
}
}, [formData.provider, options]);
const createFeedMutation = useMutation({ const createFeedMutation = useMutation({
mutationFn: (data: FeedRequest) => apiClient.createFeedForOnboarding(data), mutationFn: (data: FeedRequest) => apiClient.createFeedForOnboarding(data),
onSuccess: () => { onSuccess: () => {
@ -135,25 +146,74 @@ const FeedStep: React.FC = () => {
</div> </div>
<div> <div>
<label htmlFor="language_id" className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Language Languages
{selectedProvider?.supported_languages && (
<span className="text-xs text-gray-500 ml-2">
(Provider supports: {Object.values(selectedProvider.supported_languages).map(l => l.name).join(', ')})
</span>
)}
</label> </label>
<select <div className="space-y-2">
id="language_id" {options?.languages.map((language: Language) => {
value={formData.language_id || ''} const isSupported = !selectedProvider?.supported_languages ||
onChange={(e) => handleChange('language_id', e.target.value ? parseInt(e.target.value) : 0)} Object.keys(selectedProvider.supported_languages).includes(language.short_code);
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" const isSelected = formData.language_ids.includes(language.id);
required const isPrimary = formData.primary_language_id === language.id;
>
<option value="">Select language</option> return (
{options?.languages.map((language: Language) => ( <div key={language.id} className={`flex items-center space-x-3 p-2 rounded ${!isSupported ? 'opacity-50' : ''}`}>
<option key={language.id} value={language.id}> <input
{language.name} type="checkbox"
</option> id={`language-${language.id}`}
))} checked={isSelected}
</select> disabled={!isSupported}
{errors.language_id && ( onChange={(e) => {
<p className="text-red-600 text-sm mt-1">{errors.language_id[0]}</p> if (e.target.checked) {
const newLanguageIds = [...formData.language_ids, language.id];
setFormData(prev => ({
...prev,
language_ids: newLanguageIds,
primary_language_id: newLanguageIds.length === 1 ? language.id : prev.primary_language_id
}));
} else {
const newLanguageIds = formData.language_ids.filter(id => id !== language.id);
setFormData(prev => ({
...prev,
language_ids: newLanguageIds,
primary_language_id: prev.primary_language_id === language.id
? (newLanguageIds[0] || undefined)
: prev.primary_language_id
}));
}
}}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor={`language-${language.id}`} className="flex-1 cursor-pointer">
{language.name} ({language.short_code})
{!isSupported && <span className="text-xs text-gray-500 ml-2">(Not supported by provider)</span>}
</label>
{isSelected && formData.language_ids.length > 1 && (
<label className="flex items-center space-x-1">
<input
type="radio"
name="primary-language"
checked={isPrimary}
onChange={() => setFormData(prev => ({ ...prev, primary_language_id: language.id }))}
className="h-3 w-3 text-blue-600 focus:ring-blue-500"
/>
<span className="text-xs text-gray-600">Primary</span>
</label>
)}
</div>
);
})}
</div>
{formData.language_ids.length === 0 && (
<p className="text-red-600 text-sm mt-1">Please select at least one language</p>
)}
{errors.language_ids && (
<p className="text-red-600 text-sm mt-1">{errors.language_ids[0]}</p>
)} )}
</div> </div>