// OpenTelemetry SDK — must be imported first to instrument http, express, pg, etc. import './src/tracing.js'; 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 connectSqlite3 from 'connect-sqlite3'; import connectPg from 'connect-pg-simple'; import pg from 'pg'; import bcrypt from 'bcrypt'; import rateLimit from 'express-rate-limit'; import webpush from 'web-push'; import pino from 'pino'; import pinoHttp from 'pino-http'; import multer from 'multer'; import { createWorker } from 'tesseract.js'; import { BarcodeDetector } from 'barcode-detector'; 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; // Structured JSON logger. The Pino OTel instrumentation attaches trace_id / // span_id to every log line so they can be correlated in Grafana. const logger = pino({ level: process.env.LOG_LEVEL || 'info', base: { service: process.env.OTEL_SERVICE_NAME || 'farmaclic-backend' }, }); // Trust the reverse proxy in front of us (Docker/Traefik/nginx/Cloudflare) so // req.ip and X-Forwarded-For are honored. Configurable via TRUST_PROXY: // - unset/"1" → trust exactly one hop (typical single reverse proxy) // - "loopback" → only trust 127.0.0.1 // - "true" → trust any proxy (only behind a trusted network) // - any other value is passed through verbatim (number of hops or IP list) const trustProxyEnv = process.env.TRUST_PROXY ?? '1'; if (trustProxyEnv === 'true') app.set('trust proxy', true); else if (trustProxyEnv === 'false') app.set('trust proxy', false); else if (/^\d+$/.test(trustProxyEnv)) app.set('trust proxy', Number(trustProxyEnv)); else app.set('trust proxy', trustProxyEnv); // Configure CORS with credentials app.use(cors({ origin: process.env.CORS_ORIGIN || 'http://localhost:3000', credentials: true })); app.use(express.json()); app.use(pinoHttp({ logger })); const PG_URL = process.env.PG_URL; let pgPool = null; if (PG_URL) { const { Pool } = pg; pgPool = new Pool({ connectionString: PG_URL }); } const sessionConfig = { secret: process.env.SESSION_SECRET || 'farma-clic-secret-key-change-in-production', resave: false, saveUninitialized: false, cookie: { secure: process.env.COOKIE_SECURE === 'true', sameSite: 'lax', httpOnly: true, maxAge: 24 * 60 * 60 * 1000 // 24 hours } }; if (process.env.NODE_ENV !== 'test') { if (pgPool) { const PgStore = connectPg(session); sessionConfig.store = new PgStore({ pool: pgPool, createTableIfMissing: true }); } else { const SQLiteStore = connectSqlite3(session); sessionConfig.store = new SQLiteStore({ db: 'sessions.sqlite', dir: __dirname }); } } // Configure session app.use(session(sessionConfig)); const searchLimiter = rateLimit({ windowMs: 60_000, max: 30, standardHeaders: true, legacyHeaders: false }); const loginLimiter = rateLimit({ windowMs: 60_000, max: 5, standardHeaders: true, legacyHeaders: false }); const registerLimiter = rateLimit({ windowMs: 60 * 60_000, max: 10, standardHeaders: true, legacyHeaders: false }); const geocodeLimiter = rateLimit({ windowMs: 60_000, max: 10, standardHeaders: true, legacyHeaders: false }); const VAPID_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY || ''; const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY || ''; const VAPID_SUBJECT = process.env.VAPID_SUBJECT || 'mailto:admin@example.com'; const PUSH_ENABLED = Boolean(VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY); if (PUSH_ENABLED) { webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY); } else if (process.env.NODE_ENV !== 'test') { console.warn('[push] VAPID keys not configured — push notifications disabled'); } // Database setup const dbPath = process.env.DATABASE_PATH || 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)); // User DB helpers — route to PG when available, fallback to SQLite for tests function toPositional(sql) { let i = 0; return sql.replace(/\?/g, () => `$${++i}`); } async function userDbGet(sql, params = []) { if (pgPool) { const res = await pgPool.query(toPositional(sql), params); return res.rows[0] ?? null; } return dbGet(sql, params); } async function userDbRun(sql, params = []) { if (pgPool) { const res = await pgPool.query(toPositional(sql), params); return { lastID: res.rows[0]?.id, changes: res.rowCount }; } return dbRun(sql.replace(/\s+RETURNING\s+\w+\s*$/i, ''), params); } async function userDbAll(sql, params = []) { if (pgPool) { const res = await pgPool.query(toPositional(sql), params); return res.rows; } return dbAll(sql, params); } // Accept opening_hours as either an object or a JSON string from the client // and return a TEXT value (or null) safe to persist. function serializeOpeningHours(value) { if (value == null || value === '') return null; if (typeof value === 'string') { try { JSON.parse(value); return value; } catch { return null; } } if (typeof value === 'object') { try { return JSON.stringify(value); } catch { return null; } } return null; } // 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, opening_hours TEXT ) `); // Add opening_hours to pre-existing pharmacies tables (no-op if already present) try { await dbRun(`ALTER TABLE pharmacies ADD COLUMN opening_hours TEXT`); } catch (e) { if (!/duplicate column/i.test(e.message)) throw e; } // 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) ) `); await dbRun(`CREATE INDEX IF NOT EXISTS idx_pharmacy_medicine ON pharmacy_medicines(medicine_nregistro)`); // Create users table — PG when available, SQLite fallback if (pgPool) { await pgPool.query(` CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, is_admin INTEGER NOT NULL DEFAULT 0, address TEXT, latitude DOUBLE PRECISION, longitude DOUBLE PRECISION, created_at TIMESTAMPTZ DEFAULT NOW() ) `); } else { 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 ) `); } if (pgPool) { await pgPool.query(` CREATE TABLE IF NOT EXISTS user_alerts ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id), type TEXT NOT NULL CHECK (type IN ('reminder', 'availability', 'schedule')), medicine_nregistro TEXT, title TEXT, detail TEXT, schedule TEXT, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ) `); } await dbRun(` CREATE TABLE IF NOT EXISTS push_subscriptions ( id INTEGER PRIMARY KEY AUTOINCREMENT, medicine_nregistro TEXT NOT NULL, medicine_name TEXT, endpoint TEXT NOT NULL, p256dh TEXT NOT NULL, auth TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(medicine_nregistro, endpoint) ) `); await dbRun(`CREATE INDEX IF NOT EXISTS idx_push_subscriptions_med ON push_subscriptions(medicine_nregistro)`); await dbRun(` CREATE TABLE IF NOT EXISTS push_subscriptions_pharmacy ( id INTEGER PRIMARY KEY AUTOINCREMENT, medicine_nregistro TEXT NOT NULL, medicine_name TEXT, pharmacy_id INTEGER NOT NULL, endpoint TEXT NOT NULL, p256dh TEXT NOT NULL, auth TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(medicine_nregistro, pharmacy_id, endpoint) ) `); await dbRun(`CREATE INDEX IF NOT EXISTS idx_push_subs_pharm ON push_subscriptions_pharmacy(medicine_nregistro, pharmacy_id)`); if (!pgPool) { // SQLite-only migrations for existing deployments try { await dbRun('ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0'); } catch {} try { await dbRun('ALTER TABLE users ADD COLUMN address TEXT'); } catch {} try { await dbRun('ALTER TABLE users ADD COLUMN latitude REAL'); } catch {} try { await dbRun('ALTER TABLE users ADD COLUMN longitude REAL'); } catch {} } // Add user_id to push tables (SQLite — no cross-DB FK) try { await dbRun('ALTER TABLE push_subscriptions ADD COLUMN user_id INTEGER'); } catch {} try { await dbRun('ALTER TABLE push_subscriptions_pharmacy ADD COLUMN user_id INTEGER'); } catch {} // Create alerts table for persistent Avisos data try { await dbRun(` CREATE TABLE IF NOT EXISTS user_alerts ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, type TEXT NOT NULL CHECK (type IN ('reminder', 'availability', 'schedule')), medicine_nregistro TEXT, title TEXT, detail TEXT, schedule TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ) `); } catch (e) { if (!/table already exists/i.test(e.message)) throw e; } } catch (err) { console.error('initDatabase failed:', err); throw err; } } // API Routes // Search medicines using CIMA API with Redis cache app.get('/api/medicines/search', searchLimiter, 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, p.opening_hours, 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' }); } }); // ========== 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' }); }; // Middleware to check if user is an admin const requireAdmin = (req, res, next) => { if (req.session && req.session.userId && req.session.isAdmin) { return next(); } if (req.session && req.session.userId) { return res.status(403).json({ error: 'Admin access required' }); } return res.status(401).json({ error: 'Authentication required' }); }; // ========== RECENT SEARCHES (session-based) ========== const MAX_RECENT = 10; // Get recent searches for the logged-in user app.get('/api/search/recent', requireAuth, (req, res) => { res.json(req.session.recentSearches || []); }); // Save a medicine to recent searches app.post('/api/search/recent', requireAuth, (req, res) => { try { const { medicine } = req.body; if (!medicine || !medicine.id) { return res.status(400).json({ error: 'Medicine data required' }); } if (!req.session.recentSearches) { req.session.recentSearches = []; } // Remove duplicate if exists (by id) req.session.recentSearches = req.session.recentSearches.filter( (m) => m.id !== medicine.id ); // Add to front with timestamp req.session.recentSearches.unshift({ id: medicine.id, name: medicine.name, active_ingredient: medicine.active_ingredient, dosage: medicine.dosage, form: medicine.form, timestamp: Date.now(), }); // Keep only last MAX_RECENT if (req.session.recentSearches.length > MAX_RECENT) { req.session.recentSearches = req.session.recentSearches.slice(0, MAX_RECENT); } res.json({ ok: true }); } catch (error) { console.error('Error saving recent search:', 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' }); } }); // TSI Card scan: return mock prescriptions for a given CIP // In production this would integrate with a real health data source app.get('/api/tsi/:cip/prescriptions', async (req, res) => { try { const cip = req.params.cip || ''; // Deterministic prescription set based on CIP prefix — simulates per-patient data const cipUpper = cip.toUpperCase(); let medicineNames; if (cipUpper.startsWith('ALPE')) { medicineNames = ['Paracetamol', 'Omeprazol', 'Ibuprofeno']; } else if (cipUpper.startsWith('MADS')) { medicineNames = ['Amoxicilina', 'Loratadina', 'Ibuprofeno']; } else if (cipUpper.startsWith('VALE')) { medicineNames = ['Atorvastatina', 'Metformina', 'Losartán']; } else { medicineNames = ['Paracetamol', 'Ibuprofeno']; } // Build a static prescription list (CIMA API mock) const prescriptions = medicineNames.map((name, idx) => { const dosages = ['500mg', '20mg', '600mg', '500mg', '850mg']; const forms = ['Comprimidos', 'Cápsulas', 'Comprimidos recubiertos', 'Cápsulas', 'Comprimidos']; const actives = { 'Paracetamol': 'Paracetamol', 'Omeprazol': 'Omeprazol', 'Ibuprofeno': 'Ibuprofeno', 'Amoxicilina': 'Amoxicilina', 'Loratadina': 'Loratadina', 'Atorvastatina': 'Atorvastatina', 'Metformina': 'Metformina', 'Losartán': 'Losartán potásico', }; return { id: `RX_${cip}_${idx}`, name: name, active_ingredient: actives[name] || name, dosage: dosages[idx % dosages.length], form: forms[idx % forms.length], }; }); res.json(prescriptions); } catch (error) { console.error('Error fetching TSI prescriptions:', error); res.status(500).json({ error: 'Internal server error' }); } }); // ========== TSI OCR: extract CIP from uploaded photo ========== const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB max fileFilter: (_req, file, cb) => { if (file.mimetype.startsWith('image/')) cb(null, true); else cb(new Error('Only image files are accepted')); }, }); // Extract CIP from OCR text — looks for a long alphanumeric token (16 chars typical for CIP) function extractCip(text) { const cleaned = text.replace(/\s+/g, ''); // Try exact 16-char alphanumeric match first const exact = cleaned.match(/[A-Z0-9]{16}/i); if (exact) return exact[0].toUpperCase(); // Fallback: 8–30 alphanumeric chars const loose = cleaned.match(/[A-Z0-9]{8,30}/i); if (loose) return loose[0].toUpperCase(); return null; } app.post('/api/tsi/ocr', upload.single('photo'), async (req, res) => { try { if (!req.file) { return res.status(400).json({ error: 'No image file provided' }); } const worker = await createWorker('spa+eng'); const { data } = await worker.recognize(req.file.buffer); await worker.terminate(); console.log('[OCR] Raw text:', data.text); const cip = extractCip(data.text); console.log('[OCR] Extracted CIP:', cip); if (!cip) { return res.status(422).json({ error: 'No se pudo detectar un código CIP en la imagen', ocrText: data.text.trim().slice(0, 500), }); } res.json({ cip }); } catch (error) { console.error('TSI OCR error:', error); res.status(500).json({ error: 'Error procesando la imagen' }); } }); // ========== AUTHENTICATION ROUTES ========== // Login endpoint app.post('/api/auth/login', loginLimiter, 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 userDbGet( '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; req.session.isAdmin = Boolean(user.is_admin); res.json({ message: 'Login successful', user: { id: user.id, username: user.username, is_admin: Boolean(user.is_admin), address: user.address || null, latitude: user.latitude, longitude: user.longitude, } }); } catch (error) { console.error('Error during login:', error); res.status(500).json({ error: 'Internal server error' }); } }); // Register a new user (non-admin) and start a session app.post('/api/auth/register', registerLimiter, async (req, res) => { try { const { username, password } = req.body || {}; const u = String(username || '').trim(); const p = String(password || ''); if (!/^[A-Za-z0-9_]{3,32}$/.test(u)) { return res.status(400).json({ error: 'Username must be 3–32 characters: letters, digits, or underscore' }); } if (p.length < 8) { return res.status(400).json({ error: 'Password must be at least 8 characters' }); } const existing = await userDbGet('SELECT id FROM users WHERE username = ?', [u]); if (existing) { return res.status(409).json({ error: 'Username already taken' }); } const passwordHash = await bcrypt.hash(p, 10); const result = await userDbRun( 'INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, 0) RETURNING id', [u, passwordHash] ); req.session.userId = result.lastID; req.session.username = u; req.session.isAdmin = false; res.status(201).json({ message: 'Registered', user: { id: result.lastID, username: u, is_admin: false, address: null, latitude: null, longitude: null, } }); } catch (error) { console.error('Error during registration:', 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 — reads fresh user record so profile fields stay current app.get('/api/auth/check', async (req, res) => { try { if (req.session && req.session.userId) { const user = await userDbGet( 'SELECT id, username, is_admin, address, latitude, longitude FROM users WHERE id = ?', [req.session.userId] ); if (!user) { req.session.destroy(() => {}); return res.json({ authenticated: false }); } return res.json({ authenticated: true, user: { id: user.id, username: user.username, is_admin: Boolean(user.is_admin), address: user.address || null, latitude: user.latitude, longitude: user.longitude, } }); } res.json({ authenticated: false }); } catch (error) { console.error('Error checking auth:', error); res.status(500).json({ error: 'Internal server error' }); } }); // Current user's profile app.get('/api/users/me', requireAuth, async (req, res) => { try { const user = await userDbGet( 'SELECT id, username, is_admin, address, latitude, longitude FROM users WHERE id = ?', [req.session.userId] ); if (!user) return res.status(404).json({ error: 'Not found' }); res.json({ id: user.id, username: user.username, is_admin: Boolean(user.is_admin), address: user.address || null, latitude: user.latitude, longitude: user.longitude, }); } catch (error) { console.error('Error loading profile:', error); res.status(500).json({ error: 'Internal server error' }); } }); // Update the current user's address + coordinates app.put('/api/users/me', requireAuth, async (req, res) => { try { const { address, latitude, longitude } = req.body || {}; const addr = address == null || String(address).trim() === '' ? null : String(address).trim().slice(0, 500); let lat = null; let lon = null; if (latitude !== '' && latitude != null) { lat = typeof latitude === 'number' ? latitude : parseFloat(latitude); if (!Number.isFinite(lat) || lat < -90 || lat > 90) { return res.status(400).json({ error: 'Invalid latitude' }); } } if (longitude !== '' && longitude != null) { lon = typeof longitude === 'number' ? longitude : parseFloat(longitude); if (!Number.isFinite(lon) || lon < -180 || lon > 180) { return res.status(400).json({ error: 'Invalid longitude' }); } } await userDbRun( 'UPDATE users SET address = ?, latitude = ?, longitude = ? WHERE id = ?', [addr, lat, lon, req.session.userId] ); const user = await userDbGet( 'SELECT id, username, is_admin, address, latitude, longitude FROM users WHERE id = ?', [req.session.userId] ); res.json({ id: user.id, username: user.username, is_admin: Boolean(user.is_admin), address: user.address || null, latitude: user.latitude, longitude: user.longitude, }); } catch (error) { console.error('Error updating profile:', error); res.status(500).json({ error: 'Internal server error' }); } }); // ========== USER ALERTS API ========== // Get all alerts for the current user app.get('/api/alerts', requireAuth, async (req, res) => { try { const userId = req.session.userId; const rows = await userDbAll( 'SELECT id, type, medicine_nregistro, title, detail, schedule, created_at, updated_at FROM user_alerts WHERE user_id = ? ORDER BY created_at DESC', [userId] ); res.json(rows); } catch (error) { console.error('Error fetching alerts:', error); res.status(500).json({ error: 'Internal server error' }); } }); // Create a new alert app.post('/api/alerts', requireAuth, async (req, res) => { try { const userId = req.session.userId; const { type, medicine_nregistro, title, detail, schedule } = req.body || {}; if (!type || !['reminder', 'availability', 'schedule'].includes(type)) { return res.status(400).json({ error: 'Valid type is required (reminder, availability, schedule)' }); } const result = await userDbRun( 'INSERT INTO user_alerts (user_id, type, medicine_nregistro, title, detail, schedule) VALUES (?, ?, ?, ?, ?, ?)', [userId, type, medicine_nregistro || null, title || null, detail || null, schedule || null] ); const newAlert = await userDbGet('SELECT * FROM user_alerts WHERE id = ?', [result.lastID]); res.status(201).json(newAlert); } catch (error) { console.error('Error creating alert:', error); res.status(500).json({ error: 'Internal server error' }); } }); // Update an existing alert app.put('/api/alerts/:id', requireAuth, async (req, res) => { try { const userId = req.session.userId; const alertId = parseInt(req.params.id); const { medicine_nregistro, title, detail, schedule } = req.body || {}; const result = await userDbRun( 'UPDATE user_alerts SET medicine_nregistro = ?, title = ?, detail = ?, schedule = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?', [medicine_nregistro || null, title || null, detail || null, schedule || null, alertId, userId] ); if (result.changes === 0) { return res.status(404).json({ error: 'Alert not found' }); } const updatedAlert = await userDbGet('SELECT * FROM user_alerts WHERE id = ?', [alertId]); res.json(updatedAlert); } catch (error) { console.error('Error updating alert:', error); res.status(500).json({ error: 'Internal server error' }); } }); // Delete an alert app.delete('/api/alerts/:id', requireAuth, async (req, res) => { try { const userId = req.session.userId; const alertId = parseInt(req.params.id); const result = await userDbRun( 'DELETE FROM user_alerts WHERE id = ? AND user_id = ?', [alertId, userId] ); if (result.changes === 0) { return res.status(404).json({ error: 'Alert not found' }); } res.status(204).end(); } catch (error) { console.error('Error deleting alert:', error); res.status(500).json({ error: 'Internal server error' }); } }); // Geocode for any authenticated user (rate-limited). Same impl as /api/admin/geocode. app.get('/api/geocode', requireAuth, geocodeLimiter, 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 lat = parseFloat(result.hit.lat); const lon = parseFloat(result.hit.lon); if (!Number.isFinite(lat) || !Number.isFinite(lon)) { return res.status(502).json({ error: 'Geocoder result missing coordinates' }); } res.json({ lat, lon, displayName: result.hit.display_name || q, matchedQuery: result.triedQuery, }); } catch (err) { console.error('Geocode error:', err); res.status(500).json({ error: err.message || 'Geocode failed' }); } }); // ========== 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': 'FarmaClic/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', requireAdmin, 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', requireAdmin, async (req, res) => { try { const { name, address, phone, latitude, longitude, opening_hours } = 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 openingHoursValue = serializeOpeningHours(opening_hours); const result = await dbRun( 'INSERT INTO pharmacies (name, address, phone, latitude, longitude, opening_hours) VALUES (?, ?, ?, ?, ?, ?)', [name.trim(), address.trim(), phone ? phone.trim() : null, latitude || null, longitude || null, openingHoursValue] ); 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', requireAdmin, async (req, res) => { try { const pharmacyId = parseInt(req.params.id); const { name, address, phone, latitude, longitude, opening_hours } = req.body; if (!name || !address) { return res.status(400).json({ error: 'Name and address are required' }); } const openingHoursValue = serializeOpeningHours(opening_hours); await dbRun( 'UPDATE pharmacies SET name = ?, address = ?, phone = ?, latitude = ?, longitude = ?, opening_hours = ? WHERE id = ?', [name, address, phone || null, latitude || null, longitude || null, openingHoursValue, 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', requireAdmin, 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', requireAdmin, 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', requireAdmin, 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', requireAdmin, 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' }); } }); // Get medicines for a specific pharmacy (usando nregistro) app.get('/api/admin/pharmacies/:pharmacyId/medicines', requireAdmin, 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', requireAdmin, 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] ); if (!existing) { const pharmacy = await dbGet('SELECT id, name FROM pharmacies WHERE id = ?', [pharmacy_id]); sendPushForMedicine({ medicine_nregistro, medicine_name: medicine_name || relationship?.medicine_name || null, pharmacy, }).catch(err => console.error('[push] fanout error:', err)); } 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', requireAdmin, 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', requireAdmin, 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' }); } }); // Push notifications app.get('/api/notifications/vapid-public-key', (req, res) => { if (!PUSH_ENABLED) { return res.status(503).json({ error: 'Push notifications are not configured on this server' }); } res.json({ publicKey: VAPID_PUBLIC_KEY }); }); app.post('/api/notifications/subscribe', requireAuth, async (req, res) => { try { if (!PUSH_ENABLED) { return res.status(503).json({ error: 'Push notifications are not configured on this server' }); } const { medicine_nregistro, medicine_name, subscription, pharmacy_id } = req.body || {}; const endpoint = subscription?.endpoint; const p256dh = subscription?.keys?.p256dh; const auth = subscription?.keys?.auth; const userId = req.session.userId; if (!medicine_nregistro || !endpoint || !p256dh || !auth) { return res.status(400).json({ error: 'medicine_nregistro and a full PushSubscription are required' }); } if (pharmacy_id) { const existing = await dbGet( 'SELECT id FROM push_subscriptions_pharmacy WHERE medicine_nregistro = ? AND pharmacy_id = ? AND endpoint = ?', [medicine_nregistro, pharmacy_id, endpoint] ); if (existing) { await dbRun( 'UPDATE push_subscriptions_pharmacy SET medicine_name = ?, p256dh = ?, auth = ?, user_id = ? WHERE id = ?', [medicine_name || null, p256dh, auth, userId, existing.id] ); } else { await dbRun( 'INSERT INTO push_subscriptions_pharmacy (medicine_nregistro, medicine_name, pharmacy_id, endpoint, p256dh, auth, user_id) VALUES (?, ?, ?, ?, ?, ?, ?)', [medicine_nregistro, medicine_name || null, pharmacy_id, endpoint, p256dh, auth, userId] ); } } else { const existing = await dbGet( 'SELECT id FROM push_subscriptions WHERE medicine_nregistro = ? AND endpoint = ?', [medicine_nregistro, endpoint] ); if (existing) { await dbRun( 'UPDATE push_subscriptions SET medicine_name = ?, p256dh = ?, auth = ?, user_id = ? WHERE id = ?', [medicine_name || null, p256dh, auth, userId, existing.id] ); } else { await dbRun( 'INSERT INTO push_subscriptions (medicine_nregistro, medicine_name, endpoint, p256dh, auth, user_id) VALUES (?, ?, ?, ?, ?, ?)', [medicine_nregistro, medicine_name || null, endpoint, p256dh, auth, userId] ); } } res.status(201).json({ ok: true }); } catch (error) { console.error('Error subscribing to push:', error); res.status(500).json({ error: 'Internal server error' }); } }); app.delete('/api/notifications/unsubscribe', requireAuth, async (req, res) => { try { const { medicine_nregistro, endpoint, pharmacy_id } = req.body || {}; if (!endpoint) { return res.status(400).json({ error: 'endpoint is required' }); } if (pharmacy_id) { await dbRun( 'DELETE FROM push_subscriptions_pharmacy WHERE medicine_nregistro = ? AND pharmacy_id = ? AND endpoint = ?', [medicine_nregistro, pharmacy_id, endpoint] ); } else if (medicine_nregistro) { await dbRun( 'DELETE FROM push_subscriptions WHERE medicine_nregistro = ? AND endpoint = ?', [medicine_nregistro, endpoint] ); } else { await dbRun('DELETE FROM push_subscriptions WHERE endpoint = ?', [endpoint]); } res.status(204).end(); } catch (error) { console.error('Error unsubscribing from push:', error); res.status(500).json({ error: 'Internal server error' }); } }); // List the current user's saved notifications across all their devices app.get('/api/notifications/mine', requireAuth, async (req, res) => { try { const userId = req.session.userId; const globalRows = await dbAll( `SELECT id, medicine_nregistro, medicine_name, created_at FROM push_subscriptions WHERE user_id = ? ORDER BY created_at DESC`, [userId] ); const pharmacyRows = await dbAll( `SELECT ps.id, ps.medicine_nregistro, ps.medicine_name, ps.pharmacy_id, ps.created_at, p.name AS pharmacy_name, p.address AS pharmacy_address FROM push_subscriptions_pharmacy ps LEFT JOIN pharmacies p ON p.id = ps.pharmacy_id WHERE ps.user_id = ? ORDER BY ps.created_at DESC`, [userId] ); res.json({ global: globalRows.map(r => ({ ...r, scope: 'global' })), pharmacy: pharmacyRows.map(r => ({ ...r, scope: 'pharmacy' })), }); } catch (error) { console.error('Error listing user notifications:', error); res.status(500).json({ error: 'Internal server error' }); } }); // Delete one of the user's saved notifications by row id + scope app.delete('/api/notifications/mine', requireAuth, async (req, res) => { try { const userId = req.session.userId; const { scope, id } = req.body || {}; const rowId = Number(id); if (!Number.isInteger(rowId) || rowId <= 0) { return res.status(400).json({ error: 'id must be a positive integer' }); } if (scope === 'pharmacy') { await dbRun( 'DELETE FROM push_subscriptions_pharmacy WHERE id = ? AND user_id = ?', [rowId, userId] ); } else if (scope === 'global') { await dbRun( 'DELETE FROM push_subscriptions WHERE id = ? AND user_id = ?', [rowId, userId] ); } else { return res.status(400).json({ error: "scope must be 'global' or 'pharmacy'" }); } res.status(204).end(); } catch (error) { console.error('Error deleting user notification:', error); res.status(500).json({ error: 'Internal server error' }); } }); async function sendPushForMedicine({ medicine_nregistro, medicine_name, pharmacy }) { if (!PUSH_ENABLED) return { sent: 0, failed: 0, pruned: 0 }; const globalSubs = await dbAll( 'SELECT id, endpoint, p256dh, auth FROM push_subscriptions WHERE medicine_nregistro = ?', [medicine_nregistro] ); const pharmacySubs = pharmacy?.id ? await dbAll( 'SELECT id, endpoint, p256dh, auth FROM push_subscriptions_pharmacy WHERE medicine_nregistro = ? AND pharmacy_id = ?', [medicine_nregistro, pharmacy.id] ) : []; // Deduplicate by endpoint; pharmacy-specific overrides global so one notification per browser const endpointMap = new Map(); for (const sub of (globalSubs || [])) endpointMap.set(sub.endpoint, { ...sub, _pharmSub: false }); for (const sub of (pharmacySubs || [])) endpointMap.set(sub.endpoint, { ...sub, _pharmSub: true }); const subs = [...endpointMap.values()]; if (subs.length === 0) return { sent: 0, failed: 0, pruned: 0 }; const payload = JSON.stringify({ title: 'Medicine available', body: `${medicine_name || medicine_nregistro} is now available at ${pharmacy?.name || 'a pharmacy near you'}`, url: `/?nregistro=${encodeURIComponent(medicine_nregistro)}`, medicine_nregistro, medicine_name: medicine_name || null, pharmacy_id: pharmacy?.id ?? null, pharmacy_name: pharmacy?.name ?? null, }); let sent = 0; let failed = 0; let pruned = 0; await Promise.all(subs.map(async (sub) => { try { await webpush.sendNotification( { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }, payload ); sent++; } catch (err) { failed++; const status = err?.statusCode; if (status === 404 || status === 410) { try { const deleteQuery = sub._pharmSub ? 'DELETE FROM push_subscriptions_pharmacy WHERE id = ?' : 'DELETE FROM push_subscriptions WHERE id = ?'; await dbRun(deleteQuery, [sub.id]); pruned++; } catch {} } else { console.error('[push] send failed:', status, err?.body || err?.message); } } })); return { sent, failed, pruned }; } export { app, initDatabase, db }; if (process.env.NODE_ENV !== 'test') { initDatabase().then(() => { app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`); }); }); }