Fixes on docker compose & notify
Run Tests on Branches / Backend Tests (push) Successful in 3m28s
Run Tests on Branches / Frontend Tests (push) Successful in 3m27s

This commit is contained in:
Antoni Nuñez Romeu
2026-07-02 17:09:15 +02:00
parent 15e136f3d5
commit 1bc34b1134
3 changed files with 170 additions and 64 deletions
+77 -21
View File
@@ -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
View File
@@ -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
+67 -19
View File
@@ -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}