Recherche d'adresses : base LOCALE (Postgres ERPNext) au lieu du Supabase cloud externe
Le hub n'appelle plus rddrjzptzhypltuzmere.supabase.co. La base RQA + dispo fibre est DÉJÀ locale dans le Postgres ERPNext (rqa_addresses 5,24M + fiber_availability 21,6k jointes par address_id), le hub y accède (réseau erpnext_erpnext + module pg). - NOUVEAU lib/address-db.js : recherche locale. Phase 1 (civique présent) = filtre numero btree + mots de rue → ~20-150 ms ; Phase 2 (sans civique) = word_similarity (`<%` indexable GIN) au lieu de similarity() plein (24-76 s sur 5,24M !) → ~700 ms, dans une txn SET LOCAL (seuil 0.6 + statement_timeout 4s). Renvoie 2 formes : searchLocal (mappée, compat historique) + searchRaw (colonnes brutes de la fonction). - address-search.js : searchAddresses + searchAddressesRpc délèguent à address-db (plus aucun appel Supabase). → onboarding (/address/validate), checkout (/api/address-search) ET le pont (géocodage) passent en LOCAL. - address-validate.js : endpoints PUBLICS pour le site web (CORS) — POST /rpc/search_addresses (compat Supabase RPC, tableau direct) + GET /address/search — servis depuis le PG local (fiber_available inclus). - server.js : route /rpc/ → address-validate. Résultat pont (vérifié) : couverture 112/125 (vs 109 via Supabase), rqa_geocode 8→25 (table locale plus complète + search_text désaccentué), Mapbox 37→23, no_coords 16→13, 0 erreur. Le local est meilleur. Env hub : ADDR_DB_* dans /opt/targo-hub/.env (défauts erpnext-db-1/_eb65bdc0c4b1b2d6). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b6831a1e48
commit
a510ac3848
124
services/targo-hub/lib/address-db.js
Normal file
124
services/targo-hub/lib/address-db.js
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
'use strict'
|
||||||
|
/**
|
||||||
|
* address-db.js — recherche d'adresses LOCALE (Postgres ERPNext), remplace le Supabase cloud externe.
|
||||||
|
*
|
||||||
|
* Source : `rqa_addresses` (5,24M adresses du Québec, search_text désaccentué, index trigram GIN)
|
||||||
|
* LEFT JOIN `fiber_availability` (couverture fibre Targo, joint par address_id) — toutes deux DÉJÀ
|
||||||
|
* locales dans la db ERPNext `_eb65bdc0c4b1b2d6`. Le hub est sur le réseau erpnext_erpnext + a `pg`.
|
||||||
|
*
|
||||||
|
* Deux phases (comme l'autocomplete de dispo) :
|
||||||
|
* Phase 1 — civique présent : filtre `numero` (btree) + mots de rue (LIKE), tri fibre→J0L/J0S→similarité. ~20 ms.
|
||||||
|
* Phase 2 — sans civique / phase 1 vide : `word_similarity` (`<%`, indexable GIN), tri idem. ~700 ms.
|
||||||
|
* (NB : le `%`/similarity() plein sur 5,24M = 24-76 s → on utilise `<%` qui est sélectif et indexé.)
|
||||||
|
*
|
||||||
|
* Retourne une forme compatible avec l'ancien Supabase (adresse_formatee, numero_municipal,
|
||||||
|
* odonyme_recompose_normal, nom_municipalite, latitude, longitude, fiber_available, zone_tarifaire,
|
||||||
|
* max_speed, similarity_score) → consommateurs hub inchangés (pont, onboarding, checkout, site).
|
||||||
|
*/
|
||||||
|
const { Pool } = require('pg')
|
||||||
|
const { log } = require('./helpers')
|
||||||
|
|
||||||
|
const norm = (s) => (s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/\s+/g, ' ').trim()
|
||||||
|
|
||||||
|
let _pool
|
||||||
|
function pool () {
|
||||||
|
if (!_pool) {
|
||||||
|
_pool = new Pool({
|
||||||
|
host: process.env.ADDR_DB_HOST || 'erpnext-db-1',
|
||||||
|
port: +(process.env.ADDR_DB_PORT || 5432),
|
||||||
|
user: process.env.ADDR_DB_USER || 'postgres',
|
||||||
|
password: process.env.ADDR_DB_PASS || '123',
|
||||||
|
database: process.env.ADDR_DB_NAME || '_eb65bdc0c4b1b2d6',
|
||||||
|
max: 4, idleTimeoutMillis: 30000, connectionTimeoutMillis: 6000,
|
||||||
|
})
|
||||||
|
_pool.on('error', (e) => log('address-db pool error:', e.message))
|
||||||
|
}
|
||||||
|
return _pool
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLS = `a.id, a.address_full, a.numero, a.rue, a.ville, a.code_postal, a.longitude, a.latitude,
|
||||||
|
(f.id IS NOT NULL) AS fiber_available, COALESCE(f.zone_tarifaire,0) AS zone_tarifaire, COALESCE(f.max_speed,0) AS max_speed`
|
||||||
|
const ORDER = `(f.id IS NOT NULL) DESC, (a.code_postal LIKE 'J0L%' OR a.code_postal LIKE 'J0S%') DESC, sim DESC`
|
||||||
|
|
||||||
|
function mapRow (r) {
|
||||||
|
return {
|
||||||
|
identifiant_unique_adresse: String(r.id),
|
||||||
|
adresse_formatee: r.address_full || [r.numero, r.rue].filter(Boolean).join(' ') + (r.ville ? ', ' + r.ville : '') + (r.code_postal ? ' ' + r.code_postal : ''),
|
||||||
|
numero_municipal: r.numero,
|
||||||
|
numero_unite: null,
|
||||||
|
code_postal: r.code_postal,
|
||||||
|
odonyme_recompose_normal: r.rue,
|
||||||
|
nom_municipalite: r.ville,
|
||||||
|
latitude: r.latitude,
|
||||||
|
longitude: r.longitude,
|
||||||
|
fiber_available: !!r.fiber_available,
|
||||||
|
zone_tarifaire: r.zone_tarifaire,
|
||||||
|
max_speed: r.max_speed,
|
||||||
|
similarity_score: r.sim != null ? +r.sim : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forme BRUTE (colonnes de la fonction search_addresses : id, address_full, numero, rue, ville,
|
||||||
|
// code_postal, longitude, latitude, fiber_available, zone_tarifaire, max_speed, similarity_score)
|
||||||
|
// → consommée telle quelle par l'autocomplete du site web (endpoint /rpc/search_addresses).
|
||||||
|
function toRaw (r) {
|
||||||
|
return {
|
||||||
|
id: r.id, address_full: r.address_full, numero: r.numero, rue: r.rue, ville: r.ville,
|
||||||
|
code_postal: r.code_postal, longitude: r.longitude, latitude: r.latitude,
|
||||||
|
fiber_available: !!r.fiber_available, zone_tarifaire: r.zone_tarifaire, max_speed: r.max_speed,
|
||||||
|
similarity_score: r.sim != null ? +r.sim : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recherche principale (lignes brutes DB). term = texte tapé ("2338 rue rené-vinet" ou "rené-vinet ...").
|
||||||
|
async function searchRows (term, limit = 8) {
|
||||||
|
const clean = norm(term)
|
||||||
|
if (clean.length < 3) return []
|
||||||
|
const lim = Math.min(Math.max(parseInt(limit) || 8, 1), 25)
|
||||||
|
const civic = (clean.match(/^\s*(\d+)/) || [])[1] || null
|
||||||
|
const streetPart = clean.replace(/^\s*\d+\s*/, '').trim()
|
||||||
|
const words = streetPart ? streetPart.split(' ').filter(w => w.length >= 2) : []
|
||||||
|
const p = pool()
|
||||||
|
|
||||||
|
// ── Phase 1 : civique présent (rapide, btree numero) ──
|
||||||
|
if (civic) {
|
||||||
|
const sql = `SELECT ${COLS}, similarity(a.search_text, $3) AS sim
|
||||||
|
FROM rqa_addresses a LEFT JOIN fiber_availability f ON f.address_id = a.id
|
||||||
|
WHERE a.numero = $1
|
||||||
|
AND (array_length($2::text[], 1) IS NULL OR NOT EXISTS (
|
||||||
|
SELECT 1 FROM unnest($2::text[]) w WHERE a.search_text NOT LIKE '%' || w || '%'))
|
||||||
|
ORDER BY ${ORDER}, a.numero
|
||||||
|
LIMIT $4`
|
||||||
|
const r = await p.query(sql, [civic, words, clean, lim])
|
||||||
|
if (r.rows.length) return r.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 2 : word_similarity (sans civique, ou phase 1 vide) ──
|
||||||
|
// `<%` (indexable GIN) au lieu de `%`/similarity() plein (24-76 s sur 5,24M). Seuil + statement_timeout
|
||||||
|
// posés en SET LOCAL dans une transaction (déterministe + auto-reset au COMMIT ; borne le pire cas).
|
||||||
|
const sql2 = `SELECT ${COLS}, word_similarity($1, a.search_text) AS sim
|
||||||
|
FROM rqa_addresses a LEFT JOIN fiber_availability f ON f.address_id = a.id
|
||||||
|
WHERE $1 <% a.search_text
|
||||||
|
ORDER BY ${ORDER}
|
||||||
|
LIMIT $2`
|
||||||
|
const client = await p.connect()
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN')
|
||||||
|
await client.query('SET LOCAL pg_trgm.word_similarity_threshold = 0.6') // ~700 ms ; 0.5 plus large/lent
|
||||||
|
await client.query('SET LOCAL statement_timeout = 4000') // garde-fou anti-hang (sécurité)
|
||||||
|
const r2 = await client.query(sql2, [clean, lim])
|
||||||
|
await client.query('COMMIT')
|
||||||
|
return r2.rows
|
||||||
|
} catch (e) {
|
||||||
|
try { await client.query('ROLLBACK') } catch (_) {}
|
||||||
|
log('searchRows phase2:', e.message)
|
||||||
|
return []
|
||||||
|
} finally { client.release() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forme MAPPÉE (compat Supabase historique) → consommée par les services hub (pont, onboarding, checkout).
|
||||||
|
async function searchLocal (term, limit = 8) { return (await searchRows(term, limit)).map(mapRow) }
|
||||||
|
// Forme BRUTE (colonnes de la fonction) → consommée par l'autocomplete du site web.
|
||||||
|
async function searchRaw (term, limit = 8) { return (await searchRows(term, limit)).map(toRaw) }
|
||||||
|
|
||||||
|
module.exports = { searchLocal, searchRaw, pool, norm }
|
||||||
|
|
@ -1,65 +1,24 @@
|
||||||
'use strict'
|
'use strict'
|
||||||
const { httpRequest } = require('./helpers')
|
/**
|
||||||
|
* address-search.js — façade de recherche d'adresses.
|
||||||
const SUPABASE_URL = 'https://rddrjzptzhypltuzmere.supabase.co'
|
*
|
||||||
const SUPABASE_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InJkZHJqenB0emh5cGx0dXptZXJlIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA4MTY4NTYsImV4cCI6MjA4NjM5Mjg1Nn0.EluFlKBze8BYM6AFx88G7kt21EvR18EI3uw1zgCXVzs'
|
* Depuis 2026-06 la source est 100% LOCALE (Postgres ERPNext : rqa_addresses + fiber_availability,
|
||||||
|
* via ./address-db) — PLUS aucune dépendance au Supabase cloud externe. Les noms d'export restent
|
||||||
function wordsToIlike (str) {
|
* stables pour les consommateurs existants : address-validate (/address/*), checkout (/api/address-search),
|
||||||
const words = str.split(/\s+/).filter(w => w.length >= 2)
|
* legacy-dispatch-sync (géocodage du pont).
|
||||||
if (!words.length) return ''
|
*/
|
||||||
return '*' + words.map(w => encodeURIComponent(w)).join('*') + '*'
|
const { searchLocal } = require('./address-db')
|
||||||
}
|
|
||||||
|
|
||||||
|
// Recherche d'adresses — forme historique (adresse_formatee, numero_municipal, numero_unite,
|
||||||
|
// odonyme_recompose_normal, nom_municipalite, code_postal, latitude, longitude,
|
||||||
|
// identifiant_unique_adresse, fiber_available, zone_tarifaire, max_speed, similarity_score).
|
||||||
async function searchAddresses (term, limit = 8) {
|
async function searchAddresses (term, limit = 8) {
|
||||||
const clean = term.trim()
|
try { return await searchLocal(term, limit) } catch (e) { return [] }
|
||||||
if (clean.length < 3) return []
|
|
||||||
|
|
||||||
const numMatch = clean.match(/^\s*(\d+)\s*(.*)/)
|
|
||||||
const headers = { apikey: SUPABASE_KEY, Authorization: 'Bearer ' + SUPABASE_KEY }
|
|
||||||
const select = 'adresse_formatee,numero_municipal,numero_unite,code_postal,odonyme_recompose_normal,nom_municipalite,latitude,longitude,identifiant_unique_adresse'
|
|
||||||
const base = `${SUPABASE_URL}/rest/v1/addresses?select=${select}&limit=${limit}`
|
|
||||||
|
|
||||||
let results = []
|
|
||||||
|
|
||||||
if (numMatch) {
|
|
||||||
const num = numMatch[1]
|
|
||||||
const street = numMatch[2].trim()
|
|
||||||
let url = `${base}&numero_municipal=eq.${num}`
|
|
||||||
if (street) url += `&odonyme_recompose_normal=ilike.${wordsToIlike(street)}`
|
|
||||||
url += '&order=nom_municipalite'
|
|
||||||
const res = await httpRequest(url, '', { headers })
|
|
||||||
results = Array.isArray(res.data) ? res.data : []
|
|
||||||
|
|
||||||
if (!results.length && num.length >= 2) {
|
|
||||||
let url2 = `${base}&numero_municipal=like.${num}*`
|
|
||||||
if (street) url2 += `&odonyme_recompose_normal=ilike.${wordsToIlike(street)}`
|
|
||||||
url2 += '&order=nom_municipalite'
|
|
||||||
const res2 = await httpRequest(url2, '', { headers })
|
|
||||||
results = Array.isArray(res2.data) ? res2.data : []
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const pattern = wordsToIlike(clean)
|
|
||||||
if (!pattern) return []
|
|
||||||
const url = `${base}&odonyme_recompose_normal=ilike.${pattern}&order=nom_municipalite`
|
|
||||||
const res = await httpRequest(url, '', { headers })
|
|
||||||
results = Array.isArray(res.data) ? res.data : []
|
|
||||||
}
|
|
||||||
|
|
||||||
return results.map(a => ({ ...a, fiber_available: false }))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RECHERCHE TRIGRAM (RPC Postgres `search_addresses`) — bien plus robuste que l'ilike ci-dessus :
|
// Alias historique (anciennement la RPC trigram Supabase) → désormais la même recherche locale.
|
||||||
// phase 1 = numéro civique + mots de rue (sur odonyme normal/court/LONG[avec générique]/municipalité/CP),
|
|
||||||
// phase 2 = trigram complet (`%`, pg_trgm). Priorise les CP J0L/J0S (territoire). Renvoie aussi
|
|
||||||
// fiber_available/zone_tarifaire/max_speed/similarity_score. C'est ce qui propulse l'autocomplete de dispo.
|
|
||||||
async function searchAddressesRpc (term, limit = 8) {
|
async function searchAddressesRpc (term, limit = 8) {
|
||||||
const clean = (term || '').trim()
|
return searchLocal(term, limit)
|
||||||
if (clean.length < 3) return []
|
|
||||||
const headers = { apikey: SUPABASE_KEY, Authorization: 'Bearer ' + SUPABASE_KEY, 'Content-Type': 'application/json' }
|
|
||||||
const res = await httpRequest(`${SUPABASE_URL}/rest/v1/rpc/search_addresses`, '', {
|
|
||||||
method: 'POST', body: JSON.stringify({ search_term: clean, result_limit: limit }), headers,
|
|
||||||
})
|
|
||||||
return Array.isArray(res.data) ? res.data : []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { searchAddresses, searchAddressesRpc, wordsToIlike }
|
module.exports = { searchAddresses, searchAddressesRpc }
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,16 @@
|
||||||
|
|
||||||
const { json, parseBody, log } = require('./helpers')
|
const { json, parseBody, log } = require('./helpers')
|
||||||
const { searchAddresses } = require('./address-search')
|
const { searchAddresses } = require('./address-search')
|
||||||
|
const { searchLocal, searchRaw } = require('./address-db')
|
||||||
|
|
||||||
|
// CORS pour l'endpoint public /address/search (appelé par le site web depuis un autre domaine).
|
||||||
|
// Données publiques (adresses RQA + dispo fibre) → ouverture large, lecture seule.
|
||||||
|
function cors (res) {
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=60')
|
||||||
|
}
|
||||||
|
|
||||||
// Normalize for fuzzy comparison: lowercase, strip diacritics, collapse
|
// Normalize for fuzzy comparison: lowercase, strip diacritics, collapse
|
||||||
// whitespace, drop punctuation. Used to score how close a typed address
|
// whitespace, drop punctuation. Used to score how close a typed address
|
||||||
|
|
@ -65,6 +75,38 @@ function scoreMatch (typed, rqaRow) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handle (req, res, method, path) {
|
async function handle (req, res, method, path) {
|
||||||
|
// POST /rpc/search_addresses — COMPAT Supabase RPC (forme attendue par l'autocomplete du site web :
|
||||||
|
// body { search_term, result_limit } → tableau direct de lignes { id, address_full, numero, rue, ville,
|
||||||
|
// code_postal, longitude, latitude, fiber_available, zone_tarifaire, max_speed, similarity_score }).
|
||||||
|
// Sert depuis la base LOCALE → on débranche le Supabase cloud (juste basculer VITE_API_BASE côté site).
|
||||||
|
if (path === '/rpc/search_addresses') {
|
||||||
|
if (method === 'OPTIONS') { cors(res); res.writeHead(204); res.end(); return }
|
||||||
|
if (method === 'POST') {
|
||||||
|
const body = await parseBody(req)
|
||||||
|
const term = (body.search_term || body.q || '').toString().trim()
|
||||||
|
const limit = body.result_limit || body.limit || 8
|
||||||
|
let rows = []
|
||||||
|
try { rows = await searchRaw(term, limit) } catch (e) { log('rpc/search_addresses:', e.message) }
|
||||||
|
cors(res)
|
||||||
|
return json(res, 200, rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /address/search?q=&limit= — autocomplete PUBLIC (site web) : adresses + disponibilité fibre,
|
||||||
|
// depuis la base LOCALE (rqa_addresses + fiber_availability). Remplace l'appel direct au Supabase.
|
||||||
|
if (path.startsWith('/address/search')) {
|
||||||
|
if (method === 'OPTIONS') { cors(res); res.writeHead(204); res.end(); return }
|
||||||
|
if (method === 'GET') {
|
||||||
|
const u = new URL(req.url, 'http://localhost')
|
||||||
|
const q = (u.searchParams.get('q') || '').trim()
|
||||||
|
const limit = u.searchParams.get('limit') || 8
|
||||||
|
let results = []
|
||||||
|
try { results = await searchLocal(q, limit) } catch (e) { log('address/search error:', e.message) }
|
||||||
|
cors(res)
|
||||||
|
return json(res, 200, { ok: true, query: q, count: results.length, results })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// POST /address/validate — score-rank RQA results for a free-text address.
|
// POST /address/validate — score-rank RQA results for a free-text address.
|
||||||
if (path === '/address/validate' && method === 'POST') {
|
if (path === '/address/validate' && method === 'POST') {
|
||||||
const body = await parseBody(req)
|
const body = await parseBody(req)
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ const server = http.createServer(async (req, res) => {
|
||||||
if (path.startsWith('/auth/')) return auth.handle(req, res, method, path, url)
|
if (path.startsWith('/auth/')) return auth.handle(req, res, method, path, url)
|
||||||
if (path.startsWith('/conversations')) return conversation.handle(req, res, method, path, url)
|
if (path.startsWith('/conversations')) return conversation.handle(req, res, method, path, url)
|
||||||
if (path.startsWith('/traccar')) return traccar.handle(req, res, method, path)
|
if (path.startsWith('/traccar')) return traccar.handle(req, res, method, path)
|
||||||
if (path.startsWith('/address/')) return require('./lib/address-validate').handle(req, res, method, path)
|
if (path.startsWith('/address/') || path.startsWith('/rpc/')) return require('./lib/address-validate').handle(req, res, method, path)
|
||||||
if (path.startsWith('/magic-link')) return require('./lib/magic-link').handle(req, res, method, path)
|
if (path.startsWith('/magic-link')) return require('./lib/magic-link').handle(req, res, method, path)
|
||||||
if (path.startsWith('/portal/')) return require('./lib/portal-auth').handle(req, res, method, path)
|
if (path.startsWith('/portal/')) return require('./lib/portal-auth').handle(req, res, method, path)
|
||||||
// Lightweight tech mobile page: /t/{token}[/action]
|
// Lightweight tech mobile page: /t/{token}[/action]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user