From dc034bf393868303b66c38f0f4c249bd9de7617c Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 31 Jul 2026 11:00:41 +0300 Subject: [PATCH] add persisted set ordering --- backend/app/Controllers/SetController.php | 77 ++++++++++- backend/app/Set/EloquentSetRepository.php | 28 +++- backend/app/Set/SetModel.php | 13 +- backend/app/Set/SetRepository.php | 6 + .../Set/UseCases/ReorderSets/ReorderSets.php | 128 ++++++++++++++++++ .../ReorderSets/ReorderSetsRequest.php | 13 ++ ...31_000000_add_sort_order_to_sets_table.php | 38 ++++++ backend/routes/api.php | 2 + backend/tests/Fakes/FakeSetRepository.php | 41 ++++++ 9 files changed, 340 insertions(+), 6 deletions(-) create mode 100644 backend/app/Set/UseCases/ReorderSets/ReorderSets.php create mode 100644 backend/app/Set/UseCases/ReorderSets/ReorderSetsRequest.php create mode 100644 backend/database/migrations/2026_07_31_000000_add_sort_order_to_sets_table.php diff --git a/backend/app/Controllers/SetController.php b/backend/app/Controllers/SetController.php index a3c89f6..31b142f 100644 --- a/backend/app/Controllers/SetController.php +++ b/backend/app/Controllers/SetController.php @@ -11,6 +11,8 @@ use App\Set\UseCases\CreateSetWithRoot\CreateSetWithRoot; use App\Set\UseCases\CreateSetWithRoot\CreateSetWithRootRequest; use App\Set\UseCases\DeleteSet\DeleteSet; use App\Set\UseCases\DeleteSet\DeleteSetRequest; +use App\Set\UseCases\ReorderSets\ReorderSets; +use App\Set\UseCases\ReorderSets\ReorderSetsRequest; use App\Set\UseCases\UpdateSet\UpdateSet; use App\Set\UseCases\UpdateSet\UpdateSetRequest; use App\Shared\Files\Filesystem; @@ -26,19 +28,34 @@ class SetController private CreateSetWithRoot $createSetWithRoot, private UpdateSet $updateSet, private DeleteSet $deleteSet, + private ReorderSets $reorderSets, private Filesystem $filesystem, ) { } public function index(): JsonResponse { - $sets = []; - foreach ($this->setRepository->getAll() as $set) { - $sets[] = $this->buildSetPayload($set); + return new JsonResponse([ + 'sets' => $this->buildSetPayloads( + $this->setRepository->getAll(), + ), + ], 200); + } + + public function reorder(Request $request): JsonResponse + { + try { + $sets = $this->reorderSets->execute(new ReorderSetsRequest( + setIds: $this->intArrayInput($request, 'setIds'), + )); + } catch (BadRequestException $exception) { + return new JsonResponse([ + 'error' => $exception->getMessage(), + ], 400); } return new JsonResponse([ - 'sets' => $sets, + 'sets' => $this->buildSetPayloads($sets), ], 200); } @@ -141,6 +158,58 @@ class SetController ]; } + /** + * @param DomainSet[] $sets + * @return array + */ + private function buildSetPayloads(array $sets): array + { + $setPayloads = []; + foreach ($sets as $set) { + $setPayloads[] = $this->buildSetPayload($set); + } + + return $setPayloads; + } + + /** + * @return int[]|null + */ + private function intArrayInput(Request $request, string $key): ?array + { + if (! $request->exists($key)) { + return null; + } + + $value = $request->input($key); + if (! is_array($value)) { + return null; + } + + $integerValues = []; + foreach ($value as $item) { + if (is_int($item)) { + $integerValues[] = $item; + continue; + } + + if (is_string($item) && ctype_digit($item)) { + $integerValues[] = (int) $item; + continue; + } + + return null; + } + + return $integerValues; + } + private function stringInput(Request $request, string $key): ?string { if (! $request->exists($key)) { diff --git a/backend/app/Set/EloquentSetRepository.php b/backend/app/Set/EloquentSetRepository.php index be7ac1d..08fce3f 100644 --- a/backend/app/Set/EloquentSetRepository.php +++ b/backend/app/Set/EloquentSetRepository.php @@ -3,6 +3,7 @@ namespace App\Set; use DomainException; +use Illuminate\Support\Facades\DB; class EloquentSetRepository implements SetRepository { @@ -12,6 +13,7 @@ class EloquentSetRepository implements SetRepository 'name' => $dto->name, 'description' => $dto->description, 'icon_image_url' => $dto->iconImageUrl, + 'sort_order' => $this->nextSortOrder(), ]); return $this->toDomain($model); @@ -55,7 +57,7 @@ class EloquentSetRepository implements SetRepository public function getAll(): array { - $models = SetModel::orderBy('id')->get(); + $models = SetModel::orderBy('sort_order')->orderBy('id')->get(); $sets = []; foreach ($models as $model) { $sets[] = $this->toDomain($model); @@ -64,6 +66,30 @@ class EloquentSetRepository implements SetRepository return $sets; } + public function reorder(array $setIds): array + { + DB::transaction(function () use ($setIds): void { + $sortOrder = 1; + foreach ($setIds as $setId) { + SetModel::where('id', $setId) + ->update(['sort_order' => $sortOrder]); + $sortOrder++; + } + }); + + return $this->getAll(); + } + + private function nextSortOrder(): int + { + $currentMaxSortOrder = SetModel::max('sort_order'); + if ($currentMaxSortOrder === null) { + return 1; + } + + return (int) $currentMaxSortOrder + 1; + } + private function toDomain(SetModel $model): Set { return new Set( diff --git a/backend/app/Set/SetModel.php b/backend/app/Set/SetModel.php index d80a6df..0422ee8 100644 --- a/backend/app/Set/SetModel.php +++ b/backend/app/Set/SetModel.php @@ -10,12 +10,14 @@ use Illuminate\Database\Eloquent\Model; * @property string $name * @property string $description * @property string $icon_image_url + * @property int $sort_order * * @method static Builder|SetModel newModelQuery() * @method static Builder|SetModel newQuery() * @method static Builder|SetModel query() * @method static Builder|SetModel whereId($value) * @method static Builder|SetModel whereName($value) + * @method static Builder|SetModel whereSortOrder($value) * * @mixin \Eloquent */ @@ -25,5 +27,14 @@ class SetModel extends Model public $timestamps = false; - protected $fillable = ['name', 'description', 'icon_image_url']; + protected $fillable = [ + 'name', + 'description', + 'icon_image_url', + 'sort_order', + ]; + + protected $casts = [ + 'sort_order' => 'integer', + ]; } diff --git a/backend/app/Set/SetRepository.php b/backend/app/Set/SetRepository.php index 79d9dde..bffbb02 100644 --- a/backend/app/Set/SetRepository.php +++ b/backend/app/Set/SetRepository.php @@ -16,4 +16,10 @@ interface SetRepository * @return Set[] */ public function getAll(): array; + + /** + * @param int[] $setIds + * @return Set[] + */ + public function reorder(array $setIds): array; } diff --git a/backend/app/Set/UseCases/ReorderSets/ReorderSets.php b/backend/app/Set/UseCases/ReorderSets/ReorderSets.php new file mode 100644 index 0000000..1937519 --- /dev/null +++ b/backend/app/Set/UseCases/ReorderSets/ReorderSets.php @@ -0,0 +1,128 @@ +setIds === null) { + throw new BadRequestException('setIds is required'); + } + + $setIds = $this->validatedSetIds($request->setIds); + $existingSetIds = $this->setIds($this->setRepository->getAll()); + + $this->validateNoDuplicateIds($setIds); + $this->validateAllIdsAreSets($setIds, $existingSetIds); + $this->validateEverySetWasSubmitted($setIds, $existingSetIds); + + return $this->setRepository->reorder($setIds); + } + + /** + * @param mixed[] $setIds + * @return int[] + * @throws BadRequestException + */ + private function validatedSetIds(array $setIds): array + { + $validatedSetIds = []; + foreach ($setIds as $setId) { + if (! is_int($setId)) { + throw new BadRequestException( + 'setIds must contain integers', + ); + } + + $validatedSetIds[] = $setId; + } + + return $validatedSetIds; + } + + /** + * @param int[] $setIds + * @throws BadRequestException + */ + private function validateNoDuplicateIds(array $setIds): void + { + $seenSetIds = []; + foreach ($setIds as $setId) { + if (isset($seenSetIds[$setId])) { + throw new BadRequestException( + 'Set order contains duplicate ids', + ); + } + + $seenSetIds[$setId] = true; + } + } + + /** + * @param int[] $setIds + * @param int[] $existingSetIds + * @throws BadRequestException + */ + private function validateAllIdsAreSets( + array $setIds, + array $existingSetIds, + ): void { + $existingSetIdsById = []; + foreach ($existingSetIds as $existingSetId) { + $existingSetIdsById[$existingSetId] = true; + } + + foreach ($setIds as $setId) { + if (! isset($existingSetIdsById[$setId])) { + throw new BadRequestException( + 'Set order contains invalid set', + ); + } + } + } + + /** + * @param int[] $setIds + * @param int[] $existingSetIds + * @throws BadRequestException + */ + private function validateEverySetWasSubmitted( + array $setIds, + array $existingSetIds, + ): void { + if (count($setIds) === count($existingSetIds)) { + return; + } + + throw new BadRequestException( + 'Set order must include every set', + ); + } + + /** + * @param Set[] $sets + * @return int[] + */ + private function setIds(array $sets): array + { + $setIds = []; + foreach ($sets as $set) { + $setIds[] = $set->getId(); + } + + return $setIds; + } +} diff --git a/backend/app/Set/UseCases/ReorderSets/ReorderSetsRequest.php b/backend/app/Set/UseCases/ReorderSets/ReorderSetsRequest.php new file mode 100644 index 0000000..86dfe00 --- /dev/null +++ b/backend/app/Set/UseCases/ReorderSets/ReorderSetsRequest.php @@ -0,0 +1,13 @@ +unsignedInteger('sort_order')->nullable(); + }); + + $setIds = DB::table('sets')->orderBy('id')->pluck('id'); + $sortOrder = 1; + foreach ($setIds as $setId) { + DB::table('sets') + ->where('id', $setId) + ->update(['sort_order' => $sortOrder]); + $sortOrder++; + } + + Schema::table('sets', function (Blueprint $table): void { + $table->unsignedInteger('sort_order') + ->nullable(false) + ->change(); + }); + } + + public function down(): void + { + Schema::table('sets', function (Blueprint $table): void { + $table->dropColumn('sort_order'); + }); + } +}; diff --git a/backend/routes/api.php b/backend/routes/api.php index 12f02d8..0163757 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -11,6 +11,8 @@ Route::post('/logout', [AuthController::class, 'logout']); Route::get('/me', [AuthController::class, 'me']) ->middleware(AuthMiddleware::class); Route::get('/sets', [SetController::class, 'index']); +Route::put('/sets/order', [SetController::class, 'reorder']) + ->middleware(AuthMiddleware::class); Route::post('/sets', [SetController::class, 'create']) ->middleware(AuthMiddleware::class); Route::post('/sets/{id}/update', [SetController::class, 'update']) diff --git a/backend/tests/Fakes/FakeSetRepository.php b/backend/tests/Fakes/FakeSetRepository.php index f3efc1c..4cced39 100644 --- a/backend/tests/Fakes/FakeSetRepository.php +++ b/backend/tests/Fakes/FakeSetRepository.php @@ -13,6 +13,11 @@ class FakeSetRepository implements SetRepository */ private array $setsById = []; + /** + * @var array + */ + private array $sortOrdersById = []; + public function create(CreateSetDto $dto): DomainSet { $id = count($this->setsById) + 1; @@ -23,6 +28,7 @@ class FakeSetRepository implements SetRepository iconImageUrl: $dto->iconImageUrl, ); $this->setsById[$id] = $set; + $this->sortOrdersById[$id] = $this->nextSortOrder(); return $set; } @@ -38,6 +44,7 @@ class FakeSetRepository implements SetRepository public function delete(DomainSet $set): void { unset($this->setsById[$set->getId()]); + unset($this->sortOrdersById[$set->getId()]); } public function find(int $id): ?DomainSet @@ -58,10 +65,35 @@ class FakeSetRepository implements SetRepository foreach ($this->setsById as $set) { $sets[] = $this->cloneSet($set); } + usort($sets, function ( + DomainSet $firstSet, + DomainSet $secondSet, + ): int { + $firstSortOrder = $this->sortOrdersById[$firstSet->getId()] + ?? $firstSet->getId(); + $secondSortOrder = $this->sortOrdersById[$secondSet->getId()] + ?? $secondSet->getId(); + if ($firstSortOrder === $secondSortOrder) { + return $firstSet->getId() <=> $secondSet->getId(); + } + + return $firstSortOrder <=> $secondSortOrder; + }); return $sets; } + public function reorder(array $setIds): array + { + $sortOrder = 1; + foreach ($setIds as $setId) { + $this->sortOrdersById[$setId] = $sortOrder; + $sortOrder++; + } + + return $this->getAll(); + } + private function cloneSet(DomainSet $set): DomainSet { return new DomainSet( @@ -71,4 +103,13 @@ class FakeSetRepository implements SetRepository iconImageUrl: $set->getIconImageUrl(), ); } + + private function nextSortOrder(): int + { + if ($this->sortOrdersById === []) { + return 1; + } + + return max($this->sortOrdersById) + 1; + } }