diff --git a/backend/app/Http/Controllers/ScheduleController.php b/backend/app/Http/Controllers/ScheduleController.php index 9ec6588..f80461a 100644 --- a/backend/app/Http/Controllers/ScheduleController.php +++ b/backend/app/Http/Controllers/ScheduleController.php @@ -14,6 +14,8 @@ 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\SetAssignmentCompletion\SetAssignmentCompletion; +use App\Schedule\UseCases\SetAssignmentCompletion\SetAssignmentCompletionRequest; use App\Shared\Http\RequestInput; use App\User\User; use Illuminate\Http\JsonResponse; @@ -26,6 +28,7 @@ class ScheduleController extends Controller private ListSchedules $listSchedules, private GetSchedule $getSchedule, private ListAssignmentsForDate $listAssignmentsForDate, + private SetAssignmentCompletion $setAssignmentCompletion, ) {} public function store(Request $request): JsonResponse @@ -124,6 +127,41 @@ class ScheduleController extends Controller ]); } + public function updateAssignment( + Request $request, + int $assignmentId, + ): JsonResponse { + $input = new RequestInput($request); + + try { + $assignment = $this->setAssignmentCompletion->execute( + new SetAssignmentCompletionRequest( + assignmentId: $assignmentId, + user: $this->user($request), + completed: $input->boolean('completed'), + ), + ); + } catch (BadRequestException $exception) { + return new JsonResponse( + ['error' => $exception->getMessage()], + 400, + ); + } catch (NotFoundException $exception) { + return new JsonResponse( + ['error' => $exception->getMessage()], + 404, + ); + } + + return new JsonResponse([ + 'assignment' => [ + 'id' => $assignment->getId(), + 'completedAt' => $assignment->getCompletedAt() + ?->format(DATE_ATOM), + ], + ]); + } + /** * @return array */ @@ -180,7 +218,7 @@ class ScheduleController extends Controller } /** - * @return array{id: int, element: array{ + * @return array{id: int, completedAt: string|null, element: array{ * name: string, * kind: string, * path: list @@ -191,6 +229,8 @@ class ScheduleController extends Controller ): array { return [ 'id' => $assignment->getId(), + 'completedAt' => $assignment->getCompletedAt() + ?->format(DATE_ATOM), 'element' => [ 'name' => $assignment->getName(), 'kind' => $assignment->getKind(), diff --git a/backend/app/Schedule/EloquentScheduleRepository.php b/backend/app/Schedule/EloquentScheduleRepository.php index 7aec036..8cbde2a 100644 --- a/backend/app/Schedule/EloquentScheduleRepository.php +++ b/backend/app/Schedule/EloquentScheduleRepository.php @@ -5,6 +5,7 @@ namespace App\Schedule; use App\User\User; use DateTimeImmutable; use DateTimeZone; +use DomainException; use Illuminate\Support\Facades\DB; class EloquentScheduleRepository implements ScheduleRepository @@ -29,6 +30,7 @@ class EloquentScheduleRepository implements ScheduleRepository 'scheduled_date' => $assignmentDto->scheduledDate ->format('Y-m-d'), 'position' => $assignmentDto->position, + 'completed_at' => null, ]); } @@ -77,6 +79,7 @@ class EloquentScheduleRepository implements ScheduleRepository ->where('schedule_assignments.scheduled_date', $date->format( 'Y-m-d', )) + ->whereNull('schedule_assignments.completed_at') ->with('schedule') ->orderByDesc('schedules.id') ->orderBy('schedule_assignments.position') @@ -95,6 +98,50 @@ class EloquentScheduleRepository implements ScheduleRepository return $assignments; } + public function findAssignmentForUser( + int $id, + User $user, + ): ?ScheduleAssignment { + $model = ScheduleAssignmentModel::query() + ->select('schedule_assignments.*') + ->join( + 'schedules', + 'schedules.id', + '=', + 'schedule_assignments.schedule_id', + ) + ->where('schedule_assignments.id', $id) + ->where('schedules.user_id', $user->getId()) + ->first(); + + return $model === null + ? null + : $this->assignmentToDomain($model); + } + + public function updateAssignment( + ScheduleAssignment $assignment, + ): ScheduleAssignment { + $query = ScheduleAssignmentModel::query() + ->whereKey($assignment->getId()); + if ($assignment->getCompletedAt() === null) { + $query->update(['completed_at' => null]); + } else { + $query->whereNull('completed_at')->update([ + 'completed_at' => $assignment->getCompletedAt(), + ]); + } + + $model = ScheduleAssignmentModel::find($assignment->getId()); + if ($model === null) { + throw new DomainException( + "Assignment with id {$assignment->getId()} not found", + ); + } + + return $this->assignmentToDomain($model); + } + private function toDomain(ScheduleModel $model, User $user): Schedule { $assignmentModels = ScheduleAssignmentModel::query() @@ -129,6 +176,9 @@ class EloquentScheduleRepository implements ScheduleRepository path: $model->element_path, scheduledDate: $this->date($model->scheduled_date), position: $model->position, + completedAt: $model->completed_at === null + ? null + : $this->dateTime($model->completed_at), ); } @@ -136,4 +186,10 @@ class EloquentScheduleRepository implements ScheduleRepository { return new DateTimeImmutable($value, new DateTimeZone('UTC')); } + + private function dateTime(DateTimeImmutable $value): DateTimeImmutable + { + return DateTimeImmutable::createFromInterface($value) + ->setTimezone(new DateTimeZone('UTC')); + } } diff --git a/backend/app/Schedule/ScheduleAssignment.php b/backend/app/Schedule/ScheduleAssignment.php index 482b45d..8f90435 100644 --- a/backend/app/Schedule/ScheduleAssignment.php +++ b/backend/app/Schedule/ScheduleAssignment.php @@ -4,7 +4,7 @@ namespace App\Schedule; use DateTimeImmutable; -final readonly class ScheduleAssignment +final class ScheduleAssignment { /** * @param list $path @@ -16,6 +16,7 @@ final readonly class ScheduleAssignment private array $path, private DateTimeImmutable $scheduledDate, private int $position, + private ?DateTimeImmutable $completedAt, ) {} public function getId(): int @@ -50,4 +51,21 @@ final readonly class ScheduleAssignment { return $this->position; } + + public function getCompletedAt(): ?DateTimeImmutable + { + return $this->completedAt; + } + + public function complete(DateTimeImmutable $completedAt): void + { + if ($this->completedAt === null) { + $this->completedAt = $completedAt; + } + } + + public function reopen(): void + { + $this->completedAt = null; + } } diff --git a/backend/app/Schedule/ScheduleAssignmentModel.php b/backend/app/Schedule/ScheduleAssignmentModel.php index 3cd5577..33b2953 100644 --- a/backend/app/Schedule/ScheduleAssignmentModel.php +++ b/backend/app/Schedule/ScheduleAssignmentModel.php @@ -2,6 +2,7 @@ namespace App\Schedule; +use DateTimeImmutable; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; @@ -15,6 +16,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * @property list $element_path * @property string $scheduled_date * @property int $position + * @property DateTimeImmutable|null $completed_at * @property-read ScheduleModel $schedule * * @method static Builder|ScheduleAssignmentModel newModelQuery() @@ -30,6 +32,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; 'element_path', 'scheduled_date', 'position', + 'completed_at', ])] class ScheduleAssignmentModel extends Model { @@ -46,6 +49,7 @@ class ScheduleAssignmentModel extends Model 'schedule_id' => 'integer', 'element_path' => 'array', 'position' => 'integer', + 'completed_at' => 'immutable_datetime', ]; } diff --git a/backend/app/Schedule/ScheduleRepository.php b/backend/app/Schedule/ScheduleRepository.php index fcdcb8c..3222311 100644 --- a/backend/app/Schedule/ScheduleRepository.php +++ b/backend/app/Schedule/ScheduleRepository.php @@ -23,4 +23,13 @@ interface ScheduleRepository User $user, DateTimeImmutable $date, ): array; + + public function findAssignmentForUser( + int $id, + User $user, + ): ?ScheduleAssignment; + + public function updateAssignment( + ScheduleAssignment $assignment, + ): ScheduleAssignment; } diff --git a/backend/app/Schedule/UseCases/SetAssignmentCompletion/SetAssignmentCompletion.php b/backend/app/Schedule/UseCases/SetAssignmentCompletion/SetAssignmentCompletion.php new file mode 100644 index 0000000..df66b8d --- /dev/null +++ b/backend/app/Schedule/UseCases/SetAssignmentCompletion/SetAssignmentCompletion.php @@ -0,0 +1,47 @@ +completed === null) { + throw new BadRequestException( + 'completed must be a boolean', + ); + } + + $assignment = $this->scheduleRepository->findAssignmentForUser( + $request->assignmentId, + $request->user, + ); + if ($assignment === null) { + throw new NotFoundException('assignment not found'); + } + + if ($request->completed) { + $assignment->complete($this->clock->now()); + } else { + $assignment->reopen(); + } + + return $this->scheduleRepository->updateAssignment($assignment); + } +} diff --git a/backend/app/Schedule/UseCases/SetAssignmentCompletion/SetAssignmentCompletionRequest.php b/backend/app/Schedule/UseCases/SetAssignmentCompletion/SetAssignmentCompletionRequest.php new file mode 100644 index 0000000..e591375 --- /dev/null +++ b/backend/app/Schedule/UseCases/SetAssignmentCompletion/SetAssignmentCompletionRequest.php @@ -0,0 +1,14 @@ +request->input($key); + + return is_bool($value) ? $value : null; + } } 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 index b52c60a..c98992a 100644 --- 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 @@ -20,10 +20,12 @@ return new class extends Migration $table->json('element_path'); $table->date('scheduled_date'); $table->unsignedInteger('position'); + $table->timestamp('completed_at')->nullable(); $table->unique(['schedule_id', 'position']); $table->index([ 'schedule_id', 'scheduled_date', + 'completed_at', 'position', ]); }, diff --git a/backend/routes/api.php b/backend/routes/api.php index 2355570..76caf16 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -16,6 +16,10 @@ Route::middleware(AuthMiddleware::class)->group(function (): void { Route::get('/sets/{setId}', [SetController::class, 'show']) ->whereNumber('setId'); Route::get('/assignments', [ScheduleController::class, 'assignments']); + Route::patch( + '/assignments/{assignmentId}', + [ScheduleController::class, 'updateAssignment'], + )->whereNumber('assignmentId'); Route::post('/schedules', [ScheduleController::class, 'store']); Route::get('/schedules', [ScheduleController::class, 'index']); Route::get('/schedules/{scheduleId}', [ScheduleController::class, 'show']) diff --git a/backend/tests/Fakes/FakeScheduleRepository.php b/backend/tests/Fakes/FakeScheduleRepository.php index e30caf2..e2f35bc 100644 --- a/backend/tests/Fakes/FakeScheduleRepository.php +++ b/backend/tests/Fakes/FakeScheduleRepository.php @@ -9,6 +9,7 @@ use App\Schedule\ScheduleAssignment; use App\Schedule\ScheduleRepository; use App\User\User; use DateTimeImmutable; +use DomainException; class FakeScheduleRepository implements ScheduleRepository { @@ -17,6 +18,8 @@ class FakeScheduleRepository implements ScheduleRepository */ private array $schedules = []; + private int $nextAssignmentId = 1; + public function create(CreateScheduleDto $dto): Schedule { $id = count($this->schedules) + 1; @@ -24,12 +27,13 @@ class FakeScheduleRepository implements ScheduleRepository foreach ($dto->assignments as $assignmentDto) { $assignments[] = new ScheduleAssignment( - id: count($assignments) + 1, + id: $this->nextAssignmentId++, name: $assignmentDto->name, kind: $assignmentDto->kind, path: $assignmentDto->path, scheduledDate: $assignmentDto->scheduledDate, position: $assignmentDto->position, + completedAt: null, ); } @@ -84,6 +88,7 @@ class FakeScheduleRepository implements ScheduleRepository foreach ($schedule->getAssignments() as $assignment) { if ($assignment->getScheduledDate()->format('Y-m-d') !== $date->format('Y-m-d') + || $assignment->getCompletedAt() !== null ) { continue; } @@ -99,21 +104,66 @@ class FakeScheduleRepository implements ScheduleRepository return $assignments; } + public function findAssignmentForUser( + int $id, + User $user, + ): ?ScheduleAssignment { + foreach ($this->findAllForUser($user) as $schedule) { + foreach ($schedule->getAssignments() as $assignment) { + if ($assignment->getId() === $id) { + return $this->copyAssignment($assignment); + } + } + } + + return null; + } + + public function updateAssignment( + ScheduleAssignment $assignment, + ): ScheduleAssignment { + foreach ($this->schedules as $id => $schedule) { + $assignments = []; + $found = false; + + foreach ($schedule->getAssignments() as $storedAssignment) { + if ($storedAssignment->getId() === $assignment->getId()) { + $assignments[] = $this->copyAssignment($assignment); + $found = true; + } else { + $assignments[] = $this->copyAssignment( + $storedAssignment, + ); + } + } + + if ($found) { + $this->schedules[$id] = new Schedule( + id: $schedule->getId(), + user: $schedule->getUser(), + setName: $schedule->getSetName(), + elementKind: $schedule->getElementKind(), + startDate: $schedule->getStartDate(), + targetDate: $schedule->getTargetDate(), + assignments: $assignments, + ); + + return $this->copyAssignment($assignment); + } + } + + throw new DomainException( + "Assignment with id {$assignment->getId()} not found", + ); + } + private function copy(Schedule $schedule): Schedule { - $assignments = array_map( - function (ScheduleAssignment $assignment): ScheduleAssignment { - return new ScheduleAssignment( - id: $assignment->getId(), - name: $assignment->getName(), - kind: $assignment->getKind(), - path: $assignment->getPath(), - scheduledDate: $assignment->getScheduledDate(), - position: $assignment->getPosition(), - ); - }, - $schedule->getAssignments(), - ); + $assignments = array_map(function ( + ScheduleAssignment $assignment, + ): ScheduleAssignment { + return $this->copyAssignment($assignment); + }, $schedule->getAssignments()); return new Schedule( id: $schedule->getId(), @@ -125,4 +175,18 @@ class FakeScheduleRepository implements ScheduleRepository assignments: $assignments, ); } + + private function copyAssignment( + ScheduleAssignment $assignment, + ): ScheduleAssignment { + return new ScheduleAssignment( + id: $assignment->getId(), + name: $assignment->getName(), + kind: $assignment->getKind(), + path: $assignment->getPath(), + scheduledDate: $assignment->getScheduledDate(), + position: $assignment->getPosition(), + completedAt: $assignment->getCompletedAt(), + ); + } }