/** * Fetch pharmacy lists from an n8n (or any) HTTP webhook and map into FarmaFinder rows. * Default URL: FARMACIAS_WEBHOOK_URL env or the project webhook. */ export const DEFAULT_FARMACIAS_WEBHOOK = process.env.FARMACIAS_WEBHOOK_URL || 'https://n8n.hacecalor.net/webhook/farmacias'; /** * Append region query params, e.g. GET /webhook/farmacias?lat=41.5631&lon=2.0038&radio=1500 * @param {string} baseUrl - Absolute webhook URL (may already include other query params) * @param {{ lat?: number|string, lon?: number|string, lng?: number|string, radio?: number|string }} region */ export function buildFarmaciasWebhookUrl(baseUrl, region = {}) { const u = new URL(baseUrl); const lat = region.lat; const lon = region.lon ?? region.lng; const radio = region.radio; if (lat !== undefined && lat !== null && String(lat).trim() !== '') { u.searchParams.set('lat', String(lat).trim()); } if (lon !== undefined && lon !== null && String(lon).trim() !== '') { u.searchParams.set('lon', String(lon).trim()); } if (radio !== undefined && radio !== null && String(radio).trim() !== '') { u.searchParams.set('radio', String(radio).trim()); } return u.toString(); } function pick(obj, keys) { if (!obj || typeof obj !== 'object') return null; for (const k of keys) { if (Object.prototype.hasOwnProperty.call(obj, k)) { const v = obj[k]; if (v !== undefined && v !== null && String(v).trim() !== '') { return String(v).trim(); } } } return null; } function toNumber(v) { if (v === undefined || v === null || v === '') return null; const n = typeof v === 'number' ? v : parseFloat(String(v).replace(',', '.')); return Number.isFinite(n) ? n : null; } /** * Normalize one raw record (Spanish / English field names, GeoJSON-ish). */ export function normalizePharmacyRecord(raw) { if (!raw || typeof raw !== 'object') return null; if (raw.json != null && typeof raw.json === 'object' && !Array.isArray(raw.json)) { return normalizePharmacyRecord(raw.json); } let name = pick(raw, [ 'name', 'nombre', 'farmacia', 'titular', 'denominacion', 'denominación', 'razon_social', 'razón_social', 'title', ]); let address = pick(raw, [ 'address', 'direccion', 'dirección', 'domicilio', 'ubicacion', 'ubicación', 'calle', 'full_address', 'direccion_completa', ]); const phone = pick(raw, [ 'phone', 'telefono', 'teléfono', 'tel', 'telephone', 'movil', 'móvil', ]); let latitude = toNumber(raw.latitude ?? raw.latitud ?? raw.lat ?? raw.y); let longitude = toNumber(raw.longitude ?? raw.longitud ?? raw.lng ?? raw.lon ?? raw.x); const coords = raw.geometry?.coordinates; if (Array.isArray(coords) && coords.length >= 2) { if (longitude == null) longitude = toNumber(coords[0]); if (latitude == null) latitude = toNumber(coords[1]); } if (raw.location && typeof raw.location === 'object') { if (latitude == null) latitude = toNumber(raw.location.lat ?? raw.location.latitude); if (longitude == null) longitude = toNumber(raw.location.lng ?? raw.location.lon ?? raw.location.longitude); } if (!name && pick(raw, ['properties'])) { return normalizePharmacyRecord(raw.properties); } if (!address && name) { const parts = [pick(raw, ['localidad', 'city', 'municipio']), pick(raw, ['cp', 'codigo_postal', 'postal_code'])] .filter(Boolean) .join(', '); if (parts) address = parts; } return { name: name || null, address: address || null, phone: phone || null, latitude, longitude, }; } /** n8n often returns [{ json: { ... } }, ...] */ function unwrapN8nItemArray(arr) { if (!Array.isArray(arr) || arr.length === 0) return arr || []; const first = arr[0]; if ( first && typeof first === 'object' && first.json != null && typeof first.json === 'object' && !Array.isArray(first.json) ) { return arr.map((x) => x.json); } return arr; } export function extractPharmacyRows(payload) { if (payload == null) return []; let list = []; if (Array.isArray(payload)) list = payload; else if (typeof payload === 'object') { const candidates = [ payload.farmacias, payload.data, payload.results, payload.items, payload.rows, payload.records, payload.pharmacies, payload.body, payload.output, ]; for (const c of candidates) { if (Array.isArray(c)) { list = c; break; } } if (list.length === 0 && Array.isArray(payload.json)) list = payload.json; } return unwrapN8nItemArray(list); } export async function fetchWebhookJson(url, fetchOptions = {}) { const res = await fetch(url, { method: 'GET', headers: { Accept: 'application/json', ...fetchOptions.headers }, ...fetchOptions, }); const text = await res.text(); let json; try { json = text ? JSON.parse(text) : null; } catch { throw new Error( `Webhook returned non-JSON (HTTP ${res.status}): ${text.slice(0, 300)}` ); } if (!res.ok) { const hint = json?.message || JSON.stringify(json); throw new Error(`Webhook HTTP ${res.status}: ${hint}`); } return json; } /** * @param {Function} dbGet - (sql, params) => Promise * @param {Function} dbRun - (sql, params) => Promise<{lastID, changes}> * @param {object[]} rows - raw webhook items */ /** Insert normalized pharmacy rows; exported for OSM/Google/open-data importers */ export async function importPharmaciesFromRows(dbGet, dbRun, rows) { let inserted = 0; let skipped = 0; let invalid = 0; const errors = []; for (let i = 0; i < rows.length; i++) { const normalized = normalizePharmacyRecord(rows[i]); if (!normalized?.name || !normalized?.address) { invalid++; continue; } const { name, address, phone, latitude, longitude } = normalized; try { const existing = await dbGet( 'SELECT id FROM pharmacies WHERE name = ? AND address = ?', [name, address] ); if (existing) { skipped++; continue; } await dbRun( 'INSERT INTO pharmacies (name, address, phone, latitude, longitude) VALUES (?, ?, ?, ?, ?)', [name, address, phone || null, latitude, longitude] ); inserted++; } catch (err) { errors.push({ index: i, message: err.message }); } } return { inserted, skipped, invalid, errors }; } /** * Full flow: GET webhook → parse rows → insert into DB. * @param {string} [url] - Webhook base URL * @param {{ lat?: number|string, lon?: number|string, lng?: number|string, radio?: number|string } | null} [region] - Optional; adds ?lat=&lon=&radio= (meters) */ export async function runFarmaciaWebhookImport( dbGet, dbRun, url = DEFAULT_FARMACIAS_WEBHOOK, region = null ) { const finalUrl = region && (region.lat != null || region.lon != null || region.lng != null || region.radio != null) ? buildFarmaciasWebhookUrl(url, region) : url; const json = await fetchWebhookJson(finalUrl); const rows = extractPharmacyRows(json); if (rows.length === 0) { const keys = json && typeof json === 'object' ? Object.keys(json).join(', ') : typeof json; const err = new Error( `No pharmacy list found in webhook JSON (top-level keys: ${keys}). ` + `Fix the n8n workflow so the last node returns an array or { data: [...] }.` ); err.details = json; throw err; } const stats = await importPharmaciesFromRows(dbGet, dbRun, rows); const out = { ...stats, totalReceived: rows.length, webhookUrl: finalUrl, }; if (region && (region.lat != null || region.lon != null || region.lng != null || region.radio != null)) { out.region = { lat: region.lat ?? null, lon: region.lon ?? region.lng ?? null, radio: region.radio ?? null, }; } return out; }