diff --git a/app/Http/Controllers/Admin/NodeController.php b/app/Http/Controllers/Admin/NodeController.php index ba864af..685fef1 100644 --- a/app/Http/Controllers/Admin/NodeController.php +++ b/app/Http/Controllers/Admin/NodeController.php @@ -110,6 +110,7 @@ public function store(StoreNodeRequest $request, Subject $subject) } Node::create([ + 'user_id' => auth()->id(), 'subject_id' => $subject->id, 'parent_id' => $parent?->id, 'name' => $validated['name'], diff --git a/app/Http/Controllers/NodeController.php b/app/Http/Controllers/NodeController.php index 2680a74..53b6c15 100644 --- a/app/Http/Controllers/NodeController.php +++ b/app/Http/Controllers/NodeController.php @@ -2,14 +2,16 @@ namespace App\Http\Controllers; +use App\Mail\NodeNotificationMail; use App\Models\Node; use App\Models\Subject; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Mail; use Inertia\Inertia; class NodeController extends Controller { - // public function show(Subject $subject, $path) { $slugs = explode('/', trim($path, '/')); @@ -18,7 +20,6 @@ public function show(Subject $subject, $path) $parent = null; foreach ($slugs as $slug) { - $query = Node::where('subject_id', $subject->id) ->where('slug', $slug); @@ -34,8 +35,9 @@ public function show(Subject $subject, $path) $nodes = Cache::remember("node_children_{$node->id}", now()->addDay(), function () use ($node) { return Node::where('parent_id', $node->id) + ->withCount(['children', 'resources', 'upvotes', 'downvotes']) + ->orderByRaw('(upvotes_count - downvotes_count) DESC') ->orderBy('sort_order') - ->withCount(['children', 'resources']) ->get(['id', 'name', 'slug'])->toArray(); }); @@ -43,16 +45,86 @@ public function show(Subject $subject, $path) return $node->resources()->get()->toArray(); }); + $upvotesCount = $node->upvotes()->count(); + $downvotesCount = $node->downvotes()->count(); + $userVote = auth()->check() + ? $node->votes()->where('user_id', auth()->id())->value('type') + : null; + + $upvoters = $node->upvotes() + ->with(['user:id,name,username,image_path,institution', 'user.roles:id,name']) + ->latest('id') + ->limit(50) + ->get() + ->pluck('user') + ->filter() + ->values(); + return Inertia::render('Node', [ 'subject' => $subject, + 'currentNode' => [ + 'id' => $node->id, + 'name' => $node->name, + 'slug' => $node->slug, + ], 'nodes' => $nodes, 'breadcrumb' => Cache::remember("node_breadcrumb_{$node->id}", now()->addDay(), function () use ($node) { return $this->buildBreadcrumb($node); }), 'resources' => $resources, + 'upvotesCount' => $upvotesCount, + 'downvotesCount' => $downvotesCount, + 'userVote' => $userVote, + 'upvoters' => $upvoters, ]); } + public function vote(Request $request, Node $node) + { + $validated = $request->validate([ + 'type' => ['required', 'in:up,down'], + ]); + + $user = auth()->user(); + $existing = $node->votes()->where('user_id', $user->id)->first(); + $isNewUpvote = false; + + if ($existing) { + if ($existing->type === $validated['type']) { + $existing->delete(); + } else { + $existing->update(['type' => $validated['type']]); + if ($validated['type'] === 'up') { + $isNewUpvote = true; + } + } + } else { + $node->votes()->create([ + 'user_id' => $user->id, + 'type' => $validated['type'], + ]); + if ($validated['type'] === 'up') { + $isNewUpvote = true; + } + } + + if ($isNewUpvote) { + $upvotesCount = $node->upvotes()->count(); + $milestones = [1, 5, 10, 25, 50, 100, 250, 500, 1000]; + $isMilestone = in_array($upvotesCount, $milestones, true) || ($upvotesCount > 1000 && $upvotesCount % 500 === 0); + + if ($isMilestone) { + $node->loadMissing('user:id,name,email,receive_emails', 'subject:id,name,slug', 'parent:id,name,slug'); + + if ($node->user && $node->user_id !== $user->id && $node->user->email && $node->user->receive_emails !== false) { + Mail::to($node->user->email)->queue(NodeNotificationMail::forUpvoteMilestone($node, $user, $upvotesCount)); + } + } + } + + return back(); + } + private function buildBreadcrumb($node) { $breadcrumb = []; diff --git a/app/Http/Controllers/SubjectController.php b/app/Http/Controllers/SubjectController.php index d398d6f..c1e5f51 100644 --- a/app/Http/Controllers/SubjectController.php +++ b/app/Http/Controllers/SubjectController.php @@ -48,16 +48,21 @@ public function show(Subject $subject) $nodes = Cache::rememberForever("subject_page_{$subject->id}", function () use ($subject) { return Node::where('subject_id', $subject->id) ->whereNull('parent_id') + ->withCount(['children', 'resources', 'upvotes', 'downvotes']) ->orderBy('sort_order') - ->withCount(['children', 'resources']) ->get(['id', 'name', 'slug'])->toArray(); }); return Inertia::render('Node', [ 'subject' => $subject, + 'currentNode' => null, 'nodes' => $nodes, 'breadcrumb' => [], 'resources' => [], + 'upvotesCount' => 0, + 'downvotesCount' => 0, + 'userVote' => null, + 'upvoters' => [], ]); } } diff --git a/app/Http/Controllers/UserProfileController.php b/app/Http/Controllers/UserProfileController.php index 987ca59..5cc2931 100644 --- a/app/Http/Controllers/UserProfileController.php +++ b/app/Http/Controllers/UserProfileController.php @@ -4,6 +4,8 @@ use App\Models\BlogComment; use App\Models\BlogReaction; +use App\Models\Node; +use App\Models\NodeVote; use App\Models\Resource; use App\Models\ResourceCompletion; use App\Models\User; @@ -38,7 +40,7 @@ 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) + // Recent Community Activities (Uploads, completed topics, comments made, reactions given, upvoted folders) $recentUploads = Resource::where('user_id', $user->id) ->with(['node:id,name,subject_id', 'node.subject:id,name']) ->latest() @@ -94,6 +96,35 @@ public function show(string $username) ]) ->filter(fn ($item) => $item['title'] !== null); + $recentUpvotes = NodeVote::where('user_id', $user->id) + ->where('type', 'up') + ->with([ + 'node.subject:id,name,slug', + 'node.parent:id,name,slug', + 'node.parent.parent:id,name,slug', + ]) + ->latest('id') + ->take(4) + ->get() + ->map(function ($item) { + $node = $item->node; + if (! $node) { + return null; + } + + $url = $this->buildNodeUrl($node); + + return [ + 'type' => 'upvote', + 'title' => $node->name, + 'subtitle' => $node->subject?->name.($node->parent ? ' 路 '.$node->parent->name : ''), + 'url' => $url, + 'created_at' => $item->created_at?->diffForHumans(), + ]; + }) + ->filter() + ->values(); + // Suggested / Discover community members: 2 contributors + 2 general users $contributorUsers = User::where('id', '!=', $user->id) ->whereNotNull('username') @@ -147,8 +178,25 @@ public function show(string $username) 'completions' => $recentCompleted->values(), 'reactions' => $recentReactions->values(), 'comments' => $recentComments->values(), + 'upvotes' => $recentUpvotes->values(), ], 'suggestedUsers' => $suggestedUsers, ]); } + + private function buildNodeUrl(Node $node): ?string + { + if (! $node->subject) { + return null; + } + + $slugs = []; + $curr = $node; + while ($curr) { + array_unshift($slugs, $curr->slug); + $curr = $curr->parent; + } + + return '/'.$node->subject->slug.'/'.implode('/', $slugs); + } } diff --git a/app/Mail/NodeNotificationMail.php b/app/Mail/NodeNotificationMail.php new file mode 100644 index 0000000..0f806be --- /dev/null +++ b/app/Mail/NodeNotificationMail.php @@ -0,0 +1,85 @@ +slug); + $curr = $curr->parent; + } + + $folderPath = $node->subject ? "{$node->subject->slug}/".implode('/', $slugs) : ''; + $folderUrl = rtrim($appUrl, '/').'/'.$folderPath; + + $upvoterName = htmlspecialchars($upvoter->name, ENT_QUOTES, 'UTF-8'); + $folderTitle = htmlspecialchars($node->name, ENT_QUOTES, 'UTF-8'); + $subjectName = $node->subject ? htmlspecialchars($node->subject->name, ENT_QUOTES, 'UTF-8') : ''; + $context = $subjectName ? "in {$subjectName}" : ''; + + if ($milestoneCount === 1) { + $mailSubject = 'Your folder received its first upvote! 馃殌'; + $headline = 'Congratulations! Your folder received its very first upvote 馃殌'; + $message = "

{$upvoterName} just upvoted your folder \"{$folderTitle}\"".($context ? " {$context}" : '').'.

' + .'

Students and contributors are finding your curated materials helpful! Keep sharing and organizing knowledge for the community.

'; + } else { + $mailSubject = "馃帀 Milestone: {$milestoneCount} people upvoted your folder!"; + $headline = "馃帀 Exciting News! {$milestoneCount} Upvotes Milestone reached!"; + $message = "

Your folder \"{$folderTitle}\" just hit {$milestoneCount} upvotes, with the latest from {$upvoterName}!

" + .'

Thank you for organizing study materials that help fellow students excel.

'; + } + + $mailContent = "

{$headline}

" + .$message + .'

' + ."" + .'View Folder →' + .'' + .'

'; + + return new self($mailSubject, $mailContent, $node->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/Node.php b/app/Models/Node.php index 82f2610..0c87cb5 100644 --- a/app/Models/Node.php +++ b/app/Models/Node.php @@ -10,6 +10,7 @@ class Node extends Model { protected $fillable = [ + 'user_id', 'subject_id', 'parent_id', 'name', @@ -22,9 +23,16 @@ protected function casts(): array return [ 'children_count' => 'integer', 'resources_count' => 'integer', + 'upvotes_count' => 'integer', + 'downvotes_count' => 'integer', ]; } + public function user() + { + return $this->belongsTo(User::class); + } + public function children() { return $this->hasMany(Node::class, 'parent_id'); @@ -44,4 +52,19 @@ public function resources() { return $this->hasMany(Resource::class); } + + public function votes() + { + return $this->hasMany(NodeVote::class); + } + + public function upvotes() + { + return $this->hasMany(NodeVote::class)->where('type', 'up'); + } + + public function downvotes() + { + return $this->hasMany(NodeVote::class)->where('type', 'down'); + } } diff --git a/app/Models/NodeVote.php b/app/Models/NodeVote.php new file mode 100644 index 0000000..a385a4e --- /dev/null +++ b/app/Models/NodeVote.php @@ -0,0 +1,28 @@ +belongsTo(Node::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 1f02793..ad49b93 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -103,4 +103,14 @@ public function completedResources() { return $this->belongsToMany(Resource::class, 'resource_completions'); } + + public function nodeVotes(): HasMany + { + return $this->hasMany(NodeVote::class); + } + + public function nodes(): HasMany + { + return $this->hasMany(Node::class); + } } diff --git a/app/Observers/NodeVoteObserver.php b/app/Observers/NodeVoteObserver.php new file mode 100644 index 0000000..843dfde --- /dev/null +++ b/app/Observers/NodeVoteObserver.php @@ -0,0 +1,38 @@ +clearVoteCache($nodeVote); + } + + public function updated(NodeVote $nodeVote): void + { + $this->clearVoteCache($nodeVote); + } + + public function deleted(NodeVote $nodeVote): void + { + $this->clearVoteCache($nodeVote); + } + + private function clearVoteCache(NodeVote $nodeVote): void + { + $node = $nodeVote->node; + if (! $node) { + return; + } + + if ($node->parent_id) { + Cache::forget("node_children_{$node->parent_id}"); + } else { + Cache::forget("subject_page_{$node->subject_id}"); + } + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8bf9bf6..cbe3ca1 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,12 +4,14 @@ use App\Models\Blog; use App\Models\Node; +use App\Models\NodeVote; use App\Models\Notice; use App\Models\Resource; use App\Models\Subject; use App\Models\User; use App\Observers\BlogObserver; use App\Observers\NodeObserver; +use App\Observers\NodeVoteObserver; use App\Observers\NoticeObserver; use App\Observers\ResourceObserver; use App\Observers\SubjectObserver; @@ -39,6 +41,7 @@ public function boot(): void Blog::observe(BlogObserver::class); Node::observe(NodeObserver::class); + NodeVote::observe(NodeVoteObserver::class); Notice::observe(NoticeObserver::class); Resource::observe(ResourceObserver::class); Subject::observe(SubjectObserver::class); diff --git a/database/migrations/2026_08_26_160000_create_node_votes_table.php b/database/migrations/2026_08_26_160000_create_node_votes_table.php new file mode 100644 index 0000000..c87e9e0 --- /dev/null +++ b/database/migrations/2026_08_26_160000_create_node_votes_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('node_id')->constrained('nodes')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->enum('type', ['up', 'down']); + $table->timestamps(); + + $table->unique(['node_id', 'user_id']); + $table->index(['node_id', 'type']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('node_votes'); + } +}; diff --git a/database/migrations/2026_08_26_170000_add_user_id_to_nodes_table.php b/database/migrations/2026_08_26_170000_add_user_id_to_nodes_table.php new file mode 100644 index 0000000..6578938 --- /dev/null +++ b/database/migrations/2026_08_26_170000_add_user_id_to_nodes_table.php @@ -0,0 +1,34 @@ +foreignId('user_id')->nullable()->after('parent_id')->constrained('users')->nullOnDelete(); + }); + + // Assign existing folders to user ID 1 if user 1 exists + if (DB::table('users')->where('id', 1)->exists()) { + DB::table('nodes')->whereNull('user_id')->update(['user_id' => 1]); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('nodes', function (Blueprint $table) { + $table->dropConstrainedForeignId('user_id'); + }); + } +}; diff --git a/resources/js/components/NodeRow.vue b/resources/js/components/NodeRow.vue index f2fefa4..cbcb1fd 100644 --- a/resources/js/components/NodeRow.vue +++ b/resources/js/components/NodeRow.vue @@ -1,10 +1,13 @@ diff --git a/resources/js/pages/User/Show.vue b/resources/js/pages/User/Show.vue index 0aaa472..0ecffc0 100644 --- a/resources/js/pages/User/Show.vue +++ b/resources/js/pages/User/Show.vue @@ -18,6 +18,7 @@ import { UploadCloud, Activity, ArrowUpRight, + ArrowBigUp, Users, } from 'lucide-vue-next'; import { computed, ref } from 'vue'; @@ -98,6 +99,13 @@ const props = defineProps<{ url: string | null; created_at: string; }>; + upvotes?: Array<{ + type: string; + title: string; + subtitle?: string; + url: string | null; + created_at: string; + }>; }; suggestedUsers?: Array<{ id: number; @@ -159,8 +167,9 @@ const roleInfo = computed(() => getRoleBadge(props.profileUser.roles)); const totalActivitiesCount = computed( () => (props.recentActivities.uploads?.length || 0) + - props.recentActivities.reactions.length + - props.recentActivities.comments.length, + (props.recentActivities.reactions?.length || 0) + + (props.recentActivities.comments?.length || 0) + + (props.recentActivities.upvotes?.length || 0), ); @@ -815,6 +824,47 @@ const totalActivitiesCount = computed( "{{ item.content }}"

+ + +
+
+
+ +
+
+ Upvoted folder + + + {{ item.title }} + + + ({{ item.subtitle }}) + +
+
+ + {{ item.created_at }} + +
diff --git a/routes/web.php b/routes/web.php index 4bfa768..5ad9d12 100644 --- a/routes/web.php +++ b/routes/web.php @@ -27,6 +27,7 @@ Route::post('/blogs/{blog}/comments', [BlogController::class, 'storeComment'])->name('blogs.comments.store'); 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::prefix('admin') diff --git a/tests/Feature/NodeVoteTest.php b/tests/Feature/NodeVoteTest.php new file mode 100644 index 0000000..3631f00 --- /dev/null +++ b/tests/Feature/NodeVoteTest.php @@ -0,0 +1,474 @@ + 'Physics', + 'slug' => 'physics', + 'course' => 'hsc', + 'tailwind_format' => 'bg-indigo-500', + 'icon' => 'atom', + ]); + $node = Node::create([ + 'subject_id' => $subject->id, + 'name' => 'Chapter 1', + 'slug' => 'chapter-1', + ]); + + $this->post("/nodes/{$node->id}/vote", ['type' => 'up']) + ->assertRedirect('/login'); + + $this->assertDatabaseMissing('node_votes', [ + 'node_id' => $node->id, + ]); +}); + +test('authenticated user can upvote a folder', function () { + $user = User::factory()->create(); + $subject = Subject::create([ + 'name' => 'Physics', + 'slug' => 'physics', + 'course' => 'hsc', + 'tailwind_format' => 'bg-indigo-500', + 'icon' => 'atom', + ]); + $node = Node::create([ + 'subject_id' => $subject->id, + 'name' => 'Chapter 1', + 'slug' => 'chapter-1', + ]); + + $this->actingAs($user) + ->post("/nodes/{$node->id}/vote", ['type' => 'up']) + ->assertRedirect(); + + $this->assertDatabaseHas('node_votes', [ + 'node_id' => $node->id, + 'user_id' => $user->id, + 'type' => 'up', + ]); +}); + +test('upvoting an already upvoted folder removes the vote (toggle off)', function () { + $user = User::factory()->create(); + $subject = Subject::create([ + 'name' => 'Physics', + 'slug' => 'physics', + 'course' => 'hsc', + 'tailwind_format' => 'bg-indigo-500', + 'icon' => 'atom', + ]); + $node = Node::create([ + 'subject_id' => $subject->id, + 'name' => 'Chapter 1', + 'slug' => 'chapter-1', + ]); + + NodeVote::create([ + 'node_id' => $node->id, + 'user_id' => $user->id, + 'type' => 'up', + ]); + + $this->actingAs($user) + ->post("/nodes/{$node->id}/vote", ['type' => 'up']) + ->assertRedirect(); + + $this->assertDatabaseMissing('node_votes', [ + 'node_id' => $node->id, + 'user_id' => $user->id, + ]); +}); + +test('authenticated user can downvote a folder and toggle it off', function () { + $user = User::factory()->create(); + $subject = Subject::create([ + 'name' => 'Physics', + 'slug' => 'physics', + 'course' => 'hsc', + 'tailwind_format' => 'bg-indigo-500', + 'icon' => 'atom', + ]); + $node = Node::create([ + 'subject_id' => $subject->id, + 'name' => 'Chapter 1', + 'slug' => 'chapter-1', + ]); + + // Downvote + $this->actingAs($user) + ->post("/nodes/{$node->id}/vote", ['type' => 'down']) + ->assertRedirect(); + + $this->assertDatabaseHas('node_votes', [ + 'node_id' => $node->id, + 'user_id' => $user->id, + 'type' => 'down', + ]); + + // Toggle off + $this->actingAs($user) + ->post("/nodes/{$node->id}/vote", ['type' => 'down']) + ->assertRedirect(); + + $this->assertDatabaseMissing('node_votes', [ + 'node_id' => $node->id, + 'user_id' => $user->id, + ]); +}); + +test('authenticated user switching vote between up and down updates type', function () { + $user = User::factory()->create(); + $subject = Subject::create([ + 'name' => 'Physics', + 'slug' => 'physics', + 'course' => 'hsc', + 'tailwind_format' => 'bg-indigo-500', + 'icon' => 'atom', + ]); + $node = Node::create([ + 'subject_id' => $subject->id, + 'name' => 'Chapter 1', + 'slug' => 'chapter-1', + ]); + + // Start with upvote + $this->actingAs($user) + ->post("/nodes/{$node->id}/vote", ['type' => 'up']) + ->assertRedirect(); + + $this->assertDatabaseHas('node_votes', [ + 'node_id' => $node->id, + 'user_id' => $user->id, + 'type' => 'up', + ]); + + // Switch to downvote + $this->actingAs($user) + ->post("/nodes/{$node->id}/vote", ['type' => 'down']) + ->assertRedirect(); + + $this->assertDatabaseHas('node_votes', [ + 'node_id' => $node->id, + 'user_id' => $user->id, + 'type' => 'down', + ]); + $this->assertEquals(1, NodeVote::where('node_id', $node->id)->where('user_id', $user->id)->count()); +}); + +test('top level chapters under subject are sorted strictly by sort_order', function () { + $subject = Subject::create([ + 'name' => 'Physics', + 'slug' => 'physics', + 'course' => 'hsc', + 'tailwind_format' => 'bg-indigo-500', + 'icon' => 'atom', + ]); + + $chapter1 = Node::create([ + 'subject_id' => $subject->id, + 'name' => 'Chapter 1: Vectors', + 'slug' => 'chapter-1', + 'sort_order' => 1, + ]); + + $chapter2 = Node::create([ + 'subject_id' => $subject->id, + 'name' => 'Chapter 2: Dynamics', + 'slug' => 'chapter-2', + 'sort_order' => 2, + ]); + + $chapter3 = Node::create([ + 'subject_id' => $subject->id, + 'name' => 'Chapter 3: Energy', + 'slug' => 'chapter-3', + 'sort_order' => 3, + ]); + + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + + // Chapter 3 has 2 upvotes, Chapter 1 has 0 upvotes + NodeVote::create(['node_id' => $chapter3->id, 'user_id' => $user1->id, 'type' => 'up']); + NodeVote::create(['node_id' => $chapter3->id, 'user_id' => $user2->id, 'type' => 'up']); + + // Subject page must still maintain chronological syllabus sequence by sort_order + $response = $this->get('/physics'); + $response->assertInertia(fn (Assert $page) => $page + ->component('Node') + ->has('nodes', 3) + ->where('nodes.0.id', $chapter1->id) + ->where('nodes.1.id', $chapter2->id) + ->where('nodes.2.id', $chapter3->id) + ); +}); + +test('subfolders inside a chapter are sorted by net votes first then sort_order', function () { + $subject = Subject::create([ + 'name' => 'Physics', + 'slug' => 'physics', + 'course' => 'hsc', + 'tailwind_format' => 'bg-indigo-500', + 'icon' => 'atom', + ]); + + $chapter = Node::create([ + 'subject_id' => $subject->id, + 'name' => 'Chapter 1', + 'slug' => 'chapter-1', + 'sort_order' => 1, + ]); + + // Subfolders uploaded by different contributors inside Chapter 1 + $subA = Node::create([ + 'subject_id' => $subject->id, + 'parent_id' => $chapter->id, + 'name' => 'Notes by Rahim (score +1)', + 'slug' => 'notes-by-rahim', + 'sort_order' => 0, + ]); + + $subB = Node::create([ + 'subject_id' => $subject->id, + 'parent_id' => $chapter->id, + 'name' => 'Notes by Karim (score +3)', + 'slug' => 'notes-by-karim', + 'sort_order' => 0, + ]); + + $subC = Node::create([ + 'subject_id' => $subject->id, + 'parent_id' => $chapter->id, + 'name' => 'Notes by Teacher (score -1)', + 'slug' => 'notes-by-teacher', + 'sort_order' => 0, + ]); + + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + $user3 = User::factory()->create(); + + // SubB gets 3 upvotes + NodeVote::create(['node_id' => $subB->id, 'user_id' => $user1->id, 'type' => 'up']); + NodeVote::create(['node_id' => $subB->id, 'user_id' => $user2->id, 'type' => 'up']); + NodeVote::create(['node_id' => $subB->id, 'user_id' => $user3->id, 'type' => 'up']); + + // SubA gets 1 upvote + NodeVote::create(['node_id' => $subA->id, 'user_id' => $user1->id, 'type' => 'up']); + + // SubC gets 1 downvote + NodeVote::create(['node_id' => $subC->id, 'user_id' => $user1->id, 'type' => 'down']); + + // Inside Chapter 1, subfolders rank by net votes: SubB (+3) -> SubA (+1) -> SubC (-1) + $response = $this->get('/physics/chapter-1'); + $response->assertInertia(fn (Assert $page) => $page + ->component('Node') + ->has('nodes', 3) + ->where('nodes.0.id', $subB->id) + ->where('nodes.1.id', $subA->id) + ->where('nodes.2.id', $subC->id) + ); +}); + +test('node page shows upvote list and counts while downvote list is not exposed', function () { + $subject = Subject::create([ + 'name' => 'Physics', + 'slug' => 'physics', + 'course' => 'hsc', + 'tailwind_format' => 'bg-indigo-500', + 'icon' => 'atom', + ]); + $node = Node::create([ + 'subject_id' => $subject->id, + 'name' => 'Chapter 1', + 'slug' => 'chapter-1', + ]); + + $upvoter = User::factory()->create(['name' => 'Upvoter User']); + $downvoter = User::factory()->create(['name' => 'Downvoter Secret User']); + + NodeVote::create(['node_id' => $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->assertInertia(fn (Assert $page) => $page + ->component('Node') + ->where('upvotesCount', 1) + ->where('downvotesCount', 1) + ->where('userVote', 'up') + ->has('upvoters', 1) + ->where('upvoters.0.id', $upvoter->id) + ->where('upvoters.0.name', 'Upvoter User') + ->missing('downvoters') + ); +}); + +test('voting on a folder properly clears and refreshes parent node and subject page caches', function () { + $subject = Subject::create([ + 'name' => 'Physics', + 'slug' => 'physics', + 'course' => 'hsc', + 'tailwind_format' => 'bg-indigo-500', + 'icon' => 'atom', + ]); + + $folderA = Node::create([ + 'subject_id' => $subject->id, + 'name' => 'Folder A', + 'slug' => 'folder-a', + 'sort_order' => 1, + ]); + + $folderB = Node::create([ + 'subject_id' => $subject->id, + 'name' => 'Folder B', + 'slug' => 'folder-b', + 'sort_order' => 2, + ]); + + $user = User::factory()->create(); + + // 1. Visit subject page to warm the cache + $this->get('/physics') + ->assertInertia(fn (Assert $page) => $page + ->where('nodes.0.upvotes_count', 0) + ->where('nodes.1.upvotes_count', 0) + ); + + expect(Illuminate\Support\Facades\Cache::has("subject_page_{$subject->id}"))->toBeTrue(); + + // 2. User votes on Folder B + $this->actingAs($user) + ->post("/nodes/{$folderB->id}/vote", ['type' => 'up']) + ->assertRedirect(); + + // Cache should have been invalidated + expect(Illuminate\Support\Facades\Cache::has("subject_page_{$subject->id}"))->toBeFalse(); + + // 3. Next visit to subject page reflects Folder B with updated upvote count + $this->get('/physics') + ->assertInertia(fn (Assert $page) => $page + ->where('nodes.1.id', $folderB->id) + ->where('nodes.1.upvotes_count', 1) + ); + + // 4. Test nested child node caching + $subfolderA = Node::create([ + 'subject_id' => $subject->id, + 'parent_id' => $folderA->id, + 'name' => 'Subfolder A', + 'slug' => 'subfolder-a', + 'sort_order' => 0, + ]); + + $subfolderB = Node::create([ + 'subject_id' => $subject->id, + 'parent_id' => $folderA->id, + 'name' => 'Subfolder B', + 'slug' => 'subfolder-b', + 'sort_order' => 0, + ]); + + // Warm child cache + $this->get("/physics/{$folderA->slug}") + ->assertInertia(fn (Assert $page) => $page + ->where('nodes.0.upvotes_count', 0) + ->where('nodes.1.upvotes_count', 0) + ); + + expect(Illuminate\Support\Facades\Cache::has("node_children_{$folderA->id}"))->toBeTrue(); + + // Vote on Subfolder B + $this->actingAs($user) + ->post("/nodes/{$subfolderB->id}/vote", ['type' => 'up']) + ->assertRedirect(); + + // Cache should be cleared + expect(Illuminate\Support\Facades\Cache::has("node_children_{$folderA->id}"))->toBeFalse(); + + // Next visit reflects new order and count + $this->get("/physics/{$folderA->slug}") + ->assertInertia(fn (Assert $page) => $page + ->where('nodes.0.id', $subfolderB->id) + ->where('nodes.0.upvotes_count', 1) + ); +}); + +test('folder author receives milestone email notification when folder hits 1 upvote', function () { + Illuminate\Support\Facades\Mail::fake(); + + $author = User::factory()->create([ + 'name' => 'Author Rahim', + 'email' => 'rahim@example.com', + 'receive_emails' => true, + ]); + + $voter = User::factory()->create([ + 'name' => 'Voter Karim', + 'email' => 'karim@example.com', + ]); + + $subject = Subject::create([ + 'name' => 'Physics', + 'slug' => 'physics', + 'course' => 'hsc', + 'tailwind_format' => 'bg-indigo-500', + 'icon' => 'atom', + ]); + + $folder = Node::create([ + 'user_id' => $author->id, + 'subject_id' => $subject->id, + 'name' => 'Vectors Complete Guide', + 'slug' => 'vectors-guide', + ]); + + // Voter upvotes folder + $this->actingAs($voter) + ->post("/nodes/{$folder->id}/vote", ['type' => 'up']) + ->assertRedirect(); + + Illuminate\Support\Facades\Mail::assertQueued(App\Mail\NodeNotificationMail::class, function ($mail) use ($author, $folder) { + return $mail->hasTo($author->email) && + str_contains($mail->mailSubject, 'first upvote') && + str_contains($mail->mailContent, 'Vectors Complete Guide'); + }); +}); + +test('author upvoting their own folder does not trigger milestone email', function () { + Illuminate\Support\Facades\Mail::fake(); + + $author = User::factory()->create([ + 'name' => 'Self Voter', + 'email' => 'self@example.com', + 'receive_emails' => true, + ]); + + $subject = Subject::create([ + 'name' => 'Physics', + 'slug' => 'physics', + 'course' => 'hsc', + 'tailwind_format' => 'bg-indigo-500', + 'icon' => 'atom', + ]); + + $folder = Node::create([ + 'user_id' => $author->id, + 'subject_id' => $subject->id, + 'name' => 'Vectors Complete Guide', + 'slug' => 'vectors-guide', + ]); + + $this->actingAs($author) + ->post("/nodes/{$folder->id}/vote", ['type' => 'up']) + ->assertRedirect(); + + Illuminate\Support\Facades\Mail::assertNothingQueued(); +}); diff --git a/tests/Feature/UserProfileTest.php b/tests/Feature/UserProfileTest.php index 478fd23..a0faa4f 100644 --- a/tests/Feature/UserProfileTest.php +++ b/tests/Feature/UserProfileTest.php @@ -122,3 +122,44 @@ ->has('suggestedUsers', 4) ); }); + +test('public profile displays latest upvoted folders in recent activity', function () { + $user = User::factory()->create(['username' => 'voter_student']); + $subject = Subject::create([ + 'name' => 'Higher Math', + 'slug' => 'higher-math', + 'course' => 'hsc', + 'tailwind_format' => 'bg-emerald-500', + 'icon' => 'calculator', + ]); + + $parentFolder = Node::create([ + 'subject_id' => $subject->id, + 'name' => 'Calculus', + 'slug' => 'calculus', + ]); + + $childFolder = Node::create([ + 'subject_id' => $subject->id, + 'parent_id' => $parentFolder->id, + 'name' => 'Differentiation Master Notes', + 'slug' => 'diff-notes', + ]); + + \App\Models\NodeVote::create([ + 'node_id' => $childFolder->id, + 'user_id' => $user->id, + 'type' => 'up', + ]); + + $response = $this->get('/u/voter_student'); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->component('User/Show') + ->has('recentActivities.upvotes', 1) + ->where('recentActivities.upvotes.0.title', 'Differentiation Master Notes') + ->where('recentActivities.upvotes.0.subtitle', 'Higher Math 路 Calculus') + ->where('recentActivities.upvotes.0.url', '/higher-math/calculus/diff-notes') + ); +});