diff --git a/backend/app/Http/Controllers/ScheduleController.php b/backend/app/Http/Controllers/ScheduleController.php new file mode 100644 index 0000000..3ff4a39 --- /dev/null +++ b/backend/app/Http/Controllers/ScheduleController.php @@ -0,0 +1,198 @@ +user($request); + + try { + $schedule = $this->createSchedule->execute( + new CreateScheduleRequest( + user: $user, + setId: $input->integer('setId'), + elementKind: $input->string('elementKind'), + startDate: $input->string('startDate'), + targetDate: $input->string('targetDate'), + ), + ); + } catch (BadRequestException $exception) { + return new JsonResponse( + ['error' => $exception->getMessage()], + 400, + ); + } catch (NotFoundException $exception) { + return new JsonResponse( + ['error' => $exception->getMessage()], + 404, + ); + } + + return new JsonResponse( + ['schedule' => $this->detailPayload($schedule)], + 201, + ); + } + + public function index(Request $request): JsonResponse + { + $schedules = array_map( + function (Schedule $schedule): array { + return $this->summaryPayload($schedule); + }, + $this->listSchedules->execute($this->user($request)), + ); + + return new JsonResponse(['schedules' => $schedules]); + } + + public function show(Request $request, int $scheduleId): JsonResponse + { + try { + $schedule = $this->getSchedule->execute( + new GetScheduleRequest( + scheduleId: $scheduleId, + user: $this->user($request), + ), + ); + } catch (NotFoundException $exception) { + return new JsonResponse( + ['error' => $exception->getMessage()], + 404, + ); + } + + return new JsonResponse([ + 'schedule' => $this->detailPayload($schedule), + ]); + } + + /** + * @return array + */ + private function summaryPayload(Schedule $schedule): array + { + $set = $schedule->getSet(); + + return [ + 'id' => $schedule->getId(), + 'set' => [ + 'id' => $set->getId(), + 'name' => $set->getName(), + ], + 'elementKind' => $schedule->getElementKind(), + 'startDate' => $schedule->getStartDate()->format('Y-m-d'), + 'targetDate' => $schedule->getTargetDate()->format('Y-m-d'), + 'assignmentCount' => count($schedule->getAssignments()), + ]; + } + + /** + * @return array + */ + private function detailPayload(Schedule $schedule): array + { + $payload = $this->summaryPayload($schedule); + $payload['days'] = $this->dayPayloads($schedule); + + return $payload; + } + + /** + * @return list}> + */ + private function dayPayloads(Schedule $schedule): array + { + $assignmentsByDate = []; + foreach ($schedule->getAssignments() as $assignment) { + $date = $assignment->getScheduledDate()->format('Y-m-d'); + $assignmentsByDate[$date][] = $this->assignmentPayload( + $assignment, + ); + } + + $days = []; + $date = $schedule->getStartDate(); + while ($date <= $schedule->getTargetDate()) { + $formattedDate = $date->format('Y-m-d'); + $days[] = [ + 'date' => $formattedDate, + 'assignments' => $assignmentsByDate[$formattedDate] ?? [], + ]; + $date = $date->modify('+1 day'); + } + + return $days; + } + + /** + * @return array{element: array{ + * id: int, + * name: string, + * kind: string, + * path: list + * }} + */ + private function assignmentPayload( + ScheduleAssignment $assignment, + ): array { + $element = $assignment->getElement(); + + return [ + 'element' => [ + 'id' => $element->getId(), + 'name' => $element->getName(), + 'kind' => $element->getKind(), + 'path' => $this->elementPath($element), + ], + ]; + } + + /** + * @return list + */ + private function elementPath(Element $element): array + { + $path = []; + $currentElement = $element; + + while ($currentElement !== null) { + array_unshift($path, $currentElement->getName()); + $currentElement = $currentElement->getParentElement(); + } + + return $path; + } + + private function user(Request $request): User + { + /** @var User $user */ + $user = $request->attributes->get('user'); + + return $user; + } +} diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index 8d883fe..d0d019d 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -20,6 +20,8 @@ use App\Element\ElementRepository; use App\Element\EloquentElementRepository; use App\Set\EloquentSetRepository; use App\Set\SetRepository; +use App\Schedule\EloquentScheduleRepository; +use App\Schedule\ScheduleRepository; use App\User\EloquentUserRepository; use App\User\UserRepository; use Carbon\CarbonImmutable; @@ -57,6 +59,10 @@ class AppServiceProvider extends ServiceProvider ElementRepository::class, EloquentElementRepository::class, ); + $this->app->bind( + ScheduleRepository::class, + EloquentScheduleRepository::class, + ); $this->app->bind(PasswordHasher::class, BcryptPasswordHasher::class); $this->app->bind(TokenGenerator::class, RandomTokenGenerator::class); $this->app->bind(Clock::class, SystemClock::class); diff --git a/backend/app/Schedule/CreateScheduleAssignmentDto.php b/backend/app/Schedule/CreateScheduleAssignmentDto.php new file mode 100644 index 0000000..682438c --- /dev/null +++ b/backend/app/Schedule/CreateScheduleAssignmentDto.php @@ -0,0 +1,15 @@ + $assignments + */ + public function __construct( + public User $user, + public Set $set, + public string $elementKind, + public DateTimeImmutable $startDate, + public DateTimeImmutable $targetDate, + public array $assignments, + ) {} +} diff --git a/backend/app/Schedule/EloquentScheduleRepository.php b/backend/app/Schedule/EloquentScheduleRepository.php new file mode 100644 index 0000000..f23cb30 --- /dev/null +++ b/backend/app/Schedule/EloquentScheduleRepository.php @@ -0,0 +1,115 @@ + $dto->user->getId(), + 'set_id' => $dto->set->getId(), + 'element_kind' => $dto->elementKind, + 'start_date' => $dto->startDate->format('Y-m-d'), + 'target_date' => $dto->targetDate->format('Y-m-d'), + ]); + + foreach ($dto->assignments as $assignmentDto) { + ScheduleAssignmentModel::create([ + 'schedule_id' => $model->id, + 'element_id' => $assignmentDto->element->getId(), + 'scheduled_date' => $assignmentDto->scheduledDate + ->format('Y-m-d'), + 'position' => $assignmentDto->position, + ]); + } + + return $this->toDomain($model, $dto->user); + }); + } + + public function findForUser(int $id, User $user): ?Schedule + { + $model = ScheduleModel::query() + ->where('id', $id) + ->where('user_id', $user->getId()) + ->first(); + + return $model === null ? null : $this->toDomain($model, $user); + } + + public function findAllForUser(User $user): array + { + $models = ScheduleModel::query() + ->where('user_id', $user->getId()) + ->orderByDesc('id') + ->get(); + $schedules = []; + + foreach ($models as $model) { + $schedules[] = $this->toDomain($model, $user); + } + + return $schedules; + } + + private function toDomain(ScheduleModel $model, User $user): Schedule + { + $set = $this->setRepository->find($model->set_id); + if ($set === null) { + throw new RuntimeException('schedule set not found'); + } + + $assignmentModels = ScheduleAssignmentModel::query() + ->where('schedule_id', $model->id) + ->orderBy('position') + ->orderBy('id') + ->get(); + $assignments = []; + + foreach ($assignmentModels as $assignmentModel) { + $element = $this->elementRepository->find( + $assignmentModel->element_id, + ); + if ($element === null) { + throw new RuntimeException('schedule element not found'); + } + + $assignments[] = new ScheduleAssignment( + id: $assignmentModel->id, + element: $element, + scheduledDate: $this->date($assignmentModel->scheduled_date), + position: $assignmentModel->position, + ); + } + + return new Schedule( + id: $model->id, + user: $user, + set: $set, + elementKind: $model->element_kind, + startDate: $this->date($model->start_date), + targetDate: $this->date($model->target_date), + assignments: $assignments, + ); + } + + private function date(string $value): DateTimeImmutable + { + return new DateTimeImmutable($value, new DateTimeZone('UTC')); + } +} diff --git a/backend/app/Schedule/Schedule.php b/backend/app/Schedule/Schedule.php new file mode 100644 index 0000000..ecb3cfa --- /dev/null +++ b/backend/app/Schedule/Schedule.php @@ -0,0 +1,61 @@ + $assignments + */ + public function __construct( + private int $id, + private User $user, + private Set $set, + private string $elementKind, + private DateTimeImmutable $startDate, + private DateTimeImmutable $targetDate, + private array $assignments, + ) {} + + public function getId(): int + { + return $this->id; + } + + public function getUser(): User + { + return $this->user; + } + + public function getSet(): Set + { + return $this->set; + } + + public function getElementKind(): string + { + return $this->elementKind; + } + + public function getStartDate(): DateTimeImmutable + { + return $this->startDate; + } + + public function getTargetDate(): DateTimeImmutable + { + return $this->targetDate; + } + + /** + * @return list + */ + public function getAssignments(): array + { + return $this->assignments; + } +} diff --git a/backend/app/Schedule/ScheduleAssignment.php b/backend/app/Schedule/ScheduleAssignment.php new file mode 100644 index 0000000..97c67a3 --- /dev/null +++ b/backend/app/Schedule/ScheduleAssignment.php @@ -0,0 +1,36 @@ +id; + } + + public function getElement(): Element + { + return $this->element; + } + + public function getScheduledDate(): DateTimeImmutable + { + return $this->scheduledDate; + } + + public function getPosition(): int + { + return $this->position; + } +} diff --git a/backend/app/Schedule/ScheduleAssignmentModel.php b/backend/app/Schedule/ScheduleAssignmentModel.php new file mode 100644 index 0000000..782b906 --- /dev/null +++ b/backend/app/Schedule/ScheduleAssignmentModel.php @@ -0,0 +1,45 @@ +|ScheduleAssignmentModel newModelQuery() + * @method static Builder|ScheduleAssignmentModel newQuery() + * @method static Builder|ScheduleAssignmentModel query() + * + * @mixin \Eloquent + */ +#[Fillable([ + 'schedule_id', + 'element_id', + 'scheduled_date', + 'position', +])] +class ScheduleAssignmentModel extends Model +{ + protected $table = 'schedule_assignments'; + + public $timestamps = false; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'schedule_id' => 'integer', + 'element_id' => 'integer', + 'position' => 'integer', + ]; + } +} diff --git a/backend/app/Schedule/ScheduleModel.php b/backend/app/Schedule/ScheduleModel.php new file mode 100644 index 0000000..6d0132d --- /dev/null +++ b/backend/app/Schedule/ScheduleModel.php @@ -0,0 +1,46 @@ +|ScheduleModel newModelQuery() + * @method static Builder|ScheduleModel newQuery() + * @method static Builder|ScheduleModel query() + * + * @mixin \Eloquent + */ +#[Fillable([ + 'user_id', + 'set_id', + 'element_kind', + 'start_date', + 'target_date', +])] +class ScheduleModel extends Model +{ + protected $table = 'schedules'; + + public $timestamps = false; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'user_id' => 'integer', + 'set_id' => 'integer', + ]; + } +} diff --git a/backend/app/Schedule/ScheduleRepository.php b/backend/app/Schedule/ScheduleRepository.php new file mode 100644 index 0000000..bd02515 --- /dev/null +++ b/backend/app/Schedule/ScheduleRepository.php @@ -0,0 +1,17 @@ + + */ + public function findAllForUser(User $user): array; +} diff --git a/backend/app/Schedule/UseCases/CreateSchedule/CreateSchedule.php b/backend/app/Schedule/UseCases/CreateSchedule/CreateSchedule.php new file mode 100644 index 0000000..c41b19c --- /dev/null +++ b/backend/app/Schedule/UseCases/CreateSchedule/CreateSchedule.php @@ -0,0 +1,212 @@ +setId === null || $request->setId < 1) { + throw new BadRequestException('setId is required'); + } + + $set = $this->setRepository->find($request->setId); + if ($set === null) { + throw new NotFoundException('set not found'); + } + + if ($request->elementKind === null || $request->elementKind === '') { + throw new BadRequestException('elementKind is required'); + } + + $startDate = $this->parseDate($request->startDate, 'startDate'); + $targetDate = $this->parseDate($request->targetDate, 'targetDate'); + if ($targetDate < $startDate) { + throw new BadRequestException( + 'targetDate must not be before startDate', + ); + } + + $elements = array_values(array_filter( + $this->orderedElements($set), + function (Element $element) use ($request): bool { + return $element->getKind() === $request->elementKind; + }, + )); + if ($elements === []) { + throw new BadRequestException( + 'elementKind is not available for set', + ); + } + + $assignments = $this->assignments( + elements: $elements, + startDate: $startDate, + targetDate: $targetDate, + ); + + return $this->scheduleRepository->create(new CreateScheduleDto( + user: $request->user, + set: $set, + elementKind: $request->elementKind, + startDate: $startDate, + targetDate: $targetDate, + assignments: $assignments, + )); + } + + /** + * @return list + */ + private function orderedElements(Set $set): array + { + $childrenByParentId = []; + + foreach ($this->elementRepository->findBySet($set) as $element) { + $parentId = $element->getParentElement()?->getId() ?? 0; + $childrenByParentId[$parentId][] = $element; + } + + foreach ($childrenByParentId as &$children) { + usort($children, function (Element $first, Element $second): int { + $positionComparison = $first->getPosition() + <=> $second->getPosition(); + if ($positionComparison !== 0) { + return $positionComparison; + } + + return $first->getId() <=> $second->getId(); + }); + } + unset($children); + + $orderedElements = []; + $this->appendChildren( + parentId: 0, + childrenByParentId: $childrenByParentId, + orderedElements: $orderedElements, + ); + + return $orderedElements; + } + + /** + * @param array> $childrenByParentId + * @param list $orderedElements + */ + private function appendChildren( + int $parentId, + array $childrenByParentId, + array &$orderedElements, + ): void { + foreach ($childrenByParentId[$parentId] ?? [] as $element) { + $orderedElements[] = $element; + $this->appendChildren( + parentId: $element->getId(), + childrenByParentId: $childrenByParentId, + orderedElements: $orderedElements, + ); + } + } + + /** + * @param list $elements + * @return list + */ + private function assignments( + array $elements, + DateTimeImmutable $startDate, + DateTimeImmutable $targetDate, + ): array { + $differenceInDays = $startDate->diff($targetDate)->days; + $dayCount = $differenceInDays + 1; + $elementCount = count($elements); + $assignments = []; + + foreach ($elements as $index => $element) { + $dayIndex = $this->dayIndex( + elementIndex: $index, + elementCount: $elementCount, + dayCount: $dayCount, + ); + $assignments[] = new CreateScheduleAssignmentDto( + element: $element, + scheduledDate: $startDate->modify("+{$dayIndex} days"), + position: $index + 1, + ); + } + + return $assignments; + } + + private function dayIndex( + int $elementIndex, + int $elementCount, + int $dayCount, + ): int { + if ($elementCount === 1 || $dayCount === 1) { + return 0; + } + + if ($elementCount < $dayCount) { + $scaledIndex = $elementIndex * ($dayCount - 1) + / ($elementCount - 1); + + return (int) floor($scaledIndex + 0.5); + } + + return intdiv($elementIndex * $dayCount, $elementCount); + } + + /** + * @throws BadRequestException + */ + private function parseDate(?string $value, string $field): DateTimeImmutable + { + if ($value === null || $value === '') { + throw new BadRequestException("{$field} is required"); + } + + $date = DateTimeImmutable::createFromFormat( + '!Y-m-d', + $value, + new DateTimeZone('UTC'), + ); + $errors = DateTimeImmutable::getLastErrors(); + if ( + $date === false + || $date->format('Y-m-d') !== $value + || ($errors !== false + && ($errors['warning_count'] > 0 || $errors['error_count'] > 0)) + ) { + throw new BadRequestException( + "{$field} must be a valid date in YYYY-MM-DD format", + ); + } + + return $date; + } +} diff --git a/backend/app/Schedule/UseCases/CreateSchedule/CreateScheduleRequest.php b/backend/app/Schedule/UseCases/CreateSchedule/CreateScheduleRequest.php new file mode 100644 index 0000000..d75ab71 --- /dev/null +++ b/backend/app/Schedule/UseCases/CreateSchedule/CreateScheduleRequest.php @@ -0,0 +1,16 @@ +scheduleRepository->findForUser( + $request->scheduleId, + $request->user, + ); + if ($schedule === null) { + throw new NotFoundException('schedule not found'); + } + + return $schedule; + } +} diff --git a/backend/app/Schedule/UseCases/GetSchedule/GetScheduleRequest.php b/backend/app/Schedule/UseCases/GetSchedule/GetScheduleRequest.php new file mode 100644 index 0000000..c737d1e --- /dev/null +++ b/backend/app/Schedule/UseCases/GetSchedule/GetScheduleRequest.php @@ -0,0 +1,13 @@ + + */ + public function execute(User $user): array + { + return $this->scheduleRepository->findAllForUser($user); + } +} diff --git a/backend/app/Shared/Http/RequestInput.php b/backend/app/Shared/Http/RequestInput.php index 6dffb92..322817a 100644 --- a/backend/app/Shared/Http/RequestInput.php +++ b/backend/app/Shared/Http/RequestInput.php @@ -20,4 +20,11 @@ class RequestInput return null; } + + public function integer(string $key): ?int + { + $value = $this->request->input($key); + + return is_int($value) ? $value : null; + } } diff --git a/backend/database/migrations/2026_08_10_000000_create_schedules_table.php b/backend/database/migrations/2026_08_10_000000_create_schedules_table.php new file mode 100644 index 0000000..cfe3028 --- /dev/null +++ b/backend/database/migrations/2026_08_10_000000_create_schedules_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('user_id') + ->constrained('users') + ->restrictOnDelete(); + $table->foreignId('set_id') + ->constrained('sets') + ->restrictOnDelete(); + $table->string('element_kind'); + $table->date('start_date'); + $table->date('target_date'); + $table->index(['user_id', 'id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('schedules'); + } +}; diff --git a/backend/database/migrations/2026_08_10_000001_create_schedule_assignments_table.php b/backend/database/migrations/2026_08_10_000001_create_schedule_assignments_table.php new file mode 100644 index 0000000..88c05b3 --- /dev/null +++ b/backend/database/migrations/2026_08_10_000001_create_schedule_assignments_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('schedule_id') + ->constrained('schedules') + ->cascadeOnDelete(); + $table->foreignId('element_id') + ->constrained('elements') + ->restrictOnDelete(); + $table->date('scheduled_date'); + $table->unsignedInteger('position'); + $table->unique(['schedule_id', 'element_id']); + $table->unique(['schedule_id', 'position']); + $table->index([ + 'schedule_id', + 'scheduled_date', + 'position', + ]); + }, + ); + } + + public function down(): void + { + Schema::dropIfExists('schedule_assignments'); + } +}; diff --git a/backend/routes/api.php b/backend/routes/api.php index 9b70efe..7f984c5 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -2,6 +2,7 @@ use App\Http\Controllers\AuthController; use App\Http\Controllers\SetController; +use App\Http\Controllers\ScheduleController; use App\Http\Middleware\AuthMiddleware; use Illuminate\Support\Facades\Route; @@ -14,5 +15,9 @@ Route::middleware(AuthMiddleware::class)->group(function (): void { Route::get('/sets', [SetController::class, 'index']); Route::get('/sets/{setId}', [SetController::class, 'show']) ->whereNumber('setId'); + Route::post('/schedules', [ScheduleController::class, 'store']); + Route::get('/schedules', [ScheduleController::class, 'index']); + Route::get('/schedules/{scheduleId}', [ScheduleController::class, 'show']) + ->whereNumber('scheduleId'); Route::post('/logout', [AuthController::class, 'logout']); }); diff --git a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php index 731a221..6b9a679 100644 --- a/backend/tests/Feature/Schedule/ScheduleEndpointTest.php +++ b/backend/tests/Feature/Schedule/ScheduleEndpointTest.php @@ -284,7 +284,7 @@ class ScheduleEndpointTest extends TestCase token: $token, user: $user, createdAt: $createdAt, - expiresAt: $createdAt->modify('+7 days'), + expiresAt: $createdAt->modify('+10 years'), )); }