diff --git a/backend/app/Http/Controllers/Api/V1/FeedsController.php b/backend/app/Http/Controllers/Api/V1/FeedsController.php index 2ff0ad1..8dcc504 100644 --- a/backend/app/Http/Controllers/Api/V1/FeedsController.php +++ b/backend/app/Http/Controllers/Api/V1/FeedsController.php @@ -47,6 +47,19 @@ public function store(StoreFeedRequest $request): JsonResponse $validated = $request->validated(); $validated['is_active'] = $validated['is_active'] ?? true; + // Map provider to URL and set type + $providers = [ + 'vrt' => new \App\Services\Parsers\VrtHomepageParserAdapter(), + 'belga' => new \App\Services\Parsers\BelgaHomepageParserAdapter(), + ]; + + $adapter = $providers[$validated['provider']]; + $validated['url'] = $adapter->getHomepageUrl(); + $validated['type'] = 'website'; + + // Remove provider from validated data as it's not a database column + unset($validated['provider']); + $feed = Feed::create($validated); return $this->sendResponse( diff --git a/backend/app/Http/Controllers/Api/V1/OnboardingController.php b/backend/app/Http/Controllers/Api/V1/OnboardingController.php index c092109..0229455 100644 --- a/backend/app/Http/Controllers/Api/V1/OnboardingController.php +++ b/backend/app/Http/Controllers/Api/V1/OnboardingController.php @@ -35,7 +35,7 @@ public function status(): JsonResponse $hasFeed = Feed::where('is_active', true)->exists(); $hasChannel = PlatformChannel::where('is_active', true)->exists(); $hasRoute = Route::where('is_active', true)->exists(); - + // Check if onboarding was explicitly skipped $onboardingSkipped = Setting::where('key', 'onboarding_skipped')->value('value') === 'true'; @@ -90,11 +90,18 @@ public function options(): JsonResponse ->orderBy('name') ->get(['id', 'platform_instance_id', 'name', 'display_name', 'description']); + // Expose supported feed providers so frontend can offer only VRT and Belga + $feedProviders = [ + ['code' => 'vrt', 'name' => (new \App\Services\Parsers\VrtHomepageParserAdapter())->getSourceName()], + ['code' => 'belga', 'name' => (new \App\Services\Parsers\BelgaHomepageParserAdapter())->getSourceName()], + ]; + return $this->sendResponse([ 'languages' => $languages, 'platform_instances' => $platformInstances, 'feeds' => $feeds, 'platform_channels' => $platformChannels, + 'feed_providers' => $feedProviders, ], 'Onboarding options retrieved successfully.'); } @@ -166,19 +173,19 @@ public function createPlatform(Request $request): JsonResponse if (str_contains($e->getMessage(), 'Rate limited by')) { return $this->sendError($e->getMessage(), [], 429); } - + return $this->sendError('Invalid username or password. Please check your credentials and try again.', [], 422); } catch (\Exception $e) { - + $message = 'Unable to connect to the Lemmy instance. Please check the URL and try again.'; - + // If it's a network/connection issue, provide a more specific message - if (str_contains(strtolower($e->getMessage()), 'connection') || + if (str_contains(strtolower($e->getMessage()), 'connection') || str_contains(strtolower($e->getMessage()), 'network') || str_contains(strtolower($e->getMessage()), 'timeout')) { $message = 'Connection failed. Please check the instance URL and your internet connection.'; } - + return $this->sendError($message, [], 422); } } @@ -190,8 +197,7 @@ public function createFeed(Request $request): JsonResponse { $validator = Validator::make($request->all(), [ 'name' => 'required|string|max:255', - 'url' => 'required|url|max:500', - 'type' => 'required|in:website,rss', + 'provider' => 'required|string|in:vrt,belga', 'language_id' => 'required|exists:languages,id', 'description' => 'nullable|string|max:1000', ]); @@ -202,10 +208,25 @@ public function createFeed(Request $request): JsonResponse $validated = $validator->validated(); + // Supported providers and their canonical definitions + $providers = [ + 'vrt' => new \App\Services\Parsers\VrtHomepageParserAdapter(), + 'belga' => new \App\Services\Parsers\BelgaHomepageParserAdapter(), + ]; + + $adapter = $providers[$validated['provider']]; + $finalUrl = $adapter->getHomepageUrl(); + $finalName = $validated['name']; + + // Keep user provided name, but default to provider name if empty/blank + if (trim($finalName) === '') { + $finalName = $adapter->getSourceName(); + } + $feed = Feed::create([ - 'name' => $validated['name'], - 'url' => $validated['url'], - 'type' => $validated['type'], + 'name' => $finalName, + 'url' => $finalUrl, + 'type' => 'website', 'language_id' => $validated['language_id'], 'description' => $validated['description'] ?? null, 'is_active' => true, @@ -292,7 +313,7 @@ public function complete(): JsonResponse // or create a setting that tracks onboarding completion // For now, we'll just return success since the onboarding status // is determined by the existence of platform accounts, feeds, and channels - + return $this->sendResponse( ['completed' => true], 'Onboarding completed successfully.' @@ -308,7 +329,7 @@ public function skip(): JsonResponse ['key' => 'onboarding_skipped'], ['value' => 'true'] ); - + return $this->sendResponse( ['skipped' => true], 'Onboarding skipped successfully.' @@ -321,10 +342,10 @@ public function skip(): JsonResponse public function resetSkip(): JsonResponse { Setting::where('key', 'onboarding_skipped')->delete(); - + return $this->sendResponse( ['reset' => true], 'Onboarding skip status reset successfully.' ); } -} \ No newline at end of file +} diff --git a/backend/app/Http/Requests/StoreFeedRequest.php b/backend/app/Http/Requests/StoreFeedRequest.php index e5c5390..a49570c 100644 --- a/backend/app/Http/Requests/StoreFeedRequest.php +++ b/backend/app/Http/Requests/StoreFeedRequest.php @@ -18,8 +18,7 @@ public function rules(): array { return [ 'name' => 'required|string|max:255', - 'url' => 'required|url|unique:feeds,url', - 'type' => 'required|in:website,rss', + 'provider' => 'required|in:vrt,belga', 'language_id' => 'required|exists:languages,id', 'description' => 'nullable|string', 'is_active' => 'boolean' diff --git a/backend/database/seeders/DatabaseSeeder.php b/backend/database/seeders/DatabaseSeeder.php index d074784..2a37b0c 100644 --- a/backend/database/seeders/DatabaseSeeder.php +++ b/backend/database/seeders/DatabaseSeeder.php @@ -10,7 +10,13 @@ public function run(): void { $this->call([ SettingsSeeder::class, - LanguageSeeder::class, ]); + + // Seed languages in local/dev environment only to avoid conflicts in tests + if (app()->environment('local')) { + $this->call([ + LanguageSeeder::class, + ]); + } } } diff --git a/backend/tests/Feature/Http/Controllers/Api/V1/FeedsControllerTest.php b/backend/tests/Feature/Http/Controllers/Api/V1/FeedsControllerTest.php index a1a9d14..a53a046 100644 --- a/backend/tests/Feature/Http/Controllers/Api/V1/FeedsControllerTest.php +++ b/backend/tests/Feature/Http/Controllers/Api/V1/FeedsControllerTest.php @@ -47,14 +47,13 @@ public function test_index_returns_feeds_ordered_by_active_status_then_name(): v $this->assertFalse($feeds[2]['is_active']); } - public function test_store_creates_feed_successfully(): void + public function test_store_creates_vrt_feed_successfully(): void { $language = Language::factory()->create(); $feedData = [ - 'name' => 'Test Feed', - 'url' => 'https://example.com/feed.xml', - 'type' => 'rss', + 'name' => 'VRT Test Feed', + 'provider' => 'vrt', 'language_id' => $language->id, 'is_active' => true, ]; @@ -66,17 +65,49 @@ public function test_store_creates_feed_successfully(): void 'success' => true, 'message' => 'Feed created successfully!', 'data' => [ - 'name' => 'Test Feed', - 'url' => 'https://example.com/feed.xml', - 'type' => 'rss', + 'name' => 'VRT Test Feed', + 'url' => 'https://www.vrt.be/vrtnws/en/', + 'type' => 'website', 'is_active' => true, ] ]); $this->assertDatabaseHas('feeds', [ - 'name' => 'Test Feed', - 'url' => 'https://example.com/feed.xml', - 'type' => 'rss', + 'name' => 'VRT Test Feed', + 'url' => 'https://www.vrt.be/vrtnws/en/', + 'type' => 'website', + ]); + } + + public function test_store_creates_belga_feed_successfully(): void + { + $language = Language::factory()->create(); + + $feedData = [ + 'name' => 'Belga Test Feed', + 'provider' => 'belga', + 'language_id' => $language->id, + 'is_active' => true, + ]; + + $response = $this->postJson('/api/v1/feeds', $feedData); + + $response->assertStatus(201) + ->assertJson([ + 'success' => true, + 'message' => 'Feed created successfully!', + 'data' => [ + 'name' => 'Belga Test Feed', + 'url' => 'https://www.belganewsagency.eu/', + 'type' => 'website', + 'is_active' => true, + ] + ]); + + $this->assertDatabaseHas('feeds', [ + 'name' => 'Belga Test Feed', + 'url' => 'https://www.belganewsagency.eu/', + 'type' => 'website', ]); } @@ -86,8 +117,7 @@ public function test_store_sets_default_active_status(): void $feedData = [ 'name' => 'Test Feed', - 'url' => 'https://example.com/feed.xml', - 'type' => 'rss', + 'provider' => 'vrt', 'language_id' => $language->id, // Not setting is_active ]; @@ -107,7 +137,23 @@ public function test_store_validates_required_fields(): void $response = $this->postJson('/api/v1/feeds', []); $response->assertStatus(422) - ->assertJsonValidationErrors(['name', 'url', 'type']); + ->assertJsonValidationErrors(['name', 'provider', 'language_id']); + } + + public function test_store_rejects_invalid_provider(): void + { + $language = Language::factory()->create(); + + $feedData = [ + 'name' => 'Invalid Feed', + 'provider' => 'invalid', + 'language_id' => $language->id, + ]; + + $response = $this->postJson('/api/v1/feeds', $feedData); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['provider']); } public function test_show_returns_feed_successfully(): void diff --git a/backend/tests/Feature/Http/Controllers/Api/V1/OnboardingControllerTest.php b/backend/tests/Feature/Http/Controllers/Api/V1/OnboardingControllerTest.php index 8bb2e60..e0cc25d 100644 --- a/backend/tests/Feature/Http/Controllers/Api/V1/OnboardingControllerTest.php +++ b/backend/tests/Feature/Http/Controllers/Api/V1/OnboardingControllerTest.php @@ -191,15 +191,14 @@ public function test_create_feed_validates_required_fields() $response = $this->postJson('/api/v1/onboarding/feed', []); $response->assertStatus(422) - ->assertJsonValidationErrors(['name', 'url', 'type', 'language_id']); + ->assertJsonValidationErrors(['name', 'provider', 'language_id']); } - public function test_create_feed_creates_feed_successfully() + public function test_create_feed_creates_vrt_feed_successfully() { $feedData = [ - 'name' => 'Test Feed', - 'url' => 'https://example.com/rss', - 'type' => 'rss', + 'name' => 'VRT Test Feed', + 'provider' => 'vrt', 'language_id' => 1, 'description' => 'Test description', ]; @@ -210,22 +209,68 @@ public function test_create_feed_creates_feed_successfully() ->assertJson([ 'success' => true, 'data' => [ - 'name' => 'Test Feed', - 'url' => 'https://example.com/rss', - 'type' => 'rss', + 'name' => 'VRT Test Feed', + 'url' => 'https://www.vrt.be/vrtnws/en/', + 'type' => 'website', 'is_active' => true, ] ]); $this->assertDatabaseHas('feeds', [ - 'name' => 'Test Feed', - 'url' => 'https://example.com/rss', - 'type' => 'rss', + 'name' => 'VRT Test Feed', + 'url' => 'https://www.vrt.be/vrtnws/en/', + 'type' => 'website', 'language_id' => 1, 'is_active' => true, ]); } + public function test_create_feed_creates_belga_feed_successfully() + { + $feedData = [ + 'name' => 'Belga Test Feed', + 'provider' => 'belga', + 'language_id' => 1, + 'description' => 'Test description', + ]; + + $response = $this->postJson('/api/v1/onboarding/feed', $feedData); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'data' => [ + 'name' => 'Belga Test Feed', + 'url' => 'https://www.belganewsagency.eu/', + 'type' => 'website', + 'is_active' => true, + ] + ]); + + $this->assertDatabaseHas('feeds', [ + 'name' => 'Belga Test Feed', + 'url' => 'https://www.belganewsagency.eu/', + 'type' => 'website', + 'language_id' => 1, + 'is_active' => true, + ]); + } + + public function test_create_feed_rejects_invalid_provider() + { + $feedData = [ + 'name' => 'Invalid Feed', + 'provider' => 'invalid', + 'language_id' => 1, + 'description' => 'Test description', + ]; + + $response = $this->postJson('/api/v1/onboarding/feed', $feedData); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['provider']); + } + public function test_create_channel_validates_required_fields() { $response = $this->postJson('/api/v1/onboarding/channel', []); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 8b28dcf..011e832 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -155,11 +155,17 @@ export interface OnboardingStatus { onboarding_skipped: boolean; } +export interface FeedProvider { + code: string; + name: string; +} + export interface OnboardingOptions { languages: Language[]; platform_instances: PlatformInstance[]; feeds: Feed[]; platform_channels: PlatformChannel[]; + feed_providers: FeedProvider[]; } export interface PlatformAccountRequest { @@ -171,8 +177,7 @@ export interface PlatformAccountRequest { export interface FeedRequest { name: string; - url: string; - type: 'rss' | 'website'; + provider: 'vrt' | 'belga'; language_id: number; description?: string; } diff --git a/frontend/src/pages/onboarding/steps/FeedStep.tsx b/frontend/src/pages/onboarding/steps/FeedStep.tsx index 79fcd4f..da6e54a 100644 --- a/frontend/src/pages/onboarding/steps/FeedStep.tsx +++ b/frontend/src/pages/onboarding/steps/FeedStep.tsx @@ -1,14 +1,13 @@ import React, { useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { useMutation, useQuery } from '@tanstack/react-query'; -import { apiClient, type FeedRequest, type Language } from '../../../lib/api'; +import { apiClient, type FeedRequest, type Language, type FeedProvider } from '../../../lib/api'; const FeedStep: React.FC = () => { const navigate = useNavigate(); const [formData, setFormData] = useState({ name: '', - url: '', - type: 'rss', + provider: 'vrt', language_id: 0, description: '' }); @@ -56,7 +55,7 @@ const FeedStep: React.FC = () => {

Add Your First Feed

- Add a RSS feed or website to monitor for new articles + Choose from our supported news providers to monitor for new articles

{/* Progress indicator */} @@ -93,40 +92,25 @@ const FeedStep: React.FC = () => {
- - handleChange('url', e.target.value)} - placeholder="https://example.com/rss.xml" - 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" - required - /> - {errors.url && ( -

{errors.url[0]}

- )} -
- -
-