From cc9a24d6d621262b4788324cb234327b7b109d3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Tue, 19 May 2026 18:16:28 +0200 Subject: [PATCH] Plan Done, Dockerization and cleanup --- .claude/settings.local.json | 7 +- .gitea/workflows/docker.yaml | 66 ++++++++ README.md | 50 +++++- backend/.env.example | 7 + backend/Dockerfile | 8 + backend/__tests__/server.test.js | 84 +++++++++- backend/package-lock.json | 194 +++++++++++++++++++++++- backend/package.json | 18 ++- backend/server.js | 85 ++++------- docker-compose.yml | 37 +++++ frontend/Dockerfile | 11 ++ frontend/nginx.conf | 16 ++ frontend/package-lock.json | 32 +++- frontend/package.json | 15 +- frontend/src/App.test.jsx | 83 ++++++++-- frontend/src/components/PharmacyMap.jsx | 45 ++++++ frontend/src/views/PublicView.jsx | 4 +- frontend/vite.config.js | 5 + 18 files changed, 666 insertions(+), 101 deletions(-) create mode 100644 .gitea/workflows/docker.yaml create mode 100644 backend/.env.example create mode 100644 backend/Dockerfile create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/nginx.conf create mode 100644 frontend/src/components/PharmacyMap.jsx diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 47b3190..62a797e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,12 @@ { "permissions": { "allow": [ - "Bash(npm run:*)" + "Bash(npm run:*)", + "Bash(npm install *)", + "Bash(npm test *)", + "Bash(npx vitest *)", + "Bash(./node_modules/.bin/vitest run *)", + "Bash(git -C /home/f80ans0/Projects/Webs/FarmaFinder remote -v)" ] } } diff --git a/.gitea/workflows/docker.yaml b/.gitea/workflows/docker.yaml new file mode 100644 index 0000000..8d1a308 --- /dev/null +++ b/.gitea/workflows/docker.yaml @@ -0,0 +1,66 @@ +name: Build & Push Docker Images +run-name: ${{ gitea.actor }} - ${{ gitea.ref }} + +on: + push: + branches: + - main + +jobs: + test-backend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Install dependencies + run: cd backend && npm ci + - name: Run tests + run: cd backend && npm test + + test-frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Install dependencies + run: cd frontend && npm ci + - name: Run tests + run: cd frontend && npm test + + build-backend: + needs: [test-backend, test-frontend] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Log in to Gitea registry + uses: docker/login-action@v3 + with: + registry: git.hacecalor.net + username: ${{ gitea.actor }} + password: ${{ gitea.token }} + - name: Build and push backend image + uses: docker/build-push-action@v5 + with: + context: ./backend + push: true + tags: | + git.hacecalor.net/ichitux/farmafinder-backend:latest + git.hacecalor.net/ichitux/farmafinder-backend:${{ gitea.sha }} + + build-frontend: + needs: [test-backend, test-frontend] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Log in to Gitea registry + uses: docker/login-action@v3 + with: + registry: git.hacecalor.net + username: ${{ gitea.actor }} + password: ${{ gitea.token }} + - name: Build and push frontend image + uses: docker/build-push-action@v5 + with: + context: ./frontend + push: true + tags: | + git.hacecalor.net/ichitux/farmafinder-frontend:latest + git.hacecalor.net/ichitux/farmafinder-frontend:${{ gitea.sha }} diff --git a/README.md b/README.md index f065a32..92fc0d6 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,46 @@ A web application to search for medicines from the official Spanish CIMA databas - npm or yarn - **Redis server** (v6.0 or higher) -## πŸš€ Setup Instructions +## 🐳 Docker Setup (Recommended) + +Runs the full stack (backend, frontend, Redis) with a single command. + +**Prerequisites**: Docker and Docker Compose v2. + +```bash +# Copy and configure environment (optional β€” defaults work for local dev) +cp backend/.env.example backend/.env +# Edit backend/.env to set SESSION_SECRET and any other vars + +docker compose up --build +``` + +App available at `http://localhost:3000`. + +**First run β€” create an admin user:** +```bash +docker compose exec backend node create-admin.js +# Default: admin / admin123 β€” change after first login +``` + +**Seed sample pharmacies:** +```bash +docker compose exec backend node seed.js +``` + +**Stop:** +```bash +docker compose down +``` + +Database is persisted in a named Docker volume (`backend_data`). To wipe it: +```bash +docker compose down -v +``` + +--- + +## πŸš€ Manual Setup Instructions ### 1. Install Redis @@ -151,19 +190,24 @@ The frontend will run on `http://localhost:3000` ``` FarmaFinder/ +β”œβ”€β”€ docker-compose.yml # Full stack: backend + frontend + Redis β”œβ”€β”€ backend/ +β”‚ β”œβ”€β”€ Dockerfile β”‚ β”œβ”€β”€ server.js # Express server and API routes β”‚ β”œβ”€β”€ cima-service.js # CIMA API integration with Redis cache β”‚ β”œβ”€β”€ redis-client.js # Redis connection configuration β”‚ β”œβ”€β”€ seed.js # Database seeding script β”‚ β”œβ”€β”€ create-admin.js # Admin user creation script -β”‚ β”œβ”€β”€ database.sqlite # SQLite database (created after first run) +β”‚ β”œβ”€β”€ .env.example # Environment variable template β”‚ └── package.json β”œβ”€β”€ frontend/ +β”‚ β”œβ”€β”€ Dockerfile +β”‚ β”œβ”€β”€ nginx.conf # Nginx config (Docker): serves SPA + proxies /api β”‚ β”œβ”€β”€ src/ β”‚ β”‚ β”œβ”€β”€ components/ # React components β”‚ β”‚ β”‚ β”œβ”€β”€ admin/ # Admin panel components -β”‚ β”‚ β”‚ └── ... # Public view components +β”‚ β”‚ β”‚ β”œβ”€β”€ PharmacyMap.jsx # Leaflet map (OpenStreetMap) +β”‚ β”‚ β”‚ └── ... β”‚ β”‚ β”œβ”€β”€ views/ # View components (Public/Admin) β”‚ β”‚ β”œβ”€β”€ App.jsx # Main app component β”‚ β”‚ └── main.jsx # Entry point diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..37dd08c --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,7 @@ +PORT=3001 +SESSION_SECRET=change-me-in-production +CORS_ORIGIN=http://localhost:3000 +FARMACIAS_WEBHOOK_URL= +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..c3e9db8 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,8 @@ +FROM node:18-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm ci --omit=dev +COPY . . +RUN mkdir -p /app/data +EXPOSE 3001 +CMD ["node", "server.js"] diff --git a/backend/__tests__/server.test.js b/backend/__tests__/server.test.js index a2019e8..46edf8f 100644 --- a/backend/__tests__/server.test.js +++ b/backend/__tests__/server.test.js @@ -1,12 +1,80 @@ -import { describe, test, expect, beforeEach } from '@jest/globals' +import { jest } from '@jest/globals' -describe('Server', () => { - test('server module can be imported', () => { - // Basic sanity test - just checking the module loads - expect(typeof describe).toBe('function') - }) +jest.unstable_mockModule('../cima-service.js', () => ({ + searchMedicines: jest.fn(async () => []), + getMedicineDetails: jest.fn(async () => null), +})) - test('1 + 1 equals 2', () => { - expect(1 + 1).toBe(2) +jest.unstable_mockModule('../farmacias-webhook-import.js', () => ({ + runFarmaciaWebhookImport: jest.fn(async () => ({})), + DEFAULT_FARMACIAS_WEBHOOK: '', + importPharmaciesFromRows: jest.fn(async () => ({})), +})) + +jest.unstable_mockModule('../../API/index.js', () => ({ + fetchPharmaciesExternal: jest.fn(async () => []), +})) + +process.env.DATABASE_PATH = ':memory:' +process.env.NODE_ENV = 'test' + +const { default: supertest } = await import('supertest') +const { app, initDatabase, db } = await import('../server.js') +const { default: bcrypt } = await import('bcrypt') + +beforeAll(async () => { + await initDatabase() + const hash = await bcrypt.hash('testpass', 10) + await new Promise((resolve, reject) => { + db.run( + 'INSERT INTO users (username, password_hash) VALUES (?, ?)', + ['testadmin', hash], + (err) => (err ? reject(err) : resolve()) + ) + }) +}) + +describe('Medicine search', () => { + test('GET /api/medicines/search with empty q returns []', async () => { + const res = await supertest(app).get('/api/medicines/search?q=') + expect(res.status).toBe(200) + expect(res.body).toEqual([]) + }) + + test('GET /api/medicines/search with short q returns array', async () => { + const res = await supertest(app).get('/api/medicines/search?q=a') + expect(res.status).toBe(200) + expect(Array.isArray(res.body)).toBe(true) + }) +}) + +describe('Authentication', () => { + test('POST /api/auth/login with wrong creds returns 401', async () => { + const res = await supertest(app) + .post('/api/auth/login') + .send({ username: 'nobody', password: 'wrong' }) + expect(res.status).toBe(401) + }) +}) + +describe('Admin routes', () => { + test('GET /api/admin/medicines without auth returns 401', async () => { + const res = await supertest(app).get('/api/admin/medicines') + expect(res.status).toBe(401) + }) + + test('POST /api/admin/pharmacies with valid auth returns 201', async () => { + const agent = supertest.agent(app) + const login = await agent + .post('/api/auth/login') + .send({ username: 'testadmin', password: 'testpass' }) + expect(login.status).toBe(200) + + const res = await agent.post('/api/admin/pharmacies').send({ + name: 'Test Pharmacy', + address: 'Test Street 1', + }) + expect(res.status).toBe(201) + expect(res.body).toMatchObject({ name: 'Test Pharmacy', address: 'Test Street 1' }) }) }) diff --git a/backend/package-lock.json b/backend/package-lock.json index 7f8d563..63e2ce9 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,14 +11,17 @@ "dependencies": { "axios": "^1.6.0", "bcrypt": "^5.1.1", + "connect-sqlite3": "^0.9.16", "cors": "^2.8.5", "express": "^4.18.2", + "express-rate-limit": "^8.5.2", "express-session": "^1.17.3", "redis": "^4.6.0", "sqlite3": "^5.1.6" }, "devDependencies": { - "jest": "^29.7.0" + "jest": "^29.7.0", + "supertest": "^7.2.2" } }, "node_modules/@babel/code-frame": { @@ -1048,6 +1051,18 @@ "set-blocking": "^2.0.0" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@npmcli/fs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", @@ -1074,6 +1089,15 @@ "node": ">=10" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@redis/bloom": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", @@ -1459,6 +1483,12 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2064,12 +2094,32 @@ "node": ">= 0.8" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "license": "MIT" }, + "node_modules/connect-sqlite3": { + "version": "0.9.16", + "resolved": "https://registry.npmjs.org/connect-sqlite3/-/connect-sqlite3-0.9.16.tgz", + "integrity": "sha512-2gqo0QmcBBL8p8+eqpBETn7RgM/PaoKvpQGl8PfjEgwlr0VuMYNMxRJRrRCo3KR3fxMYeSsCw2tGNG0JKN9Nvg==", + "dependencies": { + "sqlite3": "^5.0.2" + }, + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -2119,6 +2169,12 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -2280,6 +2336,16 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -2603,6 +2669,23 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express-session": { "version": "1.18.2", "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz", @@ -2629,6 +2712,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -2726,6 +2815,23 @@ "node": ">= 6" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3236,11 +3342,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "license": "MIT", - "optional": true, + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "engines": { "node": ">= 12" } @@ -5682,6 +5786,84 @@ "node": ">=0.10.0" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/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==", + "dev": true + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/backend/package.json b/backend/package.json index 769f6d3..35de3d0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,22 +12,24 @@ "migrate": "node migrate.js", "reset-db": "bash reset-db.sh", "import-farmacias": "node import-farmacias.js", - "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js" + "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --forceExit" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { - "express": "^4.18.2", - "cors": "^2.8.5", - "sqlite3": "^5.1.6", - "express-session": "^1.17.3", + "axios": "^1.6.0", "bcrypt": "^5.1.1", + "connect-sqlite3": "^0.9.16", + "cors": "^2.8.5", + "express": "^4.18.2", + "express-rate-limit": "^8.5.2", + "express-session": "^1.17.3", "redis": "^4.6.0", - "axios": "^1.6.0" + "sqlite3": "^5.1.6" }, "devDependencies": { - "jest": "^29.7.0" + "jest": "^29.7.0", + "supertest": "^7.2.2" } } - diff --git a/backend/server.js b/backend/server.js index 6baf6fc..732ae93 100644 --- a/backend/server.js +++ b/backend/server.js @@ -5,7 +5,9 @@ import { promisify } from 'util'; import path from 'path'; import { fileURLToPath } from 'url'; import session from 'express-session'; +import connectSqlite3 from 'connect-sqlite3'; import bcrypt from 'bcrypt'; +import rateLimit from 'express-rate-limit'; 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,25 +20,36 @@ const PORT = process.env.PORT || 3001; // Configure CORS with credentials app.use(cors({ - origin: 'http://localhost:3000', + origin: process.env.CORS_ORIGIN || 'http://localhost:3000', credentials: true })); app.use(express.json()); -// Configure session -app.use(session({ +const SQLiteStore = connectSqlite3(session); + +const sessionConfig = { secret: process.env.SESSION_SECRET || 'farma-finder-secret-key-change-in-production', resave: false, saveUninitialized: false, cookie: { - secure: false, // Set to true in production with HTTPS + secure: process.env.NODE_ENV === 'production', httpOnly: true, maxAge: 24 * 60 * 60 * 1000 // 24 hours } -})); +}; + +if (process.env.NODE_ENV !== 'test') { + sessionConfig.store = new SQLiteStore({ db: 'sessions.sqlite', dir: __dirname }); +} + +// Configure session +app.use(session(sessionConfig)); + +const searchLimiter = rateLimit({ windowMs: 60_000, max: 30, standardHeaders: true, legacyHeaders: false }); +const loginLimiter = rateLimit({ windowMs: 60_000, max: 5, standardHeaders: true, legacyHeaders: false }); // Database setup -const dbPath = path.join(__dirname, 'database.sqlite'); +const dbPath = process.env.DATABASE_PATH || path.join(__dirname, 'database.sqlite'); const db = new sqlite3.Database(dbPath); // Custom wrapper to get lastID from db.run @@ -67,17 +80,6 @@ async function initDatabase() { ) `); - // Create medicines table - await dbRun(` - CREATE TABLE IF NOT EXISTS medicines ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - active_ingredient TEXT, - dosage TEXT, - form TEXT - ) - `); - // Create junction table for pharmacy-medicine relationships // Ahora usa nregistro (nΓΊmero de registro de CIMA) en lugar de medicine_id local await dbRun(` @@ -93,8 +95,6 @@ async function initDatabase() { ) `); - // Create indexes for better search performance - await dbRun(`CREATE INDEX IF NOT EXISTS idx_medicine_name ON medicines(name)`); await dbRun(`CREATE INDEX IF NOT EXISTS idx_pharmacy_medicine ON pharmacy_medicines(medicine_nregistro)`); // Create users table for authentication @@ -116,7 +116,7 @@ async function initDatabase() { // API Routes // Search medicines using CIMA API with Redis cache -app.get('/api/medicines/search', async (req, res) => { +app.get('/api/medicines/search', searchLimiter, async (req, res) => { try { const query = req.query.q || ''; @@ -206,7 +206,7 @@ const requireAuth = (req, res, next) => { // ========== AUTHENTICATION ROUTES ========== // Login endpoint -app.post('/api/auth/login', async (req, res) => { +app.post('/api/auth/login', loginLimiter, async (req, res) => { try { const { username, password } = req.body; @@ -584,34 +584,6 @@ app.get('/api/admin/medicines', requireAuth, async (req, res) => { } }); -// NOTA: Ya no necesitamos endpoints para crear/editar medicamentos localmente -// porque ahora usamos la API de CIMA como fuente de verdad - -// Add a new medicine (DEPRECATED - mantenido solo para compatibilidad) -app.post('/api/admin/medicines', requireAuth, async (req, res) => { - try { - // Ya no se agregan medicamentos localmente, se obtienen de CIMA - return res.status(400).json({ - error: 'Medicine management has been moved to CIMA API. Use the search feature to find medicines.' - }); - } catch (error) { - console.error('Error:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -// Update a medicine (DEPRECATED - mantenido solo para compatibilidad) -app.put('/api/admin/medicines/:id', requireAuth, async (req, res) => { - try { - return res.status(400).json({ - error: 'Medicine management has been moved to CIMA API.' - }); - } catch (error) { - console.error('Error updating medicine:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); - // Get medicines for a specific pharmacy (usando nregistro) app.get('/api/admin/pharmacies/:pharmacyId/medicines', requireAuth, async (req, res) => { try { @@ -718,10 +690,13 @@ app.delete('/api/admin/pharmacy-medicines/:id', requireAuth, async (req, res) => } }); -// Start server -initDatabase().then(() => { - app.listen(PORT, () => { - console.log(`Server running on http://localhost:${PORT}`); - }); -}); +export { app, initDatabase, db }; + +if (process.env.NODE_ENV !== 'test') { + initDatabase().then(() => { + app.listen(PORT, () => { + console.log(`Server running on http://localhost:${PORT}`); + }); + }); +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..44ade8d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +services: + redis: + image: redis:alpine + restart: unless-stopped + + backend: + image: git.hacecalor.net/ichitux/farmafinder-backend:latest + build: ./backend + restart: unless-stopped + ports: + - "3001:3001" + environment: + PORT: "3001" + NODE_ENV: production + SESSION_SECRET: ${SESSION_SECRET:-change-me-in-production} + CORS_ORIGIN: http://localhost:3000 + REDIS_HOST: redis + REDIS_PORT: "6379" + REDIS_PASSWORD: ${REDIS_PASSWORD:-} + DATABASE_PATH: /app/data/database.sqlite + FARMACIAS_WEBHOOK_URL: ${FARMACIAS_WEBHOOK_URL:-} + volumes: + - backend_data:/app/data + depends_on: + - redis + + frontend: + image: git.hacecalor.net/ichitux/farmafinder-frontend:latest + build: ./frontend + restart: unless-stopped + ports: + - "3000:80" + depends_on: + - backend + +volumes: + backend_data: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..e9df7cb --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,11 @@ +FROM node:18-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..85418f4 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,16 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://backend:3001; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a060c1a..a13a483 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,8 +8,10 @@ "name": "farma-finder-frontend", "version": "1.0.0", "dependencies": { + "leaflet": "^1.9.4", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-leaflet": "^4.2.1" }, "devDependencies": { "@testing-library/jest-dom": "^6.4.0", @@ -909,6 +911,16 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@react-leaflet/core": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz", + "integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -2959,6 +2971,11 @@ "node": ">=6" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==" + }, "node_modules/local-pkg": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", @@ -3468,6 +3485,19 @@ "dev": true, "license": "MIT" }, + "node_modules/react-leaflet": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz", + "integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==", + "dependencies": { + "@react-leaflet/core": "^2.1.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 55e7e48..12d75f2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,16 +9,17 @@ "test": "vitest" }, "dependencies": { + "leaflet": "^1.9.4", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-leaflet": "^4.2.1" }, "devDependencies": { - "@vitejs/plugin-react": "^4.2.1", - "vite": "^5.0.8", - "vitest": "^1.6.0", - "@testing-library/react": "^14.2.0", "@testing-library/jest-dom": "^6.4.0", - "jsdom": "^24.0.0" + "@testing-library/react": "^14.2.0", + "@vitejs/plugin-react": "^4.2.1", + "jsdom": "^24.0.0", + "vite": "^5.0.8", + "vitest": "^1.6.0" } } - diff --git a/frontend/src/App.test.jsx b/frontend/src/App.test.jsx index 6108d0b..294c882 100644 --- a/frontend/src/App.test.jsx +++ b/frontend/src/App.test.jsx @@ -1,16 +1,77 @@ -import { describe, it, expect } from 'vitest' -import { render, screen } from '@testing-library/react' -import App from './App.jsx' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, fireEvent, act } from '@testing-library/react' +import PublicView from './views/PublicView.jsx' -describe('App', () => { - it('renders the app header', () => { - render() - expect(screen.getByText(/FarmaFinder/i)).toBeInTheDocument() +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() +}) + +describe('PublicView', () => { + it('renders search bar on load with no results', () => { + render() + expect(screen.getByPlaceholderText(/search for a medicine/i)).toBeInTheDocument() + expect(screen.queryByRole('list')).not.toBeInTheDocument() }) - it('renders the view switcher', () => { - render() - expect(screen.getByText(/Public Search/i)).toBeInTheDocument() - expect(screen.getByText(/Admin Panel/i)).toBeInTheDocument() + it('does not fetch for queries shorter than 2 chars', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + render() + + fireEvent.change(screen.getByPlaceholderText(/search for a medicine/i), { + target: { value: 'a' }, + }) + + await act(async () => { + await vi.runAllTimersAsync() + }) + + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('fetches and displays results for valid query', async () => { + const medicines = [ + { id: 'REG001', name: 'Ibuprofeno 400mg', active_ingredient: 'Ibuprofeno', dosage: '400mg', form: 'Comprimido' }, + ] + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + json: async () => medicines, + }) + + render() + + fireEvent.change(screen.getByPlaceholderText(/search for a medicine/i), { + target: { value: 'ibu' }, + }) + + await act(async () => { + await vi.runAllTimersAsync() + }) + + expect(screen.getByText('Ibuprofeno 400mg')).toBeInTheDocument() + }) + + it('clears results when query is cleared after a search', async () => { + const medicines = [ + { id: 'REG001', name: 'Ibuprofeno 400mg', active_ingredient: 'Ibuprofeno', dosage: '400mg', form: 'Comprimido' }, + ] + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + json: async () => medicines, + }) + + render() + const input = screen.getByPlaceholderText(/search for a medicine/i) + + fireEvent.change(input, { target: { value: 'ibu' } }) + await act(async () => { await vi.runAllTimersAsync() }) + expect(screen.getByText('Ibuprofeno 400mg')).toBeInTheDocument() + + fireEvent.change(input, { target: { value: '' } }) + await act(async () => { await vi.runAllTimersAsync() }) + + expect(screen.queryByText('Ibuprofeno 400mg')).not.toBeInTheDocument() }) }) diff --git a/frontend/src/components/PharmacyMap.jsx b/frontend/src/components/PharmacyMap.jsx new file mode 100644 index 0000000..ca9876c --- /dev/null +++ b/frontend/src/components/PharmacyMap.jsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet'; +import 'leaflet/dist/leaflet.css'; +import L from 'leaflet'; + +// Fix default marker icon broken by webpack/vite asset bundling +delete L.Icon.Default.prototype._getIconUrl; +L.Icon.Default.mergeOptions({ + iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png', + iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png', + shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png', +}); + +function PharmacyMap({ pharmacies }) { + const located = pharmacies.filter(p => p.latitude != null && p.longitude != null); + + if (located.length === 0) return null; + + const center = [ + located.reduce((s, p) => s + p.latitude, 0) / located.length, + located.reduce((s, p) => s + p.longitude, 0) / located.length, + ]; + + return ( +
+ + + {located.map(pharmacy => ( + + + {pharmacy.name}
+ {pharmacy.address} + {pharmacy.phone && <>
{pharmacy.phone}} +
+
+ ))} +
+
+ ); +} + +export default PharmacyMap; diff --git a/frontend/src/views/PublicView.jsx b/frontend/src/views/PublicView.jsx index 78c0d37..9de502c 100644 --- a/frontend/src/views/PublicView.jsx +++ b/frontend/src/views/PublicView.jsx @@ -3,6 +3,7 @@ import '../App.css'; import SearchBar from '../components/SearchBar'; import MedicineResults from '../components/MedicineResults'; import PharmacyList from '../components/PharmacyList'; +import PharmacyMap from '../components/PharmacyMap'; function PublicView() { const [searchQuery, setSearchQuery] = useState(''); @@ -102,7 +103,8 @@ function PublicView() { - + diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 82f8918..ca774a2 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -3,6 +3,11 @@ import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], + test: { + environment: 'jsdom', + setupFiles: ['./src/test/setup.js'], + globals: true, + }, server: { allowedHosts: ['localhost', 'oligocarpous-bilaterally-keiko.ngrok-free.dev'], port: 3000,