API, Backend & Frontend
This commit is contained in:
280
backend/farmacias-webhook-import.js
Normal file
280
backend/farmacias-webhook-import.js
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user