Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/Http/Controllers/Admin/NodeController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
78 changes: 75 additions & 3 deletions app/Http/Controllers/NodeController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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, '/'));
Expand All @@ -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);

Expand All @@ -34,25 +35,96 @@ 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();
});

$resources = Cache::remember("node_resources_{$node->id}", now()->addDay(), function () use ($node) {
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 = [];
Expand Down
7 changes: 6 additions & 1 deletion app/Http/Controllers/SubjectController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => [],
]);
}
}
50 changes: 49 additions & 1 deletion app/Http/Controllers/UserProfileController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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);
}
}
85 changes: 85 additions & 0 deletions app/Mail/NodeNotificationMail.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php

namespace App\Mail;

use App\Models\Node;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

class NodeNotificationMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;

public function __construct(
public string $mailSubject,
public string $mailContent,
public ?string $recipientName = null,
) {}

public static function forUpvoteMilestone(Node $node, User $upvoter, int $milestoneCount): self
{
$appUrl = config('app.url', 'https://hscstack.site');

$slugs = [];
$curr = $node;
while ($curr) {
array_unshift($slugs, $curr->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 = "<p><strong>{$upvoterName}</strong> just upvoted your folder <a href=\"{$folderUrl}\" target=\"_blank\"><strong>\"{$folderTitle}\"</strong></a>".($context ? " {$context}" : '').'.</p>'
.'<p>Students and contributors are finding your curated materials helpful! Keep sharing and organizing knowledge for the community.</p>';
} else {
$mailSubject = "🎉 Milestone: {$milestoneCount} people upvoted your folder!";
$headline = "🎉 Exciting News! {$milestoneCount} Upvotes Milestone reached!";
$message = "<p>Your folder <a href=\"{$folderUrl}\" target=\"_blank\"><strong>\"{$folderTitle}\"</strong></a> just hit <strong>{$milestoneCount} upvotes</strong>, with the latest from <strong>{$upvoterName}</strong>!</p>"
.'<p>Thank you for organizing study materials that help fellow students excel.</p>';
}

$mailContent = "<p style=\"font-size: 16px; font-weight: 700; color: #4f46e5;\">{$headline}</p>"
.$message
.'<p style="margin-top: 24px;">'
."<a href=\"{$folderUrl}\" target=\"_blank\" style=\"display: inline-block; background-color: #4f46e5; color: #ffffff; padding: 10px 20px; font-weight: 600; text-decoration: none; border-radius: 10px; font-size: 13px;\">"
.'View Folder &rarr;'
.'</a>'
.'</p>';

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,
],
);
}
}
23 changes: 23 additions & 0 deletions app/Models/Node.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
class Node extends Model
{
protected $fillable = [
'user_id',
'subject_id',
'parent_id',
'name',
Expand All @@ -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');
Expand All @@ -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');
}
}
28 changes: 28 additions & 0 deletions app/Models/NodeVote.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class NodeVote extends Model
{
use HasFactory;

protected $fillable = [
'node_id',
'user_id',
'type',
];

public function node(): BelongsTo
{
return $this->belongsTo(Node::class);
}

public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
Loading