Hotfixes api newer path
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* FarmaClic external pharmacy sources (OpenStreetMap, Google Places, open-data URLs).
|
||||
* Used by the backend admin import; OSM does not require n8n.
|
||||
*/
|
||||
|
||||
export { buildAddressFromOsmTags, osmElementToPharmacy } from './normalize.js';
|
||||
export { fetchPharmaciesFromOsm } from './osm-overpass.js';
|
||||
export { fetchPharmaciesFromOpenDataUrl } from './open-data.js';
|
||||
|
||||
import { fetchPharmaciesFromOsm } from './osm-overpass.js';
|
||||
import { fetchPharmaciesFromOpenDataUrl } from './open-data.js';
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* source: 'osm' | 'openData',
|
||||
* lat?: number|string,
|
||||
* lon?: number|string,
|
||||
* lng?: number|string,
|
||||
* radio?: number|string,
|
||||
* openDataUrl?: string,
|
||||
* }} opts
|
||||
*/
|
||||
export async function fetchPharmaciesExternal(opts) {
|
||||
const source = opts?.source;
|
||||
if (source === 'openData') {
|
||||
return fetchPharmaciesFromOpenDataUrl(opts.openDataUrl);
|
||||
}
|
||||
|
||||
const lat = parseFloat(opts.lat);
|
||||
const lon = parseFloat(opts.lon ?? opts.lng);
|
||||
const radio = parseFloat(opts.radio) || 1500;
|
||||
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||||
throw new Error('lat and lon are required for osm sources');
|
||||
}
|
||||
|
||||
if (source === 'osm') {
|
||||
return fetchPharmaciesFromOsm({
|
||||
lat,
|
||||
lon,
|
||||
radiusMeters: radio,
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error('source must be "osm", or "openData"');
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Shared helpers: OSM tags → single-line address, element → pharmacy row.
|
||||
*/
|
||||
|
||||
import { parseOsmOpeningHours } from './opening-hours-osm.js';
|
||||
|
||||
export function buildAddressFromOsmTags(tags) {
|
||||
if (!tags || typeof tags !== 'object') return '';
|
||||
if (tags['addr:full'] && String(tags['addr:full']).trim()) {
|
||||
return String(tags['addr:full']).trim();
|
||||
}
|
||||
const street = [tags['addr:street'], tags['addr:housenumber']].filter(Boolean).join(' ').trim();
|
||||
const cityLine = [tags['addr:postcode'], tags['addr:city'] || tags['addr:place'] || tags['addr:suburb']]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.trim();
|
||||
const parts = [street, cityLine].filter(Boolean);
|
||||
if (parts.length) return parts.join(', ');
|
||||
if (tags['addr:province'] && tags['addr:city']) {
|
||||
return `${tags['addr:city']}, ${tags['addr:province']}`.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} el - Overpass element (node | way | relation with optional center)
|
||||
*/
|
||||
export function osmElementToPharmacy(el) {
|
||||
const tags = el.tags || {};
|
||||
let lat;
|
||||
let lon;
|
||||
if (el.type === 'node' && el.lat != null && el.lon != null) {
|
||||
lat = Number(el.lat);
|
||||
lon = Number(el.lon);
|
||||
} else if (el.center && el.center.lat != null && el.center.lon != null) {
|
||||
lat = Number(el.center.lat);
|
||||
lon = Number(el.center.lon);
|
||||
}
|
||||
|
||||
const name = tags.name || tags.brand || tags.operator || null;
|
||||
let address = buildAddressFromOsmTags(tags);
|
||||
if (!address && tags['addr:country']) {
|
||||
address = tags['addr:city'] || tags['addr:place'] || '';
|
||||
}
|
||||
if (!address && name) {
|
||||
address = `OpenStreetMap (no address tags; id ${el.type}/${el.id})`;
|
||||
}
|
||||
|
||||
const phone =
|
||||
tags.phone || tags['contact:phone'] || tags['contact:mobile'] || tags['contact:whatsapp'] || null;
|
||||
|
||||
const opening_hours = parseOsmOpeningHours(tags.opening_hours);
|
||||
|
||||
return {
|
||||
name,
|
||||
address: address || null,
|
||||
phone: phone ? String(phone).trim() : null,
|
||||
latitude: Number.isFinite(lat) ? lat : null,
|
||||
longitude: Number.isFinite(lon) ? lon : null,
|
||||
opening_hours: opening_hours || null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Load pharmacy rows from a public open-data URL (JSON array, GeoJSON, or similar).
|
||||
* No default URL — pass the dataset URL from the client (regional open-data portals).
|
||||
*/
|
||||
|
||||
function rowsFromGeoJson(json) {
|
||||
if (!json || json.type !== 'FeatureCollection' || !Array.isArray(json.features)) {
|
||||
return [];
|
||||
}
|
||||
return json.features
|
||||
.map((f) => {
|
||||
const p = f.properties || {};
|
||||
const name = p.name || p.nombre || p.denominacion || p.title;
|
||||
const address =
|
||||
p.address ||
|
||||
p.direccion ||
|
||||
p.domicilio ||
|
||||
[p.calle, p.municipio, p.codigo_postal].filter(Boolean).join(', ');
|
||||
let lat;
|
||||
let lon;
|
||||
if (f.geometry?.type === 'Point' && Array.isArray(f.geometry.coordinates)) {
|
||||
lon = f.geometry.coordinates[0];
|
||||
lat = f.geometry.coordinates[1];
|
||||
}
|
||||
return {
|
||||
name: name ? String(name).trim() : null,
|
||||
address: address ? String(address).trim() : null,
|
||||
phone: p.phone || p.telefono || p.tel || null,
|
||||
latitude: lat != null ? Number(lat) : null,
|
||||
longitude: lon != null ? Number(lon) : null,
|
||||
};
|
||||
})
|
||||
.filter((r) => r.name && r.address);
|
||||
}
|
||||
|
||||
function rowsFromJsonArray(json) {
|
||||
if (!Array.isArray(json)) return [];
|
||||
return json
|
||||
.map((row) => {
|
||||
if (!row || typeof row !== 'object') return null;
|
||||
const name = row.name || row.nombre || row.farmacia;
|
||||
const address =
|
||||
row.address ||
|
||||
row.direccion ||
|
||||
row.domicilio ||
|
||||
row.full_address;
|
||||
return {
|
||||
name: name ? String(name).trim() : null,
|
||||
address: address ? String(address).trim() : null,
|
||||
phone: row.phone || row.telefono || null,
|
||||
latitude:
|
||||
row.latitude != null
|
||||
? Number(row.latitude)
|
||||
: row.lat != null
|
||||
? Number(row.lat)
|
||||
: null,
|
||||
longitude:
|
||||
row.longitude != null
|
||||
? Number(row.longitude)
|
||||
: row.lon != null
|
||||
? Number(row.lon)
|
||||
: row.lng != null
|
||||
? Number(row.lng)
|
||||
: null,
|
||||
};
|
||||
})
|
||||
.filter((r) => r && r.name && r.address);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} openDataUrl - HTTPS URL returning JSON
|
||||
* @returns {Promise<Array<{name,address,phone,latitude,longitude}>>}
|
||||
*/
|
||||
export async function fetchPharmaciesFromOpenDataUrl(openDataUrl) {
|
||||
if (!openDataUrl || typeof openDataUrl !== 'string' || !openDataUrl.trim()) {
|
||||
throw new Error('openData source requires a non-empty openDataUrl');
|
||||
}
|
||||
const u = openDataUrl.trim();
|
||||
if (!/^https?:\/\//i.test(u)) {
|
||||
throw new Error('openDataUrl must be an http(s) URL');
|
||||
}
|
||||
|
||||
const res = await fetch(u, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'FarmaClic/1.0 (open-data pharmacy import)',
|
||||
},
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
throw new Error(`Open data HTTP ${res.status}: ${text.slice(0, 120)}`);
|
||||
}
|
||||
let json;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
throw new Error('Open data response is not JSON');
|
||||
}
|
||||
|
||||
if (json && json.type === 'FeatureCollection') {
|
||||
return rowsFromGeoJson(json);
|
||||
}
|
||||
if (Array.isArray(json)) {
|
||||
return rowsFromJsonArray(json);
|
||||
}
|
||||
if (json && Array.isArray(json.data)) {
|
||||
return rowsFromJsonArray(json.data);
|
||||
}
|
||||
if (json && Array.isArray(json.records)) {
|
||||
return rowsFromJsonArray(json.records);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Unsupported open-data JSON shape (expected FeatureCollection, array, or { data: [] })'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Parse a subset of the OSM opening_hours format into FarmaClic's internal
|
||||
* shape: { mon: ["09:00", "21:00"], ..., sun: null }.
|
||||
*
|
||||
* Supports the patterns commonly seen on amenity=pharmacy nodes:
|
||||
* "24/7"
|
||||
* "Mo-Fr 09:00-21:00"
|
||||
* "Mo-Fr 09:00-21:00; Sa 09:00-14:00; Su closed"
|
||||
* "Mo,We,Fr 09:00-14:00"
|
||||
* "Mo-Fr 09:00-13:30,16:30-20:00" (split shifts: opens first, closes last)
|
||||
*
|
||||
* Public-holiday rules (PH/SH) and weeknumber/date filters are ignored.
|
||||
* Returns null if no usable rule was found.
|
||||
*
|
||||
* @param {string} input
|
||||
* @returns {{mon: string[]|null, tue: string[]|null, wed: string[]|null, thu: string[]|null, fri: string[]|null, sat: string[]|null, sun: string[]|null}|null}
|
||||
*/
|
||||
export function parseOsmOpeningHours(input) {
|
||||
if (!input || typeof input !== 'string') return null;
|
||||
const cleaned = input.trim().replace(/\s+/g, ' ');
|
||||
if (!cleaned) return null;
|
||||
|
||||
const result = { mon: null, tue: null, wed: null, thu: null, fri: null, sat: null, sun: null };
|
||||
|
||||
if (/^24\s*\/\s*7$/i.test(cleaned)) {
|
||||
for (const k of Object.keys(result)) result[k] = ['00:00', '24:00'];
|
||||
return result;
|
||||
}
|
||||
|
||||
const rules = cleaned.split(';').map(r => r.trim()).filter(Boolean);
|
||||
let anySet = false;
|
||||
|
||||
for (const rule of rules) {
|
||||
const ruleNoComment = rule.replace(/\([^)]*\)/g, '').trim();
|
||||
if (!ruleNoComment) continue;
|
||||
|
||||
const lower = ruleNoComment.toLowerCase();
|
||||
if (lower === 'closed' || lower === 'off') continue;
|
||||
|
||||
const tokens = ruleNoComment.split(/\s+/);
|
||||
const dayPart = tokens.shift();
|
||||
const timePart = tokens.join(' ').trim();
|
||||
if (!dayPart) continue;
|
||||
|
||||
const days = expandDayPart(dayPart);
|
||||
if (!days.length) continue;
|
||||
|
||||
if (!timePart || /^(off|closed)$/i.test(timePart)) {
|
||||
for (const d of days) result[d] = null;
|
||||
anySet = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const range = collapseTimeRanges(timePart);
|
||||
if (!range) continue;
|
||||
|
||||
for (const d of days) result[d] = range;
|
||||
anySet = true;
|
||||
}
|
||||
|
||||
return anySet ? result : null;
|
||||
}
|
||||
|
||||
const DAY_CODE_TO_KEY = { mo: 'mon', tu: 'tue', we: 'wed', th: 'thu', fr: 'fri', sa: 'sat', su: 'sun' };
|
||||
const DAY_ORDER = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
|
||||
|
||||
function expandDayPart(dayPart) {
|
||||
const out = new Set();
|
||||
for (const segment of dayPart.split(',')) {
|
||||
const seg = segment.trim();
|
||||
if (!seg) continue;
|
||||
if (/^(ph|sh)(-(ph|sh))?$/i.test(seg)) continue;
|
||||
|
||||
const rangeMatch = seg.match(/^([A-Za-z]{2})-([A-Za-z]{2})$/);
|
||||
if (rangeMatch) {
|
||||
const a = DAY_CODE_TO_KEY[rangeMatch[1].toLowerCase()];
|
||||
const b = DAY_CODE_TO_KEY[rangeMatch[2].toLowerCase()];
|
||||
if (!a || !b) continue;
|
||||
const ai = DAY_ORDER.indexOf(a);
|
||||
const bi = DAY_ORDER.indexOf(b);
|
||||
if (ai === -1 || bi === -1) continue;
|
||||
if (ai <= bi) {
|
||||
for (let i = ai; i <= bi; i++) out.add(DAY_ORDER[i]);
|
||||
} else {
|
||||
for (let i = ai; i < DAY_ORDER.length; i++) out.add(DAY_ORDER[i]);
|
||||
for (let i = 0; i <= bi; i++) out.add(DAY_ORDER[i]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const single = DAY_CODE_TO_KEY[seg.toLowerCase()];
|
||||
if (single) out.add(single);
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
function collapseTimeRanges(timePart) {
|
||||
const matches = [...timePart.matchAll(/(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})/g)];
|
||||
if (matches.length === 0) return null;
|
||||
const first = matches[0];
|
||||
const last = matches[matches.length - 1];
|
||||
const open = `${first[1].padStart(2, '0')}:${first[2]}`;
|
||||
const close = `${last[3].padStart(2, '0')}:${last[4]}`;
|
||||
return [open, close];
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Pharmacies from OpenStreetMap via Overpass API (no API key).
|
||||
* @see https://wiki.openstreetmap.org/wiki/Overpass_API
|
||||
*/
|
||||
|
||||
import { osmElementToPharmacy } from './normalize.js';
|
||||
|
||||
const OVERPASS_ENDPOINTS = (
|
||||
process.env.OVERPASS_API_URL ||
|
||||
'https://overpass-api.de/api/interpreter,https://overpass.openstreetmap.fr/api/interpreter'
|
||||
)
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
function buildOverpassQuery(lat, lon, radiusM) {
|
||||
// Nodes only (fast); ways/relations are rarer for pharmacies and heavy on public Overpass.
|
||||
return `[out:json][timeout:25];
|
||||
(
|
||||
node["amenity"="pharmacy"](around:${radiusM},${lat},${lon});
|
||||
node["healthcare"="pharmacy"](around:${radiusM},${lat},${lon});
|
||||
);
|
||||
out;`;
|
||||
}
|
||||
|
||||
const FETCH_MS = 35000;
|
||||
|
||||
async function fetchOverpass(endpoint, body) {
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), FETCH_MS);
|
||||
try {
|
||||
return await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
signal: ctrl.signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'FarmaClic/1.0 (OSM pharmacy import; local admin)',
|
||||
},
|
||||
body,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ lat: number, lon: number, radiusMeters?: number }} opts
|
||||
* @returns {Promise<Array<{name,address,phone,latitude,longitude}>>}
|
||||
*/
|
||||
export async function fetchPharmaciesFromOsm({ lat, lon, radiusMeters = 1500 }) {
|
||||
const latN = Number(lat);
|
||||
const lonN = Number(lon);
|
||||
if (!Number.isFinite(latN) || !Number.isFinite(lonN)) {
|
||||
throw new Error('fetchPharmaciesFromOsm: invalid lat or lon');
|
||||
}
|
||||
const r = Math.round(
|
||||
Math.min(Math.max(Number(radiusMeters) || 1500, 50), 25000)
|
||||
);
|
||||
|
||||
const body = `data=${encodeURIComponent(buildOverpassQuery(latN, lonN, r))}`;
|
||||
let lastErr = null;
|
||||
let json = null;
|
||||
let text = '';
|
||||
|
||||
for (const endpoint of OVERPASS_ENDPOINTS) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetchOverpass(endpoint, body);
|
||||
} catch (e) {
|
||||
lastErr =
|
||||
e.name === 'AbortError'
|
||||
? new Error(`Overpass timeout (${FETCH_MS / 1000}s) at ${new URL(endpoint).host}`)
|
||||
: e;
|
||||
continue;
|
||||
}
|
||||
text = await res.text();
|
||||
let parsed;
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
lastErr = new Error(
|
||||
`Overpass returned non-JSON (HTTP ${res.status}): ${(text || '').slice(0, 120)}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!res.ok && (res.status === 504 || res.status === 502 || res.status === 429)) {
|
||||
lastErr = new Error(
|
||||
`Overpass busy (HTTP ${res.status}) at ${new URL(endpoint).host}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const msg = parsed.remark || parsed.error || text.slice(0, 200);
|
||||
throw new Error(`Overpass HTTP ${res.status}: ${msg}`);
|
||||
}
|
||||
json = parsed;
|
||||
lastErr = null;
|
||||
break;
|
||||
}
|
||||
|
||||
if (lastErr && !json) {
|
||||
throw lastErr;
|
||||
}
|
||||
if (!json || !Array.isArray(json.elements)) {
|
||||
throw lastErr || new Error('Overpass: no usable response');
|
||||
}
|
||||
|
||||
const elements = json.elements;
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
|
||||
for (const el of elements) {
|
||||
const key = `${el.type}/${el.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const row = osmElementToPharmacy(el);
|
||||
if (row.name && row.address) {
|
||||
out.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "farma-clic-api-sources",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
@@ -4,7 +4,7 @@ COPY apps/backend/package*.json ./
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
RUN npm ci --omit=dev
|
||||
COPY apps/backend/ .
|
||||
COPY API/ /API/
|
||||
COPY apps/API/ /API/
|
||||
RUN mkdir -p /app/data
|
||||
EXPOSE 3001
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
Reference in New Issue
Block a user