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(