From 7576ccaf09b901e08b2cf0d1c5f834fa8206183b Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 19 Aug 2026 22:44:14 +0300 Subject: [PATCH] add schedule rescheduling --- .../Http/Controllers/ScheduleController.php | 52 ++++++- .../Schedule/EloquentScheduleRepository.php | 33 +++++ .../RescheduleScheduleAssignmentDto.php | 13 ++ .../app/Schedule/RescheduleScheduleDto.php | 20 +++ backend/app/Schedule/ScheduleRepository.php | 2 + .../RescheduleSchedule/RescheduleSchedule.php | 132 ++++++++++++++++++ .../RescheduleScheduleRequest.php | 16 +++ backend/routes/api.php | 4 + .../tests/Fakes/FakeScheduleRepository.php | 47 ++++++- 9 files changed, 314 insertions(+), 5 deletions(-) create mode 100644 backend/app/Schedule/RescheduleScheduleAssignmentDto.php create mode 100644 backend/app/Schedule/RescheduleScheduleDto.php create mode 100644 backend/app/Schedule/UseCases/RescheduleSchedule/RescheduleSchedule.php create mode 100644 backend/app/Schedule/UseCases/RescheduleSchedule/RescheduleScheduleRequest.php diff --git a/backend/app/Http/Controllers/ScheduleController.php b/backend/app/Http/Controllers/ScheduleController.php index c1f8d12..d37266f 100644 --- a/backend/app/Http/Controllers/ScheduleController.php +++ b/backend/app/Http/Controllers/ScheduleController.php @@ -11,9 +11,11 @@ use App\Schedule\UseCases\CreateSchedule\CreateSchedule; use App\Schedule\UseCases\CreateSchedule\CreateScheduleRequest; use App\Schedule\UseCases\GetSchedule\GetSchedule; use App\Schedule\UseCases\GetSchedule\GetScheduleRequest; -use App\Schedule\UseCases\ListSchedules\ListSchedules; use App\Schedule\UseCases\ListAssignmentsForDate\ListAssignmentsForDate; use App\Schedule\UseCases\ListAssignmentsForDate\ListAssignmentsForDateRequest; +use App\Schedule\UseCases\ListSchedules\ListSchedules; +use App\Schedule\UseCases\RescheduleSchedule\RescheduleSchedule; +use App\Schedule\UseCases\RescheduleSchedule\RescheduleScheduleRequest; use App\Schedule\UseCases\SetAssignmentCompletion\SetAssignmentCompletion; use App\Schedule\UseCases\SetAssignmentCompletion\SetAssignmentCompletionRequest; use App\Shared\Http\RequestInput; @@ -27,6 +29,7 @@ class ScheduleController extends Controller private CreateSchedule $createSchedule, private ListSchedules $listSchedules, private GetSchedule $getSchedule, + private RescheduleSchedule $rescheduleSchedule, private ListAssignmentsForDate $listAssignmentsForDate, private SetAssignmentCompletion $setAssignmentCompletion, ) {} @@ -100,6 +103,39 @@ class ScheduleController extends Controller ]); } + public function update(Request $request, int $scheduleId): JsonResponse + { + $input = new RequestInput($request); + + try { + $schedule = $this->rescheduleSchedule->execute( + new RescheduleScheduleRequest( + scheduleId: $scheduleId, + user: $this->user($request), + startDate: $input->string('startDate'), + targetDate: $input->string('targetDate'), + workloadPlacement: $input->string( + 'workloadPlacement', + ), + ), + ); + } 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), + ]); + } + public function assignments(Request $request): JsonResponse { $input = new RequestInput($request); @@ -206,15 +242,23 @@ class ScheduleController extends Controller ); } - $days = []; + $includedDates = []; $date = $schedule->getStartDate(); while ($date <= $schedule->getTargetDate()) { - $formattedDate = $date->format('Y-m-d'); + $includedDates[$date->format('Y-m-d')] = true; + $date = $date->modify('+1 day'); + } + foreach (array_keys($assignmentsByDate) as $assignmentDate) { + $includedDates[$assignmentDate] = true; + } + ksort($includedDates); + + $days = []; + foreach (array_keys($includedDates) as $formattedDate) { $days[] = [ 'date' => $formattedDate, 'assignments' => $assignmentsByDate[$formattedDate] ?? [], ]; - $date = $date->modify('+1 day'); } return $days; diff --git a/backend/app/Schedule/EloquentScheduleRepository.php b/backend/app/Schedule/EloquentScheduleRepository.php index 8576591..69f08a5 100644 --- a/backend/app/Schedule/EloquentScheduleRepository.php +++ b/backend/app/Schedule/EloquentScheduleRepository.php @@ -38,6 +38,39 @@ class EloquentScheduleRepository implements ScheduleRepository }); } + public function reschedule(RescheduleScheduleDto $dto): Schedule + { + return DB::transaction(function () use ($dto): Schedule { + $model = ScheduleModel::query() + ->where('id', $dto->scheduleId) + ->where('user_id', $dto->user->getId()) + ->lockForUpdate() + ->first(); + if ($model === null) { + throw new DomainException( + "Schedule with id {$dto->scheduleId} not found", + ); + } + + $model->start_date = $dto->startDate->format('Y-m-d'); + $model->target_date = $dto->targetDate->format('Y-m-d'); + $model->save(); + + foreach ($dto->assignments as $assignmentDto) { + ScheduleAssignmentModel::query() + ->where('id', $assignmentDto->id) + ->where('schedule_id', $dto->scheduleId) + ->whereNull('completed_at') + ->update([ + 'scheduled_date' => $assignmentDto->scheduledDate + ->format('Y-m-d'), + ]); + } + + return $this->toDomain($model, $dto->user); + }); + } + public function findForUser(int $id, User $user): ?Schedule { $model = ScheduleModel::query() diff --git a/backend/app/Schedule/RescheduleScheduleAssignmentDto.php b/backend/app/Schedule/RescheduleScheduleAssignmentDto.php new file mode 100644 index 0000000..510a127 --- /dev/null +++ b/backend/app/Schedule/RescheduleScheduleAssignmentDto.php @@ -0,0 +1,13 @@ + $assignments + */ + public function __construct( + public int $scheduleId, + public User $user, + public DateTimeImmutable $startDate, + public DateTimeImmutable $targetDate, + public array $assignments, + ) {} +} diff --git a/backend/app/Schedule/ScheduleRepository.php b/backend/app/Schedule/ScheduleRepository.php index 20b5f9f..d422a68 100644 --- a/backend/app/Schedule/ScheduleRepository.php +++ b/backend/app/Schedule/ScheduleRepository.php @@ -9,6 +9,8 @@ interface ScheduleRepository { public function create(CreateScheduleDto $dto): Schedule; + public function reschedule(RescheduleScheduleDto $dto): Schedule; + public function findForUser(int $id, User $user): ?Schedule; /** diff --git a/backend/app/Schedule/UseCases/RescheduleSchedule/RescheduleSchedule.php b/backend/app/Schedule/UseCases/RescheduleSchedule/RescheduleSchedule.php new file mode 100644 index 0000000..407a4c7 --- /dev/null +++ b/backend/app/Schedule/UseCases/RescheduleSchedule/RescheduleSchedule.php @@ -0,0 +1,132 @@ +scheduleRepository->findForUser( + $request->scheduleId, + $request->user, + ); + if ($schedule === null) { + throw new NotFoundException('schedule not found'); + } + + $startDate = $this->parseDate($request->startDate, 'startDate'); + $targetDate = $this->parseDate($request->targetDate, 'targetDate'); + if ($targetDate < $startDate) { + throw new BadRequestException( + 'targetDate must not be before startDate', + ); + } + $workloadPlacement = $this->workloadPlacement( + $request->workloadPlacement, + ); + $unfinishedAssignments = array_values(array_filter( + $schedule->getAssignments(), + function (ScheduleAssignment $assignment): bool { + return $assignment->getCompletedAt() === null; + }, + )); + if ($unfinishedAssignments === []) { + throw new BadRequestException( + 'schedule has no unfinished assignments', + ); + } + + $scheduledDates = $this->evenDistributionScheduler->scheduledDates( + assignmentCount: count($unfinishedAssignments), + startDate: $startDate, + targetDate: $targetDate, + workloadPlacement: $workloadPlacement, + ); + $assignmentDtos = []; + foreach ($unfinishedAssignments as $index => $assignment) { + $assignmentDtos[] = new RescheduleScheduleAssignmentDto( + id: $assignment->getId(), + scheduledDate: $scheduledDates[$index], + ); + } + + return $this->scheduleRepository->reschedule( + new RescheduleScheduleDto( + scheduleId: $schedule->getId(), + user: $request->user, + startDate: $startDate, + targetDate: $targetDate, + assignments: $assignmentDtos, + ), + ); + } + + /** + * @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; + } + + /** + * @throws BadRequestException + */ + private function workloadPlacement(?string $value): WorkloadPlacement + { + if ($value === null) { + return WorkloadPlacement::Middle; + } + + $workloadPlacement = WorkloadPlacement::tryFrom($value); + if ($workloadPlacement === null) { + throw new BadRequestException( + 'workloadPlacement must be start, middle, or end', + ); + } + + return $workloadPlacement; + } +} diff --git a/backend/app/Schedule/UseCases/RescheduleSchedule/RescheduleScheduleRequest.php b/backend/app/Schedule/UseCases/RescheduleSchedule/RescheduleScheduleRequest.php new file mode 100644 index 0000000..d23468e --- /dev/null +++ b/backend/app/Schedule/UseCases/RescheduleSchedule/RescheduleScheduleRequest.php @@ -0,0 +1,16 @@ +group(function (): void { )->whereNumber('assignmentId'); Route::post('/schedules', [ScheduleController::class, 'store']); Route::get('/schedules', [ScheduleController::class, 'index']); + Route::patch( + '/schedules/{scheduleId}', + [ScheduleController::class, 'update'], + )->whereNumber('scheduleId'); Route::get('/schedules/{scheduleId}', [ScheduleController::class, 'show']) ->whereNumber('scheduleId'); Route::post('/logout', [AuthController::class, 'logout']); diff --git a/backend/tests/Fakes/FakeScheduleRepository.php b/backend/tests/Fakes/FakeScheduleRepository.php index e12d126..2713d30 100644 --- a/backend/tests/Fakes/FakeScheduleRepository.php +++ b/backend/tests/Fakes/FakeScheduleRepository.php @@ -2,8 +2,9 @@ namespace Tests\Fakes; -use App\Schedule\CreateScheduleDto; use App\Schedule\AssignmentForDate; +use App\Schedule\CreateScheduleDto; +use App\Schedule\RescheduleScheduleDto; use App\Schedule\Schedule; use App\Schedule\ScheduleAssignment; use App\Schedule\ScheduleRepository; @@ -51,6 +52,50 @@ class FakeScheduleRepository implements ScheduleRepository return $this->copy($schedule); } + public function reschedule(RescheduleScheduleDto $dto): Schedule + { + $schedule = $this->schedules[$dto->scheduleId] ?? null; + if ($schedule === null || $schedule->getUser()->getId() + !== $dto->user->getId() + ) { + throw new DomainException( + "Schedule with id {$dto->scheduleId} not found", + ); + } + + $datesByAssignmentId = []; + foreach ($dto->assignments as $assignmentDto) { + $datesByAssignmentId[$assignmentDto->id] = + $assignmentDto->scheduledDate; + } + $assignments = []; + foreach ($schedule->getAssignments() as $assignment) { + $assignments[] = new ScheduleAssignment( + id: $assignment->getId(), + name: $assignment->getName(), + kind: $assignment->getKind(), + path: $assignment->getPath(), + scheduledDate: $datesByAssignmentId[$assignment->getId()] + ?? $assignment->getScheduledDate(), + position: $assignment->getPosition(), + completedAt: $assignment->getCompletedAt(), + ); + } + + $rescheduled = new Schedule( + id: $schedule->getId(), + user: $schedule->getUser(), + setName: $schedule->getSetName(), + elementKind: $schedule->getElementKind(), + startDate: $dto->startDate, + targetDate: $dto->targetDate, + assignments: $assignments, + ); + $this->schedules[$dto->scheduleId] = $rescheduled; + + return $this->copy($rescheduled); + } + public function findForUser(int $id, User $user): ?Schedule { $schedule = $this->schedules[$id] ?? null;