API, Backend & Frontend

This commit is contained in:
Ichitux
2026-04-01 01:18:21 +02:00
parent 331c04fbef
commit 0fe8ec9bc0
44 changed files with 10060 additions and 0 deletions

View 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;
}