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

This commit is contained in:
Ichitux
2026-05-20 22:28:17 +02:00
parent 7b288d33cb
commit 2eff93e66a
118 changed files with 13780 additions and 209 deletions
+6
View File
@@ -5,3 +5,9 @@ FARMACIAS_WEBHOOK_URL=
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
# Web Push (VAPID). Generate with:
# node -e "import('web-push').then(w => console.log(w.default.generateVAPIDKeys()))"
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:admin@example.com
@@ -0,0 +1,87 @@
import { parseOsmOpeningHours } from '../../API/opening-hours-osm.js';
describe('parseOsmOpeningHours', () => {
test('returns null for empty / non-string input', () => {
expect(parseOsmOpeningHours('')).toBeNull();
expect(parseOsmOpeningHours(null)).toBeNull();
expect(parseOsmOpeningHours(undefined)).toBeNull();
expect(parseOsmOpeningHours(123)).toBeNull();
});
test('24/7 → every day 00:0024:00', () => {
expect(parseOsmOpeningHours('24/7')).toEqual({
mon: ['00:00', '24:00'],
tue: ['00:00', '24:00'],
wed: ['00:00', '24:00'],
thu: ['00:00', '24:00'],
fri: ['00:00', '24:00'],
sat: ['00:00', '24:00'],
sun: ['00:00', '24:00'],
});
});
test('Mo-Fr 09:00-21:00 → weekdays set, weekend null', () => {
const result = parseOsmOpeningHours('Mo-Fr 09:00-21:00');
expect(result.mon).toEqual(['09:00', '21:00']);
expect(result.fri).toEqual(['09:00', '21:00']);
expect(result.sat).toBeNull();
expect(result.sun).toBeNull();
});
test('Multiple rules separated by semicolons', () => {
const result = parseOsmOpeningHours('Mo-Fr 09:00-21:00; Sa 09:00-14:00; Su closed');
expect(result.mon).toEqual(['09:00', '21:00']);
expect(result.fri).toEqual(['09:00', '21:00']);
expect(result.sat).toEqual(['09:00', '14:00']);
expect(result.sun).toBeNull();
});
test('Comma-separated day list', () => {
const result = parseOsmOpeningHours('Mo,We,Fr 10:00-14:00');
expect(result.mon).toEqual(['10:00', '14:00']);
expect(result.tue).toBeNull();
expect(result.wed).toEqual(['10:00', '14:00']);
expect(result.thu).toBeNull();
expect(result.fri).toEqual(['10:00', '14:00']);
});
test('Split shifts collapsed to first-open / last-close', () => {
const result = parseOsmOpeningHours('Mo-Fr 09:00-13:30,16:30-20:00');
expect(result.mon).toEqual(['09:00', '20:00']);
expect(result.fri).toEqual(['09:00', '20:00']);
});
test('Wrap-around day range Sa-Mo', () => {
const result = parseOsmOpeningHours('Sa-Mo 10:00-18:00');
expect(result.sat).toEqual(['10:00', '18:00']);
expect(result.sun).toEqual(['10:00', '18:00']);
expect(result.mon).toEqual(['10:00', '18:00']);
expect(result.tue).toBeNull();
});
test('Public-holiday rules are ignored', () => {
const result = parseOsmOpeningHours('Mo-Fr 09:00-21:00; PH off');
expect(result.mon).toEqual(['09:00', '21:00']);
});
test('Parenthetical comments are stripped', () => {
const result = parseOsmOpeningHours('Mo-Fr 09:00-14:00 (verano)');
expect(result.mon).toEqual(['09:00', '14:00']);
});
test('"off" applies null to those days', () => {
const result = parseOsmOpeningHours('Mo-Fr 09:00-21:00; Sa off');
expect(result.sat).toBeNull();
expect(result.mon).toEqual(['09:00', '21:00']);
});
test('Returns null when nothing parses', () => {
expect(parseOsmOpeningHours('see website')).toBeNull();
expect(parseOsmOpeningHours('?')).toBeNull();
});
test('Single-digit hours get zero-padded', () => {
const result = parseOsmOpeningHours('Mo 9:00-18:00');
expect(result.mon).toEqual(['09:00', '18:00']);
});
});
+1 -1
View File
@@ -27,7 +27,7 @@ beforeAll(async () => {
const hash = await bcrypt.hash('testpass', 10)
await new Promise((resolve, reject) => {
db.run(
'INSERT INTO users (username, password_hash) VALUES (?, ?)',
'INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, 1)',
['testadmin', hash],
(err) => (err ? reject(err) : resolve())
)
+31 -3
View File
@@ -3,6 +3,8 @@
* Default URL: FARMACIAS_WEBHOOK_URL env or the project webhook.
*/
import { parseOsmOpeningHours } from '../API/opening-hours-osm.js';
export const DEFAULT_FARMACIAS_WEBHOOK =
process.env.FARMACIAS_WEBHOOK_URL ||
'https://n8n.hacecalor.net/webhook/farmacias';
@@ -120,9 +122,33 @@ export function normalizePharmacyRecord(raw) {
phone: phone || null,
latitude,
longitude,
opening_hours: extractOpeningHours(raw),
};
}
function extractOpeningHours(raw) {
if (!raw || typeof raw !== 'object') return null;
const direct = raw.opening_hours;
if (direct && typeof direct === 'object' && !Array.isArray(direct)) {
return direct;
}
const candidates = [
direct,
raw.openingHours,
raw.horario,
raw.hours,
raw.tags?.opening_hours,
raw.properties?.opening_hours,
];
for (const c of candidates) {
if (typeof c === 'string' && c.trim()) {
const parsed = parseOsmOpeningHours(c);
if (parsed) return parsed;
}
}
return null;
}
/** n8n often returns [{ json: { ... } }, ...] */
function unwrapN8nItemArray(arr) {
if (!Array.isArray(arr) || arr.length === 0) return arr || [];
@@ -209,7 +235,7 @@ export async function importPharmaciesFromRows(dbGet, dbRun, rows) {
continue;
}
const { name, address, phone, latitude, longitude } = normalized;
const { name, address, phone, latitude, longitude, opening_hours } = normalized;
try {
const existing = await dbGet(
@@ -221,9 +247,11 @@ export async function importPharmaciesFromRows(dbGet, dbRun, rows) {
continue;
}
const openingHoursValue = opening_hours ? JSON.stringify(opening_hours) : null;
await dbRun(
'INSERT INTO pharmacies (name, address, phone, latitude, longitude) VALUES (?, ?, ?, ?, ?)',
[name, address, phone || null, latitude, longitude]
'INSERT INTO pharmacies (name, address, phone, latitude, longitude, opening_hours) VALUES (?, ?, ?, ?, ?, ?)',
[name, address, phone || null, latitude, longitude, openingHoursValue]
);
inserted++;
} catch (err) {
+135 -1
View File
@@ -17,7 +17,8 @@
"express-rate-limit": "^8.5.2",
"express-session": "^1.17.3",
"redis": "^4.6.0",
"sqlite3": "^5.1.6"
"sqlite3": "^5.1.6",
"web-push": "^3.6.7"
},
"devDependencies": {
"jest": "^29.7.0",
@@ -1489,6 +1490,18 @@
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
"dev": true
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -1711,6 +1724,12 @@
"readable-stream": "^3.4.0"
}
},
"node_modules/bn.js": {
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
@@ -1826,6 +1845,12 @@
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -2370,6 +2395,15 @@
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -3116,6 +3150,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/http-cache-semantics": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
@@ -4199,6 +4242,27 @@
"node": ">=6"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/kleur": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
@@ -4426,6 +4490,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -6176,6 +6246,70 @@
"makeerror": "1.0.12"
}
},
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/web-push/node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/web-push/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/web-push/node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/web-push/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+4 -3
View File
@@ -5,8 +5,8 @@
"main": "server.js",
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js",
"start": "node --env-file-if-exists=.env server.js",
"dev": "node --env-file-if-exists=.env --watch server.js",
"seed": "node seed.js",
"create-admin": "node create-admin.js",
"migrate": "node migrate.js",
@@ -26,7 +26,8 @@
"express-rate-limit": "^8.5.2",
"express-session": "^1.17.3",
"redis": "^4.6.0",
"sqlite3": "^5.1.6"
"sqlite3": "^5.1.6",
"web-push": "^3.6.7"
},
"devDependencies": {
"jest": "^29.7.0",
+522 -30
View File
@@ -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 332 characters: letters, digits, or underscore' });
}
if (p.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
const existing = await 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') {