More changes than expected. Mobile version improvements & PWA
Build & Push Docker Images / test-backend (push) Successful in 44s
Build & Push Docker Images / test-frontend (push) Successful in 52s
Build & Push Docker Images / build-backend (push) Failing after 34s
Build & Push Docker Images / build-frontend (push) Failing after 30s
Build & Push Docker Images / test-backend (push) Successful in 44s
Build & Push Docker Images / test-frontend (push) Successful in 52s
Build & Push Docker Images / build-backend (push) Failing after 34s
Build & Push Docker Images / build-frontend (push) Failing after 30s
This commit is contained in:
+522
-30
@@ -8,6 +8,7 @@ import session from 'express-session';
|
||||
import connectSqlite3 from 'connect-sqlite3';
|
||||
import bcrypt from 'bcrypt';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import webpush from 'web-push';
|
||||
import { searchMedicines, getMedicineDetails } from './cima-service.js';
|
||||
import { runFarmaciaWebhookImport, DEFAULT_FARMACIAS_WEBHOOK, importPharmaciesFromRows } from './farmacias-webhook-import.js';
|
||||
import { fetchPharmaciesExternal } from '../API/index.js';
|
||||
@@ -18,6 +19,18 @@ const __dirname = path.dirname(__filename);
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// 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',
|
||||
@@ -47,6 +60,18 @@ 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');
|
||||
@@ -65,6 +90,28 @@ function dbRun(sql, params = []) {
|
||||
const dbAll = promisify(db.all.bind(db));
|
||||
const dbGet = promisify(db.get.bind(db));
|
||||
|
||||
// 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 {
|
||||
@@ -76,10 +123,18 @@ async function initDatabase() {
|
||||
address TEXT NOT NULL,
|
||||
phone TEXT,
|
||||
latitude REAL,
|
||||
longitude 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(`
|
||||
@@ -107,6 +162,47 @@ async function initDatabase() {
|
||||
)
|
||||
`);
|
||||
|
||||
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)`);
|
||||
|
||||
// Add is_admin to users table if not yet present (migration for existing DBs)
|
||||
try { await dbRun('ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0'); } catch {}
|
||||
|
||||
// Profile fields on users (migration for existing DBs)
|
||||
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 if not yet present (migration for existing DBs)
|
||||
try { await dbRun('ALTER TABLE push_subscriptions ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL'); } catch {}
|
||||
try { await dbRun('ALTER TABLE push_subscriptions_pharmacy ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL'); } catch {}
|
||||
|
||||
console.log('Database initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('Error initializing database:', error);
|
||||
@@ -140,13 +236,14 @@ app.get('/api/medicines/:medicineId/pharmacies', async (req, res) => {
|
||||
const nregistro = req.params.medicineId; // Ahora es el nregistro de CIMA
|
||||
|
||||
const pharmacies = await dbAll(`
|
||||
SELECT
|
||||
SELECT
|
||||
p.id,
|
||||
p.name,
|
||||
p.address,
|
||||
p.phone,
|
||||
p.latitude,
|
||||
p.longitude,
|
||||
p.opening_hours,
|
||||
pm.price,
|
||||
pm.stock
|
||||
FROM pharmacies p
|
||||
@@ -203,6 +300,17 @@ const requireAuth = (req, res, 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' });
|
||||
};
|
||||
|
||||
// ========== AUTHENTICATION ROUTES ==========
|
||||
|
||||
// Login endpoint
|
||||
@@ -234,12 +342,17 @@ app.post('/api/auth/login', loginLimiter, async (req, res) => {
|
||||
// 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
|
||||
username: user.username,
|
||||
is_admin: Boolean(user.is_admin),
|
||||
address: user.address || null,
|
||||
latitude: user.latitude,
|
||||
longitude: user.longitude,
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -248,6 +361,52 @@ app.post('/api/auth/login', loginLimiter, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 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 dbGet('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 dbRun(
|
||||
'INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, 0)',
|
||||
[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) => {
|
||||
@@ -259,18 +418,127 @@ app.post('/api/auth/logout', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Check authentication status
|
||||
app.get('/api/auth/check', (req, res) => {
|
||||
if (req.session && req.session.userId) {
|
||||
res.json({
|
||||
authenticated: true,
|
||||
user: {
|
||||
id: req.session.userId,
|
||||
username: req.session.username
|
||||
// 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 dbGet(
|
||||
'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 });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
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 dbGet(
|
||||
'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 dbRun(
|
||||
'UPDATE users SET address = ?, latitude = ?, longitude = ? WHERE id = ?',
|
||||
[addr, lat, lon, req.session.userId]
|
||||
);
|
||||
|
||||
const user = await dbGet(
|
||||
'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' });
|
||||
}
|
||||
});
|
||||
|
||||
// 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' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -355,7 +623,7 @@ async function nominatimSearchFirstHit(originalQuery) {
|
||||
}
|
||||
|
||||
// Geocode city → lat, lon, radius (OpenStreetMap Nominatim; admin-only, low volume)
|
||||
app.get('/api/admin/geocode', requireAuth, async (req, res) => {
|
||||
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' });
|
||||
@@ -395,9 +663,9 @@ app.get('/api/admin/geocode', requireAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Add a new pharmacy
|
||||
app.post('/api/admin/pharmacies', requireAuth, async (req, res) => {
|
||||
app.post('/api/admin/pharmacies', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { name, address, phone, latitude, longitude } = req.body;
|
||||
const { name, address, phone, latitude, longitude, opening_hours } = req.body;
|
||||
|
||||
if (!name || !address) {
|
||||
return res.status(400).json({ error: 'Name and address are required' });
|
||||
@@ -413,9 +681,11 @@ app.post('/api/admin/pharmacies', requireAuth, async (req, res) => {
|
||||
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) VALUES (?, ?, ?, ?, ?)',
|
||||
[name.trim(), address.trim(), phone ? phone.trim() : null, latitude || null, longitude || null]
|
||||
'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) {
|
||||
@@ -443,18 +713,20 @@ app.post('/api/admin/pharmacies', requireAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Update a pharmacy
|
||||
app.put('/api/admin/pharmacies/:id', requireAuth, async (req, res) => {
|
||||
app.put('/api/admin/pharmacies/:id', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const pharmacyId = parseInt(req.params.id);
|
||||
const { name, address, phone, latitude, longitude } = req.body;
|
||||
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 = ? WHERE id = ?',
|
||||
[name, address, phone || null, latitude || null, longitude || null, pharmacyId]
|
||||
'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(
|
||||
@@ -474,7 +746,7 @@ app.put('/api/admin/pharmacies/:id', requireAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Delete a pharmacy
|
||||
app.delete('/api/admin/pharmacies/:id', requireAuth, async (req, res) => {
|
||||
app.delete('/api/admin/pharmacies/:id', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const pharmacyId = parseInt(req.params.id);
|
||||
|
||||
@@ -493,7 +765,7 @@ app.delete('/api/admin/pharmacies/:id', requireAuth, async (req, res) => {
|
||||
|
||||
// Import pharmacies from webhook (e.g. n8n).
|
||||
// Body: { "url"?: string, "lat"?, "lon"|"lng"?, "radio"? } — lat/lon/radio add ?lat=&lon=&radio= (metres)
|
||||
app.post('/api/admin/pharmacies/import-webhook', requireAuth, async (req, res) => {
|
||||
app.post('/api/admin/pharmacies/import-webhook', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
||||
const url =
|
||||
@@ -525,7 +797,7 @@ app.post('/api/admin/pharmacies/import-webhook', requireAuth, async (req, res) =
|
||||
});
|
||||
|
||||
// Import from OpenStreetMap (Overpass), or a JSON open-data URL — see /API
|
||||
app.post('/api/admin/pharmacies/import-external', requireAuth, async (req, res) => {
|
||||
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;
|
||||
@@ -566,7 +838,7 @@ app.post('/api/admin/pharmacies/import-external', requireAuth, async (req, res)
|
||||
});
|
||||
|
||||
// Search medicines from CIMA API (para el admin)
|
||||
app.get('/api/admin/medicines', requireAuth, async (req, res) => {
|
||||
app.get('/api/admin/medicines', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const query = req.query.q || '';
|
||||
|
||||
@@ -585,7 +857,7 @@ app.get('/api/admin/medicines', requireAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Get medicines for a specific pharmacy (usando nregistro)
|
||||
app.get('/api/admin/pharmacies/:pharmacyId/medicines', requireAuth, async (req, res) => {
|
||||
app.get('/api/admin/pharmacies/:pharmacyId/medicines', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const pharmacyId = parseInt(req.params.pharmacyId);
|
||||
|
||||
@@ -610,7 +882,7 @@ app.get('/api/admin/pharmacies/:pharmacyId/medicines', requireAuth, async (req,
|
||||
});
|
||||
|
||||
// Add medicine to a pharmacy (or update if exists) usando nregistro de CIMA
|
||||
app.post('/api/admin/pharmacy-medicines', requireAuth, async (req, res) => {
|
||||
app.post('/api/admin/pharmacy-medicines', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { pharmacy_id, medicine_nregistro, medicine_name, price, stock } = req.body;
|
||||
|
||||
@@ -644,6 +916,15 @@ app.post('/api/admin/pharmacy-medicines', requireAuth, async (req, res) => {
|
||||
[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);
|
||||
@@ -652,7 +933,7 @@ app.post('/api/admin/pharmacy-medicines', requireAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Update pharmacy-medicine relationship
|
||||
app.put('/api/admin/pharmacy-medicines/:id', requireAuth, async (req, res) => {
|
||||
app.put('/api/admin/pharmacy-medicines/:id', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const { price, stock } = req.body;
|
||||
@@ -679,7 +960,7 @@ app.put('/api/admin/pharmacy-medicines/:id', requireAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
// Delete pharmacy-medicine relationship
|
||||
app.delete('/api/admin/pharmacy-medicines/:id', requireAuth, async (req, res) => {
|
||||
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]);
|
||||
@@ -690,6 +971,217 @@ app.delete('/api/admin/pharmacy-medicines/:id', requireAuth, async (req, res) =>
|
||||
}
|
||||
});
|
||||
|
||||
// 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') {
|
||||
|
||||
Reference in New Issue
Block a user