Cleanup modified & misisng files
Run Tests on Branches / Backend Tests (push) Successful in 3m31s
Run Tests on Branches / Frontend Tests (push) Successful in 3m26s

This commit is contained in:
Antoni Nuñez Romeu
2026-07-02 20:37:29 +02:00
parent 0ebd2dc35c
commit 3f5f88bbf5
16 changed files with 3732 additions and 379 deletions
+245 -332
View File
@@ -1,363 +1,276 @@
# FarmaFinder — Feature Plan
# Monitoring Integration Plan for Pharmacy Integration Platform (PIP)
## Overview
This document outlines how to integrate the PIP application with an existing Prometheus/Grafana/Alertmanager stack running on a remote host. The PIP application already exposes Prometheus metrics at `/metrics` and includes OpenTelemetry instrumentation for tracing.
Three features in recommended implementation order. Each is self-contained and can be shipped independently.
## Prerequisites
---
1. **Running PIP instance** - The application must be accessible from the monitoring host (via network).
2. **Existing monitoring stack** - Prometheus server, Grafana, and Alertmanager already deployed and reachable.
3. **Network access** - Ensure the monitoring host can scrape the PIP application's metrics endpoint (default port 8000).
4. **Credentials** (if applicable) - Any authentication tokens or basic auth required for scraping.
## Step 1 — "Near Me" Sorting (~2 hrs)
## 1. Metrics Endpoint
### Goal
Sort pharmacy results by distance from user's current location. Works in browser today, same logic reusable in React Native later.
The PIP application exposes Prometheus-formatted metrics at:
### Backend changes
None. `latitude` and `longitude` already returned by `GET /api/medicines/:nregistro/pharmacies`.
### Frontend changes
**`frontend/src/utils/geo.js`** (new)
```js
export function haversineKm(lat1, lon1, lat2, lon2) {
const R = 6371;
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLon = ((lon2 - lon1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
export function getUserPosition() {
return new Promise((resolve, reject) => {
if (!navigator.geolocation) return reject(new Error('Geolocation not supported'));
navigator.geolocation.getCurrentPosition(
pos => resolve({ lat: pos.coords.latitude, lon: pos.coords.longitude }),
reject,
{ timeout: 8000 }
);
});
}
```
http://<pip-host>:8000/metrics
```
**`frontend/src/views/PublicView.jsx`**
- Add state: `userPosition` (null | { lat, lon }), `sortByDistance` (bool)
- Add "Sort by distance" button — on click calls `getUserPosition()`, sets `userPosition`, sets `sortByDistance = true`
- Before rendering pharmacies, if `sortByDistance && userPosition`:
```js
const sorted = [...pharmacies].sort((a, b) => {
if (!a.latitude) return 1;
if (!b.latitude) return -1;
return haversineKm(userPosition.lat, userPosition.lon, a.latitude, a.longitude)
- haversineKm(userPosition.lat, userPosition.lon, b.latitude, b.longitude);
});
```
- Pass `sorted` (or original `pharmacies`) to `PharmacyList` and `PharmacyMap`
This endpoint is already mounted in the FastAPI application (see `src/main.py` line 64-65). No further changes are required unless you need to change the path or add authentication.
**`frontend/src/components/PharmacyList.jsx`**
- Accept optional `userPosition` prop
- When present and pharmacy has lat/lng, show distance badge: `1.2 km` next to pharmacy name
### Optional: Add Basic Auth to Metrics Endpoint
If your Prometheus server requires authentication, you can wrap the metrics endpoint with middleware. Example:
### Verification
```python
# In src/main.py after creating the app
from starlette.basic_auth import BasicAuthMiddleware
from src.infrastructure.config.settings import settings
if settings.METRICS_AUTH_USERNAME and settings.METRICS_AUTH_PASSWORD:
app.add_middleware(
BasicAuthMiddleware,
username=settings.METRICS_AUTH_USERNAME,
password=settings.METRICS_AUTH_PASSWORD,
)
```
Add the corresponding environment variables to your deployment.
## 2. Prometheus Configuration
Add a scrape job for the PIP service in your Prometheus configuration file (typically `prometheus.yml`).
```yaml
scrape_configs:
- job_name: 'pip-api'
static_configs:
- targets: ['<pip-host>:8000'] # replace with actual host/IP
# Optional: if you enabled basic auth on /metrics
# basic_auth:
# username: <username>
# password: <password>
# Optional: TLS configuration if using HTTPS
# scheme: https
# tls_config:
# ca_file: /path/to/ca.pem
# cert_file: /path/to/client-cert.pem
# key_file: /path/to/client-key.pem
metric_relabel_configs:
# Optional: filter or relabel metrics as needed
- source_labels: [__name__]
regex: 'pip_.*'
action: keep
```
After updating the configuration, reload Prometheus:
```bash
# Start app, search a medicine, click "Sort by distance", allow geolocation
# Pharmacies reorder, distance badges appear
curl -X POST http://<prometheus-host>:9090/-/reload
```
---
## 3. Grafana Dashboards
## Step 2 — Pharmacy Hours (~3 hrs)
You can import a pre-built dashboard JSON (provided below) or create your own panels using the exposed metrics.
### Goal
Store structured opening hours per pharmacy. Show "Open now" / "Closed" / "Opens at HH:MM" badge on pharmacy cards.
### Data model
Store hours as JSON in a single column — structured enough to parse, simple enough to edit manually in admin:
### Example Dashboard JSON
Save this as `pip-dashboard.json` and import via Grafana UI (`+ Import` → Paste JSON).
```json
{
"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
}
```
`null` = closed that day. 24h format strings. Only one time range per day (no split shifts) — extend later if needed.
### Backend changes
**`backend/server.js`** — `initDatabase()`
```sql
ALTER TABLE pharmacies ADD COLUMN opening_hours TEXT;
```
Wrap in try/catch — `ALTER TABLE` throws if column already exists.
**`GET /api/medicines/:nregistro/pharmacies`** — already returns `SELECT *`, no change needed.
**`POST /api/admin/pharmacies`** and **`PUT /api/admin/pharmacies/:id`**
- Accept `opening_hours` in request body (JSON string or object)
- Serialize to string before INSERT/UPDATE: `JSON.stringify(opening_hours) || null`
### Frontend changes
**`frontend/src/utils/hours.js`** (new)
```js
const DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
export function getOpenStatus(openingHoursJson) {
if (!openingHoursJson) return null;
let hours;
try { hours = typeof openingHoursJson === 'string' ? JSON.parse(openingHoursJson) : openingHoursJson; }
catch { return null; }
const now = new Date();
const day = DAYS[now.getDay()];
const range = hours[day];
if (!range) return { status: 'closed', label: 'Closed today' };
const [openStr, closeStr] = range;
const [oh, om] = openStr.split(':').map(Number);
const [ch, cm] = closeStr.split(':').map(Number);
const nowMins = now.getHours() * 60 + now.getMinutes();
const openMins = oh * 60 + om;
const closeMins = ch * 60 + cm;
if (nowMins < openMins) return { status: 'closed', label: `Opens at ${openStr}` };
if (nowMins >= closeMins) return { status: 'closed', label: `Closed · Opens ${getNextOpen(hours, now)}` };
return { status: 'open', label: `Open · Closes at ${closeStr}` };
}
```
**`frontend/src/components/PharmacyList.jsx`**
- Import `getOpenStatus`, render status badge per pharmacy card
- CSS: `.badge-open { color: green }` / `.badge-closed { color: #999 }`
**`frontend/src/components/admin/PharmacyManagement.jsx`**
- Add hours editor: 7 rows (MonSun), each with two time inputs (open/close) + "Closed" checkbox
- Serialize to JSON on save
### Verification
```bash
# Add pharmacy with hours via admin panel
# Search medicine linked to that pharmacy
# Card shows "Open · Closes at 21:00" or "Closed · Opens at 09:00"
# Change system clock or test at different times
```
---
## Step 3 — PWA + Push Notifications / Medicine Alerts (~1 day)
### Goal
User subscribes to a medicine. When any pharmacy links that medicine (via admin panel), the user receives a push notification in the browser. Foundation reusable for React Native (swap `web-push` for FCM).
### Architecture
```
User visits app
→ service worker registered (PWA)
→ user searches medicine, clicks "Notify me"
→ browser prompts for notification permission
→ browser generates push subscription (endpoint + keys)
→ POST /api/notifications/subscribe { medicine_nregistro, subscription }
→ stored in DB
Admin links medicine to pharmacy (POST /api/admin/pharmacy-medicines)
→ backend queries notifications table for medicine_nregistro
→ fires web-push to each subscription endpoint
→ user receives notification: "Ibuprofeno 400mg now available at Farmacia Sol"
```
### Backend changes
**Dependencies**
```bash
npm install web-push
```
Generate VAPID keys (one-time, store in `.env`):
```bash
node -e "const wp = require('web-push'); const k = wp.generateVAPIDKeys(); console.log(k)"
```
Add to `.env.example`:
```
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_EMAIL=mailto:admin@example.com
```
**`backend/server.js`** — `initDatabase()`
```sql
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
medicine_nregistro TEXT NOT NULL,
endpoint TEXT NOT NULL,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(medicine_nregistro, endpoint)
);
```
**New routes**
```
POST /api/notifications/subscribe
body: { medicine_nregistro, medicine_name, subscription: { endpoint, keys: { p256dh, auth } } }
→ upsert into push_subscriptions
→ 201
DELETE /api/notifications/unsubscribe
body: { medicine_nregistro, endpoint }
→ delete from push_subscriptions
→ 204
GET /api/notifications/vapid-public-key
→ returns { publicKey: process.env.VAPID_PUBLIC_KEY }
```
**Hook into `POST /api/admin/pharmacy-medicines`**
After successful insert:
```js
const subs = await dbAll(
'SELECT * FROM push_subscriptions WHERE medicine_nregistro = ?',
[medicine_nregistro]
);
for (const sub of subs) {
await webpush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
JSON.stringify({
title: 'Medicine available',
body: `${medicine_name} now available at ${pharmacyName}`,
url: '/'
})
).catch(() => {
// subscription expired — delete it
dbRun('DELETE FROM push_subscriptions WHERE id = ?', [sub.id]);
});
}
```
### Frontend changes
**Dependencies**
```bash
npm install vite-plugin-pwa
```
**`frontend/vite.config.js`**
```js
import { VitePWA } from 'vite-plugin-pwa';
// Add to plugins array:
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'FarmaFinder',
short_name: 'FarmaFinder',
theme_color: '#2563eb',
icons: [{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' }],
"dashboard": {
"id": null,
"title": "Pharmacy Integration Platform",
"timezone": "browser",
"schemaVersion": 38,
"version": 1,
"refresh": "10s",
"panels": [
{
"type": "graph",
"title": "HTTP Requests Rate",
"datasource": "Prometheus",
"targets": [
{
"expr": "sum by (method, endpoint, http_status) (rate(pip_http_requests_total[5m]))",
"legendFormat": "{{method}} {{endpoint}} {{http_status}}",
"refId": "A"
}
],
"yAxes": [
{
"format": "ops",
"label": "req/s"
}
],
"gridPos": { "x": 0, "y": 0, "w": 12, "h": 8 }
},
{
"type": "graph",
"title": "Request Duration (seconds)",
"datasource": "Prometheus",
"targets": [
{
"expr": "histogram_quantile(0.95, sum by (le, method, endpoint) (rate(pip_http_request_duration_seconds_bucket[5m])))",
"legendFormat": "{{method}} {{endpoint}} 95th percentile",
"refId": "A"
}
],
"yAxes": [
{
"format": "seconds",
"label": "seconds"
}
],
"gridPos": { "x": 12, "y": 0, "w": 12, "h": 8 }
},
{
"type": "graph",
"title": "Database Query Duration",
"datasource": "Prometheus",
"targets": [
{
"expr": "histogram_quantile(0.95, sum by (le, operation) (rate(pip_db_query_duration_seconds_bucket[5m])))",
"legendFormat": "{{operation}} 95th percentile",
"refId": "A"
}
],
"yAxes": [
{
"format": "seconds",
"label": "seconds"
}
],
"gridPos": { "x": 0, "y": 8, "w": 12, "h": 8 }
},
{
"type": "graph",
"title": "Redis Operations",
"datasource": "Prometheus",
"targets": [
{
"expr": "sum by (operation) (rate(pip_redis_operations_total[5m]))",
"legendFormat": "{{operation}}",
"refId": "A"
}
],
"yAxes": [
{
"format": "ops",
"label": "ops/s"
}
],
"gridPos": { "x": 12, "y": 8, "w": 12, "h": 8 }
},
{
"type": "graph",
"title": "RabbitMQ Messages Published",
"datasource": "Prometheus",
"targets": [
{
"expr": "sum by (exchange, routing_key) (rate(pip_rabbitmq_messages_published_total[5m]))",
"legendFormat": "{{exchange}} -> {{routing_key}}",
"refId": "A"
}
],
"yAxes": [
{
"format": "ops",
"label": "msgs/s"
}
],
"gridPos": { "x": 0, "y": 16, "w": 24, "h": 8 }
}
],
"templating": {
"list": []
},
"annotations": {
"list": []
}
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
},
})
```
**`frontend/src/utils/notifications.js`** (new)
```js
export async function getVapidKey() {
const res = await fetch('/api/notifications/vapid-public-key');
const { publicKey } = await res.json();
return publicKey;
}
export function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
return Uint8Array.from(atob(base64), c => c.charCodeAt(0));
}
export async function subscribeToPush(medicineNregistro, medicineName) {
const reg = await navigator.serviceWorker.ready;
const publicKey = await getVapidKey();
const subscription = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
});
await fetch('/api/notifications/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
medicine_nregistro: medicineNregistro,
medicine_name: medicineName,
subscription,
}),
});
"overwrite": true
}
```
**`frontend/src/components/MedicineResults.jsx`**
- Add "Notify me" bell button per medicine result
- On click: call `subscribeToPush(medicine.id, medicine.name)`
- Toggle state: subscribed / not subscribed (persist in `localStorage`)
### Import Steps
1. In Grafana, click the **+** icon → **Import**.
2. Paste the JSON above or upload the file.
3. Select your Prometheus data source.
4. Click **Import**.
**Service worker push handler** — `vite-plugin-pwa` injects the SW; add a custom handler:
```js
// frontend/src/sw.js (custom SW additions via injectManifest mode)
self.addEventListener('push', event => {
const data = event.data.json();
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icon-192.png',
data: { url: data.url },
})
);
});
## 4. Alerting Rules (Optional)
self.addEventListener('notificationclick', event => {
event.notification.close();
event.waitUntil(clients.openWindow(event.notification.data.url));
});
Create a rule file for Alertmanager (e.g., `pip_alerts.yml`) and add it to your Prometheus server.
```yaml
groups:
- name: pip-alerts
rules:
- alert: HighRequestLatency
expr: histogram_quantile(0.95, sum by (le, method, endpoint) (rate(pip_http_request_duration_seconds_bucket[5m]))) > 2
for: 2m
labels:
severity: warning
annotations:
summary: "High request latency on {{ $labels.endpoint }}"
description: "95th percentile latency is above 2 seconds for {{ $labels.method }} {{ $labels.endpoint }}."
- alert: APIErrorRateHigh
expr: sum by (http_status) (rate(pip_http_requests_total{http_status=~"5.."}[5m])) / sum by (http_status) (rate(pip_http_requests_total[5m])) > 0.05
for: 5m
labels:
severity: critical
annotations:
summary: "High error rate ({{ $value | printf \"%.2f\" }})"
description: "More than 5% of requests are returning 5xx errors."
- alert: RedisDown
expr: up{job="pip-api"} == 0
for: 1m
labels:
severity: critical
annotations:
summary: "PIP application scrape failed"
description: "Prometheus cannot scrape the PIP application metrics endpoint."
- alert: DatabaseConnectionFailures
expr: increase(pip_db_connection_errors_total[5m]) > 0
for: 2m
labels:
severity: warning
annotations:
summary: "Database connection errors detected"
description: "There have been {{ $value }} database connection errors in the last 5 minutes."
```
### React Native path (future)
- Replace `web-push` with Firebase Admin SDK (`firebase-admin`)
- Replace `push_subscriptions.endpoint/p256dh/auth` with `fcm_token`
- Frontend subscription logic moves to Expo `registerForPushNotificationsAsync()`
- Backend notification dispatch abstracted into `notify(medicineNregistro, message)` function — same interface, different transport
Add the file to your Prometheus server's rule files configuration and reload.
### Verification
```bash
# Start app over HTTPS (required for push — use ngrok or deploy)
# Search medicine, click bell icon
# Browser prompts notification permission — allow
# In admin: link that medicine to a pharmacy
# Browser notification fires: "Ibuprofeno 400mg now available at Farmacia Sol"
## 5. Verification Steps
1. **Verify Metrics Endpoint**
From the monitoring host, run:
```bash
curl -s http://<pip-host>:8000/metrics | head -20
```
You should see lines like `# HELP pip_http_requests_total ...` and `# TYPE pip_http_requests_total counter`.
2. **Check Prometheus Targets**
In Prometheus UI (`http://<prometheus-host>:9090/targets`), ensure the `pip-api` job shows `UP`.
3. **View Dashboard**
Open the imported dashboard in Grafana and verify panels populate with data.
4. **Test Alerts (Optional)**
You can temporarily trigger an alert by modifying the threshold (e.g., set `HighRequestLatency` to `> 0`) and verify Alertmanager fires.
## 6. Cleanup / Rollneeded) later you decide howto monitor within the docker compose stack again, just edit `docker-compose.yml` and uncomment the monitoring services (prometheus, grafana, alertmanager) and the related volumes, then run:
```
docker compose -f pip-platform/docker-compose.yml up -d
```
---
Make sure the healthcheck in the `pip-api` service still points to `/api/v1/system/health` (already updated).
## Recommended Order
---
| Step | Time | Depends on |
|------|------|-----------|
| 1. Near me sorting | ~2 hrs | Nothing |
| 2. Pharmacy hours | ~3 hrs | Nothing (can run parallel with step 1) |
| 3. PWA + alerts | ~1 day | Steps 1 and 2 done (better UX in notification: "open now, 1.2 km away") |
---
## Notes
- Push notifications require HTTPS in production. In development use ngrok or set `CHROME_FLAGS=--unsafely-treat-insecure-origin-as-secure=http://localhost:3000` for testing.
- VAPID keys are per-environment — generate once, never rotate unless necessary (rotation invalidates all existing subscriptions).
- The `UNIQUE(medicine_nregistro, endpoint)` constraint on `push_subscriptions` prevents duplicate alerts if user clicks "Notify me" multiple times.
*This plan is intended to be a onetime setup guide. Adjust hostnames, ports, authentication, and resource limits to match your production environment.*