diff --git a/app/Http/Controllers/UserProfileController.php b/app/Http/Controllers/UserProfileController.php index 5cc2931..dcb7b21 100644 --- a/app/Http/Controllers/UserProfileController.php +++ b/app/Http/Controllers/UserProfileController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Mail\UserAppreciationMail; use App\Models\BlogComment; use App\Models\BlogReaction; use App\Models\Node; @@ -9,6 +10,8 @@ use App\Models\Resource; use App\Models\ResourceCompletion; use App\Models\User; +use App\Models\UserAppreciation; +use Illuminate\Support\Facades\Mail; use Inertia\Inertia; class UserProfileController extends Controller @@ -40,7 +43,28 @@ public function show(string $username) $totalBlogLikes = BlogReaction::whereIn('blog_id', $user->blogs()->where('is_published', true)->select('id'))->count(); $sharedResourcesCount = Resource::where('user_id', $user->id)->count(); - // Recent Community Activities (Uploads, completed topics, comments made, reactions given, upvoted folders) + // Appreciations (Received & Given) + $appreciationsCount = $user->appreciationsReceived()->count(); + $appreciatingCount = $user->appreciationsGiven()->count(); + $isAppreciated = auth()->check() + ? $user->appreciationsReceived()->where('appreciator_id', auth()->id())->exists() + : false; + + $appreciators = $user->appreciators() + ->select(['users.id', 'users.name', 'users.username', 'users.image_path', 'users.institution']) + ->with('roles:id,name') + ->latest('user_appreciations.id') + ->take(50) + ->get(); + + $appreciating = $user->appreciatingUsers() + ->select(['users.id', 'users.name', 'users.username', 'users.image_path', 'users.institution']) + ->with('roles:id,name') + ->latest('user_appreciations.id') + ->take(50) + ->get(); + + // Recent Community Activities (Uploads, completed topics, comments made, reactions given, upvoted folders, appreciations given) $recentUploads = Resource::where('user_id', $user->id) ->with(['node:id,name,subject_id', 'node.subject:id,name']) ->latest() @@ -125,6 +149,21 @@ public function show(string $username) ->filter() ->values(); + $recentAppreciations = UserAppreciation::where('appreciator_id', $user->id) + ->with('user:id,name,username') + ->latest('id') + ->take(4) + ->get() + ->map(fn ($item) => [ + 'type' => 'appreciation', + 'title' => $item->user?->name, + 'username' => $item->user?->username, + 'url' => $item->user ? "/u/{$item->user->username}" : null, + 'created_at' => $item->created_at?->diffForHumans(), + ]) + ->filter(fn ($item) => $item['title'] !== null) + ->values(); + // Suggested / Discover community members: 2 contributors + 2 general users $contributorUsers = User::where('id', '!=', $user->id) ->whereNotNull('username') @@ -171,6 +210,11 @@ public function show(string $username) 'totalBlogViews' => (int) $totalBlogViews, 'sharedResourcesCount' => $sharedResourcesCount, ], + 'appreciationsCount' => $appreciationsCount, + 'appreciatingCount' => $appreciatingCount, + 'isAppreciated' => $isAppreciated, + 'appreciators' => $appreciators, + 'appreciating' => $appreciating, 'recentCompletions' => $recentCompletions, 'blogs' => $publishedBlogs, 'recentActivities' => [ @@ -179,11 +223,45 @@ public function show(string $username) 'reactions' => $recentReactions->values(), 'comments' => $recentComments->values(), 'upvotes' => $recentUpvotes->values(), + 'appreciations' => $recentAppreciations->values(), ], 'suggestedUsers' => $suggestedUsers, ]); } + public function toggleAppreciate(User $user) + { + $currentAuthUser = auth()->user(); + + // Cannot appreciate own profile + if ($currentAuthUser->id === $user->id) { + return back(); + } + + $existing = UserAppreciation::where('user_id', $user->id) + ->where('appreciator_id', $currentAuthUser->id) + ->first(); + + if ($existing) { + $existing->delete(); + } else { + UserAppreciation::create([ + 'user_id' => $user->id, + 'appreciator_id' => $currentAuthUser->id, + ]); + + $appreciationsCount = $user->appreciationsReceived()->count(); + $milestones = [1, 10, 25, 50, 100, 250, 500, 1000]; + $isMilestone = in_array($appreciationsCount, $milestones, true) || ($appreciationsCount > 1000 && $appreciationsCount % 500 === 0); + + if ($isMilestone && $user->email && $user->receive_emails !== false) { + Mail::to($user->email)->queue(UserAppreciationMail::forMilestone($user, $currentAuthUser, $appreciationsCount)); + } + } + + return back(); + } + private function buildNodeUrl(Node $node): ?string { if (! $node->subject) { diff --git a/app/Mail/UserAppreciationMail.php b/app/Mail/UserAppreciationMail.php new file mode 100644 index 0000000..42af4eb --- /dev/null +++ b/app/Mail/UserAppreciationMail.php @@ -0,0 +1,72 @@ +username}"; + $appreciatorName = htmlspecialchars($appreciator->name, ENT_QUOTES, 'UTF-8'); + $recipientName = htmlspecialchars($user->name, ENT_QUOTES, 'UTF-8'); + + if ($milestoneCount === 1) { + $mailSubject = 'Someone appreciated your profile on HSCStack! ❤️'; + $headline = 'Congratulations! You received your first community appreciation ❤️'; + $message = "

{$appreciatorName} just appreciated your profile and contributions on HSCStack.

" + .'

Your presence and contributions are helping fellow students in the community. Keep up the amazing work!

'; + } else { + $mailSubject = "🎉 Milestone: {$milestoneCount} people have appreciated your profile!"; + $headline = "🎉 Exciting News! {$milestoneCount} Appreciations Milestone reached!"; + $message = "

Your profile on HSCStack just reached {$milestoneCount} community appreciations, with the latest from {$appreciatorName}!

" + .'

Thank you for inspiring and supporting students and contributors across the platform.

'; + } + + $mailContent = "

{$headline}

" + .$message + .'

' + ."" + .'View Your Profile →' + .'' + .'

'; + + return new self($mailSubject, $mailContent, $user->name); + } + + public function envelope(): Envelope + { + return new Envelope( + subject: $this->mailSubject, + ); + } + + public function content(): Content + { + return new Content( + view: 'emails.bulk_announcement', + with: [ + 'mailSubject' => $this->mailSubject, + 'mailContent' => $this->mailContent, + 'recipientName' => $this->recipientName, + 'imageUrl' => null, + ], + ); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index ad49b93..bda86bd 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -113,4 +113,24 @@ public function nodes(): HasMany { return $this->hasMany(Node::class); } + + public function appreciationsReceived(): HasMany + { + return $this->hasMany(UserAppreciation::class, 'user_id'); + } + + public function appreciationsGiven(): HasMany + { + return $this->hasMany(UserAppreciation::class, 'appreciator_id'); + } + + public function appreciators() + { + return $this->belongsToMany(User::class, 'user_appreciations', 'user_id', 'appreciator_id'); + } + + public function appreciatingUsers() + { + return $this->belongsToMany(User::class, 'user_appreciations', 'appreciator_id', 'user_id'); + } } diff --git a/app/Models/UserAppreciation.php b/app/Models/UserAppreciation.php new file mode 100644 index 0000000..4d8d2db --- /dev/null +++ b/app/Models/UserAppreciation.php @@ -0,0 +1,24 @@ +belongsTo(User::class, 'user_id'); + } + + public function appreciator(): BelongsTo + { + return $this->belongsTo(User::class, 'appreciator_id'); + } +} diff --git a/database/migrations/2026_08_26_180000_create_user_appreciations_table.php b/database/migrations/2026_08_26_180000_create_user_appreciations_table.php new file mode 100644 index 0000000..ade8cd2 --- /dev/null +++ b/database/migrations/2026_08_26_180000_create_user_appreciations_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('appreciator_id')->constrained('users')->cascadeOnDelete(); + $table->unique(['user_id', 'appreciator_id']); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_appreciations'); + } +}; diff --git a/resources/js/pages/Node.vue b/resources/js/pages/Node.vue index 0e36da7..1e65708 100644 --- a/resources/js/pages/Node.vue +++ b/resources/js/pages/Node.vue @@ -119,6 +119,7 @@ const handleVote = (type: 'up' | 'down') => { if (previousVote === type) { // Toggle off localUserVote.value = null; + if (type === 'up') { localUpvotesCount.value = Math.max(0, localUpvotesCount.value - 1); localUpvoters.value = localUpvoters.value.filter( @@ -133,8 +134,10 @@ const handleVote = (type: 'up' | 'down') => { } else if (previousVote === null) { // New vote localUserVote.value = type; + if (type === 'up') { localUpvotesCount.value += 1; + if (currentUser.value) { localUpvoters.value = [ { @@ -157,12 +160,14 @@ const handleVote = (type: 'up' | 'down') => { } else { // Switching vote localUserVote.value = type; + if (type === 'up') { localUpvotesCount.value += 1; localDownvotesCount.value = Math.max( 0, localDownvotesCount.value - 1, ); + if (currentUser.value) { localUpvoters.value = [ { diff --git a/resources/js/pages/User/Show.vue b/resources/js/pages/User/Show.vue index 0ecffc0..530fde9 100644 --- a/resources/js/pages/User/Show.vue +++ b/resources/js/pages/User/Show.vue @@ -1,27 +1,29 @@ @@ -323,18 +409,60 @@ const totalActivitiesCount = computed( - +
Edit + +
@@ -346,9 +474,53 @@ const totalActivitiesCount = computed(

{{ profileUser.about }}

+ +
+ + + +
+
@@ -865,6 +1037,47 @@ const totalActivitiesCount = computed( {{ item.created_at }}
+ + +
+
+
+ +
+
+ Appreciated + + + {{ item.title }} + + + (@{{ item.username }}) + +
+
+ + {{ item.created_at }} + +
@@ -958,4 +1171,271 @@ const totalActivitiesCount = computed( + + + +
+
+ +
+ + +
+
+ +
+
+

+ Appreciated by +

+

+ {{ localAppreciationsCount }} community + {{ + localAppreciationsCount === 1 + ? 'member' + : 'members' + }} +

+
+
+ +
+ +
+
+ + {{ + person.name.charAt(0).toUpperCase() + }} +
+
+

+ {{ person.name }} +

+

+ {{ + person.institution || + '@' + person.username + }} +

+
+
+ + + {{ person.roles[0].name }} + + +
+ +
+ No appreciations yet. +
+
+
+
+ + + +
+
+ +
+ + +
+
+ +
+
+

+ Appreciating +

+

+ {{ appreciatingCount }} community + {{ appreciatingCount === 1 ? 'member' : 'members' }} +

+
+
+ +
+ +
+
+ + {{ + person.name.charAt(0).toUpperCase() + }} +
+
+

+ {{ person.name }} +

+

+ {{ + person.institution || + '@' + person.username + }} +

+
+
+ + + {{ person.roles[0].name }} + + +
+ +
+ Not appreciating any users yet. +
+
+
+
+ + + +
+
+ +
+ + +
+ +
+ +

+ Sign in to Appreciate +

+

+ You need to be logged in to send appreciation and support + fellow students and contributors. +

+ +
+ + + + Sign In + +
+
+
+
diff --git a/routes/web.php b/routes/web.php index 5ad9d12..f02e17d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -28,6 +28,7 @@ Route::delete('/blogs/comments/{comment}', [BlogController::class, 'destroyComment'])->name('blogs.comments.destroy'); Route::post('/resources/{resource}/complete', [ResourceController::class, 'toggleComplete'])->name('resources.complete'); Route::post('/nodes/{node}/vote', [NodeController::class, 'vote'])->name('nodes.vote'); + Route::post('/u/{user}/appreciate', [UserProfileController::class, 'toggleAppreciate'])->name('user.appreciate'); }); Route::prefix('admin') diff --git a/tests/Feature/NodeVoteTest.php b/tests/Feature/NodeVoteTest.php index 3631f00..8fdf378 100644 --- a/tests/Feature/NodeVoteTest.php +++ b/tests/Feature/NodeVoteTest.php @@ -1,9 +1,12 @@ $node->id, 'user_id' => $upvoter->id, 'type' => 'up']); NodeVote::create(['node_id' => $node->id, 'user_id' => $downvoter->id, 'type' => 'down']); - $response = $this->actingAs($upvoter)->get("/physics/chapter-1"); + $response = $this->actingAs($upvoter)->get('/physics/chapter-1'); $response->assertInertia(fn (Assert $page) => $page ->component('Node') @@ -342,7 +345,7 @@ ->where('nodes.1.upvotes_count', 0) ); - expect(Illuminate\Support\Facades\Cache::has("subject_page_{$subject->id}"))->toBeTrue(); + expect(Cache::has("subject_page_{$subject->id}"))->toBeTrue(); // 2. User votes on Folder B $this->actingAs($user) @@ -350,7 +353,7 @@ ->assertRedirect(); // Cache should have been invalidated - expect(Illuminate\Support\Facades\Cache::has("subject_page_{$subject->id}"))->toBeFalse(); + expect(Cache::has("subject_page_{$subject->id}"))->toBeFalse(); // 3. Next visit to subject page reflects Folder B with updated upvote count $this->get('/physics') @@ -383,7 +386,7 @@ ->where('nodes.1.upvotes_count', 0) ); - expect(Illuminate\Support\Facades\Cache::has("node_children_{$folderA->id}"))->toBeTrue(); + expect(Cache::has("node_children_{$folderA->id}"))->toBeTrue(); // Vote on Subfolder B $this->actingAs($user) @@ -391,7 +394,7 @@ ->assertRedirect(); // Cache should be cleared - expect(Illuminate\Support\Facades\Cache::has("node_children_{$folderA->id}"))->toBeFalse(); + expect(Cache::has("node_children_{$folderA->id}"))->toBeFalse(); // Next visit reflects new order and count $this->get("/physics/{$folderA->slug}") @@ -402,7 +405,7 @@ }); test('folder author receives milestone email notification when folder hits 1 upvote', function () { - Illuminate\Support\Facades\Mail::fake(); + Mail::fake(); $author = User::factory()->create([ 'name' => 'Author Rahim', @@ -435,7 +438,7 @@ ->post("/nodes/{$folder->id}/vote", ['type' => 'up']) ->assertRedirect(); - Illuminate\Support\Facades\Mail::assertQueued(App\Mail\NodeNotificationMail::class, function ($mail) use ($author, $folder) { + Mail::assertQueued(NodeNotificationMail::class, function ($mail) use ($author) { return $mail->hasTo($author->email) && str_contains($mail->mailSubject, 'first upvote') && str_contains($mail->mailContent, 'Vectors Complete Guide'); @@ -443,7 +446,7 @@ }); test('author upvoting their own folder does not trigger milestone email', function () { - Illuminate\Support\Facades\Mail::fake(); + Mail::fake(); $author = User::factory()->create([ 'name' => 'Self Voter', @@ -470,5 +473,5 @@ ->post("/nodes/{$folder->id}/vote", ['type' => 'up']) ->assertRedirect(); - Illuminate\Support\Facades\Mail::assertNothingQueued(); + Mail::assertNothingQueued(); }); diff --git a/tests/Feature/UserAppreciationTest.php b/tests/Feature/UserAppreciationTest.php new file mode 100644 index 0000000..901242b --- /dev/null +++ b/tests/Feature/UserAppreciationTest.php @@ -0,0 +1,147 @@ +create([ + 'username' => 'rahim', + ]); + + $this->post("/u/{$targetUser->id}/appreciate") + ->assertRedirect('/login'); + + expect(UserAppreciation::count())->toBe(0); +}); + +test('authenticated user can appreciate another user profile', function () { + $userA = User::factory()->create(['username' => 'alice']); + $userB = User::factory()->create(['username' => 'bob']); + + $this->actingAs($userA) + ->post("/u/{$userB->id}/appreciate") + ->assertRedirect(); + + expect(UserAppreciation::where('user_id', $userB->id)->where('appreciator_id', $userA->id)->exists())->toBeTrue(); + expect($userB->appreciationsReceived()->count())->toBe(1); + expect($userA->appreciationsGiven()->count())->toBe(1); +}); + +test('clicking appreciate again toggles it off', function () { + $userA = User::factory()->create(['username' => 'alice']); + $userB = User::factory()->create(['username' => 'bob']); + + UserAppreciation::create([ + 'user_id' => $userB->id, + 'appreciator_id' => $userA->id, + ]); + + $this->actingAs($userA) + ->post("/u/{$userB->id}/appreciate") + ->assertRedirect(); + + expect(UserAppreciation::where('user_id', $userB->id)->where('appreciator_id', $userA->id)->exists())->toBeFalse(); + expect($userB->appreciationsReceived()->count())->toBe(0); +}); + +test('users cannot appreciate their own profile', function () { + $user = User::factory()->create(['username' => 'alice']); + + $this->actingAs($user) + ->post("/u/{$user->id}/appreciate") + ->assertRedirect(); + + expect(UserAppreciation::count())->toBe(0); +}); + +test('user profile displays accurate appreciation counts and status', function () { + $profileUser = User::factory()->create(['username' => 'recipient']); + $fan1 = User::factory()->create(['username' => 'fan1']); + $fan2 = User::factory()->create(['username' => 'fan2']); + $idol = User::factory()->create(['username' => 'idol']); + + // Fan 1 & 2 appreciate profileUser + UserAppreciation::create(['user_id' => $profileUser->id, 'appreciator_id' => $fan1->id]); + UserAppreciation::create(['user_id' => $profileUser->id, 'appreciator_id' => $fan2->id]); + + // profileUser appreciates idol + UserAppreciation::create(['user_id' => $idol->id, 'appreciator_id' => $profileUser->id]); + + // Check as fan1 (should see isAppreciated = true) + $this->actingAs($fan1) + ->get("/u/{$profileUser->username}") + ->assertInertia(fn (Assert $page) => $page + ->component('User/Show') + ->where('appreciationsCount', 2) + ->where('appreciatingCount', 1) + ->where('isAppreciated', true) + ->has('appreciators', 2) + ->has('appreciating', 1) + ->has('recentActivities.appreciations', 1) + ); + + // Check as unauthenticated guest (isAppreciated = false) + auth()->logout(); + $this->get("/u/{$profileUser->username}") + ->assertInertia(fn (Assert $page) => $page + ->component('User/Show') + ->where('appreciationsCount', 2) + ->where('appreciatingCount', 1) + ->where('isAppreciated', false) + ); +}); + +test('recipient receives milestone email on 1st appreciation', function () { + Mail::fake(); + + $author = User::factory()->create([ + 'name' => 'Tarek Rahman', + 'username' => 'tarek', + 'email' => 'tarek@example.com', + 'receive_emails' => true, + ]); + + $fan = User::factory()->create([ + 'name' => 'Fahim Hasan', + 'username' => 'fahim', + 'email' => 'fahim@example.com', + ]); + + $this->actingAs($fan) + ->post("/u/{$author->id}/appreciate") + ->assertRedirect(); + + Mail::assertQueued(UserAppreciationMail::class, function ($mail) use ($author) { + return $mail->hasTo($author->email) && + str_contains($mail->mailSubject, 'appreciated your profile') && + str_contains($mail->mailContent, 'Fahim Hasan'); + }); +}); + +test('milestone email is not sent if recipient opted out of emails', function () { + Mail::fake(); + + $author = User::factory()->create([ + 'name' => 'Quiet Contributor', + 'username' => 'quiet', + 'email' => 'quiet@example.com', + 'receive_emails' => false, + ]); + + $fan = User::factory()->create([ + 'name' => 'Fahim Hasan', + 'username' => 'fahim', + ]); + + $this->actingAs($fan) + ->post("/u/{$author->id}/appreciate") + ->assertRedirect(); + + Mail::assertNothingQueued(); +}); diff --git a/tests/Feature/UserProfileTest.php b/tests/Feature/UserProfileTest.php index a0faa4f..7a82779 100644 --- a/tests/Feature/UserProfileTest.php +++ b/tests/Feature/UserProfileTest.php @@ -2,6 +2,7 @@ use App\Models\Blog; use App\Models\Node; +use App\Models\NodeVote; use App\Models\ResourceCompletion; use App\Models\Subject; use App\Models\User; @@ -146,7 +147,7 @@ 'slug' => 'diff-notes', ]); - \App\Models\NodeVote::create([ + NodeVote::create([ 'node_id' => $childFolder->id, 'user_id' => $user->id, 'type' => 'up',