From c75a1c8cf0cdba2a6f1097d933d2ca89ee0678f9 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Thu, 26 Feb 2026 01:19:38 +0100 Subject: [PATCH] 68 - Fix duplicate posting and publishing pipeline --- .env.example | 20 +++--- .gitignore | 1 + .../Controllers/Api/V1/SettingsController.php | 8 +-- app/Http/Resources/ArticleResource.php | 1 + .../PublishApprovedArticleListener.php | 64 +++++++++++++++++++ app/Listeners/ValidateArticleListener.php | 15 ++--- app/Models/Article.php | 9 ++- app/Models/Setting.php | 2 +- app/Modules/Lemmy/Services/LemmyPublisher.php | 25 ++++++-- app/Providers/AppServiceProvider.php | 4 ++ app/Services/Article/ValidationService.php | 26 +++++--- app/Services/Auth/LemmyAuthService.php | 21 ++++++ database/factories/ArticleFactory.php | 1 + ...00001_create_articles_and_publications.php | 2 + resources/js/app.js | 8 +-- resources/views/livewire/articles.blade.php | 14 ++++ .../Api/V1/SettingsControllerTest.php | 10 +-- 17 files changed, 176 insertions(+), 55 deletions(-) create mode 100644 app/Listeners/PublishApprovedArticleListener.php diff --git a/.env.example b/.env.example index a42f904..10d8fc8 100644 --- a/.env.example +++ b/.env.example @@ -23,14 +23,14 @@ LOG_STACK=single LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug -DB_CONNECTION=sqlite -# DB_HOST=127.0.0.1 -# DB_PORT=3306 -# DB_DATABASE=laravel -# DB_USERNAME=root -# DB_PASSWORD= +DB_CONNECTION=mysql +DB_HOST=db +DB_PORT=3306 +DB_DATABASE=ffr_dev +DB_USERNAME=ffr +DB_PASSWORD=ffr -SESSION_DRIVER=database +SESSION_DRIVER=redis SESSION_LIFETIME=120 SESSION_ENCRYPT=false SESSION_PATH=/ @@ -38,15 +38,15 @@ SESSION_DOMAIN=null BROADCAST_CONNECTION=log FILESYSTEM_DISK=local -QUEUE_CONNECTION=database +QUEUE_CONNECTION=redis -CACHE_STORE=database +CACHE_STORE=redis # CACHE_PREFIX= MEMCACHED_HOST=127.0.0.1 REDIS_CLIENT=phpredis -REDIS_HOST=127.0.0.1 +REDIS_HOST=redis REDIS_PASSWORD=null REDIS_PORT=6379 diff --git a/.gitignore b/.gitignore index 6f5ed04..d801cf9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /public/build /public/hot /public/storage +/public/vendor /storage/*.key /storage/pail /vendor diff --git a/app/Http/Controllers/Api/V1/SettingsController.php b/app/Http/Controllers/Api/V1/SettingsController.php index 6e809ec..25ba8a7 100644 --- a/app/Http/Controllers/Api/V1/SettingsController.php +++ b/app/Http/Controllers/Api/V1/SettingsController.php @@ -34,15 +34,15 @@ public function update(Request $request): JsonResponse try { $validated = $request->validate([ 'article_processing_enabled' => 'boolean', - 'enable_publishing_approvals' => 'boolean', + 'publishing_approvals_enabled' => 'boolean', ]); if (isset($validated['article_processing_enabled'])) { Setting::setArticleProcessingEnabled($validated['article_processing_enabled']); } - if (isset($validated['enable_publishing_approvals'])) { - Setting::setPublishingApprovalsEnabled($validated['enable_publishing_approvals']); + if (isset($validated['publishing_approvals_enabled'])) { + Setting::setPublishingApprovalsEnabled($validated['publishing_approvals_enabled']); } $updatedSettings = [ @@ -60,4 +60,4 @@ public function update(Request $request): JsonResponse return $this->sendError('Failed to update settings: ' . $e->getMessage(), [], 500); } } -} \ No newline at end of file +} diff --git a/app/Http/Resources/ArticleResource.php b/app/Http/Resources/ArticleResource.php index 506cf14..e336734 100644 --- a/app/Http/Resources/ArticleResource.php +++ b/app/Http/Resources/ArticleResource.php @@ -21,6 +21,7 @@ public function toArray(Request $request): array 'is_valid' => $this->is_valid, 'is_duplicate' => $this->is_duplicate, 'approval_status' => $this->approval_status, + 'publish_status' => $this->publish_status, 'approved_at' => $this->approved_at?->toISOString(), 'approved_by' => $this->approved_by, 'fetched_at' => $this->fetched_at?->toISOString(), diff --git a/app/Listeners/PublishApprovedArticleListener.php b/app/Listeners/PublishApprovedArticleListener.php new file mode 100644 index 0000000..e3207e6 --- /dev/null +++ b/app/Listeners/PublishApprovedArticleListener.php @@ -0,0 +1,64 @@ +article->fresh(); + + // Skip if already published + if ($article->articlePublication()->exists()) { + return; + } + + // Skip if not approved (safety check) + if (! $article->isApproved()) { + return; + } + + $article->update(['publish_status' => 'publishing']); + + try { + $extractedData = $this->articleFetcher->fetchArticleData($article); + $publications = $this->publishingService->publishToRoutedChannels($article, $extractedData); + + if ($publications->isNotEmpty()) { + $article->update(['publish_status' => 'published']); + + logger()->info('Published approved article', [ + 'article_id' => $article->id, + 'title' => $article->title, + ]); + } else { + $article->update(['publish_status' => 'error']); + + logger()->warning('No publications created for approved article', [ + 'article_id' => $article->id, + 'title' => $article->title, + ]); + } + } catch (Exception $e) { + $article->update(['publish_status' => 'error']); + + logger()->error('Failed to publish approved article', [ + 'article_id' => $article->id, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Listeners/ValidateArticleListener.php b/app/Listeners/ValidateArticleListener.php index 4a7739c..3b1352c 100644 --- a/app/Listeners/ValidateArticleListener.php +++ b/app/Listeners/ValidateArticleListener.php @@ -3,7 +3,6 @@ namespace App\Listeners; use App\Events\NewArticleFetched; -use App\Events\ArticleApproved; use App\Models\Setting; use App\Services\Article\ValidationService; use Exception; @@ -51,16 +50,10 @@ public function handle(NewArticleFetched $event): void return; } - // Check if approval system is enabled - if (Setting::isPublishingApprovalsEnabled()) { - // If approvals are enabled, only proceed if article is approved - if ($article->isApproved()) { - event(new ArticleApproved($article)); - } - // If not approved, article will wait for manual approval - } else { - // If approvals are disabled, proceed with publishing - event(new ArticleApproved($article)); + // If approvals are enabled, article waits for manual approval. + // If approvals are disabled, auto-approve and publish. + if (! Setting::isPublishingApprovalsEnabled()) { + $article->approve(); } } } diff --git a/app/Models/Article.php b/app/Models/Article.php index 11c622e..f5cd55b 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -40,6 +40,8 @@ class Article extends Model 'published_at', 'author', 'approval_status', + 'validated_at', + 'publish_status', ]; /** @@ -49,7 +51,9 @@ public function casts(): array { return [ 'approval_status' => 'string', + 'publish_status' => 'string', 'published_at' => 'datetime', + 'validated_at' => 'datetime', 'created_at' => 'datetime', 'updated_at' => 'datetime', ]; @@ -57,9 +61,8 @@ public function casts(): array public function isValid(): bool { - // In the consolidated schema, we only have approval_status - // Consider 'approved' status as valid - return $this->approval_status === 'approved'; + // Article is valid if it passed validation and wasn't rejected + return $this->validated_at !== null && ! $this->isRejected(); } public function isApproved(): bool diff --git a/app/Models/Setting.php b/app/Models/Setting.php index ccc6c04..9f5a132 100644 --- a/app/Models/Setting.php +++ b/app/Models/Setting.php @@ -52,7 +52,7 @@ public static function setArticleProcessingEnabled(bool $enabled): void public static function isPublishingApprovalsEnabled(): bool { - return static::getBool('enable_publishing_approvals', false); + return static::getBool('enable_publishing_approvals', true); } public static function setPublishingApprovalsEnabled(bool $enabled): void diff --git a/app/Modules/Lemmy/Services/LemmyPublisher.php b/app/Modules/Lemmy/Services/LemmyPublisher.php index c19262f..ffe60ce 100644 --- a/app/Modules/Lemmy/Services/LemmyPublisher.php +++ b/app/Modules/Lemmy/Services/LemmyPublisher.php @@ -28,13 +28,30 @@ public function __construct(PlatformAccount $account) */ public function publishToChannel(Article $article, array $extractedData, PlatformChannel $channel): array { - $token = resolve(LemmyAuthService::class)->getToken($this->account); + $authService = resolve(LemmyAuthService::class); + $token = $authService->getToken($this->account); - // Use the language ID from extracted data (should be set during validation) + try { + return $this->createPost($token, $extractedData, $channel, $article); + } catch (Exception $e) { + // If the cached token was stale, refresh and retry once + if (str_contains($e->getMessage(), 'not_logged_in') || str_contains($e->getMessage(), 'Unauthorized')) { + $token = $authService->refreshToken($this->account); + return $this->createPost($token, $extractedData, $channel, $article); + } + throw $e; + } + } + + /** + * @param array $extractedData + * @return array + */ + private function createPost(string $token, array $extractedData, PlatformChannel $channel, Article $article): array + { $languageId = $extractedData['language_id'] ?? null; - // Resolve community name to numeric ID if needed - $communityId = is_numeric($channel->channel_id) + $communityId = is_numeric($channel->channel_id) ? (int) $channel->channel_id : $this->api->getCommunityId($channel->channel_id, $token); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 1ac3ea4..dbcab92 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -30,6 +30,10 @@ public function boot(): void \App\Listeners\ValidateArticleListener::class, ); + Event::listen( + \App\Events\ArticleApproved::class, + \App\Listeners\PublishApprovedArticleListener::class, + ); app()->make(ExceptionHandler::class) ->reportable(function (Throwable $e) { diff --git a/app/Services/Article/ValidationService.php b/app/Services/Article/ValidationService.php index 271b8d7..5a796e2 100644 --- a/app/Services/Article/ValidationService.php +++ b/app/Services/Article/ValidationService.php @@ -15,7 +15,7 @@ public function validate(Article $article): Article logger('Checking keywords for article: ' . $article->id); $articleData = $this->articleFetcher->fetchArticleData($article); - + // Update article with fetched metadata (title, description) $updateData = []; @@ -24,23 +24,29 @@ public function validate(Article $article): Article $updateData['description'] = $articleData['description'] ?? $article->description; $updateData['content'] = $articleData['full_article'] ?? null; } - + if (!isset($articleData['full_article']) || empty($articleData['full_article'])) { logger()->warning('Article data missing full_article content', [ 'article_id' => $article->id, 'url' => $article->url ]); - + $updateData['approval_status'] = 'rejected'; $article->update($updateData); - + return $article->refresh(); } - - // Validate using extracted content (not stored) - $validationResult = $this->validateByKeywords($articleData['full_article']); - $updateData['approval_status'] = $validationResult ? 'approved' : 'pending'; + // Validate content against keywords. If validation fails, reject. + // If validation passes, leave approval_status as-is (pending) — + // the listener decides whether to auto-approve based on settings. + $validationResult = $this->validateByKeywords($articleData['full_article']); + + if (! $validationResult) { + $updateData['approval_status'] = 'rejected'; + } + + $updateData['validated_at'] = now(); $article->update($updateData); return $article->refresh(); @@ -53,12 +59,12 @@ private function validateByKeywords(string $full_article): bool // Political parties and leaders 'N-VA', 'Bart De Wever', 'Frank Vandenbroucke', 'Alexander De Croo', 'Vooruit', 'Open Vld', 'CD&V', 'Vlaams Belang', 'PTB', 'PVDA', - + // Belgian locations and institutions 'Belgium', 'Belgian', 'Flanders', 'Flemish', 'Wallonia', 'Brussels', 'Antwerp', 'Ghent', 'Bruges', 'Leuven', 'Mechelen', 'Namur', 'Liège', 'Charleroi', 'parliament', 'government', 'minister', 'policy', 'law', 'legislation', - + // Common Belgian news topics 'economy', 'economic', 'education', 'healthcare', 'transport', 'climate', 'energy', 'European', 'EU', 'migration', 'security', 'justice', 'culture', 'police' diff --git a/app/Services/Auth/LemmyAuthService.php b/app/Services/Auth/LemmyAuthService.php index 2bd972e..7fe5f88 100644 --- a/app/Services/Auth/LemmyAuthService.php +++ b/app/Services/Auth/LemmyAuthService.php @@ -14,6 +14,22 @@ class LemmyAuthService * @throws PlatformAuthException */ public function getToken(PlatformAccount $account): string + { + // Use cached token if available + $cachedToken = $account->settings['api_token'] ?? null; + if ($cachedToken) { + return $cachedToken; + } + + return $this->refreshToken($account); + } + + /** + * Clear cached token and re-authenticate. + * + * @throws PlatformAuthException + */ + public function refreshToken(PlatformAccount $account): string { if (! $account->username || ! $account->password || ! $account->instance_url) { throw new PlatformAuthException(PlatformEnum::LEMMY, 'Missing credentials for account: ' . $account->username); @@ -26,6 +42,11 @@ public function getToken(PlatformAccount $account): string throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed for account: ' . $account->username); } + // Cache the token for future use + $settings = $account->settings ?? []; + $settings['api_token'] = $token; + $account->update(['settings' => $settings]); + return $token; } diff --git a/database/factories/ArticleFactory.php b/database/factories/ArticleFactory.php index 40e14ee..f35645d 100644 --- a/database/factories/ArticleFactory.php +++ b/database/factories/ArticleFactory.php @@ -26,6 +26,7 @@ public function definition(): array 'published_at' => $this->faker->optional()->dateTimeBetween('-1 month', 'now'), 'author' => $this->faker->optional()->name(), 'approval_status' => $this->faker->randomElement(['pending', 'approved', 'rejected']), + 'publish_status' => 'unpublished', ]; } } diff --git a/database/migrations/2024_01_01_000001_create_articles_and_publications.php b/database/migrations/2024_01_01_000001_create_articles_and_publications.php index 9700682..919435a 100644 --- a/database/migrations/2024_01_01_000001_create_articles_and_publications.php +++ b/database/migrations/2024_01_01_000001_create_articles_and_publications.php @@ -20,6 +20,8 @@ public function up(): void $table->string('author')->nullable(); $table->unsignedBigInteger('feed_id')->nullable(); $table->enum('approval_status', ['pending', 'approved', 'rejected'])->default('pending'); + $table->timestamp('validated_at')->nullable(); + $table->enum('publish_status', ['unpublished', 'publishing', 'published', 'error'])->default('unpublished'); $table->timestamps(); $table->index(['published_at', 'approval_status']); diff --git a/resources/js/app.js b/resources/js/app.js index a8093be..3103e5d 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,7 +1 @@ -import './bootstrap'; - -import Alpine from 'alpinejs'; - -window.Alpine = Alpine; - -Alpine.start(); +import "./bootstrap"; diff --git a/resources/views/livewire/articles.blade.php b/resources/views/livewire/articles.blade.php index 62411ef..b972dd5 100644 --- a/resources/views/livewire/articles.blade.php +++ b/resources/views/livewire/articles.blade.php @@ -54,6 +54,20 @@ class="inline-flex items-center px-4 py-2 border border-transparent text-sm font Published + @elseif ($article->publish_status === 'error') + + + + + Publish Error + + @elseif ($article->publish_status === 'publishing') + + + + + Publishing... + @elseif ($article->approval_status === 'approved') diff --git a/tests/Feature/Http/Controllers/Api/V1/SettingsControllerTest.php b/tests/Feature/Http/Controllers/Api/V1/SettingsControllerTest.php index 7b86f3a..95e8ead 100644 --- a/tests/Feature/Http/Controllers/Api/V1/SettingsControllerTest.php +++ b/tests/Feature/Http/Controllers/Api/V1/SettingsControllerTest.php @@ -48,7 +48,7 @@ public function test_update_modifies_article_processing_setting(): void public function test_update_modifies_publishing_approvals_setting(): void { $response = $this->putJson('/api/v1/settings', [ - 'enable_publishing_approvals' => true, + 'publishing_approvals_enabled' => true, ]); $response->assertStatus(200) @@ -65,13 +65,13 @@ public function test_update_validates_boolean_values(): void { $response = $this->putJson('/api/v1/settings', [ 'article_processing_enabled' => 'not-a-boolean', - 'enable_publishing_approvals' => 'also-not-boolean', + 'publishing_approvals_enabled' => 'also-not-boolean', ]); $response->assertStatus(422) ->assertJsonValidationErrors([ 'article_processing_enabled', - 'enable_publishing_approvals' + 'publishing_approvals_enabled' ]); } @@ -89,7 +89,7 @@ public function test_update_accepts_partial_updates(): void 'article_processing_enabled' => true, ] ]); - + // Should still have structure for both settings $response->assertJsonStructure([ 'data' => [ @@ -98,4 +98,4 @@ public function test_update_accepts_partial_updates(): void ] ]); } -} \ No newline at end of file +}