From 44730eefca5b6fc68e3adedceda2af391f8ef062 Mon Sep 17 00:00:00 2001 From: PTA Date: Fri, 21 Aug 2026 13:50:05 +0200 Subject: [PATCH] =?UTF-8?q?Ajout=20des=20v=C3=A9rifications=20des=20types?= =?UTF-8?q?=20dans=20les=20formulaires=20(adresses,=20email,=20telephone)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/search_address.php | 20 +++ app/Controllers/AddressController.php | 78 +++++++++ app/Controllers/AgentController.php | 23 ++- app/Controllers/PageController.php | 4 +- app/Core/ContactFormat.php | 85 ++++++++++ app/Services/AddressApiService.php | 178 ++++++++++++++++++++ app/Views/layouts/footer.php | 6 +- app/Views/layouts/header.php | 2 +- app/Views/pages/role.php | 2 +- app/Views/partials/faq.php | 2 + app/Views/partials/manage_agents.php | 35 +++- assets/js/address-autocomplete.js | 232 ++++++++++++++++++++++++++ assets/js/administration.js | 4 + assets/js/field-format.js | 136 +++++++++++++++ assets/style.css | 108 ++++++++++++ 15 files changed, 898 insertions(+), 17 deletions(-) create mode 100644 api/search_address.php create mode 100644 app/Controllers/AddressController.php create mode 100644 app/Core/ContactFormat.php create mode 100644 app/Services/AddressApiService.php create mode 100644 assets/js/address-autocomplete.js create mode 100644 assets/js/field-format.js diff --git a/api/search_address.php b/api/search_address.php new file mode 100644 index 0000000..68364e0 --- /dev/null +++ b/api/search_address.php @@ -0,0 +1,20 @@ +search(); diff --git a/app/Controllers/AddressController.php b/app/Controllers/AddressController.php new file mode 100644 index 0000000..d5bb7c7 --- /dev/null +++ b/app/Controllers/AddressController.php @@ -0,0 +1,78 @@ +access = new Access($pdo); + } + + /** + * GET api/search_address.php — propositions d'adresses. + * Paramètres GET : q (saisie en cours), limit (facultatif, 1 à 8). + */ + public function search(): void + { + $this->access->requireService(); + + $query = trim((string) ($_GET['q'] ?? '')); + $length = function_exists('mb_strlen') ? mb_strlen($query, 'UTF-8') : strlen($query); + if ($length < AddressApiService::MIN_QUERY_LENGTH) { + JsonResponse::send(422, [ + 'error' => sprintf('Saisissez au moins %d caractères.', AddressApiService::MIN_QUERY_LENGTH), + ]); + } + // Plafond de saisie : la BAN n'exploite pas au-delà, et cela borne le + // coût d'une requête forgée. + if ($length > 120) { + JsonResponse::send(422, ['error' => 'Saisie trop longue pour une recherche d’adresse.']); + } + + $limit = filter_var($_GET['limit'] ?? 5, FILTER_VALIDATE_INT); + if ($limit === false) { + $limit = 5; + } + + try { + $suggestions = (new AddressApiService())->search($query, $limit); + } catch (Throwable $e) { + JsonResponse::send(502, [ + 'error' => 'Le service national des adresses est momentanément indisponible. Vous pouvez saisir l’adresse manuellement.', + 'details' => (getenv('PTA_DEBUG') ?: '0') !== '0' ? $e->getMessage() : null, + ]); + } + + JsonResponse::send(200, [ + 'query' => $query, + 'suggestions' => $suggestions, + ]); + } +} diff --git a/app/Controllers/AgentController.php b/app/Controllers/AgentController.php index 04fd394..c8983b7 100644 --- a/app/Controllers/AgentController.php +++ b/app/Controllers/AgentController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Controllers; use App\Core\Access; +use App\Core\ContactFormat; use App\Core\Csrf; use App\Core\Journal; use App\Core\JsonResponse; @@ -484,8 +485,15 @@ final class AgentController if ($rate === false || $rate <= 0 || $rate > 100) { JsonResponse::send(422, ['error' => 'La quotité de travail doit être comprise entre 1 et 100 %.']); } - if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) === false) { - JsonResponse::send(422, ['error' => 'L’adresse e-mail saisie n’est pas valide.']); + // Coordonnées FACULTATIVES : seule une valeur renseignée est contrôlée. + // Les règles de forme vivent dans ContactFormat, communes à create() et + // update(), et recopiées dans assets/js/field-format.js pour le retour + // immédiat à la saisie. + if ($email !== '' && !ContactFormat::isEmail($email)) { + JsonResponse::send(422, ['error' => 'L’adresse e-mail saisie n’est pas valide. Format attendu : prenom.nom@exemple.fr']); + } + if ($telephone !== '' && !ContactFormat::isPhone($telephone)) { + JsonResponse::send(422, ['error' => 'Le numéro de téléphone saisi n’est pas valide. Format attendu : 06 12 34 56 78 ou +33 6 12 34 56 78.']); } if (strlen($matricule) > 50 || strlen($nom) > 100 || strlen($prenom) > 100 || strlen($email) > 255 || strlen($telephone) > 30 || strlen($adresse) > 255 || strlen($diplomeLibelle) > 150 || strlen($ptaComment) > 1000) { @@ -624,8 +632,15 @@ final class AgentController || ($contractEnd !== '' && (!$contractEndDate || $contractEndDate->format('Y-m-d') !== $contractEnd || $contractEndDate < $contractStartDate))) { JsonResponse::send(422, ['error' => 'Le poste, le type de contrat et les dates du contrat doivent être valides.']); } - if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) === false) { - JsonResponse::send(422, ['error' => 'L’adresse e-mail saisie n’est pas valide.']); + // Coordonnées FACULTATIVES : seule une valeur renseignée est contrôlée. + // Les règles de forme vivent dans ContactFormat, communes à create() et + // update(), et recopiées dans assets/js/field-format.js pour le retour + // immédiat à la saisie. + if ($email !== '' && !ContactFormat::isEmail($email)) { + JsonResponse::send(422, ['error' => 'L’adresse e-mail saisie n’est pas valide. Format attendu : prenom.nom@exemple.fr']); + } + if ($telephone !== '' && !ContactFormat::isPhone($telephone)) { + JsonResponse::send(422, ['error' => 'Le numéro de téléphone saisi n’est pas valide. Format attendu : 06 12 34 56 78 ou +33 6 12 34 56 78.']); } if (strlen($nom) > 100 || strlen($prenom) > 100 || strlen($email) > 255 || strlen($telephone) > 30 || strlen($adresse) > 255 || strlen($diplomeLibelle) > 150 || strlen($ptaComment) > 1000) { diff --git a/app/Controllers/PageController.php b/app/Controllers/PageController.php index 5329f4c..100e3bc 100644 --- a/app/Controllers/PageController.php +++ b/app/Controllers/PageController.php @@ -289,8 +289,8 @@ final class PageController extends Controller 'agents' => $agentModel->allActive(), 'structures' => (new StructureModel($this->pdo))->allActive(), 'posts' => $agentModel->allPosts(), - 'scripts' => ['administration.js'], - 'modules' => ['administration'], + 'scripts' => ['administration.js', 'address-autocomplete.js', 'field-format.js'], + 'modules' => ['administration', 'addressAutocomplete', 'fieldFormat'], ]); } diff --git a/app/Core/ContactFormat.php b/app/Core/ContactFormat.php new file mode 100644 index 0000000..ce5c3e7 --- /dev/null +++ b/app/Core/ContactFormat.php @@ -0,0 +1,85 @@ +> Propositions normalisées, les + * plus pertinentes d'abord (ordre de score rendu par la BAN). + * + * @throws RuntimeException Saisie trop courte, ou API injoignable. + */ + public function search(string $query, int $limit = 5): array + { + $query = trim(preg_replace('/\s+/u', ' ', $query) ?? $query); + $length = function_exists('mb_strlen') ? mb_strlen($query, 'UTF-8') : strlen($query); + if ($length < self::MIN_QUERY_LENGTH) { + throw new RuntimeException('Saisissez au moins ' . self::MIN_QUERY_LENGTH . ' caractères.'); + } + + $limit = max(1, min(self::MAX_LIMIT, $limit)); + $url = self::ENDPOINT . '?' . http_build_query([ + 'q' => $query, + 'limit' => $limit, + 'autocomplete' => 1, + ], '', '&', PHP_QUERY_RFC3986); + + $payload = $this->fetchJson($url); + $features = $payload['features'] ?? null; + if (!is_array($features)) { + throw new RuntimeException('Réponse inattendue de l’API Adresse.'); + } + + $suggestions = []; + foreach ($features as $feature) { + $suggestion = $this->normalizeFeature($feature); + if ($suggestion !== null) { + $suggestions[] = $suggestion; + } + } + + return $suggestions; + } + + /** + * Convertit une « feature » GeoJSON de la BAN en proposition exploitable. + * + * `label` est l'adresse complète telle qu'elle sera écrite dans la fiche + * agent ; `nom` et `contexte` ne servent qu'à l'affichage de la liste. + * Une feature sans label est ignorée : sans elle il n'y a rien à insérer. + * + * @return array|null + */ + private function normalizeFeature(mixed $feature): ?array + { + if (!is_array($feature) || !is_array($feature['properties'] ?? null)) { + return null; + } + $properties = $feature['properties']; + $label = trim((string) ($properties['label'] ?? '')); + if ($label === '') { + return null; + } + + return [ + 'label' => $label, + 'nom' => trim((string) ($properties['name'] ?? '')), + 'code_postal' => trim((string) ($properties['postcode'] ?? '')), + 'commune' => trim((string) ($properties['city'] ?? '')), + 'contexte' => trim((string) ($properties['context'] ?? '')), + // housenumber | street | locality | municipality : une proposition + // « municipality » n'est qu'une commune, pas une adresse précise. + 'type' => trim((string) ($properties['type'] ?? '')), + ]; + } + + /** + * Requête HTTP + décodage JSON, sur le modèle de SchoolHolidayApiService : + * cURL quand l'extension est là, repli sur file_get_contents() sinon. + * + * Délais VOLONTAIREMENT COURTS (4 s de connexion, 8 s au total) : l'appel + * est déclenché à la frappe, un utilisateur ne patiente pas devant une + * liste de suggestions. Mieux vaut renoncer que faire attendre. + * + * @return array + * + * @throws RuntimeException Erreur réseau, code HTTP >= 400, ou JSON invalide. + */ + private function fetchJson(string $url): array + { + $body = false; + $status = 0; + + if (function_exists('curl_init')) { + $curl = curl_init($url); + if ($curl === false) { + throw new RuntimeException('Impossible d’initialiser cURL.'); + } + curl_setopt_array($curl, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_CONNECTTIMEOUT => 4, + CURLOPT_TIMEOUT => 8, + CURLOPT_ENCODING => '', + CURLOPT_HTTPHEADER => ['Accept: application/json', 'User-Agent: PTA/7.26'], + ]); + $body = curl_exec($curl); + $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE); + $error = curl_error($curl); + curl_close($curl); + if ($body === false) { + throw new RuntimeException($error ?: 'Erreur réseau cURL.'); + } + } else { + $context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'timeout' => 8, + 'ignore_errors' => true, + 'header' => "Accept: application/json\r\nUser-Agent: PTA/7.26\r\n", + ], + ]); + $body = @file_get_contents($url, false, $context); + if ($body === false) { + throw new RuntimeException('La lecture d’URL distante est désactivée sur le serveur PHP. Activez cURL ou allow_url_fopen.'); + } + foreach ($http_response_header ?? [] as $header) { + if (preg_match('/^HTTP\/\S+\s+(\d{3})/', $header, $matches)) { + $status = (int) $matches[1]; + break; + } + } + } + + if ($status >= 400) { + throw new RuntimeException('L’API Adresse a répondu avec le code HTTP ' . $status . '.'); + } + + $data = json_decode((string) $body, true); + if (!is_array($data)) { + throw new RuntimeException('Le contenu reçu depuis l’API Adresse n’est pas un JSON valide.'); + } + return $data; + } +} diff --git a/app/Views/layouts/footer.php b/app/Views/layouts/footer.php index ccb33da..5229d28 100644 --- a/app/Views/layouts/footer.php +++ b/app/Views/layouts/footer.php @@ -51,10 +51,10 @@ window.PTA_PAGE_CONFIG = $pageConfig, ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>; - + - + - + diff --git a/app/Views/layouts/header.php b/app/Views/layouts/header.php index 692316a..b0802e1 100644 --- a/app/Views/layouts/header.php +++ b/app/Views/layouts/header.php @@ -104,7 +104,7 @@ $showHelpTab = in_array('faq', $allowedPages, true); <?= htmlspecialchars($pageTitle) ?> — PTA - +
diff --git a/app/Views/pages/role.php b/app/Views/pages/role.php index d8dcb4c..dc9eab4 100644 --- a/app/Views/pages/role.php +++ b/app/Views/pages/role.php @@ -43,7 +43,7 @@ $titre = $titres[$etape] ?? 'Connexion'; <?= htmlspecialchars($titre) ?> — PTA - +
diff --git a/app/Views/partials/faq.php b/app/Views/partials/faq.php index 3ee37db..1141ad9 100644 --- a/app/Views/partials/faq.php +++ b/app/Views/partials/faq.php @@ -151,6 +151,8 @@ $sommaire = [
  • Dates de contrat : information RH. Attention, la date de début ne décale pas l’année de référence, qui reste l’année civile complète.
  • Qualification et diplôme : le champ « Diplôme » ne s’active que si l’agent est déclaré diplômé. Cette information alimente le contrôle du taux de diplômés dans les équipes.
  • Droit à l’enveloppe de formation de 14 h : coché par défaut. Décoché, le motif « Formation » ne sera pas proposé pour cet agent lors de la saisie.
  • +
  • E-mail et téléphone : facultatifs, mais contrôlés au format dès la saisie. Le téléphone accepte les séparateurs habituels (06 12 34 56 78, 06.12.34.56.78, +33 6 12 34 56 78) ; une saisie mal formée est signalée sous le champ et bloque l’enregistrement.
  • +
  • Adresse personnelle : facultative et purement informative. Dès trois caractères saisis, une liste d’adresses officielles est proposée sous le champ (source : Base Adresse Nationale) — cliquez sur la bonne, ou continuez à écrire librement si l’adresse n’y figure pas.
  • Observations utiles à la construction du PTA : disponibilités particulières, organisation connue… Ces notes se retrouvent sur l’écran « PTA détaillé ».
  • Cliquez sur Ajouter l’agent.
  • diff --git a/app/Views/partials/manage_agents.php b/app/Views/partials/manage_agents.php index 2373ce0..6787c60 100644 --- a/app/Views/partials/manage_agents.php +++ b/app/Views/partials/manage_agents.php @@ -13,6 +13,17 @@ * * L'ordre est celui du travail réel : on crée un agent, puis on consulte ou * corrige sa fiche dans le tableau qui suit. + * + * V7.25 : les deux champs d'adresse (création et fiche) sont enveloppés dans un + * conteneur `[data-address-autocomplete]` que le module `addressAutocomplete` + * branche sur la Base Adresse Nationale. La structure attendue est fixe — + * champ + `[data-address-suggestions]` + `[data-address-status]` — mais aucun + * identifiant n'est connu du module. + * + * Même principe pour l'e-mail et le téléphone : `data-format="email|phone"` sur + * le champ et un `[data-format-status]` frère suffisent au module `fieldFormat` + * à contrôler la saisie. Ce contrôle ne fait que PRÉVENIR : c'est + * App\Core\ContactFormat qui refuse pour de bon, côté serveur. */ ?>
    @@ -66,11 +77,13 @@