From d34f029d8e1859dc9c2650271c5ffb94e938bd28 Mon Sep 17 00:00:00 2001 From: Abdulrahman Reda Date: Mon, 16 Jun 2025 02:10:19 +0300 Subject: [PATCH 1/8] Add verified author functionality - Add verified_author_at column to users table with migration - Add methods to check and manage verified author status in User model - Add admin interface to verify/unverify authors in UsersController - Add publishing limits for verified authors in ArticlesController - Add VerifyAuthor and UnVerifyAuthor jobs for queue processing - Add user policies for verified author management - Add admin view updates for verified author management - Add tests for verified author functionality --- .../Controllers/Admin/UsersController.php | 25 +++++++++ .../Articles/ArticlesController.php | 17 +++++-- app/Jobs/CreateArticle.php | 6 +++ app/Jobs/UnVerifyAuthor.php | 28 ++++++++++ app/Jobs/VerifyAuthor.php | 28 ++++++++++ app/Models/Article.php | 2 +- app/Models/User.php | 48 ++++++++++++++++++ app/Policies/UserPolicy.php | 9 ++++ ..._add_verified_author_at_to_users_table.php | 31 +++++++++++ laravel | Bin 0 -> 28672 bytes resources/views/admin/users.blade.php | 31 +++++++++++ routes/web.php | 2 + tests/CreatesUsers.php | 8 +++ tests/Feature/ArticleTest.php | 29 +++++++++++ 14 files changed, 258 insertions(+), 6 deletions(-) create mode 100644 app/Jobs/UnVerifyAuthor.php create mode 100644 app/Jobs/VerifyAuthor.php create mode 100644 database/migrations/2025_06_14_222049_add_verified_author_at_to_users_table.php create mode 100644 laravel 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..9efb3b88f 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($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(ArticleRequest $request): void + { + if (! $request->author()->verifiedAuthorCanPublishMoreToday()) { + $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 0000000000000000000000000000000000000000..c4453842dd285843521d2e202b057be1a6d014ad GIT binary patch literal 28672 zcmeI&!A{#S7zc2>lw_M)iZr2K-E`8ZUGU zuG!L3G&^=n3r%ca zY<=HfWy4X}Kt$~`5#?)o;?fU==}*gxu@IrP?Dlj#8Y$ObBx4y3WQPl#)th0&Kq%jy zfByUYXy(*}9uLc^8&{v^+-ki}zq_dcKNh-uCM`v}Tf8BX)>usoGbPDDbmi0v9ctlo zE1jj0osY? zD4CyOcr48lHfKF?I5##=6P3$$LRL*=UV?BAJigd#N z>q^MNr>=)&z1>_2jj~C;Q29wJzs(y81Rwwb2tWV=5P$##AOHafKmY>wDe$yVBnJ&-2`zQdb4d}Dp<#r8J8eteQVrs2tRhBaCwVzS}p^IXI1K}gcx-v86eA7Wl85P$## zAOHafKmY;|fB*y_009Whh`<7!tI_PQ0&&6K|C>Mkqd))x5P$##AOHafKmY;|fB*y_ zFv9|mY;L&!pWzusY7l?`1Rwwb2tWV=5P$##AOHbE@cTbj0R$ib0SG_<0uX=z1Rwwb j2tZ)=1@Qa-?9VYWga8B}009U<00Izz00bZa0SNp9m9>YJ literal 0 HcmV?d00001 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'); + +}); From 4a3e3cee33b474e5ffaa16912201469bbf9fb6c2 Mon Sep 17 00:00:00 2001 From: Abdulrahman Reda Date: Mon, 16 Jun 2025 04:19:38 +0300 Subject: [PATCH 2/8] fix maybeFlashSuccessMessage --- app/Http/Controllers/Articles/ArticlesController.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/Http/Controllers/Articles/ArticlesController.php b/app/Http/Controllers/Articles/ArticlesController.php index 9efb3b88f..7f4bdffcf 100644 --- a/app/Http/Controllers/Articles/ArticlesController.php +++ b/app/Http/Controllers/Articles/ArticlesController.php @@ -115,7 +115,7 @@ public function store(ArticleRequest $request) $article = Article::findByUuidOrFail($uuid); - $this->maybeFlashSuccessMessage($request); + $this->maybeFlashSuccessMessage($article, $request); return $request->wantsJson() ? ArticleResource::make($article) @@ -173,9 +173,9 @@ public function delete(Request $request, Article $article) : redirect()->route('articles'); } - private function maybeFlashSuccessMessage(ArticleRequest $request): void + private function maybeFlashSuccessMessage(Article $article, ArticleRequest $request): void { - if (! $request->author()->verifiedAuthorCanPublishMoreToday()) { + 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.' From 7901557e770bc638cfcdce6e479e9ff19f3b8431 Mon Sep 17 00:00:00 2001 From: Dries Vints Date: Fri, 27 Jun 2025 13:10:22 +0200 Subject: [PATCH 3/8] wip --- app/Console/Commands/SyncArticleImages.php | 42 +----------- .../Controllers/Admin/UsersController.php | 5 +- app/Http/Requests/ArticleRequest.php | 7 ++ app/Jobs/CreateArticle.php | 7 ++ app/Jobs/SyncArticleImage.php | 60 ++++++++++++++++++ app/Jobs/UpdateArticle.php | 9 +++ app/Models/User.php | 18 +++--- app/Policies/UserPolicy.php | 16 ++--- ..._add_verified_author_at_to_users_table.php | 18 +----- database/seeders/UserSeeder.php | 1 + laravel | Bin 28672 -> 0 bytes resources/views/components/a.blade.php | 1 + .../views/components/articles/form.blade.php | 26 +++++++- .../views/components/rules-banner.blade.php | 4 +- .../views/components/threads/form.blade.php | 3 +- tests/CreatesUsers.php | 2 +- tests/Feature/ArticleTest.php | 19 +++++- tests/Integration/Jobs/CreateArticleTest.php | 4 +- 18 files changed, 152 insertions(+), 90 deletions(-) create mode 100644 app/Jobs/SyncArticleImage.php delete mode 100644 laravel create mode 100644 resources/views/components/a.blade.php diff --git a/app/Console/Commands/SyncArticleImages.php b/app/Console/Commands/SyncArticleImages.php index 740ab53aa..7206a337a 100644 --- a/app/Console/Commands/SyncArticleImages.php +++ b/app/Console/Commands/SyncArticleImages.php @@ -2,9 +2,9 @@ namespace App\Console\Commands; +use App\Jobs\SyncArticleImage; use App\Models\Article; use Illuminate\Console\Command; -use Illuminate\Support\Facades\Http; final class SyncArticleImages extends Command { @@ -22,46 +22,8 @@ public function handle(): void Article::unsyncedImages()->chunk(100, function ($articles) { $articles->each(function ($article) { - $imageData = $this->fetchUnsplashImageDataFromId($article); - - if (! is_null($imageData)) { - $article->hero_image_url = $imageData['image_url']; - $article->hero_image_author_name = $imageData['author_name']; - $article->hero_image_author_url = $imageData['author_url']; - $article->save(); - } else { - $this->warn("Failed to fetch image data for image {$article->hero_image_id}"); - } + SyncArticleImage::dispatch($article); }); }); } - - protected function fetchUnsplashImageDataFromId(Article $article): ?array - { - $response = Http::retry(3, 100, throw: false) - ->withToken(config('services.unsplash.access_key'), 'Client-ID') - ->get("https://api.unsplash.com/photos/{$article->hero_image_id}"); - - if ($response->failed()) { - $article->hero_image_id = null; - $article->save(); - - $this->warn("Failed to fetch image data for image {$article->hero_image_id}"); - - return null; - } - - $response = $response->json(); - - // Trigger as Unsplash download... - Http::retry(3, 100, throw: false) - ->withToken(config('services.unsplash.access_key'), 'Client-ID') - ->get($response['links']['download_location']); - - return [ - 'image_url' => $response['urls']['raw'], - 'author_name' => $response['user']['name'], - 'author_url' => $response['user']['links']['html'], - ]; - } } diff --git a/app/Http/Controllers/Admin/UsersController.php b/app/Http/Controllers/Admin/UsersController.php index a4d9207d3..130d44e93 100644 --- a/app/Http/Controllers/Admin/UsersController.php +++ b/app/Http/Controllers/Admin/UsersController.php @@ -64,7 +64,7 @@ public function unban(User $user): RedirectResponse public function verifyAuthor(User $user) { - $this->authorize(UserPolicy::VERIFY_AUTHOR, $user); + $this->authorize(UserPolicy::ADMIN, $user); $this->dispatchSync(new VerifyAuthor($user)); @@ -74,9 +74,8 @@ public function verifyAuthor(User $user) } public function unverifyAuthor(User $user) - { - $this->authorize(UserPolicy::VERIFY_AUTHOR, $user); + $this->authorize(UserPolicy::ADMIN, $user); $this->dispatchSync(new UnverifyAuthor($user)); diff --git a/app/Http/Requests/ArticleRequest.php b/app/Http/Requests/ArticleRequest.php index db953e8c9..0c2e5d470 100644 --- a/app/Http/Requests/ArticleRequest.php +++ b/app/Http/Requests/ArticleRequest.php @@ -5,6 +5,7 @@ use App\Models\User; use App\Rules\HttpImageRule; use Illuminate\Http\Concerns\InteractsWithInput; +use Illuminate\Validation\Rules\RequiredIf; class ArticleRequest extends Request { @@ -14,6 +15,7 @@ public function rules(): array { return [ 'title' => ['required', 'max:100'], + 'hero_image_id' => ['nullable', new RequiredIf(auth()->user()->isVerifiedAuthor())], 'body' => ['required', new HttpImageRule], 'tags' => 'array|nullable', 'tags.*' => 'exists:tags,id', @@ -58,4 +60,9 @@ public function shouldBeSubmitted(): bool { return $this->boolean('submitted'); } + + public function heroImageId(): ?string + { + return $this->get('hero_image_id'); + } } diff --git a/app/Jobs/CreateArticle.php b/app/Jobs/CreateArticle.php index 83bcf130a..e2bdf9a2f 100644 --- a/app/Jobs/CreateArticle.php +++ b/app/Jobs/CreateArticle.php @@ -20,6 +20,7 @@ public function __construct( private string $body, private User $author, private bool $shouldBeSubmitted, + private ?string $heroImageId = null, array $options = [] ) { $this->originalUrl = $options['original_url'] ?? null; @@ -34,6 +35,7 @@ public static function fromRequest(ArticleRequest $request, UuidInterface $uuid) $request->body(), $request->author(), $request->shouldBeSubmitted(), + $request->heroImageId(), [ 'original_url' => $request->originalUrl(), 'tags' => $request->tags(), @@ -46,6 +48,7 @@ public function handle(): void $article = new Article([ 'uuid' => $this->uuid->toString(), 'title' => $this->title, + 'hero_image_id' => $this->heroImageId, 'body' => $this->body, 'original_url' => $this->originalUrl, 'slug' => $this->title, @@ -55,6 +58,10 @@ public function handle(): void $article->authoredBy($this->author); $article->syncTags($this->tags); + if ($article->hero_image_id) { + SyncArticleImage::dispatch($article); + } + if ($article->isAwaitingApproval()) { event(new ArticleWasSubmittedForApproval($article)); } diff --git a/app/Jobs/SyncArticleImage.php b/app/Jobs/SyncArticleImage.php new file mode 100644 index 000000000..f41d9f7b5 --- /dev/null +++ b/app/Jobs/SyncArticleImage.php @@ -0,0 +1,60 @@ +fetchUnsplashImageDataFromId($this->article); + + if (! is_null($imageData)) { + $this->article->hero_image_url = $imageData['image_url']; + $this->article->hero_image_author_name = $imageData['author_name']; + $this->article->hero_image_author_url = $imageData['author_url']; + $this->article->save(); + } + } + + protected function fetchUnsplashImageDataFromId(Article $article): ?array + { + $response = Http::retry(3, 100, throw: false) + ->withToken(config('services.unsplash.access_key'), 'Client-ID') + ->get("https://api.unsplash.com/photos/{$article->hero_image_id}"); + + if ($response->failed()) { + $article->hero_image_id = null; + $article->save(); + + return null; + } + + $response = $response->json(); + + // Trigger as Unsplash download... + Http::retry(3, 100, throw: false) + ->withToken(config('services.unsplash.access_key'), 'Client-ID') + ->get($response['links']['download_location']); + + return [ + 'image_url' => $response['urls']['raw'], + 'author_name' => $response['user']['name'], + 'author_url' => $response['user']['links']['html'], + ]; + } +} \ No newline at end of file diff --git a/app/Jobs/UpdateArticle.php b/app/Jobs/UpdateArticle.php index 7a62c245d..17b12bc44 100644 --- a/app/Jobs/UpdateArticle.php +++ b/app/Jobs/UpdateArticle.php @@ -17,6 +17,7 @@ public function __construct( private string $title, private string $body, private bool $shouldBeSubmitted, + private ?string $heroImageId = null, array $options = [] ) { $this->originalUrl = $options['original_url'] ?? null; @@ -30,6 +31,7 @@ public static function fromRequest(Article $article, ArticleRequest $request): s $request->title(), $request->body(), $request->shouldBeSubmitted(), + $request->heroImageId(), [ 'original_url' => $request->originalUrl(), 'tags' => $request->tags(), @@ -39,9 +41,12 @@ public static function fromRequest(Article $article, ArticleRequest $request): s public function handle(): void { + $originalImage = $this->article->hero_image_id; + $this->article->update([ 'title' => $this->title, 'body' => $this->body, + 'hero_image_id' => $this->heroImageId, 'original_url' => $this->originalUrl, 'slug' => $this->title, ]); @@ -54,6 +59,10 @@ public function handle(): void } $this->article->syncTags($this->tags); + + if ($this->article->hero_image_id !== $originalImage) { + SyncArticleImage::dispatch($this->article); + } } private function shouldUpdateSubmittedAt(): bool diff --git a/app/Models/User.php b/app/Models/User.php index 2443b77a7..2d44625ff 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -151,6 +151,11 @@ public function type(): int return (int) $this->type; } + public function isRegularUser(): bool + { + return $this->type() === self::DEFAULT; + } + public function isModerator(): bool { return $this->type() === self::MODERATOR; @@ -300,11 +305,9 @@ public function delete() parent::delete(); } - // === Verified Author === - public function isVerifiedAuthor(): bool { - return !is_null($this->verified_author_at); + return ! is_null($this->author_verified_at); } public function isNotVerifiedAuthor(): bool @@ -314,14 +317,14 @@ public function isNotVerifiedAuthor(): bool public function verifyAuthor(): void { - $this->verified_author_at = now(); + $this->author_verified_at = now(); $this->save(); } public function unverifyAuthor(): void { - $this->verified_author_at = null; + $this->author_verified_at = null; $this->save(); } @@ -333,7 +336,6 @@ public function unverifyAuthor(): void * * @return bool True if under the daily limit, false otherwise */ - public function verifiedAuthorCanPublishMoreToday(): bool { $limit = 2; // Default limit for verified authors @@ -342,12 +344,10 @@ public function verifiedAuthorCanPublishMoreToday(): bool } $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 + ->where('submitted_at', '>', $this->author_verified_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 16dd01049..d01f52908 100644 --- a/app/Policies/UserPolicy.php +++ b/app/Policies/UserPolicy.php @@ -14,9 +14,6 @@ final class UserPolicy const DELETE = 'delete'; - const VERIFY_AUTHOR = 'verifyAuthor'; - - public function admin(User $user): bool { return $user->isAdmin() || $user->isModerator(); @@ -24,19 +21,16 @@ public function admin(User $user): bool public function ban(User $user, User $subject): bool { - return ($user->isAdmin() && ! $subject->isAdmin()) || - ($user->isModerator() && ! $subject->isAdmin() && ! $subject->isModerator()); - } + if ($subject->isAdmin()) { + return false; + } - public function verifyAuthor(User $user, User $subject): bool - { - return ($user->isAdmin() && ! $subject->isAdmin()) || - ($user->isModerator() && ! $subject->isAdmin() && ! $subject->isModerator()); + return $user->isAdmin() || ($user->isModerator() && ! $subject->isModerator()); } public function block(User $user, User $subject): bool { - return ! $user->is($subject) && ! $subject->isModerator() && ! $subject->isAdmin(); + return ! $user->is($subject) && $subject->isRegularUser(); } public function delete(User $user, User $subject): bool 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 index 89c8cca7f..f36d1c48d 100644 --- 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 @@ -6,26 +6,12 @@ return new class extends Migration { - /** - * Run the migrations. - */ public function up(): void { Schema::table('users', function (Blueprint $table) { - $table->timestamp('verified_author_at') + $table->timestamp('author_verified_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'); + ->after('email_verified_at'); }); } }; diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php index 5c3fe18a4..66beb368b 100644 --- a/database/seeders/UserSeeder.php +++ b/database/seeders/UserSeeder.php @@ -20,6 +20,7 @@ public function run(): void 'github_username' => 'driesvints', 'password' => bcrypt('password'), 'type' => User::ADMIN, + 'author_verified_at' => now(), ]); User::factory()->createQuietly([ diff --git a/laravel b/laravel deleted file mode 100644 index c4453842dd285843521d2e202b057be1a6d014ad..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 28672 zcmeI&!A{#S7zc2>lw_M)iZr2K-E`8ZUGU zuG!L3G&^=n3r%ca zY<=HfWy4X}Kt$~`5#?)o;?fU==}*gxu@IrP?Dlj#8Y$ObBx4y3WQPl#)th0&Kq%jy zfByUYXy(*}9uLc^8&{v^+-ki}zq_dcKNh-uCM`v}Tf8BX)>usoGbPDDbmi0v9ctlo zE1jj0osY? zD4CyOcr48lHfKF?I5##=6P3$$LRL*=UV?BAJigd#N z>q^MNr>=)&z1>_2jj~C;Q29wJzs(y81Rwwb2tWV=5P$##AOHafKmY>wDe$yVBnJ&-2`zQdb4d}Dp<#r8J8eteQVrs2tRhBaCwVzS}p^IXI1K}gcx-v86eA7Wl85P$## zAOHafKmY;|fB*y_009Whh`<7!tI_PQ0&&6K|C>Mkqd))x5P$##AOHafKmY;|fB*y_ zFv9|mY;L&!pWzusY7l?`1Rwwb2tWV=5P$##AOHbE@cTbj0R$ib0SG_<0uX=z1Rwwb j2tZ)=1@Qa-?9VYWga8B}009U<00Izz00bZa0SNp9m9>YJ diff --git a/resources/views/components/a.blade.php b/resources/views/components/a.blade.php new file mode 100644 index 000000000..a987a2896 --- /dev/null +++ b/resources/views/components/a.blade.php @@ -0,0 +1 @@ +{{ $slot }} \ No newline at end of file diff --git a/resources/views/components/articles/form.blade.php b/resources/views/components/articles/form.blade.php index 76ad1677d..aac694789 100644 --- a/resources/views/components/articles/form.blade.php +++ b/resources/views/components/articles/form.blade.php @@ -25,7 +25,7 @@ - Every article that gets approved will be shared with our 50.000 users and wil be tweeted out on our X (Twitter) account which has over 50,000 followers. Feel free to submit as many articles as you like. You can even cross-reference an article on your blog with the original url. + Every article that gets approved will be shared with our 50.000 users and wil be tweeted out on our X (Twitter) account which has over 50,000 followers as well as our Bluesky account. Feel free to submit as many articles as you like. You can even cross-reference an article on your blog with the original url. @@ -40,7 +40,7 @@
- + Maximum 100 characters. @@ -48,6 +48,28 @@
+
+
+ Hero Image + + + + @if (($article?->author() ?? auth()->user())->isVerifiedAuthor()) +

+ Because you're a verified author, you're required to choose an Unsplash image for your article. +

+ @else +

+ Optionally, add an Unsplash image. +

+ @endif + +

+ Please enter the Unsplash ID of the image you want to use. You can find the ID in the URL of the image on Unsplash. Please make sure to only use landscape images. For example, if the URL is https://unsplash.com/photos/...-NoiJZhDF4Es, then the ID is NoiJZhDF4Es. After saving your article, the image will be automatically fetched and displayed in the article. This might take a few minutes. If you want to change the image later, you can do so by editing the article before submitting it for approval. +

+
+
+
diff --git a/resources/views/components/rules-banner.blade.php b/resources/views/components/rules-banner.blade.php index 89ce60490..f7398d1b9 100644 --- a/resources/views/components/rules-banner.blade.php +++ b/resources/views/components/rules-banner.blade.php @@ -1,3 +1,3 @@ -

- Make sure you've read our rules before proceeding. +

+ Make sure you've read our rules before proceeding.

diff --git a/resources/views/components/threads/form.blade.php b/resources/views/components/threads/form.blade.php index 0185d412c..e8a29daff 100644 --- a/resources/views/components/threads/form.blade.php +++ b/resources/views/components/threads/form.blade.php @@ -21,11 +21,12 @@ Create a new thread @endif + Please search for your question before posting your thread by using the search box in the navigation bar.
- Want to share large code snippets? Share them through our pastebin. + Want to share large code snippets? Share them through our pastebin.
diff --git a/tests/CreatesUsers.php b/tests/CreatesUsers.php index 350844d52..4655dd6b9 100644 --- a/tests/CreatesUsers.php +++ b/tests/CreatesUsers.php @@ -44,7 +44,7 @@ protected function createUser(array $attributes = []): User protected function createVerifiedAuthor(array $attributes = []): User { return $this->createUser(array_merge($attributes, [ - 'verified_author_at' => now(), + 'author_verified_at' => now(), ])); } diff --git a/tests/Feature/ArticleTest.php b/tests/Feature/ArticleTest.php index f6fb48423..a32e565fb 100644 --- a/tests/Feature/ArticleTest.php +++ b/tests/Feature/ArticleTest.php @@ -1,11 +1,13 @@ create(['title' => 'My First Article', 'slug' => 'my-first-article', 'submitted_at' => now(), 'approved_at' => now(), 'view_count' => 9]); + $article = Article::factory()->create([ + 'title' => 'My First Article', + 'slug' => 'my-first-article', + 'submitted_at' => now(), + 'approved_at' => now(), + 'view_count' => 9, + ]); $this->get("/articles/{$article->slug()}") ->assertSee('My First Article') @@ -589,19 +597,24 @@ }); test('verified authors skip the approval message when submitting new article', function () { + Bus::fake(SyncArticleImage::class); $author = $this->createVerifiedAuthor(); $this->loginAs($author); $response = $this->post('/articles', [ 'title' => 'Using database migrations', + 'hero_image_id' => 'NoiJZhDF4Es', 'body' => 'This article will go into depth on working with database migrations.', 'tags' => [], 'submitted' => '1', ]); $response - ->assertRedirect('/articles/using-database-migrations') - ->assertSessionMissing('success'); + ->assertRedirect('/articles/using-database-migrations') + ->assertSessionMissing('success'); + Bus::assertDispatched(SyncArticleImage::class, function (SyncArticleImage $job) { + return $job->article->hero_image_id === 'NoiJZhDF4Es'; + }); }); diff --git a/tests/Integration/Jobs/CreateArticleTest.php b/tests/Integration/Jobs/CreateArticleTest.php index 01e483a1a..8ff6ee29b 100644 --- a/tests/Integration/Jobs/CreateArticleTest.php +++ b/tests/Integration/Jobs/CreateArticleTest.php @@ -15,7 +15,7 @@ $uuid = Str::uuid(); - $this->dispatch(new CreateArticle($uuid, 'Title', 'Body', $user, false, [ + $this->dispatch(new CreateArticle($uuid, 'Title', 'Body', $user, false, null, [ 'original_url' => 'https://laravel.io', ])); @@ -35,7 +35,7 @@ $uuid = Str::uuid(); - $this->dispatch(new CreateArticle($uuid, 'Title', 'Body', $user, true, [ + $this->dispatch(new CreateArticle($uuid, 'Title', 'Body', $user, true, null, [ 'original_url' => 'https://laravel.io', ])); From a3de49da0a49261114fc9dee8e4adbb9aefcaae7 Mon Sep 17 00:00:00 2001 From: Dries Vints Date: Fri, 27 Jun 2025 13:12:46 +0200 Subject: [PATCH 4/8] wip --- app/Console/Commands/SyncArticleImages.php | 29 ------------------- .../SyncArticleImageTest.php} | 6 ++-- 2 files changed, 3 insertions(+), 32 deletions(-) delete mode 100644 app/Console/Commands/SyncArticleImages.php rename tests/Integration/{Commands/SyncArticleImagesTest.php => Jobs/SyncArticleImageTest.php} (93%) diff --git a/app/Console/Commands/SyncArticleImages.php b/app/Console/Commands/SyncArticleImages.php deleted file mode 100644 index 7206a337a..000000000 --- a/app/Console/Commands/SyncArticleImages.php +++ /dev/null @@ -1,29 +0,0 @@ -error('Unsplash access key must be configured'); - - return; - } - - Article::unsyncedImages()->chunk(100, function ($articles) { - $articles->each(function ($article) { - SyncArticleImage::dispatch($article); - }); - }); - } -} diff --git a/tests/Integration/Commands/SyncArticleImagesTest.php b/tests/Integration/Jobs/SyncArticleImageTest.php similarity index 93% rename from tests/Integration/Commands/SyncArticleImagesTest.php rename to tests/Integration/Jobs/SyncArticleImageTest.php index 422994b7d..ba0e92c9b 100644 --- a/tests/Integration/Commands/SyncArticleImagesTest.php +++ b/tests/Integration/Jobs/SyncArticleImageTest.php @@ -1,6 +1,6 @@ now(), ]); - (new SyncArticleImages)->handle(); + SyncArticleImage::dispatchSync($article); $article->refresh(); @@ -53,7 +53,7 @@ 'approved_at' => now(), ]); - (new SyncArticleImages)->handle(); + SyncArticleImage::dispatchSync($article); $article->refresh(); From d858143a4af234bc6eb706775839fcd72e101c89 Mon Sep 17 00:00:00 2001 From: Dries Vints Date: Fri, 27 Jun 2025 13:12:57 +0200 Subject: [PATCH 5/8] wip --- routes/console.php | 1 - 1 file changed, 1 deletion(-) diff --git a/routes/console.php b/routes/console.php index 9c925fe1a..b45ac35de 100644 --- a/routes/console.php +++ b/routes/console.php @@ -8,5 +8,4 @@ Schedule::command('horizon:snapshot')->everyFiveMinutes(); Schedule::command('lio:post-article-to-social-media')->twiceDaily(14, 18); Schedule::command('lio:generate-sitemap')->daily()->graceTimeInMinutes(25); -Schedule::command('lio:sync-article-images')->cron('*/5 7-23 * * *'); Schedule::command('lio:update-article-view-counts')->twiceDaily(); From cf2b39dcdadf9e06e536aa072f4c040a2c0dbefd Mon Sep 17 00:00:00 2001 From: Dries Vints Date: Fri, 27 Jun 2025 13:21:18 +0200 Subject: [PATCH 6/8] wip --- resources/views/admin/users.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/admin/users.blade.php b/resources/views/admin/users.blade.php index d7c14973c..fa8a6c21c 100644 --- a/resources/views/admin/users.blade.php +++ b/resources/views/admin/users.blade.php @@ -92,7 +92,7 @@ @endcan {{-- Toggle Verified Author --}} - @can(App\Policies\UserPolicy::VERIFY_AUTHOR, $user) + @can(App\Policies\UserPolicy::ADMIN, $user) @if ($user->isVerifiedAuthor())
- - {{ $article->author()->username() }} ({{ $article->author()->name() }}) - + + + {{ $article->author()->username() }} ({{ $article->author()->name() }}) + + + @if ($article->author()->isVerifiedAuthor()) + + @svg('heroicon-s-check-badge', 'w-6 h-6 text-lio-500') + + @endif + {{ $article->author()->bio() }} diff --git a/tests/Feature/ArticleTest.php b/tests/Feature/ArticleTest.php index d449ead3b..a3dd06428 100644 --- a/tests/Feature/ArticleTest.php +++ b/tests/Feature/ArticleTest.php @@ -594,7 +594,7 @@ 'submitted_at' => now()->addMinutes(1), // after verification ]); - expect($author->verifiedAuthorCanPublishMoreToday())->toBeFalse(); + expect($author->canVerifiedAuthorPublishMoreArticleToday())->toBeFalse(); }); test('verified authors skip the approval message when submitting new article', function () {