add set layout api

This commit is contained in:
Yisroel Baum 2026-08-08 23:32:45 +03:00
parent eb7ac7d261
commit 4222d4c0a2
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
16 changed files with 487 additions and 6 deletions

View file

@ -14,6 +14,11 @@ interface ElementRepository
public function find(int $id): ?Element; public function find(int $id): ?Element;
/**
* @return list<Element>
*/
public function findBySet(Set $set): array;
/** /**
* @return list<Element> * @return list<Element>
*/ */

View file

@ -45,6 +45,37 @@ class EloquentElementRepository implements ElementRepository
return $model === null ? null : $this->toDomain($model); 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 public function findTopLevelBySet(Set $set): array
{ {
$models = ElementModel::query() $models = ElementModel::query()
@ -160,11 +191,10 @@ class EloquentElementRepository implements ElementRepository
private function findSet(int $id): Set private function findSet(int $id): Set
{ {
foreach ($this->setRepository->all() as $set) { $set = $this->setRepository->find($id);
if ($set->getId() === $id) { if ($set !== null) {
return $set; return $set;
} }
}
throw new RuntimeException('element set not found'); throw new RuntimeException('element set not found');
} }

View file

@ -0,0 +1,7 @@
<?php
namespace App\Exceptions;
use DomainException;
class NotFoundException extends DomainException {}

View file

@ -2,7 +2,10 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Exceptions\NotFoundException;
use App\Set\Set; use App\Set\Set;
use App\Set\UseCases\GetSetLayout\ElementLayoutNode;
use App\Set\UseCases\GetSetLayout\GetSetLayout;
use App\Set\UseCases\ListSets\ListSets; use App\Set\UseCases\ListSets\ListSets;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@ -10,6 +13,7 @@ class SetController extends Controller
{ {
public function __construct( public function __construct(
private ListSets $listSets, private ListSets $listSets,
private GetSetLayout $getSetLayout,
) {} ) {}
public function index(): JsonResponse public function index(): JsonResponse
@ -23,4 +27,52 @@ class SetController extends Controller
return new JsonResponse(['sets' => $sets]); return new JsonResponse(['sets' => $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<mixed>
* }
*/
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(),
),
];
}
} }

View file

@ -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 public function all(): array
{ {
$models = SetModel::query() $models = SetModel::query()

View file

@ -6,6 +6,8 @@ interface SetRepository
{ {
public function create(CreateSetDto $dto): Set; public function create(CreateSetDto $dto): Set;
public function find(int $id): ?Set;
/** /**
* @return list<Set> * @return list<Set>
*/ */

View file

@ -0,0 +1,29 @@
<?php
namespace App\Set\UseCases\GetSetLayout;
use App\Element\Element;
final readonly class ElementLayoutNode
{
/**
* @param list<ElementLayoutNode> $children
*/
public function __construct(
private Element $element,
private array $children,
) {}
public function getElement(): Element
{
return $this->element;
}
/**
* @return list<ElementLayoutNode>
*/
public function getChildren(): array
{
return $this->children;
}
}

View file

@ -0,0 +1,77 @@
<?php
namespace App\Set\UseCases\GetSetLayout;
use App\Element\Element;
use App\Element\ElementRepository;
use App\Exceptions\NotFoundException;
use App\Set\SetRepository;
class GetSetLayout
{
public function __construct(
private SetRepository $setRepository,
private ElementRepository $elementRepository,
) {}
/**
* @throws NotFoundException
*/
public function execute(int $setId): SetLayout
{
$set = $this->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<int, list<Element>> $elementsByParentId
* @return list<ElementLayoutNode>
*/
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;
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Set\UseCases\GetSetLayout;
use App\Set\Set;
final readonly class SetLayout
{
/**
* @param list<ElementLayoutNode> $elements
*/
public function __construct(
private Set $set,
private array $elements,
) {}
public function getSet(): Set
{
return $this->set;
}
/**
* @return list<ElementLayoutNode>
*/
public function getElements(): array
{
return $this->elements;
}
}

View file

@ -13,5 +13,6 @@ class DatabaseSeeder extends Seeder
{ {
$this->call(UserSeeder::class); $this->call(UserSeeder::class);
$this->call(SetSeeder::class); $this->call(SetSeeder::class);
$this->call(ElementSeeder::class);
} }
} }

View file

@ -0,0 +1,217 @@
<?php
namespace Database\Seeders;
use App\Element\CreateElementDto;
use App\Element\Element;
use App\Element\ElementRepository;
use App\Set\Set;
use App\Set\SetRepository;
use Illuminate\Database\Seeder;
class ElementSeeder extends Seeder
{
private const array ELEMENTS_BY_SET = [
'Bible' => [
[
'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<array{
* name: string,
* kind: string,
* children: list<mixed>
* }> $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<Element> $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;
}
}

View file

@ -12,5 +12,7 @@ Route::post('/confirm-email', [AuthController::class, 'confirmEmail']);
Route::middleware(AuthMiddleware::class)->group(function (): void { Route::middleware(AuthMiddleware::class)->group(function (): void {
Route::get('/me', [AuthController::class, 'me']); Route::get('/me', [AuthController::class, 'me']);
Route::get('/sets', [SetController::class, 'index']); Route::get('/sets', [SetController::class, 'index']);
Route::get('/sets/{setId}', [SetController::class, 'show'])
->whereNumber('setId');
Route::post('/logout', [AuthController::class, 'logout']); Route::post('/logout', [AuthController::class, 'logout']);
}); });

View file

@ -39,6 +39,20 @@ class FakeElementRepository implements ElementRepository
return $element === null ? null : $this->copy($element); 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 public function findTopLevelBySet(Set $set): array
{ {
$elements = array_filter( $elements = array_filter(

View file

@ -26,6 +26,13 @@ class FakeSetRepository implements SetRepository
return $this->copy($set); 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 public function all(): array
{ {
$sets = array_values($this->sets); $sets = array_values($this->sets);

View file

@ -17,6 +17,7 @@ use App\User\UserRepository;
use DateTimeImmutable; use DateTimeImmutable;
use DateTimeZone; use DateTimeZone;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Testing\TestResponse;
use Tests\TestCase; use Tests\TestCase;
class GetSetLayoutEndpointTest extends 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() return $this->withCredentials()
->withUnencryptedCookie( ->withUnencryptedCookie(

View file

@ -5,6 +5,7 @@ namespace Tests\Unit\Set\UseCases;
use App\Element\CreateElementDto; use App\Element\CreateElementDto;
use App\Exceptions\NotFoundException; use App\Exceptions\NotFoundException;
use App\Set\CreateSetDto; use App\Set\CreateSetDto;
use App\Set\UseCases\GetSetLayout\ElementLayoutNode;
use App\Set\UseCases\GetSetLayout\GetSetLayout; use App\Set\UseCases\GetSetLayout\GetSetLayout;
use App\Shared\ValueObject\EmailAddress; use App\Shared\ValueObject\EmailAddress;
use App\User\User; use App\User\User;
@ -124,7 +125,7 @@ class GetSetLayoutTest extends TestCase
} }
/** /**
* @param list<\App\Set\UseCases\GetSetLayout\ElementLayoutNode> $nodes * @param list<ElementLayoutNode> $nodes
* @return list<string> * @return list<string>
*/ */
private function nodeNames(array $nodes): array private function nodeNames(array $nodes): array