Task module

This commit is contained in:
Marek Lenczewski
2026-04-12 10:06:17 +02:00
parent efe0cfe361
commit 27b34eb90f
39 changed files with 2454 additions and 41 deletions

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Collection;
use App\Entity\Task;
use App\Enum\TaskStatus;
/**
* @implements \IteratorAggregate<int, Task>
*/
final class TaskCollection implements \IteratorAggregate
{
/** @var list<Task> */
private array $tasks = [];
/**
* @param list<Task> $tasks
*/
public function __construct(array $tasks = [])
{
foreach ($tasks as $task) {
$this->add($task);
}
}
private function add(Task $task): void
{
$this->tasks[] = $task;
}
public function getIterator(): \Iterator
{
return new \ArrayIterator($this->tasks);
}
public function filterInactive(): self
{
$this->tasks = [...array_filter(
$this->tasks,
fn (Task $t) => $t->getStatus() !== TaskStatus::Inactive
)];
return $this;
}
public function sortByDueDate(): self
{
usort($this->tasks, fn (Task $a, Task $b) => $a->getDate() <=> $b->getDate());
return $this;
}
public function sortByDueDateDesc(): self
{
usort($this->tasks, fn (Task $a, Task $b) =>
$a->getDate() === null || $b->getDate() === null
? $a->getDate() <=> $b->getDate()
: $b->getDate() <=> $a->getDate()
);
return $this;
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Controller\Api;
use App\DTO\TaskRequest;
use App\Entity\Task;
use App\Enum\TaskStatus;
use App\Repository\TaskRepository;
use App\Service\TaskManager;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload as Payload;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/tasks')]
class TaskController extends AbstractController
{
public function __construct(
private readonly TaskRepository $repo,
private readonly TaskManager $manager,
) {
}
#[Route('', methods: ['GET'])]
public function index(Request $req): JsonResponse
{
$tasks = match ($req->query->get('filter')) {
'current' => $this->repo->currentTasks()->filterInactive()->sortByDueDate(),
default => $this->repo->allTasks()->sortByDueDateDesc(),
};
return $this->json($tasks, 200, [], ['groups' => ['task:read']]);
}
#[Route('/statuses', methods: ['GET'])]
public function statuses(): JsonResponse
{
return $this->json(TaskStatus::userSelectableValues());
}
#[Route('/{id}', methods: ['GET'], requirements: ['id' => '\d+'])]
public function show(Task $task): JsonResponse
{
return $this->json($task, 200, [], ['groups' => ['task:read']]);
}
#[Route('', methods: ['POST'])]
public function create(#[Payload] TaskRequest $dto): JsonResponse
{
$task = $this->manager->create($dto);
return $this->json($task, 201, [], ['groups' => ['task:read']]);
}
#[Route('/{id}', methods: ['PUT'], requirements: ['id' => '\d+'])]
public function update(Task $task, #[Payload] TaskRequest $dto): JsonResponse
{
$task = $this->manager->update($task, $dto);
return $this->json($task, 200, [], ['groups' => ['task:read']]);
}
#[Route('/{id}', methods: ['DELETE'], requirements: ['id' => '\d+'])]
public function delete(Task $task): JsonResponse
{
$this->manager->delete($task);
return new JsonResponse(null, 204);
}
#[Route('/{id}/toggle', methods: ['PATCH'], requirements: ['id' => '\d+'])]
public function toggle(Task $task): JsonResponse
{
$task = $this->manager->toggle($task);
return $this->json($task, 200, [], ['groups' => ['task:read']]);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\DTO;
use App\Enum\TaskStatus;
use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
use Symfony\Component\Validator\Constraints as Assert;
class TaskRequest
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Length(max: 255)]
public readonly string $name,
#[Context([DateTimeNormalizer::FORMAT_KEY => '!Y-m-d'])]
public readonly ?\DateTimeImmutable $date = null,
#[Assert\NotNull]
public readonly TaskStatus $status = TaskStatus::Active,
) {
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Entity;
use App\Enum\TaskStatus;
use App\Repository\TaskRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity(repositoryClass: TaskRepository::class)]
class Task
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['task:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['task:read'])]
private string $name = '';
#[ORM\Column(type: 'date_immutable', nullable: true)]
#[Groups(['task:read'])]
private ?\DateTimeImmutable $date = null;
#[ORM\Column(enumType: TaskStatus::class)]
private TaskStatus $status = TaskStatus::Active;
public function getId(): ?int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getDate(): ?\DateTimeImmutable
{
return $this->date;
}
public function setDate(?\DateTimeImmutable $date): self
{
$this->date = $date;
return $this;
}
#[Groups(['task:read'])]
public function getStatus(): TaskStatus
{
if ($this->date !== null && $this->date < new \DateTimeImmutable('today')) {
return TaskStatus::Past;
}
return $this->status;
}
public function getRawStatus(): TaskStatus
{
return $this->status;
}
public function setStatus(TaskStatus $status): self
{
$this->status = $status;
return $this;
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Enum;
enum TaskStatus: string
{
case Active = 'active';
case Done = 'done';
case Inactive = 'inactive';
case Past = 'past';
/** @return list<string> */
public static function userSelectableValues(): array
{
return array_values(array_map(
fn (self $s) => $s->value,
array_filter(self::cases(), fn (self $s) => $s !== self::Past)
));
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Repository;
use App\Collection\TaskCollection;
use App\Entity\Task;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Task>
*/
class TaskRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Task::class);
}
public function currentTasks(): TaskCollection
{
$from = new \DateTimeImmutable('today');
$to = $from->modify('+14 days');
$tasks = $this->createQueryBuilder('t')
->andWhere('t.date IS NULL OR (t.date >= :from AND t.date <= :to)')
->setParameter('from', $from)
->setParameter('to', $to)
->getQuery()
->getResult();
return new TaskCollection($tasks);
}
public function allTasks(): TaskCollection
{
return new TaskCollection(parent::findAll());
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Service;
use App\DTO\TaskRequest;
use App\Entity\Task;
use App\Enum\TaskStatus;
use Doctrine\ORM\EntityManagerInterface;
class TaskManager
{
public function __construct(private EntityManagerInterface $em)
{
}
public function create(TaskRequest $req): Task
{
$task = new Task();
$task->setName($req->name);
$task->setDate($req->date);
$task->setStatus($req->status);
$this->em->persist($task);
$this->em->flush();
return $task;
}
public function update(Task $task, TaskRequest $req): Task
{
$task->setName($req->name);
$task->setDate($req->date);
$task->setStatus($req->status);
$this->em->flush();
return $task;
}
public function delete(Task $task): void
{
$this->em->remove($task);
$this->em->flush();
}
public function toggle(Task $task): Task
{
$new = match ($task->getRawStatus()) {
TaskStatus::Active => TaskStatus::Done,
TaskStatus::Done => TaskStatus::Active,
TaskStatus::Inactive => TaskStatus::Inactive,
TaskStatus::Past => TaskStatus::Past,
};
$task->setStatus($new);
$this->em->flush();
return $task;
}
}