Repository: dataforgoodfr/13_potentiel_solaire
Pull request #145: [FRONT] feat: update search with code postal queries (#144)
Author: login: jbuiquan
Created: 2025-03-25T22:49:09Z | Merged: 2025-03-27T20:29:33Z

Description

Github issue : https://github.com/dataforgoodfr/13_potentiel_solaire/issues/144

Cette PR a pour objectif de permettre la recherche par code postal pour le moteur de recherche. Les résultats établissements remonteront également le code postal.

La recherche par code postal récupère les communes dont le code postal commence par la query saisie par l'utilisateur.
On utilise une table de référence qui fait correspondre les codes postaux aux code communes (code insee).

### Comment tester ?
1. Mettre à jour la base duckdb pour avoir la nouvelle table
   1a. En executant le sql ajouté sur cette PR (recommandé)
   ou
   1b. Refaire le processus de création de la base depuis le début
2. Tester l'api :

Recherche :
`curl --request GET \
  --url 'http://localhost:3000/api/search?query=330'` => doit remonter la commune de Bordeaux

`curl --request GET \
  --url 'http://localhost:3000/api/search?query=exuper'` => doit remonter les établissements comme Saint Exupéry et avoir le code postal dans la propriété `extra_data`

### Pour faciliter la validation de ma PR
- [x] J'ai pris connaissance et respecté les [règles de contribution](../CONTRIBUTING.md)
- [x] Les pre-commit passent
- [x] Les test unitaires passent
- [x] Le code modifié fonctionne en local
- [x] J'ai demandé une peer-review à un autre bénévole du projet
- [x] J'ai ajouté / mis à jour de la documentation sur [outline](https://outline.services.dataforgood.fr/collection/13_potentiel_scolaire-qJFjGnz5Ec)

Closes #144 

Linked issue #144: [Front] API pour la recherche par code postal
Feature: [Moteur de recherche](https://airtable.com/app7nJqWg4cJ3qd2c/shryMuNGjfepaiTaS/tblTZWixsQgIMk38X/viwhEF8FMvDxjQLHA/recvdnEu2ucpqWe0l)

Objectif:
Définir une API qui permet de retourner les résultats du moteur de recherche via code postal.

Une commune peut correspondre à plusieurs codes postaux et l'inverse est vrai également.
On ajoutera donc cette fonctionnalité dans un second temps.
Linked issue comments: user: login: jbuiquan
created_at: 2025-03-25T22:09:00Z
body: On va se baser sur la [base des codes postaux](https://www.data.gouv.fr/fr/datasets/base-officielle-des-codes-postaux/) pour faire le lien entre code insee et code postal.
Cela nécessitera la création d'une table supplémentaire (code_insee, code_postal).
author_association: COLLABORATOR

Diff:
diff --git a/application/app/api/search/route.ts b/application/app/api/search/route.ts
index 0e72e1e6..9084d512 100644
--- a/application/app/api/search/route.ts
+++ b/application/app/api/search/route.ts
@@ -4,6 +4,7 @@ import { fetchSearchResults } from '@/app/lib/data';
 
 /**
  * Get search results from a query.
+ * If the query is a code postal, the results will be limited to communes.
  * @param request
  * @returns
  */
diff --git a/application/app/lib/data.ts b/application/app/lib/data.ts
index 573c123a..6b448689 100644
--- a/application/app/lib/data.ts
+++ b/application/app/lib/data.ts
@@ -9,7 +9,7 @@ import {
 } from '../models/etablissements';
 import { RegionFeature } from '../models/regions';
 import { SearchResult } from '../models/search';
-import { sanitizeString } from '../utils/string-utils';
+import { isCodePostal, sanitizeString } from '../utils/string-utils';
 import db from './duckdb';
 
 // --- Etablissements ---
@@ -102,6 +102,16 @@ export async function fetchEtablissementsGeoJSON(
 						e.type_etablissement,
 						'libelle_nature',
 						e.libelle_nature,
+						'adresse_1',
+						e.adresse_1,
+						'adresse_2',
+						e.adresse_2,
+						'adresse_3',
+						e.adresse_3,
+						'code_postal',
+						e.code_postal,
+						'nb_eleves',
+						e.nb_eleves,
 						'code_commune',
 						e.code_commune,
 						'nom_commune',
@@ -163,6 +173,16 @@ export async function fetchEtablissementGeoJSONById(
 					e.type_etablissement,
 					'libelle_nature',
 					e.libelle_nature,
+					'adresse_1',
+					e.adresse_1,
+					'adresse_2',
+					e.adresse_2,
+					'adresse_3',
+					e.adresse_3,
+					'code_postal',
+					e.code_postal,
+					'nb_eleves',
+					e.nb_eleves,
 					'code_commune',
 					e.code_commune,
 					'nom_commune',
@@ -521,6 +541,7 @@ const DEFAULT_LIMIT = 10;
 
 /**
  * Fetch results from the search view.
+ * If the query is a code postal, the results will be limited to communes.
  * @param query
  * @param limit
  * @returns
@@ -532,16 +553,31 @@ export async function fetchSearchResults(
 	try {
 		const connection = await db.connect();
 
-		const prepared = await connection.prepare(
-			`
-		SELECT source_table as source, id, libelle, extra_data
-		FROM main.search_view sv
-		WHERE sv.sanitized_libelle like $1
-		ORDER BY sv.libelle
-		LIMIT $2;
-		`,
-		);
-		prepared.bindVarchar(1, `%${sanitizeString(query).toLowerCase()}%`);
+		let prepared;
+		if (isCodePostal(query)) {
+			prepared = await connection.prepare(
+				`
+			SELECT sv.source_table as source, sv.id, sv.libelle, sv.extra_data
+			FROM ref_code_postal refCp
+			INNER JOIN search_view sv ON sv.source_table = 'communes' AND sv.id = refCp.code_insee
+			WHERE refCp.code_postal like $1
+			ORDER BY sv.libelle
+			LIMIT $2;
+			`,
+			);
+			prepared.bindVarchar(1, `${query}%`);
+		} else {
+			prepared = await connection.prepare(
+				`
+			SELECT source_table as source, id, libelle, extra_data
+			FROM main.search_view sv
+			WHERE sv.sanitized_libelle like $1
+			ORDER BY sv.libelle
+			LIMIT $2;
+			`,
+			);
+			prepared.bindVarchar(1, `%${sanitizeString(query).toLowerCase()}%`);
+		}
 		prepared.bindInteger(2, limit);
 
 		const reader = await prepared.runAndReadAll();
diff --git a/application/app/models/etablissements.ts b/application/app/models/etablissements.ts
index 97c39d8f..e4ce70c8 100644
--- a/application/app/models/etablissements.ts
+++ b/application/app/models/etablissements.ts
@@ -3,6 +3,11 @@ export interface EtablissementProperties {
 	nom_etablissement: string;
 	type_etablissement: string;
 	libelle_nature: string;
+	adresse_1: string | null;
+	adresse_2: string | null;
+	adresse_3: string | null;
+	code_postal: string;
+	nb_eleves: number | null;
 	code_commune: string;
 	nom_commune: string;
 	code_departement: string;
diff --git a/application/app/utils/string-utils.ts b/application/app/utils/string-utils.ts
index 6dc98743..50892388 100644
--- a/application/app/utils/string-utils.ts
+++ b/application/app/utils/string-utils.ts
@@ -20,3 +20,10 @@ export function sanitizeString(libelle: string) {
 	// Step 3: Replace multiple spaces with a single space
 	return sanitized.replace(/\s{2,}/g, ' ').trim();
 }
+
+/**
+ * Returns true if the string matches the pattern for a code postal (i.e : only digits).
+ * @param query
+ * @returns
+ */
+export const isCodePostal = (query: string) => /^\d+$/.test(query);
diff --git a/application/database/prepare-JDD-test.sql b/application/database/prepare-JDD-test.sql
index 13df4e3a..1a8be16e 100644
--- a/application/database/prepare-JDD-test.sql
+++ b/application/database/prepare-JDD-test.sql
@@ -70,6 +70,11 @@ CREATE OR REPLACE TABLE etablissements AS
 	nom_etablissement,
 	type_etablissement,
 	libelle_nature,
+	adresse_1,
+	adresse_2,
+	adresse_3,
+	code_postal,
+	nombre_d_eleves AS nb_eleves,
 	code_commune,
 	nom_commune,
 	code_departement,
@@ -141,20 +146,25 @@ FROM (
 ) AS communeEtablissements
 WHERE c.code_commune = communeEtablissements.code_commune;
 
+-- create reference table between code_postal and code_insee
+-- source : https://www.data.gouv.fr/fr/datasets/base-officielle-des-codes-postaux/
+CREATE OR REPLACE TABLE ref_code_postal AS
+SELECT DISTINCT ON(code_insee, code_postal) "#Code_commune_INSEE" AS code_insee, Code_postal AS code_postal
+FROM read_parquet('https://object.files.data.gouv.fr/hydra-parquet/hydra-parquet/54535896c301b2b3928f8db6305309ad.parquet'); -- 0.7s
+
 -- create materialized view with a common libelle column on every tables
 -- On nettoie le libellé dans la colonne sanitized_libelle :
 -- 1. suppression des accents
 -- 2. remplacement de la ponctuation par des espaces
 -- 3. suppression des doublons d'espaces
 -- 4. conversion en lowercase.
--- TODO: add code_postal in etablissement extra_data
 CREATE OR REPLACE TABLE search_view AS 
 SELECT 
 'etablissements' AS source_table, 
 identifiant_de_l_etablissement as id, 
 nom_etablissement AS libelle, 
 lower(regexp_replace(regexp_replace(strip_accents(nom_etablissement), '[^\w\s]', ' ', 'g'), '[\s]{2,}', ' ', 'g')) AS sanitized_libelle, 
-to_json(struct_pack(nom_commune)) AS extra_data 
+to_json(struct_pack(nom_commune, code_postal)) AS extra_data 
 FROM etablissements
 UNION ALL
 SELECT 


Reviews:
user: login: OctaveLT
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-03-27T10:18:09Z
user: login: jbuiquan
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-27T11:35:32Z
user: login: OctaveLT
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-27T18:54:23Z

Review comments:
diff_hunk: @@ -3,6 +3,11 @@ export interface EtablissementProperties {
 	nom_etablissement: string;
 	type_etablissement: string;
 	libelle_nature: string;
+	adresse_1: string | null;
+	adresse_2: string | null;
+	adresse_3: string | null;
+	code_postal: string;
+	nombre_d_eleves: number | null;
path: application/app/models/etablissements.ts
user: login: OctaveLT
body: plutot nombre_eleve ? Il ny a pas besoin de determinant dans les clefs, non ?

(pareil pour la db)
created_at: 2025-03-27T10:17:30Z
author_association: COLLABORATOR
diff_hunk: @@ -3,6 +3,11 @@ export interface EtablissementProperties {
 	nom_etablissement: string;
 	type_etablissement: string;
 	libelle_nature: string;
+	adresse_1: string | null;
+	adresse_2: string | null;
+	adresse_3: string | null;
+	code_postal: string;
+	nombre_d_eleves: number | null;
path: application/app/models/etablissements.ts
user: login: jbuiquan
body: J ai gardé le nom de la colonne de l annuaire des etablissements tel quel. C était pour pas me prendre la  tête.
created_at: 2025-03-27T11:35:32Z
author_association: COLLABORATOR

PR comments:
user: login: jbuiquan
created_at: 2025-03-25T22:52:15Z
body: J'ai rajouté au passage les informations suivantes depuis l'annuaire des établissements :
- code_postal
- nb d'eleves
- adresse 1,2,3

Related to #133 
author_association: COLLABORATOR
user: login: jbuiquan
created_at: 2025-03-26T07:22:21Z
body: Il manque la création d index sur le code postal / code insee. Également la deduplication des lignes du référentiel.
author_association: COLLABORATOR
user: login: jbuiquan
created_at: 2025-03-26T18:43:44Z
body: J'ai géré la déduplication du JDD de test, on rajoutera les index plus tard. La PR est prête
author_association: COLLABORATOR