TaskSchema module

This commit is contained in:
Marek Lenczewski
2026-04-12 15:42:48 +02:00
parent 4e81cea831
commit 5198769de4
57 changed files with 3066 additions and 324 deletions

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Collection;
use App\Entity\TaskSchema;
/**
* @implements \IteratorAggregate<int, TaskSchema>
*/
final class TaskSchemaCollection implements \IteratorAggregate
{
/** @var list<TaskSchema> */
private array $schemas = [];
/**
* @param list<TaskSchema> $schemas
*/
public function __construct(array $schemas = [])
{
foreach ($schemas as $schema) {
$this->schemas[] = $schema;
}
}
public function getIterator(): \Iterator
{
return new \ArrayIterator($this->schemas);
}
}

View File

@@ -45,14 +45,6 @@ class TaskController extends AbstractController
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
{

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Controller\Api;
use App\DTO\TaskSchemaRequest;
use App\Entity\TaskSchema;
use App\Repository\TaskSchemaRepository;
use App\Service\TaskSchemaManager;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload as Payload;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/task-schemas')]
class TaskSchemaController extends AbstractController
{
public function __construct(
private readonly TaskSchemaRepository $repo,
private readonly TaskSchemaManager $manager,
) {
}
#[Route('', methods: ['GET'])]
public function index(): JsonResponse
{
$schemas = $this->repo->allSchemas();
return $this->json($schemas, 200, [], ['groups' => ['task_schema:read']]);
}
#[Route('/{id}', methods: ['GET'], requirements: ['id' => '\d+'])]
public function show(TaskSchema $schema): JsonResponse
{
return $this->json($schema, 200, [], ['groups' => ['task_schema:read']]);
}
#[Route('', methods: ['POST'])]
public function create(#[Payload] TaskSchemaRequest $dto): JsonResponse
{
$this->manager->create($dto);
return new JsonResponse(null, 201);
}
#[Route('/{id}', methods: ['PUT'], requirements: ['id' => '\d+'])]
public function update(TaskSchema $schema, #[Payload] TaskSchemaRequest $dto): JsonResponse
{
$this->manager->update($schema, $dto);
return $this->json($schema, 200, [], ['groups' => ['task_schema:read']]);
}
#[Route('/{id}', methods: ['DELETE'], requirements: ['id' => '\d+'])]
public function delete(TaskSchema $schema): JsonResponse
{
$this->manager->delete($schema);
return new JsonResponse(null, 204);
}
}

View File

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

View File

@@ -6,6 +6,7 @@ use App\Enum\TaskStatus;
use App\Repository\TaskRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\Ignore;
#[ORM\Entity(repositoryClass: TaskRepository::class)]
class Task
@@ -27,6 +28,11 @@ class Task
#[ORM\Column(enumType: TaskStatus::class)]
private TaskStatus $status = TaskStatus::Active;
#[ORM\ManyToOne(targetEntity: TaskSchema::class, inversedBy: 'tasks')]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Ignore]
private ?TaskSchema $schema = null;
public function getId(): ?int
{
return $this->id;
@@ -77,4 +83,16 @@ class Task
return $this;
}
public function getSchema(): ?TaskSchema
{
return $this->schema;
}
public function setSchema(?TaskSchema $schema): self
{
$this->schema = $schema;
return $this;
}
}

View File

@@ -0,0 +1,158 @@
<?php
namespace App\Entity;
use App\Enum\TaskSchemaStatus;
use App\Enum\TaskStatus;
use App\Repository\TaskSchemaRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity(repositoryClass: TaskSchemaRepository::class)]
class TaskSchema
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['task_schema:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['task_schema:read'])]
private string $name = '';
#[ORM\Column(enumType: TaskSchemaStatus::class)]
#[Groups(['task_schema:read'])]
private TaskSchemaStatus $status = TaskSchemaStatus::Active;
#[ORM\Column(enumType: TaskStatus::class)]
#[Groups(['task_schema:read'])]
private TaskStatus $taskStatus = TaskStatus::Active;
#[ORM\Column(type: 'date_immutable', nullable: true)]
#[Groups(['task_schema:read'])]
private ?\DateTimeImmutable $date = null;
#[ORM\Column(name: '`repeat`', type: 'json', nullable: true)]
#[Groups(['task_schema:read'])]
private ?array $repeat = null;
#[ORM\Column(name: '`start`', type: 'date_immutable', nullable: true)]
#[Groups(['task_schema:read'])]
private ?\DateTimeImmutable $start = null;
#[ORM\Column(name: '`end`', type: 'date_immutable', nullable: true)]
#[Groups(['task_schema:read'])]
private ?\DateTimeImmutable $end = null;
/** @var Collection<int, Task> */
#[ORM\OneToMany(targetEntity: Task::class, mappedBy: 'schema')]
private Collection $tasks;
public function __construct()
{
$this->tasks = new ArrayCollection();
}
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 getStatus(): TaskSchemaStatus
{
return $this->status;
}
public function setStatus(TaskSchemaStatus $status): self
{
$this->status = $status;
return $this;
}
public function getTaskStatus(): TaskStatus
{
return $this->taskStatus;
}
public function setTaskStatus(TaskStatus $taskStatus): self
{
$this->taskStatus = $taskStatus;
return $this;
}
public function getDate(): ?\DateTimeImmutable
{
return $this->date;
}
public function setDate(?\DateTimeImmutable $date): self
{
$this->date = $date;
return $this;
}
public function getRepeat(): ?array
{
return $this->repeat;
}
public function getRepeatType(): ?string
{
return $this->repeat !== null ? array_key_first($this->repeat) : null;
}
public function setRepeat(?array $repeat): self
{
$this->repeat = $repeat;
return $this;
}
public function getStart(): ?\DateTimeImmutable
{
return $this->start;
}
public function setStart(?\DateTimeImmutable $start): self
{
$this->start = $start;
return $this;
}
public function getEnd(): ?\DateTimeImmutable
{
return $this->end;
}
public function setEnd(?\DateTimeImmutable $end): self
{
$this->end = $end;
return $this;
}
/** @return Collection<int, Task> */
public function getTasks(): Collection
{
return $this->tasks;
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Enum;
enum TaskSchemaStatus: string
{
case Active = 'active';
case Inactive = 'inactive';
}

View File

@@ -0,0 +1,7 @@
<?php
namespace App\Message;
final class GenerateTasksMessage
{
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\MessageHandler;
use App\Message\GenerateTasksMessage;
use App\Service\TaskGenerator;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class GenerateTasksMessageHandler
{
public function __construct(private TaskGenerator $generator)
{
}
public function __invoke(GenerateTasksMessage $message): void
{
$this->generator->generateNewTasks();
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Repository;
use App\Collection\TaskSchemaCollection;
use App\Entity\TaskSchema;
use App\Enum\TaskSchemaStatus;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<TaskSchema>
*/
class TaskSchemaRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TaskSchema::class);
}
public function allSchemas(): TaskSchemaCollection
{
return new TaskSchemaCollection(parent::findAll());
}
/** @return list<TaskSchema> */
public function findActiveWithRepeat(): array
{
return $this->createQueryBuilder('s')
->andWhere('s.status = :status')
->andWhere('s.repeat IS NOT NULL')
->setParameter('status', TaskSchemaStatus::Active)
->getQuery()
->getResult();
}
}

27
backend/src/Schedule.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
namespace App;
use App\Message\GenerateTasksMessage;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule as SymfonySchedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
use Symfony\Contracts\Cache\CacheInterface;
#[AsSchedule]
class Schedule implements ScheduleProviderInterface
{
public function __construct(
private CacheInterface $cache,
) {
}
public function getSchedule(): SymfonySchedule
{
return (new SymfonySchedule())
->stateful($this->cache)
->processOnlyLastMissedRun(true)
->add(RecurringMessage::cron('0 3 * * *', new GenerateTasksMessage()));
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Service;
use App\Entity\Task;
use App\Entity\TaskSchema;
use App\Enum\TaskStatus;
use App\Repository\TaskSchemaRepository;
use Doctrine\ORM\EntityManagerInterface;
class TaskGenerator
{
public function __construct(
private EntityManagerInterface $em,
private TaskSchemaRepository $schemaRepo,
) {
}
public function generateNewTasks(): void
{
$schemas = $this->schemaRepo->findActiveWithRepeat();
foreach ($schemas as $schema) {
$this->removeTasks($schema);
$this->generateTasks($schema);
}
$this->em->flush();
}
public function removeTasks(TaskSchema $schema): void
{
foreach ($schema->getTasks() as $task) {
if ($task->getStatus() === TaskStatus::Past) continue;
$this->em->remove($task);
}
}
public function generateTasks(TaskSchema $schema): void
{
$dates = $this->getDates($schema);
foreach ($dates as $date) {
$task = new Task();
$task->setName($schema->getName());
$task->setDate($date);
$task->setStatus($schema->getTaskStatus());
$task->setSchema($schema);
$this->em->persist($task);
}
}
/** @return array{\DateTimeImmutable, \DateTimeImmutable} */
private function getDateRange(TaskSchema $schema): array
{
$today = new \DateTimeImmutable('today');
$from = max($today, $schema->getStart() ?? $today);
$end = min($today->modify('+14 days'), $schema->getEnd() ?? $today->modify('+14 days'));
return [$from, $end];
}
/** @return list<\DateTimeImmutable> */
private function getDates(TaskSchema $schema): array
{
[$from, $end] = $this->getDateRange($schema);
$type = $schema->getRepeatType();
$repeat = $schema->getRepeat();
$dates = [];
for ($date = $from; $date <= $end; $date = $date->modify('+1 day')) {
if ($type === 'weekly') {
$weekday = (int) $date->format('N') - 1;
if(!$repeat['weekly'][$weekday]) continue;
}
if ($type === 'monthly') {
$monthday = (int) $date->format('j') - 1;
if(!$repeat['monthly'][$monthday]) continue;
}
$dates[] = $date;
}
return $dates;
}
}

View File

@@ -13,19 +13,6 @@ class TaskManager
{
}
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);

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Service;
use App\DTO\TaskSchemaRequest;
use App\Entity\Task;
use App\Entity\TaskSchema;
use App\Enum\TaskSchemaStatus;
use Doctrine\ORM\EntityManagerInterface;
class TaskSchemaManager
{
public function __construct(
private EntityManagerInterface $em,
private TaskGenerator $generator,
) {
}
public function create(TaskSchemaRequest $req): void
{
if ($req->repeat === null) {
$task = new Task();
$task->setName($req->name);
$task->setDate($req->date);
$task->setStatus($req->taskStatus);
$this->em->persist($task);
$this->em->flush();
return;
}
$schema = new TaskSchema();
$schema->setName($req->name);
$schema->setStatus($req->status);
$schema->setTaskStatus($req->taskStatus);
$schema->setDate($req->date);
$schema->setRepeat($req->repeat);
$schema->setStart($req->start);
$schema->setEnd($req->end);
$this->em->persist($schema);
$this->em->flush();
if ($schema->getStatus() === TaskSchemaStatus::Inactive) {
return;
}
$this->generator->generateTasks($schema);
$this->em->flush();
}
public function update(TaskSchema $schema, TaskSchemaRequest $req): void
{
$schema->setName($req->name);
$schema->setStatus($req->status);
$schema->setTaskStatus($req->taskStatus);
$schema->setDate($req->date);
$schema->setRepeat($req->repeat);
$schema->setStart($req->start);
$schema->setEnd($req->end);
if ($schema->getStatus() === TaskSchemaStatus::Inactive) {
$this->em->flush();
return;
}
$this->generator->removeTasks($schema);
$this->generator->generateTasks($schema);
$this->em->flush();
}
public function delete(TaskSchema $schema): void
{
$this->generator->removeTasks($schema);
$this->em->remove($schema);
$this->em->flush();
}
}