Compare commits
9 Commits
c8b830017b
...
86962dfa3a
| Author | SHA1 | Date | |
|---|---|---|---|
| 86962dfa3a | |||
| 271d23b072 | |||
| 258839dfc8 | |||
| ddd8ae8537 | |||
| e3e7d2f60b | |||
| d43cbfa44e | |||
| 573d2e5d35 | |||
| 076ca2d590 | |||
| 1f340c1aa1 |
@@ -254,30 +254,16 @@ jobs:
|
||||
set -euo pipefail
|
||||
git pull
|
||||
|
||||
- name: Sync .env files from .env.example
|
||||
- name: Inject .env files from Gitea variables
|
||||
working-directory: /docker/FarmaFinder
|
||||
env:
|
||||
GITEA_TOKEN: ${{ vars.GITEA_TOKEN }}
|
||||
GITEA_OWNER: Ichitux
|
||||
GITEA_REPO: FarmaFinder
|
||||
WORK_DIR: /docker/FarmaFinder
|
||||
run: |
|
||||
set -euo pipefail
|
||||
while IFS= read -r env_example; do
|
||||
dir=$(dirname "$env_example")
|
||||
env_file="$dir/.env"
|
||||
|
||||
if [ ! -f "$env_file" ]; then
|
||||
cp "$env_example" "$env_file"
|
||||
echo "[env-sync] Created $env_file from $env_example"
|
||||
else
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
case "$line" in
|
||||
''|\#*) continue ;;
|
||||
esac
|
||||
key=$(echo "$line" | cut -d'=' -f1)
|
||||
if ! grep -qF "$key=" "$env_file"; then
|
||||
echo "$line" >> "$env_file"
|
||||
echo "[env-sync] Added missing key '$key' to $env_file"
|
||||
fi
|
||||
done < "$env_example"
|
||||
fi
|
||||
done < <(find apps -name ".env.example" -type f)
|
||||
bash scripts/deploy-env.sh
|
||||
|
||||
- name: Deploy containers
|
||||
working-directory: /docker/FarmaFinder
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"pid":568607,"startedAt":1784046408782}
|
||||
{"pid":3854791,"startedAt":1784736547988}
|
||||
@@ -184,6 +184,44 @@ cp .env.example .env
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
### Production Environment Variables (IMPORTANT)
|
||||
|
||||
The root `.env` file is the **source of truth** for all Docker Compose services. The `docker-compose.yml` uses `${VAR:?...}` syntax which reads from this file. **Do not use placeholder values in production** — the backend validates them on startup and will crash.
|
||||
|
||||
Required variables to set with real secrets:
|
||||
|
||||
```env
|
||||
# PostgreSQL password (used by postgres, backend, n8n, exporters)
|
||||
PG_PASSWORD=<strong-random-hex>
|
||||
|
||||
# Backend session secret (required, non-placeholder)
|
||||
SESSION_SECRET=<strong-random-hex>
|
||||
|
||||
# Backend CORS origin (must be your real domain, not localhost)
|
||||
CORS_ORIGIN=https://farmacias.hacecalor.net
|
||||
|
||||
# N8N admin password
|
||||
N8N_PASSWORD=<strong-random-string>
|
||||
|
||||
# Parapharmacy API keys (required for product ingestion)
|
||||
INGEST_API_KEY=<strong-random-hex>
|
||||
ADMIN_API_KEY=<strong-random-hex>
|
||||
```
|
||||
|
||||
Generate secrets with:
|
||||
```bash
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
```
|
||||
|
||||
**After changing `PG_PASSWORD`**, you must reset the PostgreSQL volume:
|
||||
```bash
|
||||
docker compose down
|
||||
docker volume rm farmafinder_postgres_data
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Then re-seed data (see "First Run" below).
|
||||
|
||||
### Services
|
||||
|
||||
| Service | URL | Description |
|
||||
@@ -201,10 +239,27 @@ docker compose up --build
|
||||
docker compose exec backend node create-admin.js
|
||||
# Default: admin / admin123
|
||||
|
||||
# Seed sample pharmacies
|
||||
# Seed sample pharmacies (SQLite — for local dev)
|
||||
docker compose exec backend node seed.js
|
||||
|
||||
# Seed parapharmacy products (requires INGEST_API_KEY)
|
||||
# The n8n-init container handles this automatically on first run.
|
||||
# To re-seed manually after a PostgreSQL reset:
|
||||
docker compose exec parapharmacy-api node -e "
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const seed = JSON.parse(fs.readFileSync('/home/node/seed.json','utf8'));
|
||||
const body = JSON.stringify(seed);
|
||||
const req = http.request('http://localhost:3002/api/products/bulk', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-service-key': process.env.INGEST_API_KEY, 'Content-Length': Buffer.byteLength(body) }
|
||||
}, res => { let d=''; res.on('data',c=>d+=c); res.on('end',()=>console.log(d)); });
|
||||
req.write(body); req.end();
|
||||
"
|
||||
```
|
||||
|
||||
After a PostgreSQL volume reset, the n8n-init container will re-import workflows automatically. The backend re-creates its PG tables on startup (`initDatabase()` in `server.js`).
|
||||
|
||||
### N8N Setup
|
||||
|
||||
N8N auto-creates an admin account on first start:
|
||||
@@ -225,6 +280,16 @@ docker compose down
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
### Reset PostgreSQL Only (keep other data)
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker volume rm farmafinder_postgres_data
|
||||
docker compose up -d
|
||||
# n8n-init re-imports workflows; backend re-creates tables on startup
|
||||
# Re-seed parapharmacy products (see First Run above)
|
||||
```
|
||||
|
||||
## Manual Setup
|
||||
|
||||
### 1. Install Redis
|
||||
@@ -325,13 +390,27 @@ See [Parapharmacy Documentation](docs/parapharmacy.md) for details.
|
||||
|
||||
## Database Schema
|
||||
|
||||
### SQLite Tables
|
||||
In production (Docker), the backend uses **PostgreSQL**. In local dev without PG, it falls back to **SQLite**.
|
||||
|
||||
**pharmacies**: `id`, `name`, `address`, `phone`, `latitude`, `longitude`
|
||||
### PostgreSQL Tables (Production)
|
||||
|
||||
**pharmacies**: `id`, `name`, `address`, `phone`, `latitude`, `longitude`, `opening_hours`
|
||||
|
||||
**pharmacy_medicines**: `id`, `pharmacy_id`, `medicine_nregistro`, `medicine_name`, `price`, `stock`
|
||||
|
||||
**users**: `id`, `username`, `password_hash`, `created_at`
|
||||
**users**: `id`, `username`, `password_hash`, `is_admin`, `address`, `latitude`, `longitude`, `created_at`
|
||||
|
||||
**user_alerts**: `id`, `user_id`, `type`, `medicine_nregistro`, `title`, `detail`, `schedule`, `created_at`, `updated_at`
|
||||
|
||||
**push_subscriptions**: `id`, `user_id`, `medicine_nregistro`, `medicine_name`, `endpoint`, `p256dh`, `auth`, `created_at`
|
||||
|
||||
**push_subscriptions_pharmacy**: `id`, `user_id`, `medicine_nregistro`, `medicine_name`, `pharmacy_id`, `endpoint`, `p256dh`, `auth`, `created_at`
|
||||
|
||||
**expo_push_tokens**: `id`, `user_id`, `expo_token`, `created_at`
|
||||
|
||||
### SQLite Tables (Local Dev Fallback)
|
||||
|
||||
Same schema as above minus foreign key constraints and PostgreSQL-specific types.
|
||||
|
||||
### Redis Cache
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { isOpenNow, isAlwaysOpen } from '../src/hours.js';
|
||||
|
||||
const HOURS_24_7 = {
|
||||
mon: ['00:00', '24:00'],
|
||||
tue: ['00:00', '24:00'],
|
||||
wed: ['00:00', '24:00'],
|
||||
thu: ['00:00', '24:00'],
|
||||
fri: ['00:00', '24:00'],
|
||||
sat: ['00:00', '24:00'],
|
||||
sun: ['00:00', '24:00'],
|
||||
};
|
||||
|
||||
const HOURS_NORMAL_WEEK = {
|
||||
mon: ['09:00', '21:00'],
|
||||
tue: ['09:00', '21:00'],
|
||||
wed: ['09:00', '21:00'],
|
||||
thu: ['09:00', '21:00'],
|
||||
fri: ['09:00', '21:00'],
|
||||
sat: ['09:00', '14:00'],
|
||||
sun: null,
|
||||
};
|
||||
|
||||
const HOURS_ALL_CLOSED = {
|
||||
mon: null, tue: null, wed: null, thu: null, fri: null, sat: null, sun: null,
|
||||
};
|
||||
|
||||
const HOURS_MIDNIGHT_CROSS = {
|
||||
mon: ['22:00', '02:00'],
|
||||
tue: ['09:00', '21:00'],
|
||||
wed: null, thu: null, fri: null, sat: null, sun: null,
|
||||
};
|
||||
|
||||
const at = (iso) => new Date(iso);
|
||||
|
||||
describe('isAlwaysOpen', () => {
|
||||
test('all 7 days 00:00-24:00 → true', () => {
|
||||
expect(isAlwaysOpen(HOURS_24_7)).toBe(true);
|
||||
});
|
||||
|
||||
test('accepts JSON string', () => {
|
||||
expect(isAlwaysOpen(JSON.stringify(HOURS_24_7))).toBe(true);
|
||||
});
|
||||
|
||||
test('one day different → false', () => {
|
||||
expect(isAlwaysOpen({ ...HOURS_24_7, sun: ['00:00', '23:59'] })).toBe(false);
|
||||
});
|
||||
|
||||
test('one day null → false', () => {
|
||||
expect(isAlwaysOpen({ ...HOURS_24_7, sun: null })).toBe(false);
|
||||
});
|
||||
|
||||
test('null input → false', () => {
|
||||
expect(isAlwaysOpen(null)).toBe(false);
|
||||
expect(isAlwaysOpen('')).toBe(false);
|
||||
expect(isAlwaysOpen(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
test('malformed JSON → false', () => {
|
||||
expect(isAlwaysOpen('{not-json')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOpenNow', () => {
|
||||
test('null/empty input → null', () => {
|
||||
expect(isOpenNow(null)).toBeNull();
|
||||
expect(isOpenNow('')).toBeNull();
|
||||
expect(isOpenNow(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
test('malformed JSON → null', () => {
|
||||
expect(isOpenNow('{garbage')).toBeNull();
|
||||
});
|
||||
|
||||
test('24/7 → { isOpen: true, kind: "24h" }', () => {
|
||||
const s = isOpenNow(HOURS_24_7, at('2026-07-27T10:00:00'));
|
||||
expect(s).toEqual({ isOpen: true, kind: '24h' });
|
||||
});
|
||||
|
||||
test('24/7 at midnight → still 24h', () => {
|
||||
const s = isOpenNow(HOURS_24_7, at('2026-07-27T00:00:00'));
|
||||
expect(s).toEqual({ isOpen: true, kind: '24h' });
|
||||
});
|
||||
|
||||
test('24/7 (JSON string) → kind 24h', () => {
|
||||
const s = isOpenNow(JSON.stringify(HOURS_24_7), at('2026-07-27T15:00:00'));
|
||||
expect(s).toEqual({ isOpen: true, kind: '24h' });
|
||||
});
|
||||
|
||||
test('normal weekday at 10:00 (monday) → open, closesAt 21:00', () => {
|
||||
// 2026-07-27 is a Monday (verify with date -d)
|
||||
const s = isOpenNow(HOURS_NORMAL_WEEK, at('2026-07-27T10:30:00'));
|
||||
expect(s).toEqual({ isOpen: true, kind: 'open', closesAt: '21:00' });
|
||||
});
|
||||
|
||||
test('normal weekday at 21:30 (monday) → closed, nextOpen tuesday 09:00', () => {
|
||||
const s = isOpenNow(HOURS_NORMAL_WEEK, at('2026-07-27T21:30:00'));
|
||||
expect(s.isOpen).toBe(false);
|
||||
expect(s.kind).toBe('after-close');
|
||||
expect(s.nextOpen).toEqual({ day: 'tue', time: '09:00' });
|
||||
});
|
||||
|
||||
test('normal weekday at 08:30 (monday) → closed before-open, opensAt 09:00', () => {
|
||||
const s = isOpenNow(HOURS_NORMAL_WEEK, at('2026-07-27T08:30:00'));
|
||||
expect(s).toEqual({ isOpen: false, kind: 'before-open', opensAt: '09:00', nextOpen: null });
|
||||
});
|
||||
|
||||
test('sunday with no hours → closed, nextOpen monday 09:00', () => {
|
||||
// 2026-07-26 is a Sunday
|
||||
const s = isOpenNow(HOURS_NORMAL_WEEK, at('2026-07-26T10:00:00'));
|
||||
expect(s.isOpen).toBe(false);
|
||||
expect(s.kind).toBe('closed');
|
||||
expect(s.nextOpen).toEqual({ day: 'mon', time: '09:00' });
|
||||
});
|
||||
|
||||
test('all week closed → closed, no nextOpen', () => {
|
||||
const s = isOpenNow(HOURS_ALL_CLOSED, at('2026-07-27T10:00:00'));
|
||||
expect(s).toEqual({ isOpen: false, kind: 'closed', nextOpen: null });
|
||||
});
|
||||
|
||||
test('midnight-crossing: monday 22:00-02:00, at 23:00 → open', () => {
|
||||
const s = isOpenNow(HOURS_MIDNIGHT_CROSS, at('2026-07-27T23:00:00'));
|
||||
expect(s.isOpen).toBe(true);
|
||||
expect(s.kind).toBe('open');
|
||||
expect(s.closesAt).toBe('02:00');
|
||||
});
|
||||
|
||||
test('midnight-crossing: monday 22:00-02:00, at 01:00 → open (yesterday range still active)', () => {
|
||||
// Tuesday 01:00 → it's inside monday's 22:00-02:00 range (extended past midnight)
|
||||
const s = isOpenNow(HOURS_MIDNIGHT_CROSS, at('2026-07-28T01:00:00'));
|
||||
expect(s.isOpen).toBe(true);
|
||||
expect(s.kind).toBe('open');
|
||||
});
|
||||
|
||||
test('midnight-crossing: at tuesday 03:00, today opens at 09:00 → before-open', () => {
|
||||
// Tuesday 03:00 with monday 22:00-02:00 already finished; today's tue is 09:00-21:00.
|
||||
const s = isOpenNow(HOURS_MIDNIGHT_CROSS, at('2026-07-28T03:00:00'));
|
||||
expect(s).toEqual({ isOpen: false, kind: 'before-open', opensAt: '09:00', nextOpen: null });
|
||||
});
|
||||
|
||||
test('midnight-crossing: same day before today opens → before-open with today time', () => {
|
||||
// HOURS_MIDNIGHT_CROSS: mon 22:00-02:00 (crossing). At monday 10:00, before today's 22:00.
|
||||
const s = isOpenNow(HOURS_MIDNIGHT_CROSS, at('2026-07-27T10:00:00'));
|
||||
expect(s).toEqual({ isOpen: false, kind: 'before-open', opensAt: '22:00', nextOpen: null });
|
||||
});
|
||||
|
||||
test('saturday morning at 10:00 (with 09:00-14:00) → open, closesAt 14:00', () => {
|
||||
// 2026-07-25 is a Saturday
|
||||
const s = isOpenNow(HOURS_NORMAL_WEEK, at('2026-07-25T10:00:00'));
|
||||
expect(s).toEqual({ isOpen: true, kind: 'open', closesAt: '14:00' });
|
||||
});
|
||||
|
||||
test('saturday at 15:00 → closed, nextOpen monday 09:00', () => {
|
||||
const s = isOpenNow(HOURS_NORMAL_WEEK, at('2026-07-25T15:00:00'));
|
||||
expect(s.isOpen).toBe(false);
|
||||
expect(s.kind).toBe('after-close');
|
||||
expect(s.nextOpen).toEqual({ day: 'mon', time: '09:00' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { jest } from '@jest/globals'
|
||||
|
||||
jest.unstable_mockModule('../cima-service.js', () => ({
|
||||
searchMedicines: jest.fn(async () => []),
|
||||
getMedicineDetails: jest.fn(async () => null),
|
||||
searchOTC: jest.fn(async () => []),
|
||||
}))
|
||||
|
||||
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 HOURS_24_7 = JSON.stringify({
|
||||
mon: ['00:00', '24:00'],
|
||||
tue: ['00:00', '24:00'],
|
||||
wed: ['00:00', '24:00'],
|
||||
thu: ['00:00', '24:00'],
|
||||
fri: ['00:00', '24:00'],
|
||||
sat: ['00:00', '24:00'],
|
||||
sun: ['00:00', '24:00'],
|
||||
})
|
||||
|
||||
const HOURS_NORMAL = JSON.stringify({
|
||||
mon: ['09:00', '21:00'],
|
||||
tue: ['09:00', '21:00'],
|
||||
wed: ['09:00', '21:00'],
|
||||
thu: ['09:00', '21:00'],
|
||||
fri: ['09:00', '21:00'],
|
||||
sat: ['09:00', '14:00'],
|
||||
sun: null,
|
||||
})
|
||||
|
||||
function insertPharmacy(name, openingHours) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
'INSERT INTO pharmacies (name, address, latitude, longitude, opening_hours) VALUES (?, ?, ?, ?, ?)',
|
||||
[name, 'Address of ' + name, 41.5, 2.0, openingHours],
|
||||
function (err) { return err ? reject(err) : resolve(this.lastID) }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await initDatabase()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise((resolve, reject) => {
|
||||
db.run('DELETE FROM pharmacies', (err) => (err ? reject(err) : resolve()))
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/pharmacies — is_open / is_24h enrichment', () => {
|
||||
test('24/7 pharmacy → is_open=true, is_24h=true', async () => {
|
||||
await insertPharmacy('24h Pharmacy', HOURS_24_7)
|
||||
const res = await supertest(app).get('/api/pharmacies')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.length).toBe(1)
|
||||
expect(res.body[0].is_open).toBe(true)
|
||||
expect(res.body[0].is_24h).toBe(true)
|
||||
})
|
||||
|
||||
test('normal hours pharmacy at an open time → is_open=true, is_24h=false', async () => {
|
||||
await insertPharmacy('Normal Pharmacy', HOURS_NORMAL)
|
||||
const res = await supertest(app).get('/api/pharmacies')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body[0].is_24h).toBe(false)
|
||||
expect(typeof res.body[0].is_open).toBe('boolean')
|
||||
})
|
||||
|
||||
test('null opening_hours → is_open=null, is_24h=false', async () => {
|
||||
await insertPharmacy('No Hours', null)
|
||||
const res = await supertest(app).get('/api/pharmacies')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body[0].is_open).toBeNull()
|
||||
expect(res.body[0].is_24h).toBe(false)
|
||||
})
|
||||
|
||||
test('multiple pharmacies each get correct enrichment', async () => {
|
||||
await insertPharmacy('24h Pharmacy', HOURS_24_7)
|
||||
await insertPharmacy('Normal Pharmacy', HOURS_NORMAL)
|
||||
await insertPharmacy('No Hours', null)
|
||||
const res = await supertest(app).get('/api/pharmacies')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.body.length).toBe(3)
|
||||
const byName = Object.fromEntries(res.body.map(p => [p.name, p]))
|
||||
expect(byName['24h Pharmacy'].is_open).toBe(true)
|
||||
expect(byName['24h Pharmacy'].is_24h).toBe(true)
|
||||
expect(byName['Normal Pharmacy'].is_24h).toBe(false)
|
||||
expect(byName['No Hours'].is_open).toBeNull()
|
||||
expect(byName['No Hours'].is_24h).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -44,7 +44,7 @@
|
||||
"pino": "^9.4.0",
|
||||
"pino-http": "^10.3.0",
|
||||
"redis": "^4.6.0",
|
||||
"sqlite3": "^5.1.6",
|
||||
"sqlite3": "^5.1.7",
|
||||
"tesseract.js": "^7.0.0",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
|
||||
+16
-4
@@ -27,6 +27,7 @@ import { searchMedicines, getMedicineDetails, searchOTC } from './cima-service.j
|
||||
import { runFarmaciaWebhookImport, DEFAULT_FARMACIAS_WEBHOOK, importPharmaciesFromRows } from './farmacias-webhook-import.js';
|
||||
import { fetchPharmaciesExternal } from '../API/index.js';
|
||||
import { validateProductionEnv } from './src/config/required-env.js';
|
||||
import { isOpenNow, isAlwaysOpen } from './src/hours.js';
|
||||
|
||||
validateProductionEnv();
|
||||
|
||||
@@ -88,7 +89,7 @@ const sessionConfig = {
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
secure: process.env.NODE_ENV === 'production' || process.env.COOKIE_SECURE === 'true',
|
||||
secure: process.env.COOKIE_SECURE !== 'false' && (process.env.NODE_ENV === 'production' || process.env.COOKIE_SECURE === 'true'),
|
||||
sameSite: 'lax',
|
||||
httpOnly: true,
|
||||
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
||||
@@ -226,6 +227,17 @@ function serializeOpeningHours(value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function enrichPharmacy(row) {
|
||||
let isOpen = null;
|
||||
let is24h = false;
|
||||
if (row.opening_hours) {
|
||||
const status = isOpenNow(row.opening_hours);
|
||||
isOpen = status ? status.isOpen : null;
|
||||
is24h = isAlwaysOpen(row.opening_hours);
|
||||
}
|
||||
return { ...row, is_open: isOpen, is_24h: is24h };
|
||||
}
|
||||
|
||||
// Initialize database tables
|
||||
async function initDatabase() {
|
||||
try {
|
||||
@@ -658,7 +670,7 @@ app.get('/api/medicines/:medicineId/pharmacies', async (req, res) => {
|
||||
`);
|
||||
}
|
||||
|
||||
res.json(pharmacies);
|
||||
res.json(pharmacies.map(enrichPharmacy));
|
||||
} catch (error) {
|
||||
console.error('Error fetching pharmacies:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
@@ -823,7 +835,7 @@ app.get('/api/products/:source/:productId/pharmacies', async (req, res) => {
|
||||
`);
|
||||
}
|
||||
|
||||
res.json(pharmacies);
|
||||
res.json(pharmacies.map(enrichPharmacy));
|
||||
} catch (error) {
|
||||
console.error('Error fetching pharmacies for product:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
@@ -920,7 +932,7 @@ app.get('/api/pharmacies', async (req, res) => {
|
||||
const pharmacies = await userDbAll(`
|
||||
SELECT * FROM pharmacies ORDER BY name
|
||||
`);
|
||||
res.json(pharmacies);
|
||||
res.json(pharmacies.map(enrichPharmacy));
|
||||
} catch (error) {
|
||||
console.error('Error fetching pharmacies:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Compute "open now" / "always open" for a pharmacy whose opening_hours is
|
||||
* stored as JSON in the { mon, tue, ..., sun } shape produced by
|
||||
* apps/API/opening-hours-osm.js.
|
||||
*
|
||||
* 24/7 is internally represented as every day ["00:00", "24:00"]. The
|
||||
* literal "24:00" is not valid HH:mm ISO; we accept it as 1440 minutes
|
||||
* (00:00 of the next day) so range math works, and treat a 24h week as
|
||||
* the dedicated `kind: '24h'` result.
|
||||
*
|
||||
* Midnight-crossing ranges (close <= open) are detected by comparing
|
||||
* the previous day's range when "now" is in the early hours.
|
||||
*/
|
||||
|
||||
const DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
|
||||
|
||||
function parse(raw) {
|
||||
if (raw == null || raw === '') return null;
|
||||
if (typeof raw === 'object') return raw;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function toMin(hm) {
|
||||
if (hm == null) return null;
|
||||
const parts = String(hm).split(':');
|
||||
if (parts.length !== 2) return null;
|
||||
const h = Number(parts[0]);
|
||||
const m = Number(parts[1]);
|
||||
if (!Number.isFinite(h) || !Number.isFinite(m)) return null;
|
||||
if (h === 24 && m === 0) return 1440;
|
||||
if (h < 0 || h > 24 || m < 0 || m >= 60) return null;
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
function findNextOpen(h, now) {
|
||||
for (let off = 1; off <= 7; off++) {
|
||||
const key = DAYS[(now.getDay() + off) % 7];
|
||||
const r = h[key];
|
||||
if (Array.isArray(r) && r.length === 2) {
|
||||
return { day: key, time: r[0] };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isAlwaysOpen(rawHours) {
|
||||
const h = parse(rawHours);
|
||||
if (!h || typeof h !== 'object') return false;
|
||||
for (const d of DAYS) {
|
||||
const r = h[d];
|
||||
if (!Array.isArray(r) || r.length !== 2) return false;
|
||||
if (r[0] !== '00:00' || r[1] !== '24:00') return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isOpenNow(rawHours, now = new Date()) {
|
||||
const h = parse(rawHours);
|
||||
if (!h) return null;
|
||||
|
||||
if (isAlwaysOpen(h)) {
|
||||
return { isOpen: true, kind: '24h' };
|
||||
}
|
||||
|
||||
const dayKey = DAYS[now.getDay()];
|
||||
const range = h[dayKey];
|
||||
|
||||
if (!Array.isArray(range) || range.length !== 2) {
|
||||
return { isOpen: false, kind: 'closed', nextOpen: findNextOpen(h, now) };
|
||||
}
|
||||
|
||||
const openM = toMin(range[0]);
|
||||
const closeM = toMin(range[1]);
|
||||
if (openM == null || closeM == null) return null;
|
||||
|
||||
const nowM = now.getHours() * 60 + now.getMinutes();
|
||||
|
||||
// First: is "now" still inside yesterday's midnight-crossing range?
|
||||
// (Today is irrelevant while we're still in the previous day's after-midnight tail.)
|
||||
const yestKey = DAYS[(now.getDay() + 6) % 7];
|
||||
const yest = h[yestKey];
|
||||
if (Array.isArray(yest) && yest.length === 2) {
|
||||
const yOpen = toMin(yest[0]);
|
||||
const yClose = toMin(yest[1]);
|
||||
if (yOpen != null && yClose != null && yClose <= yOpen && nowM < yClose) {
|
||||
return { isOpen: true, kind: 'open', closesAt: yest[1] };
|
||||
}
|
||||
}
|
||||
|
||||
// Midnight-crossing: today's range spans past 24:00.
|
||||
if (closeM <= openM) {
|
||||
if (nowM >= openM) {
|
||||
return { isOpen: true, kind: 'open', closesAt: range[1] };
|
||||
}
|
||||
return { isOpen: false, kind: 'before-open', opensAt: range[0], nextOpen: null };
|
||||
}
|
||||
|
||||
if (nowM < openM) {
|
||||
return { isOpen: false, kind: 'before-open', opensAt: range[0], nextOpen: null };
|
||||
}
|
||||
if (nowM >= closeM) {
|
||||
return { isOpen: false, kind: 'after-close', nextOpen: findNextOpen(h, now) };
|
||||
}
|
||||
return { isOpen: true, kind: 'open', closesAt: range[1] };
|
||||
}
|
||||
@@ -28,10 +28,10 @@
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.4.0",
|
||||
"@testing-library/react": "^14.2.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"jsdom": "^24.0.0",
|
||||
"vite": "^5.0.8",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vitest": "^1.6.0"
|
||||
"vitest": "^1.6.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +110,11 @@
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
|
||||
.pharmacy-hours--unknown {
|
||||
color: var(--on-surface-variant);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.pharmacy-pricing {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -107,7 +107,10 @@ function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequ
|
||||
return (
|
||||
<div className="pharmacy-card">
|
||||
<div className="pharmacy-header">
|
||||
<h4>🏥 {pharmacy.name}</h4>
|
||||
<h4>
|
||||
🏥 {pharmacy.name}
|
||||
{pharmacy.is_24h && <span className="pharmacy-badge pharmacy-badge--24h" aria-label={t('pharmacy.badge24h')}>24h</span>}
|
||||
</h4>
|
||||
<div className="pharmacy-header-actions">
|
||||
{distanceKm != null && (
|
||||
<span className="pharmacy-distance">{formatDistance(distanceKm)}</span>
|
||||
@@ -142,7 +145,7 @@ function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequ
|
||||
<div className="pharmacy-details">
|
||||
{openStatus && (
|
||||
<p className={`pharmacy-hours pharmacy-hours--${openStatus.status}`}>
|
||||
<span className="pharmacy-hours-dot" /> {openStatus.label}
|
||||
<span className="pharmacy-hours-dot" /> {openStatus.labelKey && openStatus.labelParams ? t(openStatus.labelKey, openStatus.labelParams) : openStatus.label}
|
||||
</p>
|
||||
)}
|
||||
<p className="pharmacy-address">📍 {pharmacy.address}</p>
|
||||
|
||||
@@ -31,7 +31,7 @@ function PharmacyMap({ pharmacies }) {
|
||||
{located.map(pharmacy => (
|
||||
<Marker key={pharmacy.id} position={[pharmacy.latitude, pharmacy.longitude]}>
|
||||
<Popup>
|
||||
<strong>{pharmacy.name}</strong><br />
|
||||
<strong>{pharmacy.name} {pharmacy.is_24h && <span className="map-badge-24h">24h</span>}</strong><br />
|
||||
{pharmacy.address}
|
||||
{pharmacy.phone && <><br />{pharmacy.phone}</>}
|
||||
<br />
|
||||
|
||||
@@ -1,49 +1,8 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import './AdminComponents.css';
|
||||
import { DAY_KEYS, DAY_LABEL } from '../../utils/hours';
|
||||
import { DAY_KEYS, DAY_LABEL, emptyHoursDraft, hoursToDraft, draftToHours, makeAlwaysOpenDraft, isAlwaysOpen } from '../../utils/hours';
|
||||
import { useTranslation } from '../../i18n';
|
||||
|
||||
function emptyHoursDraft() {
|
||||
const draft = {};
|
||||
for (const day of DAY_KEYS) {
|
||||
draft[day] = { open: '09:00', close: '21:00', closed: true };
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
|
||||
function hoursToDraft(raw) {
|
||||
let parsed = null;
|
||||
if (raw) {
|
||||
try { parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; }
|
||||
catch { parsed = null; }
|
||||
}
|
||||
const draft = {};
|
||||
for (const day of DAY_KEYS) {
|
||||
const v = parsed && parsed[day];
|
||||
if (Array.isArray(v) && v.length === 2) {
|
||||
draft[day] = { open: v[0], close: v[1], closed: false };
|
||||
} else {
|
||||
draft[day] = { open: '09:00', close: '21:00', closed: true };
|
||||
}
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
|
||||
function draftToHours(draft) {
|
||||
const out = {};
|
||||
let hasAny = false;
|
||||
for (const day of DAY_KEYS) {
|
||||
const d = draft[day];
|
||||
if (d && !d.closed && d.open && d.close) {
|
||||
out[day] = [d.open, d.close];
|
||||
hasAny = true;
|
||||
} else {
|
||||
out[day] = null;
|
||||
}
|
||||
}
|
||||
return hasAny ? out : null;
|
||||
}
|
||||
|
||||
/** Distance in metres between two WGS84 points */
|
||||
function haversineMeters(lat1, lon1, lat2, lon2) {
|
||||
const R = 6371000;
|
||||
@@ -629,6 +588,22 @@ function PharmacyManagement() {
|
||||
<fieldset className="hours-editor">
|
||||
<legend>{t('admin.pharmacy.openingHours')}</legend>
|
||||
<p className="hours-editor-hint">{t('admin.pharmacy.dayClosed')}</p>
|
||||
<label className="hours-24h-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAlwaysOpen(draftToHours(hoursDraft))}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setHoursDraft(makeAlwaysOpenDraft());
|
||||
} else {
|
||||
if (window.confirm(t('admin.pharmacy.confirmDisable24h'))) {
|
||||
setHoursDraft(emptyHoursDraft());
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{t('admin.pharmacy.alwaysOpen')}
|
||||
</label>
|
||||
{DAY_KEYS.map((day) => {
|
||||
const d = hoursDraft[day];
|
||||
return (
|
||||
|
||||
@@ -66,6 +66,17 @@ const ca = {
|
||||
'pharmacy.notifyWhenArrives': 'Notificar-me quan arribi a aquesta farmàcia',
|
||||
'pharmacy.notificationsActivatedPharmacy': 'Notificacions activades per a aquesta farmàcia — clic per desactivar',
|
||||
'pharmacy.notificationsRequired': 'Les notificacions requereixen iOS 16.4+ i aquest lloc instal·lat com a app (Compartir → Afegir a Pantalla d\'Inici).',
|
||||
'pharmacy.openNow': 'Obert · Tanca a les {{time}}',
|
||||
'pharmacy.closedAllDay': 'Tancat',
|
||||
'pharmacy.opensAt': 'Tancat · Obre a les {{time}}',
|
||||
'pharmacy.opensTomorrow': 'Tancat · Obre demà a les {{time}}',
|
||||
'pharmacy.opensDay': 'Tancat · Obre el {{day}} a les {{time}}',
|
||||
'pharmacy.alwaysOpen': 'Obert 24h',
|
||||
'pharmacy.filterOpenNow': 'Mostrar només obertes ara',
|
||||
'pharmacy.filterOpenNowActive': 'Només obertes ara',
|
||||
'pharmacy.badge24h': '24h',
|
||||
'pharmacy.noHours': 'Sense horari disponible',
|
||||
'pharmacy.filterNoResults': 'Cap farmàcia oberta ara. Desactiva el filtre per veure-les totes.',
|
||||
|
||||
// ProductResults
|
||||
'product.sinReceta': 'Sense Recepta',
|
||||
@@ -338,6 +349,8 @@ const ca = {
|
||||
'admin.pharmacy.apiNotFound': 'L\'app no ha pogut connectar amb l\'API (404). Useu http://localhost:3000 amb frontend i backend actius.',
|
||||
'admin.pharmacy.geocodificationNotFound': 'Servei de geocodificació no trobat. Actualitzeu el backend i reinicieu-lo.',
|
||||
'admin.pharmacy.searchFailed': 'Cerca fallida (HTTP',
|
||||
'admin.pharmacy.alwaysOpen': '24 hores (oberta tot el dia)',
|
||||
'admin.pharmacy.confirmDisable24h': 'Desactivar 24h? Es descartaran els horaris actuals.',
|
||||
'admin.pharmacy.dayClosed': 'Marqueu un dia com a Tancat si la farmàcia no obre aquest dia.',
|
||||
'admin.pharmacy.saveError': 'Error en desar farmàcia',
|
||||
'admin.pharmacy.radius': 'Radi (m)',
|
||||
|
||||
@@ -66,6 +66,17 @@ const es = {
|
||||
'pharmacy.notifyWhenArrives': 'Notificarme cuando llegue a esta farmacia',
|
||||
'pharmacy.notificationsActivatedPharmacy': 'Notificaciones activadas para esta farmacia — clic para desactivar',
|
||||
'pharmacy.notificationsRequired': 'Las notificaciones requieren iOS 16.4+ y este sitio instalado como app (Compartir → Añadir a Pantalla de Inicio).',
|
||||
'pharmacy.openNow': 'Abierto · Cierra a las {{time}}',
|
||||
'pharmacy.closedAllDay': 'Cerrado',
|
||||
'pharmacy.opensAt': 'Cerrado · Abre a las {{time}}',
|
||||
'pharmacy.opensTomorrow': 'Cerrado · Abre mañana a las {{time}}',
|
||||
'pharmacy.opensDay': 'Cerrado · Abre el {{day}} a las {{time}}',
|
||||
'pharmacy.alwaysOpen': 'Abierto 24h',
|
||||
'pharmacy.filterOpenNow': 'Mostrar solo abiertas ahora',
|
||||
'pharmacy.filterOpenNowActive': 'Solo abiertas ahora',
|
||||
'pharmacy.badge24h': '24h',
|
||||
'pharmacy.noHours': 'Sin horario disponible',
|
||||
'pharmacy.filterNoResults': 'Ninguna farmacia abierta ahora. Desactiva el filtro para ver todas.',
|
||||
|
||||
// ProductResults
|
||||
'product.sinReceta': 'Sin Receta',
|
||||
@@ -340,6 +351,8 @@ const es = {
|
||||
'admin.pharmacy.apiNotFound': 'La app no pudo conectar con la API (404). Usa http://localhost:3000 con frontend y backend activos.',
|
||||
'admin.pharmacy.geocodingNotFound': 'Servicio de geocodificación no encontrado. Actualiza el backend y reinícialo.',
|
||||
'admin.pharmacy.searchFailed': 'Búsqueda fallida (HTTP',
|
||||
'admin.pharmacy.alwaysOpen': '24 horas (abierta todo el día)',
|
||||
'admin.pharmacy.confirmDisable24h': '¿Desactivar 24h? Se descartarán los horarios actuales.',
|
||||
'admin.pharmacy.dayClosed': 'Marca un día como Cerrado si la farmacia no abre ese día.',
|
||||
'admin.pharmacy.saveError': 'Error al guardar farmacia',
|
||||
'admin.pharmacy.radius': 'Radio (m)',
|
||||
|
||||
@@ -27,14 +27,12 @@ function parseHours(raw) {
|
||||
}
|
||||
}
|
||||
|
||||
function findNextOpen(hours, now) {
|
||||
function findNextOpenInfo(hours, now) {
|
||||
for (let offset = 1; offset <= 7; offset++) {
|
||||
const day = DAYS[(now.getDay() + offset) % 7];
|
||||
const range = hours[day];
|
||||
if (Array.isArray(range) && range.length === 2) {
|
||||
const openStr = range[0];
|
||||
if (offset === 1) return `mañana a las ${openStr}`;
|
||||
return `${DAY_LABELS[day]} a las ${openStr}`;
|
||||
return { day, time: range[0], offset };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -42,14 +40,24 @@ function findNextOpen(hours, now) {
|
||||
|
||||
export function getOpenStatus(rawHours, now = new Date()) {
|
||||
const hours = parseHours(rawHours);
|
||||
if (!hours) return null;
|
||||
if (!hours) return { status: 'unknown', label: 'Sin horario', labelKey: 'pharmacy.noHours', labelParams: {} };
|
||||
|
||||
if (isAlwaysOpen(hours)) {
|
||||
return { status: 'open', label: 'Abierto 24h', labelKey: 'pharmacy.alwaysOpen', labelParams: {} };
|
||||
}
|
||||
|
||||
const day = DAYS[now.getDay()];
|
||||
const range = hours[day];
|
||||
|
||||
if (!Array.isArray(range) || range.length !== 2) {
|
||||
const next = findNextOpen(hours, now);
|
||||
return { status: 'closed', label: next ? `Cerrado · Abre ${next}` : 'Cerrado' };
|
||||
const next = findNextOpenInfo(hours, now);
|
||||
if (!next) {
|
||||
return { status: 'closed', label: 'Cerrado', labelKey: 'pharmacy.closedAllDay', labelParams: {} };
|
||||
}
|
||||
if (next.offset === 1) {
|
||||
return { status: 'closed', label: `Cerrado · Abre mañana a las ${next.time}`, labelKey: 'pharmacy.opensTomorrow', labelParams: { time: next.time } };
|
||||
}
|
||||
return { status: 'closed', label: `Cerrado · Abre el ${DAY_LABELS[next.day]} a las ${next.time}`, labelKey: 'pharmacy.opensDay', labelParams: { day: DAY_LABELS[next.day], time: next.time } };
|
||||
}
|
||||
|
||||
const openMins = parseHM(range[0]);
|
||||
@@ -59,15 +67,81 @@ export function getOpenStatus(rawHours, now = new Date()) {
|
||||
const nowMins = now.getHours() * 60 + now.getMinutes();
|
||||
|
||||
if (nowMins < openMins) {
|
||||
return { status: 'closed', label: `Cerrado · Abre a las ${range[0]}` };
|
||||
return { status: 'closed', label: `Cerrado · Abre a las ${range[0]}`, labelKey: 'pharmacy.opensAt', labelParams: { time: range[0] } };
|
||||
}
|
||||
if (nowMins >= closeMins) {
|
||||
const next = findNextOpen(hours, now);
|
||||
return { status: 'closed', label: next ? `Cerrado · Abre ${next}` : 'Cerrado' };
|
||||
const next = findNextOpenInfo(hours, now);
|
||||
if (!next) {
|
||||
return { status: 'closed', label: 'Cerrado', labelKey: 'pharmacy.closedAllDay', labelParams: {} };
|
||||
}
|
||||
if (next.offset === 1) {
|
||||
return { status: 'closed', label: `Cerrado · Abre mañana a las ${next.time}`, labelKey: 'pharmacy.opensTomorrow', labelParams: { time: next.time } };
|
||||
}
|
||||
return { status: 'closed', label: `Cerrado · Abre el ${DAY_LABELS[next.day]} a las ${next.time}`, labelKey: 'pharmacy.opensDay', labelParams: { day: DAY_LABELS[next.day], time: next.time } };
|
||||
}
|
||||
return { status: 'open', label: `Abierto · Cierra a las ${range[1]}` };
|
||||
return { status: 'open', label: `Abierto · Cierra a las ${range[1]}`, labelKey: 'pharmacy.openNow', labelParams: { time: range[1] } };
|
||||
}
|
||||
|
||||
export function emptyHours() {
|
||||
return { sun: null, mon: null, tue: null, wed: null, thu: null, fri: null, sat: null };
|
||||
}
|
||||
|
||||
export function emptyHoursDraft() {
|
||||
const draft = {};
|
||||
for (const day of DAYS) {
|
||||
draft[day] = { open: '09:00', close: '21:00', closed: true };
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
|
||||
export function hoursToDraft(raw) {
|
||||
let parsed = null;
|
||||
if (raw) {
|
||||
try { parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; }
|
||||
catch { parsed = null; }
|
||||
}
|
||||
const draft = {};
|
||||
for (const day of DAYS) {
|
||||
const v = parsed && parsed[day];
|
||||
if (Array.isArray(v) && v.length === 2) {
|
||||
draft[day] = { open: v[0], close: v[1], closed: false };
|
||||
} else {
|
||||
draft[day] = { open: '09:00', close: '21:00', closed: true };
|
||||
}
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
|
||||
export function draftToHours(draft) {
|
||||
const out = {};
|
||||
let hasAny = false;
|
||||
for (const day of DAYS) {
|
||||
const d = draft[day];
|
||||
if (d && !d.closed && d.open && d.close) {
|
||||
out[day] = [d.open, d.close];
|
||||
hasAny = true;
|
||||
} else {
|
||||
out[day] = null;
|
||||
}
|
||||
}
|
||||
return hasAny ? out : null;
|
||||
}
|
||||
|
||||
export function isAlwaysOpen(rawHours) {
|
||||
const h = parseHours(rawHours);
|
||||
if (!h) return false;
|
||||
for (const d of DAYS) {
|
||||
const r = h[d];
|
||||
if (!Array.isArray(r) || r.length !== 2) return false;
|
||||
if (r[0] !== '00:00' || r[1] !== '24:00') return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function makeAlwaysOpenDraft() {
|
||||
const draft = {};
|
||||
for (const day of DAYS) {
|
||||
draft[day] = { open: '00:00', close: '24:00', closed: false };
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ function PublicView({
|
||||
const [userPosition, setUserPosition] = useState(null);
|
||||
const [positionSource, setPositionSource] = useState(null); // 'profile' | 'browser'
|
||||
const [sortByDistance, setSortByDistance] = useState(false);
|
||||
const [openNow, setOpenNow] = useState(false);
|
||||
const [locating, setLocating] = useState(false);
|
||||
const [locationError, setLocationError] = useState('');
|
||||
|
||||
@@ -177,8 +178,12 @@ function PublicView({
|
||||
};
|
||||
|
||||
const displayedPharmacies = useMemo(() => {
|
||||
if (!sortByDistance || !userPosition) return pharmacies;
|
||||
return [...pharmacies].sort((a, b) => {
|
||||
let filtered = pharmacies;
|
||||
if (openNow) {
|
||||
filtered = pharmacies.filter(p => p.is_open === true || p.opening_hours == null);
|
||||
}
|
||||
if (!sortByDistance || !userPosition) return filtered;
|
||||
return [...filtered].sort((a, b) => {
|
||||
if (a.latitude == null || a.longitude == null) return 1;
|
||||
if (b.latitude == null || b.longitude == null) return -1;
|
||||
return (
|
||||
@@ -186,7 +191,7 @@ function PublicView({
|
||||
haversineKm(userPosition.lat, userPosition.lon, b.latitude, b.longitude)
|
||||
);
|
||||
});
|
||||
}, [pharmacies, sortByDistance, userPosition]);
|
||||
}, [pharmacies, openNow, sortByDistance, userPosition]);
|
||||
|
||||
/* ── Scanner → Search handoff ──────────────────────────── */
|
||||
function handleScanSelectMedicine(medicineName) {
|
||||
@@ -323,6 +328,13 @@ function PublicView({
|
||||
|
||||
{pharmacies.length > 0 && (
|
||||
<div className="pharmacy-controls">
|
||||
<button
|
||||
className={`open-now-toggle ${openNow ? 'active' : ''}`}
|
||||
onClick={() => setOpenNow(o => !o)}
|
||||
aria-pressed={openNow}
|
||||
>
|
||||
{openNow ? '🟢 Solo abiertas ahora' : '⏱ Mostrar solo abiertas ahora'}
|
||||
</button>
|
||||
<button
|
||||
className={`sort-distance-button ${sortByDistance ? 'active' : ''}`}
|
||||
onClick={handleSortByDistance}
|
||||
@@ -345,6 +357,11 @@ function PublicView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{openNow && displayedPharmacies.length === 0 && (
|
||||
<div className="open-now-empty">
|
||||
<p>Ninguna farmacia abierta ahora — desactiva el filtro para ver todas.</p>
|
||||
</div>
|
||||
)}
|
||||
<PharmacyMap pharmacies={displayedPharmacies} />
|
||||
<PharmacyList
|
||||
pharmacies={displayedPharmacies}
|
||||
|
||||
@@ -296,6 +296,11 @@
|
||||
gap: 0.75rem;
|
||||
margin: 1rem 0 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--surface);
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.sort-distance-button {
|
||||
@@ -312,7 +317,7 @@
|
||||
|
||||
.sort-distance-button:hover:not(:disabled) {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
color: #151c17;
|
||||
}
|
||||
|
||||
.sort-distance-button.active {
|
||||
|
||||
@@ -6,6 +6,7 @@ import PharmacyList from '../components/PharmacyList';
|
||||
import PharmacyMap from '../components/PharmacyMap';
|
||||
import { haversineKm, getUserPosition, hasCachedPosition } from '../utils/geo';
|
||||
import { useTranslation } from '../i18n';
|
||||
import { getOpenStatus } from '../utils/hours';
|
||||
import './SearchView.css';
|
||||
|
||||
const suggestions = [
|
||||
@@ -26,6 +27,7 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
|
||||
const [userPosition, setUserPosition] = useState(null);
|
||||
const [positionSource, setPositionSource] = useState(null);
|
||||
const [sortByDistance, setSortByDistance] = useState(false);
|
||||
const [openNow, setOpenNow] = useState(false);
|
||||
const [locating, setLocating] = useState(false);
|
||||
const [locationError, setLocationError] = useState('');
|
||||
const [recentSearches, setRecentSearches] = useState([]);
|
||||
@@ -208,8 +210,19 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
|
||||
};
|
||||
|
||||
const displayedPharmacies = useMemo(() => {
|
||||
if (!sortByDistance || !userPosition) return pharmacies;
|
||||
return [...pharmacies].sort((a, b) => {
|
||||
let result = pharmacies;
|
||||
if (openNow) {
|
||||
result = result.filter((p) => {
|
||||
if (p.is_open === true) return true;
|
||||
if (p.is_open == null) {
|
||||
const s = getOpenStatus(p.opening_hours);
|
||||
return s && s.status === 'open';
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
if (!sortByDistance || !userPosition) return result;
|
||||
return [...result].sort((a, b) => {
|
||||
if (a.latitude == null || a.longitude == null) return 1;
|
||||
if (b.latitude == null || b.longitude == null) return -1;
|
||||
return (
|
||||
@@ -217,7 +230,7 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
|
||||
haversineKm(userPosition.lat, userPosition.lon, b.latitude, b.longitude)
|
||||
);
|
||||
});
|
||||
}, [pharmacies, sortByDistance, userPosition]);
|
||||
}, [pharmacies, sortByDistance, userPosition, openNow]);
|
||||
|
||||
return (
|
||||
<div className="search-view">
|
||||
@@ -359,6 +372,12 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
|
||||
|
||||
{pharmacies.length > 0 && (
|
||||
<div className="pharmacy-controls">
|
||||
<button
|
||||
className={`sort-distance-button ${openNow ? 'active' : ''}`}
|
||||
onClick={() => setOpenNow((v) => !v)}
|
||||
>
|
||||
{openNow ? t('pharmacy.filterOpenNowActive') : t('pharmacy.filterOpenNow')}
|
||||
</button>
|
||||
<button
|
||||
className={`sort-distance-button ${sortByDistance ? 'active' : ''}`}
|
||||
onClick={handleSortByDistance}
|
||||
@@ -388,6 +407,9 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{openNow && pharmacies.length > 0 && displayedPharmacies.length === 0 && (
|
||||
<div className="no-pharmacies">{t('pharmacy.filterNoResults')}</div>
|
||||
)}
|
||||
|
||||
<PharmacyMap pharmacies={displayedPharmacies} />
|
||||
<PharmacyList
|
||||
|
||||
@@ -30,6 +30,9 @@ ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
# Copy package files
|
||||
COPY apps/parapharmacy-api/package*.json ./
|
||||
|
||||
#Run npm install to avoid npm ci issues
|
||||
RUN npm install
|
||||
|
||||
# Install dependencies from the committed lockfile (production only)
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml ./
|
||||
COPY apps/pip-platform/pyproject.toml ./
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir .
|
||||
|
||||
COPY . .
|
||||
COPY apps/pip-platform/ .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
|
||||
@@ -62,6 +62,11 @@ plugins = ["pydantic.mypy"]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pip-audit>=2.10.1",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src"]
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ class TestCircuitBreaker:
|
||||
return await coro
|
||||
|
||||
import asyncio
|
||||
result = asyncio.get_event_loop().run_until_complete(_run())
|
||||
result = asyncio.run(_run())
|
||||
assert result == "ok"
|
||||
|
||||
def test_open_raises_service_unavailable(self):
|
||||
@@ -42,7 +42,7 @@ class TestCircuitBreaker:
|
||||
|
||||
import asyncio
|
||||
with pytest.raises(ServiceUnavailableException):
|
||||
asyncio.get_event_loop().run_until_complete(_run())
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_half_open_after_recovery_timeout(self):
|
||||
cb = CircuitBreaker(name="test", failure_threshold=1, recovery_timeout=0.01)
|
||||
@@ -126,7 +126,7 @@ class TestWithRetry:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count < 3:
|
||||
raise Exception("temp fail")
|
||||
raise ConnectionError("temp fail")
|
||||
return "ok"
|
||||
|
||||
policy = RetryPolicy(max_attempts=3, base_delay=0.01, max_delay=0.1)
|
||||
@@ -141,7 +141,7 @@ class TestWithRetry:
|
||||
async def _always_fail():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
raise Exception("always fail")
|
||||
raise ConnectionError("always fail")
|
||||
|
||||
policy = RetryPolicy(max_attempts=2, base_delay=0.01, max_delay=0.1)
|
||||
with pytest.raises(Exception, match="always fail"):
|
||||
|
||||
Generated
+2532
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,11 @@ services:
|
||||
redis:
|
||||
image: redis:alpine
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- internal
|
||||
|
||||
@@ -28,6 +33,12 @@ services:
|
||||
context: .
|
||||
dockerfile: apps/backend/Dockerfile
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/healthz', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
env_file:
|
||||
- ./apps/backend/.env
|
||||
environment:
|
||||
@@ -35,6 +46,7 @@ services:
|
||||
NODE_ENV: production
|
||||
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
|
||||
CORS_ORIGIN: ${CORS_ORIGIN:?CORS_ORIGIN must be set}
|
||||
COOKIE_SECURE: ${COOKIE_SECURE:-false}
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: "6379"
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||
@@ -67,6 +79,12 @@ services:
|
||||
VITE_FARO_ENV: ${VITE_FARO_ENV:-production}
|
||||
VITE_FARO_APP_VERSION: ${VITE_FARO_APP_VERSION:-1.0.0}
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO", "/dev/null", "http://localhost:80/"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
ports:
|
||||
- "4000:80"
|
||||
depends_on:
|
||||
@@ -79,6 +97,11 @@ services:
|
||||
redis-exporter:
|
||||
image: oliver006/redis_exporter:latest
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO", "/dev/null", "http://localhost:9121/metrics"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
environment:
|
||||
REDIS_ADDR: redis://redis:6379
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||
@@ -90,6 +113,11 @@ services:
|
||||
postgres-exporter:
|
||||
image: prometheuscommunity/postgres-exporter:latest
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO", "/dev/null", "http://localhost:9187/metrics"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
environment:
|
||||
DATA_SOURCE_NAME: postgresql://farmafinder:${PG_PASSWORD:?PG_PASSWORD must be set}@postgres:5432/farmafinder?sslmode=disable
|
||||
depends_on:
|
||||
@@ -128,6 +156,12 @@ services:
|
||||
mongodb:
|
||||
image: mongo:7
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping')"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
volumes:
|
||||
- mongodb_data:/data/db
|
||||
networks:
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# Horarios de farmacias — Implementación
|
||||
|
||||
## Resumen
|
||||
|
||||
Sistema completo para gestionar, calcular y filtrar horarios de apertura de farmacias. Implementado siguiendo el plan en `docs/PLAN-HORARIOS.md`.
|
||||
|
||||
## Lo implementado
|
||||
|
||||
### Backend (`apps/backend/`)
|
||||
|
||||
| Archivo | Qué hace |
|
||||
|---------|----------|
|
||||
| `src/hours.js` | Helper puro: `isOpenNow(rawHours, now?)` y `isAlwaysOpen(rawHours)`. Soporta 24/7, rangos normales, cruce de medianoche, días cerrados, `24:00` como 1440 min. |
|
||||
| `server.js` | Los 3 endpoints públicos (`/api/medicines/:id/pharmacies`, `/api/products/:source/:id/pharmacies`, `/api/pharmacies`) devuelven `is_open` (boolean o null) e `is_24h` (boolean) precalculados con la hora del servidor. Helper `enrichPharmacy(row)`. |
|
||||
|
||||
### Frontend (`apps/frontend/`)
|
||||
|
||||
| Archivo | Qué hace |
|
||||
|---------|----------|
|
||||
| `src/utils/hours.js` | `getOpenStatus()` refactorizado: devuelve `labelKey`/`labelParams` para i18n + `label` legacy. Nuevos: `emptyHoursDraft()`, `hoursToDraft()`, `draftToHours()` (movidos desde admin), `isAlwaysOpen()`, `makeAlwaysOpenDraft()`. |
|
||||
| `src/views/PublicView.jsx` | Nuevo estado `openNow`. Botón "Mostrar solo abiertas ahora" en `.pharmacy-controls`. Filtro en `displayedPharmacies` (incluye farmacias sin horarios). Mensaje si 0 resultados. |
|
||||
| `src/components/PharmacyList.jsx` | Badge `24h` junto al nombre. Texto de estado usa `t()` con claves i18n. |
|
||||
| `src/components/PharmacyMap.jsx` | Badge `24h` en popup del marcador. |
|
||||
| `src/components/admin/PharmacyManagement.jsx` | Toggle "24 horas" en el editor de horarios. Al activarlo, rellena todos los días con `00:00-24:00`. Al desactivarlo, `confirm()` antes de descartar. |
|
||||
| `src/i18n/locales/es.js` | Nuevas claves: `pharmacy.openNow`, `pharmacy.closedAllDay`, `pharmacy.opensAt`, `pharmacy.opensTomorrow`, `pharmacy.opensDay`, `pharmacy.alwaysOpen`, `pharmacy.filterOpenNow`, `pharmacy.filterOpenNowActive`, `pharmacy.badge24h`, `pharmacy.filterNoResults`, `admin.pharmacy.alwaysOpen`, `admin.pharmacy.confirmDisable24h`. |
|
||||
| `src/i18n/locales/ca.js` | Traducciones catalanas de todas las claves nuevas. |
|
||||
|
||||
### Tests
|
||||
|
||||
| Archivo | Tests |
|
||||
|---------|-------|
|
||||
| `apps/backend/__tests__/hours.test.js` | 22 tests — `isAlwaysOpen` (6), `isOpenNow` (16): 24/7, normal, cerrado, cruce medianoche, null, malformed JSON. |
|
||||
| `apps/backend/__tests__/pharmacy-hours-endpoint.test.js` | 4 tests — verifica `is_open`/`is_24h` en respuesta JSON para 24h, normal, null, múltiples. |
|
||||
| `apps/frontend/src/App.test.jsx` | 6 tests existentes — sin regresión. |
|
||||
| `apps/frontend/src/utils/notifications.test.js` | 1 test existente — sin regresión. |
|
||||
|
||||
## Cómo funciona
|
||||
|
||||
1. **Almacenamiento**: `pharmacies.opening_hours` como TEXT JSON. Shape: `{ mon: ["09:00","21:00"], tue: null, ... }`. 24/7 → todos los días `["00:00","24:00"]`.
|
||||
2. **Cálculo en servidor**: Cada request a endpoints públicos ejecuta `isOpenNow()` con la hora del servidor. El frontend recibe `is_open` e `is_24h` ya calculados.
|
||||
3. **Cálculo en cliente**: `getOpenStatus()` existe como fallback si `is_open` no está presente (datos legacy).
|
||||
4. **Filtro**: Cliente-side. Farmacias sin horarios (`opening_hours = null`) no se filtran.
|
||||
5. **Admin**: El toggle 24h rellena los 7 días. El editor manual permite día por día.
|
||||
|
||||
## Puntos de mejora futuros
|
||||
|
||||
### Pendientes del plan original
|
||||
|
||||
- [ ] **Refresco automático cada 60s**: Si la página permanece abierta mucho tiempo, el estado "abierto/cerrado" puede quedar desactualizado. Añadir `setInterval` de 60s en `PublicView.jsx` para recalcular o re-fetch.
|
||||
- [ ] **Cache server-side**: Si el dataset de farmacias crece (>1000), cachear `isOpenNow` con TTL de 1 minuto por minuto actual. Comentario `TODO(cache)` ya está en `server.js`.
|
||||
|
||||
### UI/UX
|
||||
|
||||
- [ ] **Tooltip explicativo**: Al pasar el ratón sobre el badge "24h", mostrar "Abierta las 24 horas del día".
|
||||
- [ ] **Color en el filtro**: El botón "Abiertas ahora" ganaría con un icono verde intermitente o un cambio de color más evidente.
|
||||
- [ ] **Separar horas de apertura/cierre**: Actualmente el editor de admin usa `<input type="time">` que no acepta `24:00` como valor. Para crear una farmacia 24h hay que usar el toggle. El input manual no permite escribir `24:00`.
|
||||
- [ ] **Ordenación combinada**: "Abiertas ahora" + "Ordenar por distancia" deberían priorizar las abiertas pero ordenadas por distancia. Actualmente primero filtra, luego ordena.
|
||||
|
||||
### Técnicos
|
||||
|
||||
- [ ] **Zona horaria explícita**: El servidor usa su hora local. Devolver `server_now` y `server_tz` en la respuesta para que la UI pueda mostrar "según hora del servidor". Ver pregunta abierta #1 en el plan.
|
||||
- [ ] **Tests de integración real**: Los tests de backend usan SQLite en memoria. Con PostgreSQL real los endpoints deben comportarse igual.
|
||||
- [ ] **Parser OSM 24/7 → `24:00`**: El parser de OSM ya produce `["00:00","24:00"]` para `24/7`. Si en el futuro OSM cambia el formato, actualizar solo `opening-hours-osm.js`. No tocar nada más (los helpers son agnósticos al formato de entrada).
|
||||
- [ ] **Cobertura frontend**: Los helpers puros de `hours.js` no tienen tests unitarios. Añadir tests para `isAlwaysOpen()` frontend, `emptyHoursDraft()`, `hoursToDraft()`, `draftToHours()`, `makeAlwaysOpenDraft()`.
|
||||
|
||||
### Admin
|
||||
|
||||
- [ ] **Vista previa de horarios**: En la lista de farmacias del admin, mostrar un resumen "L-V 9:00-21:00, S 9:00-14:00" o "24h" en vez del JSON crudo.
|
||||
- [ ] **Importación masiva con horarios**: El importador OSM ya trae `opening_hours`. El importador de datos abiertos también si el JSON incluye el campo. Verificar que los tres caminos de ingesta sigan parseando correctamente tras los cambios.
|
||||
|
||||
### Mobile (`apps/frontend-mobile/`)
|
||||
|
||||
- [ ] **Los mismos cambios en la app móvil**: El frontend móvil tiene su propia copia de i18n (`apps/frontend-mobile/src/i18n/locales/es.js` y `ca.js`) con menos claves. No se ha tocado. Habría que añadir las mismas claves `pharmacy.*` y replicar la lógica de filtrado/badge.
|
||||
|
||||
## Archivos creados/modificados
|
||||
|
||||
**Creados:**
|
||||
- `apps/backend/__tests__/pharmacy-hours-endpoint.test.js`
|
||||
- `docs/horarios.md` (este)
|
||||
|
||||
**Modificados:**
|
||||
- `apps/backend/server.js` — import `src/hours.js`, helper `enrichPharmacy`, 3 endpoints enriquecidos
|
||||
- `apps/frontend/src/utils/hours.js` — `getOpenStatus()` con i18n, +5 nuevas exportaciones
|
||||
- `apps/frontend/src/views/PublicView.jsx` — filtro openNow
|
||||
- `apps/frontend/src/components/PharmacyList.jsx` — badge 24h, i18n en estado
|
||||
- `apps/frontend/src/components/PharmacyMap.jsx` — badge 24h en popup
|
||||
- `apps/frontend/src/components/admin/PharmacyManagement.jsx` — toggle 24h, import desde hours.js
|
||||
- `apps/frontend/src/i18n/locales/es.js` — 12 nuevas claves
|
||||
- `apps/frontend/src/i18n/locales/ca.js` — 12 nuevas claves
|
||||
|
||||
**No tocados (intencionalmente):**
|
||||
- `apps/API/opening-hours-osm.js` — parser OSM en producción
|
||||
- `apps/backend/farmacias-webhook-import.js` — ya parsea correctamente
|
||||
- `apps/frontend-mobile/` — requiere移植 manual
|
||||
@@ -0,0 +1,49 @@
|
||||
# Dependency Hardening Baseline
|
||||
|
||||
**Baseline date:** 2026-07-22
|
||||
**Branch:** `security/dependency-hardening-2026-07-22`
|
||||
**Lockfile:** root `package-lock.json`, installed with `npm ci`
|
||||
|
||||
## Toolchain
|
||||
|
||||
- Node.js `v22.22.1` (the production Dockerfiles target Node 20 and 24)
|
||||
- npm `9.2.0`
|
||||
- Python `3.14.4`
|
||||
- uv `0.5.9`
|
||||
- Docker `29.6.1`
|
||||
- Docker Compose `v5.3.1`
|
||||
|
||||
The local Node version produces an expected engine warning for `@zxing/library@0.23.0`, which requires Node 24 or newer. Production and CI must use the declared Node image/version rather than this local Node 22 runtime.
|
||||
|
||||
## Audit counts
|
||||
|
||||
Reports were generated online and saved outside the repository. Counts are advisory snapshots, not a substitute for the CI gate.
|
||||
|
||||
| Scope | Critical | High | Moderate | Low | Total |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| Workspace root | 3 | 13 | 60 | 3 | 79 |
|
||||
| Backend workspace | 1 | 10 | 41 | 2 | 54 |
|
||||
| Parapharmacy API | 0 | 2 | 0 | 1 | 3 |
|
||||
| Frontend workspace | 1 | 6 | 20 | 1 | 28 |
|
||||
| Scraper workspace | 0 | 1 | 0 | 0 | 1 |
|
||||
|
||||
The parapharmacy and scraper reports resolve through the root workspace lockfile. Their package-local audit invocation fails without a package-local lockfile; the workspace-scoped audit is the authoritative current result.
|
||||
|
||||
## Direct dependency families selected for review
|
||||
|
||||
- Backend OpenTelemetry: `@opentelemetry/auto-instrumentations-node ^0.52.0`, exporters/SDKs primarily on `^0.55.0`, resources/API families on `^1.x`.
|
||||
- Backend native dependencies: `bcrypt ^5.1.1`, `sqlite3 ^5.1.6`, and `connect-sqlite3 ^0.9.16`.
|
||||
- Parapharmacy: `mongoose ^8.8.0`, `puppeteer ^22.0.0`; current installed Puppeteer is `22.15.0`.
|
||||
- Scraper: `puppeteer ^24.40.0`, `puppeteer-extra ^3.3.6`, and stealth plugin `^2.11.2`.
|
||||
- Frontend: `vite ^5.0.8`, `vitest ^1.6.0`, `vite-plugin-pwa ^1.3.0`, and Grafana Faro/OpenTelemetry packages on the `^1.x` family.
|
||||
|
||||
## Upgrade order and known risks
|
||||
|
||||
1. Resolve Python dependencies and audit them independently.
|
||||
2. Pin runtime images and verify lockfile-enforced Docker builds.
|
||||
3. Upgrade backend OpenTelemetry as one family, then native bcrypt/SQLite packages.
|
||||
4. Upgrade parapharmacy Mongoose/`fast-uri` and scraper Puppeteer separately.
|
||||
5. Upgrade frontend Vite/Rollup/esbuild/Vitest without enabling Vitest UI.
|
||||
6. Re-run all audits and record any residual advisory with production reachability, mitigation, owner, and expiry/review date.
|
||||
|
||||
Do not run `npm audit fix --force`: the baseline reports breaking-version fixes for several families, including OpenTelemetry, SQLite, and Vite/Vitest. Each such upgrade requires its own compatibility test and commit.
|
||||
Generated
+3
-3
@@ -58,7 +58,7 @@
|
||||
"pino": "^9.4.0",
|
||||
"pino-http": "^10.3.0",
|
||||
"redis": "^4.6.0",
|
||||
"sqlite3": "^5.1.6",
|
||||
"sqlite3": "^5.1.7",
|
||||
"tesseract.js": "^7.0.0",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
@@ -127,11 +127,11 @@
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.4.0",
|
||||
"@testing-library/react": "^14.2.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"jsdom": "^24.0.0",
|
||||
"vite": "^5.0.8",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vitest": "^1.6.0"
|
||||
"vitest": "^1.6.1"
|
||||
}
|
||||
},
|
||||
"apps/frontend-mobile": {
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy-env.sh — Fetch Gitea Actions variables and write .env files.
|
||||
# Called by the CI/CD deploy step on the self-hosted runner.
|
||||
#
|
||||
# Required env vars (injected by Gitea Actions):
|
||||
# GITEA_TOKEN — API token with repo read access
|
||||
# GITEA_OWNER — repo owner (e.g. Ichitux)
|
||||
# GITEA_REPO — repo name (e.g. FarmaFinder)
|
||||
#
|
||||
# Usage:
|
||||
# GITEA_TOKEN=xxx GITEA_OWNER=Ichitux GITEA_REPO=FarmaFinder ./scripts/deploy-env.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
GITEA_URL="${GITEA_URL:-https://git.hacecalor.net}"
|
||||
WORK_DIR="${WORK_DIR:-.}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: fetch all repo variables from Gitea API (handles pagination)
|
||||
# Returns JSON array on stdout
|
||||
# ---------------------------------------------------------------------------
|
||||
fetch_variables() {
|
||||
local page=1 limit=50 all='[]'
|
||||
while true; do
|
||||
local resp
|
||||
resp=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/actions/variables?limit=${limit}&page=${page}")
|
||||
|
||||
local count
|
||||
count=$(echo "$resp" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))")
|
||||
|
||||
all=$(python3 -c "
|
||||
import sys, json
|
||||
a = json.loads('''${all}''')
|
||||
b = json.loads(sys.stdin.read())
|
||||
print(json.dumps(a + b))
|
||||
" <<< "$resp")
|
||||
|
||||
if [ "$count" -lt "$limit" ]; then
|
||||
break
|
||||
fi
|
||||
page=$((page + 1))
|
||||
done
|
||||
echo "$all"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: write a .env file from a JSON object of key-value pairs.
|
||||
# Keys are sorted alphabetically. Comments from .env.example are preserved.
|
||||
# ---------------------------------------------------------------------------
|
||||
write_env() {
|
||||
local env_path="$1"
|
||||
local json_obj="$2"
|
||||
local example_path="${env_path}.example"
|
||||
|
||||
# If no example file exists, write a plain KEY=VALUE file
|
||||
if [ ! -f "$example_path" ]; then
|
||||
python3 -c "
|
||||
import json, sys
|
||||
obj = json.loads('''${json_obj}''')
|
||||
for k in sorted(obj):
|
||||
print(f'{k}={obj[k]}')
|
||||
" > "$env_path"
|
||||
echo "[env] Wrote $env_path (${#json_obj} bytes, no .example template)"
|
||||
return
|
||||
fi
|
||||
|
||||
# If .example exists, produce a file that preserves comments/order
|
||||
# and fills in values from the JSON
|
||||
python3 -c "
|
||||
import json, sys, re
|
||||
|
||||
example_path = '''${example_path}'''
|
||||
env_path = '''${env_path}'''
|
||||
values = json.loads('''${json_obj}''')
|
||||
|
||||
with open(example_path) as f:
|
||||
lines = f.readlines()
|
||||
|
||||
output = []
|
||||
written_keys = set()
|
||||
for line in lines:
|
||||
stripped = line.rstrip('\n')
|
||||
|
||||
# Pass through comments and blank lines
|
||||
if stripped.startswith('#') or stripped.strip() == '':
|
||||
output.append(stripped)
|
||||
continue
|
||||
|
||||
# Parse KEY=VALUE lines
|
||||
m = re.match(r'^([A-Za-z_][A-Za-z0-9_]*)=(.*)', stripped)
|
||||
if m:
|
||||
key, _ = m.group(1), m.group(2)
|
||||
if key in values:
|
||||
output.append(f'{key}={values[key]}')
|
||||
written_keys.add(key)
|
||||
else:
|
||||
output.append(stripped) # keep example default
|
||||
else:
|
||||
output.append(stripped)
|
||||
|
||||
# Append any keys from values that weren't in .example
|
||||
for k in sorted(values):
|
||||
if k not in written_keys:
|
||||
output.append(f'{k}={values[k]}')
|
||||
|
||||
with open(env_path, 'w') as f:
|
||||
f.write('\n'.join(output) + '\n')
|
||||
|
||||
print(f'[env] Wrote {env_path} ({len(values)} vars)')
|
||||
" 2>&1
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "=== deploy-env.sh ==="
|
||||
echo "Fetching variables from Gitea: ${GITEA_OWNER}/${GITEA_REPO}"
|
||||
|
||||
VARS_JSON=$(fetch_variables)
|
||||
VAR_COUNT=$(echo "$VARS_JSON" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))")
|
||||
echo "Found ${VAR_COUNT} variables"
|
||||
|
||||
# Group variables by prefix
|
||||
# ROOT_* → root .env (strip prefix)
|
||||
# BACKEND_* → apps/backend/.env (strip prefix)
|
||||
# FRONTEND_* → apps/frontend/.env (strip prefix)
|
||||
# PARAPHARMACY_* → apps/parapharmacy-api/.env (strip prefix)
|
||||
# No prefix → root .env (backward compat with original vars)
|
||||
|
||||
ROOT_VARS=$(python3 -c "
|
||||
import json, sys
|
||||
vars = json.loads(sys.stdin.read())
|
||||
# Keys that are CI-only and should NOT appear in application .env files
|
||||
skip = {'GITEA_TOKEN'}
|
||||
result = {}
|
||||
for v in vars:
|
||||
name = v['name']
|
||||
value = v['data']
|
||||
if name in skip:
|
||||
continue
|
||||
if name.startswith('ROOT_'):
|
||||
result[name[5:]] = value # strip ROOT_ prefix
|
||||
elif not any(name.startswith(p) for p in ['BACKEND_', 'FRONTEND_', 'PARAPHARMACY_']):
|
||||
result[name] = value # unprefixed → root
|
||||
print(json.dumps(result))
|
||||
" <<< "$VARS_JSON")
|
||||
|
||||
BACKEND_VARS=$(python3 -c "
|
||||
import json, sys
|
||||
vars = json.loads(sys.stdin.read())
|
||||
result = {}
|
||||
for v in vars:
|
||||
name = v['name']
|
||||
if name.startswith('BACKEND_'):
|
||||
result[name[8:]] = value = v['data']
|
||||
print(json.dumps(result))
|
||||
" <<< "$VARS_JSON")
|
||||
|
||||
FRONTEND_VARS=$(python3 -c "
|
||||
import json, sys
|
||||
vars = json.loads(sys.stdin.read())
|
||||
result = {}
|
||||
for v in vars:
|
||||
name = v['name']
|
||||
if name.startswith('FRONTEND_'):
|
||||
result[name[9:]] = v['data']
|
||||
print(json.dumps(result))
|
||||
" <<< "$VARS_JSON")
|
||||
|
||||
PARAPHARMACY_VARS=$(python3 -c "
|
||||
import json, sys
|
||||
vars = json.loads(sys.stdin.read())
|
||||
result = {}
|
||||
for v in vars:
|
||||
name = v['name']
|
||||
if name.startswith('PARAPHARMACY_'):
|
||||
result[name[13:]] = v['data']
|
||||
print(json.dumps(result))
|
||||
" <<< "$VARS_JSON")
|
||||
|
||||
# Write each .env file
|
||||
echo ""
|
||||
write_env "${WORK_DIR}/.env" "$ROOT_VARS"
|
||||
write_env "${WORK_DIR}/apps/backend/.env" "$BACKEND_VARS"
|
||||
write_env "${WORK_DIR}/apps/frontend/.env" "$FRONTEND_VARS"
|
||||
write_env "${WORK_DIR}/apps/parapharmacy-api/.env" "$PARAPHARMACY_VARS"
|
||||
|
||||
echo ""
|
||||
echo "=== All .env files written ==="
|
||||
Reference in New Issue
Block a user