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) {
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,
diff --git a/backend/tests/Feature/SetsEndpointTest.php b/backend/tests/Feature/SetsEndpointTest.php
index 2f8264b..8f4af23 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,124 @@ 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);
+
+ $elementResponse = $this->getJson("/api/elements/$rootElementId");
+ $elementResponse->assertOk();
+ $elementResponse->assertJsonPath(
+ 'element.iconImageUrl',
+ $body['set']['iconImageUrl'],
+ );
+ }
+
+ 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,
+ );
+ }
}
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')
+ })
})
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
+
No media sets are available yet.