Article Approval

This commit is contained in:
myrmidex 2025-07-10 14:57:10 +02:00
parent 8c5bccae9e
commit 3ee9e342e6
11 changed files with 234 additions and 3 deletions

View file

@ -0,0 +1,17 @@
<?php
namespace App\Events;
use App\Models\Article;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ArticleApproved
{
use Dispatchable, SerializesModels;
public function __construct(public Article $article)
{
//
}
}

View file

@ -3,7 +3,9 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Article; use App\Models\Article;
use App\Models\Setting;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class ArticlesController extends Controller class ArticlesController extends Controller
@ -14,6 +16,22 @@ public function __invoke(Request $request): View
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc')
->paginate(15); ->paginate(15);
return view('pages.articles.index', compact('articles')); $publishingApprovalsEnabled = Setting::isPublishingApprovalsEnabled();
return view('pages.articles.index', compact('articles', 'publishingApprovalsEnabled'));
}
public function approve(Article $article): RedirectResponse
{
$article->approve('manual');
return redirect()->back()->with('success', 'Article approved and queued for publishing.');
}
public function reject(Article $article): RedirectResponse
{
$article->reject('manual');
return redirect()->back()->with('success', 'Article rejected.');
} }
} }

View file

@ -12,17 +12,20 @@ class SettingsController extends Controller
public function index(): View public function index(): View
{ {
$articleProcessingEnabled = Setting::isArticleProcessingEnabled(); $articleProcessingEnabled = Setting::isArticleProcessingEnabled();
$publishingApprovalsEnabled = Setting::isPublishingApprovalsEnabled();
return view('pages.settings.index', compact('articleProcessingEnabled')); return view('pages.settings.index', compact('articleProcessingEnabled', 'publishingApprovalsEnabled'));
} }
public function update(Request $request): RedirectResponse public function update(Request $request): RedirectResponse
{ {
$request->validate([ $request->validate([
'article_processing_enabled' => 'boolean', 'article_processing_enabled' => 'boolean',
'enable_publishing_approvals' => 'boolean',
]); ]);
Setting::setArticleProcessingEnabled($request->boolean('article_processing_enabled')); Setting::setArticleProcessingEnabled($request->boolean('article_processing_enabled'));
Setting::setPublishingApprovalsEnabled($request->boolean('enable_publishing_approvals'));
// If redirected from onboarding, go to dashboard // If redirected from onboarding, go to dashboard
if ($request->get('from') === 'onboarding') { if ($request->get('from') === 'onboarding') {

View file

@ -0,0 +1,27 @@
<?php
namespace App\Listeners;
use App\Events\ArticleApproved;
use App\Events\ArticleReadyToPublish;
use Illuminate\Contracts\Queue\ShouldQueue;
class PublishApprovedArticle implements ShouldQueue
{
public string $queue = 'default';
public function handle(ArticleApproved $event): void
{
$article = $event->article;
// Skip if already has publication (prevents duplicate processing)
if ($article->articlePublication()->exists()) {
return;
}
// Only publish if the article is valid and approved
if ($article->isValid() && $article->isApproved()) {
event(new ArticleReadyToPublish($article));
}
}
}

View file

@ -4,6 +4,7 @@
use App\Events\NewArticleFetched; use App\Events\NewArticleFetched;
use App\Events\ArticleReadyToPublish; use App\Events\ArticleReadyToPublish;
use App\Models\Setting;
use App\Services\Article\ValidationService; use App\Services\Article\ValidationService;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@ -32,7 +33,17 @@ public function handle(NewArticleFetched $event): void
return; 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 ArticleReadyToPublish($article));
}
// If not approved, article will wait for manual approval
} else {
// If approvals are disabled, proceed with publishing
event(new ArticleReadyToPublish($article)); event(new ArticleReadyToPublish($article));
} }
} }
}
} }

View file

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Events\ArticleApproved;
use App\Events\NewArticleFetched; use App\Events\NewArticleFetched;
use Database\Factories\ArticleFactory; use Database\Factories\ArticleFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -36,6 +37,9 @@ class Article extends Model
'description', 'description',
'is_valid', 'is_valid',
'is_duplicate', 'is_duplicate',
'approval_status',
'approved_at',
'approved_by',
'fetched_at', 'fetched_at',
'validated_at', 'validated_at',
]; ];
@ -48,6 +52,8 @@ public function casts(): array
return [ return [
'is_valid' => 'boolean', 'is_valid' => 'boolean',
'is_duplicate' => 'boolean', 'is_duplicate' => 'boolean',
'approval_status' => 'string',
'approved_at' => 'datetime',
'fetched_at' => 'datetime', 'fetched_at' => 'datetime',
'validated_at' => 'datetime', 'validated_at' => 'datetime',
'created_at' => 'datetime', 'created_at' => 'datetime',
@ -68,6 +74,57 @@ public function isValid(): bool
return $this->is_valid; return $this->is_valid;
} }
public function isApproved(): bool
{
return $this->approval_status === 'approved';
}
public function isPending(): bool
{
return $this->approval_status === 'pending';
}
public function isRejected(): bool
{
return $this->approval_status === 'rejected';
}
public function approve(string $approvedBy = null): void
{
$this->update([
'approval_status' => 'approved',
'approved_at' => now(),
'approved_by' => $approvedBy,
]);
// Fire event to trigger publishing
event(new ArticleApproved($this));
}
public function reject(string $rejectedBy = null): void
{
$this->update([
'approval_status' => 'rejected',
'approved_at' => now(),
'approved_by' => $rejectedBy,
]);
}
public function canBePublished(): bool
{
if (!$this->isValid()) {
return false;
}
// If approval system is disabled, auto-approve valid articles
if (!\App\Models\Setting::isPublishingApprovalsEnabled()) {
return true;
}
// If approval system is enabled, only approved articles can be published
return $this->isApproved();
}
/** /**
* @return HasOne<ArticlePublication, $this> * @return HasOne<ArticlePublication, $this>
*/ */

View file

@ -41,4 +41,14 @@ public static function setArticleProcessingEnabled(bool $enabled): void
{ {
static::setBool('article_processing_enabled', $enabled); static::setBool('article_processing_enabled', $enabled);
} }
public static function isPublishingApprovalsEnabled(): bool
{
return static::getBool('enable_publishing_approvals', false);
}
public static function setPublishingApprovalsEnabled(bool $enabled): void
{
static::setBool('enable_publishing_approvals', $enabled);
}
} }

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('articles', function (Blueprint $table) {
$table->enum('approval_status', ['pending', 'approved', 'rejected'])
->default('pending')
->after('is_duplicate');
$table->timestamp('approved_at')->nullable()->after('approval_status');
$table->string('approved_by')->nullable()->after('approved_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('articles', function (Blueprint $table) {
$table->dropColumn(['approval_status', 'approved_at', 'approved_by']);
});
}
};

View file

@ -15,7 +15,9 @@
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">URL</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">URL</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Approval</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created At</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created At</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-200"> <tbody class="bg-white divide-y divide-gray-200">
@ -33,7 +35,35 @@
{{ $article->articlePublication ? 'Published' : 'Pending' }} {{ $article->articlePublication ? 'Published' : 'Pending' }}
</span> </span>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full
@if($article->approval_status === 'approved') bg-green-100 text-green-800
@elseif($article->approval_status === 'rejected') bg-red-100 text-red-800
@else bg-yellow-100 text-yellow-800 @endif">
{{ ucfirst($article->approval_status) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ $article->created_at->format('Y-m-d H:i') }}</td> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ $article->created_at->format('Y-m-d H:i') }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
@if($publishingApprovalsEnabled && $article->isValid() && $article->isPending() && !$article->articlePublication)
<div class="flex space-x-2">
<form method="POST" action="{{ route('articles.approve', $article) }}" class="inline">
@csrf
<button type="submit" class="text-green-600 hover:text-green-900 font-medium">
Approve & Publish
</button>
</form>
<form method="POST" action="{{ route('articles.reject', $article) }}" class="inline">
@csrf
<button type="submit" class="text-red-600 hover:text-red-900 font-medium">
Reject
</button>
</form>
</div>
@elseif($article->isValid() && !$article->articlePublication)
<span class="text-gray-500">Auto-publishing enabled</span>
@endif
</td>
</tr> </tr>
@endforeach @endforeach
</tbody> </tbody>

View file

@ -43,6 +43,30 @@ class="form-checkbox h-5 w-5 text-blue-600">
</div> </div>
</div> </div>
<div class="mb-6">
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">Publishing Control</h3>
<div class="flex items-center justify-between">
<div class="flex-1">
<label class="text-sm font-medium text-gray-700">Enable Publishing Approvals</label>
<p class="text-sm text-gray-500 mt-1">When enabled, articles will require manual approval before being published to platforms.</p>
</div>
<div class="ml-4">
<label class="inline-flex items-center">
<input type="hidden" name="enable_publishing_approvals" value="0">
<input type="checkbox"
name="enable_publishing_approvals"
value="1"
{{ $publishingApprovalsEnabled ? 'checked' : '' }}
class="form-checkbox h-5 w-5 text-blue-600">
<span class="ml-2 text-sm text-gray-700">
{{ $publishingApprovalsEnabled ? 'Enabled' : 'Disabled' }}
</span>
</label>
</div>
</div>
</div>
<div class="flex justify-end"> <div class="flex justify-end">
<button type="submit" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"> <button type="submit" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
Save Settings Save Settings

View file

@ -14,6 +14,8 @@
Route::get('/onboarding/complete', [OnboardingController::class, 'complete'])->name('onboarding.complete'); Route::get('/onboarding/complete', [OnboardingController::class, 'complete'])->name('onboarding.complete');
Route::get('/articles', ArticlesController::class)->name('articles'); Route::get('/articles', ArticlesController::class)->name('articles');
Route::post('/articles/{article}/approve', [ArticlesController::class, 'approve'])->name('articles.approve');
Route::post('/articles/{article}/reject', [ArticlesController::class, 'reject'])->name('articles.reject');
Route::get('/logs', LogsController::class)->name('logs'); Route::get('/logs', LogsController::class)->name('logs');
Route::get('/settings', [SettingsController::class, 'index'])->name('settings.index'); Route::get('/settings', [SettingsController::class, 'index'])->name('settings.index');
Route::put('/settings', [SettingsController::class, 'update'])->name('settings.update'); Route::put('/settings', [SettingsController::class, 'update'])->name('settings.update');