Plan Done, Dockerization and cleanup
Build & Push Docker Images / test-backend (push) Successful in 55s
Build & Push Docker Images / test-frontend (push) Successful in 43s
Run Tests / test-backend (push) Successful in 48s
Run Tests / test-frontend (push) Successful in 42s
Build & Push Docker Images / build-backend (push) Failing after 4m7s
Build & Push Docker Images / build-frontend (push) Failing after 32s

This commit is contained in:
Antoni Nuñez Romeu
2026-05-19 18:16:28 +02:00
parent 93ec8e6a6c
commit cc9a24d6d6
18 changed files with 666 additions and 101 deletions
+6 -1
View File
@@ -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)"
]
}
}
+66
View File
@@ -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 }}
+47 -3
View File
@@ -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
+7
View File
@@ -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=
+8
View File
@@ -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"]
+76 -8
View File
@@ -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' })
})
})
+188 -6
View File
@@ -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",
+10 -8
View File
@@ -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"
}
}
+30 -55
View File
@@ -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}`);
});
});
}
+37
View File
@@ -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:
+11
View File
@@ -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
+16
View File
@@ -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;
}
}
+31 -1
View File
@@ -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",
+8 -7
View File
@@ -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"
}
}
+72 -11
View File
@@ -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(<App />)
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(<PublicView />)
expect(screen.getByPlaceholderText(/search for a medicine/i)).toBeInTheDocument()
expect(screen.queryByRole('list')).not.toBeInTheDocument()
})
it('renders the view switcher', () => {
render(<App />)
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(<PublicView />)
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(<PublicView />)
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(<PublicView />)
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()
})
})
+45
View File
@@ -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 (
<div className="pharmacy-map-wrapper" style={{ height: '350px', width: '100%', marginTop: '1rem', borderRadius: '8px', overflow: 'hidden' }}>
<MapContainer center={center} zoom={13} style={{ height: '100%', width: '100%' }} scrollWheelZoom={false}>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{located.map(pharmacy => (
<Marker key={pharmacy.id} position={[pharmacy.latitude, pharmacy.longitude]}>
<Popup>
<strong>{pharmacy.name}</strong><br />
{pharmacy.address}
{pharmacy.phone && <><br />{pharmacy.phone}</>}
</Popup>
</Marker>
))}
</MapContainer>
</div>
);
}
export default PharmacyMap;
+3 -1
View File
@@ -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() {
</button>
</div>
<PharmacyList
<PharmacyMap pharmacies={pharmacies} />
<PharmacyList
pharmacies={pharmacies}
loading={loading}
/>
+5
View File
@@ -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,