add assignment completion api

This commit is contained in:
Yisroel Baum 2026-08-15 22:43:06 +03:00
parent 916f070455
commit 350d2ec0b7
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
11 changed files with 281 additions and 16 deletions

View file

@ -14,6 +14,8 @@ use App\Schedule\UseCases\GetSchedule\GetScheduleRequest;
use App\Schedule\UseCases\ListSchedules\ListSchedules; use App\Schedule\UseCases\ListSchedules\ListSchedules;
use App\Schedule\UseCases\ListAssignmentsForDate\ListAssignmentsForDate; use App\Schedule\UseCases\ListAssignmentsForDate\ListAssignmentsForDate;
use App\Schedule\UseCases\ListAssignmentsForDate\ListAssignmentsForDateRequest; 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\Shared\Http\RequestInput;
use App\User\User; use App\User\User;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@ -26,6 +28,7 @@ class ScheduleController extends Controller
private ListSchedules $listSchedules, private ListSchedules $listSchedules,
private GetSchedule $getSchedule, private GetSchedule $getSchedule,
private ListAssignmentsForDate $listAssignmentsForDate, private ListAssignmentsForDate $listAssignmentsForDate,
private SetAssignmentCompletion $setAssignmentCompletion,
) {} ) {}
public function store(Request $request): JsonResponse 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<string, mixed> * @return array<string, mixed>
*/ */
@ -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, * name: string,
* kind: string, * kind: string,
* path: list<string> * path: list<string>
@ -191,6 +229,8 @@ class ScheduleController extends Controller
): array { ): array {
return [ return [
'id' => $assignment->getId(), 'id' => $assignment->getId(),
'completedAt' => $assignment->getCompletedAt()
?->format(DATE_ATOM),
'element' => [ 'element' => [
'name' => $assignment->getName(), 'name' => $assignment->getName(),
'kind' => $assignment->getKind(), 'kind' => $assignment->getKind(),

View file

@ -5,6 +5,7 @@ namespace App\Schedule;
use App\User\User; use App\User\User;
use DateTimeImmutable; use DateTimeImmutable;
use DateTimeZone; use DateTimeZone;
use DomainException;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
class EloquentScheduleRepository implements ScheduleRepository class EloquentScheduleRepository implements ScheduleRepository
@ -29,6 +30,7 @@ class EloquentScheduleRepository implements ScheduleRepository
'scheduled_date' => $assignmentDto->scheduledDate 'scheduled_date' => $assignmentDto->scheduledDate
->format('Y-m-d'), ->format('Y-m-d'),
'position' => $assignmentDto->position, 'position' => $assignmentDto->position,
'completed_at' => null,
]); ]);
} }
@ -77,6 +79,7 @@ class EloquentScheduleRepository implements ScheduleRepository
->where('schedule_assignments.scheduled_date', $date->format( ->where('schedule_assignments.scheduled_date', $date->format(
'Y-m-d', 'Y-m-d',
)) ))
->whereNull('schedule_assignments.completed_at')
->with('schedule') ->with('schedule')
->orderByDesc('schedules.id') ->orderByDesc('schedules.id')
->orderBy('schedule_assignments.position') ->orderBy('schedule_assignments.position')
@ -95,6 +98,50 @@ class EloquentScheduleRepository implements ScheduleRepository
return $assignments; 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 private function toDomain(ScheduleModel $model, User $user): Schedule
{ {
$assignmentModels = ScheduleAssignmentModel::query() $assignmentModels = ScheduleAssignmentModel::query()
@ -129,6 +176,9 @@ class EloquentScheduleRepository implements ScheduleRepository
path: $model->element_path, path: $model->element_path,
scheduledDate: $this->date($model->scheduled_date), scheduledDate: $this->date($model->scheduled_date),
position: $model->position, 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')); return new DateTimeImmutable($value, new DateTimeZone('UTC'));
} }
private function dateTime(DateTimeImmutable $value): DateTimeImmutable
{
return DateTimeImmutable::createFromInterface($value)
->setTimezone(new DateTimeZone('UTC'));
}
} }

View file

@ -4,7 +4,7 @@ namespace App\Schedule;
use DateTimeImmutable; use DateTimeImmutable;
final readonly class ScheduleAssignment final class ScheduleAssignment
{ {
/** /**
* @param list<string> $path * @param list<string> $path
@ -16,6 +16,7 @@ final readonly class ScheduleAssignment
private array $path, private array $path,
private DateTimeImmutable $scheduledDate, private DateTimeImmutable $scheduledDate,
private int $position, private int $position,
private ?DateTimeImmutable $completedAt,
) {} ) {}
public function getId(): int public function getId(): int
@ -50,4 +51,21 @@ final readonly class ScheduleAssignment
{ {
return $this->position; 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;
}
} }

View file

@ -2,6 +2,7 @@
namespace App\Schedule; namespace App\Schedule;
use DateTimeImmutable;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -15,6 +16,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property list<string> $element_path * @property list<string> $element_path
* @property string $scheduled_date * @property string $scheduled_date
* @property int $position * @property int $position
* @property DateTimeImmutable|null $completed_at
* @property-read ScheduleModel $schedule * @property-read ScheduleModel $schedule
* *
* @method static Builder<static>|ScheduleAssignmentModel newModelQuery() * @method static Builder<static>|ScheduleAssignmentModel newModelQuery()
@ -30,6 +32,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
'element_path', 'element_path',
'scheduled_date', 'scheduled_date',
'position', 'position',
'completed_at',
])] ])]
class ScheduleAssignmentModel extends Model class ScheduleAssignmentModel extends Model
{ {
@ -46,6 +49,7 @@ class ScheduleAssignmentModel extends Model
'schedule_id' => 'integer', 'schedule_id' => 'integer',
'element_path' => 'array', 'element_path' => 'array',
'position' => 'integer', 'position' => 'integer',
'completed_at' => 'immutable_datetime',
]; ];
} }

View file

@ -23,4 +23,13 @@ interface ScheduleRepository
User $user, User $user,
DateTimeImmutable $date, DateTimeImmutable $date,
): array; ): array;
public function findAssignmentForUser(
int $id,
User $user,
): ?ScheduleAssignment;
public function updateAssignment(
ScheduleAssignment $assignment,
): ScheduleAssignment;
} }

View file

@ -0,0 +1,47 @@
<?php
namespace App\Schedule\UseCases\SetAssignmentCompletion;
use App\Auth\Clock;
use App\Exceptions\BadRequestException;
use App\Exceptions\NotFoundException;
use App\Schedule\ScheduleAssignment;
use App\Schedule\ScheduleRepository;
class SetAssignmentCompletion
{
public function __construct(
private ScheduleRepository $scheduleRepository,
private Clock $clock,
) {}
/**
* @throws BadRequestException
* @throws NotFoundException
*/
public function execute(
SetAssignmentCompletionRequest $request,
): ScheduleAssignment {
if ($request->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);
}
}

View file

@ -0,0 +1,14 @@
<?php
namespace App\Schedule\UseCases\SetAssignmentCompletion;
use App\User\User;
final readonly class SetAssignmentCompletionRequest
{
public function __construct(
public int $assignmentId,
public User $user,
public ?bool $completed,
) {}
}

View file

@ -27,4 +27,11 @@ class RequestInput
return is_int($value) ? $value : null; return is_int($value) ? $value : null;
} }
public function boolean(string $key): ?bool
{
$value = $this->request->input($key);
return is_bool($value) ? $value : null;
}
} }

View file

@ -20,10 +20,12 @@ return new class extends Migration
$table->json('element_path'); $table->json('element_path');
$table->date('scheduled_date'); $table->date('scheduled_date');
$table->unsignedInteger('position'); $table->unsignedInteger('position');
$table->timestamp('completed_at')->nullable();
$table->unique(['schedule_id', 'position']); $table->unique(['schedule_id', 'position']);
$table->index([ $table->index([
'schedule_id', 'schedule_id',
'scheduled_date', 'scheduled_date',
'completed_at',
'position', 'position',
]); ]);
}, },

View file

@ -16,6 +16,10 @@ Route::middleware(AuthMiddleware::class)->group(function (): void {
Route::get('/sets/{setId}', [SetController::class, 'show']) Route::get('/sets/{setId}', [SetController::class, 'show'])
->whereNumber('setId'); ->whereNumber('setId');
Route::get('/assignments', [ScheduleController::class, 'assignments']); Route::get('/assignments', [ScheduleController::class, 'assignments']);
Route::patch(
'/assignments/{assignmentId}',
[ScheduleController::class, 'updateAssignment'],
)->whereNumber('assignmentId');
Route::post('/schedules', [ScheduleController::class, 'store']); Route::post('/schedules', [ScheduleController::class, 'store']);
Route::get('/schedules', [ScheduleController::class, 'index']); Route::get('/schedules', [ScheduleController::class, 'index']);
Route::get('/schedules/{scheduleId}', [ScheduleController::class, 'show']) Route::get('/schedules/{scheduleId}', [ScheduleController::class, 'show'])

View file

@ -9,6 +9,7 @@ use App\Schedule\ScheduleAssignment;
use App\Schedule\ScheduleRepository; use App\Schedule\ScheduleRepository;
use App\User\User; use App\User\User;
use DateTimeImmutable; use DateTimeImmutable;
use DomainException;
class FakeScheduleRepository implements ScheduleRepository class FakeScheduleRepository implements ScheduleRepository
{ {
@ -17,6 +18,8 @@ class FakeScheduleRepository implements ScheduleRepository
*/ */
private array $schedules = []; private array $schedules = [];
private int $nextAssignmentId = 1;
public function create(CreateScheduleDto $dto): Schedule public function create(CreateScheduleDto $dto): Schedule
{ {
$id = count($this->schedules) + 1; $id = count($this->schedules) + 1;
@ -24,12 +27,13 @@ class FakeScheduleRepository implements ScheduleRepository
foreach ($dto->assignments as $assignmentDto) { foreach ($dto->assignments as $assignmentDto) {
$assignments[] = new ScheduleAssignment( $assignments[] = new ScheduleAssignment(
id: count($assignments) + 1, id: $this->nextAssignmentId++,
name: $assignmentDto->name, name: $assignmentDto->name,
kind: $assignmentDto->kind, kind: $assignmentDto->kind,
path: $assignmentDto->path, path: $assignmentDto->path,
scheduledDate: $assignmentDto->scheduledDate, scheduledDate: $assignmentDto->scheduledDate,
position: $assignmentDto->position, position: $assignmentDto->position,
completedAt: null,
); );
} }
@ -84,6 +88,7 @@ class FakeScheduleRepository implements ScheduleRepository
foreach ($schedule->getAssignments() as $assignment) { foreach ($schedule->getAssignments() as $assignment) {
if ($assignment->getScheduledDate()->format('Y-m-d') if ($assignment->getScheduledDate()->format('Y-m-d')
!== $date->format('Y-m-d') !== $date->format('Y-m-d')
|| $assignment->getCompletedAt() !== null
) { ) {
continue; continue;
} }
@ -99,21 +104,66 @@ class FakeScheduleRepository implements ScheduleRepository
return $assignments; 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 private function copy(Schedule $schedule): Schedule
{ {
$assignments = array_map( $assignments = array_map(function (
function (ScheduleAssignment $assignment): ScheduleAssignment { ScheduleAssignment $assignment,
return new ScheduleAssignment( ): ScheduleAssignment {
id: $assignment->getId(), return $this->copyAssignment($assignment);
name: $assignment->getName(), }, $schedule->getAssignments());
kind: $assignment->getKind(),
path: $assignment->getPath(),
scheduledDate: $assignment->getScheduledDate(),
position: $assignment->getPosition(),
);
},
$schedule->getAssignments(),
);
return new Schedule( return new Schedule(
id: $schedule->getId(), id: $schedule->getId(),
@ -125,4 +175,18 @@ class FakeScheduleRepository implements ScheduleRepository
assignments: $assignments, 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(),
);
}
} }