'use strict' // Address validation against the RQA (Répertoire des adresses du Québec). // // Why this exists: every Service Location in ERPNext should have its lat/lng // derived from the official Quebec civic address registry rather than from // human typing or Mapbox geocode. Free-text geocoding is error-prone for // rural areas (we just hit a case where the SL coords pointed 9 km away // from the real address). RQA is the authoritative source — updated every // 2 weeks, includes a stable `identifiant_unique_adresse` per civic address. // // This module exposes ONE public route: // POST /address/validate // body: { address_line, postal_code?, city? } // returns: { exact_match, best, candidates, confidence } // // The heavy lifting (Supabase REST → RQA table search) lives in // ./address-search.js — already used by the customer onboarding wizard. // We layer a confidence score + canonical formatting on top. const { json, parseBody, log, cors } = require('./helpers') const { searchAddresses } = require('./address-search') const { searchLocal, searchRaw } = require('./address-db') // Normalize for fuzzy comparison: lowercase, strip diacritics, collapse // whitespace, drop punctuation. Used to score how close a typed address // is to a RQA result. function normalizeForCompare (s) { return (s || '') .toString() .toLowerCase() .normalize('NFD').replace(/[̀-ͯ]/g, '') .replace(/[^a-z0-9\s]/g, ' ') .replace(/\s+/g, ' ') .trim() } // 0..1 score: civic number must match exactly, road name fuzzy-includes, // postal code (first 3 chars) bonus when present, city name bonus. function scoreMatch (typed, rqaRow) { const tNorm = normalizeForCompare(typed.address_line || '') const rNum = String(rqaRow.numero_municipal || '') const rRoad = normalizeForCompare(rqaRow.odonyme_recompose_normal || '') const rCity = normalizeForCompare(rqaRow.nom_municipalite || '') const rPC = (rqaRow.code_postal || '').replace(/\s+/g, '').toLowerCase() let score = 0 // Civic number — must appear in the typed string if (rNum && tNorm.match(new RegExp('\\b' + rNum + '\\b'))) score += 0.4 // Road name fuzzy: every word of the RQA road must appear in the typed const rWords = rRoad.split(/\s+/).filter(w => w.length > 1 && !['rue','chemin','rang','route','avenue','boulevard'].includes(w)) if (rWords.length) { const matched = rWords.filter(w => tNorm.includes(w)).length score += 0.3 * (matched / rWords.length) } // City — small bonus if (typed.city && rCity.includes(normalizeForCompare(typed.city))) score += 0.1 // Postal code — first 3 chars (FSA) is enough; full postal code = bigger bonus if (typed.postal_code) { const tPC = typed.postal_code.replace(/\s+/g, '').toLowerCase() if (tPC && rPC) { if (rPC.startsWith(tPC.slice(0, 3))) score += 0.1 if (rPC === tPC) score += 0.1 } } return Math.min(1, score) } 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. if (path === '/address/validate' && method === 'POST') { const body = await parseBody(req) const addressLine = (body.address_line || '').trim() if (!addressLine) return json(res, 400, { error: 'address_line required' }) // Build a search term — RQA's full-text search prefers "civic + road" const term = body.postal_code ? `${addressLine} ${body.postal_code}` : addressLine let rqaResults = [] try { rqaResults = await searchAddresses(term, 12) } catch (e) { log('RQA search failed:', e.message) return json(res, 502, { error: 'RQA unavailable: ' + e.message }) } if (!rqaResults.length) { return json(res, 200, { exact_match: false, best: null, candidates: [], confidence: 0, recommendation: 'unmatched', }) } const scored = rqaResults .map(r => ({ aq_address_id: r.identifiant_unique_adresse, formatted: r.adresse_formatee, civic: r.numero_municipal, unit: r.numero_unite, road: r.odonyme_recompose_normal, city: r.nom_municipalite, postal_code: r.code_postal, latitude: r.latitude ? parseFloat(r.latitude) : null, longitude: r.longitude ? parseFloat(r.longitude) : null, score: scoreMatch({ address_line: addressLine, postal_code: body.postal_code, city: body.city }, r), })) .sort((a, b) => b.score - a.score) const best = scored[0] const exactMatch = best.score >= 0.7 const recommendation = exactMatch ? 'validated' : best.score >= 0.4 ? 'review' : 'unmatched' return json(res, 200, { exact_match: exactMatch, best, candidates: scored.slice(0, 5), confidence: best.score, recommendation, }) } return json(res, 404, { error: 'Not found' }) } module.exports = { handle, scoreMatch, normalizeForCompare }