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

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,12 +28,29 @@ 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

@ -37,10 +37,16 @@ public function validate(Article $article): Article
return $article->refresh(); return $article->refresh();
} }
// Validate using extracted content (not stored) // 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']); $validationResult = $this->validateByKeywords($articleData['full_article']);
$updateData['approval_status'] = $validationResult ? 'approved' : 'pending';
if (! $validationResult) {
$updateData['approval_status'] = 'rejected';
}
$updateData['validated_at'] = now();
$article->update($updateData); $article->update($updateData);
return $article->refresh(); return $article->refresh();

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