diff --git a/app/Http/Controllers/Admin/UsersController.php b/app/Http/Controllers/Admin/UsersController.php index 9474ccb0b..a4d9207d3 100644 --- a/app/Http/Controllers/Admin/UsersController.php +++ b/app/Http/Controllers/Admin/UsersController.php @@ -9,6 +9,8 @@ use App\Jobs\DeleteUser; use App\Jobs\DeleteUserThreads; use App\Jobs\UnbanUser; +use App\Jobs\UnVerifyAuthor; +use App\Jobs\VerifyAuthor; use App\Models\User; use App\Policies\UserPolicy; use App\Queries\SearchUsers; @@ -60,6 +62,29 @@ public function unban(User $user): RedirectResponse return redirect()->route('profile', $user->username()); } + public function verifyAuthor(User $user) + { + $this->authorize(UserPolicy::VERIFY_AUTHOR, $user); + + $this->dispatchSync(new VerifyAuthor($user)); + + $this->success($user->name() . ' was verified!'); + + return redirect()->route('admin.users'); + } + + public function unverifyAuthor(User $user) + + { + $this->authorize(UserPolicy::VERIFY_AUTHOR, $user); + + $this->dispatchSync(new UnverifyAuthor($user)); + + $this->success($user->name() . ' was unverified!'); + + return redirect()->route('admin.users'); + } + public function delete(User $user): RedirectResponse { $this->authorize(UserPolicy::DELETE, $user); diff --git a/app/Http/Controllers/Articles/ArticlesController.php b/app/Http/Controllers/Articles/ArticlesController.php index b876e4461..7f4bdffcf 100644 --- a/app/Http/Controllers/Articles/ArticlesController.php +++ b/app/Http/Controllers/Articles/ArticlesController.php @@ -115,11 +115,7 @@ public function store(ArticleRequest $request) $article = Article::findByUuidOrFail($uuid); - $this->success( - $request->shouldBeSubmitted() - ? 'Thank you for submitting, unfortunately we can\'t accept every submission. You\'ll only hear back from us when we accept your article.' - : 'Article successfully created!' - ); + $this->maybeFlashSuccessMessage($article, $request); return $request->wantsJson() ? ArticleResource::make($article) @@ -176,4 +172,15 @@ public function delete(Request $request, Article $article) ? response()->json([], Response::HTTP_NO_CONTENT) : redirect()->route('articles'); } + + private function maybeFlashSuccessMessage(Article $article, ArticleRequest $request): void + { + if ($article->isNotApproved()) { + $this->success( + $request->shouldBeSubmitted() + ? 'Thank you for submitting, unfortunately we can\'t accept every submission. You\'ll only hear back from us when we accept your article.' + : 'Article successfully created!' + ); + } + } } diff --git a/app/Jobs/CreateArticle.php b/app/Jobs/CreateArticle.php index 840e7d0df..83bcf130a 100644 --- a/app/Jobs/CreateArticle.php +++ b/app/Jobs/CreateArticle.php @@ -50,6 +50,7 @@ public function handle(): void 'original_url' => $this->originalUrl, 'slug' => $this->title, 'submitted_at' => $this->shouldBeSubmitted ? now() : null, + 'approved_at' => $this->canBeAutoApproved() ? now() : null, ]); $article->authoredBy($this->author); $article->syncTags($this->tags); @@ -58,4 +59,9 @@ public function handle(): void event(new ArticleWasSubmittedForApproval($article)); } } + + private function canBeAutoApproved(): bool + { + return $this->shouldBeSubmitted && $this->author->verifiedAuthorCanPublishMoreToday(); + } } diff --git a/app/Jobs/UnVerifyAuthor.php b/app/Jobs/UnVerifyAuthor.php new file mode 100644 index 000000000..e511b806d --- /dev/null +++ b/app/Jobs/UnVerifyAuthor.php @@ -0,0 +1,28 @@ +user->unverifyAuthor(); + } +} diff --git a/app/Jobs/VerifyAuthor.php b/app/Jobs/VerifyAuthor.php new file mode 100644 index 000000000..22b39e25d --- /dev/null +++ b/app/Jobs/VerifyAuthor.php @@ -0,0 +1,28 @@ +user->verifyAuthor(); + } +} diff --git a/app/Models/Article.php b/app/Models/Article.php index ad6aad1da..5ecc1e71c 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -196,7 +196,7 @@ public function isShared(): bool public function isAwaitingApproval(): bool { - return $this->isSubmitted() && $this->isNotApproved() && $this->isNotDeclined(); + return $this->isSubmitted() && $this->isNotApproved() && $this->isNotDeclined() && ! $this->author()->verifiedAuthorCanPublishMoreToday(); } public function isNotAwaitingApproval(): bool diff --git a/app/Models/User.php b/app/Models/User.php index cda596775..2443b77a7 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -300,6 +300,54 @@ public function delete() parent::delete(); } + // === Verified Author === + + public function isVerifiedAuthor(): bool + { + return !is_null($this->verified_author_at); + } + + public function isNotVerifiedAuthor(): bool + { + return !$this->isVerifiedAuthor(); + } + + public function verifyAuthor(): void + { + $this->verified_author_at = now(); + $this->save(); + } + + + public function unverifyAuthor(): void + { + $this->verified_author_at = null; + $this->save(); + } + + /** + * Check if the verified author can publish more articles today. + * + * Verified authors are allowed to publish up to 2 articles per day, + * but will start count from the moment they are verified. + * + * @return bool True if under the daily limit, false otherwise + */ + + public function verifiedAuthorCanPublishMoreToday(): bool + { + $limit = 2; // Default limit for verified authors + if ($this->isNotVerifiedAuthor()) { + return false; + } + $publishedTodayCount = $this->articles() + ->whereDate('submitted_at', today()) + ->where('submitted_at', '>', $this->verified_author_at)->count(); // to ensure we only count articles published after verify the author + return $publishedTodayCount < $limit; + } + + // === End Verified Author === + public function countSolutions(): int { return $this->replyAble()->isSolution()->count(); diff --git a/app/Policies/UserPolicy.php b/app/Policies/UserPolicy.php index 519a218ec..16dd01049 100644 --- a/app/Policies/UserPolicy.php +++ b/app/Policies/UserPolicy.php @@ -14,6 +14,9 @@ final class UserPolicy const DELETE = 'delete'; + const VERIFY_AUTHOR = 'verifyAuthor'; + + public function admin(User $user): bool { return $user->isAdmin() || $user->isModerator(); @@ -25,6 +28,12 @@ public function ban(User $user, User $subject): bool ($user->isModerator() && ! $subject->isAdmin() && ! $subject->isModerator()); } + public function verifyAuthor(User $user, User $subject): bool + { + return ($user->isAdmin() && ! $subject->isAdmin()) || + ($user->isModerator() && ! $subject->isAdmin() && ! $subject->isModerator()); + } + public function block(User $user, User $subject): bool { return ! $user->is($subject) && ! $subject->isModerator() && ! $subject->isAdmin(); diff --git a/database/migrations/2025_06_14_222049_add_verified_author_at_to_users_table.php b/database/migrations/2025_06_14_222049_add_verified_author_at_to_users_table.php new file mode 100644 index 000000000..89c8cca7f --- /dev/null +++ b/database/migrations/2025_06_14_222049_add_verified_author_at_to_users_table.php @@ -0,0 +1,31 @@ +timestamp('verified_author_at') + ->nullable() + ->after('email_verified_at') + ->comment('Indicates if the user is a verified author'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('verified_author_at'); + }); + } +}; diff --git a/laravel b/laravel new file mode 100644 index 000000000..c4453842d Binary files /dev/null and b/laravel differ diff --git a/resources/views/admin/users.blade.php b/resources/views/admin/users.blade.php index 204d1f018..d7c14973c 100644 --- a/resources/views/admin/users.blade.php +++ b/resources/views/admin/users.blade.php @@ -90,6 +90,37 @@

All the threads from this user will be deleted. This cannot be undone.

@endcan + + {{-- Toggle Verified Author --}} + @can(App\Policies\UserPolicy::VERIFY_AUTHOR, $user) + @if ($user->isVerifiedAuthor()) + + +

This will remove the verified author status from this user.

+
+ @else + + +

This will mark this user as a verified author.

+
+ @endif + @endcan @endforeach diff --git a/routes/web.php b/routes/web.php index 3e141b0bb..3e01420bd 100644 --- a/routes/web.php +++ b/routes/web.php @@ -136,6 +136,8 @@ Route::get('users', [UsersController::class, 'index'])->name('.users'); Route::put('users/{username}/ban', [UsersController::class, 'ban'])->name('.users.ban'); Route::put('users/{username}/unban', [UsersController::class, 'unban'])->name('.users.unban'); + Route::put('users/{username}/verify-author', [UsersController::class, 'verifyAuthor'])->name('.users.verify-author'); + Route::put('users/{username}/unverify-author', [UsersController::class, 'unverifyAuthor'])->name('.users.unverify-author'); Route::delete('users/{username}', [UsersController::class, 'delete'])->name('.users.delete'); Route::delete('users/{username}/threads', [UsersController::class, 'deleteThreads'])->name('.users.threads.delete'); diff --git a/tests/CreatesUsers.php b/tests/CreatesUsers.php index cde560304..350844d52 100644 --- a/tests/CreatesUsers.php +++ b/tests/CreatesUsers.php @@ -40,4 +40,12 @@ protected function createUser(array $attributes = []): User 'github_username' => 'johndoe', ], $attributes)); } + + protected function createVerifiedAuthor(array $attributes = []): User + { + return $this->createUser(array_merge($attributes, [ + 'verified_author_at' => now(), + ])); + } + } diff --git a/tests/Feature/ArticleTest.php b/tests/Feature/ArticleTest.php index 78b331d37..f6fb48423 100644 --- a/tests/Feature/ArticleTest.php +++ b/tests/Feature/ArticleTest.php @@ -576,3 +576,32 @@ ->assertSee('My First Article') ->assertSee('10 views'); }); + +test('verified authors can publish two articles per day with no approval needed', function () { + $author = $this->createVerifiedAuthor(); + + Article::factory()->count(2)->create([ + 'author_id' => $author->id, + 'submitted_at' => now()->addMinutes(1), // after verification + ]); + + expect($author->verifiedAuthorCanPublishMoreToday())->toBeFalse(); +}); + +test('verified authors skip the approval message when submitting new article', function () { + + $author = $this->createVerifiedAuthor(); + $this->loginAs($author); + + $response = $this->post('/articles', [ + 'title' => 'Using database migrations', + 'body' => 'This article will go into depth on working with database migrations.', + 'tags' => [], + 'submitted' => '1', + ]); + + $response + ->assertRedirect('/articles/using-database-migrations') + ->assertSessionMissing('success'); + +});