From 7a99c3eb59ed02227d1c7caaea11d73b870c2c7e Mon Sep 17 00:00:00 2001 From: DoarMihai Date: Fri, 6 Feb 2026 15:13:20 +0200 Subject: [PATCH] engagement system --- app/Http/Controllers/BookmarkController.php | 43 ++ app/Http/Controllers/CommentController.php | 43 ++ app/Http/Controllers/ProductController.php | 2 +- app/Http/Controllers/UpvoteController.php | 47 ++ app/Models/Bookmark.php | 42 ++ app/Models/Comment.php | 36 ++ app/Models/Product.php | 64 +++ app/Models/Upvote.php | 42 ++ app/Models/User.php | 24 + ...2026_02_06_125733_create_upvotes_table.php | 32 ++ ...026_02_06_125740_create_comments_table.php | 33 ++ ...26_02_06_125741_create_bookmarks_table.php | 32 ++ resources/views/products/show.blade.php | 180 ++++++- routes/web.php | 13 + tests/Feature/EngagementTest.php | 448 ++++++++++++++++++ 15 files changed, 1079 insertions(+), 2 deletions(-) create mode 100644 app/Http/Controllers/BookmarkController.php create mode 100644 app/Http/Controllers/CommentController.php create mode 100644 app/Http/Controllers/UpvoteController.php create mode 100644 app/Models/Bookmark.php create mode 100644 app/Models/Comment.php create mode 100644 app/Models/Upvote.php create mode 100644 database/migrations/2026_02_06_125733_create_upvotes_table.php create mode 100644 database/migrations/2026_02_06_125740_create_comments_table.php create mode 100644 database/migrations/2026_02_06_125741_create_bookmarks_table.php create mode 100644 tests/Feature/EngagementTest.php diff --git a/app/Http/Controllers/BookmarkController.php b/app/Http/Controllers/BookmarkController.php new file mode 100644 index 0000000..6c09f01 --- /dev/null +++ b/app/Http/Controllers/BookmarkController.php @@ -0,0 +1,43 @@ +id) + ->where('product_id', $product->id) + ->first(); + + if ($existingBookmark) { + // Remove bookmark + $existingBookmark->delete(); + $bookmarked = false; + } else { + // Add bookmark + Bookmark::create([ + 'user_id' => $user->id, + 'product_id' => $product->id, + ]); + $bookmarked = true; + } + + return response()->json([ + 'success' => true, + 'bookmarked' => $bookmarked, + ]); + } +} diff --git a/app/Http/Controllers/CommentController.php b/app/Http/Controllers/CommentController.php new file mode 100644 index 0000000..3ccbbeb --- /dev/null +++ b/app/Http/Controllers/CommentController.php @@ -0,0 +1,43 @@ +validate([ + 'body' => 'required|string|max:1000', + ]); + + Comment::create([ + 'user_id' => Auth::id(), + 'product_id' => $product->id, + 'body' => $validated['body'], + ]); + + return back()->with('success', 'Comment posted successfully!'); + } + + /** + * Remove the specified comment. + */ + public function destroy(Comment $comment): RedirectResponse + { + Gate::authorize('delete-comment', $comment); + + $comment->delete(); + + return back()->with('success', 'Comment deleted successfully!'); + } +} diff --git a/app/Http/Controllers/ProductController.php b/app/Http/Controllers/ProductController.php index 9237317..3bfaefc 100644 --- a/app/Http/Controllers/ProductController.php +++ b/app/Http/Controllers/ProductController.php @@ -79,7 +79,7 @@ class ProductController extends Controller abort(404); } - $product->load(['user', 'category']); + $product->load(['user', 'category', 'comments.user', 'upvotes', 'bookmarks']); return view('products.show', compact('product')); } diff --git a/app/Http/Controllers/UpvoteController.php b/app/Http/Controllers/UpvoteController.php new file mode 100644 index 0000000..57aefcd --- /dev/null +++ b/app/Http/Controllers/UpvoteController.php @@ -0,0 +1,47 @@ +id) + ->where('product_id', $product->id) + ->first(); + + if ($existingUpvote) { + // Remove upvote + $existingUpvote->delete(); + $upvoted = false; + } else { + // Add upvote + Upvote::create([ + 'user_id' => $user->id, + 'product_id' => $product->id, + ]); + $upvoted = true; + } + + // Get fresh upvotes count + $upvotesCount = $product->upvotes()->count(); + + return response()->json([ + 'success' => true, + 'upvoted' => $upvoted, + 'upvotesCount' => $upvotesCount, + ]); + } +} diff --git a/app/Models/Bookmark.php b/app/Models/Bookmark.php new file mode 100644 index 0000000..7b4f797 --- /dev/null +++ b/app/Models/Bookmark.php @@ -0,0 +1,42 @@ + + */ + protected $fillable = [ + 'user_id', + 'product_id', + ]; + + /** + * Get the user that owns the bookmark. + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Get the product that owns the bookmark. + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } +} diff --git a/app/Models/Comment.php b/app/Models/Comment.php new file mode 100644 index 0000000..826be85 --- /dev/null +++ b/app/Models/Comment.php @@ -0,0 +1,36 @@ + + */ + protected $fillable = [ + 'user_id', + 'product_id', + 'body', + ]; + + /** + * Get the user that owns the comment. + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Get the product that owns the comment. + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php index cc1f8ca..8ba6b5d 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -197,4 +197,68 @@ class Product extends Model 'approved_at' => null, ]); } + + /** + * Get the upvotes for the product. + */ + public function upvotes() + { + return $this->hasMany(Upvote::class); + } + + /** + * Get the comments for the product. + */ + public function comments() + { + return $this->hasMany(Comment::class)->latest(); + } + + /** + * Get the bookmarks for the product. + */ + public function bookmarks() + { + return $this->hasMany(Bookmark::class); + } + + /** + * Check if a user has upvoted the product. + */ + public function isUpvotedBy(?User $user): bool + { + if (!$user) { + return false; + } + + return $this->upvotes()->where('user_id', $user->id)->exists(); + } + + /** + * Check if a user has bookmarked the product. + */ + public function isBookmarkedBy(?User $user): bool + { + if (!$user) { + return false; + } + + return $this->bookmarks()->where('user_id', $user->id)->exists(); + } + + /** + * Get the upvotes count for the product. + */ + public function getUpvotesCountAttribute(): int + { + return $this->upvotes()->count(); + } + + /** + * Get the comments count for the product. + */ + public function getCommentsCountAttribute(): int + { + return $this->comments()->count(); + } } diff --git a/app/Models/Upvote.php b/app/Models/Upvote.php new file mode 100644 index 0000000..040d36e --- /dev/null +++ b/app/Models/Upvote.php @@ -0,0 +1,42 @@ + + */ + protected $fillable = [ + 'user_id', + 'product_id', + ]; + + /** + * Get the user that owns the upvote. + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Get the product that owns the upvote. + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 834ef29..8774939 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -120,4 +120,28 @@ class User extends Authenticatable { return $this->hasMany(Product::class); } + + /** + * Get the upvotes for the user. + */ + public function upvotes(): HasMany + { + return $this->hasMany(Upvote::class); + } + + /** + * Get the comments for the user. + */ + public function comments(): HasMany + { + return $this->hasMany(Comment::class); + } + + /** + * Get the bookmarks for the user. + */ + public function bookmarks(): HasMany + { + return $this->hasMany(Bookmark::class); + } } diff --git a/database/migrations/2026_02_06_125733_create_upvotes_table.php b/database/migrations/2026_02_06_125733_create_upvotes_table.php new file mode 100644 index 0000000..104c284 --- /dev/null +++ b/database/migrations/2026_02_06_125733_create_upvotes_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->foreignId('product_id')->constrained()->onDelete('cascade'); + $table->timestamp('created_at')->useCurrent(); + + // Unique composite index to prevent duplicate upvotes + $table->unique(['user_id', 'product_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('upvotes'); + } +}; diff --git a/database/migrations/2026_02_06_125740_create_comments_table.php b/database/migrations/2026_02_06_125740_create_comments_table.php new file mode 100644 index 0000000..d664394 --- /dev/null +++ b/database/migrations/2026_02_06_125740_create_comments_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->foreignId('product_id')->constrained()->onDelete('cascade'); + $table->text('body'); + $table->timestamps(); + + $table->index('product_id'); + $table->index('user_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('comments'); + } +}; diff --git a/database/migrations/2026_02_06_125741_create_bookmarks_table.php b/database/migrations/2026_02_06_125741_create_bookmarks_table.php new file mode 100644 index 0000000..d81f77b --- /dev/null +++ b/database/migrations/2026_02_06_125741_create_bookmarks_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->foreignId('product_id')->constrained()->onDelete('cascade'); + $table->timestamp('created_at')->useCurrent(); + + // Unique composite index to prevent duplicate bookmarks + $table->unique(['user_id', 'product_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('bookmarks'); + } +}; diff --git a/resources/views/products/show.blade.php b/resources/views/products/show.blade.php index 76e5b83..e0a75e9 100644 --- a/resources/views/products/show.blade.php +++ b/resources/views/products/show.blade.php @@ -28,7 +28,31 @@ @endif

-
+
+ @auth + + + + + + @else +
+ + {{ $product->upvotes()->count() }} upvotes +
+ @endauth + @can('edit-product', $product) Edit @endcan @@ -84,6 +108,77 @@
+ +
+
+
Comments ({{ $product->comments()->count() }})
+
+
+ @auth + +
+ @csrf +
+ + @error('body') + + {{ $message }} + + @enderror +
+ +
+ @else +

+ Login to post a comment. +

+ @endauth + + + @forelse($product->comments as $comment) +
+
+
+ @if($comment->user->avatar_url) + {{ $comment->user->name }} + @else +
+ {{ strtoupper(substr($comment->user->name, 0, 1)) }} +
+ @endif +
+
+
+
+ {{ $comment->user->name }} + ยท {{ $comment->created_at->diffForHumans() }} +
+ @can('delete-comment', $comment) +
+ @csrf + @method('DELETE') + +
+ @endcan +
+

{{ $comment->body }}

+
+
+
+ @empty +

No comments yet. Be the first to comment!

+ @endforelse +
+
+ @can('approve-product') @if($product->isPending() || $product->isRejected()) @@ -111,4 +206,87 @@ + +@push('styles') + +@endpush + +@push('scripts') + +@endpush @endsection diff --git a/routes/web.php b/routes/web.php index e2930cb..6f2e471 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,7 +1,10 @@ group(function () { Route::get('/products/{product}/edit', [ProductController::class, 'edit'])->name('products.edit'); Route::put('/products/{product}', [ProductController::class, 'update'])->name('products.update'); Route::delete('/products/{product}', [ProductController::class, 'destroy'])->name('products.destroy'); + + // Upvotes + Route::post('/products/{product}/upvote', [UpvoteController::class, 'store'])->name('products.upvote'); + + // Comments + Route::post('/products/{product}/comments', [CommentController::class, 'store'])->name('products.comments.store'); + Route::delete('/comments/{comment}', [CommentController::class, 'destroy'])->name('comments.destroy'); + + // Bookmarks + Route::post('/products/{product}/bookmark', [BookmarkController::class, 'store'])->name('products.bookmark'); }); // Admin/Moderator Product Management Routes diff --git a/tests/Feature/EngagementTest.php b/tests/Feature/EngagementTest.php new file mode 100644 index 0000000..04041ee --- /dev/null +++ b/tests/Feature/EngagementTest.php @@ -0,0 +1,448 @@ +userRole = Role::create([ + 'name' => 'User', + 'slug' => 'user', + 'description' => 'Regular user', + ]); + + $this->moderatorRole = Role::create([ + 'name' => 'Moderator', + 'slug' => 'moderator', + 'description' => 'Moderator', + ]); + + $this->adminRole = Role::create([ + 'name' => 'Admin', + 'slug' => 'admin', + 'description' => 'Admin', + ]); + + // Create category + $this->category = Category::create([ + 'name' => 'Test Category', + 'slug' => 'test-category', + 'description' => 'Test description', + ]); +}); + +// ============ UPVOTE TESTS ============ + +test('authenticated users can upvote a product', function () { + $user = User::factory()->create(); + $user->roles()->attach($this->userRole); + + $owner = User::factory()->create(); + $owner->roles()->attach($this->userRole); + + $product = Product::create([ + 'title' => 'Test Product', + 'slug' => 'test-product', + 'description' => 'Test description', + 'website_url' => 'https://example.com', + 'user_id' => $owner->id, + 'category_id' => $this->category->id, + 'status' => 'approved', + ]); + + $response = $this->actingAs($user)->postJson(route('products.upvote', $product)); + + $response->assertStatus(200); + $response->assertJson([ + 'success' => true, + 'upvoted' => true, + 'upvotesCount' => 1, + ]); + + $this->assertDatabaseHas('upvotes', [ + 'user_id' => $user->id, + 'product_id' => $product->id, + ]); +}); + +test('authenticated users can remove their upvote', function () { + $user = User::factory()->create(); + $user->roles()->attach($this->userRole); + + $owner = User::factory()->create(); + $owner->roles()->attach($this->userRole); + + $product = Product::create([ + 'title' => 'Test Product', + 'slug' => 'test-product', + 'description' => 'Test description', + 'website_url' => 'https://example.com', + 'user_id' => $owner->id, + 'category_id' => $this->category->id, + 'status' => 'approved', + ]); + + // First upvote + Upvote::create([ + 'user_id' => $user->id, + 'product_id' => $product->id, + ]); + + // Remove upvote + $response = $this->actingAs($user)->postJson(route('products.upvote', $product)); + + $response->assertStatus(200); + $response->assertJson([ + 'success' => true, + 'upvoted' => false, + 'upvotesCount' => 0, + ]); + + $this->assertDatabaseMissing('upvotes', [ + 'user_id' => $user->id, + 'product_id' => $product->id, + ]); +}); + +test('guests cannot upvote products', function () { + $owner = User::factory()->create(); + $owner->roles()->attach($this->userRole); + + $product = Product::create([ + 'title' => 'Test Product', + 'slug' => 'test-product', + 'description' => 'Test description', + 'website_url' => 'https://example.com', + 'user_id' => $owner->id, + 'category_id' => $this->category->id, + 'status' => 'approved', + ]); + + $response = $this->postJson(route('products.upvote', $product)); + + $response->assertStatus(401); +}); + +test('users cannot upvote the same product twice', function () { + $user = User::factory()->create(); + $user->roles()->attach($this->userRole); + + $owner = User::factory()->create(); + $owner->roles()->attach($this->userRole); + + $product = Product::create([ + 'title' => 'Test Product', + 'slug' => 'test-product', + 'description' => 'Test description', + 'website_url' => 'https://example.com', + 'user_id' => $owner->id, + 'category_id' => $this->category->id, + 'status' => 'approved', + ]); + + // First upvote + $this->actingAs($user)->postJson(route('products.upvote', $product)); + + // Second upvote should toggle (remove) + $response = $this->actingAs($user)->postJson(route('products.upvote', $product)); + + $response->assertJson(['upvoted' => false]); + + expect(Upvote::where('user_id', $user->id)->where('product_id', $product->id)->count())->toBe(0); +}); + +// ============ COMMENT TESTS ============ + +test('authenticated users can comment on products', function () { + $user = User::factory()->create(); + $user->roles()->attach($this->userRole); + + $owner = User::factory()->create(); + $owner->roles()->attach($this->userRole); + + $product = Product::create([ + 'title' => 'Test Product', + 'slug' => 'test-product', + 'description' => 'Test description', + 'website_url' => 'https://example.com', + 'user_id' => $owner->id, + 'category_id' => $this->category->id, + 'status' => 'approved', + ]); + + $response = $this->actingAs($user)->post(route('products.comments.store', $product), [ + 'body' => 'This is a great product!', + ]); + + $response->assertRedirect(); + $response->assertSessionHas('success'); + + $this->assertDatabaseHas('comments', [ + 'user_id' => $user->id, + 'product_id' => $product->id, + 'body' => 'This is a great product!', + ]); +}); + +test('guests cannot comment on products', function () { + $owner = User::factory()->create(); + $owner->roles()->attach($this->userRole); + + $product = Product::create([ + 'title' => 'Test Product', + 'slug' => 'test-product', + 'description' => 'Test description', + 'website_url' => 'https://example.com', + 'user_id' => $owner->id, + 'category_id' => $this->category->id, + 'status' => 'approved', + ]); + + $response = $this->post(route('products.comments.store', $product), [ + 'body' => 'This is a great product!', + ]); + + $response->assertRedirect(route('login')); +}); + +test('comment body is required', function () { + $user = User::factory()->create(); + $user->roles()->attach($this->userRole); + + $owner = User::factory()->create(); + $owner->roles()->attach($this->userRole); + + $product = Product::create([ + 'title' => 'Test Product', + 'slug' => 'test-product', + 'description' => 'Test description', + 'website_url' => 'https://example.com', + 'user_id' => $owner->id, + 'category_id' => $this->category->id, + 'status' => 'approved', + ]); + + $response = $this->actingAs($user)->post(route('products.comments.store', $product), [ + 'body' => '', + ]); + + $response->assertSessionHasErrors('body'); +}); + +test('comment body cannot exceed 1000 characters', function () { + $user = User::factory()->create(); + $user->roles()->attach($this->userRole); + + $owner = User::factory()->create(); + $owner->roles()->attach($this->userRole); + + $product = Product::create([ + 'title' => 'Test Product', + 'slug' => 'test-product', + 'description' => 'Test description', + 'website_url' => 'https://example.com', + 'user_id' => $owner->id, + 'category_id' => $this->category->id, + 'status' => 'approved', + ]); + + $response = $this->actingAs($user)->post(route('products.comments.store', $product), [ + 'body' => str_repeat('a', 1001), + ]); + + $response->assertSessionHasErrors('body'); +}); + +test('comment owners can delete their comments', function () { + $user = User::factory()->create(); + $user->roles()->attach($this->userRole); + + $owner = User::factory()->create(); + $owner->roles()->attach($this->userRole); + + $product = Product::create([ + 'title' => 'Test Product', + 'slug' => 'test-product', + 'description' => 'Test description', + 'website_url' => 'https://example.com', + 'user_id' => $owner->id, + 'category_id' => $this->category->id, + 'status' => 'approved', + ]); + + $comment = Comment::create([ + 'user_id' => $user->id, + 'product_id' => $product->id, + 'body' => 'Test comment', + ]); + + $response = $this->actingAs($user)->delete(route('comments.destroy', $comment)); + + $response->assertRedirect(); + $response->assertSessionHas('success'); + + $this->assertDatabaseMissing('comments', ['id' => $comment->id]); +}); + +test('non-owners cannot delete comments', function () { + $user1 = User::factory()->create(); + $user1->roles()->attach($this->userRole); + + $user2 = User::factory()->create(); + $user2->roles()->attach($this->userRole); + + $owner = User::factory()->create(); + $owner->roles()->attach($this->userRole); + + $product = Product::create([ + 'title' => 'Test Product', + 'slug' => 'test-product', + 'description' => 'Test description', + 'website_url' => 'https://example.com', + 'user_id' => $owner->id, + 'category_id' => $this->category->id, + 'status' => 'approved', + ]); + + $comment = Comment::create([ + 'user_id' => $user1->id, + 'product_id' => $product->id, + 'body' => 'Test comment', + ]); + + $response = $this->actingAs($user2)->delete(route('comments.destroy', $comment)); + + $response->assertStatus(403); +}); + +test('moderators can delete any comment', function () { + $moderator = User::factory()->create(); + $moderator->roles()->attach($this->moderatorRole); + + $user = User::factory()->create(); + $user->roles()->attach($this->userRole); + + $owner = User::factory()->create(); + $owner->roles()->attach($this->userRole); + + $product = Product::create([ + 'title' => 'Test Product', + 'slug' => 'test-product', + 'description' => 'Test description', + 'website_url' => 'https://example.com', + 'user_id' => $owner->id, + 'category_id' => $this->category->id, + 'status' => 'approved', + ]); + + $comment = Comment::create([ + 'user_id' => $user->id, + 'product_id' => $product->id, + 'body' => 'Test comment', + ]); + + $response = $this->actingAs($moderator)->delete(route('comments.destroy', $comment)); + + $response->assertRedirect(); + $response->assertSessionHas('success'); + + $this->assertDatabaseMissing('comments', ['id' => $comment->id]); +}); + +// ============ BOOKMARK TESTS ============ + +test('authenticated users can bookmark a product', function () { + $user = User::factory()->create(); + $user->roles()->attach($this->userRole); + + $owner = User::factory()->create(); + $owner->roles()->attach($this->userRole); + + $product = Product::create([ + 'title' => 'Test Product', + 'slug' => 'test-product', + 'description' => 'Test description', + 'website_url' => 'https://example.com', + 'user_id' => $owner->id, + 'category_id' => $this->category->id, + 'status' => 'approved', + ]); + + $response = $this->actingAs($user)->postJson(route('products.bookmark', $product)); + + $response->assertStatus(200); + $response->assertJson([ + 'success' => true, + 'bookmarked' => true, + ]); + + $this->assertDatabaseHas('bookmarks', [ + 'user_id' => $user->id, + 'product_id' => $product->id, + ]); +}); + +test('authenticated users can remove their bookmark', function () { + $user = User::factory()->create(); + $user->roles()->attach($this->userRole); + + $owner = User::factory()->create(); + $owner->roles()->attach($this->userRole); + + $product = Product::create([ + 'title' => 'Test Product', + 'slug' => 'test-product', + 'description' => 'Test description', + 'website_url' => 'https://example.com', + 'user_id' => $owner->id, + 'category_id' => $this->category->id, + 'status' => 'approved', + ]); + + // First bookmark + Bookmark::create([ + 'user_id' => $user->id, + 'product_id' => $product->id, + ]); + + // Remove bookmark + $response = $this->actingAs($user)->postJson(route('products.bookmark', $product)); + + $response->assertStatus(200); + $response->assertJson([ + 'success' => true, + 'bookmarked' => false, + ]); + + $this->assertDatabaseMissing('bookmarks', [ + 'user_id' => $user->id, + 'product_id' => $product->id, + ]); +}); + +test('guests cannot bookmark products', function () { + $owner = User::factory()->create(); + $owner->roles()->attach($this->userRole); + + $product = Product::create([ + 'title' => 'Test Product', + 'slug' => 'test-product', + 'description' => 'Test description', + 'website_url' => 'https://example.com', + 'user_id' => $owner->id, + 'category_id' => $this->category->id, + 'status' => 'approved', + ]); + + $response = $this->postJson(route('products.bookmark', $product)); + + $response->assertStatus(401); +});