diff --git a/.github/workflows/laravel.yml b/.github/workflows/feature-tests.yml similarity index 98% rename from .github/workflows/laravel.yml rename to .github/workflows/feature-tests.yml index 6fc9dade..d5805489 100644 --- a/.github/workflows/laravel.yml +++ b/.github/workflows/feature-tests.yml @@ -47,7 +47,7 @@ jobs: - name: Set up PHP & Composer uses: shivammathur/setup-php@v2 with: - php-version: 8.3 + php-version: 8.4 extensions: mbstring, pdo, mysql, bcmath, tokenizer tools: composer diff --git a/.github/workflows/review-dog.yml b/.github/workflows/review-dog.yml new file mode 100644 index 00000000..064626ec --- /dev/null +++ b/.github/workflows/review-dog.yml @@ -0,0 +1,39 @@ +name: Laravel Pint Code Review + +on: + pull_request: + paths: + - '**.php' + +jobs: + reviewdog: + name: Laravel Pint Review + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + tools: composer + extensions: mbstring, dom, curl + + - name: Install Dependencies + run: composer install --no-interaction --no-progress --prefer-dist + + - name: Install Pint + run: composer global require laravel/pint + + - name: Run Pint with ReviewDog + env: + REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + ~/.composer/vendor/bin/pint --test --format=checkstyle \ + | npx reviewdog -f=checkstyle \ + -name="Laravel Pint" \ + -reporter=github-pr-review \ + -fail-on-error=false \ + -level=info diff --git a/app/Actions/Fortify/UpdateUserProfileInformation.php b/app/Actions/Fortify/UpdateUserProfileInformation.php index 9738772d..89d0234d 100644 --- a/app/Actions/Fortify/UpdateUserProfileInformation.php +++ b/app/Actions/Fortify/UpdateUserProfileInformation.php @@ -20,7 +20,7 @@ public function update(User $user, array $input): void Validator::make($input, [ 'name' => ['required', 'string', 'max:255'], 'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)], - 'photo' => ['nullable', 'mimes:jpg,jpeg,png', 'max:1024'], + 'photo' => ['nullable', 'image', 'max:400'], ])->validateWithBag('updateProfileInformation'); if (isset($input['photo'])) { diff --git a/app/Events/CommentCreated.php b/app/Events/CommentCreated.php deleted file mode 100644 index 49a01826..00000000 --- a/app/Events/CommentCreated.php +++ /dev/null @@ -1,37 +0,0 @@ - - */ - public function broadcastOn(): array - { - return [ - new PrivateChannel('channel-name'), - ]; - } -} diff --git a/app/Events/ResourceReviewProcessed.php b/app/Events/ResourceReviewProcessed.php index 563889e9..bcd2e72e 100644 --- a/app/Events/ResourceReviewProcessed.php +++ b/app/Events/ResourceReviewProcessed.php @@ -2,11 +2,8 @@ namespace App\Events; -use Illuminate\Broadcasting\Channel; use Illuminate\Broadcasting\InteractsWithSockets; -use Illuminate\Broadcasting\PresenceChannel; use Illuminate\Broadcasting\PrivateChannel; -use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Contracts\Events\ShouldDispatchAfterCommit; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; @@ -20,8 +17,8 @@ class ResourceReviewProcessed implements ShouldDispatchAfterCommit */ public function __construct( public int $resource, - public array|null $oldReview, - public array|null $newReview, + public ?array $oldReview, + public ?array $newReview, ) {} /** diff --git a/app/Events/TagFrequencyChanged.php b/app/Events/TagFrequencyChanged.php index 2c9c3868..87a5e28c 100644 --- a/app/Events/TagFrequencyChanged.php +++ b/app/Events/TagFrequencyChanged.php @@ -2,11 +2,8 @@ namespace App\Events; -use Illuminate\Broadcasting\Channel; use Illuminate\Broadcasting\InteractsWithSockets; -use Illuminate\Broadcasting\PresenceChannel; use Illuminate\Broadcasting\PrivateChannel; -use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; @@ -17,7 +14,7 @@ class TagFrequencyChanged /** * Create a new event instance. */ - public function __construct(public array|null $oldTags, public array|null $newTags) { } + public function __construct(public ?array $oldTags, public ?array $newTags) {} /** * Get the channels the event should broadcast on. diff --git a/app/Events/UpvoteProcessed.php b/app/Events/UpvoteProcessed.php index c94aa3a0..9b7d12f1 100644 --- a/app/Events/UpvoteProcessed.php +++ b/app/Events/UpvoteProcessed.php @@ -2,26 +2,20 @@ namespace App\Events; -use App\Models\Upvote; -use Illuminate\Broadcasting\Channel; use Illuminate\Broadcasting\InteractsWithSockets; -use Illuminate\Broadcasting\PresenceChannel; -use Illuminate\Contracts\Events\ShouldDispatchAfterCommit; use Illuminate\Broadcasting\PrivateChannel; -use Illuminate\Contracts\Broadcasting\ShouldBroadcast; +use Illuminate\Contracts\Events\ShouldDispatchAfterCommit; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -class UpvoteProcessed implements ShouldDispatchAfterCommit +class UpvoteProcessed implements ShouldDispatchAfterCommit { use Dispatchable, InteractsWithSockets, SerializesModels; /** * Create a new event instance. */ - public function __construct(public string $type, public int $id, public int $previousValue, public int $newValue) - { - } + public function __construct(public string $type, public int $id, public int $previousValue, public int $newValue) {} /** * Get the channels the event should broadcast on. diff --git a/app/Http/Controllers/CommentController.php b/app/Http/Controllers/CommentController.php index 0f21b850..3d3a3d8e 100644 --- a/app/Http/Controllers/CommentController.php +++ b/app/Http/Controllers/CommentController.php @@ -2,13 +2,12 @@ namespace App\Http\Controllers; -use App\Events\CommentCreated; use App\Http\Requests\Comment\StoreCommentRequest; use App\Http\Resources\CommentResource; -use App\Models\Comment; -use App\Services\ModelResolverService; use App\Http\Resources\UserResource; +use App\Models\Comment; use App\Services\CommentService; +use App\Services\ModelResolverService; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Validator; @@ -18,6 +17,7 @@ class CommentController extends Controller { protected $modelResolver; + protected $commentService; public function __construct(ModelResolverService $modelResolver, CommentService $commentService) @@ -31,10 +31,9 @@ public function __construct(ModelResolverService $modelResolver, CommentService */ public function store(StoreCommentRequest $request) { - Log::debug("Called store on comment controller"); $validatedData = $request->validated(); + Log::debug('Comment Controller Store', ['validated data' => $validatedData]); - Log::debug("Data validated and is " . json_encode($validatedData)); $comment = new Comment; $comment->content = $validatedData['content']; $comment->user_id = Auth::id(); @@ -44,10 +43,9 @@ public function store(StoreCommentRequest $request) // Ensure that the model exists $model = $this->modelResolver->resolve($validatedData['commentable_key'], $commentableId); - if (!$model) { + if (! $model) { return response()->json(['message' => 'Model not found'], 404); } - Log::debug("Resolved model class: " . $commentableType); // Set the commentable type $comment->commentable_type = $commentableType; @@ -55,7 +53,7 @@ public function store(StoreCommentRequest $request) // Top level comment $parentCommentId = $validatedData['parent_comment_id']; - if (!$parentCommentId) { + if (! $parentCommentId) { $comment->parent_comment_id = null; $comment->depth = 1; $comment->children_count = 0; @@ -84,7 +82,7 @@ public function store(StoreCommentRequest $request) 'commentable_id' => $commentableId, 'commentable_type' => $commentableType, 'depth' => $new_comment_depth, - 'replies_count' => $replies_count + 'replies_count' => $replies_count, ], [ 'commentable_id' => [ @@ -99,14 +97,14 @@ public function store(StoreCommentRequest $request) 'depth' => [ 'required', 'integer', - 'lte:' . (config('comment.max_depth')) + 'lte:'.(config('comment.max_depth')), ], // Cannot exceed max replies 'replies_count' => [ 'required', 'integer', - 'lt:' . (config('comment.max_replies')) - ] + 'lt:'.(config('comment.max_replies')), + ], ] ); @@ -125,16 +123,19 @@ public function store(StoreCommentRequest $request) } $comment->save(); - CommentCreated::dispatch( - $commentableId, - $commentableType - ); - Log::debug("New saved comment is " . json_encode($comment)); + Log::debug('New comment saved', [ + 'comment_id' => $comment->id, + 'user_id' => $comment->user_id, + 'commentable_type' => $comment->commentable_type, + 'commentable_id' => $comment->commentable_id, + 'depth' => $comment->depth, + ]); + return response()->json([ 'new_comment' => new CommentResource($comment), 'user' => new UserResource(Auth::user()), - ]);; + ]); } /** @@ -142,14 +143,19 @@ public function store(StoreCommentRequest $request) */ public function show(Request $request, string $commentableKey, int $commentableId, int $index, int $paginationLimit = -1) { - if ($paginationLimit == -1) - { + if ($paginationLimit == -1) { $paginationLimit = config('comment.default_pagination_limit'); } $sortBy = $request->query('sort_by', 'top'); - Log::debug("Request is, commentable_type: " . $commentableKey . ". id: " . $commentableId . ". index: " . $index . ". Sorting: " . $sortBy); + Log::debug('Processing comment show request', [ + 'commentable_key' => $commentableKey, + 'commentable_id' => $commentableId, + 'index' => $index, + 'sort_by' => $sortBy, + 'pagination_limit' => $paginationLimit, + ]); $paginatedResults = $this->commentService->getPaginatedComments($commentableKey, $commentableId, $index, $paginationLimit, $sortBy); return $paginatedResults; diff --git a/app/Http/Controllers/ComputerScienceResourceController.php b/app/Http/Controllers/ComputerScienceResourceController.php index 07abfb47..956b5896 100644 --- a/app/Http/Controllers/ComputerScienceResourceController.php +++ b/app/Http/Controllers/ComputerScienceResourceController.php @@ -15,18 +15,20 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\Validator; -use Illuminate\Validation\Rule; +use Illuminate\Support\Facades\Storage; use Inertia\Inertia; class ComputerScienceResourceController extends Controller { protected $commentService; + protected $generalVotesSortingManager; + protected $reviewService; + protected $resourceSortingManager; - function __construct( + public function __construct( CommentService $commentService, GeneralVotesSortingManager $generalVotesSortingManager, ResourceReviewService $reviewService, @@ -71,12 +73,18 @@ public function create() public function store(StoreResourceRequest $request) { $validatedData = $request->validated(); - Log::debug("Called store resource with data " . json_encode($request)); + Log::debug('Called store resource with data '.json_encode($request)); + + // Store the image onto storage + $path = null; + if (array_key_exists('image_file', $validatedData) && $imageFile = $validatedData['image_file']) { + $path = $imageFile->store('resource', 'public'); + } $resource = ComputerScienceResource::create([ 'user_id' => Auth::id(), 'name' => $validatedData['name'], - 'image_url' => $validatedData['image_url'], + 'image_path' => $path, 'description' => $validatedData['description'], 'page_url' => $validatedData['page_url'], 'platforms' => $validatedData['platforms'], @@ -97,10 +105,10 @@ public function store(StoreResourceRequest $request) $resource->general_tags = $validatedData['general_tags']; } - // Change tag frequency + // Dispatch tag frequency change event TagFrequencyChanged::dispatch(null, $resource->tagCounter()); - Log::debug("Created resource " . json_encode($resource)); + Log::debug('Created resource '.json_encode($resource)); return redirect(route('resources.show', ['computerScienceResource' => $resource->id])) ->with('success', 'Created Resource Succesfully!'); @@ -111,9 +119,13 @@ public function store(StoreResourceRequest $request) */ public function show(Request $request, ComputerScienceResource $computerScienceResource, string $tab = 'reviews') { + // Get the review summaries + $computerScienceResource->load('reviewSummary'); + $computerScienceResource->load('user'); + $validTabs = ['reviews', 'discussion', 'edits']; - if (!in_array($tab, $validTabs)) { + if (! in_array($tab, $validTabs)) { // Redirect to default if invalid return redirect()->route('resources.show', [ 'computerScienceResource' => $computerScienceResource->id, @@ -130,32 +142,40 @@ public function show(Request $request, ComputerScienceResource $computerScienceR $sortBy = $request->query('sort_by', 'top'); // Load only the necessary tab data if ($tab === 'reviews') { + $userReview = null; + if ($userId = Auth::id()) { + $userReview = ResourceReview::where('user_id', $userId)->first(); + } + + $data['userReview'] = $userReview; + $data['reviews'] = Inertia::defer( - function () use ($computerScienceResource, $sortBy) { + function () use ($computerScienceResource, $sortBy, $request) { $query = ResourceReview::where('computer_science_resource_id', $computerScienceResource->id); $query = $this->generalVotesSortingManager->applySort($query, $sortBy, ResourceReview::class); - return $query->get(); + + return $query->with('user')->paginate(10)->appends($request->query()); } ); } elseif ($tab === 'edits') { $data['resourceEdits'] = Inertia::defer( - function () use ($computerScienceResource, $sortBy) { + function () use ($computerScienceResource, $sortBy, $request) { $query = ResourceEdits::where('computer_science_resource_id', $computerScienceResource->id); + // TODO: ADD ERROR LOGS IF THIS MAKES IT RETURN NOTHING, SORTING SHOULD NOT CHANGE SIZE, ONLY ORDER $query = $this->generalVotesSortingManager->applySort($query, $sortBy, ResourceEdits::class); - return $query->get(); + + return $query->with('user')->paginate(10)->appends($request->query()); } ); } elseif ($tab === 'discussion') { $data['discussion'] = Inertia::defer( - fn() => - $this->commentService->getPaginatedComments('resource', $computerScienceResource->id, 0, 150, $sortBy) + fn () => $this->commentService->getPaginatedComments('resource', $computerScienceResource->id, 0, 150, $sortBy) ); } return Inertia::render('Resources/Show', $data); } - /** * Remove the specified resource from storage. */ diff --git a/app/Http/Controllers/ResourceEditsController.php b/app/Http/Controllers/ResourceEditsController.php index 23dbde7b..23bd12d1 100644 --- a/app/Http/Controllers/ResourceEditsController.php +++ b/app/Http/Controllers/ResourceEditsController.php @@ -3,17 +3,27 @@ namespace App\Http\Controllers; use App\Events\TagFrequencyChanged; +use App\Http\Requests\ResourceEdit\StoreResourceEdit; use App\Models\ComputerScienceResource; use App\Models\ResourceEdits; +use App\Services\DataNormalizationService; use App\Services\ResourceEditsService; -use App\Http\Requests\ResourceEdit\StoreResourceEdit; -use App\Http\Resources\ComputerScienceResourceResource; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Storage; use Inertia\Inertia; use Log; +use Str; class ResourceEditsController extends Controller { + private DataNormalizationService $dataService; + + public function __construct( + private DataNormalizationService $dataNormalizationService + ) { + $this->dataService = $dataNormalizationService; + } + /** * Return the form to create a edit. */ @@ -22,37 +32,33 @@ public function create(ComputerScienceResource $computerScienceResource) $computerScienceResource->load('user'); return Inertia::render('ResourceEdits/Create', [ - 'resource' => fn() => $computerScienceResource + 'resource' => fn () => $computerScienceResource, ]); } - - // TODO: Make an array facade or something - function normalize($array) { - ksort($array); - foreach ($array as &$value) { - if (is_array($value)) { - sort($value); // Assumes it's a flat array of values - } - } - return $array; - } - /** * Store the edits request. */ public function store(ComputerScienceResource $computerScienceResource, StoreResourceEdit $request) { $validatedData = $request->validated(); - Log::debug("Creating a resource edit: " . json_encode($validatedData)); - - // Ensure that they are not the same - $originalData = $this->normalize((new ComputerScienceResourceResource($computerScienceResource))->resolve()); - $editData = $this->normalize($validatedData); - unset($editData['edit_title'], $editData['edit_description']); - - if ($originalData == $editData) { - return response()->json(['message' => 'No changes detected'], 422); + $proposedChanges = $validatedData['proposed_changes'] ?? []; + + $actualChanges = $this->calculateChanges($computerScienceResource, $proposedChanges); + + if (array_key_exists('image_file', $proposedChanges)) { + $actualChanges['image_path'] = null; + if (isset($proposedChanges['image_file'])) { + $path = $proposedChanges['image_file']->store('resource-edits', 'public'); + $actualChanges['image_path'] = $path; + } + unset($actualChanges['image_file']); + } + + if (empty($actualChanges)) { + Log::warning("Resource edit was submitted without any changes for resource ID: {$computerScienceResource->id}"); + + return redirect()->back()->with('warning', 'Cannot submit an edit with no changes made.'); } $resourceEdit = ResourceEdits::create([ @@ -60,65 +66,101 @@ public function store(ComputerScienceResource $computerScienceResource, StoreRes 'computer_science_resource_id' => $computerScienceResource->id, 'edit_title' => $validatedData['edit_title'], 'edit_description' => $validatedData['edit_description'], - 'image_url' => $validatedData['image_url'], - 'name' => $validatedData['name'], - 'description' => $validatedData['description'], - 'page_url' => $validatedData['page_url'], - 'platforms' => $validatedData['platforms'], - 'difficulty' => $validatedData['difficulty'], - 'pricing' => $validatedData['pricing'], - 'topic_tags' => $validatedData['topic_tags'], - 'programming_language_tags' => $validatedData['programming_language_tags'], - 'general_tags' => $validatedData['general_tags'], + 'proposed_changes' => $actualChanges, ]); return redirect()->route('resource_edits.show', ['resourceEdits' => $resourceEdit->id]) - ->with('success', 'The proposed edits were created. Other\'s can now view it.'); + ->with('success', 'The proposed edits were created. Others can now view it.'); + } + + /** + * Calculate the actual differences between the proposed changes and the original resource. + */ + private function calculateChanges(ComputerScienceResource $resource, array $proposedChanges): array + { + $actualChanges = []; + $normalizedProposed = $this->dataNormalizationService->normalize($proposedChanges); + $normalizedOriginal = $this->dataNormalizationService->normalize($resource->toArray()); + + foreach ($normalizedProposed as $key => $value) { + if (! array_key_exists($key, $normalizedOriginal) || $normalizedOriginal[$key] !== $value) { + // Use the original value from the request, not the normalized one, for file uploads. + $actualChanges[$key] = $proposedChanges[$key]; + } + } + + return $actualChanges; } public function show(ResourceEdits $resourceEdits) { $resourceEdits->load('resource'); + $resourceEdits->load('user'); return Inertia::render('ResourceEdits/Show', [ - 'resourceId' => $resourceEdits->id, - 'originalResource' => fn () => $resourceEdits->resource, 'editedResource' => fn () => $resourceEdits, ]); } public function merge(ResourceEditsService $editsService, ResourceEdits $resourceEdits) { - if (!$editsService->canMergeEdits($resourceEdits)) { + if (! $editsService->canMergeEdits($resourceEdits)) { return redirect()->back()->with('warning', 'Not enough approvals'); } $resource = ComputerScienceResource::findOrFail($resourceEdits->computer_science_resource_id); - $old_tag_counter = $resource->tagCounter(); - - $resource->name = $resourceEdits->name; - $resource->description = $resourceEdits->description; - $resource->image_url = $resourceEdits->image_url; - $resource->page_url = $resourceEdits->page_url; - $resource->platforms = $resourceEdits->platforms; - $resource->difficulty = $resourceEdits->difficulty; - $resource->pricing = $resourceEdits->pricing; - + $oldTagCounter = $resource->tagCounter(); + + // Go through each property in proposed_changes, and if it exists. then set the value + $changes = $resourceEdits->proposed_changes; + $proposedFields = ['name', 'description', 'page_url', 'platforms', 'difficulty', 'pricing']; + foreach ($proposedFields as $field) { + if (array_key_exists($field, $changes)) { + $resource->$field = $changes[$field]; + } + } + + if (array_key_exists('image_path', $changes)) { + // Delete the existing resource image from storage + if ($resource->image_path) { + Storage::disk('public')->delete($resource->image_path); + } + + $destPath = null; + if (isset($changes['image_path'])) { + // Move the new file from 'resource-edits' to 'resource' + $sourcePath = $changes['image_path']; + $fileExtension = pathinfo($sourcePath, PATHINFO_EXTENSION); + $newFileName = Str::random(40).'.'.$fileExtension; + $destPath = 'resource/'.$newFileName; + + Storage::disk('public')->move($sourcePath, $destPath); + } + + // Update image_path in DB + $resource->image_path = $destPath; + } + $resource->save(); - - $resource->topic_tags = $resourceEdits->topic_tags; - $resource->programming_language_tags = $resourceEdits->programming_language_tags; - $resource->general_tags = $resourceEdits->general_tags; + + $proposedTagFields = ['topic_tags', 'programming_language_tags', 'general_tags']; + $allTags = []; + foreach ($proposedTagFields as $field) { + if (array_key_exists($field, $changes)) { + $resource->$field = $changes[$field]; + $allTags[] = $changes[$field]; + } + } // Get the new tag counter - $new_tags = collect([$resourceEdits->topic_tags, $resourceEdits->programming_language_tags, $resourceEdits->general_tags])->flatten()->countBy()->toArray(); + $newTags = collect($allTags)->flatten()->countBy()->toArray(); // Change tag frequency - TagFrequencyChanged::dispatch($old_tag_counter, $new_tags); + TagFrequencyChanged::dispatch($oldTagCounter, $newTags); // Delete the edit since we successfully merged the changes $resourceEdits->delete(); - return redirect(route('resources.show', ['computerScienceResource'=>$resourceEdits->computer_science_resource_id])) + return redirect(route('resources.show', ['computerScienceResource' => $resourceEdits->computer_science_resource_id])) ->with('success', 'Successfully merged new changed!'); } } diff --git a/app/Http/Controllers/ResourceReviewController.php b/app/Http/Controllers/ResourceReviewController.php index d7954e3f..319ba03e 100644 --- a/app/Http/Controllers/ResourceReviewController.php +++ b/app/Http/Controllers/ResourceReviewController.php @@ -2,7 +2,6 @@ namespace App\Http\Controllers; -use App\Events\ResourceReviewProcessed; use App\Http\Requests\ResourceReview\StoreResourceReview; use App\Models\ComputerScienceResource; use App\Models\ResourceReview; @@ -23,15 +22,22 @@ public function store(StoreResourceReview $request, ComputerScienceResource $com ])->first(); if ($existingReview) { - Log::debug("User has already posted a review"); - // TODO: Make it a json with errors instead - return back()->with('warning', 'You already have a review posted, you should edit your existing one instead.'); + Log::warning("User has already posted a review, can't make a new one", [ + 'user_id' => Auth::id(), + 'computer_science_resource_id' => $computerScienceResource->id, + ]); + + return response()->json([], 400); } - Log::debug("Storing resource review: " . json_encode($validatedData)); + Log::debug('Storing resource review', [ + 'user_id' => Auth::id(), + 'computer_science_resource_id' => $computerScienceResource->id, + 'review_data' => $validatedData, + ]); // Create the resource review - $review = ResourceReview::create([ + ResourceReview::create([ 'user_id' => Auth::id(), 'computer_science_resource_id' => $computerScienceResource->id, 'title' => $validatedData['title'], @@ -46,8 +52,6 @@ public function store(StoreResourceReview $request, ComputerScienceResource $com 'cons' => $validatedData['cons'], ]); - ResourceReviewProcessed::dispatch($computerScienceResource->id, null, $review->attributesToArray()); - return response()->json(); } @@ -61,17 +65,22 @@ public function update(StoreResourceReview $request, ComputerScienceResource $co 'computer_science_resource_id' => $computerScienceResource->id, ])->first(); - if (!$existingReview) { - Log::debug("User has not already posted a review"); - // TODO: Make it a json with errors instead - return back()->with('warning', 'You need to have a review posted before editing one.'); + if (! $existingReview) { + Log::warning('User has not posted a review, yet is trying to edit theirs', [ + 'user_id' => Auth::id(), + 'computer_science_resource_id' => $computerScienceResource->id, + ]); + + return response()->json([], 400); } - Log::debug("Updating resource review: " . json_encode($validatedData)); + Log::debug('Updating resource review', [ + 'user_id' => Auth::id(), + 'computer_science_resource_id' => $computerScienceResource->id, + 'review_data' => $validatedData, + ]); // Update the existing review - $oldAttributes = $existingReview->attributesToArray(); // Save old attributes - $existingReview->update([ 'title' => $validatedData['title'], 'description' => $validatedData['description'], @@ -85,13 +94,6 @@ public function update(StoreResourceReview $request, ComputerScienceResource $co 'cons' => $validatedData['cons'], ]); - // Dispatch event with old and new data - ResourceReviewProcessed::dispatch( - $computerScienceResource->id, - $oldAttributes, - $existingReview->attributesToArray() - ); - return response()->json(); } } diff --git a/app/Http/Controllers/TagDiffViewer.vue b/app/Http/Controllers/TagDiffViewer.vue new file mode 100644 index 00000000..3a5bd4f3 --- /dev/null +++ b/app/Http/Controllers/TagDiffViewer.vue @@ -0,0 +1,62 @@ + + + diff --git a/app/Http/Controllers/TagFrequencyController.php b/app/Http/Controllers/TagFrequencyController.php index e3df355c..08691dba 100644 --- a/app/Http/Controllers/TagFrequencyController.php +++ b/app/Http/Controllers/TagFrequencyController.php @@ -3,14 +3,12 @@ namespace App\Http\Controllers; use App\Models\TagFrequency; -use Illuminate\Http\Request; class TagFrequencyController extends Controller { - public function search(string $query = "") + public function search(string $query = '') { - if (strlen($query) > 50) - { + if (strlen($query) > 50) { return response()->json(['message' => 'Query too long.'], 422); } @@ -20,7 +18,7 @@ public function search(string $query = "") ->get(); return response()->json([ - 'tags' => $prefixed_tags + 'tags' => $prefixed_tags, ]); } } diff --git a/app/Http/Controllers/UpvoteController.php b/app/Http/Controllers/UpvoteController.php index 2dc0c9f3..81be336b 100644 --- a/app/Http/Controllers/UpvoteController.php +++ b/app/Http/Controllers/UpvoteController.php @@ -2,18 +2,15 @@ namespace App\Http\Controllers; -use App\Models\UpvoteSummary; use App\Services\ModelResolverService; -use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Log; use Illuminate\Validation\Rule; class UpvoteController extends Controller { protected $modelResolver; - function __construct(ModelResolverService $modelResolver) + public function __construct(ModelResolverService $modelResolver) { $this->modelResolver = $modelResolver; } @@ -28,13 +25,13 @@ public function upvote($typeKey, $id) 'type_key' => $typeKey, ], [ - 'type_key' => ['required', Rule::in(config('upvotes.upvotable_keys'))] + 'type_key' => ['required', Rule::in(config('upvotes.upvotable_keys'))], ] - )->validate(); + )->validate(); $model = $this->modelResolver->resolve($typeKey, $id); - if (!$model) { + if (! $model) { return response()->json(['message' => 'Model not found'], 404); } @@ -43,7 +40,7 @@ public function upvote($typeKey, $id) return response()->json([ 'userVote' => $result['userVote'], - 'changeFromVote' => $result['changeFromVote'] + 'changeFromVote' => $result['changeFromVote'], ]); } @@ -57,13 +54,13 @@ public function downvote($typeKey, $id) 'type_key' => $typeKey, ], [ - 'type_key' => ['required', Rule::in(config('upvotes.upvotable_keys'))] + 'type_key' => ['required', Rule::in(config('upvotes.upvotable_keys'))], ] - )->validate(); + )->validate(); $model = $this->modelResolver->resolve($typeKey, $id); - if (!$model) { + if (! $model) { return response()->json(['message' => 'Model not found'], 404); } @@ -72,7 +69,7 @@ public function downvote($typeKey, $id) return response()->json([ 'userVote' => $result['userVote'], - 'changeFromVote' => $result['changeFromVote'] + 'changeFromVote' => $result['changeFromVote'], ]); } } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index fbe9cdd6..b0395db1 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -39,12 +39,11 @@ public function share(Request $request): array 'flash' => [ 'success' => $request->session()->get('success'), 'warning' => $request->session()->get('warning'), - 'error' => $request->session()->get('error'), ], 'config' => [ 'COMMENT_MAX_DEPTH' => config('comment.max_depth'), 'COMMENT_PAGINATION_LIMIT' => config('comment.pagination_limit'), - ] + ], ]; } } diff --git a/app/Http/Requests/Comment/StoreCommentRequest.php b/app/Http/Requests/Comment/StoreCommentRequest.php index e27e4502..57ba2c47 100644 --- a/app/Http/Requests/Comment/StoreCommentRequest.php +++ b/app/Http/Requests/Comment/StoreCommentRequest.php @@ -33,14 +33,14 @@ public function authorize(): bool public function rules(): array { return [ - "commentable_id" => ['required', 'integer'], - "commentable_key" => [ + 'commentable_id' => ['required', 'integer'], + 'commentable_key' => [ 'required', 'string', Rule::in(config('comment.commentable_keys')), ], - "content" => ["required", "string", "max:4000"], - "parent_comment_id" => ["nullable", "exists:App\Models\Comment,id"] + 'content' => ['required', 'string', 'max:4000'], + 'parent_comment_id' => ['nullable', "exists:App\Models\Comment,id"], ]; } } diff --git a/app/Http/Requests/ComputerScienceResource/StoreResourceRequest.php b/app/Http/Requests/ComputerScienceResource/StoreResourceRequest.php index eecb12b1..14640773 100644 --- a/app/Http/Requests/ComputerScienceResource/StoreResourceRequest.php +++ b/app/Http/Requests/ComputerScienceResource/StoreResourceRequest.php @@ -2,14 +2,12 @@ namespace App\Http\Requests\ComputerScienceResource; -use App\Http\Requests\Shared\ComputerScienceResourceRequest; -use Illuminate\Support\Facades\Auth; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Support\Facades\Auth; +use Illuminate\Validation\Rule; class StoreResourceRequest extends FormRequest { - use ComputerScienceResourceRequest; - /** * Determine if the user is authorized to make this request. */ @@ -25,6 +23,23 @@ public function authorize(): bool */ public function rules(): array { - return $this->baseResourceRules(); + return [ + 'name' => ['required', 'string', 'max:100'], + 'description' => ['required', 'string', 'max:10000'], + 'platforms' => ['required', 'array', 'min:1'], + 'platforms.*' => ['required', 'distinct', 'string', Rule::in(config('computerScienceResource.platforms'))], + 'page_url' => ['required', 'string', 'url:http,https', 'max:255'], + 'difficulty' => ['required', 'string', Rule::in(config('computerScienceResource.difficulties'))], + 'pricing' => ['required', 'string', Rule::in(config('computerScienceResource.pricings'))], + 'topic_tags' => ['required', 'array', 'min:3'], + 'topic_tags.*' => ['required', 'distinct', 'string', 'max:50'], + + // Optional, can just be omitted + 'image_file' => ['nullable', 'image', 'max:400'], // 400 kiloBytes + 'general_tags' => ['array'], + 'general_tags.*' => ['required', 'distinct', 'string', 'max:50'], + 'programming_language_tags' => ['array'], + 'programming_language_tags.*' => ['required', 'distinct', 'string', 'max:50'], + ]; } } diff --git a/app/Http/Requests/ResourceEdit/StoreResourceEdit.php b/app/Http/Requests/ResourceEdit/StoreResourceEdit.php index 517654a8..22bc4558 100644 --- a/app/Http/Requests/ResourceEdit/StoreResourceEdit.php +++ b/app/Http/Requests/ResourceEdit/StoreResourceEdit.php @@ -2,13 +2,12 @@ namespace App\Http\Requests\ResourceEdit; -use App\Http\Requests\Shared\ComputerScienceResourceRequest; -use Illuminate\Support\Facades\Auth; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Support\Facades\Auth; +use Illuminate\Validation\Rule; class StoreResourceEdit extends FormRequest { - use ComputerScienceResourceRequest; /** * Determine if the user is authorized to make this request. */ @@ -24,9 +23,25 @@ public function authorize(): bool */ public function rules(): array { - return array_merge($this->baseResourceRules(), [ + return [ 'edit_title' => ['required', 'string', 'max:100'], 'edit_description' => ['required', 'string', 'max:10000'], - ]); + 'proposed_changes' => ['sometimes', 'array'], + + 'proposed_changes.name' => ['nullable', 'string', 'max:100'], + 'proposed_changes.description' => ['nullable', 'string', 'max:10000'], + 'proposed_changes.platforms' => ['nullable', 'array'], + 'proposed_changes.platforms.*' => ['required', 'distinct', 'string', Rule::in(config('computerScienceResource.platforms'))], + 'proposed_changes.page_url' => ['nullable', 'string', 'url:http,https', 'max:255'], + 'proposed_changes.difficulty' => ['nullable', 'string', Rule::in(config('computerScienceResource.difficulties'))], + 'proposed_changes.pricing' => ['nullable', 'string', Rule::in(config('computerScienceResource.pricings'))], + 'proposed_changes.topic_tags' => ['nullable', 'array', 'min:3'], + 'proposed_changes.topic_tags.*' => ['required', 'distinct', 'string', 'max:50'], + 'proposed_changes.image_file' => ['nullable', 'image', 'max:400'], // 400 kilobytes + 'proposed_changes.general_tags' => ['nullable', 'array'], + 'proposed_changes.general_tags.*' => ['required', 'distinct', 'string', 'max:50'], + 'proposed_changes.programming_language_tags' => ['nullable', 'array'], + 'proposed_changes.programming_language_tags.*' => ['required', 'distinct', 'string', 'max:50'], + ]; } } diff --git a/app/Http/Requests/Shared/ComputerScienceResourceRequest.php b/app/Http/Requests/Shared/ComputerScienceResourceRequest.php index 188325eb..e69de29b 100644 --- a/app/Http/Requests/Shared/ComputerScienceResourceRequest.php +++ b/app/Http/Requests/Shared/ComputerScienceResourceRequest.php @@ -1,32 +0,0 @@ - ['required', 'string', 'max:100'], - 'description' => ['required', 'string', 'max:10000'], - 'platforms' => ['required', 'array', 'min:1'], - 'platforms.*' => ['required', 'distinct', 'string', Rule::in(config('computerScienceResource.platforms'))], - 'page_url' => ['required', 'string', 'url:http,https', 'max:255'], - 'image_url' => ['nullable', 'string', 'url:http,https', 'max:255'], - 'difficulty' => ['required', 'string', Rule::in(config('computerScienceResource.difficulties'))], - 'pricing' => ['required', 'string', Rule::in(config('computerScienceResource.pricings'))], - - 'topic_tags' => ['required', 'array', 'min:3'], - 'topic_tags.*' => ['required', 'distinct', 'string', 'max:50'], - - // Optional, can just be omitted - 'general_tags' => ['array'], - 'general_tags.*' => ['required', 'distinct', 'string', 'max:50'], - 'programming_language_tags' => ['array'], - 'programming_language_tags.*' => ['required', 'distinct', 'string', 'max:50'], - ]; - } - -} diff --git a/app/Http/Resources/ComputerScienceResourceResource.php b/app/Http/Resources/ComputerScienceResourceResource.php index c347f3f3..871cf5bd 100644 --- a/app/Http/Resources/ComputerScienceResourceResource.php +++ b/app/Http/Resources/ComputerScienceResourceResource.php @@ -18,13 +18,13 @@ public function toArray(Request $request): array 'name' => $this->name, 'description' => $this->description, 'page_url' => $this->page_url, - 'image_url' => $this->image_url, + 'image_path' => $this->image_path, 'platforms' => $this->platforms, 'difficulty' => $this->difficulty, 'pricing' => $this->pricing, 'topic_tags' => $this->topic_tags, 'programming_language_tags' => $this->programming_language_tags, 'general_tags' => $this->general_tags, - ]; + ]; } } diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index a6e8fe74..d51a712e 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -23,5 +23,4 @@ public function toArray(Request $request): array 'profile_photo_url' => $this->profile_photo_url, ]; } - } diff --git a/app/Listeners/ModifyTagFrequency.php b/app/Listeners/ModifyTagFrequency.php index 52ca429d..2569580a 100644 --- a/app/Listeners/ModifyTagFrequency.php +++ b/app/Listeners/ModifyTagFrequency.php @@ -3,9 +3,6 @@ namespace App\Listeners; use App\Events\TagFrequencyChanged; -use App\Models\TagFrequency; -use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Queue\InteractsWithQueue; use Illuminate\Support\Facades\DB; class ModifyTagFrequency @@ -26,7 +23,7 @@ public function handle(TagFrequencyChanged $event): void $old = $event->oldTags ?? []; $new = $event->newTags ?? []; - // 1. Build diffs for every tag + // Build diffs for every tag $diffs = []; foreach (array_unique(array_merge(array_keys($old), array_keys($new))) as $tag) { $diff = ($new[$tag] ?? 0) - ($old[$tag] ?? 0); @@ -39,7 +36,7 @@ public function handle(TagFrequencyChanged $event): void return; } - // 2. One upsert: increment (or decrement) existing, insert new + // One upsert: increment (or decrement) existing, insert new $upserts = []; foreach ($diffs as $tag => $count) { $upserts[] = [ @@ -56,7 +53,7 @@ public function handle(TagFrequencyChanged $event): void ] ); - // 3. Clean out any zero-or-negative counts + // Clean out any zero-or-negative counts DB::table('tag_frequencies') ->where('count', '<=', 0) ->delete(); diff --git a/app/Listeners/UpdateCommentsCount.php b/app/Listeners/UpdateCommentsCount.php deleted file mode 100644 index a8a768d9..00000000 --- a/app/Listeners/UpdateCommentsCount.php +++ /dev/null @@ -1,40 +0,0 @@ - $event->commentable_type, - 'commentable_id' => $event->commentable_id - ] - ); - - // Add 1 - $commentsCount->count = $commentsCount->count + 1; - - $commentsCount->save(); - } -} diff --git a/app/Listeners/UpdateResourceReviewSummary.php b/app/Listeners/UpdateResourceReviewSummary.php index 74a973fc..3b9e07a3 100644 --- a/app/Listeners/UpdateResourceReviewSummary.php +++ b/app/Listeners/UpdateResourceReviewSummary.php @@ -5,8 +5,6 @@ use App\Events\ResourceReviewProcessed; use App\Models\ResourceReviewSummary; use Illuminate\Support\Facades\Log; -use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Queue\InteractsWithQueue; class UpdateResourceReviewSummary { @@ -23,17 +21,25 @@ public function __construct() */ public function handle(ResourceReviewProcessed $event): void { - Log::debug("Handling ResourceReviewProcessed: " . json_encode($event)); + Log::debug('Handling ResourceReviewProcessed', [ + 'resource_id' => $event->resource, + 'old_review' => $event->oldReview, + 'new_review' => $event->newReview, + ]); if ($event->oldReview == null && $event->newReview == null) { - Log::critical("Update Resource Review Summary Listener reached impossible condition: null oldReview and null newReview"); + Log::critical('Update Resource Review Summary Listener reached impossible condition', [ + 'resource_id' => $event->resource, + 'error' => 'Both oldReview and newReview are null', + ]); + return; } - + $review_summary = ResourceReviewSummary::firstOrNew( ['computer_science_resource_id' => $event->resource], ); - + $fields = [ 'community', 'teaching_clarity', @@ -42,18 +48,18 @@ public function handle(ResourceReviewProcessed $event): void 'user_friendliness', 'updates', ]; - + foreach ($fields as $field) { $old = $event->oldReview[$field] ?? 0; $new = $event->newReview[$field] ?? 0; $review_summary->$field += ($new - $old); } - + if ($event->oldReview === null) { // It's a new review, so increase the count $review_summary->review_count += 1; } - + $review_summary->save(); } } diff --git a/app/Listeners/UpdateUpvoteSummary.php b/app/Listeners/UpdateUpvoteSummary.php index 0e92c9b1..beefee9a 100644 --- a/app/Listeners/UpdateUpvoteSummary.php +++ b/app/Listeners/UpdateUpvoteSummary.php @@ -5,8 +5,6 @@ use App\Events\UpvoteProcessed; use App\Models\UpvoteSummary; use Illuminate\Support\Facades\Log; -use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Queue\InteractsWithQueue; class UpdateUpvoteSummary { @@ -20,33 +18,32 @@ public function __construct() /** * Handle the event. - */ + */ public function handle(UpvoteProcessed $event): void { - Log::debug("Handling UpvoteProcessed: " . json_encode($event)); + Log::debug('Handling UpvoteProcessed', [ + 'upvotable_id' => $event->id, + 'upvotable_type' => $event->type, + 'previous_value' => $event->previousValue, + 'new_value' => $event->newValue, + ]); $summary = UpvoteSummary::firstOrNew([ 'upvotable_id' => $event->id, - 'upvotable_type' => $event->type + 'upvotable_type' => $event->type, ]); // Remove the past value - if ($event->previousValue > 0) - { + if ($event->previousValue > 0) { $summary->upvotes -= $event->previousValue; - } - elseif ($event->previousValue < 0) - { + } elseif ($event->previousValue < 0) { $summary->downvotes -= abs($event->previousValue); } // Add the new value - if ($event->newValue > 0) - { + if ($event->newValue > 0) { $summary->upvotes += $event->newValue; - } - elseif ($event->newValue < 0) - { + } elseif ($event->newValue < 0) { $summary->downvotes += abs($event->newValue); } diff --git a/app/Models/Comment.php b/app/Models/Comment.php index 2c6c8c0c..990957de 100644 --- a/app/Models/Comment.php +++ b/app/Models/Comment.php @@ -2,18 +2,26 @@ namespace App\Models; +use App\Observers\CommentObserver; use App\Traits\HasVotes; +use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use ShiftOneLabs\LaravelCascadeDeletes\CascadesDeletes; +#[ObservedBy([CommentObserver::class])] class Comment extends Model { + use CascadesDeletes; + /** @use HasFactory<\Database\Factories\CommentFactory> */ use HasFactory; use HasVotes; + protected $cascadeDeletes = ['votes', 'upvoteSummary']; + protected $with = ['votes', 'upvoteSummary']; protected $appends = ['vote_score', 'user_vote']; diff --git a/app/Models/CommentsCount.php b/app/Models/CommentsCount.php index 32d3f5e7..26194f19 100644 --- a/app/Models/CommentsCount.php +++ b/app/Models/CommentsCount.php @@ -6,6 +6,5 @@ class CommentsCount extends Model { - // protected $fillable = ['commentable_type', 'commentable_id']; } diff --git a/app/Models/ComputerScienceResource.php b/app/Models/ComputerScienceResource.php index 8cc750ab..771714bd 100644 --- a/app/Models/ComputerScienceResource.php +++ b/app/Models/ComputerScienceResource.php @@ -2,36 +2,50 @@ namespace App\Models; +use App\Observers\ComputerScienceResourceObserver; use App\Traits\HasComments; use App\Traits\HasVotes; +use Illuminate\Database\Eloquent\Attributes\ObservedBy; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; -use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Casts\Attribute; -use Spatie\Tags\HasTags; -use Illuminate\Support\Facades\Auth; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Support\Facades\Storage; +use Spatie\Tags\HasTags; +#[ObservedBy([ComputerScienceResourceObserver::class])] class ComputerScienceResource extends Model { + use HasComments; + /** @use HasFactory<\Database\Factories\ComputerScienceResourceFactory> */ use HasFactory; use HasTags; - use HasComments; use HasVotes; - protected $table = "computer_science_resources"; + protected $table = 'computer_science_resources'; protected $guarded = []; - protected $appends = ['topic_tags', 'programming_language_tags', 'general_tags', 'vote_score', 'user_vote', 'comments_count']; + protected $appends = ['topic_tags', 'programming_language_tags', 'general_tags', 'vote_score', 'user_vote', 'comments_count', 'image_url']; public function user(): BelongsTo { return $this->belongsTo(User::class); } + /** + * Attribute to get the image_url + */ + protected function imageUrl(): Attribute + { + return Attribute::make( + get: fn () => $this->image_path ? Storage::url($this->image_path) : null, + ); + } + /** * Get the review summary relationship. */ @@ -55,59 +69,52 @@ public function edits(): HasMany /** * Attribute to get and set platforms as an array - * - * @return Attribute */ protected function platforms(): Attribute { return Attribute::make( - get: fn($value) => explode(',', $value), - set: fn($value) => implode(',', $value) + get: fn ($value) => explode(',', $value), + set: fn ($value) => implode(',', $value) ); } /** * Accessor to get topic tags. - * - * @return Attribute */ protected function topicTags(): Attribute { return Attribute::make( - get: fn() => $this->tagsWithType('topics')->pluck('name')->toArray(), - set: fn(array $value) => $this->syncTagsWithType($value, 'topics') + get: fn () => $this->tagsWithType('topics')->pluck('name')->toArray(), + set: fn (array $value) => $this->syncTagsWithType($value, 'topics') ); } /** * Accessor to get programming language tags. - * - * @return Attribute */ protected function programmingLanguageTags(): Attribute { return Attribute::make( - get: fn() => $this->tagsWithType('programming_languages')->pluck('name')->toArray(), - set: fn(array $value) => $this->syncTagsWithType($value, 'programming_languages') + get: fn () => $this->tagsWithType('programming_languages')->pluck('name')->toArray(), + set: fn (array $value) => $this->syncTagsWithType($value, 'programming_languages') ); } /** * Accessor to get general tags. - * - * @return Attribute */ protected function generalTags(): Attribute { return Attribute::make( - get: fn() => $this->tagsWithType('general_tags')->pluck('name')->toArray(), - set: fn(array $value) => $this->syncTagsWithType($value, 'general_tags') + get: fn () => $this->tagsWithType('general_tags')->pluck('name')->toArray(), + set: fn (array $value) => $this->syncTagsWithType($value, 'general_tags') ); } public function tagCounter(): array { $tag_collection = collect([$this->topic_tags, $this->programming_language_tags, $this->general_tags]); + return $tag_collection->flatten()->countBy()->toArray(); } } diff --git a/app/Models/ResourceEdits.php b/app/Models/ResourceEdits.php index df99a59b..1766c2fd 100644 --- a/app/Models/ResourceEdits.php +++ b/app/Models/ResourceEdits.php @@ -2,31 +2,39 @@ namespace App\Models; +use App\Observers\ResourceEditsObserver; use App\Services\ResourceEditsService; use App\Traits\HasComments; use App\Traits\HasVotes; +use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Storage; +use ShiftOneLabs\LaravelCascadeDeletes\CascadesDeletes; +#[ObservedBy([ResourceEditsObserver::class])] class ResourceEdits extends Model { + use CascadesDeletes; + + use HasComments; /** @use HasFactory<\Database\Factories\ResourceEditsFactory> */ use HasFactory; - use HasComments; use HasVotes; + protected $cascadeDeletes = ['votes', 'upvoteSummary', 'comments', 'commentsCountRelationship']; + protected $guarded = []; - protected $with = ['votes','upvoteSummary', 'commentsCountRelationship', 'resource']; + protected $with = ['votes', 'upvoteSummary', 'commentsCountRelationship', 'resource']; protected $appends = ['user_vote', 'vote_score', 'comments_count', 'can_merge_edits']; protected $casts = [ - 'topic_tags' => 'array', - 'programming_language_tags' => 'array', - 'general_tags' => 'array', + 'proposed_changes' => 'array', ]; public function resource(): BelongsTo @@ -34,28 +42,36 @@ public function resource(): BelongsTo return $this->belongsTo(ComputerScienceResource::class, 'computer_science_resource_id', 'id'); } - /** - * Attribute to get and set platforms as an array - * - * @return Attribute - */ - protected function platforms(): Attribute + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + protected function proposedChanges(): Attribute { return Attribute::make( - get: fn($value) => explode(',', $value), - set: fn($value) => implode(',', $value) + get: function ($value) { + $changes = json_decode($value, true); + + if (array_key_exists('image_path', $changes)) { + $changes['image_url'] = null; + if ($changes['image_path']) { + $changes['image_url'] = Storage::url($changes['image_path']); + } + } + + return $changes; + }, ); } /** * Attribute to know if the edit can be merged - * - * @return Attribute */ protected function canMergeEdits(): Attribute { return Attribute::make( - get: fn () => app(ResourceEditsService::class)->canMergeEdits($this), + get: fn () => app(ResourceEditsService::class)->canMergeEdits($this) && Auth::id() === $this->user_id, ); } -} \ No newline at end of file +} diff --git a/app/Models/ResourceReview.php b/app/Models/ResourceReview.php index 79d89ac4..2b5b73a9 100644 --- a/app/Models/ResourceReview.php +++ b/app/Models/ResourceReview.php @@ -2,18 +2,23 @@ namespace App\Models; +use App\Observers\ResourceReviewObserver; use App\Traits\HasComments; use App\Traits\HasVotes; +use Illuminate\Database\Eloquent\Attributes\ObservedBy; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Casts\Attribute; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +#[ObservedBy([ResourceReviewObserver::class])] class ResourceReview extends Model { + use HasComments; + /** @use HasFactory<\Database\Factories\ResourceReviewFactory> */ use HasFactory; use HasVotes; - use HasComments; protected $guarded = []; @@ -26,10 +31,13 @@ class ResourceReview extends Model 'cons' => 'array', ]; + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + /** * Get the average review score. - * - * @return Attribute */ protected function averageScore(): Attribute { diff --git a/app/Models/ResourceReviewSummary.php b/app/Models/ResourceReviewSummary.php index 08118068..ecff7592 100644 --- a/app/Models/ResourceReviewSummary.php +++ b/app/Models/ResourceReviewSummary.php @@ -4,13 +4,14 @@ use App\Traits\HasComments; use App\Traits\HasVotes; -use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; class ResourceReviewSummary extends Model { - use HasVotes; use HasComments; + use HasVotes; + + public $timestamps = false; protected $fillable = ['computer_science_resource_id']; diff --git a/app/Models/Upvote.php b/app/Models/Upvote.php index 57d4b457..6a95c3c2 100644 --- a/app/Models/Upvote.php +++ b/app/Models/Upvote.php @@ -10,5 +10,7 @@ class Upvote extends Model /** @use HasFactory<\Database\Factories\UpvoteFactory> */ use HasFactory; + public $timestamps = false; + protected $fillable = ['value', 'user_id']; } diff --git a/app/Models/UpvoteSummary.php b/app/Models/UpvoteSummary.php index ca3cbe83..48c308e1 100644 --- a/app/Models/UpvoteSummary.php +++ b/app/Models/UpvoteSummary.php @@ -3,27 +3,28 @@ namespace App\Models; use Illuminate\Database\Eloquent\Casts\Attribute; -use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; class UpvoteSummary extends Model { use HasFactory; public $timestamps = false; + protected $fillable = ['upvotable_id', 'upvotable_type']; public function voteScore(): Attribute { return Attribute::make( - get: fn() => $this->upvotes - $this->downvotes + get: fn () => $this->upvotes - $this->downvotes ); } protected function votesCount(): Attribute { return Attribute::make( - get: fn() => $this->upvotes + $this->downvotes, + get: fn () => $this->upvotes + $this->downvotes, ); } } diff --git a/app/Models/User.php b/app/Models/User.php index 2b475cce..7482db6e 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\HasCustomProfilePhoto; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; @@ -16,10 +17,10 @@ class User extends Authenticatable { use HasApiTokens; use HasConnectedAccounts; - use HasFactory; - use HasProfilePhoto { - HasProfilePhoto::profilePhotoUrl as getPhotoUrl; + use HasCustomProfilePhoto, HasProfilePhoto { + HasCustomProfilePhoto::profilePhotoUrl as getPhotoUrl; } + use HasFactory; use Notifiable; use SetsProfilePhotoFromUrl; use TwoFactorAuthenticatable; diff --git a/app/Observers/CommentObserver.php b/app/Observers/CommentObserver.php new file mode 100644 index 00000000..29bcf29d --- /dev/null +++ b/app/Observers/CommentObserver.php @@ -0,0 +1,87 @@ + $comment->commentable_type, + 'commentable_id' => $comment->commentable_id, + ]); + + // Create the upvotes summary + UpvoteSummary::create([ + 'upvotable_id' => $comment->id, + 'upvotable_type' => Comment::class, + ]); + + $commentsCount = CommentsCount::firstOrNew( + [ + 'commentable_type' => $comment->commentable_type, + 'commentable_id' => $comment->commentable_id, + ] + ); + + // Add 1 to the count (handle null case) + $commentsCount->count = ($commentsCount->count ?? 0) + 1; + + $commentsCount->save(); + } + + /** + * Handle the Comment "updated" event. + */ + public function updated(Comment $comment): void + { + // + } + + /** + * Handle the Comment "deleted" event. + */ + public function deleted(Comment $comment): void + { + Log::debug('Handling comment deleted', [ + 'comment_id' => $comment->id, + 'commentable_type' => $comment->commentable_type, + 'commentable_id' => $comment->commentable_id, + ]); + + // Decrease the comment count + $commentsCount = CommentsCount::where([ + 'commentable_type' => $comment->commentable_type, + 'commentable_id' => $comment->commentable_id, + ])->first(); + + if ($commentsCount) { + $commentsCount->count = max(0, ($commentsCount->count ?? 1) - 1); + $commentsCount->save(); + } + } + + /** + * Handle the Comment "restored" event. + */ + public function restored(Comment $comment): void + { + // + } + + /** + * Handle the Comment "force deleted" event. + */ + public function forceDeleted(Comment $comment): void + { + // + } +} diff --git a/app/Observers/ComputerScienceResourceObserver.php b/app/Observers/ComputerScienceResourceObserver.php new file mode 100644 index 00000000..919a5f50 --- /dev/null +++ b/app/Observers/ComputerScienceResourceObserver.php @@ -0,0 +1,56 @@ + $computerScienceResource->id, + 'upvotable_type' => ComputerScienceResource::class, + ]); + + // TagFrequencyChanged is in store ComputerScienceResource controller + } + + /** + * Handle the ComputerScienceResource "updated" event. + */ + public function updated(ComputerScienceResource $computerScienceResource): void + { + // + } + + /** + * Handle the ComputerScienceResource "deleted" event. + */ + public function deleted(ComputerScienceResource $computerScienceResource): void + { + // + } + + /** + * Handle the ComputerScienceResource "restored" event. + */ + public function restored(ComputerScienceResource $computerScienceResource): void + { + // + } + + /** + * Handle the ComputerScienceResource "force deleted" event. + */ + public function forceDeleted(ComputerScienceResource $computerScienceResource): void + { + // + } +} diff --git a/app/Observers/ResourceEditsObserver.php b/app/Observers/ResourceEditsObserver.php new file mode 100644 index 00000000..998d5685 --- /dev/null +++ b/app/Observers/ResourceEditsObserver.php @@ -0,0 +1,53 @@ + $resourceEdits->id, + 'upvotable_type' => ResourceEdits::class, + ]); + } + + /** + * Handle the ResourceEdits "updated" event. + */ + public function updated(ResourceEdits $resourceEdits): void + { + // + } + + /** + * Handle the ResourceEdits "deleted" event. + */ + public function deleted(ResourceEdits $resourceEdits): void + { + // + } + + /** + * Handle the ResourceEdits "restored" event. + */ + public function restored(ResourceEdits $resourceEdits): void + { + // + } + + /** + * Handle the ResourceEdits "force deleted" event. + */ + public function forceDeleted(ResourceEdits $resourceEdits): void + { + // + } +} diff --git a/app/Observers/ResourceReviewObserver.php b/app/Observers/ResourceReviewObserver.php new file mode 100644 index 00000000..025bc51f --- /dev/null +++ b/app/Observers/ResourceReviewObserver.php @@ -0,0 +1,64 @@ + $resourceReview->id, + 'upvotable_type' => ResourceReview::class, + ]); + + ResourceReviewProcessed::dispatch( + $resourceReview->computer_science_resource_id, + null, + $resourceReview->attributesToArray() + ); + } + + /** + * Handle the ResourceReview "updated" event. + */ + public function updated(ResourceReview $resourceReview): void + { + ResourceReviewProcessed::dispatch( + $resourceReview->computer_science_resource_id, + $resourceReview->getOriginal(), + $resourceReview->attributesToArray() + ); + } + + /** + * Handle the ResourceReview "deleted" event. + */ + public function deleted(ResourceReview $resourceReview): void + { + // + } + + /** + * Handle the ResourceReview "restored" event. + */ + public function restored(ResourceReview $resourceReview): void + { + // + } + + /** + * Handle the ResourceReview "force deleted" event. + */ + public function forceDeleted(ResourceReview $resourceReview): void + { + // + } +} diff --git a/app/Policies/ComputerScienceResourcePolicy.php b/app/Policies/ComputerScienceResourcePolicy.php index fd4fd46f..843d58e8 100644 --- a/app/Policies/ComputerScienceResourcePolicy.php +++ b/app/Policies/ComputerScienceResourcePolicy.php @@ -4,7 +4,6 @@ use App\Models\ComputerScienceResource; use App\Models\User; -use Illuminate\Auth\Access\Response; class ComputerScienceResourcePolicy { diff --git a/app/Policies/ResourceEditsPolicy.php b/app/Policies/ResourceEditsPolicy.php new file mode 100644 index 00000000..881e4fd7 --- /dev/null +++ b/app/Policies/ResourceEditsPolicy.php @@ -0,0 +1,70 @@ +id == $resourceEdits->user_id; + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return false; + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, ResourceEdits $resourceEdits): bool + { + return false; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, ResourceEdits $resourceEdits): bool + { + return false; + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, ResourceEdits $resourceEdits): bool + { + return false; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, ResourceEdits $resourceEdits): bool + { + return false; + } +} diff --git a/app/Providers/ModelResolverServiceProvider.php b/app/Providers/ModelResolverServiceProvider.php index f4429d2a..291d8832 100644 --- a/app/Providers/ModelResolverServiceProvider.php +++ b/app/Providers/ModelResolverServiceProvider.php @@ -2,8 +2,8 @@ namespace App\Providers; -use Illuminate\Support\ServiceProvider; use App\Services\ModelResolverService; +use Illuminate\Support\ServiceProvider; class ModelResolverServiceProvider extends ServiceProvider { @@ -13,7 +13,7 @@ class ModelResolverServiceProvider extends ServiceProvider public function register(): void { $this->app->singleton(ModelResolverService::class, function ($app) { - return new ModelResolverService(); + return new ModelResolverService; }); } diff --git a/app/Services/CommentService.php b/app/Services/CommentService.php index 36938f93..9b3432c3 100644 --- a/app/Services/CommentService.php +++ b/app/Services/CommentService.php @@ -4,8 +4,8 @@ use App\Http\Resources\CommentResource; use App\Http\Resources\UserResource; -use App\Services\SortingManagers\GeneralVotesSortingManager; use App\Models\Comment; +use App\Services\SortingManagers\GeneralVotesSortingManager; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Validator; @@ -15,7 +15,7 @@ class CommentService { protected $modelResolver; - function __construct(ModelResolverService $resolver) + public function __construct(ModelResolverService $resolver) { $this->modelResolver = $resolver; } @@ -23,15 +23,11 @@ function __construct(ModelResolverService $resolver) /** * Get paginated comments with custom logic. * - * @param string $commentableType - * @param int $commentableId - * @param int $index - * @return array + * @param string $commentableType */ public function getPaginatedComments(string $commentableKey, int $commentableId, int $index, int $paginationLimit = -1, string $sortBy = 'top'): array { - if ($paginationLimit == -1) - { + if ($paginationLimit == -1) { $paginationLimit = config('comment.default_pagination_limit'); } @@ -39,14 +35,22 @@ public function getPaginatedComments(string $commentableKey, int $commentableId, 'index' => $index, 'commentable_key' => $commentableKey, 'pagination_limit' => $paginationLimit, + 'sort_by' => $sortBy, ], [ 'index' => ['required', 'integer', 'min:0'], 'commentable_key' => ['required', Rule::in(config('comment.commentable_keys'))], - 'pagination_limit' => ['required', 'integer', 'max:' . config('comment.pagination_limit')], + 'pagination_limit' => ['required', 'integer', 'max:'.config('comment.pagination_limit')], + 'sort_by' => ['required', 'string'], ]); $commentableType = $this->modelResolver->getModelClass($commentableKey); - Log::debug("Request is, commentable_type: {$commentableType}. id: {$commentableId}. index: {$index}"); + Log::debug('Getting paginated comments', [ + 'commentable_type' => $commentableType, + 'commentable_id' => $commentableId, + 'index' => $index, + 'sort_by' => $sortBy, + 'pagination_limit' => $paginationLimit, + ]); // Get the root comments: $query = Comment::where([ @@ -59,7 +63,11 @@ public function getPaginatedComments(string $commentableKey, int $commentableId, $query = app(GeneralVotesSortingManager::class)->applySort($query, $sortBy, Comment::class); $rootComments = $query->get(); - Log::debug("Root comments: " . json_encode($rootComments)); + Log::debug('Root comments retrieved', [ + 'count' => $rootComments->count(), + 'commentable_type' => $commentableType, + 'commentable_id' => $commentableId, + ]); // Initialize variables $currentCommentsSum = 0; @@ -73,13 +81,13 @@ public function getPaginatedComments(string $commentableKey, int $commentableId, // Handle comments that exceed MAX when alone in a page if ($currentCommentsSum + $childrenCount > $paginationLimit) { if ($currentCommentsSum === 0) { - // Force include oversized comment if it's the first in page - Log::warning("Had to force include for oversized comment tree. Should consider increasing the max commentx in config or lowering the replies size limit."); + // Force include oversized comment if ($currentIndex === $index) { $resultingPaginatedComments[] = $comment; $currentCommentsSum += $childrenCount; } $currentIndex++; + continue; } @@ -93,7 +101,7 @@ public function getPaginatedComments(string $commentableKey, int $commentableId, break; } // Only add comments for the desired index - else if ($currentIndex === $index) { + elseif ($currentIndex === $index) { $resultingPaginatedComments[] = $comment; } $currentCommentsSum += $childrenCount; @@ -131,7 +139,13 @@ public function getPaginatedComments(string $commentableKey, int $commentableId, } } - Log::debug("Returned comments: " . json_encode($flattenedComments)); + Log::debug('Returning paginated comments', [ + 'comments_count' => $flattenedComments->count(), + 'users_count' => $users->count(), + 'has_more_comments' => $hasMoreComments, + 'current_index' => $index, + ]); + return [ 'comments' => $flattenedComments, 'users' => $users->values(), diff --git a/app/Services/ComputerScienceResourceFilter.php b/app/Services/ComputerScienceResourceFilter.php index f2cec7de..2670d6e3 100644 --- a/app/Services/ComputerScienceResourceFilter.php +++ b/app/Services/ComputerScienceResourceFilter.php @@ -1,13 +1,15 @@ ['nullable', 'string', 'max:1000'], 'platforms' => ['nullable', 'array'], 'platforms.*' => ['required', 'distinct', 'string', Rule::in(config('computerScienceResource.platforms'))], - 'difficulty' => ['nullable', 'string', Rule::in(config('computerScienceResource.difficulties'))], - 'pricing' => ['nullable', 'string', Rule::in(config('computerScienceResource.pricings'))], + 'difficulty' => ['nullable', 'array'], + 'difficulty.*' => ['required', 'distinct', 'string', Rule::in(config('computerScienceResource.difficulties'))], + 'pricing' => ['nullable', 'array'], + 'pricing.*' => ['required', 'distinct', 'string', Rule::in(config('computerScienceResource.pricings'))], 'topics' => ['nullable', 'array'], 'topics.*' => ['required', 'distinct', 'string', 'max:50'], 'general_tags' => ['nullable', 'array'], @@ -32,7 +36,9 @@ public function validate(array $request) 'programming_languages' => ['nullable', 'array'], 'programming_languages.*' => ['required', 'distinct', 'string', 'max:50'], - 'community_rating' => ['nullable', 'integer', 'between:1,4'], + // Fixed field names to match frontend + 'overall' => ['nullable', 'integer', 'between:1,4'], + 'community' => ['nullable', 'integer', 'between:1,4'], 'teaching_clarity' => ['nullable', 'integer', 'between:1,4'], 'engagement' => ['nullable', 'integer', 'between:1,4'], 'practicality' => ['nullable', 'integer', 'between:1,4'], @@ -44,8 +50,8 @@ public function validate(array $request) 'updated_from' => ['nullable', 'date'], 'updated_to' => ['nullable', 'date'], - 'sort_by' => ['nullable', 'string'], - 'reverse' => ['nullable', 'string'], + 'sort_by' => ['string'], + 'reverse' => ['string'], ]; $validator = Validator::make($request, $rules); @@ -60,17 +66,17 @@ public function applyFilters($query, array $filters) $query->with(['tags', 'votes', 'upvoteSummary', 'reviewSummary', 'commentsCountRelationship']); // Fulltext search on name - if (!empty($filters['name'])) { + if (! empty($filters['name'])) { $query->whereFullText('name', $filters['name']); } // Fulltext search on description - if (!empty($filters['description'])) { + if (! empty($filters['description'])) { $query->whereFullText('description', $filters['description']); } // Filter by platforms (array) - if (!empty($filters['platforms'])) { + if (! empty($filters['platforms'])) { $query->where(function ($q) use ($filters) { foreach ($filters['platforms'] as $platform) { $q->orWhereRaw('FIND_IN_SET(?, platforms)', [$platform]); @@ -79,27 +85,27 @@ public function applyFilters($query, array $filters) } // Filter by difficulty - if (!empty($filters['difficulty'])) { + if (! empty($filters['difficulty'])) { $query->whereIn('difficulty', (array) $filters['difficulty']); } // Filter by pricing - if (!empty($filters['pricing'])) { + if (! empty($filters['pricing'])) { $query->whereIn('pricing', (array) $filters['pricing']); } // Filter by topic tags - if (!empty($filters['topics'])) { + if (! empty($filters['topics'])) { $query->withAnyTags((array) $filters['topics'], 'topics'); } // Filter by programming languages - if (!empty($filters['programming_languages'])) { + if (! empty($filters['programming_languages'])) { $query->withAnyTags((array) $filters['programming_languages'], 'programming_languages'); } // Filter by general tags - if (!empty($filters['general_tags'])) { + if (! empty($filters['general_tags'])) { $query->withAnyTags((array) $filters['general_tags'], 'general_tags'); } @@ -115,24 +121,24 @@ public function applyFilters($query, array $filters) ]; foreach ($ratingFilters as $field) { - if (!empty($filters[$field])) { + if (! empty($filters[$field])) { $query = $this->reviewService->applyRatingFilter($query, $field, $filters[$field]); } } // Filter by Date posted - if (!empty($filters['created_from'])) { + if (! empty($filters['created_from'])) { $query->whereDate('computer_science_resources.created_at', '>=', $filters['created_from']); } - if (!empty($filters['created_to'])) { + if (! empty($filters['created_to'])) { $query->whereDate('computer_science_resources.created_at', '<=', $filters['created_to']); } // Filter by Date updated - if (!empty($filters['updated_from'])) { + if (! empty($filters['updated_from'])) { $query->whereDate('computer_science_resources.updated_at', '>=', $filters['updated_from']); } - if (!empty($filters['updated_to'])) { + if (! empty($filters['updated_to'])) { $query->whereDate('computer_science_resources.updated_at', '<=', $filters['updated_to']); } diff --git a/app/Services/DataNormalizationService.php b/app/Services/DataNormalizationService.php new file mode 100644 index 00000000..a4f6530d --- /dev/null +++ b/app/Services/DataNormalizationService.php @@ -0,0 +1,30 @@ +normalize($array1) === $this->normalize($array2); + } +} diff --git a/app/Services/ModelResolverService.php b/app/Services/ModelResolverService.php index 58b78191..f31d1f95 100644 --- a/app/Services/ModelResolverService.php +++ b/app/Services/ModelResolverService.php @@ -14,17 +14,17 @@ class ModelResolverService /** * Finds the model that exists for the given type and id - * - * @param $type, the colloquial name for the type - * @param $id, the id for the type - * + * + * @param $type, the colloquial name for the type + * @param $id, the id for the type + * * returns null if no model exists, otherwise, it will return the model */ public function resolve($type, $id) { $modelClass = $this->getModelClass($type); - - if (!$modelClass) { + + if (! $modelClass) { return null; } diff --git a/app/Services/ResourceEditsService.php b/app/Services/ResourceEditsService.php index cbe0bc72..9f28aa8e 100644 --- a/app/Services/ResourceEditsService.php +++ b/app/Services/ResourceEditsService.php @@ -16,18 +16,17 @@ class ResourceEditsService */ public function requiredVotes(int $totalVotes): int { - // Either the current votes, or the log equation - $votes = min($totalVotes, - floor(log($totalVotes, 1.25)) + 1 - ); - return max(3, $votes); // Need to be 3 votes minimum + // Take the minimum of total votes OR the logarithmic calculation + $votes = min($totalVotes, floor(log($totalVotes, 1.25)) + 1); + + // Ensure minimum of 3 votes is always required + return max(3, $votes); } /** * Handles determining if a resource edit is mergeable, by getting the upvotes for the resource edit - * */ - public function canMergeEdits(ResourceEdits $edits) : bool + public function canMergeEdits(ResourceEdits $edits): bool { if (app()->isLocal()) { return true; diff --git a/app/Services/ResourceReviewService.php b/app/Services/ResourceReviewService.php index b2e1caef..b551c4de 100644 --- a/app/Services/ResourceReviewService.php +++ b/app/Services/ResourceReviewService.php @@ -2,9 +2,9 @@ namespace App\Services; +use App\Traits\HandlesResourceReviewJoins; use Illuminate\Database\Eloquent\Builder; use InvalidArgumentException; -use App\Traits\HandlesResourceReviewJoins; class ResourceReviewService { @@ -17,7 +17,7 @@ public function applyRatingFilter(Builder $query, string $field, int $minRating) } if ($minRating < 1 || $minRating > 5) { - throw new InvalidArgumentException("Rating must be between 1 and 5."); + throw new InvalidArgumentException('Rating must be between 1 and 5.'); } $query = $this->ensureReviewSummaryJoined($query); @@ -26,4 +26,3 @@ public function applyRatingFilter(Builder $query, string $field, int $minRating) return $query->where("{$reviewTable}.{$field}_rating", '>=', $minRating); } } - diff --git a/app/Services/SortingManagers/SortingManager.php b/app/Services/SortingManagers/SortingManager.php index bfb4696b..ca45df42 100644 --- a/app/Services/SortingManagers/SortingManager.php +++ b/app/Services/SortingManagers/SortingManager.php @@ -2,8 +2,8 @@ namespace App\Services\SortingManagers; -use Illuminate\Database\Eloquent\Builder; use App\Contracts\SortStrategy; +use Illuminate\Database\Eloquent\Builder; class SortingManager { diff --git a/app/SortingStrategies/ResourceReviewsSortingStrategy.php b/app/SortingStrategies/ResourceReviewsSortingStrategy.php index a6de2620..b734ecb0 100644 --- a/app/SortingStrategies/ResourceReviewsSortingStrategy.php +++ b/app/SortingStrategies/ResourceReviewsSortingStrategy.php @@ -3,8 +3,8 @@ namespace App\SortingStrategies; use App\Contracts\SortingStrategy; -use Illuminate\Database\Eloquent\Builder; use App\Traits\HandlesResourceReviewJoins; +use Illuminate\Database\Eloquent\Builder; class ResourceReviewsSortingStrategy implements SortingStrategy { @@ -17,7 +17,7 @@ public static function supports(string $sortBy): bool public static function apply(Builder $query, string $sortBy): Builder { - $instance = new self(); + $instance = new self; $resourceTable = $query->getModel()->getTable(); $query = $instance->ensureReviewSummaryJoined($query); $reviewTable = $instance->getReviewSummaryTable(); diff --git a/app/SortingStrategies/VoteSortingStrategy.php b/app/SortingStrategies/VoteSortingStrategy.php index 35a44db4..f2ad7c7f 100644 --- a/app/SortingStrategies/VoteSortingStrategy.php +++ b/app/SortingStrategies/VoteSortingStrategy.php @@ -17,10 +17,10 @@ public static function apply(Builder $query, string $sortBy): Builder $table = $query->getModel()->getTable(); $modelClass = get_class($query->getModel()); - # Join on polymorphic relationship - $query->leftJoin('upvote_summaries', function ($join) use ($table, $modelClass) { + // Join on polymorphic relationship + $query->join('upvote_summaries', function ($join) use ($table, $modelClass) { $join->on('upvote_summaries.upvotable_id', '=', "{$table}.id") - ->where('upvote_summaries.upvotable_type', '=', $modelClass); + ->where('upvote_summaries.upvotable_type', '=', $modelClass); })->select("{$table}.*"); switch ($sortBy) { diff --git a/app/Traits/HandlesResourceReviewJoins.php b/app/Traits/HandlesResourceReviewJoins.php index 5abcb847..6fdbe4f6 100644 --- a/app/Traits/HandlesResourceReviewJoins.php +++ b/app/Traits/HandlesResourceReviewJoins.php @@ -2,14 +2,14 @@ namespace App\Traits; -use Illuminate\Database\Eloquent\Builder; use App\Models\ResourceReviewSummary; +use Illuminate\Database\Eloquent\Builder; trait HandlesResourceReviewJoins { protected function ensureReviewSummaryJoined(Builder $query): Builder { - $reviewTable = (new ResourceReviewSummary())->getTable(); + $reviewTable = (new ResourceReviewSummary)->getTable(); $joins = $query->getQuery()->joins ?? []; @@ -32,7 +32,7 @@ protected function ensureReviewSummaryJoined(Builder $query): Builder protected function getReviewSummaryTable(): string { - return (new ResourceReviewSummary())->getTable(); + return (new ResourceReviewSummary)->getTable(); } protected function getAllowedReviewFields(): array diff --git a/app/Traits/HasComments.php b/app/Traits/HasComments.php index 4afab144..38041116 100644 --- a/app/Traits/HasComments.php +++ b/app/Traits/HasComments.php @@ -2,16 +2,25 @@ namespace App\Traits; +use App\Models\Comment; use App\Models\CommentsCount; use Illuminate\Database\Eloquent\Casts\Attribute; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; trait HasComments { + /** Get all of the comments for the HasComments + * + */ + public function comments(): HasMany + { + return $this->hasMany(Comment::class, 'commentable_id', 'id') + ->where('commentable_type', static::class); + } + /** * Define a relationship to the CommentsCount model. - * - * @return \Illuminate\Database\Eloquent\Relations\HasOne */ public function commentsCountRelationship(): HasOne { diff --git a/app/Traits/HasCustomProfilePhoto.php b/app/Traits/HasCustomProfilePhoto.php new file mode 100644 index 00000000..bf3c35fa --- /dev/null +++ b/app/Traits/HasCustomProfilePhoto.php @@ -0,0 +1,28 @@ +profile_photo_path, FILTER_VALIDATE_URL)) { + return $this->profile_photo_path; + } + + // Otherwise, generate a default avatar URL + $name = trim(collect(explode(' ', $this->name))->map(function ($segment) { + return mb_substr($segment, 0, 1); + })->join(' ')); + + return 'https://ui-avatars.com/api/?name='.urlencode($name).'&color=e05c00&background=fff7cc'; + }); + } +} diff --git a/app/Traits/HasVotes.php b/app/Traits/HasVotes.php index db9bd348..fb8da468 100644 --- a/app/Traits/HasVotes.php +++ b/app/Traits/HasVotes.php @@ -2,50 +2,44 @@ namespace App\Traits; -use App\Models\Upvote; use App\Events\UpvoteProcessed; +use App\Models\Upvote; use App\Models\UpvoteSummary; -use Illuminate\Support\Facades\Auth; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphOne; +use Illuminate\Support\Facades\Auth; trait HasVotes { /** * Accessor to get the current user's vote. - * - * @return \Illuminate\Database\Eloquent\Casts\Attribute */ protected function userVote(): Attribute { return Attribute::make( - get: fn() => Auth::check() ? $this->getVoteValue(Auth::id()) : 0 + get: fn () => Auth::check() ? $this->getVoteValue(Auth::id()) : 0 ); } /** * Accessor to get vote sum. - * - * @return Attribute */ protected function voteScore(): Attribute { return Attribute::make( - get: fn() => $this->upvoteSummary ? + get: fn () => $this->upvoteSummary ? $this->upvoteSummary->vote_score : 0, ); } /** * Accessor to get vote count. - * - * @return Attribute */ protected function votesCount(): Attribute { return Attribute::make( - get: fn() => $this->upvoteSummary ? $this->upvoteSummary->votes_count : 0 + get: fn () => $this->upvoteSummary ? $this->upvoteSummary->votes_count : 0 ); } @@ -76,21 +70,23 @@ public function upvote($userId): array if ($currentVote > 0) { UpvoteProcessed::dispatch($modelType, $modelId, $currentVote, 0); - return array( + + return [ 'model' => $this->deleteVote($userId), // The value of the user vote (-n,0,n) 'userVote' => 0, // What the change of votes of the model after the user voted 'changeFromVote' => 0 - $currentVote, - ); + ]; } UpvoteProcessed::dispatch($modelType, $modelId, $currentVote, 1); - return array( + + return [ 'model' => $this->vote($userId, 1), 'userVote' => 1, 'changeFromVote' => 1 - $currentVote, - ); + ]; } /** @@ -104,19 +100,21 @@ public function downvote($userId): array if ($currentVote < 0) { UpvoteProcessed::dispatch($modelType, $modelId, $currentVote, 0); - return array( + + return [ 'model' => $this->deleteVote($userId), 'userVote' => 0, 'changeFromVote' => 0 - $currentVote, - ); + ]; } UpvoteProcessed::dispatch($modelType, $modelId, $currentVote, -1); - return array( + + return [ 'model' => $this->vote($userId, -1), 'userVote' => -1, 'changeFromVote' => -1 - $currentVote, - ); + ]; } public function getChangeInVotes(): int @@ -143,9 +141,10 @@ public function unvote($userId) public function getVoteValue($userId): int { $vote = $this->votes->where('user_id', $userId)->first(); + return $vote ? $vote->value : 0; } - + /** * Vote on the model. */ diff --git a/composer.json b/composer.json index 5ebfb7ee..e30db757 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,7 @@ "laravel/jetstream": "^5.3", "laravel/sanctum": "^4.0", "laravel/tinker": "^2.9", + "shiftonelabs/laravel-cascade-deletes": "^2.0", "spatie/laravel-tags": "^4.9", "tightenco/ziggy": "^2.0" }, @@ -22,7 +23,7 @@ "beyondcode/laravel-query-detector": "^2.0", "fakerphp/faker": "^1.23", "laravel/pail": "^1.1", - "laravel/pint": "^1.13", + "laravel/pint": "^1.24", "laravel/sail": "^1.40", "laravel/telescope": "^5.3", "mockery/mockery": "^1.6", diff --git a/composer.lock b/composer.lock index b8fa7528..c20d24c1 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7725813741904ac1263575e1bcce49b0", + "content-hash": "a4d431d94e32b4fdec6440b52e4361cb", "packages": [ { "name": "bacon/bacon-qr-code", @@ -4352,6 +4352,63 @@ ], "time": "2024-04-27T21:32:50+00:00" }, + { + "name": "shiftonelabs/laravel-cascade-deletes", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/shiftonelabs/laravel-cascade-deletes.git", + "reference": "bf1eeb195513b41a98bbcd0f598f601b0e006bc2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/shiftonelabs/laravel-cascade-deletes/zipball/bf1eeb195513b41a98bbcd0f598f601b0e006bc2", + "reference": "bf1eeb195513b41a98bbcd0f598f601b0e006bc2", + "shasum": "" + }, + "require": { + "illuminate/database": ">=9.0", + "illuminate/events": ">=9.0", + "php": ">=8.0.2" + }, + "require-dev": { + "mockery/mockery": "~1.3", + "phpunit/phpunit": "~9.3 || ~10.0", + "shiftonelabs/codesniffer-standard": "0.*", + "squizlabs/php_codesniffer": "3.*" + }, + "type": "library", + "autoload": { + "psr-4": { + "ShiftOneLabs\\LaravelCascadeDeletes\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Patrick Carlo-Hickman", + "email": "patrick@shiftonelabs.com" + } + ], + "description": "Adds application level cascading deletes to Eloquent Models.", + "homepage": "https://github.com/shiftonelabs/laravel-cascade-deletes", + "keywords": [ + "cascade", + "deletes", + "eloquent", + "laravel", + "lumen", + "model" + ], + "support": { + "issues": "https://github.com/shiftonelabs/laravel-cascade-deletes/issues", + "source": "https://github.com/shiftonelabs/laravel-cascade-deletes" + }, + "time": "2024-12-14T18:36:46+00:00" + }, { "name": "spatie/eloquent-sortable", "version": "4.4.1", @@ -7917,16 +7974,16 @@ }, { "name": "laravel/pint", - "version": "v1.20.0", + "version": "v1.24.0", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "53072e8ea22213a7ed168a8a15b96fbb8b82d44b" + "reference": "0345f3b05f136801af8c339f9d16ef29e6b4df8a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/53072e8ea22213a7ed168a8a15b96fbb8b82d44b", - "reference": "53072e8ea22213a7ed168a8a15b96fbb8b82d44b", + "url": "https://api.github.com/repos/laravel/pint/zipball/0345f3b05f136801af8c339f9d16ef29e6b4df8a", + "reference": "0345f3b05f136801af8c339f9d16ef29e6b4df8a", "shasum": "" }, "require": { @@ -7934,15 +7991,15 @@ "ext-mbstring": "*", "ext-tokenizer": "*", "ext-xml": "*", - "php": "^8.1.0" + "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.66.0", - "illuminate/view": "^10.48.25", - "larastan/larastan": "^2.9.12", - "laravel-zero/framework": "^10.48.25", + "friendsofphp/php-cs-fixer": "^3.82.2", + "illuminate/view": "^11.45.1", + "larastan/larastan": "^3.5.0", + "laravel-zero/framework": "^11.45.0", "mockery/mockery": "^1.6.12", - "nunomaduro/termwind": "^1.17.0", + "nunomaduro/termwind": "^2.3.1", "pestphp/pest": "^2.36.0" }, "bin": [ @@ -7950,6 +8007,9 @@ ], "type": "project", "autoload": { + "files": [ + "overrides/Runner/Parallel/ProcessFactory.php" + ], "psr-4": { "App\\": "app/", "Database\\Seeders\\": "database/seeders/", @@ -7979,7 +8039,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2025-01-14T16:20:53+00:00" + "time": "2025-07-10T18:09:32+00:00" }, { "name": "laravel/sail", diff --git a/config/comment.php b/config/comment.php index 293683a3..45db6e6d 100644 --- a/config/comment.php +++ b/config/comment.php @@ -1,10 +1,10 @@ 150, - 'max_depth' => 7, // 1-indexed, 1 is the start - 'pagination_limit' => 150, - 'default_pagination_limit' => 5, + 'max_replies' => 150, // Max replies for a comment + 'max_depth' => 7, // Count of comment + replies. + 'pagination_limit' => 150, // Should be larger than or equal to max_replies. Max amount of comments (including replies) allowed to be returned to the page each 'view comments' button press. + 'default_pagination_limit' => 10, // ideal amount of comments to load (can be over) each 'view comments' button press 'commentable_keys' => ['review', 'comment', 'edit', 'resource'], - 'sortable_options' => ['latest', 'top', 'bottom', 'controversial', 'mine'] -]; \ No newline at end of file + 'sortable_options' => ['latest', 'top', 'bottom', 'controversial', 'mine'], +]; diff --git a/config/computerScienceResource.php b/config/computerScienceResource.php index e7a66c61..9a9272cc 100644 --- a/config/computerScienceResource.php +++ b/config/computerScienceResource.php @@ -4,4 +4,4 @@ 'platforms' => ['book', 'podcast', 'youtube_channel', 'blog', 'website', 'organization', 'bootcamp', 'newsletter', 'workshop', 'course', 'forum', 'mobile_app', 'desktop_app', 'magazine'], 'difficulties' => ['beginner', 'industry_simple', 'industry_standard', 'industry_professional', 'academic'], 'pricings' => ['free', 'paid', 'freemium'], -]; \ No newline at end of file +]; diff --git a/config/fortify.php b/config/fortify.php index 0551d1d6..5b277307 100644 --- a/config/fortify.php +++ b/config/fortify.php @@ -73,7 +73,7 @@ | */ - 'home' => '/dashboard', + 'home' => '/', /* |-------------------------------------------------------------------------- diff --git a/config/jetstream.php b/config/jetstream.php index 1fd04dfa..9c0c4132 100644 --- a/config/jetstream.php +++ b/config/jetstream.php @@ -59,9 +59,9 @@ 'features' => [ // Features::termsAndPrivacyPolicy(), - // Features::profilePhotos(), // Features::api(), // Features::teams(['invitations' => true]), + // Features::profilePhotos(), Features::accountDeletion(), ], diff --git a/config/socialstream.php b/config/socialstream.php index 902a0176..cca8b6e5 100644 --- a/config/socialstream.php +++ b/config/socialstream.php @@ -19,10 +19,10 @@ Features::providerAvatars(), Features::refreshOAuthTokens(), ], - 'home' => '/dashboard', + 'home' => '/', 'redirects' => [ - 'login' => '/dashboard', - 'register' => '/dashboard', + 'login' => '/', + 'register' => '/', 'login-failed' => '/login', 'registration-failed' => '/register', 'provider-linked' => '/user/profile', diff --git a/config/tags.php b/config/tags.php index 628dc1a6..cd614908 100644 --- a/config/tags.php +++ b/config/tags.php @@ -24,5 +24,5 @@ * The fully qualified class name of the pivot model. */ 'class_name' => Illuminate\Database\Eloquent\Relations\MorphPivot::class, - ] + ], ]; diff --git a/config/upvotes.php b/config/upvotes.php index 8c0b1f57..085f5eda 100644 --- a/config/upvotes.php +++ b/config/upvotes.php @@ -1,5 +1,5 @@ ['review', 'comment', 'edit', 'resource'] -]; \ No newline at end of file + 'upvotable_keys' => ['review', 'comment', 'edit', 'resource'], +]; diff --git a/database/factories/CommentFactory.php b/database/factories/CommentFactory.php index 4c6c22a9..6cc2bf2d 100644 --- a/database/factories/CommentFactory.php +++ b/database/factories/CommentFactory.php @@ -2,11 +2,10 @@ namespace Database\Factories; -use App\Events\CommentCreated; -use Illuminate\Database\Eloquent\Factories\Factory; -use App\Models\User; use App\Models\Comment; +use App\Models\User; use App\Services\ModelResolverService; +use Illuminate\Database\Eloquent\Factories\Factory; /** * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Comment> @@ -19,17 +18,17 @@ class CommentFactory extends Factory * @return array */ - // TODO: Double check this logic + // TODO: Double check this logic public function definition(): array { // Pick a random commentable type from config. $commentableKey = $this->faker->randomElement(['comment', 'resource']); $modelResolver = app(ModelResolverService::class); $modelClass = $modelResolver->getModelClass($commentableKey); - + // Use an existing user or create one. $user = User::inRandomOrder()->first() ?? User::factory()->create(); - + // If the commentable type is a Comment, it means this new comment is a reply. if ($modelClass === Comment::class) { // Get an existing comment or create one if none exists. @@ -37,7 +36,7 @@ public function definition(): array $commentableId = $existingComment->commentable_id; $commentableType = $existingComment->commentable_type; - + // Since it's a recursive comment, the existing comment becomes the parent. $parent = $existingComment; $parentCommentId = $parent->id; @@ -49,13 +48,13 @@ public function definition(): array $commenting = $modelClass::inRandomOrder()->first() ?? $modelClass::factory()->create(); $commentableId = $commenting->id; $commentableType = $modelClass; - + // For non-comment targets we always create a top-level comment. $parentCommentId = null; $rootCommentId = null; $depth = 1; } - + return [ 'user_id' => $user->id, 'content' => $this->faker->paragraph, @@ -67,17 +66,4 @@ public function definition(): array 'children_count' => 0, ]; } - - public function configure() - { - // TODO: Consider making the increment part of the dispatch event? - return $this->afterCreating(function (Comment $comment) { - if ($comment->root_comment_id) { - Comment::where('id', $comment->root_comment_id) - ->increment('children_count'); - } - - CommentCreated::dispatch($comment->commentable_id, $comment->commentable_type); - }); - } } diff --git a/database/factories/ComputerScienceResourceFactory.php b/database/factories/ComputerScienceResourceFactory.php index 2a7cc9bc..6bd041f8 100644 --- a/database/factories/ComputerScienceResourceFactory.php +++ b/database/factories/ComputerScienceResourceFactory.php @@ -5,8 +5,8 @@ use App\Events\TagFrequencyChanged; use App\Models\ComputerScienceResource; use App\Models\User; -use Illuminate\Support\Facades\Log; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Http\UploadedFile; /** * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ComputerScienceResource> @@ -16,7 +16,9 @@ class ComputerScienceResourceFactory extends Factory protected $model = ComputerScienceResource::class; protected ?array $topicTags = null; + protected ?array $programmingLanguageTags = null; + protected ?array $generalTags = null; public function definition(): array @@ -25,11 +27,14 @@ public function definition(): array $difficulties = config('computerScienceResource.difficulties'); $pricings = config('computerScienceResource.pricings'); + $fakeImage = UploadedFile::fake()->image('resource.jpg'); + $imagePath = $fakeImage->store('resource', 'public'); + return [ 'name' => fake()->name(), 'description' => fake()->realText(), 'user_id' => User::inRandomOrder()->first() ?? User::factory()->create(), - 'image_url' => 'https://cdn.iconscout.com/icon/free/png-256/free-leetcode-logo-icon-download-in-svg-png-gif-file-formats--technology-social-media-company-vol-1-pack-logos-icons-3030025.png', + 'image_path' => $imagePath, 'page_url' => fake()->url(), 'platforms' => fake()->randomElements($platforms, rand(1, 3)), 'difficulty' => fake()->randomElement($difficulties), @@ -42,6 +47,7 @@ public function setTags(array $topic = [], array $language = [], array $general $this->topicTags = $topic; $this->programmingLanguageTags = $language; $this->generalTags = $general; + return $this; } diff --git a/database/factories/ResourceEditsFactory.php b/database/factories/ResourceEditsFactory.php index 3bd842da..19169c48 100644 --- a/database/factories/ResourceEditsFactory.php +++ b/database/factories/ResourceEditsFactory.php @@ -2,9 +2,9 @@ namespace Database\Factories; +use App\Models\ComputerScienceResource; use App\Models\ResourceEdits; use App\Models\User; -use App\Models\ComputerScienceResource; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -20,6 +20,30 @@ public function definition(): array $difficulties = config('computerScienceResource.difficulties'); $pricings = config('computerScienceResource.pricings'); + $possibleChanges = [ + 'name' => $this->faker->name(), + 'description' => $this->faker->realText(), + 'image_path' => 'https://cdn.iconscout.com/icon/free/png-256/free-leetcode-logo-icon-download-in-svg-png-gif-file-formats--technology-social-media-company-vol-1-pack-logos-icons-3030025.png', + 'page_url' => $this->faker->url(), + 'platforms' => $this->faker->randomElements($platforms, rand(1, 3)), + 'difficulty' => $this->faker->randomElement($difficulties), + 'pricing' => $this->faker->randomElement($pricings), + 'topic_tags' => ['data structures', 'algorithms'], + 'programming_language_tags' => ['python'], + 'general_tags' => ['interactive', 'challenging'], + ]; + + // Select a random subset of keys to include in the proposed changes. + $proposedKeys = $this->faker->randomElements( + array_keys($possibleChanges), + $this->faker->numberBetween(1, count($possibleChanges)) + ); + + $proposedChanges = []; + foreach ($proposedKeys as $key) { + $proposedChanges[$key] = $possibleChanges[$key]; + } + return [ 'computer_science_resource_id' => function () { return ComputerScienceResource::inRandomOrder()->firstOr(function () { @@ -34,19 +58,7 @@ public function definition(): array 'edit_title' => $this->faker->sentence, 'edit_description' => $this->faker->paragraph, - // Copied fields from the resource - 'name' => $this->faker->name(), - 'description' => $this->faker->realText(), - 'image_url' => 'https://cdn.iconscout.com/icon/free/png-256/free-leetcode-logo-icon-download-in-svg-png-gif-file-formats--technology-social-media-company-vol-1-pack-logos-icons-3030025.png', - 'page_url' => $this->faker->url(), - - 'platforms' => $this->faker->randomElements($platforms, rand(1, 3)), - 'difficulty' => $this->faker->randomElement($difficulties), - 'pricing' => $this->faker->randomElement($pricings), - - 'topic_tags' => ['data structures', 'algorithms'], - 'programming_language_tags' => ['python'], - 'general_tags' => ['interactive', 'challenging'], + 'proposed_changes' => $proposedChanges, ]; } } diff --git a/database/factories/ResourceReviewFactory.php b/database/factories/ResourceReviewFactory.php index 16f2a035..9ea44821 100644 --- a/database/factories/ResourceReviewFactory.php +++ b/database/factories/ResourceReviewFactory.php @@ -2,7 +2,6 @@ namespace Database\Factories; -use App\Events\ResourceReviewProcessed; use App\Models\ComputerScienceResource; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; @@ -38,21 +37,4 @@ public function definition(): array 'cons' => $this->faker->words(mt_rand(1, 5)), ]; } - - /** - * Configure the model factory. - * - * @return static - */ - public function configure(): static - { - return $this->afterCreating(function ($resourceReview) { - // Dispatch the event after creating the resource review - ResourceReviewProcessed::dispatch( - $resourceReview->computer_science_resource_id, - null, - $resourceReview->attributesToArray() - ); - }); - } } diff --git a/database/migrations/2025_01_24_171530_create_computer_science_resources_table.php b/database/migrations/2025_01_24_171530_create_computer_science_resources_table.php index 1a97f74e..3b58e1a4 100644 --- a/database/migrations/2025_01_24_171530_create_computer_science_resources_table.php +++ b/database/migrations/2025_01_24_171530_create_computer_science_resources_table.php @@ -1,9 +1,9 @@ id(); @@ -21,7 +21,7 @@ public function up(): void $table->string('name')->fulltext(); $table->text('description')->fulltext(); - $table->string('image_url')->nullable(); + $table->string('image_path')->nullable(); // TODO: Have a url for each platform the resource is on. $table->string('page_url'); diff --git a/database/migrations/2025_02_07_023923_create_upvotes_table.php b/database/migrations/2025_02_07_023923_create_upvotes_table.php index feb609bb..1ea7b007 100644 --- a/database/migrations/2025_02_07_023923_create_upvotes_table.php +++ b/database/migrations/2025_02_07_023923_create_upvotes_table.php @@ -18,9 +18,7 @@ public function up(): void $table->foreignIdFor(User::class)->index(); $table->morphs('upvotable'); - $table->unique(['upvotable_id','upvotable_type', 'user_id']); - - $table->timestamps(); + $table->unique(['upvotable_id', 'upvotable_type', 'user_id']); }); } diff --git a/database/migrations/2025_02_09_172232_create_upvote_summaries_table.php b/database/migrations/2025_02_09_172232_create_upvote_summaries_table.php index 2654e0e6..aa770c64 100644 --- a/database/migrations/2025_02_09_172232_create_upvote_summaries_table.php +++ b/database/migrations/2025_02_09_172232_create_upvote_summaries_table.php @@ -1,6 +1,5 @@ integer('total_votes')->storedAs('upvotes + downvotes'); $table->integer('controversy')->storedAs('(upvotes + downvotes) - ABS(upvotes - downvotes)'); - $table->timestamps(); }); } diff --git a/database/migrations/2025_02_14_225555_create_resource_reviews_table.php b/database/migrations/2025_02_14_225555_create_resource_reviews_table.php index f58acb54..d05f9f4a 100644 --- a/database/migrations/2025_02_14_225555_create_resource_reviews_table.php +++ b/database/migrations/2025_02_14_225555_create_resource_reviews_table.php @@ -17,10 +17,10 @@ public function up(): void $table->id(); $table->foreignIdFor(User::class)->index(); $table->foreignIdFor(ComputerScienceResource::class)->index(); - + $table->unique([ 'user_id', - 'computer_science_resource_id' + 'computer_science_resource_id', ]); // Text diff --git a/database/migrations/2025_02_18_225525_create_resource_review_summaries_table.php b/database/migrations/2025_02_18_225525_create_resource_review_summaries_table.php index 8760d449..0e1fd463 100644 --- a/database/migrations/2025_02_18_225525_create_resource_review_summaries_table.php +++ b/database/migrations/2025_02_18_225525_create_resource_review_summaries_table.php @@ -23,29 +23,29 @@ public function up(): void $table->bigInteger('user_friendliness')->default(0); $table->bigInteger('updates')->default(0); - $table->decimal('community_rating')->storedAs(" + $table->decimal('community_rating')->storedAs(' CASE WHEN review_count = 0 THEN 0 ELSE community / review_count END - ")->index(); + ')->index(); - $table->decimal('teaching_clarity_rating')->storedAs(" + $table->decimal('teaching_clarity_rating')->storedAs(' CASE WHEN review_count = 0 THEN 0 ELSE teaching_clarity / review_count END - ")->index(); + ')->index(); - $table->decimal('engagement_rating')->storedAs(" + $table->decimal('engagement_rating')->storedAs(' CASE WHEN review_count = 0 THEN 0 ELSE engagement / review_count END - ")->index(); + ')->index(); - $table->decimal('practicality_rating')->storedAs(" + $table->decimal('practicality_rating')->storedAs(' CASE WHEN review_count = 0 THEN 0 ELSE practicality / review_count END - ")->index(); + ')->index(); - $table->decimal('user_friendliness_rating')->storedAs(" + $table->decimal('user_friendliness_rating')->storedAs(' CASE WHEN review_count = 0 THEN 0 ELSE user_friendliness / review_count END - ")->index(); + ')->index(); - $table->decimal('updates_rating')->storedAs(" + $table->decimal('updates_rating')->storedAs(' CASE WHEN review_count = 0 THEN 0 ELSE updates / review_count END - ")->index(); + ')->index(); $table->decimal('overall_rating') ->storedAs('(community_rating + @@ -57,8 +57,6 @@ public function up(): void ->index(); $table->integer('review_count')->default(0); - - $table->timestamps(); }); } diff --git a/database/migrations/2025_03_04_161737_create_comments_table.php b/database/migrations/2025_03_04_161737_create_comments_table.php index 471e0efa..bc0ed51b 100644 --- a/database/migrations/2025_03_04_161737_create_comments_table.php +++ b/database/migrations/2025_03_04_161737_create_comments_table.php @@ -1,10 +1,10 @@ id(); $table->timestamps(); - $table->morphs("commentable"); - $table->foreignIdFor(Comment::class, "root_comment_id")->nullable(); - $table->foreignIdFor(Comment::class, "parent_comment_id")->nullable(); + $table->morphs('commentable'); + $table->foreignIdFor(Comment::class, 'root_comment_id')->nullable(); + $table->foreignIdFor(Comment::class, 'parent_comment_id')->nullable(); $table->foreignIdFor(User::class); - $table->text("content"); + $table->text('content'); - $table->smallInteger("depth")->default(1)->index(); + $table->smallInteger('depth')->default(1)->index(); // Only root comment uses this - $table->unsignedInteger("children_count")->default(0); + $table->unsignedInteger('children_count')->default(0); }); } diff --git a/database/migrations/2025_03_24_154128_create_resource_edits_table.php b/database/migrations/2025_03_24_154128_create_resource_edits_table.php index 74886f2e..a1174203 100644 --- a/database/migrations/2025_03_24_154128_create_resource_edits_table.php +++ b/database/migrations/2025_03_24_154128_create_resource_edits_table.php @@ -16,7 +16,7 @@ public function up(): void Schema::create('resource_edits', function (Blueprint $table) { $table->id(); $table->timestamps(); - + // The resource we are editting $table->foreignIdFor(ComputerScienceResource::class)->constrained()->cascadeOnDelete(); // The user who created the edit @@ -26,27 +26,7 @@ public function up(): void $table->string('edit_title'); $table->text('edit_description'); - // Copied Schema of Computer Science Resource - $table->string('name')->fulltext(); - $table->text('description')->fulltext(); - - // TODO: have it be nullable or something - $table->string('image_url')->nullable(); - - $table->string('page_url'); - - $table->set('platforms', ['book', 'podcast', 'youtube_channel', 'blog', 'website', 'organization', 'bootcamp', 'newsletter', 'workshop', 'course', 'forum', 'mobile_app', 'desktop_app', 'magazine']) - ->index(); - $table->enum('difficulty', ['beginner', 'industry_simple', 'industry_standard', 'industry_professional', 'academic']) - ->index(); - $table->enum('pricing', ['free', 'premium', 'paid', 'freemium']) - ->index(); - - // Handle Tags: - // 'topic_tags', 'programming_language_tags', 'general_tags' - $table->json('topic_tags'); - $table->json('programming_language_tags'); - $table->json('general_tags'); + $table->json('proposed_changes'); }); } diff --git a/database/seeders/CommentSeeder.php b/database/seeders/CommentSeeder.php index 27d5e138..254a32ef 100644 --- a/database/seeders/CommentSeeder.php +++ b/database/seeders/CommentSeeder.php @@ -3,7 +3,6 @@ namespace Database\Seeders; use App\Models\Comment; -use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; class CommentSeeder extends Seeder diff --git a/database/seeders/ComputerScienceResourceSeeder.php b/database/seeders/ComputerScienceResourceSeeder.php index 2e315472..60443886 100644 --- a/database/seeders/ComputerScienceResourceSeeder.php +++ b/database/seeders/ComputerScienceResourceSeeder.php @@ -2,10 +2,8 @@ namespace Database\Seeders; -use Illuminate\Database\Console\Seeds\WithoutModelEvents; use App\Models\ComputerScienceResource; use Illuminate\Database\Seeder; -use Illuminate\Support\Facades\Log; class ComputerScienceResourceSeeder extends Seeder { diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index d85a8485..f751f0d2 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,12 +2,7 @@ namespace Database\Seeders; -use App\Models\User; // use Illuminate\Database\Console\Seeds\WithoutModelEvents; -use Database\Seeders\ComputerScienceResourceSeeder; -use Database\Seeders\UserSeeder; -use Database\Seeders\ResourceReviewSeeder; -use Database\Seeders\ResourceEditsSeeder; use Illuminate\Database\Seeder; class DatabaseSeeder extends Seeder diff --git a/database/seeders/ResourceEditsSeeder.php b/database/seeders/ResourceEditsSeeder.php index 5483162b..fee7eaca 100644 --- a/database/seeders/ResourceEditsSeeder.php +++ b/database/seeders/ResourceEditsSeeder.php @@ -3,7 +3,6 @@ namespace Database\Seeders; use App\Models\ResourceEdits; -use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; class ResourceEditsSeeder extends Seeder diff --git a/database/seeders/ResourceReviewSeeder.php b/database/seeders/ResourceReviewSeeder.php index bfec227b..4c254491 100644 --- a/database/seeders/ResourceReviewSeeder.php +++ b/database/seeders/ResourceReviewSeeder.php @@ -3,7 +3,6 @@ namespace Database\Seeders; use App\Models\ResourceReview; -use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; class ResourceReviewSeeder extends Seeder diff --git a/database/seeders/UpvoteSeeder.php b/database/seeders/UpvoteSeeder.php index 8cf15673..b2b982d9 100644 --- a/database/seeders/UpvoteSeeder.php +++ b/database/seeders/UpvoteSeeder.php @@ -2,7 +2,6 @@ namespace Database\Seeders; -use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; class UpvoteSeeder extends Seeder diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php index a22feda1..47397fdc 100644 --- a/database/seeders/UserSeeder.php +++ b/database/seeders/UserSeeder.php @@ -3,9 +3,7 @@ namespace Database\Seeders; use App\Models\User; -use Database\Factories\ComputerScienceResourceFactory; use Hash; -use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\Log; @@ -19,7 +17,7 @@ public function run(): void Log::info('Running UserSeeder'); User::factory(10)->create(); - if (!User::where('name','Allan Kong')->exists()) { + if (! User::where('name', 'Allan Kong')->exists()) { User::factory()->create( [ 'name' => 'Allan Kong', diff --git a/package-lock.json b/package-lock.json index b34a3100..2ffb1b30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,11 +4,14 @@ "requires": true, "packages": { "": { + "name": "html", "dependencies": { + "@fontsource/open-sans": "^5.2.5", "@primevue/forms": "^4.3.3", "@primevue/themes": "^4.3.3", "diff": "^7.0.0", "primevue": "^4.3.3", + "vue-picture-input": "^3.0.1", "yup": "^1.6.1" }, "devDependencies": { @@ -512,6 +515,14 @@ "node": ">=18" } }, + "node_modules/@fontsource/open-sans": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@fontsource/open-sans/-/open-sans-5.2.5.tgz", + "integrity": "sha512-f0Ww6H+LB6GXA8UCgqs90h4djVttu3quH/1+wkfUY8b09mG1ESn4ACRBHYY78bsoeDXpaCyZh7eoGROBWplvAQ==", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@iconify/types": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", @@ -1361,11 +1372,10 @@ } }, "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -3683,6 +3693,12 @@ } } }, + "node_modules/vue-picture-input": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/vue-picture-input/-/vue-picture-input-3.0.1.tgz", + "integrity": "sha512-VGzqoH4iAN/S7L9RY2DyyLs4AjLt4QoWx6asT3y3PTXK2yFTXbUjx5qYdHrUJrVpInhvQwngEO/mJ7LhGXGFFg==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 2d70c7cc..43be8a2a 100644 --- a/package.json +++ b/package.json @@ -22,10 +22,12 @@ "vue": "^3.3.13" }, "dependencies": { + "@fontsource/open-sans": "^5.2.5", "@primevue/forms": "^4.3.3", "@primevue/themes": "^4.3.3", - "primevue": "^4.3.3", "diff": "^7.0.0", + "primevue": "^4.3.3", + "vue-picture-input": "^3.0.1", "yup": "^1.6.1" } } diff --git a/public/images/Logo.svg b/public/images/Logo.svg new file mode 100644 index 00000000..75983a8b --- /dev/null +++ b/public/images/Logo.svg @@ -0,0 +1,608 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/LogoHead.svg b/public/images/LogoHead.svg new file mode 100644 index 00000000..72d60ff9 --- /dev/null +++ b/public/images/LogoHead.svg @@ -0,0 +1,380 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/test_logo.png b/public/images/test_logo.png deleted file mode 100644 index c7f82897..00000000 Binary files a/public/images/test_logo.png and /dev/null differ diff --git a/resources/css/app.css b/resources/css/app.css index 0de21207..b8459b52 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,7 +1,10 @@ +@import "@fontsource/open-sans"; + @tailwind base; @tailwind components; @tailwind utilities; + [x-cloak] { display: none; } diff --git a/resources/js/Components/ActionSection.vue b/resources/js/Components/ActionSection.vue index bdba111f..8100d78a 100644 --- a/resources/js/Components/ActionSection.vue +++ b/resources/js/Components/ActionSection.vue @@ -14,7 +14,7 @@ import SectionTitle from './SectionTitle.vue';
-
+
diff --git a/resources/js/Components/ApplicationLogo.vue b/resources/js/Components/ApplicationLogo.vue index aad5948d..a332446e 100644 --- a/resources/js/Components/ApplicationLogo.vue +++ b/resources/js/Components/ApplicationLogo.vue @@ -1,7 +1,3 @@ diff --git a/resources/js/Components/ApplicationMark.vue b/resources/js/Components/ApplicationMark.vue index a178c152..4ea89148 100644 --- a/resources/js/Components/ApplicationMark.vue +++ b/resources/js/Components/ApplicationMark.vue @@ -1,4 +1,3 @@ diff --git a/resources/js/Components/AuthenticationCardLogo.vue b/resources/js/Components/AuthenticationCardLogo.vue index 34b64201..7ddd6b0d 100644 --- a/resources/js/Components/AuthenticationCardLogo.vue +++ b/resources/js/Components/AuthenticationCardLogo.vue @@ -4,14 +4,6 @@ import { Link } from '@inertiajs/vue3'; diff --git a/resources/js/Components/Comments/CommentActionsForm.vue b/resources/js/Components/Comments/CommentActionsForm.vue index 739c9522..15ff2082 100644 --- a/resources/js/Components/Comments/CommentActionsForm.vue +++ b/resources/js/Components/Comments/CommentActionsForm.vue @@ -1,7 +1,9 @@ + + diff --git a/resources/js/Components/NavLink.vue b/resources/js/Components/NavLink.vue index 7ab3f2f5..7fd50b69 100644 --- a/resources/js/Components/NavLink.vue +++ b/resources/js/Components/NavLink.vue @@ -9,8 +9,8 @@ const props = defineProps({ const classes = computed(() => { return props.active - ? 'inline-flex items-center px-1 pt-1 border-b-2 border-primary dark:border-indigo-600 text-sm font-medium leading-5 text-gray-900 dark:text-gray-100 focus:outline-none focus:border-indigo-700 transition duration-150 ease-in-out' - : 'inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium leading-5 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-700 focus:outline-none focus:text-gray-700 dark:focus:text-gray-300 focus:border-gray-300 dark:focus:border-gray-700 transition duration-150 ease-in-out'; + ? 'inline-flex items-center px-1 pt-1 border-b-2 border-primary dark:border-primaryDark text-sm font-medium leading-5 text-primaryDark dark:text-secondary focus:outline-none focus:border-primaryDark transition duration-150 ease-in-out' + : 'inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium leading-5 text-primary dark:text-secondary hover:text-primaryDark dark:hover:text-secondaryDark hover:border-secondaryDark focus:outline-none focus:text-primaryDark dark:focus:text-secondaryDark focus:border-secondaryDark transition duration-150 ease-in-out'; }); diff --git a/resources/js/Components/Navigation/BackButton.vue b/resources/js/Components/Navigation/BackButton.vue new file mode 100644 index 00000000..974dbb17 --- /dev/null +++ b/resources/js/Components/Navigation/BackButton.vue @@ -0,0 +1,18 @@ + + + diff --git a/resources/js/Components/Navigation/Navbar.vue b/resources/js/Components/Navigation/Navbar.vue index aef55d33..757cbbfb 100644 --- a/resources/js/Components/Navigation/Navbar.vue +++ b/resources/js/Components/Navigation/Navbar.vue @@ -30,6 +30,14 @@ const logout = () => {