From 50df56a0cbfb5331d64ad91a748ab63f868aa79b Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 26 Jun 2026 10:50:21 +0300 Subject: [PATCH 1/6] test set creation endpoint --- backend/tests/Feature/SetsEndpointTest.php | 122 +++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/backend/tests/Feature/SetsEndpointTest.php b/backend/tests/Feature/SetsEndpointTest.php index 2f8264b..74592cc 100644 --- a/backend/tests/Feature/SetsEndpointTest.php +++ b/backend/tests/Feature/SetsEndpointTest.php @@ -2,11 +2,20 @@ namespace Tests\Feature; +use App\Auth\CreateSessionDto; +use App\Auth\SessionRepository; use App\Element\CreateElementDto; use App\Element\ElementRepository; use App\Set\CreateSetDto; use App\Set\SetRepository; +use App\Shared\ValueObject\EmailAddress; +use App\User\CreateUserDto; +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 SetsEndpointTest extends TestCase @@ -63,4 +72,117 @@ class SetsEndpointTest extends TestCase ], ]); } + + public function testCreateSetRequiresAuthentication(): void + { + $response = $this->postJson('/api/sets', [ + 'name' => 'New Series', + 'description' => 'A new learning series', + ]); + + $response->assertUnauthorized(); + $response->assertExactJson([ + 'error' => 'unauthenticated', + ]); + } + + public function testAuthenticatedCreateSetCreatesRootElement(): void + { + Storage::fake('public'); + $setRepository = app(SetRepository::class); + $elementRepository = app(ElementRepository::class); + $this->createSession('valid-token'); + + $response = $this->withCredentials() + ->withUnencryptedCookie('auth_token', 'valid-token') + ->post('/api/sets', [ + 'name' => 'New Series', + 'description' => 'A new learning series', + 'iconImage' => $this->iconImageUpload(), + ]); + + $response->assertCreated(); + $body = $response->json(); + $setId = $body['set']['id']; + $rootElementId = $body['set']['rootElementId']; + $this->assertIsInt($setId); + $this->assertIsInt($rootElementId); + $this->assertSame('New Series', $body['set']['name']); + $this->assertSame( + 'A new learning series', + $body['set']['description'], + ); + $this->assertStringContainsString( + '/storage/set-icons/', + $body['set']['iconImageUrl'], + ); + + $createdSet = $setRepository->find($setId); + $this->assertNotNull($createdSet); + $rootElement = $elementRepository->findRootBySet($createdSet); + $this->assertNotNull($rootElement); + $this->assertSame($rootElementId, $rootElement->getId()); + $this->assertSame('New Series', $rootElement->getTitle()); + $this->assertSame( + 'A new learning series', + $rootElement->getDescription(), + ); + $storedIconPath = $createdSet->getIconImageUrl(); + $this->assertStringStartsWith('set-icons/', $storedIconPath); + $this->assertSame($storedIconPath, $rootElement->getIconImageUrl()); + Storage::disk('public')->assertExists($storedIconPath); + } + + public function testCreateSetRequiresIconImage(): void + { + $this->createSession('valid-token'); + + $response = $this->withCredentials() + ->withUnencryptedCookie('auth_token', 'valid-token') + ->post('/api/sets', [ + 'name' => 'New Series', + 'description' => 'A new learning series', + ]); + + $response->assertBadRequest(); + $response->assertExactJson([ + 'error' => 'icon image is required', + ]); + } + + private function createSession(string $token): void + { + $userRepository = app(UserRepository::class); + $sessionRepository = app(SessionRepository::class); + $user = $userRepository->create(new CreateUserDto( + email: new EmailAddress('admin@example.com'), + passwordHash: 'password-hash', + )); + $now = new DateTimeImmutable( + '2026-05-01T00:00:00', + new DateTimeZone('UTC'), + ); + $sessionRepository->create(new CreateSessionDto( + token: $token, + user: $user, + createdAt: $now, + expiresAt: new DateTimeImmutable( + '2099-01-01T00:00:00', + new DateTimeZone('UTC'), + ), + )); + } + + private function iconImageUpload(): UploadedFile + { + $fixturePath = __DIR__ . '/../fixtures/icon.png'; + + return new UploadedFile( + $fixturePath, + 'icon.png', + 'image/png', + null, + true, + ); + } } From 18073f147f82097abe1bc791972e0bfd28c9924c Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 26 Jun 2026 10:52:00 +0300 Subject: [PATCH 2/6] create set endpoint --- backend/app/Controllers/SetController.php | 105 ++++++++++++++++- .../CreateSetWithRoot/CreateSetWithRoot.php | 106 ++++++++++++++++++ .../CreateSetWithRootRequest.php | 16 +++ backend/routes/api.php | 2 + 4 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 backend/app/Set/UseCases/CreateSetWithRoot/CreateSetWithRoot.php create mode 100644 backend/app/Set/UseCases/CreateSetWithRoot/CreateSetWithRootRequest.php diff --git a/backend/app/Controllers/SetController.php b/backend/app/Controllers/SetController.php index 6fcbb8c..37b846f 100644 --- a/backend/app/Controllers/SetController.php +++ b/backend/app/Controllers/SetController.php @@ -3,15 +3,23 @@ namespace App\Controllers; use App\Element\ElementRepository; +use App\Exceptions\BadRequestException; use App\Set\Set as DomainSet; use App\Set\SetRepository; +use App\Set\UseCases\CreateSetWithRoot\CreateSetWithRoot; +use App\Set\UseCases\CreateSetWithRoot\CreateSetWithRootRequest; +use App\Shared\Files\Filesystem; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; +use Illuminate\Http\UploadedFile; class SetController { public function __construct( private SetRepository $setRepository, private ElementRepository $elementRepository, + private CreateSetWithRoot $createSetWithRoot, + private Filesystem $filesystem, ) { } @@ -27,6 +35,32 @@ class SetController ], 200); } + public function create(Request $request): JsonResponse + { + $iconImage = $this->uploadedFileInput($request, 'iconImage'); + + try { + $set = $this->createSetWithRoot->execute( + new CreateSetWithRootRequest( + name: $this->stringInput($request, 'name'), + description: $this->stringInput($request, 'description'), + iconImageContents: $iconImage['contents'], + iconImageOriginalName: $iconImage['originalName'], + iconImageMimeType: $iconImage['mimeType'], + iconImageSizeBytes: $iconImage['sizeBytes'], + ) + ); + } catch (BadRequestException $exception) { + return new JsonResponse([ + 'error' => $exception->getMessage(), + ], 400); + } + + return new JsonResponse([ + 'set' => $this->buildSetPayload($set), + ], 201); + } + /** * @return array{ * id: int, @@ -44,8 +78,77 @@ class SetController 'id' => $set->getId(), 'name' => $set->getName(), 'description' => $set->getDescription(), - 'iconImageUrl' => $set->getIconImageUrl(), + 'iconImageUrl' => $this->storageUrl( + $set->getIconImageUrl(), + 'set-icons/', + ), 'rootElementId' => $rootElement?->getId(), ]; } + + private function stringInput(Request $request, string $key): ?string + { + if (! $request->exists($key)) { + return null; + } + + $value = $request->input($key); + if ($value === null) { + return ''; + } + + if (! is_string($value)) { + return null; + } + + 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(), + ]; + } + + private function storageUrl(string $path, string $folderPrefix): string + { + if (str_starts_with($path, $folderPrefix)) { + return $this->filesystem->url($path); + } + + return $path; + } } diff --git a/backend/app/Set/UseCases/CreateSetWithRoot/CreateSetWithRoot.php b/backend/app/Set/UseCases/CreateSetWithRoot/CreateSetWithRoot.php new file mode 100644 index 0000000..9d9c98d --- /dev/null +++ b/backend/app/Set/UseCases/CreateSetWithRoot/CreateSetWithRoot.php @@ -0,0 +1,106 @@ +name === null || $request->name === '') { + throw new BadRequestException('name is required'); + } + if ($request->description === null || $request->description === '') { + throw new BadRequestException('description is required'); + } + + $iconPath = $this->uploadIconImage($request); + $set = $this->setRepository->create(new CreateSetDto( + name: $request->name, + description: $request->description, + iconImageUrl: $iconPath, + )); + + $this->elementRepository->create(new CreateElementDto( + set: $set, + title: $set->getName(), + description: $set->getDescription(), + iconImageUrl: $iconPath, + richText: '', + shortPdfPath: null, + longPdfPath: null, + youtubeUrl: null, + parentElement: null, + )); + + return $set; + } + + /** + * @throws BadRequestException + */ + private function uploadIconImage(CreateSetWithRootRequest $request): string + { + 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', + ); + } + + $iconImage = new FileToUpload( + contents: $request->iconImageContents, + originalName: $request->iconImageOriginalName, + mimeType: $request->iconImageMimeType, + sizeBytes: $request->iconImageSizeBytes, + ); + + return $this->filesystem->upload($iconImage, 'set-icons'); + } +} diff --git a/backend/app/Set/UseCases/CreateSetWithRoot/CreateSetWithRootRequest.php b/backend/app/Set/UseCases/CreateSetWithRoot/CreateSetWithRootRequest.php new file mode 100644 index 0000000..166a901 --- /dev/null +++ b/backend/app/Set/UseCases/CreateSetWithRoot/CreateSetWithRootRequest.php @@ -0,0 +1,16 @@ +middleware(AuthMiddleware::class); Route::get('/sets', [SetController::class, 'index']); +Route::post('/sets', [SetController::class, 'create']) + ->middleware(AuthMiddleware::class); Route::get('/elements/{id}', [ElementController::class, 'show']); Route::get('/element-pdfs/{folder}/{fileName}', [ ElementController::class, From b3c6f6af0b77c536c20a953c640a751c76924c19 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 26 Jun 2026 10:53:30 +0300 Subject: [PATCH 3/6] test media set creation --- frontend/rabbi_gerzi/cypress/e2e/media.cy.ts | 42 ++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/frontend/rabbi_gerzi/cypress/e2e/media.cy.ts b/frontend/rabbi_gerzi/cypress/e2e/media.cy.ts index 0233789..9aca794 100644 --- a/frontend/rabbi_gerzi/cypress/e2e/media.cy.ts +++ b/frontend/rabbi_gerzi/cypress/e2e/media.cy.ts @@ -1,3 +1,14 @@ +function loginAsAdmin(): void { + cy.visit('/login') + + cy.get('[data-cy="login-email"]').type('admin@rabbigerzi.test') + cy.get('[data-cy="login-password"]').type('password123!@#') + cy.get('[data-cy="login-submit"]').click() + + cy.url().should('eq', Cypress.config().baseUrl + '/') + cy.getCookie('auth_token').should('exist') +} + describe('media page sets', () => { it('fetches and renders seeded set cards', () => { const introductionAudioTitle = @@ -163,4 +174,35 @@ describe('media page sets', () => { .and('include', 'https://www.youtube.com/embed/yHx-r4p6hHU') .and('include', 'start=1') }) + + it('does not show the create set card to logged-out users', () => { + cy.visit('/media') + + cy.get('[data-cy="media-create-set-card"]').should('not.exist') + }) + + it('creates a set from the logged-in media page modal', () => { + loginAsAdmin() + cy.visit('/media') + cy.intercept('POST', /\/api\/sets$/).as('createSet') + + cy.get('[data-cy="media-create-set-card"]') + .should('be.visible') + .and('contain.text', 'Create another set') + .click() + cy.get('[data-cy="create-set-modal"]').should('be.visible') + cy.get('[data-cy="create-set-name"]').type('New Cypress Set') + cy.get('[data-cy="create-set-description"]') + .type('Created through the media page modal') + cy.get('[data-cy="create-set-icon"]') + .selectFile('public/assets/baderech-haavodah-icon.png') + cy.get('[data-cy="create-set-submit"]').click() + cy.wait('@createSet') + + cy.location('pathname').should('match', /^\/admin\/element\/\d+$/) + cy.get('[data-cy="admin-element-title"]') + .should('have.value', 'New Cypress Set') + cy.get('[data-cy="admin-element-description"]') + .should('have.value', 'Created through the media page modal') + }) }) From 47254ea3563d931f9b3002025732d34c11a0d036 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 26 Jun 2026 10:57:25 +0300 Subject: [PATCH 4/6] test root set icon url --- backend/tests/Feature/SetsEndpointTest.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/backend/tests/Feature/SetsEndpointTest.php b/backend/tests/Feature/SetsEndpointTest.php index 74592cc..8f4af23 100644 --- a/backend/tests/Feature/SetsEndpointTest.php +++ b/backend/tests/Feature/SetsEndpointTest.php @@ -131,6 +131,13 @@ class SetsEndpointTest extends TestCase $this->assertStringStartsWith('set-icons/', $storedIconPath); $this->assertSame($storedIconPath, $rootElement->getIconImageUrl()); Storage::disk('public')->assertExists($storedIconPath); + + $elementResponse = $this->getJson("/api/elements/$rootElementId"); + $elementResponse->assertOk(); + $elementResponse->assertJsonPath( + 'element.iconImageUrl', + $body['set']['iconImageUrl'], + ); } public function testCreateSetRequiresIconImage(): void From 0ec8403d4f10d04312b9411e0189512520c03a9d Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 26 Jun 2026 10:58:04 +0300 Subject: [PATCH 5/6] fix root icon urls --- backend/app/Controllers/ElementController.php | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/backend/app/Controllers/ElementController.php b/backend/app/Controllers/ElementController.php index 5fc14d7..31c162c 100644 --- a/backend/app/Controllers/ElementController.php +++ b/backend/app/Controllers/ElementController.php @@ -339,9 +339,8 @@ class ElementController 'id' => $element->getId(), 'title' => $element->getTitle(), 'description' => $element->getDescription(), - 'iconImageUrl' => $this->storageUrl( - $element->getIconImageUrl(), - 'element-icons/', + 'iconImageUrl' => $this->iconImageUrl( + $element->getIconImageUrl() ), 'richText' => $element->getRichText(), 'shortPdfPath' => $this->storageUrl( @@ -397,6 +396,22 @@ class ElementController ]; } + private function iconImageUrl(?string $path): ?string + { + if ($path === null) { + return null; + } + + if ( + str_starts_with($path, 'element-icons/') + || str_starts_with($path, 'set-icons/') + ) { + return $this->filesystem->url($path); + } + + return $path; + } + private function storageUrl(?string $path, string $folderPrefix): ?string { if ($path === null) { From be1e890330ca6a5fb3c0095695e8316a3603b1e6 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 26 Jun 2026 10:58:32 +0300 Subject: [PATCH 6/6] add media create modal --- frontend/rabbi_gerzi/src/stores/mediaSets.ts | 62 ++- frontend/rabbi_gerzi/src/views/MediaPage.vue | 435 ++++++++++++++++++- 2 files changed, 493 insertions(+), 4 deletions(-) diff --git a/frontend/rabbi_gerzi/src/stores/mediaSets.ts b/frontend/rabbi_gerzi/src/stores/mediaSets.ts index b899929..e869470 100644 --- a/frontend/rabbi_gerzi/src/stores/mediaSets.ts +++ b/frontend/rabbi_gerzi/src/stores/mediaSets.ts @@ -9,16 +9,32 @@ export interface MediaSet { rootElementId: number | null } +export interface CreateMediaSetInput { + name: string + description: string + iconImage: File +} + interface SetsResponse { sets: MediaSet[] } +interface CreateSetResponse { + set: MediaSet +} + +interface ErrorResponse { + error?: string +} + const API_BASE_URL = import.meta.env.VITE_API_BASE_URL as string export const useMediaSetsStore = defineStore('mediaSets', () => { const sets = ref([]) const isLoading = ref(false) const error = ref(null) + const isCreating = ref(false) + const createError = ref(null) async function fetchSets(): Promise { error.value = null @@ -43,5 +59,49 @@ export const useMediaSetsStore = defineStore('mediaSets', () => { } } - return { sets, isLoading, error, fetchSets } + async function createSet( + input: CreateMediaSetInput, + ): Promise { + createError.value = null + isCreating.value = true + + const formData = new FormData() + formData.append('name', input.name) + formData.append('description', input.description) + formData.append('iconImage', input.iconImage) + + try { + const response = await fetch(`${API_BASE_URL}/api/sets`, { + method: 'POST', + credentials: 'include', + body: formData, + }) + + if (!response.ok) { + const data: ErrorResponse = await response.json() + createError.value = data.error ?? 'Could not create media set' + return null + } + + const data: CreateSetResponse = await response.json() + sets.value = [...sets.value, data.set] + + return data.set + } catch { + createError.value = 'Network error - could not create media set' + return null + } finally { + isCreating.value = false + } + } + + return { + sets, + isLoading, + error, + isCreating, + createError, + fetchSets, + createSet, + } }) diff --git a/frontend/rabbi_gerzi/src/views/MediaPage.vue b/frontend/rabbi_gerzi/src/views/MediaPage.vue index c1880cd..513a329 100644 --- a/frontend/rabbi_gerzi/src/views/MediaPage.vue +++ b/frontend/rabbi_gerzi/src/views/MediaPage.vue @@ -1,15 +1,121 @@ + + @@ -183,6 +398,205 @@ a.media-page__card:focus-visible { line-height: 1.38; } +.media-page__create-card { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + border-style: dashed; + border-color: #b8a16a; + background: #fbf8ef; + color: var(--color-slate); +} + +.media-page__create-card:hover, +.media-page__create-card:focus-visible { + border-color: #8d7b4e; + background: #f7f0db; + box-shadow: 0 14px 35px rgb(44 44 44 / 8%); + transform: translateY(-2px); +} + +.media-page__create-card:focus-visible { + outline: 3px solid rgb(94 107 76 / 35%); + outline-offset: 4px; +} + +.media-page__create-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 140px; + height: 140px; + margin: 0 auto 2.65rem; + background: var(--color-white); + border: 1px solid #e5cf9f; + border-radius: 9999px; + color: var(--color-olive); +} + +.media-page__create-title { + color: #333333; + font-family: var(--font-serif); + font-size: clamp(2rem, 3.7vw, 2.4rem); + font-weight: 400; + line-height: 1.2; +} + +.media-page__create-description { + margin-top: 2.15rem; + color: var(--color-text-muted); + font-family: var(--font-serif); + font-size: clamp(1.25rem, 2.3vw, 1.45rem); + line-height: 1.38; +} + +.create-set-modal { + position: fixed; + inset: 0; + z-index: 20; + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; + background: rgb(31 31 31 / 46%); +} + +.create-set-modal__panel { + width: min(100%, 520px); + padding: 1.5rem; + background: var(--color-white); + border: 1px solid var(--color-border); + border-radius: 8px; + box-shadow: 0 24px 70px rgb(31 31 31 / 20%); +} + +.create-set-modal__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.create-set-modal__title { + margin: 0; + color: var(--color-slate); + font-family: var(--font-serif); + font-size: 1.6rem; + font-weight: 400; + line-height: 1.2; +} + +.create-set-modal__close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.25rem; + height: 2.25rem; + padding: 0; + color: var(--color-slate); + background: var(--color-cream); + border: 1px solid var(--color-border); + border-radius: 9999px; +} + +.create-set-modal__form { + display: flex; + flex-direction: column; + gap: 1rem; + margin-top: 1.5rem; +} + +.create-set-modal__field { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.create-set-modal__label { + color: var(--color-text-muted); + font-size: 0.9rem; +} + +.create-set-modal__input, +.create-set-modal__textarea { + width: 100%; + padding: 0.7rem 0.8rem; + color: var(--color-text); + background: var(--color-white); + border: 1px solid var(--color-border); + border-radius: 6px; + font-family: var(--font-sans); + font-size: 0.95rem; +} + +.create-set-modal__textarea { + resize: vertical; +} + +.create-set-modal__input:focus, +.create-set-modal__textarea:focus { + border-color: var(--color-olive); + outline: 3px solid rgb(94 107 76 / 18%); +} + +.create-set-modal__file { + width: 100%; + color: var(--color-text); + font-size: 0.92rem; +} + +.create-set-modal__file-name { + color: var(--color-text-muted); + font-size: 0.85rem; +} + +.create-set-modal__error { + margin: 0; + padding: 0.65rem 0.8rem; + color: #7c2d2d; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 6px; + font-size: 0.9rem; +} + +.create-set-modal__actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + flex-wrap: wrap; + margin-top: 0.5rem; +} + +.create-set-modal__secondary, +.create-set-modal__submit { + min-width: 7.5rem; + padding: 0.7rem 1rem; + border-radius: 6px; + font-size: 0.95rem; + font-weight: 600; +} + +.create-set-modal__secondary { + color: var(--color-slate); + background: var(--color-white); + border: 1px solid var(--color-border); +} + +.create-set-modal__submit { + color: var(--color-white); + background: var(--color-olive); + border: 1px solid var(--color-olive); +} + +.create-set-modal__secondary:disabled, +.create-set-modal__submit:disabled, +.create-set-modal__close:disabled { + cursor: not-allowed; + opacity: 0.65; +} + @media (max-width: 768px) { .media-page__main { padding: 3rem 1rem; @@ -207,5 +621,20 @@ a.media-page__card:focus-visible { height: 118px; margin-bottom: 2rem; } + + .media-page__create-icon { + width: 118px; + height: 118px; + margin-bottom: 2rem; + } + + .create-set-modal { + align-items: flex-start; + padding: 1rem; + } + + .create-set-modal__panel { + padding: 1.25rem; + } }