Merge pull request 'Fixes on docker compose & notify' (#5) from search_improvements into main
Build & Push Docker Images / test-backend (push) Successful in 32s
Build & Push Docker Images / test-frontend (push) Successful in 29s
Build & Push Docker Images / deploy (push) Successful in 19s
Build & Push Docker Images / build-backend (push) Successful in 19s
Build & Push Docker Images / build-frontend (push) Successful in 31s
Build & Push Docker Images / test-backend (push) Successful in 32s
Build & Push Docker Images / test-frontend (push) Successful in 29s
Build & Push Docker Images / deploy (push) Successful in 19s
Build & Push Docker Images / build-backend (push) Successful in 19s
Build & Push Docker Images / build-frontend (push) Successful in 31s
Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
+77
-21
@@ -139,6 +139,14 @@ async function userDbRun(sql, params = []) {
|
||||
return dbRun(sql.replace(/\s+RETURNING\s+\w+\s*$/i, ''), params);
|
||||
}
|
||||
|
||||
async function userDbAll(sql, params = []) {
|
||||
if (pgPool) {
|
||||
const res = await pgPool.query(toPositional(sql), params);
|
||||
return res.rows;
|
||||
}
|
||||
return dbAll(sql, params);
|
||||
}
|
||||
|
||||
// Accept opening_hours as either an object or a JSON string from the client
|
||||
// and return a TEXT value (or null) safe to persist.
|
||||
function serializeOpeningHours(value) {
|
||||
@@ -376,6 +384,75 @@ app.get('/api/medicines/:medicineId', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ========== AUTHENTICATION MIDDLEWARE ==========
|
||||
|
||||
// Middleware to check if user is authenticated
|
||||
const requireAuth = (req, res, next) => {
|
||||
if (req.session && req.session.userId) {
|
||||
return next();
|
||||
}
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
};
|
||||
|
||||
// Middleware to check if user is an admin
|
||||
const requireAdmin = (req, res, next) => {
|
||||
if (req.session && req.session.userId && req.session.isAdmin) {
|
||||
return next();
|
||||
}
|
||||
if (req.session && req.session.userId) {
|
||||
return res.status(403).json({ error: 'Admin access required' });
|
||||
}
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
};
|
||||
|
||||
// ========== RECENT SEARCHES (session-based) ==========
|
||||
|
||||
const MAX_RECENT = 10;
|
||||
|
||||
// Get recent searches for the logged-in user
|
||||
app.get('/api/search/recent', requireAuth, (req, res) => {
|
||||
res.json(req.session.recentSearches || []);
|
||||
});
|
||||
|
||||
// Save a medicine to recent searches
|
||||
app.post('/api/search/recent', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { medicine } = req.body;
|
||||
if (!medicine || !medicine.id) {
|
||||
return res.status(400).json({ error: 'Medicine data required' });
|
||||
}
|
||||
|
||||
if (!req.session.recentSearches) {
|
||||
req.session.recentSearches = [];
|
||||
}
|
||||
|
||||
// Remove duplicate if exists (by id)
|
||||
req.session.recentSearches = req.session.recentSearches.filter(
|
||||
(m) => m.id !== medicine.id
|
||||
);
|
||||
|
||||
// Add to front with timestamp
|
||||
req.session.recentSearches.unshift({
|
||||
id: medicine.id,
|
||||
name: medicine.name,
|
||||
active_ingredient: medicine.active_ingredient,
|
||||
dosage: medicine.dosage,
|
||||
form: medicine.form,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Keep only last MAX_RECENT
|
||||
if (req.session.recentSearches.length > MAX_RECENT) {
|
||||
req.session.recentSearches = req.session.recentSearches.slice(0, MAX_RECENT);
|
||||
}
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('Error saving recent search:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all pharmacies (for admin/debugging)
|
||||
app.get('/api/pharmacies', async (req, res) => {
|
||||
try {
|
||||
@@ -438,27 +515,6 @@ app.get('/api/tsi/:cip/prescriptions', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ========== AUTHENTICATION MIDDLEWARE ==========
|
||||
|
||||
// Middleware to check if user is authenticated
|
||||
const requireAuth = (req, res, next) => {
|
||||
if (req.session && req.session.userId) {
|
||||
return next();
|
||||
}
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
};
|
||||
|
||||
// Middleware to check if user is an admin
|
||||
const requireAdmin = (req, res, next) => {
|
||||
if (req.session && req.session.userId && req.session.isAdmin) {
|
||||
return next();
|
||||
}
|
||||
if (req.session && req.session.userId) {
|
||||
return res.status(403).json({ error: 'Admin access required' });
|
||||
}
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
};
|
||||
|
||||
// ========== AUTHENTICATION ROUTES ==========
|
||||
|
||||
// Login endpoint
|
||||
|
||||
+10
-8
@@ -7,18 +7,20 @@ services:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: farmaclic
|
||||
POSTGRES_USER: farmaclic
|
||||
POSTGRES_DB: farmafinder
|
||||
POSTGRES_USER: farmafinder
|
||||
POSTGRES_PASSWORD: ${PG_PASSWORD:-change-me-in-production}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
|
||||
backend:
|
||||
image: git.hacecalor.net/ichitux/farmaclic-backend:latest
|
||||
image: git.hacecalor.net/ichitux/farmafinder-backend:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
ports:
|
||||
- "3001:3001"
|
||||
environment:
|
||||
@@ -31,13 +33,13 @@ services:
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||
DATABASE_PATH: /app/data/database.sqlite
|
||||
FARMACIAS_WEBHOOK_URL: ${FARMACIAS_WEBHOOK_URL:-}
|
||||
PG_URL: postgresql://farmaclic:${PG_PASSWORD:-change-me-in-production}@postgres:5432/farmaclic
|
||||
PG_URL: postgresql://farmafinder:${PG_PASSWORD:-change-me-in-production}@postgres:5432/farmafinder
|
||||
# OpenTelemetry — exported via OTLP gRPC to the shared Alloy collector
|
||||
OTEL_SERVICE_NAME: farmaclic-backend
|
||||
OTEL_SERVICE_NAME: farmafinder-backend
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://host.docker.internal:4317
|
||||
OTEL_TRACES_EXPORTER: otlp
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_RESOURCE_ATTRIBUTES: service.namespace=farmaclic
|
||||
OTEL_RESOURCE_ATTRIBUTES: service.namespace=farmafinder
|
||||
volumes:
|
||||
- backend_data:/app/data
|
||||
depends_on:
|
||||
@@ -45,12 +47,12 @@ services:
|
||||
- postgres
|
||||
|
||||
frontend:
|
||||
image: git.hacecalor.net/ichitux/farmaclic-frontend:latest
|
||||
image: git.hacecalor.net/ichitux/farmafinder-frontend:latest
|
||||
build:
|
||||
context: ./frontend
|
||||
args:
|
||||
VITE_FARO_ENDPOINT: ${VITE_FARO_ENDPOINT:-http://localhost:4318}
|
||||
VITE_FARO_APP_NAME: ${VITE_FARO_APP_NAME:-farmaclic-frontend}
|
||||
VITE_FARO_APP_NAME: ${VITE_FARO_APP_NAME:-farmafinder-frontend}
|
||||
VITE_FARO_ENV: ${VITE_FARO_ENV:-production}
|
||||
VITE_FARO_APP_VERSION: ${VITE_FARO_APP_VERSION:-1.0.0}
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import SearchBar from '../components/SearchBar';
|
||||
import MedicineResults from '../components/MedicineResults';
|
||||
import PharmacyList from '../components/PharmacyList';
|
||||
@@ -13,11 +13,6 @@ const suggestions = [
|
||||
{ name: 'Omeprazol', icon: 'emergency_home', color: 'tint' },
|
||||
];
|
||||
|
||||
const recentResults = [
|
||||
{ name: 'Amoxicilina', detail: '500mg • 24 Cápsulas', tag: 'Antibiótico', distance: '300m - Farmacia Central' },
|
||||
{ name: 'Losartán', detail: '50mg • 30 Comprimidos', tag: 'Hipertensión', distance: '1.2km - Farmacia del Sol' },
|
||||
];
|
||||
|
||||
function SearchView({ currentUser, onLoginRequest }) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [medicines, setMedicines] = useState([]);
|
||||
@@ -29,6 +24,44 @@ function SearchView({ currentUser, onLoginRequest }) {
|
||||
const [sortByDistance, setSortByDistance] = useState(false);
|
||||
const [locating, setLocating] = useState(false);
|
||||
const [locationError, setLocationError] = useState('');
|
||||
const [recentSearches, setRecentSearches] = useState([]);
|
||||
|
||||
// Fetch recent searches when user is logged in
|
||||
useEffect(() => {
|
||||
if (!currentUser) {
|
||||
setRecentSearches([]);
|
||||
return;
|
||||
}
|
||||
fetch('/api/search/recent', { credentials: 'include' })
|
||||
.then((r) => r.json())
|
||||
.then((data) => setRecentSearches(Array.isArray(data) ? data : []))
|
||||
.catch(() => setRecentSearches([]));
|
||||
}, [currentUser]);
|
||||
|
||||
const saveToRecent = useCallback((medicine) => {
|
||||
if (!currentUser) return;
|
||||
fetch('/api/search/recent', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ medicine }),
|
||||
}).catch(() => {});
|
||||
// Update local state immediately
|
||||
setRecentSearches((prev) => {
|
||||
const filtered = prev.filter((m) => m.id !== medicine.id);
|
||||
return [
|
||||
{
|
||||
id: medicine.id,
|
||||
name: medicine.name,
|
||||
active_ingredient: medicine.active_ingredient,
|
||||
dosage: medicine.dosage,
|
||||
form: medicine.form,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
...filtered,
|
||||
].slice(0, 10);
|
||||
});
|
||||
}, [currentUser]);
|
||||
|
||||
useEffect(() => {
|
||||
const searchMedicines = async () => {
|
||||
@@ -157,26 +190,37 @@ function SearchView({ currentUser, onLoginRequest }) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{currentUser && recentSearches.length > 0 && (
|
||||
<section className="recent-section">
|
||||
<h2 className="section-title">Resultados Recientes</h2>
|
||||
<div className="recent-list">
|
||||
{recentResults.map((r, i) => (
|
||||
<div key={i} className="recent-card">
|
||||
{recentSearches.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="recent-card"
|
||||
onClick={() => setSelectedMedicine(r)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<div className="recent-card-top">
|
||||
<div>
|
||||
<h3 className="recent-name">{r.name}</h3>
|
||||
<p className="recent-detail">{r.detail}</p>
|
||||
<p className="recent-detail">
|
||||
{r.dosage && `${r.dosage}`}
|
||||
{r.dosage && r.form && ' \u2022 '}
|
||||
{r.form}
|
||||
</p>
|
||||
</div>
|
||||
<span className="recent-tag">{r.tag}</span>
|
||||
{r.active_ingredient && (
|
||||
<span className="recent-tag">{r.active_ingredient}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="recent-distance">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
<span>{r.distance}</span>
|
||||
</div>
|
||||
<button className="recent-btn">
|
||||
<button
|
||||
className="recent-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedMedicine(r);
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
@@ -187,13 +231,17 @@ function SearchView({ currentUser, onLoginRequest }) {
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{searchQuery && !selectedMedicine && (
|
||||
<MedicineResults
|
||||
medicines={medicines}
|
||||
onSelect={setSelectedMedicine}
|
||||
onSelect={(m) => {
|
||||
saveToRecent(m);
|
||||
setSelectedMedicine(m);
|
||||
}}
|
||||
query={searchQuery}
|
||||
currentUser={currentUser}
|
||||
onLoginRequest={onLoginRequest}
|
||||
|
||||
Reference in New Issue
Block a user