Article Approval
This commit is contained in:
parent
8c5bccae9e
commit
3ee9e342e6
11 changed files with 234 additions and 3 deletions
17
app/Events/ArticleApproved.php
Normal file
17
app/Events/ArticleApproved.php
Normal 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)
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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') {
|
||||||
|
|
|
||||||
27
app/Listeners/PublishApprovedArticle.php
Normal file
27
app/Listeners/PublishApprovedArticle.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
event(new ArticleReadyToPublish($article));
|
// 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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue