From 4222d4c0a22d2d93d684fc68bbac749a4b7adfc4 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 8 Aug 2026 23:32:45 +0300 Subject: [PATCH] add set layout api --- backend/app/Element/ElementRepository.php | 5 + .../app/Element/EloquentElementRepository.php | 38 ++- backend/app/Exceptions/NotFoundException.php | 7 + .../app/Http/Controllers/SetController.php | 52 +++++ backend/app/Set/EloquentSetRepository.php | 7 + backend/app/Set/SetRepository.php | 2 + .../GetSetLayout/ElementLayoutNode.php | 29 +++ .../UseCases/GetSetLayout/GetSetLayout.php | 77 +++++++ .../Set/UseCases/GetSetLayout/SetLayout.php | 29 +++ backend/database/seeders/DatabaseSeeder.php | 1 + backend/database/seeders/ElementSeeder.php | 217 ++++++++++++++++++ backend/routes/api.php | 2 + backend/tests/Fakes/FakeElementRepository.php | 14 ++ backend/tests/Fakes/FakeSetRepository.php | 7 + .../Feature/Set/GetSetLayoutEndpointTest.php | 3 +- .../Unit/Set/UseCases/GetSetLayoutTest.php | 3 +- 16 files changed, 487 insertions(+), 6 deletions(-) create mode 100644 backend/app/Exceptions/NotFoundException.php create mode 100644 backend/app/Set/UseCases/GetSetLayout/ElementLayoutNode.php create mode 100644 backend/app/Set/UseCases/GetSetLayout/GetSetLayout.php create mode 100644 backend/app/Set/UseCases/GetSetLayout/SetLayout.php create mode 100644 backend/database/seeders/ElementSeeder.php diff --git a/backend/app/Element/ElementRepository.php b/backend/app/Element/ElementRepository.php index fc46687..3ca103c 100644 --- a/backend/app/Element/ElementRepository.php +++ b/backend/app/Element/ElementRepository.php @@ -14,6 +14,11 @@ interface ElementRepository public function find(int $id): ?Element; + /** + * @return list + */ + public function findBySet(Set $set): array; + /** * @return list */ diff --git a/backend/app/Element/EloquentElementRepository.php b/backend/app/Element/EloquentElementRepository.php index cfd93f7..1f30e24 100644 --- a/backend/app/Element/EloquentElementRepository.php +++ b/backend/app/Element/EloquentElementRepository.php @@ -45,6 +45,37 @@ class EloquentElementRepository implements ElementRepository return $model === null ? null : $this->toDomain($model); } + public function findBySet(Set $set): array + { + $models = ElementModel::query() + ->where('set_id', $set->getId()) + ->orderBy('id') + ->get(); + $elements = []; + $elementsById = []; + + foreach ($models as $model) { + $parentElement = null; + if ($model->parent_element_id !== null) { + $parentElement = $elementsById[$model->parent_element_id] + ?? null; + if ($parentElement === null) { + throw new RuntimeException('element parent not found'); + } + } + + $element = $this->toDomainWithRelations( + model: $model, + set: $set, + parentElement: $parentElement, + ); + $elements[] = $element; + $elementsById[$element->getId()] = $element; + } + + return $elements; + } + public function findTopLevelBySet(Set $set): array { $models = ElementModel::query() @@ -160,10 +191,9 @@ class EloquentElementRepository implements ElementRepository private function findSet(int $id): Set { - foreach ($this->setRepository->all() as $set) { - if ($set->getId() === $id) { - return $set; - } + $set = $this->setRepository->find($id); + if ($set !== null) { + return $set; } throw new RuntimeException('element set not found'); diff --git a/backend/app/Exceptions/NotFoundException.php b/backend/app/Exceptions/NotFoundException.php new file mode 100644 index 0000000..0531457 --- /dev/null +++ b/backend/app/Exceptions/NotFoundException.php @@ -0,0 +1,7 @@ + $sets]); } + + public function show(int $setId): JsonResponse + { + try { + $layout = $this->getSetLayout->execute($setId); + } catch (NotFoundException $exception) { + return new JsonResponse( + ['error' => $exception->getMessage()], + 404, + ); + } + + $set = $layout->getSet(); + + return new JsonResponse([ + 'set' => [ + 'id' => $set->getId(), + 'name' => $set->getName(), + ], + 'elements' => array_map( + $this->elementPayload(...), + $layout->getElements(), + ), + ]); + } + + /** + * @return array{ + * id: int, + * name: string, + * kind: string, + * children: list + * } + */ + private function elementPayload(ElementLayoutNode $node): array + { + $element = $node->getElement(); + + return [ + 'id' => $element->getId(), + 'name' => $element->getName(), + 'kind' => $element->getKind(), + 'children' => array_map( + $this->elementPayload(...), + $node->getChildren(), + ), + ]; + } } diff --git a/backend/app/Set/EloquentSetRepository.php b/backend/app/Set/EloquentSetRepository.php index ed6ac88..3a0bdf0 100644 --- a/backend/app/Set/EloquentSetRepository.php +++ b/backend/app/Set/EloquentSetRepository.php @@ -25,6 +25,13 @@ class EloquentSetRepository implements SetRepository ); } + public function find(int $id): ?Set + { + $model = SetModel::find($id); + + return $model === null ? null : $this->toDomain($model); + } + public function all(): array { $models = SetModel::query() diff --git a/backend/app/Set/SetRepository.php b/backend/app/Set/SetRepository.php index 0545ab1..a824e53 100644 --- a/backend/app/Set/SetRepository.php +++ b/backend/app/Set/SetRepository.php @@ -6,6 +6,8 @@ interface SetRepository { public function create(CreateSetDto $dto): Set; + public function find(int $id): ?Set; + /** * @return list */ diff --git a/backend/app/Set/UseCases/GetSetLayout/ElementLayoutNode.php b/backend/app/Set/UseCases/GetSetLayout/ElementLayoutNode.php new file mode 100644 index 0000000..6866e8f --- /dev/null +++ b/backend/app/Set/UseCases/GetSetLayout/ElementLayoutNode.php @@ -0,0 +1,29 @@ + $children + */ + public function __construct( + private Element $element, + private array $children, + ) {} + + public function getElement(): Element + { + return $this->element; + } + + /** + * @return list + */ + public function getChildren(): array + { + return $this->children; + } +} diff --git a/backend/app/Set/UseCases/GetSetLayout/GetSetLayout.php b/backend/app/Set/UseCases/GetSetLayout/GetSetLayout.php new file mode 100644 index 0000000..70725c2 --- /dev/null +++ b/backend/app/Set/UseCases/GetSetLayout/GetSetLayout.php @@ -0,0 +1,77 @@ +setRepository->find($setId); + if ($set === null) { + throw new NotFoundException('set not found'); + } + + $elementsByParentId = []; + foreach ($this->elementRepository->findBySet($set) as $element) { + $parentId = $element->getParentElement()?->getId() ?? 0; + $elementsByParentId[$parentId][] = $element; + } + + foreach ($elementsByParentId as &$siblings) { + usort($siblings, function ( + Element $first, + Element $second, + ): int { + $positionComparison = $first->getPosition() + <=> $second->getPosition(); + if ($positionComparison !== 0) { + return $positionComparison; + } + + return $first->getId() <=> $second->getId(); + }); + } + unset($siblings); + + return new SetLayout( + set: $set, + elements: $this->buildNodes(0, $elementsByParentId), + ); + } + + /** + * @param array> $elementsByParentId + * @return list + */ + private function buildNodes( + int $parentId, + array $elementsByParentId, + ): array { + $nodes = []; + + foreach ($elementsByParentId[$parentId] ?? [] as $element) { + $nodes[] = new ElementLayoutNode( + element: $element, + children: $this->buildNodes( + $element->getId(), + $elementsByParentId, + ), + ); + } + + return $nodes; + } +} diff --git a/backend/app/Set/UseCases/GetSetLayout/SetLayout.php b/backend/app/Set/UseCases/GetSetLayout/SetLayout.php new file mode 100644 index 0000000..c98974a --- /dev/null +++ b/backend/app/Set/UseCases/GetSetLayout/SetLayout.php @@ -0,0 +1,29 @@ + $elements + */ + public function __construct( + private Set $set, + private array $elements, + ) {} + + public function getSet(): Set + { + return $this->set; + } + + /** + * @return list + */ + public function getElements(): array + { + return $this->elements; + } +} diff --git a/backend/database/seeders/DatabaseSeeder.php b/backend/database/seeders/DatabaseSeeder.php index 31a5820..c90217c 100644 --- a/backend/database/seeders/DatabaseSeeder.php +++ b/backend/database/seeders/DatabaseSeeder.php @@ -13,5 +13,6 @@ class DatabaseSeeder extends Seeder { $this->call(UserSeeder::class); $this->call(SetSeeder::class); + $this->call(ElementSeeder::class); } } diff --git a/backend/database/seeders/ElementSeeder.php b/backend/database/seeders/ElementSeeder.php new file mode 100644 index 0000000..bde3ed9 --- /dev/null +++ b/backend/database/seeders/ElementSeeder.php @@ -0,0 +1,217 @@ + [ + [ + 'name' => 'Genesis', + 'kind' => 'book', + 'children' => [ + [ + 'name' => 'Creation', + 'kind' => 'portion', + 'children' => [ + [ + 'name' => 'Chapter 1', + 'kind' => 'chapter', + 'children' => [], + ], + [ + 'name' => 'Chapter 2', + 'kind' => 'chapter', + 'children' => [], + ], + ], + ], + [ + 'name' => 'Noah', + 'kind' => 'portion', + 'children' => [], + ], + ], + ], + [ + 'name' => 'Exodus', + 'kind' => 'book', + 'children' => [ + [ + 'name' => 'Shemot', + 'kind' => 'portion', + 'children' => [], + ], + ], + ], + ], + 'Course' => [ + [ + 'name' => 'Foundations', + 'kind' => 'module', + 'children' => [ + [ + 'name' => 'Welcome', + 'kind' => 'lesson', + 'children' => [], + ], + [ + 'name' => 'Core Concepts', + 'kind' => 'lesson', + 'children' => [], + ], + ], + ], + [ + 'name' => 'Applied Practice', + 'kind' => 'module', + 'children' => [ + [ + 'name' => 'Guided Exercise', + 'kind' => 'lesson', + 'children' => [], + ], + [ + 'name' => 'Final Review', + 'kind' => 'lesson', + 'children' => [], + ], + ], + ], + ], + 'Fitness Program' => [ + [ + 'name' => 'Foundation Phase', + 'kind' => 'phase', + 'children' => [ + [ + 'name' => 'Strength Day', + 'kind' => 'workout', + 'children' => [ + [ + 'name' => 'Squat', + 'kind' => 'exercise', + 'children' => [], + ], + [ + 'name' => 'Push-up', + 'kind' => 'exercise', + 'children' => [], + ], + ], + ], + [ + 'name' => 'Mobility Day', + 'kind' => 'workout', + 'children' => [ + [ + 'name' => 'Hip Flow', + 'kind' => 'exercise', + 'children' => [], + ], + ], + ], + ], + ], + [ + 'name' => 'Build Phase', + 'kind' => 'phase', + 'children' => [ + [ + 'name' => 'Full Body Circuit', + 'kind' => 'workout', + 'children' => [], + ], + ], + ], + ], + ]; + + public function run(): void + { + $elementRepository = app(ElementRepository::class); + + foreach (app(SetRepository::class)->all() as $set) { + $definitions = self::ELEMENTS_BY_SET[$set->getName()] ?? null; + if ($definitions === null) { + continue; + } + + $this->seedChildren( + repository: $elementRepository, + set: $set, + parentElement: null, + definitions: $definitions, + ); + } + } + + /** + * @param list + * }> $definitions + */ + private function seedChildren( + ElementRepository $repository, + Set $set, + ?Element $parentElement, + array $definitions, + ): void { + $siblings = $parentElement === null + ? $repository->findTopLevelBySet($set) + : $repository->findByParentElement($parentElement); + + foreach ($definitions as $definition) { + $element = $this->findSibling( + siblings: $siblings, + name: $definition['name'], + kind: $definition['kind'], + ); + if ($element === null) { + $element = $repository->create(new CreateElementDto( + set: $set, + name: $definition['name'], + kind: $definition['kind'], + parentElement: $parentElement, + )); + $siblings[] = $element; + } + + $this->seedChildren( + repository: $repository, + set: $set, + parentElement: $element, + definitions: $definition['children'], + ); + } + } + + /** + * @param list $siblings + */ + private function findSibling( + array $siblings, + string $name, + string $kind, + ): ?Element { + foreach ($siblings as $sibling) { + if ( + $sibling->getName() === $name + && $sibling->getKind() === $kind + ) { + return $sibling; + } + } + + return null; + } +} diff --git a/backend/routes/api.php b/backend/routes/api.php index 62384a9..9b70efe 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -12,5 +12,7 @@ Route::post('/confirm-email', [AuthController::class, 'confirmEmail']); Route::middleware(AuthMiddleware::class)->group(function (): void { Route::get('/me', [AuthController::class, 'me']); Route::get('/sets', [SetController::class, 'index']); + Route::get('/sets/{setId}', [SetController::class, 'show']) + ->whereNumber('setId'); Route::post('/logout', [AuthController::class, 'logout']); }); diff --git a/backend/tests/Fakes/FakeElementRepository.php b/backend/tests/Fakes/FakeElementRepository.php index e44cb53..1e9200d 100644 --- a/backend/tests/Fakes/FakeElementRepository.php +++ b/backend/tests/Fakes/FakeElementRepository.php @@ -39,6 +39,20 @@ class FakeElementRepository implements ElementRepository return $element === null ? null : $this->copy($element); } + public function findBySet(Set $set): array + { + $elements = array_filter( + $this->elements, + function (Element $element) use ($set): bool { + return $element->getSet()->getId() === $set->getId(); + }, + ); + + return array_map(function (Element $element): Element { + return $this->copy($element); + }, array_values($elements)); + } + public function findTopLevelBySet(Set $set): array { $elements = array_filter( diff --git a/backend/tests/Fakes/FakeSetRepository.php b/backend/tests/Fakes/FakeSetRepository.php index a4cff4c..9f808b0 100644 --- a/backend/tests/Fakes/FakeSetRepository.php +++ b/backend/tests/Fakes/FakeSetRepository.php @@ -26,6 +26,13 @@ class FakeSetRepository implements SetRepository return $this->copy($set); } + public function find(int $id): ?Set + { + $set = $this->sets[$id] ?? null; + + return $set === null ? null : $this->copy($set); + } + public function all(): array { $sets = array_values($this->sets); diff --git a/backend/tests/Feature/Set/GetSetLayoutEndpointTest.php b/backend/tests/Feature/Set/GetSetLayoutEndpointTest.php index adc5f56..074f7f5 100644 --- a/backend/tests/Feature/Set/GetSetLayoutEndpointTest.php +++ b/backend/tests/Feature/Set/GetSetLayoutEndpointTest.php @@ -17,6 +17,7 @@ use App\User\UserRepository; use DateTimeImmutable; use DateTimeZone; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Testing\TestResponse; use Tests\TestCase; class GetSetLayoutEndpointTest extends TestCase @@ -160,7 +161,7 @@ class GetSetLayoutEndpointTest extends TestCase )); } - private function credentialedGet(string $uri): \Illuminate\Testing\TestResponse + private function credentialedGet(string $uri): TestResponse { return $this->withCredentials() ->withUnencryptedCookie( diff --git a/backend/tests/Unit/Set/UseCases/GetSetLayoutTest.php b/backend/tests/Unit/Set/UseCases/GetSetLayoutTest.php index d17ab79..d754f40 100644 --- a/backend/tests/Unit/Set/UseCases/GetSetLayoutTest.php +++ b/backend/tests/Unit/Set/UseCases/GetSetLayoutTest.php @@ -5,6 +5,7 @@ namespace Tests\Unit\Set\UseCases; use App\Element\CreateElementDto; use App\Exceptions\NotFoundException; use App\Set\CreateSetDto; +use App\Set\UseCases\GetSetLayout\ElementLayoutNode; use App\Set\UseCases\GetSetLayout\GetSetLayout; use App\Shared\ValueObject\EmailAddress; use App\User\User; @@ -124,7 +125,7 @@ class GetSetLayoutTest extends TestCase } /** - * @param list<\App\Set\UseCases\GetSetLayout\ElementLayoutNode> $nodes + * @param list $nodes * @return list */ private function nodeNames(array $nodes): array