68 - Fix duplicate posting and publishing pipeline
This commit is contained in:
parent
8bc6e99f96
commit
c75a1c8cf0
17 changed files with 176 additions and 55 deletions
20
.env.example
20
.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
|
||||
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,6 +4,7 @@
|
|||
/public/build
|
||||
/public/hot
|
||||
/public/storage
|
||||
/public/vendor
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
64
app/Listeners/PublishApprovedArticleListener.php
Normal file
64
app/Listeners/PublishApprovedArticleListener.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -1,7 +1 @@
|
|||
import './bootstrap';
|
||||
|
||||
import Alpine from 'alpinejs';
|
||||
|
||||
window.Alpine = Alpine;
|
||||
|
||||
Alpine.start();
|
||||
import "./bootstrap";
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
|||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue