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);
$feeds = Feed::with(['language'])
$feeds = Feed::with(['languages'])
->withCount('articles')
->orderBy('is_active', 'desc')
->orderBy('name')
@ -57,8 +57,28 @@ 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),
'Feed created successfully!',
@ -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) {

View file

@ -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.'
);
}

View file

@ -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,

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(),
'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

View file

@ -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<string, mixed> $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<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 [
'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<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',
'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<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,
'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(),

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();
$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

View file

@ -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
];

View file

@ -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 = [

View file

@ -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

View file

@ -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();
$this->assertInstanceOf(Language::class, $feed->language);
$this->assertEquals($language->id, $feed->language->id);
$this->assertEquals($language->name, $feed->language->name);
$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
]);
$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);

View file

@ -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

View file

@ -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;
}

View file

@ -8,9 +8,11 @@ const FeedStep: React.FC = () => {
const [formData, setFormData] = useState<FeedRequest>({
name: '',
provider: 'vrt',
language_id: 0,
language_ids: [],
primary_language_id: undefined,
description: ''
});
const [selectedProvider, setSelectedProvider] = useState<FeedProvider | null>(null);
const [errors, setErrors] = useState<Record<string, string[]>>({});
// 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 = () => {
</div>
<div>
<label htmlFor="language_id" className="block text-sm font-medium text-gray-700 mb-2">
Language
<label className="block text-sm font-medium text-gray-700 mb-2">
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>
<select
id="language_id"
value={formData.language_id || ''}
onChange={(e) => handleChange('language_id', e.target.value ? parseInt(e.target.value) : 0)}
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
>
<option value="">Select language</option>
{options?.languages.map((language: Language) => (
<option key={language.id} value={language.id}>
{language.name}
</option>
))}
</select>
{errors.language_id && (
<p className="text-red-600 text-sm mt-1">{errors.language_id[0]}</p>
<div className="space-y-2">
{options?.languages.map((language: Language) => {
const isSupported = !selectedProvider?.supported_languages ||
Object.keys(selectedProvider.supported_languages).includes(language.short_code);
const isSelected = formData.language_ids.includes(language.id);
const isPrimary = formData.primary_language_id === language.id;
return (
<div key={language.id} className={`flex items-center space-x-3 p-2 rounded ${!isSupported ? 'opacity-50' : ''}`}>
<input
type="checkbox"
id={`language-${language.id}`}
checked={isSelected}
disabled={!isSupported}
onChange={(e) => {
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>