Files
FarmaFinder/apps/backend/server.js
T
Antoni Nuñez Romeu 190b3d163d
Run Tests on Branches / Backend Tests (push) Successful in 3m38s
Run Tests on Branches / Frontend Tests (push) Successful in 3m28s
Restructure with Turborepo
2026-07-06 15:51:53 +02:00

1539 lines
50 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 } 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: 830 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 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,
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}`);
});
});
}