add set scheduling api
This commit is contained in:
parent
bdb266746d
commit
98ae9bf088
20 changed files with 935 additions and 1 deletions
198
backend/app/Http/Controllers/ScheduleController.php
Normal file
198
backend/app/Http/Controllers/ScheduleController.php
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Element\Element;
|
||||||
|
use App\Exceptions\BadRequestException;
|
||||||
|
use App\Exceptions\NotFoundException;
|
||||||
|
use App\Schedule\Schedule;
|
||||||
|
use App\Schedule\ScheduleAssignment;
|
||||||
|
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\Shared\Http\RequestInput;
|
||||||
|
use App\User\User;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ScheduleController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private CreateSchedule $createSchedule,
|
||||||
|
private ListSchedules $listSchedules,
|
||||||
|
private GetSchedule $getSchedule,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$input = new RequestInput($request);
|
||||||
|
$user = $this->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<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed>
|
||||||
|
*/
|
||||||
|
private function detailPayload(Schedule $schedule): array
|
||||||
|
{
|
||||||
|
$payload = $this->summaryPayload($schedule);
|
||||||
|
$payload['days'] = $this->dayPayloads($schedule);
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{date: string, assignments: list<mixed>}>
|
||||||
|
*/
|
||||||
|
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<string>
|
||||||
|
* }}
|
||||||
|
*/
|
||||||
|
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<string>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,8 @@ use App\Element\ElementRepository;
|
||||||
use App\Element\EloquentElementRepository;
|
use App\Element\EloquentElementRepository;
|
||||||
use App\Set\EloquentSetRepository;
|
use App\Set\EloquentSetRepository;
|
||||||
use App\Set\SetRepository;
|
use App\Set\SetRepository;
|
||||||
|
use App\Schedule\EloquentScheduleRepository;
|
||||||
|
use App\Schedule\ScheduleRepository;
|
||||||
use App\User\EloquentUserRepository;
|
use App\User\EloquentUserRepository;
|
||||||
use App\User\UserRepository;
|
use App\User\UserRepository;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
|
|
@ -57,6 +59,10 @@ class AppServiceProvider extends ServiceProvider
|
||||||
ElementRepository::class,
|
ElementRepository::class,
|
||||||
EloquentElementRepository::class,
|
EloquentElementRepository::class,
|
||||||
);
|
);
|
||||||
|
$this->app->bind(
|
||||||
|
ScheduleRepository::class,
|
||||||
|
EloquentScheduleRepository::class,
|
||||||
|
);
|
||||||
$this->app->bind(PasswordHasher::class, BcryptPasswordHasher::class);
|
$this->app->bind(PasswordHasher::class, BcryptPasswordHasher::class);
|
||||||
$this->app->bind(TokenGenerator::class, RandomTokenGenerator::class);
|
$this->app->bind(TokenGenerator::class, RandomTokenGenerator::class);
|
||||||
$this->app->bind(Clock::class, SystemClock::class);
|
$this->app->bind(Clock::class, SystemClock::class);
|
||||||
|
|
|
||||||
15
backend/app/Schedule/CreateScheduleAssignmentDto.php
Normal file
15
backend/app/Schedule/CreateScheduleAssignmentDto.php
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Schedule;
|
||||||
|
|
||||||
|
use App\Element\Element;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
final readonly class CreateScheduleAssignmentDto
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public Element $element,
|
||||||
|
public DateTimeImmutable $scheduledDate,
|
||||||
|
public int $position,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
22
backend/app/Schedule/CreateScheduleDto.php
Normal file
22
backend/app/Schedule/CreateScheduleDto.php
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Schedule;
|
||||||
|
|
||||||
|
use App\Set\Set;
|
||||||
|
use App\User\User;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
final readonly class CreateScheduleDto
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<CreateScheduleAssignmentDto> $assignments
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public User $user,
|
||||||
|
public Set $set,
|
||||||
|
public string $elementKind,
|
||||||
|
public DateTimeImmutable $startDate,
|
||||||
|
public DateTimeImmutable $targetDate,
|
||||||
|
public array $assignments,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
115
backend/app/Schedule/EloquentScheduleRepository.php
Normal file
115
backend/app/Schedule/EloquentScheduleRepository.php
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Schedule;
|
||||||
|
|
||||||
|
use App\Element\ElementRepository;
|
||||||
|
use App\Set\SetRepository;
|
||||||
|
use App\User\User;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class EloquentScheduleRepository implements ScheduleRepository
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private SetRepository $setRepository,
|
||||||
|
private ElementRepository $elementRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function create(CreateScheduleDto $dto): Schedule
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($dto): Schedule {
|
||||||
|
$model = ScheduleModel::create([
|
||||||
|
'user_id' => $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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
61
backend/app/Schedule/Schedule.php
Normal file
61
backend/app/Schedule/Schedule.php
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Schedule;
|
||||||
|
|
||||||
|
use App\Set\Set;
|
||||||
|
use App\User\User;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
final readonly class Schedule
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<ScheduleAssignment> $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<ScheduleAssignment>
|
||||||
|
*/
|
||||||
|
public function getAssignments(): array
|
||||||
|
{
|
||||||
|
return $this->assignments;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
backend/app/Schedule/ScheduleAssignment.php
Normal file
36
backend/app/Schedule/ScheduleAssignment.php
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Schedule;
|
||||||
|
|
||||||
|
use App\Element\Element;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
final readonly class ScheduleAssignment
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private int $id,
|
||||||
|
private Element $element,
|
||||||
|
private DateTimeImmutable $scheduledDate,
|
||||||
|
private int $position,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function getId(): int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getElement(): Element
|
||||||
|
{
|
||||||
|
return $this->element;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getScheduledDate(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->scheduledDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPosition(): int
|
||||||
|
{
|
||||||
|
return $this->position;
|
||||||
|
}
|
||||||
|
}
|
||||||
45
backend/app/Schedule/ScheduleAssignmentModel.php
Normal file
45
backend/app/Schedule/ScheduleAssignmentModel.php
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Schedule;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $id
|
||||||
|
* @property int $schedule_id
|
||||||
|
* @property int $element_id
|
||||||
|
* @property string $scheduled_date
|
||||||
|
* @property int $position
|
||||||
|
*
|
||||||
|
* @method static Builder<static>|ScheduleAssignmentModel newModelQuery()
|
||||||
|
* @method static Builder<static>|ScheduleAssignmentModel newQuery()
|
||||||
|
* @method static Builder<static>|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<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'schedule_id' => 'integer',
|
||||||
|
'element_id' => 'integer',
|
||||||
|
'position' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
46
backend/app/Schedule/ScheduleModel.php
Normal file
46
backend/app/Schedule/ScheduleModel.php
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Schedule;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $id
|
||||||
|
* @property int $user_id
|
||||||
|
* @property int $set_id
|
||||||
|
* @property string $element_kind
|
||||||
|
* @property string $start_date
|
||||||
|
* @property string $target_date
|
||||||
|
*
|
||||||
|
* @method static Builder<static>|ScheduleModel newModelQuery()
|
||||||
|
* @method static Builder<static>|ScheduleModel newQuery()
|
||||||
|
* @method static Builder<static>|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<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'user_id' => 'integer',
|
||||||
|
'set_id' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
17
backend/app/Schedule/ScheduleRepository.php
Normal file
17
backend/app/Schedule/ScheduleRepository.php
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Schedule;
|
||||||
|
|
||||||
|
use App\User\User;
|
||||||
|
|
||||||
|
interface ScheduleRepository
|
||||||
|
{
|
||||||
|
public function create(CreateScheduleDto $dto): Schedule;
|
||||||
|
|
||||||
|
public function findForUser(int $id, User $user): ?Schedule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<Schedule>
|
||||||
|
*/
|
||||||
|
public function findAllForUser(User $user): array;
|
||||||
|
}
|
||||||
212
backend/app/Schedule/UseCases/CreateSchedule/CreateSchedule.php
Normal file
212
backend/app/Schedule/UseCases/CreateSchedule/CreateSchedule.php
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Schedule\UseCases\CreateSchedule;
|
||||||
|
|
||||||
|
use App\Element\Element;
|
||||||
|
use App\Element\ElementRepository;
|
||||||
|
use App\Exceptions\BadRequestException;
|
||||||
|
use App\Exceptions\NotFoundException;
|
||||||
|
use App\Schedule\CreateScheduleAssignmentDto;
|
||||||
|
use App\Schedule\CreateScheduleDto;
|
||||||
|
use App\Schedule\Schedule;
|
||||||
|
use App\Schedule\ScheduleRepository;
|
||||||
|
use App\Set\Set;
|
||||||
|
use App\Set\SetRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
|
|
||||||
|
class CreateSchedule
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private SetRepository $setRepository,
|
||||||
|
private ElementRepository $elementRepository,
|
||||||
|
private ScheduleRepository $scheduleRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws BadRequestException
|
||||||
|
* @throws NotFoundException
|
||||||
|
*/
|
||||||
|
public function execute(CreateScheduleRequest $request): Schedule
|
||||||
|
{
|
||||||
|
if ($request->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<Element>
|
||||||
|
*/
|
||||||
|
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<int, list<Element>> $childrenByParentId
|
||||||
|
* @param list<Element> $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<Element> $elements
|
||||||
|
* @return list<CreateScheduleAssignmentDto>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Schedule\UseCases\CreateSchedule;
|
||||||
|
|
||||||
|
use App\User\User;
|
||||||
|
|
||||||
|
final readonly class CreateScheduleRequest
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public User $user,
|
||||||
|
public ?int $setId,
|
||||||
|
public ?string $elementKind,
|
||||||
|
public ?string $startDate,
|
||||||
|
public ?string $targetDate,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
30
backend/app/Schedule/UseCases/GetSchedule/GetSchedule.php
Normal file
30
backend/app/Schedule/UseCases/GetSchedule/GetSchedule.php
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Schedule\UseCases\GetSchedule;
|
||||||
|
|
||||||
|
use App\Exceptions\NotFoundException;
|
||||||
|
use App\Schedule\Schedule;
|
||||||
|
use App\Schedule\ScheduleRepository;
|
||||||
|
|
||||||
|
class GetSchedule
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ScheduleRepository $scheduleRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws NotFoundException
|
||||||
|
*/
|
||||||
|
public function execute(GetScheduleRequest $request): Schedule
|
||||||
|
{
|
||||||
|
$schedule = $this->scheduleRepository->findForUser(
|
||||||
|
$request->scheduleId,
|
||||||
|
$request->user,
|
||||||
|
);
|
||||||
|
if ($schedule === null) {
|
||||||
|
throw new NotFoundException('schedule not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $schedule;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Schedule\UseCases\GetSchedule;
|
||||||
|
|
||||||
|
use App\User\User;
|
||||||
|
|
||||||
|
final readonly class GetScheduleRequest
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $scheduleId,
|
||||||
|
public User $user,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Schedule\UseCases\ListSchedules;
|
||||||
|
|
||||||
|
use App\Schedule\Schedule;
|
||||||
|
use App\Schedule\ScheduleRepository;
|
||||||
|
use App\User\User;
|
||||||
|
|
||||||
|
class ListSchedules
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ScheduleRepository $scheduleRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<Schedule>
|
||||||
|
*/
|
||||||
|
public function execute(User $user): array
|
||||||
|
{
|
||||||
|
return $this->scheduleRepository->findAllForUser($user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,4 +20,11 @@ class RequestInput
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function integer(string $key): ?int
|
||||||
|
{
|
||||||
|
$value = $this->request->input($key);
|
||||||
|
|
||||||
|
return is_int($value) ? $value : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('schedules', function (Blueprint $table): void {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create(
|
||||||
|
'schedule_assignments',
|
||||||
|
function (Blueprint $table): void {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
use App\Http\Controllers\AuthController;
|
use App\Http\Controllers\AuthController;
|
||||||
use App\Http\Controllers\SetController;
|
use App\Http\Controllers\SetController;
|
||||||
|
use App\Http\Controllers\ScheduleController;
|
||||||
use App\Http\Middleware\AuthMiddleware;
|
use App\Http\Middleware\AuthMiddleware;
|
||||||
use Illuminate\Support\Facades\Route;
|
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', [SetController::class, 'index']);
|
||||||
Route::get('/sets/{setId}', [SetController::class, 'show'])
|
Route::get('/sets/{setId}', [SetController::class, 'show'])
|
||||||
->whereNumber('setId');
|
->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']);
|
Route::post('/logout', [AuthController::class, 'logout']);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -284,7 +284,7 @@ class ScheduleEndpointTest extends TestCase
|
||||||
token: $token,
|
token: $token,
|
||||||
user: $user,
|
user: $user,
|
||||||
createdAt: $createdAt,
|
createdAt: $createdAt,
|
||||||
expiresAt: $createdAt->modify('+7 days'),
|
expiresAt: $createdAt->modify('+10 years'),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue