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:
@@ -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:00–24: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']);
|
||||
});
|
||||
});
|
||||
@@ -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())
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Generated
+135
-1
@@ -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",
|
||||
|
||||
@@ -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
@@ -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