Files
FarmaFinder/backend/server.js
2026-04-01 01:18:21 +02:00

728 lines
22 KiB
JavaScript

import express from 'express';
import cors from 'cors';
import sqlite3 from 'sqlite3';
import { promisify } from 'util';
import path from 'path';
import { fileURLToPath } from 'url';
import session from 'express-session';
import bcrypt from 'bcrypt';
import { searchMedicines, getMedicineDetails } from './cima-service.js';
import { runFarmaciaWebhookImport, DEFAULT_FARMACIAS_WEBHOOK, importPharmaciesFromRows } from './farmacias-webhook-import.js';
import { fetchPharmaciesExternal } from '../API/index.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.PORT || 3001;
// Configure CORS with credentials
app.use(cors({
origin: 'http://localhost:3000',
credentials: true
}));
app.use(express.json());
// Configure session
app.use(session({
secret: process.env.SESSION_SECRET || 'farma-finder-secret-key-change-in-production',
resave: false,
saveUninitialized: false,
cookie: {
secure: false, // Set to true in production with HTTPS
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
}));
// Database setup
const dbPath = path.join(__dirname, 'database.sqlite');
const db = new sqlite3.Database(dbPath);
// Custom wrapper to get lastID from db.run
function dbRun(sql, params = []) {
return new Promise((resolve, reject) => {
db.run(sql, params, function(err) {
if (err) reject(err);
else resolve({ lastID: this.lastID, changes: this.changes });
});
});
}
const dbAll = promisify(db.all.bind(db));
const dbGet = promisify(db.get.bind(db));
// Initialize database tables
async function initDatabase() {
try {
// Create pharmacies table
await dbRun(`
CREATE TABLE IF NOT EXISTS pharmacies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
address TEXT NOT NULL,
phone TEXT,
latitude REAL,
longitude REAL
)
`);
// Create medicines table
await dbRun(`
CREATE TABLE IF NOT EXISTS medicines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
active_ingredient TEXT,
dosage TEXT,
form TEXT
)
`);
// Create junction table for pharmacy-medicine relationships
// Ahora usa nregistro (número de registro de CIMA) en lugar de medicine_id local
await dbRun(`
CREATE TABLE IF NOT EXISTS pharmacy_medicines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pharmacy_id INTEGER NOT NULL,
medicine_nregistro TEXT NOT NULL,
medicine_name TEXT,
price REAL,
stock INTEGER DEFAULT 0,
FOREIGN KEY (pharmacy_id) REFERENCES pharmacies(id),
UNIQUE(pharmacy_id, medicine_nregistro)
)
`);
// Create indexes for better search performance
await dbRun(`CREATE INDEX IF NOT EXISTS idx_medicine_name ON medicines(name)`);
await dbRun(`CREATE INDEX IF NOT EXISTS idx_pharmacy_medicine ON pharmacy_medicines(medicine_nregistro)`);
// Create users table for authentication
await dbRun(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
console.log('Database initialized successfully');
} catch (error) {
console.error('Error initializing database:', error);
}
}
// API Routes
// Search medicines using CIMA API with Redis cache
app.get('/api/medicines/search', async (req, res) => {
try {
const query = req.query.q || '';
if (!query.trim()) {
return res.json([]);
}
// Usar el servicio de CIMA con caché de Redis
const medicines = await searchMedicines(query);
res.json(medicines);
} catch (error) {
console.error('Error searching medicines:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get pharmacies that sell a specific medicine (usando nregistro de CIMA)
app.get('/api/medicines/:medicineId/pharmacies', async (req, res) => {
try {
const nregistro = req.params.medicineId; // Ahora es el nregistro de CIMA
const pharmacies = await dbAll(`
SELECT
p.id,
p.name,
p.address,
p.phone,
p.latitude,
p.longitude,
pm.price,
pm.stock
FROM pharmacies p
INNER JOIN pharmacy_medicines pm ON p.id = pm.pharmacy_id
WHERE pm.medicine_nregistro = ?
ORDER BY p.name
`, [nregistro]);
res.json(pharmacies);
} catch (error) {
console.error('Error fetching pharmacies:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get medicine details from CIMA API (usando nregistro)
app.get('/api/medicines/:medicineId', async (req, res) => {
try {
const nregistro = req.params.medicineId; // Ahora es el nregistro de CIMA
const medicine = await getMedicineDetails(nregistro);
if (!medicine) {
return res.status(404).json({ error: 'Medicine not found' });
}
res.json(medicine);
} catch (error) {
console.error('Error fetching medicine:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get all pharmacies (for admin/debugging)
app.get('/api/pharmacies', async (req, res) => {
try {
const pharmacies = await dbAll(`
SELECT * FROM pharmacies ORDER BY name
`);
res.json(pharmacies);
} catch (error) {
console.error('Error fetching pharmacies:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// ========== AUTHENTICATION MIDDLEWARE ==========
// Middleware to check if user is authenticated
const requireAuth = (req, res, next) => {
if (req.session && req.session.userId) {
return next();
}
return res.status(401).json({ error: 'Authentication required' });
};
// ========== AUTHENTICATION ROUTES ==========
// Login endpoint
app.post('/api/auth/login', async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required' });
}
// Find user by username
const user = await dbGet(
'SELECT * FROM users WHERE username = ?',
[username.trim()]
);
if (!user) {
return res.status(401).json({ error: 'Invalid username or password' });
}
// Verify password
const isValidPassword = await bcrypt.compare(password, user.password_hash);
if (!isValidPassword) {
return res.status(401).json({ error: 'Invalid username or password' });
}
// Create session
req.session.userId = user.id;
req.session.username = user.username;
res.json({
message: 'Login successful',
user: {
id: user.id,
username: user.username
}
});
} catch (error) {
console.error('Error during login:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Logout endpoint
app.post('/api/auth/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
console.error('Error destroying session:', err);
return res.status(500).json({ error: 'Error logging out' });
}
res.json({ message: 'Logout successful' });
});
});
// Check authentication status
app.get('/api/auth/check', (req, res) => {
if (req.session && req.session.userId) {
res.json({
authenticated: true,
user: {
id: req.session.userId,
username: req.session.username
}
});
} else {
res.json({ authenticated: false });
}
});
// ========== ADMIN API ROUTES ==========
/** Suggested search radius (m) from Nominatim bounding box [south, north, west, east] */
function radiusMetersFromBoundingBox(south, north, west, east) {
const s = parseFloat(south);
const n = parseFloat(north);
const w = parseFloat(west);
const e = parseFloat(east);
if (![s, n, w, e].every(Number.isFinite)) return null;
const latMid = (s + n) / 2;
const latM = Math.abs(n - s) * 111320;
const lonM = Math.abs(e - w) * 111320 * Math.cos((latMid * Math.PI) / 180);
const half = (Math.max(latM, lonM) / 2) * 1.12;
return Math.round(Math.min(Math.max(half, 1500), 50000));
}
async function nominatimSearchFirstHit(originalQuery) {
const trimmed = originalQuery.trim();
const variants = [
trimmed,
`${trimmed}, España`,
`${trimmed}, Spain`,
`${trimmed}, ES`,
];
const seen = new Set();
const uniqueVariants = variants.filter((v) => {
const k = v.toLowerCase();
if (seen.has(k)) return false;
seen.add(k);
return true;
});
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
for (let i = 0; i < uniqueVariants.length; i++) {
const q = uniqueVariants[i];
if (i > 0) await delay(1100);
const params = new URLSearchParams({
format: 'json',
limit: '1',
q,
addressdetails: '0',
});
const url = `https://nominatim.openstreetmap.org/search?${params}`;
const nomRes = await fetch(url, {
headers: {
Accept: 'application/json',
'Accept-Language': 'es,en',
'User-Agent':
'FarmaFinder/1.0 (pharmacy admin; geocoding; https://github.com/)',
},
});
const text = await nomRes.text();
let data;
try {
data = text ? JSON.parse(text) : [];
} catch {
return { ok: false, error: 'Geocoder returned invalid JSON', status: 502 };
}
if (!nomRes.ok) {
return {
ok: false,
error: `Geocoder HTTP ${nomRes.status}`,
status: 502,
};
}
if (Array.isArray(data) && data.length > 0) {
return { ok: true, hit: data[0], triedQuery: q };
}
}
return {
ok: false,
error:
'No place found. Try adding the region (e.g. "Rubí, Barcelona" or "Toledo, Spain").',
status: 422,
};
}
// Geocode city → lat, lon, radius (OpenStreetMap Nominatim; admin-only, low volume)
app.get('/api/admin/geocode', requireAuth, async (req, res) => {
const q = (req.query.q || '').trim();
if (!q) {
return res.status(400).json({ error: 'Query parameter q is required' });
}
try {
const result = await nominatimSearchFirstHit(q);
if (!result.ok) {
return res.status(result.status).json({ error: result.error });
}
const hit = result.hit;
const lat = parseFloat(hit.lat);
const lon = parseFloat(hit.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
return res.status(502).json({ error: 'Geocoder result missing coordinates' });
}
let radius = 12000;
if (hit.boundingbox && hit.boundingbox.length >= 4) {
const r = radiusMetersFromBoundingBox(
hit.boundingbox[0],
hit.boundingbox[1],
hit.boundingbox[2],
hit.boundingbox[3]
);
if (r != null) radius = r;
}
res.json({
lat,
lon,
radius,
displayName: hit.display_name || q,
matchedQuery: result.triedQuery,
});
} catch (err) {
console.error('Geocode error:', err);
res.status(500).json({ error: err.message || 'Geocode failed' });
}
});
// Add a new pharmacy
app.post('/api/admin/pharmacies', requireAuth, async (req, res) => {
try {
const { name, address, phone, latitude, longitude } = req.body;
if (!name || !address) {
return res.status(400).json({ error: 'Name and address are required' });
}
// Check for duplicate (same name and address)
const existing = await dbGet(
'SELECT * FROM pharmacies WHERE name = ? AND address = ?',
[name.trim(), address.trim()]
);
if (existing) {
return res.status(400).json({ error: 'A pharmacy with this name and address already exists' });
}
const result = await dbRun(
'INSERT INTO pharmacies (name, address, phone, latitude, longitude) VALUES (?, ?, ?, ?, ?)',
[name.trim(), address.trim(), phone ? phone.trim() : null, latitude || null, longitude || null]
);
if (!result || result.lastID === undefined) {
throw new Error('Failed to get lastID from database insert');
}
const newPharmacy = await dbGet(
'SELECT * FROM pharmacies WHERE id = ?',
[result.lastID]
);
if (!newPharmacy) {
throw new Error('Failed to retrieve created pharmacy');
}
res.status(201).json(newPharmacy);
} catch (error) {
console.error('Error adding pharmacy:', error);
if (error.message.includes('UNIQUE constraint')) {
res.status(400).json({ error: 'A pharmacy with this information already exists' });
} else {
res.status(500).json({ error: error.message || 'Internal server error' });
}
}
});
// Update a pharmacy
app.put('/api/admin/pharmacies/:id', requireAuth, async (req, res) => {
try {
const pharmacyId = parseInt(req.params.id);
const { name, address, phone, latitude, longitude } = req.body;
if (!name || !address) {
return res.status(400).json({ error: 'Name and address are required' });
}
await dbRun(
'UPDATE pharmacies SET name = ?, address = ?, phone = ?, latitude = ?, longitude = ? WHERE id = ?',
[name, address, phone || null, latitude || null, longitude || null, pharmacyId]
);
const updatedPharmacy = await dbGet(
'SELECT * FROM pharmacies WHERE id = ?',
[pharmacyId]
);
if (!updatedPharmacy) {
return res.status(404).json({ error: 'Pharmacy not found' });
}
res.json(updatedPharmacy);
} catch (error) {
console.error('Error updating pharmacy:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Delete a pharmacy
app.delete('/api/admin/pharmacies/:id', requireAuth, async (req, res) => {
try {
const pharmacyId = parseInt(req.params.id);
// Delete related pharmacy_medicines first
await dbRun('DELETE FROM pharmacy_medicines WHERE pharmacy_id = ?', [pharmacyId]);
// Delete the pharmacy
await dbRun('DELETE FROM pharmacies WHERE id = ?', [pharmacyId]);
res.json({ message: 'Pharmacy deleted successfully' });
} catch (error) {
console.error('Error deleting pharmacy:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Import pharmacies from webhook (e.g. n8n).
// Body: { "url"?: string, "lat"?, "lon"|"lng"?, "radio"? } — lat/lon/radio add ?lat=&lon=&radio= (metres)
app.post('/api/admin/pharmacies/import-webhook', requireAuth, async (req, res) => {
try {
const body = req.body && typeof req.body === 'object' ? req.body : {};
const url =
(typeof body.url === 'string' && body.url.trim()) ||
process.env.FARMACIAS_WEBHOOK_URL ||
DEFAULT_FARMACIAS_WEBHOOK;
const region = {};
if (body.lat != null && String(body.lat).trim() !== '') region.lat = body.lat;
const lonVal = body.lon ?? body.lng;
if (lonVal != null && String(lonVal).trim() !== '') region.lon = lonVal;
if (body.radio != null && String(body.radio).trim() !== '') region.radio = body.radio;
const hasRegion =
region.lat != null || region.lon != null || region.radio != null;
const result = await runFarmaciaWebhookImport(
dbGet,
dbRun,
url.trim(),
hasRegion ? region : null
);
res.json(result);
} catch (error) {
console.error('Webhook pharmacy import:', error);
const status = error.message?.includes('HTTP') ? 502 : 400;
res.status(status).json({
error: error.message || 'Webhook import failed',
...(error.details ? { details: error.details } : {}),
});
}
});
// Import from OpenStreetMap (Overpass), or a JSON open-data URL — see /API
app.post('/api/admin/pharmacies/import-external', requireAuth, async (req, res) => {
try {
const body = req.body && typeof req.body === 'object' ? req.body : {};
const source = body.source;
if (!['osm', 'openData'].includes(source)) {
return res
.status(400)
.json({ error: 'source must be "osm", or "openData"' });
}
const rows = await fetchPharmaciesExternal({
source,
lat: body.lat,
lon: body.lon ?? body.lng,
lng: body.lng,
radio: body.radio,
openDataUrl:
typeof body.openDataUrl === 'string' ? body.openDataUrl.trim() : undefined,
});
if (!rows.length) {
return res.json({
inserted: 0,
skipped: 0,
invalid: 0,
errors: [],
totalReceived: 0,
source,
message: 'No pharmacies returned for this query',
});
}
const stats = await importPharmaciesFromRows(dbGet, dbRun, rows);
res.json({ ...stats, totalReceived: rows.length, source });
} catch (error) {
console.error('External pharmacy import:', error);
res.status(400).json({ error: error.message || 'External import failed' });
}
});
// Search medicines from CIMA API (para el admin)
app.get('/api/admin/medicines', requireAuth, async (req, res) => {
try {
const query = req.query.q || '';
if (!query.trim()) {
// Si no hay query, retornar lista vacía o medicamentos populares
return res.json([]);
}
// Usar el servicio de CIMA
const medicines = await searchMedicines(query);
res.json(medicines);
} catch (error) {
console.error('Error fetching medicines:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// NOTA: Ya no necesitamos endpoints para crear/editar medicamentos localmente
// porque ahora usamos la API de CIMA como fuente de verdad
// Add a new medicine (DEPRECATED - mantenido solo para compatibilidad)
app.post('/api/admin/medicines', requireAuth, async (req, res) => {
try {
// Ya no se agregan medicamentos localmente, se obtienen de CIMA
return res.status(400).json({
error: 'Medicine management has been moved to CIMA API. Use the search feature to find medicines.'
});
} catch (error) {
console.error('Error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Update a medicine (DEPRECATED - mantenido solo para compatibilidad)
app.put('/api/admin/medicines/:id', requireAuth, async (req, res) => {
try {
return res.status(400).json({
error: 'Medicine management has been moved to CIMA API.'
});
} catch (error) {
console.error('Error updating medicine:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get medicines for a specific pharmacy (usando nregistro)
app.get('/api/admin/pharmacies/:pharmacyId/medicines', requireAuth, async (req, res) => {
try {
const pharmacyId = parseInt(req.params.pharmacyId);
const medicines = await dbAll(`
SELECT
pm.id,
pm.pharmacy_id,
pm.medicine_nregistro,
pm.medicine_name,
pm.price,
pm.stock
FROM pharmacy_medicines pm
WHERE pm.pharmacy_id = ?
ORDER BY pm.medicine_name
`, [pharmacyId]);
res.json(medicines);
} catch (error) {
console.error('Error fetching pharmacy medicines:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Add medicine to a pharmacy (or update if exists) usando nregistro de CIMA
app.post('/api/admin/pharmacy-medicines', requireAuth, async (req, res) => {
try {
const { pharmacy_id, medicine_nregistro, medicine_name, price, stock } = req.body;
if (!pharmacy_id || !medicine_nregistro) {
return res.status(400).json({ error: 'Pharmacy ID and Medicine nregistro are required' });
}
// Check if relationship already exists
const existing = await dbGet(
'SELECT * FROM pharmacy_medicines WHERE pharmacy_id = ? AND medicine_nregistro = ?',
[pharmacy_id, medicine_nregistro]
);
if (existing) {
// Update existing relationship
await dbRun(
'UPDATE pharmacy_medicines SET medicine_name = ?, price = ?, stock = ? WHERE pharmacy_id = ? AND medicine_nregistro = ?',
[medicine_name, price || null, stock || 0, pharmacy_id, medicine_nregistro]
);
} else {
// Insert new relationship
await dbRun(
'INSERT INTO pharmacy_medicines (pharmacy_id, medicine_nregistro, medicine_name, price, stock) VALUES (?, ?, ?, ?, ?)',
[pharmacy_id, medicine_nregistro, medicine_name, price || null, stock || 0]
);
}
const relationship = await dbGet(
`SELECT * FROM pharmacy_medicines
WHERE pharmacy_id = ? AND medicine_nregistro = ?`,
[pharmacy_id, medicine_nregistro]
);
res.status(201).json(relationship);
} catch (error) {
console.error('Error adding medicine to pharmacy:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Update pharmacy-medicine relationship
app.put('/api/admin/pharmacy-medicines/:id', requireAuth, async (req, res) => {
try {
const id = parseInt(req.params.id);
const { price, stock } = req.body;
await dbRun(
'UPDATE pharmacy_medicines SET price = ?, stock = ? WHERE id = ?',
[price || null, stock || 0, id]
);
const updated = await dbGet(
'SELECT * FROM pharmacy_medicines WHERE id = ?',
[id]
);
if (!updated) {
return res.status(404).json({ error: 'Relationship not found' });
}
res.json(updated);
} catch (error) {
console.error('Error updating pharmacy-medicine:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Delete pharmacy-medicine relationship
app.delete('/api/admin/pharmacy-medicines/:id', requireAuth, async (req, res) => {
try {
const id = parseInt(req.params.id);
await dbRun('DELETE FROM pharmacy_medicines WHERE id = ?', [id]);
res.json({ message: 'Medicine removed from pharmacy successfully' });
} catch (error) {
console.error('Error deleting pharmacy-medicine:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Start server
initDatabase().then(() => {
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
});