diff --git a/app/Exceptions/Resources/ResourceAlreadyCreatedException.php b/app/Exceptions/Resources/ResourceAlreadyCreatedException.php new file mode 100644 index 00000000..f268c8f1 --- /dev/null +++ b/app/Exceptions/Resources/ResourceAlreadyCreatedException.php @@ -0,0 +1,16 @@ +resource = $resource; + } +} diff --git a/app/Exceptions/Resources/ResourceInvalidTabException.php b/app/Exceptions/Resources/ResourceInvalidTabException.php new file mode 100644 index 00000000..39e1ac19 --- /dev/null +++ b/app/Exceptions/Resources/ResourceInvalidTabException.php @@ -0,0 +1,7 @@ + $e->getMessage(), - 'trace' => $e->getTraceAsString(), 'validated_data' => $validatedData, 'user_id' => Auth::id(), + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), ]); return response()->json(['message' => 'Failed to save comment'], 500); diff --git a/app/Http/Controllers/ComputerScienceResourceController.php b/app/Http/Controllers/ComputerScienceResourceController.php index c93b2391..5fb8dd3a 100644 --- a/app/Http/Controllers/ComputerScienceResourceController.php +++ b/app/Http/Controllers/ComputerScienceResourceController.php @@ -2,20 +2,15 @@ namespace App\Http\Controllers; +use App\Exceptions\Resources\ResourceAlreadyCreatedException; +use App\Exceptions\Resources\ResourceInvalidTabException; use App\Http\Requests\StoreResourceRequest; use App\Models\ComputerScienceResource; use App\Models\NewsPost; -use App\Models\ResourceEdits; -use App\Models\ResourceReview; -use App\Services\CommentService; use App\Services\ComputerScienceResourceFilter; -use App\Services\ResourceReviewService; -use App\Services\SortingManagers\GeneralVotesSortingManager; -use App\Services\SortingManagers\ResourceSortingManager; -use App\Services\UpvoteService; +use App\Services\ComputerScienceResourceService; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Inertia\Inertia; use Throwable; @@ -23,11 +18,7 @@ class ComputerScienceResourceController extends Controller { public function __construct( - protected CommentService $commentService, - protected GeneralVotesSortingManager $generalVotesSortingManager, - protected ResourceReviewService $reviewService, - protected ResourceSortingManager $resourceSortingManager, - protected UpvoteService $upvoteService, + protected ComputerScienceResourceService $resourceService, ) {} /** @@ -66,72 +57,25 @@ public function create() public function store(StoreResourceRequest $request) { $validatedData = $request->validated(); - - DB::beginTransaction(); try { - // Store the image onto storage - $path = null; - if (array_key_exists('image_file', $validatedData) && $imageFile = $validatedData['image_file']) { - $path = $imageFile->store('resource', 'public'); - if (! $path) { - Log::error('Failed to store image file', [ - 'user_id' => Auth::id(), - 'file_info' => $imageFile, - ]); - - $fileName = $imageFile->getClientOriginalName(); - throw new \RuntimeException( - "Could not save the image file '{$fileName}' for user ID ".Auth::id().'.' - ); - } - } - - $resource = ComputerScienceResource::create([ - 'user_id' => Auth::id(), - 'name' => $validatedData['name'], - 'image_path' => $path, - 'description' => $validatedData['description'], - 'page_url' => $validatedData['page_url'], - 'platforms' => $validatedData['platforms'], - 'difficulty' => $validatedData['difficulty'], - 'pricing' => $validatedData['pricing'], - ]); - - // Add topics as tags - $resource->topic_tags = $validatedData['topic_tags']; - - // Add programming languages as tags (if provided) - if (isset($validatedData['programming_language_tags'])) { - $resource->programming_language_tags = $validatedData['programming_language_tags']; - } - - // Add general tags (if provided) - if (isset($validatedData['general_tags'])) { - $resource->general_tags = $validatedData['general_tags']; - } - - DB::commit(); - - $this->upvoteService->upvote('resource', $resource->id); + $resource = $this->resourceService->createResource($validatedData); + session()->flash('success', 'Created Resource!'); - Log::info('Resource created', [ - 'resource_id' => $resource->id, + return response()->json($resource); + } catch (ResourceAlreadyCreatedException $e) { + Log::warning('Resource already exists', [ 'user_id' => Auth::id(), - 'name' => $resource->name, - 'slug' => $resource->slug, - 'platforms' => $resource->platforms, + 'resource_id' => $e->resource->id ?? null, + 'name' => $e->resource->name ?? null, ]); + session()->flash('warning', 'Resource Already Exists!'); - session()->flash('success', 'Created Resource!'); - - return response()->json($resource); + return response()->json($e->resource); } catch (Throwable $e) { - DB::rollBack(); - Log::critical('Failed to create resource', [ + Log::error('Error creating resource', [ + 'user_id' => Auth::id(), 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), - 'user_id' => Auth::id(), - 'data' => $validatedData, ]); return response()->json([], 500); @@ -143,62 +87,23 @@ public function store(StoreResourceRequest $request) */ public function show(Request $request, string $slug, string $tab = 'reviews') { - $computerScienceResource = ComputerScienceResource::where('slug', $slug)->firstOrFail(); - // Get the review summaries - $computerScienceResource->load('reviewSummary'); - $computerScienceResource->load('user'); + try { + $result = $this->resourceService->getShowResourceData($request, $slug, $tab); - $validTabs = ['reviews', 'discussion', 'edits']; + return Inertia::render('Resources/Show', $result); + } catch (ResourceInvalidTabException $e) { + Log::warning('Invalid resource tab requested', [ + 'user_id' => Auth::id(), + 'slug' => $slug, + 'requested_tab' => $tab, + 'error' => $e->getMessage(), + ]); - if (! in_array($tab, $validTabs)) { - // Redirect to default if invalid return redirect()->route('resources.show', [ - 'slug' => $computerScienceResource->slug, + 'slug' => $slug, 'tab' => 'reviews', - ]); + ])->with('warning', 'Invalid tab requested, redirected to reviews.'); } - - // return the resource and tab - $data = [ - 'tab' => $tab, - 'resource' => $computerScienceResource, - ]; - - $sortBy = $request->query('sort_by', 'top'); - // Load only the necessary tab data - if ($tab === 'reviews') { - $userReview = null; - if ($userId = Auth::id()) { - $userReview = ResourceReview::whereBelongsTo($computerScienceResource) - ->firstWhere('user_id', $userId); - } - - $data['userReview'] = $userReview; - - $data['reviews'] = Inertia::defer( - function () use ($computerScienceResource, $sortBy, $request) { - $query = ResourceReview::whereBelongsTo($computerScienceResource); - $query = $this->generalVotesSortingManager->applySort($query, $sortBy, ResourceReview::class); - - return $query->with('user')->paginate(10)->appends($request->query()); - } - ); - } elseif ($tab === 'edits') { - $data['resourceEdits'] = Inertia::defer( - function () use ($computerScienceResource, $sortBy, $request) { - $query = ResourceEdits::whereBelongsTo($computerScienceResource); - $query = $this->generalVotesSortingManager->applySort($query, $sortBy, ResourceEdits::class); - - 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) - ); - } - - return Inertia::render('Resources/Show', $data); } /** diff --git a/app/Http/Controllers/ResourceEditsController.php b/app/Http/Controllers/ResourceEditsController.php index 42e732f3..3f7cca63 100644 --- a/app/Http/Controllers/ResourceEditsController.php +++ b/app/Http/Controllers/ResourceEditsController.php @@ -44,6 +44,7 @@ public function store(ComputerScienceResource $computerScienceResource, StoreRes $actualChanges = $this->calculateChanges($computerScienceResource, $proposedChanges); + // Add image path to the actual changes if (array_key_exists('image_file', $proposedChanges)) { $actualChanges['image_path'] = null; if (isset($proposedChanges['image_file'])) { @@ -122,7 +123,11 @@ public function merge(ResourceEditsService $editsService, ResourceEdits $resourc } if (array_key_exists('image_path', $changes)) { - // TODO: Removed code to delete photo, will be handled in a cron job + // TODO: Remove the old photo resource photo, will be handled in a cron job, + // + // photo image_url history can be viewed via activity log. + // + $destPath = null; if (isset($changes['image_path'])) { // Copy the new file from 'resource-edits' to 'resource' (do not delete the old one) @@ -131,7 +136,8 @@ public function merge(ResourceEditsService $editsService, ResourceEdits $resourc $newFileName = Str::random(40).'.'.$fileExtension; $destPath = 'resource/'.$newFileName; - Storage::disk('public')->copy($sourcePath, $destPath); + // TODO: FIGURE OUT WHAT TO DO IN CASE OF EXCEPTION IN CODE FROM LATER STEPS + Storage::disk('public')->move($sourcePath, $destPath); } // Update image_path in DB @@ -141,11 +147,9 @@ public function merge(ResourceEditsService $editsService, ResourceEdits $resourc $resource->save(); $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]; } } diff --git a/app/Http/Controllers/UpvoteController.php b/app/Http/Controllers/UpvoteController.php index ebc84b5c..2593233d 100644 --- a/app/Http/Controllers/UpvoteController.php +++ b/app/Http/Controllers/UpvoteController.php @@ -29,6 +29,7 @@ public function downvote($typeKey, $id) { $result = $this->upvoteService->downvote($typeKey, $id); + // TODO: REFACTOR TO EXCEPTIONS if (isset($result['error'])) { return response()->json(['message' => $result['error']], $result['status']); } diff --git a/app/Services/ComputerScienceResourceService.php b/app/Services/ComputerScienceResourceService.php new file mode 100644 index 00000000..13e7cc69 --- /dev/null +++ b/app/Services/ComputerScienceResourceService.php @@ -0,0 +1,166 @@ +existingConflictingResource($validatedData)) { + throw new ResourceAlreadyCreatedException($conflictingResource); + } + + DB::beginTransaction(); + try { + // Store the image onto storage + $path = null; + if (array_key_exists('image_file', $validatedData) && $imageFile = $validatedData['image_file']) { + // TODO: FIGURE OUT WHAT TO DO IN CASE OF EXCEPTION IN CODE FROM LATER STEPS + $path = $imageFile->store('resource', 'public'); + } + + $resource = ComputerScienceResource::create([ + 'user_id' => Auth::id(), + 'name' => $validatedData['name'], + 'image_path' => $path, + 'description' => $validatedData['description'], + 'page_url' => $validatedData['page_url'], + 'platforms' => $validatedData['platforms'], + 'difficulty' => $validatedData['difficulty'], + 'pricing' => $validatedData['pricing'], + ]); + + // Add topics as tags + $resource->topic_tags = $validatedData['topic_tags']; + + // Add programming languages as tags (if provided) + if (isset($validatedData['programming_language_tags'])) { + $resource->programming_language_tags = $validatedData['programming_language_tags']; + } + + // Add general tags (if provided) + if (isset($validatedData['general_tags'])) { + $resource->general_tags = $validatedData['general_tags']; + } + + DB::commit(); + + $this->upvoteService->upvote('resource', $resource->id); + + Log::info('Resource created', [ + 'resource_id' => $resource->id, + 'user_id' => Auth::id(), + 'name' => $resource->name, + 'slug' => $resource->slug, + 'platforms' => $resource->platforms, + ]); + + return $resource; + } catch (Throwable $e) { + DB::rollBack(); + + Log::critical('Failed to create resource', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + 'user_id' => Auth::id(), + 'data' => $validatedData, + ]); + + throw $e; + } + } + + /** + * Get all data for the resource show page, including tab logic. + * + * @throws Throwable + */ + public function getShowResourceData(Request $request, string $slug, string $tab = 'reviews') + { + $computerScienceResource = ComputerScienceResource::where('slug', $slug)->firstOrFail(); + $computerScienceResource->load('reviewSummary'); + $computerScienceResource->load('user'); + + $validTabs = ['reviews', 'discussion', 'edits']; + if (! in_array($tab, $validTabs)) { + throw new ResourceInvalidTabException('Invalid tab: '.$tab); + } + + $data = [ + 'tab' => $tab, + 'resource' => $computerScienceResource, + ]; + + $sortBy = $request->query('sort_by', 'top'); + if ($tab === 'reviews') { + $userReview = null; + if ($userId = Auth::id()) { + $userReview = ResourceReview::whereBelongsTo($computerScienceResource) + ->firstWhere('user_id', $userId); + } + $data['userReview'] = $userReview; + $data['reviews'] = Inertia::defer( + function () use ($computerScienceResource, $sortBy, $request) { + $query = ResourceReview::whereBelongsTo($computerScienceResource); + $query = $this->resourceSortingManager->applySort($query, $sortBy, ResourceReview::class); + + return $query->with('user')->paginate(10)->appends($request->query()); + } + ); + } elseif ($tab === 'edits') { + $data['resourceEdits'] = Inertia::defer( + function () use ($computerScienceResource, $sortBy, $request) { + $query = ResourceEdits::whereBelongsTo($computerScienceResource); + $query = $this->resourceSortingManager->applySort($query, $sortBy, ResourceEdits::class); + + 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) + ); + } + + return $data; + } + + /** + * In case a user does a double submit, we have a check for that + * Checks against StoreResourceRequest + */ + private function existingConflictingResource(array $data): ?ComputerScienceResource + { + // If there is a resource with these matching properties, it is safe to say + // it is the same resource + return ComputerScienceResource::where('name', $data['name']) + ->where('page_url', $data['page_url']) + ->first(); + } +} diff --git a/resources/js/Components/Form/TagSelector.vue b/resources/js/Components/Form/TagSelector.vue index 5cac5deb..f90fed58 100644 --- a/resources/js/Components/Form/TagSelector.vue +++ b/resources/js/Components/Form/TagSelector.vue @@ -47,6 +47,12 @@ function updateDropdownPosition() { position: "absolute", }; } +// Watch selectedTags and update dropdown position when tags change and dropdown is open +watch(selectedTags, () => { + if (showDropdown.value) { + nextTick(updateDropdownPosition); + } +}); watch(showDropdown, (val) => { if (val) { @@ -191,14 +197,6 @@ function onKeydown(event) { searchInput.value?.blur(); return; } - - if ( - event.key === "Backspace" && - !searchQuery.value && - selectedTags.value.length > 0 - ) { - removeTag(selectedTags.value[selectedTags.value.length - 1]); - } } function onBlur() { diff --git a/resources/js/Components/Resources/ResourceEdit/ResourceEditsFAQ.vue b/resources/js/Components/Resources/ResourceEdit/ResourceEditsFAQ.vue index 99a66aff..107fcd68 100644 --- a/resources/js/Components/Resources/ResourceEdit/ResourceEditsFAQ.vue +++ b/resources/js/Components/Resources/ResourceEdit/ResourceEditsFAQ.vue @@ -113,7 +113,8 @@ import FrequentlyAskedQuestion from "@/Components/FrequentlyAskedQuestion.vue"; class="border border-gray-300 dark:border-gray-600 px-4 py-2" > Since there are no existing votes (positive or negative), the entire community can merge any edits. - The reasoning is that someone could've make a typo and needed a quick fix. + The reasoning is that someone could've made a typo and needed a quick fix. + Everyone automatically upvotes their own resources. So, you must unvote your newly created resource to reach 0 votes. diff --git a/resources/js/Pages/Resources/Create.vue b/resources/js/Pages/Resources/Create.vue index 2902927d..3d788e24 100644 --- a/resources/js/Pages/Resources/Create.vue +++ b/resources/js/Pages/Resources/Create.vue @@ -46,6 +46,7 @@ const { isSavedToLocalStorage, hasFormContent, clearLocalStorage } = const showReset = ref(false); const stepperValue = ref("1"); const formRef = ref(null); +const isLoading = ref(false); const toast = useToast(); const scrollToForm = () => { @@ -70,13 +71,15 @@ const resetForm = () => { }; const submitForm = async () => { + isLoading.value = true; try { const response = await axios.postForm( route("resources.store"), formData ); - clearLocalStorage(); + clearLocalStorage(); + isLoading.value = false; router.visit(route("resources.show", { slug: response.data.slug })); } catch (err) { toast.add({ @@ -89,6 +92,7 @@ const submitForm = async () => { : "An error occurred while creating the resource. Please try again."), life: 10000, }); + isLoading.value = false; console.error(err); } }; @@ -161,6 +165,7 @@ const handleFormChange = (newFormData) => { @change="handleFormChange" @back="() => navigateToStep('1')" @next="submitForm" + :is-loading="isLoading" /> diff --git a/resources/js/Pages/Resources/Form/TagsFields.vue b/resources/js/Pages/Resources/Form/TagsFields.vue index acee9832..f405e9f3 100644 --- a/resources/js/Pages/Resources/Form/TagsFields.vue +++ b/resources/js/Pages/Resources/Form/TagsFields.vue @@ -14,6 +14,10 @@ const props = defineProps({ type: Object, required: true, }, + isLoading: { + type: Boolean, + required: true, + } }); const emit = defineEmits(["change", "next", "back"]); @@ -97,7 +101,7 @@ const validateAndNext = async () => { Back - + Submit diff --git a/tests/Feature/ComputerScienceResourceControllerTest.php b/tests/Feature/ComputerScienceResourceTest.php similarity index 67% rename from tests/Feature/ComputerScienceResourceControllerTest.php rename to tests/Feature/ComputerScienceResourceTest.php index aeb5cb98..bd342f9e 100644 --- a/tests/Feature/ComputerScienceResourceControllerTest.php +++ b/tests/Feature/ComputerScienceResourceTest.php @@ -13,7 +13,7 @@ use Tests\RequestFactories\ComputerScienceResource\StoreResourceRequestFactory; use Tests\TestCase; -class ComputerScienceResourceControllerTest extends TestCase +class ComputerScienceResourceTest extends TestCase { use RefreshDatabase; @@ -112,4 +112,52 @@ public function test_cannot_post_resource_with_invalid_fields(string $field, mix "Failed asserting that a resource with name '{$validData['name']}' was not created in the database. Invalid field: $field" ); } + + public function test_image_is_removed_if_resource_creation_fails() + { + Storage::fake('public'); + $this->actingAs($this->user); + + // Create valid form data but set an invalid field to force failure + $formData = StoreResourceRequestFactory::new()->create(); + $formData['image_file'] = UploadedFile::fake()->image('fail_image.jpg'); + $formData['name'] = str_repeat('a', 101); // Invalid name, will fail validation + + $response = $this->postJson(route('resources.store'), $formData); + $response->assertStatus(422); // Validation error + + // The image should not exist in storage + $this->assertEmpty(Storage::disk('public')->allFiles('resource'), "Failed asserting that no image files exist in the 'resource' directory after failed resource creation."); + } + + public function test_model_removes_image_upon_deletion() + { + $resource = ComputerScienceResource::factory()->create(); + + $imagePath = $resource->image_path; + + Storage::disk('public')->assertExists($imagePath); + + $resource->delete(); + + Storage::disk('public')->assertMissing($imagePath); + } + + public function test_posting_duplicate_resource_returns_existing_resource() + { + $this->actingAs($this->user); + + // Create a resource first + $formData = StoreResourceRequestFactory::new()->create(); + $this->postJson(route('resources.store'), $formData); + + // Try to create the same resource again + $response = $this->postJson(route('resources.store'), $formData); + + $response->assertStatus(200); + + // Should return the existing resource, not create a new one + $resources = ComputerScienceResource::where('name', $formData['name'])->get(); + $this->assertCount(1, $resources); + } } diff --git a/tests/Feature/ResourceEditsTest.php b/tests/Feature/ResourceEditsTest.php index 0bb7861e..17558938 100644 --- a/tests/Feature/ResourceEditsTest.php +++ b/tests/Feature/ResourceEditsTest.php @@ -148,13 +148,13 @@ public function test_merged_edit_reflects_changes_on_original_resource(): void $resource = ComputerScienceResource::factory()->create(); $this->actingAs($this->user); - $mergeAttempts = 10; + $mergeAttempts = 5; for ($i = 0; $i < $mergeAttempts; $i++) { $changes = [ 'name' => "Resource Name Edited {$i}", 'description' => "Resource Description Changed {$i}", - 'image_file' => UploadedFile::fake()->image('resource.jpg'), + 'image_file' => UploadedFile::fake()->image("resource_{$i}.jpg"), 'page_url' => "http://{$i}.com", 'difficulty' => fake()->randomElement(config('computerScienceResource.difficulties')), 'platforms' => fake()->randomElements(config('computerScienceResource.platforms'), fake()->numberBetween(1, 3)), @@ -164,14 +164,17 @@ public function test_merged_edit_reflects_changes_on_original_resource(): void 'general_tags' => ["$i-a", "$i-b", "$i-c"], ]; - $this->makeAndApplyResourceEdits($resource->id, $changes); + $edit = $this->createResourceEdit($resource->id, $changes); + $this->approveResourceEdit($edit); // Refresh and assert $resource->refresh(); $this->assertEquals($changes['name'], $resource->name); $this->assertEquals($changes['description'], $resource->description); + Storage::disk('public')->assertExists($resource->image_path); + Storage::disk('public')->assertMissing($edit->image_path); $this->assertEquals($changes['page_url'], $resource->page_url); $this->assertEquals($changes['difficulty'], $resource->difficulty);