refactor : simplify codebase and fix critical issues

Backend:
- Add MCP Serializer to centralize entity-to-array conversion (~300 lines deduped)
- Fix race condition in task/ticket number generation (SELECT FOR UPDATE + transaction)
- Add unique constraint on task (project_id, number) with migration
- Fix MIME type validation: use server-detected finfo instead of client-supplied type
- Add allowlist of permitted MIME types for uploads
- Fix TaskDocumentDownloadController: allow ROLE_CLIENT access, add priority:1
- Fix notification sent even when ticket status unchanged
- Remove redundant exception constructors
- Simplify services (BookStackApi double fetch, TokenEncryptor, GiteaApi)
- Consolidate duplicate checks in processors

Frontend:
- Fix useApi isHandlingUnauthorized scope (module-level to prevent double 401 redirect)
- Fix client-tickets toast key copy-paste bug
- Merge duplicated tasks service methods (getByProject + getByProjectArchived)
- Extract shared uploadWithRelation helper in task-documents service
- Extract formatFileSize utility from duplicated component code
- Extract status transition logic into useClientTicketHelpers composable
- Remove dead code (unused router, handleLogout, empty script blocks)
- Merge duplicate watchers and onMounted calls
- Normalize arrow functions to function declarations per convention

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 22:09:16 +01:00
parent a5144443a4
commit e4fc34b90f
52 changed files with 662 additions and 569 deletions

View File

@@ -59,7 +59,7 @@ final class BookStackApiService
* Search for pages and books within a specific shelf.
*
* Algorithm:
* 1. Fetch the shelf's book IDs
* 1. Fetch the shelf data (book IDs + slugs)
* 2. Run two search queries (one for pages, one for books)
* 3. Filter results: pages must belong to a book on the shelf, books must be on the shelf
*
@@ -67,17 +67,27 @@ final class BookStackApiService
*/
public function searchInShelf(int $shelfId, string $query): array
{
$bookIds = $this->getShelfBookIds($shelfId);
$shelfData = $this->request('GET', sprintf('/api/shelves/%d', $shelfId));
$books = $shelfData['books'] ?? [];
if (empty($bookIds)) {
if (empty($books)) {
return [];
}
$bookIds = array_map(static fn (array $book): int => $book['id'], $books);
$bookSlugs = [];
foreach ($books as $book) {
$bookSlugs[$book['id']] = $book['slug'] ?? '';
}
// Update cache for getShelfBookIds
$this->shelfBookCache[$shelfId] = $bookIds;
$config = $this->getConfiguration();
$baseUrl = rtrim($config->getUrl() ?? '', '/');
$trimmed = trim($query);
// BookStack search API accepts {type:X} for one type at a time run two queries
// BookStack search API accepts {type:X} for one type at a time -- run two queries
$pageResults = $this->request('GET', '/api/search', [
'query' => ['query' => $trimmed.' {type:page}', 'count' => 50],
]);
@@ -87,13 +97,6 @@ final class BookStackApiService
$allResults = array_merge($pageResults['data'] ?? [], $bookResults['data'] ?? []);
// Build a map of bookId → bookSlug for URL construction
$shelfData = $this->request('GET', sprintf('/api/shelves/%d', $shelfId));
$bookSlugs = [];
foreach ($shelfData['books'] ?? [] as $book) {
$bookSlugs[$book['id']] = $book['slug'] ?? '';
}
$filtered = [];
foreach ($allResults as $item) {
$type = $item['type'] ?? '';
@@ -101,23 +104,20 @@ final class BookStackApiService
if ('page' === $type) {
$bookId = $item['book_id'] ?? 0;
if (in_array($bookId, $bookIds, true)) {
$bookSlug = $bookSlugs[$bookId] ?? '';
$filtered[] = [
'id' => $item['id'],
'type' => 'page',
'name' => $item['name'] ?? '',
'url' => $baseUrl.'/books/'.$bookSlug.'/page/'.$item['slug'],
];
}
} elseif ('book' === $type) {
if (in_array($item['id'], $bookIds, true)) {
$filtered[] = [
'id' => $item['id'],
'type' => 'book',
'name' => $item['name'] ?? '',
'url' => $baseUrl.'/books/'.$item['slug'],
'url' => $baseUrl.'/books/'.($bookSlugs[$bookId] ?? '').'/page/'.$item['slug'],
];
}
} elseif ('book' === $type && in_array($item['id'], $bookIds, true)) {
$filtered[] = [
'id' => $item['id'],
'type' => 'book',
'name' => $item['name'] ?? '',
'url' => $baseUrl.'/books/'.$item['slug'],
];
}
}

View File

@@ -126,9 +126,10 @@ final readonly class GiteaApiService
$regex = sprintf('#^[^/]+/%s($|-.+)#', preg_quote($taskCode, '#'));
return array_values(array_filter($allBranches, static function (array $branch) use ($regex): bool {
return 1 === preg_match($regex, $branch['name']);
}));
return array_values(array_filter(
$allBranches,
static fn (array $branch): bool => 1 === preg_match($regex, $branch['name']),
));
}
/**

View File

@@ -52,12 +52,12 @@ final readonly class NotificationService
return;
}
$number = sprintf('CT-%03d', $ticket->getNumber());
$statusLabel = $ticket->getStatus();
$message = 'Nouveau statut : '.$statusLabel;
$number = sprintf('CT-%03d', $ticket->getNumber());
$statusComment = $ticket->getStatusComment();
$message = 'Nouveau statut : '.$ticket->getStatus();
if (null !== $ticket->getStatusComment() && '' !== $ticket->getStatusComment()) {
$message .= ' — '.$ticket->getStatusComment();
if (null !== $statusComment && '' !== $statusComment) {
$message .= ' — '.$statusComment;
}
$notification = new Notification();

View File

@@ -17,31 +17,10 @@ final class TokenEncryptor
#[Autowire('%env(ENCRYPTION_KEY)%')]
string $encryptionKey,
) {
if ('' === $encryptionKey) {
$this->key = '';
$this->configured = false;
$key = $this->tryDecodeKey($encryptionKey);
return;
}
try {
$key = sodium_hex2bin($encryptionKey);
} catch (SodiumException) {
$this->key = '';
$this->configured = false;
return;
}
if (SODIUM_CRYPTO_SECRETBOX_KEYBYTES !== mb_strlen($key, '8bit')) {
$this->key = '';
$this->configured = false;
return;
}
$this->key = $key;
$this->configured = true;
$this->key = $key ?? '';
$this->configured = null !== $key;
}
public function encrypt(string $plaintext): string
@@ -71,6 +50,25 @@ final class TokenEncryptor
return $plaintext;
}
private function tryDecodeKey(string $encryptionKey): ?string
{
if ('' === $encryptionKey) {
return null;
}
try {
$key = sodium_hex2bin($encryptionKey);
} catch (SodiumException) {
return null;
}
if (SODIUM_CRYPTO_SECRETBOX_KEYBYTES !== mb_strlen($key, '8bit')) {
return null;
}
return $key;
}
private function assertConfigured(): void
{
if (!$this->configured) {