From 0f3bb6de6bee34a69466b347e1e3c8b1fff0033c Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Tue, 2 Jun 2026 10:17:21 +0300 Subject: [PATCH 01/19] test icon uploads --- backend/tests/Fakes/FakeFileUploader.php | 26 +++ .../tests/Feature/ElementsEndpointTest.php | 95 ++++++++++ .../Controllers/ElementControllerTest.php | 61 +++++- .../UseCases/UpdateElementIconImageTest.php | 175 ++++++++++++++++++ 4 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 backend/tests/Fakes/FakeFileUploader.php create mode 100644 backend/tests/Unit/Element/UseCases/UpdateElementIconImageTest.php diff --git a/backend/tests/Fakes/FakeFileUploader.php b/backend/tests/Fakes/FakeFileUploader.php new file mode 100644 index 0000000..61ab381 --- /dev/null +++ b/backend/tests/Fakes/FakeFileUploader.php @@ -0,0 +1,26 @@ + + */ + public array $uploads = []; + + public function upload(FileToUpload $file, string $folder): string + { + $this->uploads[] = ['file' => $file, 'folder' => $folder]; + + return $folder . '/fake-' . $file->getOriginalName(); + } + + public function url(string $path): string + { + return 'https://test.local/storage/' . $path; + } +} diff --git a/backend/tests/Feature/ElementsEndpointTest.php b/backend/tests/Feature/ElementsEndpointTest.php index 2ff9089..52662f8 100644 --- a/backend/tests/Feature/ElementsEndpointTest.php +++ b/backend/tests/Feature/ElementsEndpointTest.php @@ -14,6 +14,8 @@ use App\User\UserRepository; use DateTimeImmutable; use DateTimeZone; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Storage; use Tests\TestCase; class ElementsEndpointTest extends TestCase @@ -189,6 +191,84 @@ class ElementsEndpointTest extends TestCase ); } + public function testIconImageUploadRequiresAuthentication(): void + { + $setRepository = app(SetRepository::class); + $elementRepository = app(ElementRepository::class); + $set = $setRepository->create(new CreateSetDto( + name: 'Baderech HaAvodah', + description: 'A structured path for growth', + iconImageUrl: '/assets/baderech-haavodah-icon.png', + )); + $element = $elementRepository->create(new CreateElementDto( + set: $set, + title: 'Baderech HaAvodah', + description: 'A structured path for growth', + iconImageUrl: null, + richText: '

A structured path for growth

', + pdfPath: null, + youtubeUrl: null, + parentElement: null, + )); + + $response = $this->post( + "/api/elements/{$element->getId()}/icon-image", + ['iconImage' => $this->iconImageUpload()], + ); + + $response->assertUnauthorized(); + $response->assertExactJson([ + 'error' => 'unauthenticated', + ]); + } + + public function testAuthenticatedIconImageUploadReturnsElementPayload(): void + { + Storage::fake('public'); + $setRepository = app(SetRepository::class); + $elementRepository = app(ElementRepository::class); + $set = $setRepository->create(new CreateSetDto( + name: 'Baderech HaAvodah', + description: 'A structured path for growth', + iconImageUrl: '/assets/baderech-haavodah-icon.png', + )); + $element = $elementRepository->create(new CreateElementDto( + set: $set, + title: 'Baderech HaAvodah', + description: 'A structured path for growth', + iconImageUrl: null, + richText: '

A structured path for growth

', + pdfPath: null, + youtubeUrl: null, + parentElement: null, + )); + $this->createSession('valid-token'); + + $response = $this->withCredentials() + ->withUnencryptedCookie('auth_token', 'valid-token') + ->post( + "/api/elements/{$element->getId()}/icon-image", + ['iconImage' => $this->iconImageUpload()], + ); + + $response->assertOk(); + $body = $response->json(); + $this->assertSame($element->getId(), $body['element']['id']); + $this->assertStringContainsString( + '/storage/element-icons/', + $body['element']['iconImageUrl'], + ); + $updatedElement = $elementRepository->find($element->getId()); + $this->assertNotNull($updatedElement); + $updatedIconImageUrl = $updatedElement->getIconImageUrl(); + $this->assertIsString($updatedIconImageUrl); + $this->assertStringStartsWith( + 'element-icons/', + $updatedIconImageUrl, + ); + Storage::disk('public')->assertExists($updatedIconImageUrl); + } + private function createSession(string $token): void { $userRepository = app(UserRepository::class); @@ -211,4 +291,19 @@ class ElementsEndpointTest extends TestCase ), )); } + + private function iconImageUpload(): UploadedFile + { + $fixturePath = __DIR__ + . '/../../../frontend/rabbi_gerzi/public/assets/' + . 'baderech-haavodah-icon.png'; + + return new UploadedFile( + $fixturePath, + 'icon.png', + 'image/png', + null, + true, + ); + } } diff --git a/backend/tests/Unit/Controllers/ElementControllerTest.php b/backend/tests/Unit/Controllers/ElementControllerTest.php index 99bfa3e..984dae0 100644 --- a/backend/tests/Unit/Controllers/ElementControllerTest.php +++ b/backend/tests/Unit/Controllers/ElementControllerTest.php @@ -8,13 +8,17 @@ use App\Element\Element; use App\Element\UseCases\GetElement\GetElement; use App\Element\UseCases\UpdateElement\UpdateDescription; use App\Element\UseCases\UpdateElement\UpdateElement; +use App\Element\UseCases\UpdateElement\UpdateElementIconImage; use App\Element\UseCases\UpdateElement\UpdateIconImageUrl; use App\Element\UseCases\UpdateElement\UpdatePdfPath; use App\Element\UseCases\UpdateElement\UpdateRichText; use App\Element\UseCases\UpdateElement\UpdateTitle; use App\Element\UseCases\UpdateElement\UpdateYoutubeUrl; use App\Set\Set as DomainSet; +use Illuminate\Http\Request; +use Illuminate\Http\UploadedFile; use Tests\Fakes\FakeElementRepository; +use Tests\Fakes\FakeFileUploader; use Tests\TestCase; class ElementControllerTest extends TestCase @@ -23,9 +27,12 @@ class ElementControllerTest extends TestCase private FakeElementRepository $elementRepo; + private FakeFileUploader $fileUploader; + protected function setUp(): void { $this->elementRepo = new FakeElementRepository(); + $this->fileUploader = new FakeFileUploader(); $getElement = new GetElement($this->elementRepo); $updateElement = new UpdateElement( new UpdateTitle($this->elementRepo), @@ -36,7 +43,15 @@ class ElementControllerTest extends TestCase new UpdateYoutubeUrl($this->elementRepo), $this->elementRepo, ); - $this->controller = new ElementController($getElement, $updateElement); + $this->controller = new ElementController( + $getElement, + $updateElement, + new UpdateElementIconImage( + $this->elementRepo, + $this->fileUploader, + ), + $this->fileUploader, + ); } public function testShowReturnsElementPayload(): void @@ -135,6 +150,50 @@ class ElementControllerTest extends TestCase ); } + public function testUploadIconImageReturnsElementPayload(): void + { + $set = $this->createSet(1, 'Baderech'); + $element = $this->createElement( + $set, + 'Baderech HaAvodah', + 'A structured path for growth', + null, + '

A structured path for growth

', + null, + null, + null, + ); + $fixturePath = __DIR__ + . '/../../../../frontend/rabbi_gerzi/public/assets/' + . 'baderech-haavodah-icon.png'; + $request = new Request([], [], [], [], [ + 'iconImage' => new UploadedFile( + $fixturePath, + 'icon.png', + 'image/png', + null, + true, + ), + ]); + + $response = $this->controller->uploadIconImage( + $element->getId(), + $request, + ); + + $this->assertEquals(200, $response->getStatusCode()); + $body = json_decode($response->getContent(), true); + $this->assertSame($element->getId(), $body['element']['id']); + $this->assertSame( + 'https://test.local/storage/element-icons/fake-icon.png', + $body['element']['iconImageUrl'], + ); + $this->assertSame( + 'element-icons', + $this->fileUploader->uploads[0]['folder'], + ); + } + private function createSet(int $id, string $name): DomainSet { return new DomainSet( diff --git a/backend/tests/Unit/Element/UseCases/UpdateElementIconImageTest.php b/backend/tests/Unit/Element/UseCases/UpdateElementIconImageTest.php new file mode 100644 index 0000000..14328f8 --- /dev/null +++ b/backend/tests/Unit/Element/UseCases/UpdateElementIconImageTest.php @@ -0,0 +1,175 @@ +elementRepository = new FakeElementRepository(); + $this->fileUploader = new FakeFileUploader(); + $this->useCase = new UpdateElementIconImage( + $this->elementRepository, + $this->fileUploader, + ); + } + + /** + * @return array{ + * iconImageContents: string, + * iconImageOriginalName: string, + * iconImageMimeType: string, + * iconImageSizeBytes: int + * } + */ + private function imageArgs(): array + { + return [ + 'iconImageContents' => 'binary-image-bytes', + 'iconImageOriginalName' => 'icon.png', + 'iconImageMimeType' => 'image/png', + 'iconImageSizeBytes' => 1024, + ]; + } + + public function testUploadsIconImageAndStoresPath(): void + { + $element = $this->createElement(); + + $updatedElement = $this->useCase->execute( + new UpdateElementIconImageRequest( + ...$this->imageArgs(), + id: $element->getId(), + ) + ); + + $this->assertSame( + 'element-icons/fake-icon.png', + $updatedElement->getIconImageUrl(), + ); + $this->assertSame( + 'element-icons/fake-icon.png', + $this->elementRepository + ->find($element->getId()) + ->getIconImageUrl(), + ); + $this->assertSame( + 'element-icons', + $this->fileUploader->uploads[0]['folder'], + ); + } + + public function testNullIdThrowsBadRequest(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('id is required'); + + $this->useCase->execute( + new UpdateElementIconImageRequest( + ...$this->imageArgs(), + id: null, + ) + ); + } + + public function testNullIconImageThrowsBadRequest(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('icon image is required'); + + $this->useCase->execute( + new UpdateElementIconImageRequest( + id: 1, + iconImageContents: null, + iconImageOriginalName: null, + iconImageMimeType: null, + iconImageSizeBytes: null, + ) + ); + } + + public function testDisallowedMimeThrowsBadRequest(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage( + 'icon image must be a jpeg, png or webp image', + ); + + $this->useCase->execute( + new UpdateElementIconImageRequest( + id: 1, + iconImageContents: 'not-an-image', + iconImageOriginalName: 'icon.pdf', + iconImageMimeType: 'application/pdf', + iconImageSizeBytes: 1024, + ) + ); + } + + public function testOversizedIconImageThrowsBadRequest(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('icon image must be 5MB or smaller'); + + $this->useCase->execute( + new UpdateElementIconImageRequest( + id: 1, + iconImageContents: 'big', + iconImageOriginalName: 'icon.png', + iconImageMimeType: 'image/png', + iconImageSizeBytes: 6 * 1024 * 1024, + ) + ); + } + + public function testMissingElementThrowsNotFound(): void + { + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Element not found'); + + $this->useCase->execute( + new UpdateElementIconImageRequest( + ...$this->imageArgs(), + id: 999, + ) + ); + } + + private function createElement(): Element + { + $set = new DomainSet( + id: 1, + name: 'Baderech', + description: 'Baderech description', + iconImageUrl: '/assets/baderech-icon.png', + ); + + return $this->elementRepository->create(new CreateElementDto( + set: $set, + title: 'Original title', + description: 'Original description', + iconImageUrl: null, + richText: '', + pdfPath: null, + youtubeUrl: null, + parentElement: null, + )); + } +} From 97ecbebb9eeb69673a9f6ae89d01af6efba64121 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Tue, 2 Jun 2026 10:19:28 +0300 Subject: [PATCH 02/19] add icon uploads --- backend/app/Controllers/ElementController.php | 92 ++++++++++++++++++- .../UpdateElement/UpdateElementIconImage.php | 81 ++++++++++++++++ .../UpdateElementIconImageRequest.php | 15 +++ backend/app/Providers/AppServiceProvider.php | 6 ++ backend/app/Shared/Files/FileToUpload.php | 34 +++++++ backend/app/Shared/Files/FileUploader.php | 10 ++ .../app/Shared/Files/LaravelFileUploader.php | 44 +++++++++ backend/routes/api.php | 5 + 8 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 backend/app/Element/UseCases/UpdateElement/UpdateElementIconImage.php create mode 100644 backend/app/Element/UseCases/UpdateElement/UpdateElementIconImageRequest.php create mode 100644 backend/app/Shared/Files/FileToUpload.php create mode 100644 backend/app/Shared/Files/FileUploader.php create mode 100644 backend/app/Shared/Files/LaravelFileUploader.php diff --git a/backend/app/Controllers/ElementController.php b/backend/app/Controllers/ElementController.php index 6a63a85..66edf8c 100644 --- a/backend/app/Controllers/ElementController.php +++ b/backend/app/Controllers/ElementController.php @@ -5,18 +5,24 @@ namespace App\Controllers; use App\Element\Element; use App\Element\UseCases\GetElement\GetElement; use App\Element\UseCases\GetElement\GetElementRequest; +use App\Element\UseCases\UpdateElement\UpdateElementIconImage; +use App\Element\UseCases\UpdateElement\UpdateElementIconImageRequest; use App\Element\UseCases\UpdateElement\UpdateElement; use App\Element\UseCases\UpdateElement\UpdateElementRequest; use App\Exceptions\BadRequestException; use App\Exceptions\NotFoundException; +use App\Shared\Files\FileUploader; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Http\UploadedFile; class ElementController { public function __construct( private GetElement $getElement, private UpdateElement $updateElement, + private UpdateElementIconImage $updateElementIconImage, + private FileUploader $fileUploader, ) { } @@ -84,6 +90,35 @@ class ElementController ], 200); } + public function uploadIconImage(?int $id, Request $request): JsonResponse + { + $iconImage = $this->uploadedFileInput($request, 'iconImage'); + + try { + $element = $this->updateElementIconImage->execute( + new UpdateElementIconImageRequest( + id: $id, + iconImageContents: $iconImage['contents'], + iconImageOriginalName: $iconImage['originalName'], + iconImageMimeType: $iconImage['mimeType'], + iconImageSizeBytes: $iconImage['sizeBytes'], + ) + ); + } catch (BadRequestException $exception) { + return new JsonResponse([ + 'error' => $exception->getMessage(), + ], 400); + } catch (NotFoundException $exception) { + return new JsonResponse([ + 'error' => $exception->getMessage(), + ], 404); + } + + return new JsonResponse([ + 'element' => $this->buildElementPayload($element), + ], 200); + } + private function stringInput(Request $request, string $key): ?string { $value = $request->input($key); @@ -94,6 +129,45 @@ class ElementController return $value; } + /** + * @return array{ + * contents: string|null, + * originalName: string|null, + * mimeType: string|null, + * sizeBytes: int|null + * } + */ + private function uploadedFileInput(Request $request, string $key): array + { + $emptyFileInput = [ + 'contents' => null, + 'originalName' => null, + 'mimeType' => null, + 'sizeBytes' => null, + ]; + + if (! $request->hasFile($key)) { + return $emptyFileInput; + } + + $file = $request->file($key); + if (! $file instanceof UploadedFile) { + return $emptyFileInput; + } + + $realPath = $file->getRealPath(); + if ($realPath === false) { + return $emptyFileInput; + } + + return [ + 'contents' => (string) file_get_contents($realPath), + 'originalName' => $file->getClientOriginalName(), + 'mimeType' => (string) $file->getMimeType(), + 'sizeBytes' => (int) $file->getSize(), + ]; + } + /** * @return array{ * id: int, @@ -111,10 +185,26 @@ class ElementController 'id' => $element->getId(), 'title' => $element->getTitle(), 'description' => $element->getDescription(), - 'iconImageUrl' => $element->getIconImageUrl(), + 'iconImageUrl' => $this->storageUrl( + $element->getIconImageUrl(), + 'element-icons/', + ), 'richText' => $element->getRichText(), 'pdfPath' => $element->getPdfPath(), 'youtubeUrl' => $element->getYoutubeUrl(), ]; } + + private function storageUrl(?string $path, string $folderPrefix): ?string + { + if ($path === null) { + return null; + } + + if (str_starts_with($path, $folderPrefix)) { + return $this->fileUploader->url($path); + } + + return $path; + } } diff --git a/backend/app/Element/UseCases/UpdateElement/UpdateElementIconImage.php b/backend/app/Element/UseCases/UpdateElement/UpdateElementIconImage.php new file mode 100644 index 0000000..4cdd746 --- /dev/null +++ b/backend/app/Element/UseCases/UpdateElement/UpdateElementIconImage.php @@ -0,0 +1,81 @@ +id === null) { + throw new BadRequestException('id is required'); + } + + if ( + $request->iconImageContents === null + || $request->iconImageOriginalName === null + || $request->iconImageMimeType === null + || $request->iconImageSizeBytes === null + ) { + throw new BadRequestException('icon image is required'); + } + + if ( + ! in_array( + $request->iconImageMimeType, + self::ALLOWED_MIME_TYPES, + true, + ) + ) { + throw new BadRequestException( + 'icon image must be a jpeg, png or webp image', + ); + } + + if ($request->iconImageSizeBytes > self::MAX_SIZE_BYTES) { + throw new BadRequestException( + 'icon image must be 5MB or smaller', + ); + } + + $element = $this->elementRepository->find($request->id); + if ($element === null) { + throw new NotFoundException('Element not found'); + } + + $iconImage = new FileToUpload( + contents: $request->iconImageContents, + originalName: $request->iconImageOriginalName, + mimeType: $request->iconImageMimeType, + sizeBytes: $request->iconImageSizeBytes, + ); + $path = $this->fileUploader->upload($iconImage, 'element-icons'); + $element->setIconImageUrl($path); + + return $this->elementRepository->update($element); + } +} diff --git a/backend/app/Element/UseCases/UpdateElement/UpdateElementIconImageRequest.php b/backend/app/Element/UseCases/UpdateElement/UpdateElementIconImageRequest.php new file mode 100644 index 0000000..d230d96 --- /dev/null +++ b/backend/app/Element/UseCases/UpdateElement/UpdateElementIconImageRequest.php @@ -0,0 +1,15 @@ +app->bind( + FileUploader::class, + LaravelFileUploader::class, + ); } } diff --git a/backend/app/Shared/Files/FileToUpload.php b/backend/app/Shared/Files/FileToUpload.php new file mode 100644 index 0000000..e8b8f41 --- /dev/null +++ b/backend/app/Shared/Files/FileToUpload.php @@ -0,0 +1,34 @@ +contents; + } + + public function getOriginalName(): string + { + return $this->originalName; + } + + public function getMimeType(): string + { + return $this->mimeType; + } + + public function getSizeBytes(): int + { + return $this->sizeBytes; + } +} diff --git a/backend/app/Shared/Files/FileUploader.php b/backend/app/Shared/Files/FileUploader.php new file mode 100644 index 0000000..b5bad4e --- /dev/null +++ b/backend/app/Shared/Files/FileUploader.php @@ -0,0 +1,10 @@ +extensionFor($file); + Storage::disk('public')->put($path, $file->getContents()); + + return $path; + } + + public function url(string $path): string + { + return Storage::disk('public')->url($path); + } + + private function extensionFor(FileToUpload $file): string + { + $mimeExtensions = [ + 'image/jpeg' => '.jpg', + 'image/png' => '.png', + 'image/webp' => '.webp', + ]; + if (isset($mimeExtensions[$file->getMimeType()])) { + return $mimeExtensions[$file->getMimeType()]; + } + + $originalExtension = pathinfo( + $file->getOriginalName(), + PATHINFO_EXTENSION, + ); + if ($originalExtension !== '') { + return '.' . $originalExtension; + } + + return ''; + } +} diff --git a/backend/routes/api.php b/backend/routes/api.php index 9d253d0..b864ae2 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -14,3 +14,8 @@ Route::get('/sets', [SetController::class, 'index']); Route::get('/elements/{id}', [ElementController::class, 'show']); Route::patch('/elements/{id}', [ElementController::class, 'update']) ->middleware(AuthMiddleware::class); +Route::post( + '/elements/{id}/icon-image', + [ElementController::class, 'uploadIconImage'], +) + ->middleware(AuthMiddleware::class); From 1fbe198d8cd2251c58abb43a8d545d90f34ada04 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Tue, 2 Jun 2026 10:21:28 +0300 Subject: [PATCH 03/19] test pdf uploads --- .../tests/Feature/ElementsEndpointTest.php | 90 +++++++++ .../Controllers/ElementControllerTest.php | 49 +++++ .../Element/UseCases/UpdateElementPdfTest.php | 173 ++++++++++++++++++ 3 files changed, 312 insertions(+) create mode 100644 backend/tests/Unit/Element/UseCases/UpdateElementPdfTest.php diff --git a/backend/tests/Feature/ElementsEndpointTest.php b/backend/tests/Feature/ElementsEndpointTest.php index 52662f8..f161ed3 100644 --- a/backend/tests/Feature/ElementsEndpointTest.php +++ b/backend/tests/Feature/ElementsEndpointTest.php @@ -269,6 +269,81 @@ class ElementsEndpointTest extends TestCase Storage::disk('public')->assertExists($updatedIconImageUrl); } + public function testPdfUploadRequiresAuthentication(): void + { + $setRepository = app(SetRepository::class); + $elementRepository = app(ElementRepository::class); + $set = $setRepository->create(new CreateSetDto( + name: 'Baderech HaAvodah', + description: 'A structured path for growth', + iconImageUrl: '/assets/baderech-haavodah-icon.png', + )); + $element = $elementRepository->create(new CreateElementDto( + set: $set, + title: 'Baderech HaAvodah', + description: 'A structured path for growth', + iconImageUrl: null, + richText: '

A structured path for growth

', + pdfPath: null, + youtubeUrl: null, + parentElement: null, + )); + + $response = $this->post( + "/api/elements/{$element->getId()}/pdf", + ['pdf' => $this->pdfUpload()], + ); + + $response->assertUnauthorized(); + $response->assertExactJson([ + 'error' => 'unauthenticated', + ]); + } + + public function testAuthenticatedPdfUploadReturnsElementPayload(): void + { + Storage::fake('public'); + $setRepository = app(SetRepository::class); + $elementRepository = app(ElementRepository::class); + $set = $setRepository->create(new CreateSetDto( + name: 'Baderech HaAvodah', + description: 'A structured path for growth', + iconImageUrl: '/assets/baderech-haavodah-icon.png', + )); + $element = $elementRepository->create(new CreateElementDto( + set: $set, + title: 'Baderech HaAvodah', + description: 'A structured path for growth', + iconImageUrl: null, + richText: '

A structured path for growth

', + pdfPath: null, + youtubeUrl: null, + parentElement: null, + )); + $this->createSession('valid-token'); + + $response = $this->withCredentials() + ->withUnencryptedCookie('auth_token', 'valid-token') + ->post( + "/api/elements/{$element->getId()}/pdf", + ['pdf' => $this->pdfUpload()], + ); + + $response->assertOk(); + $body = $response->json(); + $this->assertSame($element->getId(), $body['element']['id']); + $this->assertStringContainsString( + '/storage/element-pdfs/', + $body['element']['pdfPath'], + ); + $updatedElement = $elementRepository->find($element->getId()); + $this->assertNotNull($updatedElement); + $updatedPdfPath = $updatedElement->getPdfPath(); + $this->assertIsString($updatedPdfPath); + $this->assertStringStartsWith('element-pdfs/', $updatedPdfPath); + Storage::disk('public')->assertExists($updatedPdfPath); + } + private function createSession(string $token): void { $userRepository = app(UserRepository::class); @@ -306,4 +381,19 @@ class ElementsEndpointTest extends TestCase true, ); } + + private function pdfUpload(): UploadedFile + { + $fixturePath = __DIR__ + . '/../../../frontend/rabbi_gerzi/public/assets/pdfs/' + . 'baderech.pdf'; + + return new UploadedFile( + $fixturePath, + 'baderech.pdf', + 'application/pdf', + null, + true, + ); + } } diff --git a/backend/tests/Unit/Controllers/ElementControllerTest.php b/backend/tests/Unit/Controllers/ElementControllerTest.php index 984dae0..0732914 100644 --- a/backend/tests/Unit/Controllers/ElementControllerTest.php +++ b/backend/tests/Unit/Controllers/ElementControllerTest.php @@ -9,6 +9,7 @@ use App\Element\UseCases\GetElement\GetElement; use App\Element\UseCases\UpdateElement\UpdateDescription; use App\Element\UseCases\UpdateElement\UpdateElement; use App\Element\UseCases\UpdateElement\UpdateElementIconImage; +use App\Element\UseCases\UpdateElement\UpdateElementPdf; use App\Element\UseCases\UpdateElement\UpdateIconImageUrl; use App\Element\UseCases\UpdateElement\UpdatePdfPath; use App\Element\UseCases\UpdateElement\UpdateRichText; @@ -50,6 +51,10 @@ class ElementControllerTest extends TestCase $this->elementRepo, $this->fileUploader, ), + new UpdateElementPdf( + $this->elementRepo, + $this->fileUploader, + ), $this->fileUploader, ); } @@ -194,6 +199,50 @@ class ElementControllerTest extends TestCase ); } + public function testUploadPdfReturnsElementPayload(): void + { + $set = $this->createSet(1, 'Baderech'); + $element = $this->createElement( + $set, + 'Baderech HaAvodah', + 'A structured path for growth', + null, + '

A structured path for growth

', + null, + null, + null, + ); + $fixturePath = __DIR__ + . '/../../../../frontend/rabbi_gerzi/public/assets/pdfs/' + . 'baderech.pdf'; + $request = new Request([], [], [], [], [ + 'pdf' => new UploadedFile( + $fixturePath, + 'baderech.pdf', + 'application/pdf', + null, + true, + ), + ]); + + $response = $this->controller->uploadPdf( + $element->getId(), + $request, + ); + + $this->assertEquals(200, $response->getStatusCode()); + $body = json_decode($response->getContent(), true); + $this->assertSame($element->getId(), $body['element']['id']); + $this->assertSame( + 'https://test.local/storage/element-pdfs/fake-baderech.pdf', + $body['element']['pdfPath'], + ); + $this->assertSame( + 'element-pdfs', + $this->fileUploader->uploads[0]['folder'], + ); + } + private function createSet(int $id, string $name): DomainSet { return new DomainSet( diff --git a/backend/tests/Unit/Element/UseCases/UpdateElementPdfTest.php b/backend/tests/Unit/Element/UseCases/UpdateElementPdfTest.php new file mode 100644 index 0000000..8fd8ef7 --- /dev/null +++ b/backend/tests/Unit/Element/UseCases/UpdateElementPdfTest.php @@ -0,0 +1,173 @@ +elementRepository = new FakeElementRepository(); + $this->fileUploader = new FakeFileUploader(); + $this->useCase = new UpdateElementPdf( + $this->elementRepository, + $this->fileUploader, + ); + } + + /** + * @return array{ + * pdfContents: string, + * pdfOriginalName: string, + * pdfMimeType: string, + * pdfSizeBytes: int + * } + */ + private function pdfArgs(): array + { + return [ + 'pdfContents' => 'binary-pdf-bytes', + 'pdfOriginalName' => 'baderech.pdf', + 'pdfMimeType' => 'application/pdf', + 'pdfSizeBytes' => 1024, + ]; + } + + public function testUploadsPdfAndStoresPath(): void + { + $element = $this->createElement(); + + $updatedElement = $this->useCase->execute( + new UpdateElementPdfRequest( + ...$this->pdfArgs(), + id: $element->getId(), + ) + ); + + $this->assertSame( + 'element-pdfs/fake-baderech.pdf', + $updatedElement->getPdfPath(), + ); + $storedElement = $this->elementRepository->find($element->getId()); + $this->assertNotNull($storedElement); + $this->assertSame( + 'element-pdfs/fake-baderech.pdf', + $storedElement->getPdfPath(), + ); + $this->assertSame( + 'element-pdfs', + $this->fileUploader->uploads[0]['folder'], + ); + } + + public function testNullIdThrowsBadRequest(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('id is required'); + + $this->useCase->execute( + new UpdateElementPdfRequest( + ...$this->pdfArgs(), + id: null, + ) + ); + } + + public function testNullPdfThrowsBadRequest(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('pdf is required'); + + $this->useCase->execute( + new UpdateElementPdfRequest( + id: 1, + pdfContents: null, + pdfOriginalName: null, + pdfMimeType: null, + pdfSizeBytes: null, + ) + ); + } + + public function testDisallowedMimeThrowsBadRequest(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('pdf must be a pdf file'); + + $this->useCase->execute( + new UpdateElementPdfRequest( + id: 1, + pdfContents: 'not-a-pdf', + pdfOriginalName: 'icon.png', + pdfMimeType: 'image/png', + pdfSizeBytes: 1024, + ) + ); + } + + public function testOversizedPdfThrowsBadRequest(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('pdf must be 10MB or smaller'); + + $this->useCase->execute( + new UpdateElementPdfRequest( + id: 1, + pdfContents: 'big', + pdfOriginalName: 'baderech.pdf', + pdfMimeType: 'application/pdf', + pdfSizeBytes: 11 * 1024 * 1024, + ) + ); + } + + public function testMissingElementThrowsNotFound(): void + { + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Element not found'); + + $this->useCase->execute( + new UpdateElementPdfRequest( + ...$this->pdfArgs(), + id: 999, + ) + ); + } + + private function createElement(): Element + { + $set = new DomainSet( + id: 1, + name: 'Baderech', + description: 'Baderech description', + iconImageUrl: '/assets/baderech-icon.png', + ); + + return $this->elementRepository->create(new CreateElementDto( + set: $set, + title: 'Original title', + description: 'Original description', + iconImageUrl: null, + richText: '', + pdfPath: null, + youtubeUrl: null, + parentElement: null, + )); + } +} From cd2e63d17d9d9f2c1dc90d78b2e4e7bce68609ce Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Tue, 2 Jun 2026 10:22:53 +0300 Subject: [PATCH 04/19] add pdf uploads --- backend/app/Controllers/ElementController.php | 39 +++++++++- .../UpdateElement/UpdateElementPdf.php | 75 +++++++++++++++++++ .../UpdateElement/UpdateElementPdfRequest.php | 15 ++++ .../app/Shared/Files/LaravelFileUploader.php | 1 + backend/routes/api.php | 5 ++ 5 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 backend/app/Element/UseCases/UpdateElement/UpdateElementPdf.php create mode 100644 backend/app/Element/UseCases/UpdateElement/UpdateElementPdfRequest.php diff --git a/backend/app/Controllers/ElementController.php b/backend/app/Controllers/ElementController.php index 66edf8c..b6aae90 100644 --- a/backend/app/Controllers/ElementController.php +++ b/backend/app/Controllers/ElementController.php @@ -5,9 +5,11 @@ namespace App\Controllers; use App\Element\Element; use App\Element\UseCases\GetElement\GetElement; use App\Element\UseCases\GetElement\GetElementRequest; +use App\Element\UseCases\UpdateElement\UpdateElement; use App\Element\UseCases\UpdateElement\UpdateElementIconImage; use App\Element\UseCases\UpdateElement\UpdateElementIconImageRequest; -use App\Element\UseCases\UpdateElement\UpdateElement; +use App\Element\UseCases\UpdateElement\UpdateElementPdf; +use App\Element\UseCases\UpdateElement\UpdateElementPdfRequest; use App\Element\UseCases\UpdateElement\UpdateElementRequest; use App\Exceptions\BadRequestException; use App\Exceptions\NotFoundException; @@ -22,6 +24,7 @@ class ElementController private GetElement $getElement, private UpdateElement $updateElement, private UpdateElementIconImage $updateElementIconImage, + private UpdateElementPdf $updateElementPdf, private FileUploader $fileUploader, ) { } @@ -90,6 +93,35 @@ class ElementController ], 200); } + public function uploadPdf(?int $id, Request $request): JsonResponse + { + $pdf = $this->uploadedFileInput($request, 'pdf'); + + try { + $element = $this->updateElementPdf->execute( + new UpdateElementPdfRequest( + id: $id, + pdfContents: $pdf['contents'], + pdfOriginalName: $pdf['originalName'], + pdfMimeType: $pdf['mimeType'], + pdfSizeBytes: $pdf['sizeBytes'], + ) + ); + } catch (BadRequestException $exception) { + return new JsonResponse([ + 'error' => $exception->getMessage(), + ], 400); + } catch (NotFoundException $exception) { + return new JsonResponse([ + 'error' => $exception->getMessage(), + ], 404); + } + + return new JsonResponse([ + 'element' => $this->buildElementPayload($element), + ], 200); + } + public function uploadIconImage(?int $id, Request $request): JsonResponse { $iconImage = $this->uploadedFileInput($request, 'iconImage'); @@ -190,7 +222,10 @@ class ElementController 'element-icons/', ), 'richText' => $element->getRichText(), - 'pdfPath' => $element->getPdfPath(), + 'pdfPath' => $this->storageUrl( + $element->getPdfPath(), + 'element-pdfs/', + ), 'youtubeUrl' => $element->getYoutubeUrl(), ]; } diff --git a/backend/app/Element/UseCases/UpdateElement/UpdateElementPdf.php b/backend/app/Element/UseCases/UpdateElement/UpdateElementPdf.php new file mode 100644 index 0000000..2f84fc8 --- /dev/null +++ b/backend/app/Element/UseCases/UpdateElement/UpdateElementPdf.php @@ -0,0 +1,75 @@ +id === null) { + throw new BadRequestException('id is required'); + } + + if ( + $request->pdfContents === null + || $request->pdfOriginalName === null + || $request->pdfMimeType === null + || $request->pdfSizeBytes === null + ) { + throw new BadRequestException('pdf is required'); + } + + if ( + ! in_array( + $request->pdfMimeType, + self::ALLOWED_MIME_TYPES, + true, + ) + ) { + throw new BadRequestException('pdf must be a pdf file'); + } + + if ($request->pdfSizeBytes > self::MAX_SIZE_BYTES) { + throw new BadRequestException('pdf must be 10MB or smaller'); + } + + $element = $this->elementRepository->find($request->id); + if ($element === null) { + throw new NotFoundException('Element not found'); + } + + $pdf = new FileToUpload( + contents: $request->pdfContents, + originalName: $request->pdfOriginalName, + mimeType: $request->pdfMimeType, + sizeBytes: $request->pdfSizeBytes, + ); + $path = $this->fileUploader->upload($pdf, 'element-pdfs'); + $element->setPdfPath($path); + + return $this->elementRepository->update($element); + } +} diff --git a/backend/app/Element/UseCases/UpdateElement/UpdateElementPdfRequest.php b/backend/app/Element/UseCases/UpdateElement/UpdateElementPdfRequest.php new file mode 100644 index 0000000..7808083 --- /dev/null +++ b/backend/app/Element/UseCases/UpdateElement/UpdateElementPdfRequest.php @@ -0,0 +1,15 @@ + '.jpg', 'image/png' => '.png', 'image/webp' => '.webp', + 'application/pdf' => '.pdf', ]; if (isset($mimeExtensions[$file->getMimeType()])) { return $mimeExtensions[$file->getMimeType()]; diff --git a/backend/routes/api.php b/backend/routes/api.php index b864ae2..1ad6827 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -19,3 +19,8 @@ Route::post( [ElementController::class, 'uploadIconImage'], ) ->middleware(AuthMiddleware::class); +Route::post( + '/elements/{id}/pdf', + [ElementController::class, 'uploadPdf'], +) + ->middleware(AuthMiddleware::class); From 42924943457417cd6a9feece1e4b31f0a46f25a4 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Tue, 2 Jun 2026 10:24:48 +0300 Subject: [PATCH 05/19] test element uploads --- .../cypress/e2e/admin-element.cy.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts b/frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts index 8678ec2..760ee87 100644 --- a/frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts +++ b/frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts @@ -120,4 +120,46 @@ describe('admin element editing', () => { .should('be.visible') .and('contain.text', 'Saved') }) + + it('uploads icon and pdf files immediately through the UI', () => { + cy.resetDb() + loginAsAdmin() + cy.visit('/admin/element/1') + + cy.get('[data-cy="admin-element-icon-image-input"]').selectFile( + 'public/assets/baderech-haavodah-icon.png', + { force: true }, + ) + cy.get('[data-cy="admin-element-icon-image-status"]') + .should('be.visible') + .and('contain.text', 'Icon image updated') + cy.get('[data-cy="admin-element-current-icon"]') + .should('be.visible') + .and('have.attr', 'src') + .and('include', '/storage/element-icons/') + + cy.get('[data-cy="admin-element-pdf-input"]').selectFile( + 'public/assets/pdfs/baderech.pdf', + { force: true }, + ) + cy.get('[data-cy="admin-element-pdf-status"]') + .should('be.visible') + .and('contain.text', 'PDF updated') + cy.get('[data-cy="admin-element-current-pdf"]') + .should('be.visible') + .and('have.attr', 'href') + .and('include', '/storage/element-pdfs/') + + cy.contains('header.site-header a', 'View Element').click() + cy.get('[data-cy="element-icon"]') + .should('be.visible') + .and('have.attr', 'src') + .and('include', '/storage/element-icons/') + cy.get('[data-cy="element-pdf-link"]') + .should('be.visible') + .and('have.attr', 'href') + .and('include', '/storage/element-pdfs/') + + cy.resetDb() + }) }) From a2c3ffad8b370622c9d61538221a0057bab58b03 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Tue, 2 Jun 2026 10:30:48 +0300 Subject: [PATCH 06/19] add element upload UI --- .../cypress/e2e/admin-element.cy.ts | 14 - frontend/rabbi_gerzi/src/stores/elements.ts | 150 +++++++-- .../src/views/AdminElementPage.vue | 287 ++++++++++++++++-- 3 files changed, 384 insertions(+), 67 deletions(-) diff --git a/frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts b/frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts index 760ee87..eb40d27 100644 --- a/frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts +++ b/frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts @@ -12,26 +12,17 @@ function loginAsAdmin(): void { function fillElementForm( title: string, description: string, - iconImageUrl: string, richText: string, - pdfPath: string, youtubeUrl: string, ): void { cy.get('[data-cy="admin-element-title"]').clear().type(title) cy.get('[data-cy="admin-element-description"]').clear().type(description) - cy.get('[data-cy="admin-element-icon-image-url"]') - .clear() - .type(iconImageUrl) cy.get('[data-cy="admin-element-rich-text"]').clear() if (richText !== '') { cy.get('[data-cy="admin-element-rich-text"]').type(richText, { parseSpecialCharSequences: false, }) } - cy.get('[data-cy="admin-element-pdf-path"]').clear() - if (pdfPath !== '') { - cy.get('[data-cy="admin-element-pdf-path"]').type(pdfPath) - } cy.get('[data-cy="admin-element-youtube-url"]').clear() if (youtubeUrl !== '') { cy.get('[data-cy="admin-element-youtube-url"]').type(youtubeUrl) @@ -79,7 +70,6 @@ describe('admin element editing', () => { it('saves element edits and restores the seed content through the UI', () => { const originalTitle = 'Baderech HaAvodah' const originalDescription = 'a structured path for inner and outer growth' - const originalIconImageUrl = '/assets/baderech-haavodah-icon.png' const updatedTitle = 'Baderech HaAvodah Updated' const updatedDescription = 'Updated admin description' const updatedRichText = '

Updated admin rich text

' @@ -90,10 +80,8 @@ describe('admin element editing', () => { fillElementForm( updatedTitle, updatedDescription, - originalIconImageUrl, updatedRichText, '', - '', ) cy.get('[data-cy="admin-element-save"]').click() @@ -110,8 +98,6 @@ describe('admin element editing', () => { fillElementForm( originalTitle, originalDescription, - originalIconImageUrl, - '', '', '', ) diff --git a/frontend/rabbi_gerzi/src/stores/elements.ts b/frontend/rabbi_gerzi/src/stores/elements.ts index 9da179f..bf01744 100644 --- a/frontend/rabbi_gerzi/src/stores/elements.ts +++ b/frontend/rabbi_gerzi/src/stores/elements.ts @@ -22,9 +22,7 @@ interface ElementResponse { export interface UpdateElementInput { title: string description: string - iconImageUrl: string richText: string - pdfPath: string youtubeUrl: string } @@ -32,6 +30,15 @@ interface UpdateElementResponse { element: Element } +interface ElementPatchInput { + title?: string + description?: string + iconImageUrl?: string + richText?: string + pdfPath?: string + youtubeUrl?: string +} + interface ErrorResponse { error?: string } @@ -44,6 +51,8 @@ export const useElementsStore = defineStore('elements', () => { const isLoading = ref(false) const error = ref(null) const isSaving = ref(false) + const isUploadingIconImage = ref(false) + const isUploadingPdf = ref(false) const saveError = ref(null) async function fetchElement(elementId: string): Promise { @@ -80,6 +89,22 @@ export const useElementsStore = defineStore('elements', () => { } async function updateElement(elementId: string, input: UpdateElementInput): Promise { + return await saveElementPatch(elementId, input, 'Could not save element') + } + + async function clearElementIconImage(elementId: string): Promise { + return await saveElementPatch(elementId, { iconImageUrl: '' }, 'Could not remove icon image') + } + + async function clearElementPdf(elementId: string): Promise { + return await saveElementPatch(elementId, { pdfPath: '' }, 'Could not remove PDF') + } + + async function saveElementPatch( + elementId: string, + input: ElementPatchInput, + failureMessage: string, + ): Promise { saveError.value = null isSaving.value = true @@ -93,46 +118,117 @@ export const useElementsStore = defineStore('elements', () => { body: JSON.stringify(input), }) - if (response.status === 401) { - saveError.value = 'Please log in again' - return false - } - - if (response.status === 404) { - saveError.value = 'Element not found' - return false - } - - if (response.status === 400) { - const data: ErrorResponse = await response.json() - saveError.value = data.error ?? 'Could not save element' - return false - } - - if (!response.ok) { - saveError.value = 'Could not save element' - return false - } - - const data: UpdateElementResponse = await response.json() - element.value = data.element - return true + return await handleElementResponse(response, failureMessage) } catch { - saveError.value = 'Network error - could not save element' + saveError.value = `Network error - ${failureMessage.toLowerCase()}` return false } finally { isSaving.value = false } } + async function uploadElementIconImage(elementId: string, file: File): Promise { + saveError.value = null + isUploadingIconImage.value = true + + try { + const encodedElementId = encodeURIComponent(elementId) + const elementUrl = `${API_BASE_URL}/api/elements/${encodedElementId}` + const formData = new FormData() + formData.append('iconImage', file) + const response = await fetch(`${elementUrl}/icon-image`, { + method: 'POST', + headers: { Accept: 'application/json' }, + credentials: 'include', + body: formData, + }) + + return await handleElementResponse(response, 'Could not upload icon image') + } catch { + saveError.value = 'Network error - could not upload icon image' + return false + } finally { + isUploadingIconImage.value = false + } + } + + async function uploadElementPdf(elementId: string, file: File): Promise { + saveError.value = null + isUploadingPdf.value = true + + try { + const encodedElementId = encodeURIComponent(elementId) + const elementUrl = `${API_BASE_URL}/api/elements/${encodedElementId}` + const formData = new FormData() + formData.append('pdf', file) + const response = await fetch(`${elementUrl}/pdf`, { + method: 'POST', + headers: { Accept: 'application/json' }, + credentials: 'include', + body: formData, + }) + + return await handleElementResponse(response, 'Could not upload PDF') + } catch { + saveError.value = 'Network error - could not upload PDF' + return false + } finally { + isUploadingPdf.value = false + } + } + + async function handleElementResponse( + response: Response, + failureMessage: string, + ): Promise { + if (response.status === 401) { + saveError.value = 'Please log in again' + return false + } + + if (response.status === 404) { + saveError.value = 'Element not found' + return false + } + + if (response.status === 400) { + saveError.value = await errorMessage(response, failureMessage) + return false + } + + if (!response.ok) { + saveError.value = failureMessage + return false + } + + const data: UpdateElementResponse = await response.json() + element.value = data.element + return true + } + + async function errorMessage(response: Response, fallbackMessage: string): Promise { + try { + const data: ErrorResponse = await response.json() + return data.error ?? fallbackMessage + } catch { + return fallbackMessage + } + } + return { element, childElements, isLoading, error, isSaving, + isUploadingIconImage, + isUploadingPdf, saveError, fetchElement, updateElement, + uploadElementIconImage, + uploadElementPdf, + clearElementIconImage, + clearElementPdf, } }) diff --git a/frontend/rabbi_gerzi/src/views/AdminElementPage.vue b/frontend/rabbi_gerzi/src/views/AdminElementPage.vue index cc19049..37f03ff 100644 --- a/frontend/rabbi_gerzi/src/views/AdminElementPage.vue +++ b/frontend/rabbi_gerzi/src/views/AdminElementPage.vue @@ -8,23 +8,24 @@ import { useElementsStore } from '@/stores/elements' interface ElementForm { title: string description: string - iconImageUrl: string richText: string - pdfPath: string youtubeUrl: string } const route = useRoute() const elementsStore = useElementsStore() -const { element, isLoading, error, isSaving, saveError } = storeToRefs(elementsStore) +const { element, isLoading, error, isSaving, isUploadingIconImage, isUploadingPdf, saveError } = + storeToRefs(elementsStore) const savedMessage = ref(null) +const iconImageStatus = ref(null) +const pdfStatus = ref(null) +const iconImageInput = ref(null) +const pdfInput = ref(null) const form = reactive({ title: '', description: '', - iconImageUrl: '', richText: '', - pdfPath: '', youtubeUrl: '', }) @@ -54,6 +55,8 @@ watch( } savedMessage.value = null + iconImageStatus.value = null + pdfStatus.value = null void elementsStore.fetchElement(currentElementId) }, { immediate: true }, @@ -66,9 +69,7 @@ watch(element, (currentElement) => { form.title = currentElement.title form.description = currentElement.description - form.iconImageUrl = currentElement.iconImageUrl ?? '' form.richText = currentElement.richText - form.pdfPath = currentElement.pdfPath ?? '' form.youtubeUrl = currentElement.youtubeUrl ?? '' }) @@ -81,9 +82,7 @@ async function handleSubmit(): Promise { const saved = await elementsStore.updateElement(elementId.value, { title: form.title, description: form.description, - iconImageUrl: form.iconImageUrl, richText: form.richText, - pdfPath: form.pdfPath, youtubeUrl: form.youtubeUrl, }) @@ -91,6 +90,92 @@ async function handleSubmit(): Promise { savedMessage.value = 'Saved' } } + +function chooseIconImage(): void { + iconImageInput.value?.click() +} + +function choosePdf(): void { + pdfInput.value?.click() +} + +async function handleIconImageChange(changeEvent: Event): Promise { + const selectedFile = selectedInputFile(changeEvent) + if (selectedFile === null || elementId.value === '') { + return + } + + savedMessage.value = null + iconImageStatus.value = null + const uploaded = await elementsStore.uploadElementIconImage(elementId.value, selectedFile) + resetInput(changeEvent) + + if (uploaded) { + iconImageStatus.value = 'Icon image updated' + } +} + +async function handlePdfChange(changeEvent: Event): Promise { + const selectedFile = selectedInputFile(changeEvent) + if (selectedFile === null || elementId.value === '') { + return + } + + savedMessage.value = null + pdfStatus.value = null + const uploaded = await elementsStore.uploadElementPdf(elementId.value, selectedFile) + resetInput(changeEvent) + + if (uploaded) { + pdfStatus.value = 'PDF updated' + } +} + +async function handleRemoveIconImage(): Promise { + if (elementId.value === '') { + return + } + + savedMessage.value = null + iconImageStatus.value = null + const removed = await elementsStore.clearElementIconImage(elementId.value) + if (removed) { + iconImageStatus.value = 'Icon image removed' + } +} + +async function handleRemovePdf(): Promise { + if (elementId.value === '') { + return + } + + savedMessage.value = null + pdfStatus.value = null + const removed = await elementsStore.clearElementPdf(elementId.value) + if (removed) { + pdfStatus.value = 'PDF removed' + } +} + +function selectedInputFile(changeEvent: Event): File | null { + const target = changeEvent.target + if (!(target instanceof HTMLInputElement)) { + return null + } + + if (target.files === null || target.files.length === 0) { + return null + } + + return target.files.item(0) +} + +function resetInput(changeEvent: Event): void { + const target = changeEvent.target + if (target instanceof HTMLInputElement) { + target.value = '' + } +}