68 - Fix duplicate posting and publishing pipeline

This commit is contained in:
myrmidex 2026-02-26 01:19:38 +01:00
parent 8bc6e99f96
commit c75a1c8cf0
17 changed files with 176 additions and 55 deletions

View file

@ -23,14 +23,14 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug LOG_LEVEL=debug
DB_CONNECTION=sqlite DB_CONNECTION=mysql
# DB_HOST=127.0.0.1 DB_HOST=db
# DB_PORT=3306 DB_PORT=3306
# DB_DATABASE=laravel DB_DATABASE=ffr_dev
# DB_USERNAME=root DB_USERNAME=ffr
# DB_PASSWORD= DB_PASSWORD=ffr
SESSION_DRIVER=database SESSION_DRIVER=redis
SESSION_LIFETIME=120 SESSION_LIFETIME=120
SESSION_ENCRYPT=false SESSION_ENCRYPT=false
SESSION_PATH=/ SESSION_PATH=/
@ -38,15 +38,15 @@ SESSION_DOMAIN=null
BROADCAST_CONNECTION=log BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
QUEUE_CONNECTION=database QUEUE_CONNECTION=redis
CACHE_STORE=database CACHE_STORE=redis
# CACHE_PREFIX= # CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1 MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1 REDIS_HOST=redis
REDIS_PASSWORD=null REDIS_PASSWORD=null
REDIS_PORT=6379 REDIS_PORT=6379

1
.gitignore vendored
View file

@ -4,6 +4,7 @@
/public/build /public/build
/public/hot /public/hot
/public/storage /public/storage
/public/vendor
/storage/*.key /storage/*.key
/storage/pail /storage/pail
/vendor /vendor

View file

@ -34,15 +34,15 @@ public function update(Request $request): JsonResponse
try { try {
$validated = $request->validate([ $validated = $request->validate([
'article_processing_enabled' => 'boolean', 'article_processing_enabled' => 'boolean',
'enable_publishing_approvals' => 'boolean', 'publishing_approvals_enabled' => 'boolean',
]); ]);
if (isset($validated['article_processing_enabled'])) { if (isset($validated['article_processing_enabled'])) {
Setting::setArticleProcessingEnabled($validated['article_processing_enabled']); Setting::setArticleProcessingEnabled($validated['article_processing_enabled']);
} }
if (isset($validated['enable_publishing_approvals'])) { if (isset($validated['publishing_approvals_enabled'])) {
Setting::setPublishingApprovalsEnabled($validated['enable_publishing_approvals']); Setting::setPublishingApprovalsEnabled($validated['publishing_approvals_enabled']);
} }
$updatedSettings = [ $updatedSettings = [
@ -60,4 +60,4 @@ public function update(Request $request): JsonResponse
return $this->sendError('Failed to update settings: ' . $e->getMessage(), [], 500); return $this->sendError('Failed to update settings: ' . $e->getMessage(), [], 500);
} }
} }
} }

View file

@ -21,6 +21,7 @@ public function toArray(Request $request): array
'is_valid' => $this->is_valid, 'is_valid' => $this->is_valid,
'is_duplicate' => $this->is_duplicate, 'is_duplicate' => $this->is_duplicate,
'approval_status' => $this->approval_status, 'approval_status' => $this->approval_status,
'publish_status' => $this->publish_status,
'approved_at' => $this->approved_at?->toISOString(), 'approved_at' => $this->approved_at?->toISOString(),
'approved_by' => $this->approved_by, 'approved_by' => $this->approved_by,
'fetched_at' => $this->fetched_at?->toISOString(), 'fetched_at' => $this->fetched_at?->toISOString(),

View file

@ -0,0 +1,64 @@
<?php
namespace App\Listeners;
use App\Events\ArticleApproved;
use App\Services\Article\ArticleFetcher;
use App\Services\Publishing\ArticlePublishingService;
use Exception;
use Illuminate\Contracts\Queue\ShouldQueue;
class PublishApprovedArticleListener implements ShouldQueue
{
public string $queue = 'publishing';
public function __construct(
private ArticleFetcher $articleFetcher,
private ArticlePublishingService $publishingService
) {}
public function handle(ArticleApproved $event): void
{
$article = $event->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(),
]);
}
}
}

View file

@ -3,7 +3,6 @@
namespace App\Listeners; namespace App\Listeners;
use App\Events\NewArticleFetched; use App\Events\NewArticleFetched;
use App\Events\ArticleApproved;
use App\Models\Setting; use App\Models\Setting;
use App\Services\Article\ValidationService; use App\Services\Article\ValidationService;
use Exception; use Exception;
@ -51,16 +50,10 @@ public function handle(NewArticleFetched $event): void
return; return;
} }
// Check if approval system is enabled // If approvals are enabled, article waits for manual approval.
if (Setting::isPublishingApprovalsEnabled()) { // If approvals are disabled, auto-approve and publish.
// If approvals are enabled, only proceed if article is approved if (! Setting::isPublishingApprovalsEnabled()) {
if ($article->isApproved()) { $article->approve();
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));
} }
} }
} }

View file

@ -40,6 +40,8 @@ class Article extends Model
'published_at', 'published_at',
'author', 'author',
'approval_status', 'approval_status',
'validated_at',
'publish_status',
]; ];
/** /**
@ -49,7 +51,9 @@ public function casts(): array
{ {
return [ return [
'approval_status' => 'string', 'approval_status' => 'string',
'publish_status' => 'string',
'published_at' => 'datetime', 'published_at' => 'datetime',
'validated_at' => 'datetime',
'created_at' => 'datetime', 'created_at' => 'datetime',
'updated_at' => 'datetime', 'updated_at' => 'datetime',
]; ];
@ -57,9 +61,8 @@ public function casts(): array
public function isValid(): bool public function isValid(): bool
{ {
// In the consolidated schema, we only have approval_status // Article is valid if it passed validation and wasn't rejected
// Consider 'approved' status as valid return $this->validated_at !== null && ! $this->isRejected();
return $this->approval_status === 'approved';
} }
public function isApproved(): bool public function isApproved(): bool

View file

@ -52,7 +52,7 @@ public static function setArticleProcessingEnabled(bool $enabled): void
public static function isPublishingApprovalsEnabled(): bool 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 public static function setPublishingApprovalsEnabled(bool $enabled): void

View file

@ -28,13 +28,30 @@ public function __construct(PlatformAccount $account)
*/ */
public function publishToChannel(Article $article, array $extractedData, PlatformChannel $channel): array 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<string, mixed> $extractedData
* @return array<string, mixed>
*/
private function createPost(string $token, array $extractedData, PlatformChannel $channel, Article $article): array
{
$languageId = $extractedData['language_id'] ?? null; $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 ? (int) $channel->channel_id
: $this->api->getCommunityId($channel->channel_id, $token); : $this->api->getCommunityId($channel->channel_id, $token);

View file

@ -30,6 +30,10 @@ public function boot(): void
\App\Listeners\ValidateArticleListener::class, \App\Listeners\ValidateArticleListener::class,
); );
Event::listen(
\App\Events\ArticleApproved::class,
\App\Listeners\PublishApprovedArticleListener::class,
);
app()->make(ExceptionHandler::class) app()->make(ExceptionHandler::class)
->reportable(function (Throwable $e) { ->reportable(function (Throwable $e) {

View file

@ -15,7 +15,7 @@ public function validate(Article $article): Article
logger('Checking keywords for article: ' . $article->id); logger('Checking keywords for article: ' . $article->id);
$articleData = $this->articleFetcher->fetchArticleData($article); $articleData = $this->articleFetcher->fetchArticleData($article);
// Update article with fetched metadata (title, description) // Update article with fetched metadata (title, description)
$updateData = []; $updateData = [];
@ -24,23 +24,29 @@ public function validate(Article $article): Article
$updateData['description'] = $articleData['description'] ?? $article->description; $updateData['description'] = $articleData['description'] ?? $article->description;
$updateData['content'] = $articleData['full_article'] ?? null; $updateData['content'] = $articleData['full_article'] ?? null;
} }
if (!isset($articleData['full_article']) || empty($articleData['full_article'])) { if (!isset($articleData['full_article']) || empty($articleData['full_article'])) {
logger()->warning('Article data missing full_article content', [ logger()->warning('Article data missing full_article content', [
'article_id' => $article->id, 'article_id' => $article->id,
'url' => $article->url 'url' => $article->url
]); ]);
$updateData['approval_status'] = 'rejected'; $updateData['approval_status'] = 'rejected';
$article->update($updateData); $article->update($updateData);
return $article->refresh(); 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); $article->update($updateData);
return $article->refresh(); return $article->refresh();
@ -53,12 +59,12 @@ private function validateByKeywords(string $full_article): bool
// Political parties and leaders // Political parties and leaders
'N-VA', 'Bart De Wever', 'Frank Vandenbroucke', 'Alexander De Croo', 'N-VA', 'Bart De Wever', 'Frank Vandenbroucke', 'Alexander De Croo',
'Vooruit', 'Open Vld', 'CD&V', 'Vlaams Belang', 'PTB', 'PVDA', 'Vooruit', 'Open Vld', 'CD&V', 'Vlaams Belang', 'PTB', 'PVDA',
// Belgian locations and institutions // Belgian locations and institutions
'Belgium', 'Belgian', 'Flanders', 'Flemish', 'Wallonia', 'Brussels', 'Belgium', 'Belgian', 'Flanders', 'Flemish', 'Wallonia', 'Brussels',
'Antwerp', 'Ghent', 'Bruges', 'Leuven', 'Mechelen', 'Namur', 'Liège', 'Charleroi', 'Antwerp', 'Ghent', 'Bruges', 'Leuven', 'Mechelen', 'Namur', 'Liège', 'Charleroi',
'parliament', 'government', 'minister', 'policy', 'law', 'legislation', 'parliament', 'government', 'minister', 'policy', 'law', 'legislation',
// Common Belgian news topics // Common Belgian news topics
'economy', 'economic', 'education', 'healthcare', 'transport', 'climate', 'energy', 'economy', 'economic', 'education', 'healthcare', 'transport', 'climate', 'energy',
'European', 'EU', 'migration', 'security', 'justice', 'culture', 'police' 'European', 'EU', 'migration', 'security', 'justice', 'culture', 'police'

View file

@ -14,6 +14,22 @@ class LemmyAuthService
* @throws PlatformAuthException * @throws PlatformAuthException
*/ */
public function getToken(PlatformAccount $account): string 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) { if (! $account->username || ! $account->password || ! $account->instance_url) {
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Missing credentials for account: ' . $account->username); 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); 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; return $token;
} }

View file

@ -26,6 +26,7 @@ public function definition(): array
'published_at' => $this->faker->optional()->dateTimeBetween('-1 month', 'now'), 'published_at' => $this->faker->optional()->dateTimeBetween('-1 month', 'now'),
'author' => $this->faker->optional()->name(), 'author' => $this->faker->optional()->name(),
'approval_status' => $this->faker->randomElement(['pending', 'approved', 'rejected']), 'approval_status' => $this->faker->randomElement(['pending', 'approved', 'rejected']),
'publish_status' => 'unpublished',
]; ];
} }
} }

View file

@ -20,6 +20,8 @@ public function up(): void
$table->string('author')->nullable(); $table->string('author')->nullable();
$table->unsignedBigInteger('feed_id')->nullable(); $table->unsignedBigInteger('feed_id')->nullable();
$table->enum('approval_status', ['pending', 'approved', 'rejected'])->default('pending'); $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->timestamps();
$table->index(['published_at', 'approval_status']); $table->index(['published_at', 'approval_status']);

View file

@ -1,7 +1 @@
import './bootstrap'; import "./bootstrap";
import Alpine from 'alpinejs';
window.Alpine = Alpine;
Alpine.start();

View file

@ -54,6 +54,20 @@ class="inline-flex items-center px-4 py-2 border border-transparent text-sm font
</svg> </svg>
Published Published
</span> </span>
@elseif ($article->publish_status === 'error')
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
<svg class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
Publish Error
</span>
@elseif ($article->publish_status === 'publishing')
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800">
<svg class="h-3 w-3 mr-1 animate-spin" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
Publishing...
</span>
@elseif ($article->approval_status === 'approved') @elseif ($article->approval_status === 'approved')
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"> <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<svg class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <svg class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">

View file

@ -48,7 +48,7 @@ public function test_update_modifies_article_processing_setting(): void
public function test_update_modifies_publishing_approvals_setting(): void public function test_update_modifies_publishing_approvals_setting(): void
{ {
$response = $this->putJson('/api/v1/settings', [ $response = $this->putJson('/api/v1/settings', [
'enable_publishing_approvals' => true, 'publishing_approvals_enabled' => true,
]); ]);
$response->assertStatus(200) $response->assertStatus(200)
@ -65,13 +65,13 @@ public function test_update_validates_boolean_values(): void
{ {
$response = $this->putJson('/api/v1/settings', [ $response = $this->putJson('/api/v1/settings', [
'article_processing_enabled' => 'not-a-boolean', 'article_processing_enabled' => 'not-a-boolean',
'enable_publishing_approvals' => 'also-not-boolean', 'publishing_approvals_enabled' => 'also-not-boolean',
]); ]);
$response->assertStatus(422) $response->assertStatus(422)
->assertJsonValidationErrors([ ->assertJsonValidationErrors([
'article_processing_enabled', '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, 'article_processing_enabled' => true,
] ]
]); ]);
// Should still have structure for both settings // Should still have structure for both settings
$response->assertJsonStructure([ $response->assertJsonStructure([
'data' => [ 'data' => [
@ -98,4 +98,4 @@ public function test_update_accepts_partial_updates(): void
] ]
]); ]);
} }
} }