From dde4ec4217f273e974db7f4cc80c0c87074927e8 Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Fri, 16 Jan 2026 10:34:29 +0100 Subject: [PATCH] =?UTF-8?q?```=20=E2=9C=A8=20feat(search):=20Ajoute=20EsyS?= =?UTF-8?q?earch=20pour=20la=20recherche=20globale=20dans=20le=20CRM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute le service EsySearch, initialise l'index des admins et crée une page de recherche unifiée. Active PWA en prod. ``` --- .env | 2 +- assets/admin.scss | 14 ++ assets/app.scss | 1 + composer.json | 1 + composer.lock | 161 +++++++++++++++++- config/packages/http_discovery.yaml | 10 ++ config/packages/pwa.yaml | 1 + src/Command/SearchCommand.php | 49 ++++++ src/Controller/Dashboard/SearchController.php | 66 +++++++ src/Service/Search/Client.php | 131 ++++++++++++++ symfony.lock | 12 ++ templates/base.twig | 4 +- templates/dashboard/base.twig | 92 +++++----- templates/dashboard/search.twig | 64 +++++++ 14 files changed, 558 insertions(+), 50 deletions(-) create mode 100644 config/packages/http_discovery.yaml create mode 100644 src/Command/SearchCommand.php create mode 100644 src/Controller/Dashboard/SearchController.php create mode 100644 src/Service/Search/Client.php create mode 100644 templates/dashboard/search.twig diff --git a/.env b/.env index 4598d60..154c066 100644 --- a/.env +++ b/.env @@ -90,4 +90,4 @@ MINIO_S3_CLIENT_ID= MINIO_S3_CLIENT_SECRET= MINIO_S3_CLIENT_BUCKET= -ESY_SEARCH_KEY= +ESY_SEARCH_KEY=b09d9a708b427d495c39fe6e8fc5361fe33fee57a0435f3e1bf3ed8155f2a277 diff --git a/assets/admin.scss b/assets/admin.scss index ed69e06..5657766 100644 --- a/assets/admin.scss +++ b/assets/admin.scss @@ -9,3 +9,17 @@ form { .animate-fadeIn { animation: fadeIn 0.3s ease-in-out; } @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } +.custom-scrollbar::-webkit-scrollbar { width: 5px; height: 5px; } +.custom-scrollbar::-webkit-scrollbar-track { background: transparent; } +.custom-scrollbar::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; } +.dark .custom-scrollbar::-webkit-scrollbar-thumb { background: #334155; } + +.page-transition { animation: fadeIn 0.3s ease-out; } +@keyframes fadeIn { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Menu Accordion sans JS */ +details summary::-webkit-details-marker { display:none; } +details[open] .arrow-icon { transform: rotate(180deg); } diff --git a/assets/app.scss b/assets/app.scss index f1d8c73..3d552a6 100644 --- a/assets/app.scss +++ b/assets/app.scss @@ -1 +1,2 @@ @import "tailwindcss"; + diff --git a/composer.json b/composer.json index c7e5aaa..7257fe5 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,7 @@ "league/flysystem-bundle": "^3.6.1", "liip/imagine-bundle": "^2.15", "lufiipe/insee-sierene": ">=1", + "meilisearch/meilisearch-php": "^1.16", "minishlink/web-push": "^9.0.4", "mittwald/vault-php": "^3.0.2", "mobiledetect/mobiledetectlib": "^4.8.10", diff --git a/composer.lock b/composer.lock index 6830229..cfe966e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6cc6334636ac6b9f69ebd4f4736d69f4", + "content-hash": "f476989731711ededed94397298a39d6", "packages": [ { "name": "async-aws/core", @@ -5768,6 +5768,86 @@ }, "time": "2022-12-02T22:17:43+00:00" }, + { + "name": "meilisearch/meilisearch-php", + "version": "v1.16.1", + "source": { + "type": "git", + "url": "https://github.com/meilisearch/meilisearch-php.git", + "reference": "f9f63e0e7d12ffaae54f7317fa8f4f4dfa8ae7b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/meilisearch/meilisearch-php/zipball/f9f63e0e7d12ffaae54f7317fa8f4f4dfa8ae7b6", + "reference": "f9f63e0e7d12ffaae54f7317fa8f4f4dfa8ae7b6", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.4 || ^8.0", + "php-http/discovery": "^1.7", + "psr/http-client": "^1.0", + "symfony/polyfill-php81": "^1.33" + }, + "require-dev": { + "http-interop/http-factory-guzzle": "^1.2.0", + "php-cs-fixer/shim": "^3.59.3", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.5 || ^10.5", + "symfony/http-client": "^5.4|^6.0|^7.0" + }, + "suggest": { + "guzzlehttp/guzzle": "Use Guzzle ^7 as HTTP client", + "http-interop/http-factory-guzzle": "Factory for guzzlehttp/guzzle", + "symfony/http-client": "Use Symfony Http client" + }, + "type": "library", + "autoload": { + "psr-4": { + "MeiliSearch\\": "src/", + "Meilisearch\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Clémentine Urquizar", + "email": "clementine@meilisearch.com" + }, + { + "name": "Bruno Casali", + "email": "bruno@meilisearch.com" + }, + { + "name": "Laurent Cazanove", + "email": "lau.cazanove@gmail.com" + }, + { + "name": "Tomas Norkūnas", + "email": "norkunas.tom@gmail.com" + } + ], + "description": "PHP wrapper for the Meilisearch API", + "keywords": [ + "api", + "client", + "instant", + "meilisearch", + "php", + "search" + ], + "support": { + "issues": "https://github.com/meilisearch/meilisearch-php/issues", + "source": "https://github.com/meilisearch/meilisearch-php/tree/v1.16.1" + }, + "time": "2025-09-18T10:15:45+00:00" + }, { "name": "meyfa/php-svg", "version": "v0.9.1", @@ -6720,6 +6800,85 @@ ], "time": "2025-11-17T21:16:04+00:00" }, + { + "name": "php-http/discovery", + "version": "1.20.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/discovery.git", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "nyholm/psr7": "<1.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", + "sebastian/comparator": "^3.0.5 || ^4.0.8", + "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", + "keywords": [ + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" + ], + "support": { + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.20.0" + }, + "time": "2024-10-02T11:20:13+00:00" + }, { "name": "phpdocumentor/reflection-common", "version": "2.2.0", diff --git a/config/packages/http_discovery.yaml b/config/packages/http_discovery.yaml new file mode 100644 index 0000000..2a789e7 --- /dev/null +++ b/config/packages/http_discovery.yaml @@ -0,0 +1,10 @@ +services: + Psr\Http\Message\RequestFactoryInterface: '@http_discovery.psr17_factory' + Psr\Http\Message\ResponseFactoryInterface: '@http_discovery.psr17_factory' + Psr\Http\Message\ServerRequestFactoryInterface: '@http_discovery.psr17_factory' + Psr\Http\Message\StreamFactoryInterface: '@http_discovery.psr17_factory' + Psr\Http\Message\UploadedFileFactoryInterface: '@http_discovery.psr17_factory' + Psr\Http\Message\UriFactoryInterface: '@http_discovery.psr17_factory' + + http_discovery.psr17_factory: + class: Http\Discovery\Psr17Factory diff --git a/config/packages/pwa.yaml b/config/packages/pwa.yaml index 8a2067a..f9eb1dd 100644 --- a/config/packages/pwa.yaml +++ b/config/packages/pwa.yaml @@ -1,4 +1,5 @@ pwa: + image_processor: 'pwa.image_processor.gd' favicons: enabled: true diff --git a/src/Command/SearchCommand.php b/src/Command/SearchCommand.php new file mode 100644 index 0000000..8071cd1 --- /dev/null +++ b/src/Command/SearchCommand.php @@ -0,0 +1,49 @@ +client->init(); + + $accounts = $this->entityManager->getRepository(Account::class)->findAll(); + + foreach ($accounts as $account) { + // ON IGNORE LES COMPTES AYANT LE RÔLE ROLE_ROOT + if (in_array('ROLE_ROOT', $account->getRoles(), true)) { + continue; + } + + $datas = [ + 'id' => $account->getId(), + 'name' => $account->getName(), + 'surname' => $account->getFirstName(), + 'email' => $account->getEmail(), + ]; + + $this->client->indexDocuments($datas, 'admin'); + } + + $output->writeln('Indexation terminée (hors ROOT).'); + + return Command::SUCCESS; + } +} diff --git a/src/Controller/Dashboard/SearchController.php b/src/Controller/Dashboard/SearchController.php new file mode 100644 index 0000000..549223d --- /dev/null +++ b/src/Controller/Dashboard/SearchController.php @@ -0,0 +1,66 @@ + false], methods: ['GET'])] + public function crmSearch( + AccountRepository $accountRepository, + Client $client, + Request $request + ): Response { + $query = $request->query->get('q', ''); + $unifiedResults = []; + + if (!empty($query)) { + $response = $client->searchGlobal($query, 20); + + foreach ($response['results'] as $resultGroup) { + // On vérifie si l'index correspond aux administrateurs + if (str_contains($resultGroup['indexUid'], 'intranet_ludikevent_admin')) { + + // Extraction des IDs pour éviter les requêtes en boucle + $ids = array_map(fn($h) => $h['id'], $resultGroup['hits']); + $accounts = $accountRepository->findBy(['id' => $ids]); + + foreach ($accounts as $account) { + $unifiedResults[] = [ + 'title' => $account->getName() . " " . $account->getFirstName(), + 'subtitle' => $account->getEmail(), + 'link' => $this->generateUrl('app_crm_administrateur_view', ['id' => $account->getId()]), + 'type' => 'Administrateur', + 'id' => $account->getId(), + 'initials' => strtoupper(substr($account->getName(), 0, 1) . substr($account->getFirstName(), 0, 1)) + ]; + } + } + } + } + + return $this->render('dashboard/search.twig', [ + 'results' => $unifiedResults, + 'query' => $query + ]); + } +} diff --git a/src/Service/Search/Client.php b/src/Service/Search/Client.php new file mode 100644 index 0000000..66a735f --- /dev/null +++ b/src/Service/Search/Client.php @@ -0,0 +1,131 @@ +env = $_ENV['APP_ENV'] ?? 'dev'; + + // Connexion au serveur Meilisearch + $this->client = new MeilisearchClient( + "https://tools-meilisearch.esy-web.dev", + $_ENV['ESY_SEARCH_KEY'] + ); + } + + /** + * Retourne la configuration centrale des index et de leurs filtres. + */ + private function getIndexConfig(): array + { + return [ + "intranet_ludikevent_admin" => [], + + ]; + } + + /** + * Initialise les index avec préfixe et configure les attributs filtrables. + */ + public function init(): void + { + $config = $this->getIndexConfig(); + + foreach ($config as $indexName => $filterableAttributes) { + $fullName = $this->env . "_" . $indexName; + + try { + $this->client->createIndex($fullName, ['primaryKey' => 'id']); + } catch (\Exception $e) { + // L'index existe déjà + } + + // Met à jour les réglages de filtrage + $this->client->index($fullName)->updateFilterableAttributes($filterableAttributes); + } + } + + /** + * Recherche globale dans TOUS les index configurés (Multi-search). + * @param string $query Le terme recherché + * @param int $limitPerIndex Nombre max de résultats par catégorie + */ + public function searchGlobal(string $query, int $limitPerIndex = 5): array + { + $queries = []; + $indexNames = array_keys($this->getIndexConfig()); + + foreach ($indexNames as $indexName) { + $searchQuery =new SearchQuery(); + $searchQuery->setQuery($query); + $searchQuery->setLimit($limitPerIndex); + $searchQuery->setIndexUid($this->env . "_" . $indexName); + $queries[] = $searchQuery; + } + + // Effectue une seule requête pour interroger tous les index + return $this->client->multiSearch($queries); + } + + /** + * Recherche classique dans un index spécifique. + */ + public function search(string $key, string $query, array $options = []): array + { + $fullName = $this->env . "_" . $key; + return $this->client->index($fullName)->search($query, $options)->toArray(); + } + + /** + * Ajoute ou met à jour des documents (Supporte un lot ou un document seul). + */ + public function indexDocuments(array $documents, string $key): void + { + if (empty($documents)) return; + + $fullName = $this->env . "_intranet_ludikevent_" . $key; + + // Si on envoie un seul document (non-multidimensionnel) + if (!isset($documents[0])) { + $documents = [$documents]; + } + + $this->client->index($fullName)->addDocuments($documents); + } + + /** + * Récupère un document précis. + */ + public function getDocument(string $key, $id): array + { + $fullName = $this->env . "_" . $key; + return $this->client->index($fullName)->getDocument($id); + } + + /** + * Supprime un document par son ID. + */ + public function deleteDocument(string $key, $id): void + { + $fullName = $this->env . "_" . $key; + $this->client->index($fullName)->deleteDocument($id); + } + + /** + * Supprime un index complet. + */ + public function deleteIndex(string $key): void + { + $fullName = $this->env . "_" . $key; + $this->client->deleteIndex($fullName); + } +} diff --git a/symfony.lock b/symfony.lock index f833676..0282f7a 100644 --- a/symfony.lock +++ b/symfony.lock @@ -112,6 +112,18 @@ "config/packages/nelmio_security.yaml" ] }, + "php-http/discovery": { + "version": "1.20", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "1.18", + "ref": "f45b5dd173a27873ab19f5e3180b2f661c21de02" + }, + "files": [ + "config/packages/http_discovery.yaml" + ] + }, "phpstan/phpstan": { "version": "2.1", "recipe": { diff --git a/templates/base.twig b/templates/base.twig index 7189371..2e88109 100644 --- a/templates/base.twig +++ b/templates/base.twig @@ -10,7 +10,9 @@ {{ vite_asset('app.js', []) }} - {{ pwa() }} + {% if app.environment != 'dev' %} + {{ pwa(swAttributes={ 'nonce': csp_nonce('script') }) }} + {% endif %} {# Le corps aura un fond gris clair pour correspondre au fond du logo #} diff --git a/templates/dashboard/base.twig b/templates/dashboard/base.twig index dac23b6..8012c87 100644 --- a/templates/dashboard/base.twig +++ b/templates/dashboard/base.twig @@ -5,27 +5,18 @@ {% block title %}Administration{% endblock %} — Intranet Ludikevent {{ vite_asset('admin.js', {}) }} - {{ pwa(swAttributes={ 'nonce': csp_nonce('script') }) }} -
- {# SIDEBAR MODERNE #} -