281 lines
7.8 KiB
JavaScript
281 lines
7.8 KiB
JavaScript
/**
|
|
* 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<row|undefined>
|
|
* @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;
|
|
}
|