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}`); }); });