fix(backend) : add validation constraints and fix concurrent numbering

- Add Assert\Choice on ClientTicket type and status with typed constants
- Add Assert\Url on GiteaConfiguration, BookStackConfiguration, TaskBookStackLink, ClientTicket
- Fix concurrent task/ticket numbering: use pg_advisory_xact_lock instead of FOR UPDATE with MAX()
- Wrap CreateTaskTool numbering in transaction
- Harmonize repository contracts: both return max number, caller adds +1

Tickets: T-004, T-008, T-011, T-012

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-03-17 15:23:56 +01:00
parent ed58a402b0
commit ff7cff1d39
7 changed files with 53 additions and 6 deletions

View File

@@ -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;
}
}

View File

@@ -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()],