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

1
.gitignore vendored
View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

@ -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<string, mixed> $extractedData
* @return array<string, mixed>
*/
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);

View file

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

View file

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

View file

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

View file

@ -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',
];
}
}

View file

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

View file

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

View file

@ -54,6 +54,20 @@ class="inline-flex items-center px-4 py-2 border border-transparent text-sm font
</svg>
Published
</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')
<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">

View file

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