Plan.md for later
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
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
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
# 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 (Mon–Sun), 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.
|
||||
Reference in New Issue
Block a user