Files
FarmaFinder/apps/backend/server.js
T
Antoni Nuñez Romeu 1a2e9aafb2 Add unified product search routes (CIMA OTC + Open Food Facts)
- GET /api/products/search?q={query} - parallel search across both sources
- GET /api/products/:source/:id - product detail by source (cima/openfoodfacts)
- Uses Promise.allSettled for fault-tolerant parallel search
- Added imports for searchOTC and OFF service functions
2026-07-13 20:58:40 +02:00

2307 lines
76 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, searchOTC } from './cima-service.js';
import { searchBabyProducts, getBabyProductDetails } from './off-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({ limit: '10mb' }));
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 EXPO_ACCESS_TOKEN = process.env.EXPO_ACCESS_TOKEN || '';
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 — web push 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 — PG when available, SQLite fallback
if (pgPool) {
await pgPool.query(`
CREATE TABLE IF NOT EXISTS pharmacies (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
address TEXT NOT NULL,
phone TEXT,
latitude DOUBLE PRECISION,
longitude DOUBLE PRECISION,
opening_hours TEXT
)
`);
} else {
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
if (pgPool) {
await pgPool.query(`
CREATE TABLE IF NOT EXISTS pharmacy_medicines (
id SERIAL PRIMARY KEY,
pharmacy_id INTEGER NOT NULL REFERENCES pharmacies(id),
medicine_nregistro TEXT NOT NULL,
medicine_name TEXT,
price REAL,
stock INTEGER DEFAULT 0,
UNIQUE(pharmacy_id, medicine_nregistro)
)
`);
await pgPool.query(`CREATE INDEX IF NOT EXISTS idx_pharmacy_medicine ON pharmacy_medicines(medicine_nregistro)`);
} else {
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()
)
`);
}
// Push subscription tables — PG when available, SQLite fallback
if (pgPool) {
await pgPool.query(`
CREATE TABLE IF NOT EXISTS push_subscriptions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
medicine_nregistro TEXT NOT NULL,
medicine_name TEXT,
endpoint TEXT NOT NULL,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, medicine_nregistro, endpoint)
)
`);
await pgPool.query(`CREATE INDEX IF NOT EXISTS idx_push_subscriptions_med ON push_subscriptions(medicine_nregistro)`);
await pgPool.query(`CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user ON push_subscriptions(user_id)`);
await pgPool.query(`
CREATE TABLE IF NOT EXISTS push_subscriptions_pharmacy (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
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 TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, medicine_nregistro, pharmacy_id, endpoint)
)
`);
await pgPool.query(`CREATE INDEX IF NOT EXISTS idx_push_subs_pharm ON push_subscriptions_pharmacy(medicine_nregistro, pharmacy_id)`);
await pgPool.query(`CREATE INDEX IF NOT EXISTS idx_push_subs_pharm_user ON push_subscriptions_pharmacy(user_id)`);
// Expo Push tokens (mobile)
await pgPool.query(`
CREATE TABLE IF NOT EXISTS expo_push_tokens (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expo_token TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, expo_token)
)
`);
await pgPool.query(`CREATE INDEX IF NOT EXISTS idx_expo_push_tokens_user ON expo_push_tokens(user_id)`);
} else {
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)`);
// Expo Push tokens (mobile)
await dbRun(`
CREATE TABLE IF NOT EXISTS expo_push_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
expo_token TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, expo_token)
)
`);
await dbRun(`CREATE INDEX IF NOT EXISTS idx_expo_push_tokens_user ON expo_push_tokens(user_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 new fields for profile redesign
if (pgPool) {
await pgPool.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS first_name TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS email TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS city TEXT;
`);
} else {
try { await dbRun('ALTER TABLE users ADD COLUMN first_name TEXT'); } catch {}
try { await dbRun('ALTER TABLE users ADD COLUMN last_name TEXT'); } catch {}
try { await dbRun('ALTER TABLE users ADD COLUMN avatar_url TEXT'); } catch {}
try { await dbRun('ALTER TABLE users ADD COLUMN email TEXT'); } catch {}
try { await dbRun('ALTER TABLE users ADD COLUMN city TEXT'); } catch {}
}
// Add user_id to push tables (SQLite only — PG tables already have it)
if (!pgPool) {
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;
}
// Search history for "find pharmacies near me"
if (pgPool) {
await pgPool.query(`
CREATE TABLE IF NOT EXISTS search_history (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
address TEXT NOT NULL,
latitude DOUBLE PRECISION,
longitude DOUBLE PRECISION,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
} else {
await dbRun(`
CREATE TABLE IF NOT EXISTS search_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
address TEXT NOT NULL,
latitude REAL,
longitude REAL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
)
`);
}
// Recent searches table
if (pgPool) {
await pgPool.query(`
CREATE TABLE IF NOT EXISTS recent_searches (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
medicine_id TEXT NOT NULL,
name TEXT NOT NULL,
active_ingredient TEXT,
dosage TEXT,
form TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
} else {
await dbRun(`
CREATE TABLE IF NOT EXISTS recent_searches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
medicine_id TEXT NOT NULL,
name TEXT NOT NULL,
active_ingredient TEXT,
dosage TEXT,
form TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
)
`);
}
// User addresses table
if (pgPool) {
await pgPool.query(`
CREATE TABLE IF NOT EXISTS user_addresses (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
address TEXT NOT NULL,
latitude DOUBLE PRECISION,
longitude DOUBLE PRECISION,
label TEXT DEFAULT '',
is_default INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
} else {
await dbRun(`
CREATE TABLE IF NOT EXISTS user_addresses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
address TEXT NOT NULL,
latitude REAL,
longitude REAL,
label TEXT DEFAULT '',
is_default INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
)
`);
}
} 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
// First try to get pharmacies with specific medicine data
let pharmacies = await userDbAll(`
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]);
// If no specific links exist, return all pharmacies as potential sellers
if (pharmacies.length === 0) {
pharmacies = await userDbAll(`
SELECT
p.id,
p.name,
p.address,
p.phone,
p.latitude,
p.longitude,
p.opening_hours,
NULL as price,
0 as stock
FROM pharmacies p
ORDER BY p.name
`);
}
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' });
}
});
// ========== UNIFIED PRODUCT SEARCH ==========
app.get('/api/products/search', async (req, res) => {
try {
const { q } = req.query;
if (!q || q.trim().length < 2) {
return res.json({ results: [], total: 0 });
}
const searchTerm = q.trim();
const [cimaResults, offResults] = await Promise.allSettled([
searchOTC(searchTerm),
searchBabyProducts(searchTerm)
]);
const cimaProducts = cimaResults.status === 'fulfilled' ? cimaResults.value : [];
const offProducts = offResults.status === 'fulfilled' ? offResults.value : [];
const allProducts = [...cimaProducts, ...offProducts]
.sort((a, b) => a.name.localeCompare(b.name));
res.json({
results: allProducts,
total: allProducts.length,
sources: { cima: cimaProducts.length, openfoodfacts: offProducts.length }
});
} catch (err) {
console.error('[Products] Search error:', err);
res.status(500).json({ error: 'Error searching products' });
}
});
app.get('/api/products/:source/:id', async (req, res) => {
try {
const { source, id } = req.params;
if (source === 'cima') {
const product = await getMedicineDetails(id);
if (!product) return res.status(404).json({ error: 'Product not found' });
return res.json({ ...product, source: 'cima', category: 'otc' });
}
if (source === 'openfoodfacts') {
const product = await getBabyProductDetails(id);
if (!product) return res.status(404).json({ error: 'Product not found' });
return res.json(product);
}
res.status(400).json({ error: 'Invalid source' });
} catch (err) {
console.error('[Products] Detail error:', err);
res.status(500).json({ error: 'Error fetching product details' });
}
});
// ========== 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 (database-based) ==========
const MAX_RECENT = 5;
// Get recent searches for the logged-in user
app.get('/api/search/recent', requireAuth, async (req, res) => {
try {
const userId = req.session.userId;
const rows = await userDbAll(
'SELECT id, medicine_id, name, active_ingredient, dosage, form, created_at FROM recent_searches WHERE user_id = ? ORDER BY created_at DESC LIMIT ?',
[userId, MAX_RECENT]
);
res.json(rows.map(r => ({
id: r.medicine_id,
name: r.name,
active_ingredient: r.active_ingredient,
dosage: r.dosage,
form: r.form,
timestamp: r.created_at,
})));
} catch (error) {
console.error('Error fetching recent searches:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Save a medicine to recent searches
app.post('/api/search/recent', requireAuth, async (req, res) => {
try {
const { medicine } = req.body;
if (!medicine || !medicine.id) {
return res.status(400).json({ error: 'Medicine data required' });
}
const userId = req.session.userId;
// Remove duplicate if exists (by medicine_id)
await userDbRun(
'DELETE FROM recent_searches WHERE user_id = ? AND medicine_id = ?',
[userId, medicine.id]
);
// Insert new entry
await userDbRun(
'INSERT INTO recent_searches (user_id, medicine_id, name, active_ingredient, dosage, form) VALUES (?, ?, ?, ?, ?, ?)',
[userId, medicine.id, medicine.name, medicine.active_ingredient || null, medicine.dosage || null, medicine.form || null]
);
// Trim to MAX_RECENT — delete oldest beyond limit
await userDbRun(
`DELETE FROM recent_searches WHERE user_id = ? AND id NOT IN (
SELECT id FROM recent_searches WHERE user_id = ? ORDER BY created_at DESC LIMIT ?
)`,
[userId, userId, 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 userDbAll(`
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, '');
// Common Spanish text on TSI cards that should NOT be treated as CIP
const CARD_TEXT_PATTERNS = [
/TARJETASANITARIA/i,
/TARJETASANIT/i,
/TARJETASEGURIDAD/i,
/SISTEMASALUD/i,
/SERVICIOANDALUZ/i,
/SALUD/i,
/NUMERO/i,
/NUM/i,
/CIP/i,
/CODIGO/i,
/IDENTIFICACION/i,
/PERSONAL/i,
/TITULAR/i,
/FECHA/i,
/NACIMIENTO/i,
/CADUCIDAD/i,
/VALIDA/i,
/ESPANA/i,
/ESPAÑA/i,
];
// Check if OCR result is just common card text
const isCardText = (candidate) => {
const upper = candidate.toUpperCase();
return CARD_TEXT_PATTERNS.some(pattern => pattern.test(upper));
};
// TSI barcodes are alphanumeric like "803401INURO0890417003211T11"
// Prioritize full alphanumeric matches (8-30 chars) before pure numeric
const exact = cleaned.match(/[A-Z0-9]{16}/i);
if (exact) {
const candidate = exact[0].toUpperCase();
if (!isCardText(candidate)) {
return candidate;
}
}
const looseMatches = cleaned.match(/[A-Z0-9]{8,30}/gi) || [];
for (const match of looseMatches) {
const candidate = match.toUpperCase();
if (!isCardText(candidate)) {
return candidate;
}
}
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' });
}
console.log('[OCR] Processing image, size:', req.file.size, 'bytes');
const worker = await createWorker('spa+eng');
const { data } = await worker.recognize(req.file.buffer);
await worker.terminate();
console.log('[OCR] Raw text:', data.text);
console.log('[OCR] Words:', data.words?.length || 0, 'words detected');
if (data.words && data.words.length > 0) {
console.log('[OCR] First 10 words:', data.words.slice(0, 10).map(w => `${w.text}(${w.confidence?.toFixed(2)})`).join(', '));
}
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,
first_name: user.first_name || null,
last_name: user.last_name || null,
avatar_url: user.avatar_url || null,
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 332 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,
first_name: null,
last_name: null,
avatar_url: null,
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, first_name, last_name, avatar_url, email, city, 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,
first_name: user.first_name || null,
last_name: user.last_name || null,
avatar_url: user.avatar_url || null,
email: user.email || null,
city: user.city || null,
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, first_name, last_name, avatar_url, email, city, 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,
first_name: user.first_name || null,
last_name: user.last_name || null,
avatar_url: user.avatar_url || null,
email: user.email || null,
city: user.city || null,
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 body = req.body || {};
const sets = [];
const vals = [];
if ('first_name' in body) {
const v = body.first_name == null ? null : String(body.first_name).trim().slice(0, 100);
sets.push('first_name = ?');
vals.push(v);
}
if ('last_name' in body) {
const v = body.last_name == null ? null : String(body.last_name).trim().slice(0, 100);
sets.push('last_name = ?');
vals.push(v);
}
if ('avatar_url' in body) {
const v = body.avatar_url == null ? null : String(body.avatar_url);
sets.push('avatar_url = ?');
vals.push(v);
}
if ('email' in body) {
const v = body.email == null ? null : String(body.email).trim().slice(0, 200);
sets.push('email = ?');
vals.push(v);
}
if ('city' in body) {
const v = body.city == null ? null : String(body.city).trim().slice(0, 200);
sets.push('city = ?');
vals.push(v);
}
if ('address' in body) {
const v = body.address == null || String(body.address).trim() === ''
? null
: String(body.address).trim().slice(0, 500);
sets.push('address = ?');
vals.push(v);
}
if ('latitude' in body) {
if (body.latitude !== '' && body.latitude != null) {
const lat = typeof body.latitude === 'number' ? body.latitude : parseFloat(body.latitude);
if (!Number.isFinite(lat) || lat < -90 || lat > 90) {
return res.status(400).json({ error: 'Invalid latitude' });
}
sets.push('latitude = ?');
vals.push(lat);
} else {
sets.push('latitude = ?');
vals.push(null);
}
}
if ('longitude' in body) {
if (body.longitude !== '' && body.longitude != null) {
const lon = typeof body.longitude === 'number' ? body.longitude : parseFloat(body.longitude);
if (!Number.isFinite(lon) || lon < -180 || lon > 180) {
return res.status(400).json({ error: 'Invalid longitude' });
}
sets.push('longitude = ?');
vals.push(lon);
} else {
sets.push('longitude = ?');
vals.push(null);
}
}
if (sets.length > 0) {
vals.push(req.session.userId);
await userDbRun(
'UPDATE users SET ' + sets.join(', ') + ' WHERE id = ?',
vals
);
}
const user = await userDbGet(
'SELECT id, username, first_name, last_name, avatar_url, email, city, is_admin, address, latitude, longitude FROM users WHERE id = ?',
[req.session.userId]
);
res.json({
id: user.id,
username: user.username,
first_name: user.first_name || null,
last_name: user.last_name || null,
avatar_url: user.avatar_url || null,
email: user.email || null,
city: user.city || null,
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' });
}
});
// ========== SEARCH HISTORY API ==========
// Get search history for current user
app.get('/api/search-history', requireAuth, async (req, res) => {
try {
const rows = await userDbAll(
'SELECT id, address, latitude, longitude, created_at FROM search_history WHERE user_id = ? ORDER BY created_at DESC LIMIT 20',
[req.session.userId]
);
res.json(rows);
} catch (error) {
console.error('Error fetching search history:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Add new search to history
app.post('/api/search-history', requireAuth, async (req, res) => {
try {
const { address, latitude, longitude } = req.body || {};
if (!address) {
return res.status(400).json({ error: 'Address is required' });
}
const result = await userDbRun(
'INSERT INTO search_history (user_id, address, latitude, longitude) VALUES (?, ?, ?, ?)',
[req.session.userId, address, latitude || null, longitude || null]
);
res.json({ id: result.lastID, address, latitude, longitude });
} catch (error) {
console.error('Error adding search history:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Delete search history item
app.delete('/api/search-history/:id', requireAuth, async (req, res) => {
try {
await userDbRun(
'DELETE FROM search_history WHERE id = ? AND user_id = ?',
[req.params.id, req.session.userId]
);
res.json({ ok: true });
} catch (error) {
console.error('Error deleting search history:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// ========== USER ADDRESSES API ==========
// List all addresses for the current user
app.get('/api/addresses', requireAuth, async (req, res) => {
try {
const rows = await userDbAll(
'SELECT id, address, latitude, longitude, label, is_default, created_at FROM user_addresses WHERE user_id = ? ORDER BY is_default DESC, created_at ASC',
[req.session.userId]
);
res.json(rows);
} catch (error) {
console.error('Error fetching addresses:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Add a new address
app.post('/api/addresses', requireAuth, async (req, res) => {
try {
const { address, latitude, longitude, label, is_default } = req.body || {};
if (!address) {
return res.status(400).json({ error: 'Address is required' });
}
if (is_default) {
await userDbRun(
'UPDATE user_addresses SET is_default = 0 WHERE user_id = ?',
[req.session.userId]
);
}
const result = await userDbRun(
'INSERT INTO user_addresses (user_id, address, latitude, longitude, label, is_default) VALUES (?, ?, ?, ?, ?, ?)',
[req.session.userId, address, latitude || null, longitude || null, label || '', is_default ? 1 : 0]
);
const newAddress = await userDbGet(
'SELECT id, address, latitude, longitude, label, is_default, created_at FROM user_addresses WHERE id = ?',
[result.lastID]
);
res.status(201).json(newAddress);
} catch (error) {
console.error('Error adding address:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Update an address
app.put('/api/addresses/:id', requireAuth, async (req, res) => {
try {
const addressId = parseInt(req.params.id);
const { address, latitude, longitude, label, is_default } = req.body || {};
if (!address) {
return res.status(400).json({ error: 'Address is required' });
}
if (is_default) {
await userDbRun(
'UPDATE user_addresses SET is_default = 0 WHERE user_id = ?',
[req.session.userId]
);
}
await userDbRun(
'UPDATE user_addresses SET address = ?, latitude = ?, longitude = ?, label = ?, is_default = ? WHERE id = ? AND user_id = ?',
[address, latitude || null, longitude || null, label || '', is_default ? 1 : 0, addressId, req.session.userId]
);
const updated = await userDbGet(
'SELECT id, address, latitude, longitude, label, is_default, created_at FROM user_addresses WHERE id = ?',
[addressId]
);
if (!updated) {
return res.status(404).json({ error: 'Address not found' });
}
res.json(updated);
} catch (error) {
console.error('Error updating address:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Set address as default
app.put('/api/addresses/:id/default', requireAuth, async (req, res) => {
try {
const addressId = parseInt(req.params.id);
const existing = await userDbGet(
'SELECT id FROM user_addresses WHERE id = ? AND user_id = ?',
[addressId, req.session.userId]
);
if (!existing) {
return res.status(404).json({ error: 'Address not found' });
}
await userDbRun(
'UPDATE user_addresses SET is_default = 0 WHERE user_id = ?',
[req.session.userId]
);
await userDbRun(
'UPDATE user_addresses SET is_default = 1 WHERE id = ?',
[addressId]
);
res.json({ ok: true });
} catch (error) {
console.error('Error setting default address:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Delete an address
app.delete('/api/addresses/:id', requireAuth, async (req, res) => {
try {
const addressId = parseInt(req.params.id);
const deleted = await userDbGet(
'SELECT is_default FROM user_addresses WHERE id = ? AND user_id = ?',
[addressId, req.session.userId]
);
await userDbRun(
'DELETE FROM user_addresses WHERE id = ? AND user_id = ?',
[addressId, req.session.userId]
);
// If the deleted address was default, set the oldest remaining as default
if (deleted?.is_default) {
const oldest = await userDbGet(
'SELECT id FROM user_addresses WHERE user_id = ? ORDER BY created_at ASC LIMIT 1',
[req.session.userId]
);
if (oldest) {
await userDbRun('UPDATE user_addresses SET is_default = 1 WHERE id = ?', [oldest.id]);
}
}
res.json({ ok: true });
} catch (error) {
console.error('Error deleting address:', 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 userDbGet(
'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 userDbRun(
'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 userDbGet(
'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 userDbRun(
'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 userDbGet(
'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 userDbRun('DELETE FROM pharmacy_medicines WHERE pharmacy_id = ?', [pharmacyId]);
// Delete the pharmacy
await userDbRun('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(
userDbGet,
userDbRun,
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(userDbGet, userDbRun, 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 userDbAll(`
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 userDbGet(
'SELECT * FROM pharmacy_medicines WHERE pharmacy_id = ? AND medicine_nregistro = ?',
[pharmacy_id, medicine_nregistro]
);
if (existing) {
// Update existing relationship
await userDbRun(
'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 userDbRun(
'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 userDbGet(
`SELECT * FROM pharmacy_medicines
WHERE pharmacy_id = ? AND medicine_nregistro = ?`,
[pharmacy_id, medicine_nregistro]
);
if (!existing) {
const pharmacy = await userDbGet('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 userDbRun(
'UPDATE pharmacy_medicines SET price = ?, stock = ? WHERE id = ?',
[price || null, stock || 0, id]
);
const updated = await userDbGet(
'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 userDbRun('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 userDbGet(
'SELECT id FROM push_subscriptions_pharmacy WHERE user_id = ? AND medicine_nregistro = ? AND pharmacy_id = ? AND endpoint = ?',
[userId, medicine_nregistro, pharmacy_id, endpoint]
);
if (existing) {
await userDbRun(
'UPDATE push_subscriptions_pharmacy SET medicine_name = ?, p256dh = ?, auth = ? WHERE id = ? AND user_id = ?',
[medicine_name || null, p256dh, auth, existing.id, userId]
);
} else {
await userDbRun(
'INSERT INTO push_subscriptions_pharmacy (user_id, medicine_nregistro, medicine_name, pharmacy_id, endpoint, p256dh, auth) VALUES (?, ?, ?, ?, ?, ?, ?)',
[userId, medicine_nregistro, medicine_name || null, pharmacy_id, endpoint, p256dh, auth]
);
}
} else {
const existing = await userDbGet(
'SELECT id FROM push_subscriptions WHERE user_id = ? AND medicine_nregistro = ? AND endpoint = ?',
[userId, medicine_nregistro, endpoint]
);
if (existing) {
await userDbRun(
'UPDATE push_subscriptions SET medicine_name = ?, p256dh = ?, auth = ? WHERE id = ? AND user_id = ?',
[medicine_name || null, p256dh, auth, existing.id, userId]
);
} else {
await userDbRun(
'INSERT INTO push_subscriptions (user_id, medicine_nregistro, medicine_name, endpoint, p256dh, auth) VALUES (?, ?, ?, ?, ?, ?)',
[userId, medicine_nregistro, medicine_name || null, endpoint, p256dh, auth]
);
}
}
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 || {};
const userId = req.session.userId;
if (!endpoint) {
return res.status(400).json({ error: 'endpoint is required' });
}
if (pharmacy_id) {
await userDbRun(
'DELETE FROM push_subscriptions_pharmacy WHERE user_id = ? AND medicine_nregistro = ? AND pharmacy_id = ? AND endpoint = ?',
[userId, medicine_nregistro, pharmacy_id, endpoint]
);
} else if (medicine_nregistro) {
await userDbRun(
'DELETE FROM push_subscriptions WHERE user_id = ? AND medicine_nregistro = ? AND endpoint = ?',
[userId, medicine_nregistro, endpoint]
);
} else {
await userDbRun('DELETE FROM push_subscriptions WHERE user_id = ? AND endpoint = ?', [userId, endpoint]);
}
res.status(204).end();
} catch (error) {
console.error('Error unsubscribing from push:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Expo Push tokens (mobile app)
app.post('/api/notifications/expo-register', requireAuth, async (req, res) => {
try {
const { token, medicine_nregistro, medicine_name, pharmacy_id } = req.body || {};
const userId = req.session.userId;
if (!token) {
return res.status(400).json({ error: 'expo token is required' });
}
// Upsert the token for this user
const existing = await userDbGet(
'SELECT id FROM expo_push_tokens WHERE user_id = ? AND expo_token = ?',
[userId, token]
);
if (!existing) {
await userDbRun(
'INSERT INTO expo_push_tokens (user_id, expo_token) VALUES (?, ?)',
[userId, token]
);
}
// Also store the medicine subscription (reuse existing tables with expo prefix for endpoint)
if (medicine_nregistro) {
const expoEndpoint = `expo://${token}`;
if (pharmacy_id) {
const existingSub = await userDbGet(
'SELECT id FROM push_subscriptions_pharmacy WHERE user_id = ? AND medicine_nregistro = ? AND pharmacy_id = ? AND endpoint = ?',
[userId, medicine_nregistro, pharmacy_id, expoEndpoint]
);
if (!existingSub) {
await userDbRun(
'INSERT INTO push_subscriptions_pharmacy (user_id, medicine_nregistro, medicine_name, pharmacy_id, endpoint, p256dh, auth) VALUES (?, ?, ?, ?, ?, ?, ?)',
[userId, medicine_nregistro, medicine_name || null, pharmacy_id, expoEndpoint, '', '']
);
}
} else {
const existingSub = await userDbGet(
'SELECT id FROM push_subscriptions WHERE user_id = ? AND medicine_nregistro = ? AND endpoint = ?',
[userId, medicine_nregistro, expoEndpoint]
);
if (!existingSub) {
await userDbRun(
'INSERT INTO push_subscriptions (user_id, medicine_nregistro, medicine_name, endpoint, p256dh, auth) VALUES (?, ?, ?, ?, ?, ?)',
[userId, medicine_nregistro, medicine_name || null, expoEndpoint, '', '']
);
}
}
}
res.status(201).json({ ok: true });
} catch (error) {
console.error('Error registering Expo push token:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
app.delete('/api/notifications/expo-unregister', requireAuth, async (req, res) => {
try {
const { medicine_nregistro, pharmacy_id } = req.body || {};
const userId = req.session.userId;
// Get all expo tokens for this user to find the endpoints
const tokens = await userDbAll(
'SELECT expo_token FROM expo_push_tokens WHERE user_id = ?',
[userId]
);
for (const t of tokens || []) {
const expoEndpoint = `expo://${t.expo_token}`;
if (pharmacy_id && medicine_nregistro) {
await userDbRun(
'DELETE FROM push_subscriptions_pharmacy WHERE user_id = ? AND medicine_nregistro = ? AND pharmacy_id = ? AND endpoint = ?',
[userId, medicine_nregistro, pharmacy_id, expoEndpoint]
);
} else if (medicine_nregistro) {
await userDbRun(
'DELETE FROM push_subscriptions WHERE user_id = ? AND medicine_nregistro = ? AND endpoint = ?',
[userId, medicine_nregistro, expoEndpoint]
);
}
}
// If no medicine specified, remove the token entirely
if (!medicine_nregistro) {
await userDbRun('DELETE FROM expo_push_tokens WHERE user_id = ?', [userId]);
}
res.status(204).end();
} catch (error) {
console.error('Error unregistering Expo push token:', 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 userDbAll(
`SELECT id, medicine_nregistro, medicine_name, created_at
FROM push_subscriptions
WHERE user_id = ?
ORDER BY created_at DESC`,
[userId]
);
const pharmacyRows = await userDbAll(
`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 userDbRun(
'DELETE FROM push_subscriptions_pharmacy WHERE id = ? AND user_id = ?',
[rowId, userId]
);
} else if (scope === 'global') {
await userDbRun(
'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 && !EXPO_ACCESS_TOKEN) return { sent: 0, failed: 0, pruned: 0 };
const globalSubs = await userDbAll(
'SELECT id, endpoint, p256dh, auth FROM push_subscriptions WHERE medicine_nregistro = ?',
[medicine_nregistro]
);
const pharmacySubs = pharmacy?.id ? await userDbAll(
'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 device
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 };
// Split into web push (VAPID) and Expo push (mobile)
const webSubs = [];
const expoTokens = [];
for (const sub of subs) {
if (sub.endpoint?.startsWith('expo://')) {
const token = sub.endpoint.replace('expo://', '');
if (!expoTokens.includes(token)) expoTokens.push(token);
} else {
webSubs.push(sub);
}
}
const title = 'Medicamento disponible';
const body = `${medicine_name || medicine_nregistro} está disponible en ${pharmacy?.name || 'una farmacia cercana'}`;
const url = `/?nregistro=${encodeURIComponent(medicine_nregistro)}`;
let sent = 0;
let failed = 0;
let pruned = 0;
// Web Push via VAPID
if (PUSH_ENABLED && webSubs.length > 0) {
const payload = JSON.stringify({
title,
body,
url,
medicine_nregistro,
medicine_name: medicine_name || null,
pharmacy_id: pharmacy?.id ?? null,
pharmacy_name: pharmacy?.name ?? null,
});
await Promise.all(webSubs.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 userDbRun(deleteQuery, [sub.id]);
pruned++;
} catch {}
} else {
console.error('[push] web send failed:', status, err?.body || err?.message);
}
}
}));
}
// Expo Push (mobile)
if (EXPO_ACCESS_TOKEN && expoTokens.length > 0) {
const chunks = [];
for (let i = 0; i < expoTokens.length; i += 100) {
chunks.push(expoTokens.slice(i, i + 100));
}
for (const chunk of chunks) {
const messages = chunk.map((token) => ({
to: token,
sound: 'default',
title,
body,
data: {
url,
medicine_nregistro,
medicine_name: medicine_name || null,
pharmacy_id: pharmacy?.id ?? null,
pharmacy_name: pharmacy?.name ?? null,
},
}));
try {
const response = await fetch('https://exp.host/--/api/v2/push/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${EXPO_ACCESS_TOKEN}`,
},
body: JSON.stringify(messages),
});
if (response.ok) {
const result = await response.json();
// Handle individual ticket errors
if (result.data) {
for (const ticket of result.data) {
if (ticket.status === 'ok') {
sent++;
} else {
failed++;
// Prune invalid tokens
if (ticket.message?.includes('InvalidCredentials') || ticket.message?.includes('DeviceNotRegistered')) {
const expoEndpoint = `expo://${ticket.id || ''}`;
try {
await userDbRun('DELETE FROM push_subscriptions WHERE endpoint = ?', [expoEndpoint]);
await userDbRun('DELETE FROM push_subscriptions_pharmacy WHERE endpoint = ?', [expoEndpoint]);
await userDbRun('DELETE FROM expo_push_tokens WHERE expo_token = ?', [ticket.id || '']);
pruned++;
} catch {}
}
}
}
} else {
sent += chunk.length;
}
} else {
failed += chunk.length;
console.error('[push] expo send failed:', response.status);
}
} catch (err) {
failed += chunk.length;
console.error('[push] expo send error:', 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}`);
});
});
}