diff --git a/backend/app/Http/Controllers/Api/V1/FeedsController.php b/backend/app/Http/Controllers/Api/V1/FeedsController.php index b3f3fa6..1321c02 100644 --- a/backend/app/Http/Controllers/Api/V1/FeedsController.php +++ b/backend/app/Http/Controllers/Api/V1/FeedsController.php @@ -19,7 +19,7 @@ public function index(Request $request): JsonResponse { $perPage = min($request->get('per_page', 15), 100); - $feeds = Feed::with(['language']) + $feeds = Feed::with(['languages']) ->withCount('articles') ->orderBy('is_active', 'desc') ->orderBy('name') @@ -57,7 +57,27 @@ public function store(StoreFeedRequest $request): JsonResponse $validated['url'] = $adapter->getHomepageUrl(); $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); + + // 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( new FeedResource($feed), @@ -76,6 +96,8 @@ public function store(StoreFeedRequest $request): JsonResponse */ public function show(Feed $feed): JsonResponse { + $feed->load('languages'); + return $this->sendResponse( new FeedResource($feed), 'Feed retrieved successfully.' @@ -91,10 +113,34 @@ public function update(UpdateFeedRequest $request, Feed $feed): JsonResponse $validated = $request->validated(); $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); + + // 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( - new FeedResource($feed->fresh()), + new FeedResource($feed), 'Feed updated successfully!' ); } catch (ValidationException $e) { diff --git a/backend/app/Http/Controllers/Api/V1/OnboardingController.php b/backend/app/Http/Controllers/Api/V1/OnboardingController.php index a11ee4b..9f60ecf 100644 --- a/backend/app/Http/Controllers/Api/V1/OnboardingController.php +++ b/backend/app/Http/Controllers/Api/V1/OnboardingController.php @@ -204,7 +204,9 @@ public function createFeed(Request $request): JsonResponse $validator = Validator::make($request->all(), [ 'name' => 'required|string|max:255', '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', ]); @@ -224,20 +226,34 @@ public function createFeed(Request $request): JsonResponse $url = 'https://www.belganewsagency.eu/'; } + // Extract language-related data + $languageIds = $validated['language_ids']; + $primaryLanguageId = $validated['primary_language_id'] ?? $languageIds[0]; + $feed = Feed::firstOrCreate( ['url' => $url], [ 'name' => $validated['name'], 'type' => $type, 'provider' => $provider, - 'language_id' => $validated['language_id'], 'description' => $validated['description'] ?? null, '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( - new FeedResource($feed->load('language')), + new FeedResource($feed->load('languages')), 'Feed created successfully.' ); } diff --git a/backend/config/feed.php b/backend/config/feed.php index 7809b85..f055aee 100644 --- a/backend/config/feed.php +++ b/backend/config/feed.php @@ -19,6 +19,16 @@ 'description' => 'Belgian public broadcaster news', 'type' => 'website', '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' => [ 'homepage' => \Domains\Article\Parsers\Vrt\VrtHomepageParserAdapter::class, 'article' => \Domains\Article\Parsers\Vrt\VrtArticleParser::class, @@ -31,6 +41,12 @@ 'description' => 'Belgian national news agency', 'type' => 'rss', 'is_active' => true, + 'supported_languages' => [ + 'en' => [ + 'url' => 'https://www.belganewsagency.eu/', + 'name' => 'English', + ], + ], 'parsers' => [ 'homepage' => \Domains\Article\Parsers\Belga\BelgaHomepageParserAdapter::class, 'article' => \Domains\Article\Parsers\Belga\BelgaArticleParser::class, diff --git a/backend/database/migrations/2025_08_15_185640_create_feed_languages_table.php b/backend/database/migrations/2025_08_15_185640_create_feed_languages_table.php new file mode 100644 index 0000000..4483e1a --- /dev/null +++ b/backend/database/migrations/2025_08_15_185640_create_feed_languages_table.php @@ -0,0 +1,61 @@ +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'); + } +}; diff --git a/backend/database/migrations/2025_08_15_185804_remove_language_id_from_feeds_table.php b/backend/database/migrations/2025_08_15_185804_remove_language_id_from_feeds_table.php new file mode 100644 index 0000000..eed7fb8 --- /dev/null +++ b/backend/database/migrations/2025_08_15_185804_remove_language_id_from_feeds_table.php @@ -0,0 +1,33 @@ +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'); + }); + } +}; diff --git a/backend/src/Domains/Feed/Factories/FeedFactory.php b/backend/src/Domains/Feed/Factories/FeedFactory.php index 0f02004..481370f 100644 --- a/backend/src/Domains/Feed/Factories/FeedFactory.php +++ b/backend/src/Domains/Feed/Factories/FeedFactory.php @@ -20,7 +20,6 @@ public function definition(): array 'url' => $this->faker->url(), 'type' => $this->faker->randomElement(['website', 'rss']), 'provider' => $this->faker->randomElement(['vrt', 'belga']), - 'language_id' => null, 'description' => $this->faker->optional()->sentence(), 'settings' => [], '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) => [ - 'language_id' => $language->id, - ]); + return $this->afterCreating(function (Feed $feed) use ($languageIds, $usePrimary) { + 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 diff --git a/backend/src/Domains/Feed/Models/Feed.php b/backend/src/Domains/Feed/Models/Feed.php index a4a0462..88118f1 100644 --- a/backend/src/Domains/Feed/Models/Feed.php +++ b/backend/src/Domains/Feed/Models/Feed.php @@ -19,8 +19,6 @@ * @property string $url * @property string $type * @property string $provider - * @property int $language_id - * @property Language|null $language * @property string $description * @property array $settings * @property bool $is_active @@ -48,7 +46,6 @@ protected static function newFactory() 'url', 'type', 'provider', - 'language_id', 'description', 'settings', 'is_active', @@ -120,10 +117,35 @@ public function articles(): HasMany } /** - * @return BelongsTo + * @return BelongsToMany */ - 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 + */ + public function allLanguages(): BelongsToMany + { + return $this->belongsToMany(Language::class, 'feed_languages') + ->withPivot(['url', 'settings', 'is_active', 'is_primary']) + ->withTimestamps() + ->orderByPivot('is_primary', 'desc'); } } diff --git a/backend/src/Domains/Feed/Requests/StoreFeedRequest.php b/backend/src/Domains/Feed/Requests/StoreFeedRequest.php index eb1fe7c..8022c7b 100644 --- a/backend/src/Domains/Feed/Requests/StoreFeedRequest.php +++ b/backend/src/Domains/Feed/Requests/StoreFeedRequest.php @@ -19,9 +19,26 @@ public function rules(): array return [ 'name' => 'required|string|max:255', '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', 'is_active' => 'boolean' ]; } + + /** + * @return array + */ + 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.', + ]; + } } \ No newline at end of file diff --git a/backend/src/Domains/Feed/Requests/UpdateFeedRequest.php b/backend/src/Domains/Feed/Requests/UpdateFeedRequest.php index c786c36..3d79f8c 100644 --- a/backend/src/Domains/Feed/Requests/UpdateFeedRequest.php +++ b/backend/src/Domains/Feed/Requests/UpdateFeedRequest.php @@ -21,9 +21,26 @@ public function rules(): array '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')), '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', 'is_active' => 'boolean' ]; } + + /** + * @return array + */ + 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.', + ]; + } } \ No newline at end of file diff --git a/backend/src/Domains/Feed/Resources/FeedResource.php b/backend/src/Domains/Feed/Resources/FeedResource.php index 1ceb35f..1006c45 100644 --- a/backend/src/Domains/Feed/Resources/FeedResource.php +++ b/backend/src/Domains/Feed/Resources/FeedResource.php @@ -20,7 +20,29 @@ public function toArray(Request $request): array 'url' => $this->url, 'type' => $this->type, '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, 'description' => $this->description, 'created_at' => $this->created_at->toISOString(), diff --git a/backend/src/Domains/Settings/Models/Language.php b/backend/src/Domains/Settings/Models/Language.php index 07890f2..2f604f0 100644 --- a/backend/src/Domains/Settings/Models/Language.php +++ b/backend/src/Domains/Settings/Models/Language.php @@ -51,10 +51,12 @@ public function platformChannels(): HasMany } /** - * @return HasMany + * @return BelongsToMany */ - 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(); } } diff --git a/backend/tests/Feature/DatabaseIntegrationTest.php b/backend/tests/Feature/DatabaseIntegrationTest.php index 4999e1b..e92d089 100644 --- a/backend/tests/Feature/DatabaseIntegrationTest.php +++ b/backend/tests/Feature/DatabaseIntegrationTest.php @@ -108,20 +108,31 @@ public function test_feed_model_creates_successfully(): void $language = Language::factory()->create(); $feed = Feed::factory()->create([ - 'language_id' => $language->id, 'name' => 'Test Feed', 'url' => 'https://example.com/feed.rss', 'is_active' => true ]); + // Attach language to feed + $feed->languages()->attach($language->id, [ + 'url' => $feed->url, + 'is_active' => true, + 'is_primary' => true + ]); + $this->assertDatabaseHas('feeds', [ - 'language_id' => $language->id, 'name' => 'Test Feed', 'url' => 'https://example.com/feed.rss', '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 diff --git a/backend/tests/Feature/Http/Controllers/Api/V1/FeedsControllerTest.php b/backend/tests/Feature/Http/Controllers/Api/V1/FeedsControllerTest.php index 787b2e4..9179acd 100644 --- a/backend/tests/Feature/Http/Controllers/Api/V1/FeedsControllerTest.php +++ b/backend/tests/Feature/Http/Controllers/Api/V1/FeedsControllerTest.php @@ -54,7 +54,8 @@ public function test_store_creates_vrt_feed_successfully(): void $feedData = [ 'name' => 'VRT Test Feed', 'provider' => 'vrt', - 'language_id' => $language->id, + 'language_ids' => [$language->id], + 'primary_language_id' => $language->id, 'is_active' => true, ]; @@ -86,7 +87,8 @@ public function test_store_creates_belga_feed_successfully(): void $feedData = [ 'name' => 'Belga Test Feed', 'provider' => 'belga', - 'language_id' => $language->id, + 'language_ids' => [$language->id], + 'primary_language_id' => $language->id, 'is_active' => true, ]; @@ -118,7 +120,8 @@ public function test_store_sets_default_active_status(): void $feedData = [ 'name' => 'Test Feed', 'provider' => 'vrt', - 'language_id' => $language->id, + 'language_ids' => [$language->id], + 'primary_language_id' => $language->id, // Not setting is_active ]; @@ -137,7 +140,7 @@ public function test_store_validates_required_fields(): void $response = $this->postJson('/api/v1/feeds', []); $response->assertStatus(422) - ->assertJsonValidationErrors(['name', 'provider', 'language_id']); + ->assertJsonValidationErrors(['name', 'provider', 'language_ids']); } public function test_store_rejects_invalid_provider(): void @@ -147,7 +150,8 @@ public function test_store_rejects_invalid_provider(): void $feedData = [ 'name' => 'Invalid Feed', 'provider' => 'invalid', - 'language_id' => $language->id, + 'language_ids' => [$language->id], + 'primary_language_id' => $language->id, ]; $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 { $language = Language::factory()->create(); - $feed = Feed::factory()->language($language)->create(['name' => 'Original Name']); + $feed = Feed::factory()->withLanguages([$language->id])->create(['name' => 'Original Name']); $updateData = [ 'name' => 'Updated Name', 'url' => $feed->url, '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); @@ -212,13 +217,14 @@ public function test_update_modifies_feed_successfully(): void public function test_update_preserves_active_status_when_not_provided(): void { $language = Language::factory()->create(); - $feed = Feed::factory()->language($language)->create(['is_active' => false]); + $feed = Feed::factory()->withLanguages([$language->id])->create(['is_active' => false]); $updateData = [ 'name' => $feed->name, 'url' => $feed->url, 'type' => $feed->type, - 'language_id' => $feed->language_id, + 'language_ids' => [$language->id], + 'primary_language_id' => $language->id, // Not providing is_active ]; diff --git a/backend/tests/Feature/Http/Controllers/Api/V1/OnboardingControllerTest.php b/backend/tests/Feature/Http/Controllers/Api/V1/OnboardingControllerTest.php index f773383..060ce93 100644 --- a/backend/tests/Feature/Http/Controllers/Api/V1/OnboardingControllerTest.php +++ b/backend/tests/Feature/Http/Controllers/Api/V1/OnboardingControllerTest.php @@ -74,7 +74,7 @@ public function test_status_shows_channel_step_when_platform_account_and_feed_ex { $language = Language::first(); 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'); @@ -96,7 +96,7 @@ public function test_status_shows_route_step_when_platform_account_feed_and_chan { $language = Language::first(); 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]); $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(); 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]); 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->assertStatus(422) - ->assertJsonValidationErrors(['name', 'provider', 'language_id']); + ->assertJsonValidationErrors(['name', 'provider', 'language_ids']); } public function test_create_feed_creates_vrt_feed_successfully() @@ -202,7 +202,8 @@ public function test_create_feed_creates_vrt_feed_successfully() $feedData = [ 'name' => 'VRT Test Feed', 'provider' => 'vrt', - 'language_id' => 1, + 'language_ids' => [1], + 'primary_language_id' => 1, 'description' => 'Test description', ]; @@ -223,9 +224,13 @@ public function test_create_feed_creates_vrt_feed_successfully() 'name' => 'VRT Test Feed', 'url' => 'https://www.vrt.be/vrtnws/en/', 'type' => 'website', - 'language_id' => 1, 'is_active' => true, ]); + + $this->assertDatabaseHas('feed_languages', [ + 'language_id' => 1, + 'is_primary' => true, + ]); } public function test_create_feed_creates_belga_feed_successfully() @@ -233,7 +238,8 @@ public function test_create_feed_creates_belga_feed_successfully() $feedData = [ 'name' => 'Belga Test Feed', 'provider' => 'belga', - 'language_id' => 1, + 'language_ids' => [1], + 'primary_language_id' => 1, 'description' => 'Test description', ]; @@ -254,9 +260,13 @@ public function test_create_feed_creates_belga_feed_successfully() 'name' => 'Belga Test Feed', 'url' => 'https://www.belganewsagency.eu/', 'type' => 'website', - 'language_id' => 1, 'is_active' => true, ]); + + $this->assertDatabaseHas('feed_languages', [ + 'language_id' => 1, + 'is_primary' => true, + ]); } public function test_create_feed_rejects_invalid_provider() @@ -264,7 +274,8 @@ public function test_create_feed_rejects_invalid_provider() $feedData = [ 'name' => 'Invalid Feed', 'provider' => 'invalid', - 'language_id' => 1, + 'language_ids' => [1], + 'primary_language_id' => 1, 'description' => 'Test description', ]; @@ -333,7 +344,7 @@ public function test_create_route_validates_required_fields() public function test_create_route_creates_route_successfully() { $language = Language::first(); - $feed = Feed::factory()->language($language)->create(); + $feed = Feed::factory()->withLanguages([$language->id])->create(); $platformChannel = PlatformChannel::factory()->create(); $routeData = [ diff --git a/backend/tests/Feature/Http/Controllers/Api/V1/RoutingControllerTest.php b/backend/tests/Feature/Http/Controllers/Api/V1/RoutingControllerTest.php index 2604bd3..36c2f6f 100644 --- a/backend/tests/Feature/Http/Controllers/Api/V1/RoutingControllerTest.php +++ b/backend/tests/Feature/Http/Controllers/Api/V1/RoutingControllerTest.php @@ -20,7 +20,15 @@ public function test_index_returns_successful_response(): void $instance = PlatformInstance::factory()->create(); // 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([ 'platform_instance_id' => $instance->id, 'language_id' => $language->id @@ -60,7 +68,12 @@ public function test_store_creates_routing_configuration_successfully(): void { $language = Language::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([ 'platform_instance_id' => $instance->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 { $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 = [ 'feed_id' => $feed->id, @@ -149,7 +167,12 @@ public function test_show_returns_routing_configuration_successfully(): void { $language = Language::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([ 'platform_instance_id' => $instance->id, 'language_id' => $language->id @@ -185,7 +208,12 @@ public function test_show_returns_404_for_nonexistent_routing(): void { $language = Language::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([ 'platform_instance_id' => $instance->id, 'language_id' => $language->id @@ -204,7 +232,12 @@ public function test_update_modifies_routing_configuration_successfully(): void { $language = Language::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([ 'platform_instance_id' => $instance->id, 'language_id' => $language->id @@ -242,7 +275,12 @@ public function test_update_returns_404_for_nonexistent_routing(): void { $language = Language::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([ 'platform_instance_id' => $instance->id, 'language_id' => $language->id @@ -263,7 +301,12 @@ public function test_destroy_deletes_routing_configuration_successfully(): void { $language = Language::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([ 'platform_instance_id' => $instance->id, 'language_id' => $language->id @@ -292,7 +335,12 @@ public function test_destroy_returns_404_for_nonexistent_routing(): void { $language = Language::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([ 'platform_instance_id' => $instance->id, 'language_id' => $language->id @@ -311,7 +359,12 @@ public function test_toggle_activates_inactive_routing(): void { $language = Language::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([ 'platform_instance_id' => $instance->id, 'language_id' => $language->id @@ -342,7 +395,12 @@ public function test_toggle_deactivates_active_routing(): void { $language = Language::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([ 'platform_instance_id' => $instance->id, 'language_id' => $language->id @@ -373,7 +431,12 @@ public function test_toggle_returns_404_for_nonexistent_routing(): void { $language = Language::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([ 'platform_instance_id' => $instance->id, 'language_id' => $language->id diff --git a/backend/tests/Unit/Models/FeedTest.php b/backend/tests/Unit/Models/FeedTest.php index 0e059dc..3b6105d 100644 --- a/backend/tests/Unit/Models/FeedTest.php +++ b/backend/tests/Unit/Models/FeedTest.php @@ -16,7 +16,7 @@ class FeedTest extends TestCase 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(); $this->assertEquals($fillableFields, $feed->getFillable()); @@ -113,14 +113,33 @@ public function test_status_attribute_fetched_days_ago(): void $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(['language_id' => $language->id]); + $feed = Feed::factory()->create(); + $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); - $this->assertEquals($language->id, $feed->language->id); - $this->assertEquals($language->name, $feed->language->name); + $languages = $feed->languages; + + $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 @@ -241,7 +260,6 @@ public function test_feed_creation_with_explicit_values(): void 'url' => 'https://example.com/feed', 'type' => 'rss', 'provider' => 'vrt', - 'language_id' => $language->id, 'description' => 'Test description', 'settings' => $settings, 'is_active' => false @@ -250,7 +268,14 @@ public function test_feed_creation_with_explicit_values(): void $this->assertEquals('Test Feed', $feed->name); $this->assertEquals('https://example.com/feed', $feed->url); $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($settings, $feed->settings); $this->assertFalse($feed->is_active); diff --git a/backend/tests/Unit/Models/LanguageTest.php b/backend/tests/Unit/Models/LanguageTest.php index 93ec65a..f7efec0 100644 --- a/backend/tests/Unit/Models/LanguageTest.php +++ b/backend/tests/Unit/Models/LanguageTest.php @@ -80,22 +80,40 @@ public function test_has_many_platform_channels_relationship(): void $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(); - $feed1 = Feed::factory()->create(['language_id' => $language->id]); - $feed2 = Feed::factory()->create(['language_id' => $language->id]); + $feed1 = Feed::factory()->create(); + $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 $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; $this->assertCount(2, $feeds); $this->assertTrue($feeds->contains('id', $feed1->id)); $this->assertTrue($feeds->contains('id', $feed2->id)); + $this->assertFalse($feeds->contains('id', $feed3->id)); $this->assertInstanceOf(Feed::class, $feeds->first()); } @@ -295,7 +313,12 @@ public function test_language_relationships_maintain_referential_integrity(): vo // Create related models $instance = PlatformInstance::factory()->create(); $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 $language->platformInstances()->attach($instance->id, [ @@ -309,7 +332,7 @@ public function test_language_relationships_maintain_referential_integrity(): vo $this->assertCount(1, $language->feeds); $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 diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d6f3ce4..e35b6d9 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -49,6 +49,15 @@ export interface Article { } // Feed types +export interface FeedLanguage { + id: number; + short_code: string; + name: string; + url?: string; + is_active: boolean; + is_primary: boolean; +} + export interface Feed { id: number; name: string; @@ -56,7 +65,13 @@ export interface Feed { type: 'website' | 'rss'; is_active: boolean; description: string | null; - language_id?: number; + language_ids?: number[]; + languages?: FeedLanguage[]; + primary_language?: { + id: number; + short_code: string; + name: string; + }; provider?: string; created_at: string; updated_at: string; @@ -164,6 +179,12 @@ export interface OnboardingStatus { export interface FeedProvider { code: string; name: string; + supported_languages?: { + [key: string]: { + url: string; + name: string; + }; + }; } export interface OnboardingOptions { @@ -184,7 +205,9 @@ export interface PlatformAccountRequest { export interface FeedRequest { name: string; provider: 'vrt' | 'belga'; - language_id: number; + language_ids: number[]; + primary_language_id?: number; + language_urls?: { [key: number]: string }; description?: string; } diff --git a/frontend/src/pages/onboarding/steps/FeedStep.tsx b/frontend/src/pages/onboarding/steps/FeedStep.tsx index cd9c1c5..7cfe72a 100644 --- a/frontend/src/pages/onboarding/steps/FeedStep.tsx +++ b/frontend/src/pages/onboarding/steps/FeedStep.tsx @@ -8,9 +8,11 @@ const FeedStep: React.FC = () => { const [formData, setFormData] = useState({ name: '', provider: 'vrt', - language_id: 0, + language_ids: [], + primary_language_id: undefined, description: '' }); + const [selectedProvider, setSelectedProvider] = useState(null); const [errors, setErrors] = useState>({}); // Get onboarding options (languages) @@ -33,12 +35,21 @@ const FeedStep: React.FC = () => { setFormData({ name: firstFeed.name || '', 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 || '' }); } }, [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({ mutationFn: (data: FeedRequest) => apiClient.createFeedForOnboarding(data), onSuccess: () => { @@ -135,25 +146,74 @@ const FeedStep: React.FC = () => {
-