Files
FarmaFinder/PLAN.md
Antoni Nuñez Romeu a1e52aa910
Build & Push Docker Images / test-backend (push) Successful in 42s
Build & Push Docker Images / build-frontend (push) Has been cancelled
Build & Push Docker Images / test-frontend (push) Has been cancelled
Run Tests / test-frontend (push) Has been cancelled
Run Tests / test-backend (push) Has been cancelled
Build & Push Docker Images / build-backend (push) Has been cancelled
Plan.md for later
2026-05-19 18:27:16 +02:00

364 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# FarmaFinder — Feature Plan
## Overview
Three features in recommended implementation order. Each is self-contained and can be shipped independently.
---
## Step 1 — "Near Me" Sorting (~2 hrs)
### Goal
Sort pharmacy results by distance from user's current location. Works in browser today, same logic reusable in React Native later.
### 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 }
);
});
}
```
**`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`
**`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
### Verification
```bash
# Start app, search a medicine, click "Sort by distance", allow geolocation
# Pharmacies reorder, distance badges appear
```
---
## Step 2 — Pharmacy Hours (~3 hrs)
### 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:
```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' }],
},
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,
}),
});
}
```
**`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`)
**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 },
})
);
});
self.addEventListener('notificationclick', event => {
event.notification.close();
event.waitUntil(clients.openWindow(event.notification.data.url));
});
```
### 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
### 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"
```
---
## 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.