From 673945b875d7662111488a51041c5637c55e1a74 Mon Sep 17 00:00:00 2001
From: Tajim
Date: Wed, 26 Aug 2026 15:51:59 +0600
Subject: [PATCH 1/5] feat(database): add node_votes table and user_id to nodes
table
- Create migration for node_votes with user_id, node_id, and type (up/down)
- Add unique constraint on [node_id, user_id] and index on [node_id, type]
- Add migration to assign user_id on nodes table for internal author tracking
- Define NodeVote Eloquent model and relationships on Node and User models
---
app/Models/Node.php | 23 +++++++++++++
app/Models/NodeVote.php | 28 +++++++++++++++
app/Models/User.php | 10 ++++++
...6_08_26_160000_create_node_votes_table.php | 33 ++++++++++++++++++
...8_26_170000_add_user_id_to_nodes_table.php | 34 +++++++++++++++++++
5 files changed, 128 insertions(+)
create mode 100644 app/Models/NodeVote.php
create mode 100644 database/migrations/2026_08_26_160000_create_node_votes_table.php
create mode 100644 database/migrations/2026_08_26_170000_add_user_id_to_nodes_table.php
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/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');
+ });
+ }
+};
From e73f5f9f7e7579c47626417f03c45cb9519c0dff Mon Sep 17 00:00:00 2001
From: Tajim
Date: Wed, 26 Aug 2026 15:52:02 +0600
Subject: [PATCH 2/5] feat(backend): implement folder voting endpoint, cache
invalidation, and sorting logic
- Add POST /nodes/{node}/vote route for toggling upvotes/downvotes
- Create NodeVoteObserver to invalidate parent node and subject page caches
- Keep top-level curriculum chapters strictly sorted by sort_order on subject page
- Sort subfolders inside chapters by net votes (upvotes - downvotes) then sort_order
- Pass upvotesCount, downvotesCount, userVote, and upvoters list to inertia props
- Ensure downvoter identity list is private and never exposed
- Add comprehensive feature test suite covering all voting and sorting behavior
---
app/Http/Controllers/Admin/NodeController.php | 1 +
app/Http/Controllers/NodeController.php | 55 ++-
app/Http/Controllers/SubjectController.php | 7 +-
app/Observers/NodeVoteObserver.php | 38 ++
app/Providers/AppServiceProvider.php | 3 +
routes/web.php | 1 +
tests/Feature/NodeVoteTest.php | 402 ++++++++++++++++++
7 files changed, 503 insertions(+), 4 deletions(-)
create mode 100644 app/Observers/NodeVoteObserver.php
create mode 100644 tests/Feature/NodeVoteTest.php
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..422624d 100644
--- a/app/Http/Controllers/NodeController.php
+++ b/app/Http/Controllers/NodeController.php
@@ -4,12 +4,12 @@
use App\Models\Node;
use App\Models\Subject;
+use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Inertia\Inertia;
class NodeController extends Controller
{
- //
public function show(Subject $subject, $path)
{
$slugs = explode('/', trim($path, '/'));
@@ -18,7 +18,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 +33,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,14 +43,63 @@ 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'],
]);
+
+ $userId = auth()->id();
+ $existing = $node->votes()->where('user_id', $userId)->first();
+
+ if ($existing) {
+ if ($existing->type === $validated['type']) {
+ $existing->delete();
+ } else {
+ $existing->update(['type' => $validated['type']]);
+ }
+ } else {
+ $node->votes()->create([
+ 'user_id' => $userId,
+ 'type' => $validated['type'],
+ ]);
+ }
+
+ return back();
}
private function buildBreadcrumb($node)
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/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/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..5043625
--- /dev/null
+++ b/tests/Feature/NodeVoteTest.php
@@ -0,0 +1,402 @@
+ '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)
+ );
+});
From daf991f6b567b1175d9bbb9b909ab87f6aa50ff7 Mon Sep 17 00:00:00 2001
From: Tajim
Date: Wed, 26 Aug 2026 15:52:05 +0600
Subject: [PATCH 3/5] feat(frontend): add folder vote buttons, upvoters modal,
and listing badges
- Add optimistic upvote and downvote buttons with ArrowBigUp / ArrowBigDown icons
- Add modal to view the list of upvoters (name, avatar, institution, role badge)
- Add guest sign-in dialog prompt when unauthenticated user attempts to vote
- Display folder upvote count badge on child folder rows in desktop and mobile views
---
resources/js/components/NodeRow.vue | 43 ++-
resources/js/pages/Node.vue | 480 +++++++++++++++++++++++++++-
2 files changed, 498 insertions(+), 25 deletions(-)
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 @@
@@ -45,18 +222,158 @@ const totalItemsCount = computed(
>
-
+
+
+
+
+
+
+
+
+
+ Sign in required
+
+
+ {{ authModalMessage }}
+
+
+
+
+
+ Sign in
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Upvoted by
+
+
+ {{ localUpvotesCount }}
+ {{
+ localUpvotesCount === 1
+ ? 'person'
+ : 'people'
+ }}
+ upvoted this folder
+
+
+
+
+
+
+ No upvotes yet.
+
+
+
+
+
+
+
+
From 8454e13c03bdd0c605436953063470e3b3a482e4 Mon Sep 17 00:00:00 2001
From: Tajim
Date: Wed, 26 Aug 2026 15:52:07 +0600
Subject: [PATCH 4/5] feat(profile): display latest upvoted folders in user
profile activity feed
- Query latest 4 upvoted folders with multi-level parent eager loading
- Construct full hierarchical canonical paths without N+1 query overhead
- Render upvoted folder activity card with ArrowBigUp badge in Activity tab
- Add automated test verifying upvoted folders in public user profile
---
.../Controllers/UserProfileController.php | 50 ++++++++++++++++-
resources/js/pages/User/Show.vue | 54 ++++++++++++++++++-
tests/Feature/UserProfileTest.php | 41 ++++++++++++++
3 files changed, 142 insertions(+), 3 deletions(-)
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/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/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')
+ );
+});
From 5c713f29627f860755ebb6cfd2c35dc4beb7057f Mon Sep 17 00:00:00 2001
From: Tajim
Date: Wed, 26 Aug 2026 15:54:03 +0600
Subject: [PATCH 5/5] feat(mail): send milestone email notifications for folder
upvotes
- Create NodeNotificationMail mailable reusing the branded bulk_announcement email layout
- Queue milestone emails to folder author at 1, 5, 10, 25, 50, 100, 250, 500, 1000 upvotes
- Respect user email preferences (receive_emails) and avoid self-notification
- Add feature tests for folder upvote milestone email triggers
---
app/Http/Controllers/NodeController.php | 29 ++++++++-
app/Mail/NodeNotificationMail.php | 85 +++++++++++++++++++++++++
tests/Feature/NodeVoteTest.php | 72 +++++++++++++++++++++
3 files changed, 183 insertions(+), 3 deletions(-)
create mode 100644 app/Mail/NodeNotificationMail.php
diff --git a/app/Http/Controllers/NodeController.php b/app/Http/Controllers/NodeController.php
index 422624d..53b6c15 100644
--- a/app/Http/Controllers/NodeController.php
+++ b/app/Http/Controllers/NodeController.php
@@ -2,10 +2,12 @@
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
@@ -83,20 +85,41 @@ public function vote(Request $request, Node $node)
'type' => ['required', 'in:up,down'],
]);
- $userId = auth()->id();
- $existing = $node->votes()->where('user_id', $userId)->first();
+ $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' => $userId,
+ '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();
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/tests/Feature/NodeVoteTest.php b/tests/Feature/NodeVoteTest.php
index 5043625..3631f00 100644
--- a/tests/Feature/NodeVoteTest.php
+++ b/tests/Feature/NodeVoteTest.php
@@ -400,3 +400,75 @@
->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();
+});