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
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:
@@ -1,7 +1,12 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }}
|
||||||
@@ -29,7 +29,46 @@ A web application to search for medicines from the official Spanish CIMA databas
|
|||||||
- npm or yarn
|
- npm or yarn
|
||||||
- **Redis server** (v6.0 or higher)
|
- **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
|
### 1. Install Redis
|
||||||
|
|
||||||
@@ -151,19 +190,24 @@ The frontend will run on `http://localhost:3000`
|
|||||||
|
|
||||||
```
|
```
|
||||||
FarmaFinder/
|
FarmaFinder/
|
||||||
|
├── docker-compose.yml # Full stack: backend + frontend + Redis
|
||||||
├── backend/
|
├── backend/
|
||||||
|
│ ├── Dockerfile
|
||||||
│ ├── server.js # Express server and API routes
|
│ ├── server.js # Express server and API routes
|
||||||
│ ├── cima-service.js # CIMA API integration with Redis cache
|
│ ├── cima-service.js # CIMA API integration with Redis cache
|
||||||
│ ├── redis-client.js # Redis connection configuration
|
│ ├── redis-client.js # Redis connection configuration
|
||||||
│ ├── seed.js # Database seeding script
|
│ ├── seed.js # Database seeding script
|
||||||
│ ├── create-admin.js # Admin user creation script
|
│ ├── create-admin.js # Admin user creation script
|
||||||
│ ├── database.sqlite # SQLite database (created after first run)
|
│ ├── .env.example # Environment variable template
|
||||||
│ └── package.json
|
│ └── package.json
|
||||||
├── frontend/
|
├── frontend/
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ ├── nginx.conf # Nginx config (Docker): serves SPA + proxies /api
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── components/ # React components
|
│ │ ├── components/ # React components
|
||||||
│ │ │ ├── admin/ # Admin panel components
|
│ │ │ ├── admin/ # Admin panel components
|
||||||
│ │ │ └── ... # Public view components
|
│ │ │ ├── PharmacyMap.jsx # Leaflet map (OpenStreetMap)
|
||||||
|
│ │ │ └── ...
|
||||||
│ │ ├── views/ # View components (Public/Admin)
|
│ │ ├── views/ # View components (Public/Admin)
|
||||||
│ │ ├── App.jsx # Main app component
|
│ │ ├── App.jsx # Main app component
|
||||||
│ │ └── main.jsx # Entry point
|
│ │ └── main.jsx # Entry point
|
||||||
|
|||||||
@@ -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=
|
||||||
@@ -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"]
|
||||||
@@ -1,12 +1,80 @@
|
|||||||
import { describe, test, expect, beforeEach } from '@jest/globals'
|
import { jest } from '@jest/globals'
|
||||||
|
|
||||||
describe('Server', () => {
|
jest.unstable_mockModule('../cima-service.js', () => ({
|
||||||
test('server module can be imported', () => {
|
searchMedicines: jest.fn(async () => []),
|
||||||
// Basic sanity test - just checking the module loads
|
getMedicineDetails: jest.fn(async () => null),
|
||||||
expect(typeof describe).toBe('function')
|
}))
|
||||||
})
|
|
||||||
|
|
||||||
test('1 + 1 equals 2', () => {
|
jest.unstable_mockModule('../farmacias-webhook-import.js', () => ({
|
||||||
expect(1 + 1).toBe(2)
|
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' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Generated
+188
-6
@@ -11,14 +11,17 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
|
"connect-sqlite3": "^0.9.16",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
"express-rate-limit": "^8.5.2",
|
||||||
"express-session": "^1.17.3",
|
"express-session": "^1.17.3",
|
||||||
"redis": "^4.6.0",
|
"redis": "^4.6.0",
|
||||||
"sqlite3": "^5.1.6"
|
"sqlite3": "^5.1.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"jest": "^29.7.0"
|
"jest": "^29.7.0",
|
||||||
|
"supertest": "^7.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
@@ -1048,6 +1051,18 @@
|
|||||||
"set-blocking": "^2.0.0"
|
"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": {
|
"node_modules/@npmcli/fs": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz",
|
||||||
@@ -1074,6 +1089,15 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/@redis/bloom": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz",
|
||||||
@@ -1459,6 +1483,12 @@
|
|||||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/asynckit": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
@@ -2064,12 +2094,32 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/console-control-strings": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
|
||||||
@@ -2119,6 +2169,12 @@
|
|||||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/cors": {
|
||||||
"version": "2.8.5",
|
"version": "2.8.5",
|
||||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
|
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
|
||||||
@@ -2280,6 +2336,16 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/diff-sequences": {
|
||||||
"version": "29.6.3",
|
"version": "29.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
|
||||||
@@ -2603,6 +2669,23 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/express-session": {
|
||||||
"version": "1.18.2",
|
"version": "1.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz",
|
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz",
|
||||||
@@ -2629,6 +2712,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/fb-watchman": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz",
|
||||||
@@ -2726,6 +2815,23 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/forwarded": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
@@ -3236,11 +3342,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.1.0",
|
"version": "10.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12"
|
"node": ">= 12"
|
||||||
}
|
}
|
||||||
@@ -5682,6 +5786,84 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/supports-color": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
|
|||||||
+10
-8
@@ -12,22 +12,24 @@
|
|||||||
"migrate": "node migrate.js",
|
"migrate": "node migrate.js",
|
||||||
"reset-db": "bash reset-db.sh",
|
"reset-db": "bash reset-db.sh",
|
||||||
"import-farmacias": "node import-farmacias.js",
|
"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": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.18.2",
|
"axios": "^1.6.0",
|
||||||
"cors": "^2.8.5",
|
|
||||||
"sqlite3": "^5.1.6",
|
|
||||||
"express-session": "^1.17.3",
|
|
||||||
"bcrypt": "^5.1.1",
|
"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",
|
"redis": "^4.6.0",
|
||||||
"axios": "^1.6.0"
|
"sqlite3": "^5.1.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"jest": "^29.7.0"
|
"jest": "^29.7.0",
|
||||||
|
"supertest": "^7.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+30
-55
@@ -5,7 +5,9 @@ import { promisify } from 'util';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import session from 'express-session';
|
import session from 'express-session';
|
||||||
|
import connectSqlite3 from 'connect-sqlite3';
|
||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
import { searchMedicines, getMedicineDetails } from './cima-service.js';
|
import { searchMedicines, getMedicineDetails } from './cima-service.js';
|
||||||
import { runFarmaciaWebhookImport, DEFAULT_FARMACIAS_WEBHOOK, importPharmaciesFromRows } from './farmacias-webhook-import.js';
|
import { runFarmaciaWebhookImport, DEFAULT_FARMACIAS_WEBHOOK, importPharmaciesFromRows } from './farmacias-webhook-import.js';
|
||||||
import { fetchPharmaciesExternal } from '../API/index.js';
|
import { fetchPharmaciesExternal } from '../API/index.js';
|
||||||
@@ -18,25 +20,36 @@ const PORT = process.env.PORT || 3001;
|
|||||||
|
|
||||||
// Configure CORS with credentials
|
// Configure CORS with credentials
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
origin: 'http://localhost:3000',
|
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
|
||||||
credentials: true
|
credentials: true
|
||||||
}));
|
}));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
// Configure session
|
const SQLiteStore = connectSqlite3(session);
|
||||||
app.use(session({
|
|
||||||
|
const sessionConfig = {
|
||||||
secret: process.env.SESSION_SECRET || 'farma-finder-secret-key-change-in-production',
|
secret: process.env.SESSION_SECRET || 'farma-finder-secret-key-change-in-production',
|
||||||
resave: false,
|
resave: false,
|
||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
cookie: {
|
cookie: {
|
||||||
secure: false, // Set to true in production with HTTPS
|
secure: process.env.NODE_ENV === 'production',
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
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
|
// 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);
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
// Custom wrapper to get lastID from db.run
|
// 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
|
// Create junction table for pharmacy-medicine relationships
|
||||||
// Ahora usa nregistro (número de registro de CIMA) en lugar de medicine_id local
|
// Ahora usa nregistro (número de registro de CIMA) en lugar de medicine_id local
|
||||||
await dbRun(`
|
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)`);
|
await dbRun(`CREATE INDEX IF NOT EXISTS idx_pharmacy_medicine ON pharmacy_medicines(medicine_nregistro)`);
|
||||||
|
|
||||||
// Create users table for authentication
|
// Create users table for authentication
|
||||||
@@ -116,7 +116,7 @@ async function initDatabase() {
|
|||||||
// API Routes
|
// API Routes
|
||||||
|
|
||||||
// Search medicines using CIMA API with Redis cache
|
// 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 {
|
try {
|
||||||
const query = req.query.q || '';
|
const query = req.query.q || '';
|
||||||
|
|
||||||
@@ -206,7 +206,7 @@ const requireAuth = (req, res, next) => {
|
|||||||
// ========== AUTHENTICATION ROUTES ==========
|
// ========== AUTHENTICATION ROUTES ==========
|
||||||
|
|
||||||
// Login endpoint
|
// Login endpoint
|
||||||
app.post('/api/auth/login', async (req, res) => {
|
app.post('/api/auth/login', loginLimiter, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { username, password } = req.body;
|
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)
|
// Get medicines for a specific pharmacy (usando nregistro)
|
||||||
app.get('/api/admin/pharmacies/:pharmacyId/medicines', requireAuth, async (req, res) => {
|
app.get('/api/admin/pharmacies/:pharmacyId/medicines', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -718,10 +690,13 @@ app.delete('/api/admin/pharmacy-medicines/:id', requireAuth, async (req, res) =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start server
|
export { app, initDatabase, db };
|
||||||
initDatabase().then(() => {
|
|
||||||
app.listen(PORT, () => {
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
console.log(`Server running on http://localhost:${PORT}`);
|
initDatabase().then(() => {
|
||||||
});
|
app.listen(PORT, () => {
|
||||||
});
|
console.log(`Server running on http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+31
-1
@@ -8,8 +8,10 @@
|
|||||||
"name": "farma-finder-frontend",
|
"name": "farma-finder-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0"
|
"react-dom": "^18.2.0",
|
||||||
|
"react-leaflet": "^4.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.4.0",
|
"@testing-library/jest-dom": "^6.4.0",
|
||||||
@@ -909,6 +911,16 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
@@ -2959,6 +2971,11 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/local-pkg": {
|
||||||
"version": "0.5.1",
|
"version": "0.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz",
|
||||||
@@ -3468,6 +3485,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
|
|||||||
@@ -9,16 +9,17 @@
|
|||||||
"test": "vitest"
|
"test": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0"
|
"react-dom": "^18.2.0",
|
||||||
|
"react-leaflet": "^4.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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",
|
"@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
@@ -1,16 +1,77 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||||
import App from './App.jsx'
|
import PublicView from './views/PublicView.jsx'
|
||||||
|
|
||||||
describe('App', () => {
|
beforeEach(() => {
|
||||||
it('renders the app header', () => {
|
vi.useFakeTimers()
|
||||||
render(<App />)
|
})
|
||||||
expect(screen.getByText(/FarmaFinder/i)).toBeInTheDocument()
|
|
||||||
|
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', () => {
|
it('does not fetch for queries shorter than 2 chars', async () => {
|
||||||
render(<App />)
|
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||||
expect(screen.getByText(/Public Search/i)).toBeInTheDocument()
|
render(<PublicView />)
|
||||||
expect(screen.getByText(/Admin Panel/i)).toBeInTheDocument()
|
|
||||||
|
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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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='© <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,6 +3,7 @@ import '../App.css';
|
|||||||
import SearchBar from '../components/SearchBar';
|
import SearchBar from '../components/SearchBar';
|
||||||
import MedicineResults from '../components/MedicineResults';
|
import MedicineResults from '../components/MedicineResults';
|
||||||
import PharmacyList from '../components/PharmacyList';
|
import PharmacyList from '../components/PharmacyList';
|
||||||
|
import PharmacyMap from '../components/PharmacyMap';
|
||||||
|
|
||||||
function PublicView() {
|
function PublicView() {
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@@ -102,7 +103,8 @@ function PublicView() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PharmacyList
|
<PharmacyMap pharmacies={pharmacies} />
|
||||||
|
<PharmacyList
|
||||||
pharmacies={pharmacies}
|
pharmacies={pharmacies}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import react from '@vitejs/plugin-react';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./src/test/setup.js'],
|
||||||
|
globals: true,
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
allowedHosts: ['localhost', 'oligocarpous-bilaterally-keiko.ngrok-free.dev'],
|
allowedHosts: ['localhost', 'oligocarpous-bilaterally-keiko.ngrok-free.dev'],
|
||||||
port: 3000,
|
port: 3000,
|
||||||
|
|||||||
Reference in New Issue
Block a user