diff --git a/src/Entity/BookStackConfiguration.php b/src/Entity/BookStackConfiguration.php index 48300fe..5bc546e 100644 --- a/src/Entity/BookStackConfiguration.php +++ b/src/Entity/BookStackConfiguration.php @@ -6,6 +6,7 @@ namespace App\Entity; use App\Repository\BookStackConfigurationRepository; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Validator\Constraints as Assert; #[ORM\Entity(repositoryClass: BookStackConfigurationRepository::class)] class BookStackConfiguration @@ -16,6 +17,7 @@ class BookStackConfiguration private ?int $id = null; #[ORM\Column(length: 255, nullable: true)] + #[Assert\Url] private ?string $url = null; #[ORM\Column(type: 'text', nullable: true)] diff --git a/src/Entity/ClientTicket.php b/src/Entity/ClientTicket.php index e4daf47..54a4d7e 100644 --- a/src/Entity/ClientTicket.php +++ b/src/Entity/ClientTicket.php @@ -19,6 +19,7 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; +use Symfony\Component\Validator\Constraints as Assert; #[ApiResource( operations: [ @@ -54,6 +55,27 @@ use Symfony\Component\Serializer\Attribute\Groups; )] class ClientTicket { + public const string TYPE_BUG = 'bug'; + public const string TYPE_IMPROVEMENT = 'improvement'; + public const string TYPE_OTHER = 'other'; + + public const array TYPES = [ + self::TYPE_BUG, + self::TYPE_IMPROVEMENT, + self::TYPE_OTHER, + ]; + + public const string STATUS_NEW = 'new'; + public const string STATUS_IN_PROGRESS = 'in_progress'; + public const string STATUS_DONE = 'done'; + public const string STATUS_REJECTED = 'rejected'; + + public const array STATUSES = [ + self::STATUS_NEW, + self::STATUS_IN_PROGRESS, + self::STATUS_DONE, + self::STATUS_REJECTED, + ]; #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] @@ -66,6 +88,7 @@ class ClientTicket #[ORM\Column(length: 20)] #[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])] + #[Assert\Choice(choices: self::TYPES)] private ?string $type = null; #[ORM\Column(length: 255)] @@ -78,10 +101,12 @@ class ClientTicket #[ORM\Column(length: 255, nullable: true)] #[Groups(['client_ticket:read', 'client_ticket:write'])] + #[Assert\Url] private ?string $url = null; #[ORM\Column(length: 20)] #[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])] + #[Assert\Choice(choices: self::STATUSES)] private ?string $status = 'new'; #[ORM\Column(type: 'text', nullable: true)] diff --git a/src/Entity/GiteaConfiguration.php b/src/Entity/GiteaConfiguration.php index 0469ac5..45345ad 100644 --- a/src/Entity/GiteaConfiguration.php +++ b/src/Entity/GiteaConfiguration.php @@ -6,6 +6,7 @@ namespace App\Entity; use App\Repository\GiteaConfigurationRepository; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Validator\Constraints as Assert; #[ORM\Entity(repositoryClass: GiteaConfigurationRepository::class)] class GiteaConfiguration @@ -16,6 +17,7 @@ class GiteaConfiguration private ?int $id = null; #[ORM\Column(length: 255, nullable: true)] + #[Assert\Url] private ?string $url = null; #[ORM\Column(type: 'text', nullable: true)] diff --git a/src/Entity/TaskBookStackLink.php b/src/Entity/TaskBookStackLink.php index 5bac0e5..34b3f01 100644 --- a/src/Entity/TaskBookStackLink.php +++ b/src/Entity/TaskBookStackLink.php @@ -7,6 +7,7 @@ namespace App\Entity; use App\Repository\TaskBookStackLinkRepository; use DateTimeImmutable; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Validator\Constraints as Assert; #[ORM\Entity(repositoryClass: TaskBookStackLinkRepository::class)] #[ORM\UniqueConstraint(name: 'UNIQ_task_bookstack_link', columns: ['task_id', 'bookstack_id', 'bookstack_type'])] @@ -31,6 +32,7 @@ class TaskBookStackLink private string $title; #[ORM\Column(length: 500)] + #[Assert\Url] private string $url; #[ORM\Column] diff --git a/src/Repository/ClientTicketRepository.php b/src/Repository/ClientTicketRepository.php index 37815b0..9ca3726 100644 --- a/src/Repository/ClientTicketRepository.php +++ b/src/Repository/ClientTicketRepository.php @@ -20,18 +20,26 @@ class ClientTicketRepository extends ServiceEntityRepository } /** - * Returns the next ticket number for a project, using a row-level lock + * Returns the max ticket number for a project, using an advisory lock * to prevent race conditions when creating tickets concurrently. */ - public function findNextNumberForProjectForUpdate(Project $project): int + public function findMaxNumberByProjectForUpdate(Project $project): int { $conn = $this->getEntityManager()->getConnection(); + // Use PostgreSQL advisory lock instead of FOR UPDATE + // because FOR UPDATE is not allowed with aggregate functions in PostgreSQL. + // Offset by 1000000 to avoid collision with task locks on the same project ID. + $conn->executeStatement( + 'SELECT pg_advisory_xact_lock(:lockKey)', + ['lockKey' => $project->getId() + 1000000], + ); + $result = $conn->fetchOne( - 'SELECT COALESCE(MAX(number), 0) FROM client_ticket WHERE project_id = :project FOR UPDATE', + 'SELECT COALESCE(MAX(number), 0) FROM client_ticket WHERE project_id = :project', ['project' => $project->getId()], ); - return ((int) $result) + 1; + return (int) $result; } } diff --git a/src/Repository/TaskRepository.php b/src/Repository/TaskRepository.php index 99857a5..5922bcc 100644 --- a/src/Repository/TaskRepository.php +++ b/src/Repository/TaskRepository.php @@ -20,13 +20,20 @@ class TaskRepository extends ServiceEntityRepository } /** - * Returns the max task number for a project, using a row-level lock + * Returns the max task number for a project, using an advisory lock * to prevent race conditions when creating tasks concurrently. */ public function findMaxNumberByProjectForUpdate(Project $project): int { $conn = $this->getEntityManager()->getConnection(); + // Use PostgreSQL advisory lock (project ID as lock key) instead of FOR UPDATE + // because FOR UPDATE is not allowed with aggregate functions in PostgreSQL. + $conn->executeStatement( + 'SELECT pg_advisory_xact_lock(:project)', + ['project' => $project->getId()], + ); + $result = $conn->fetchOne( 'SELECT COALESCE(MAX(number), 0) FROM task WHERE project_id = :project', ['project' => $project->getId()], diff --git a/src/State/ClientTicketNumberProcessor.php b/src/State/ClientTicketNumberProcessor.php index f96e4ba..49bebcc 100644 --- a/src/State/ClientTicketNumberProcessor.php +++ b/src/State/ClientTicketNumberProcessor.php @@ -53,7 +53,8 @@ final readonly class ClientTicketNumberProcessor implements ProcessorInterface $now = new DateTimeImmutable(); - $data->setNumber($this->clientTicketRepository->findNextNumberForProjectForUpdate($project)); + $maxNumber = $this->clientTicketRepository->findMaxNumberByProjectForUpdate($project); + $data->setNumber($maxNumber + 1); $data->setSubmittedBy($user); $data->setStatus('new'); $data->setCreatedAt($now);