4404db62ee
- Added avatar selection popup with predefined avatar options - Redesigned ProfileView (web + mobile) with avatar upload and editable fields - Added AvatarSelectionModal for mobile profile - Fixed medicine search: parse dosage terms (e.g. 'Paracetamol 1G') to query CIMA only by name and filter by dosage field - Updated styles for profile, search, and medicine results
1932 lines
62 KiB
JavaScript
1932 lines
62 KiB
JavaScript
// 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({ 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 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 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 — 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;
|
||
}
|
||
|
||
// 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)
|
||
)
|
||
`);
|
||
}
|
||
|
||
// 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
|
||
|
||
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, '');
|
||
|
||
// 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));
|
||
};
|
||
|
||
// Try to find barcodes first (typically numeric-heavy)
|
||
// TSI barcodes are usually numeric or alphanumeric with mostly numbers
|
||
const numericMatches = cleaned.match(/\d{10,30}/g) || [];
|
||
for (const match of numericMatches) {
|
||
if (match.length >= 10 && match.length <= 30) {
|
||
return match.toUpperCase();
|
||
}
|
||
}
|
||
|
||
// Try exact 16-char alphanumeric match, but filter card text
|
||
const exact = cleaned.match(/[A-Z0-9]{16}/i);
|
||
if (exact) {
|
||
const candidate = exact[0].toUpperCase();
|
||
if (!isCardText(candidate)) {
|
||
return candidate;
|
||
}
|
||
}
|
||
|
||
// Fallback: 8–30 alphanumeric chars, filter card text
|
||
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 3–32 characters: letters, digits, or underscore' });
|
||
}
|
||
if (p.length < 8) {
|
||
return res.status(400).json({ error: 'Password must be at least 8 characters' });
|
||
}
|
||
|
||
const existing = await userDbGet('SELECT id FROM users WHERE username = ?', [u]);
|
||
if (existing) {
|
||
return res.status(409).json({ error: 'Username already taken' });
|
||
}
|
||
|
||
const passwordHash = await bcrypt.hash(p, 10);
|
||
const result = await userDbRun(
|
||
'INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, 0) RETURNING id',
|
||
[u, passwordHash]
|
||
);
|
||
|
||
req.session.userId = result.lastID;
|
||
req.session.username = u;
|
||
req.session.isAdmin = false;
|
||
|
||
res.status(201).json({
|
||
message: 'Registered',
|
||
user: {
|
||
id: result.lastID,
|
||
username: u,
|
||
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 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}`);
|
||
});
|
||
});
|
||
}
|
||
|