feat: avatar popup options, profile redesign, and search dosage parsing
Run Tests on Branches / Backend Tests (push) Successful in 2m3s
Run Tests on Branches / Frontend Tests (push) Successful in 1m56s

- Added avatar selection popup with predefined avatar options
- Redesigned ProfileView (web + mobile) with avatar upload and editable fields
- Added AvatarSelectionModal for mobile profile
- Fixed medicine search: parse dosage terms (e.g. 'Paracetamol 1G')
  to query CIMA only by name and filter by dosage field
- Updated styles for profile, search, and medicine results
This commit is contained in:
Antoni Nuñez Romeu
2026-07-07 12:09:42 +02:00
parent 166e9635a1
commit 4404db62ee
34 changed files with 1625 additions and 60 deletions
+51 -15
View File
@@ -2,22 +2,56 @@ import axios from 'axios';
import redisClient from './redis-client.js';
const CIMA_API_BASE_URL = 'https://cima.aemps.es/cima/rest';
const CACHE_TTL = 3600; // 1 hora en segundos
const CACHE_TTL = 3600;
/**
* CIMA's nombre filter is prefix-oriented; narrow to rows that contain every
* search term in the commercial name or active ingredient (full-word style).
* Separa los términos de dosis (ej: "1g", "1000 mg") del nombre del
* medicamento, para poder buscar en CIMA solo con el nombre y luego
* filtrar por dosis con mayor precisión.
*/
function parseSearchQuery(query) {
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
const dosagePattern = /^\d+(g|mg|mcg|µg|ml|ui|%)$/i;
const dosageTerms = [];
const nameTerms = [];
for (const term of terms) {
const clean = term.replace(/\s+/g, '');
if (dosagePattern.test(clean)) {
dosageTerms.push(clean);
} else {
nameTerms.push(term);
}
}
return { nameQuery: nameTerms.join(' '), dosageTerms };
}
/**
* Filtra los resultados de CIMA comprobando que todos los términos del
* nombre aparezcan en el nombre comercial o principio activo, y que los
* términos de dosis coincidan con el campo dosage normalizado.
*/
function filterMedicinesByFullQuery(medicines, searchTerm) {
const terms = searchTerm
.trim()
.toLowerCase()
.split(/\s+/)
.filter(Boolean);
if (terms.length === 0) return medicines;
const parsed = parseSearchQuery(searchTerm);
const nameTerms = parsed.nameQuery.split(/\s+/).filter(Boolean);
const dosageTerms = parsed.dosageTerms;
if (nameTerms.length === 0 && dosageTerms.length === 0) return medicines;
return medicines.filter((m) => {
const hay = `${m.name || ''} ${m.active_ingredient || ''}`.toLowerCase();
return terms.every((term) => hay.includes(term));
if (nameTerms.length > 0) {
const hay = `${m.name || ''} ${m.active_ingredient || ''}`.toLowerCase();
if (!nameTerms.every((term) => hay.includes(term))) return false;
}
if (dosageTerms.length > 0) {
const dosageHay = (m.dosage || '').toLowerCase().replace(/\s+/g, '');
if (!dosageTerms.every((term) => dosageHay.includes(term))) return false;
}
return true;
});
}
@@ -32,7 +66,7 @@ export async function searchMedicines(query) {
}
const searchTerm = query.trim().toLowerCase();
const cacheKey = `medicines:search:v2:${searchTerm}`;
const cacheKey = `medicines:search:v3:${searchTerm}`;
try {
// Intentar obtener del caché
@@ -43,11 +77,13 @@ export async function searchMedicines(query) {
return JSON.parse(cachedData);
}
// Si no está en caché, consultar la API de CIMA
console.log(`🌐 Fetching from CIMA API: ${searchTerm}`);
const parsed = parseSearchQuery(searchTerm);
const apiSearchTerm = parsed.nameQuery || searchTerm;
console.log(`🌐 Fetching from CIMA API: ${apiSearchTerm}`);
const response = await axios.get(`${CIMA_API_BASE_URL}/medicamentos`, {
params: {
nombre: searchTerm
nombre: apiSearchTerm
},
timeout: 5000
});
+244 -27
View File
@@ -53,7 +53,7 @@ app.use(cors({
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
credentials: true
}));
app.use(express.json());
app.use(express.json({ limit: '10mb' }));
app.use(pinoHttp({ logger }));
const PG_URL = process.env.PG_URL;
@@ -356,6 +356,36 @@ if (!pgPool) {
)
`);
}
// User addresses table
if (pgPool) {
await pgPool.query(`
CREATE TABLE IF NOT EXISTS user_addresses (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
address TEXT NOT NULL,
latitude DOUBLE PRECISION,
longitude DOUBLE PRECISION,
label TEXT DEFAULT '',
is_default INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
} else {
await dbRun(`
CREATE TABLE IF NOT EXISTS user_addresses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
address TEXT NOT NULL,
latitude REAL,
longitude REAL,
label TEXT DEFAULT '',
is_default INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
)
`);
}
} catch (err) {
console.error('initDatabase failed:', err);
throw err;
@@ -787,7 +817,7 @@ app.get('/api/auth/check', async (req, res) => {
try {
if (req.session && req.session.userId) {
const user = await userDbGet(
'SELECT id, username, first_name, last_name, avatar_url, is_admin, address, latitude, longitude FROM users WHERE id = ?',
'SELECT id, username, first_name, last_name, avatar_url, email, city, is_admin, address, latitude, longitude FROM users WHERE id = ?',
[req.session.userId]
);
if (!user) {
@@ -802,6 +832,8 @@ app.get('/api/auth/check', async (req, res) => {
first_name: user.first_name || null,
last_name: user.last_name || null,
avatar_url: user.avatar_url || null,
email: user.email || null,
city: user.city || null,
is_admin: Boolean(user.is_admin),
address: user.address || null,
latitude: user.latitude,
@@ -846,38 +878,76 @@ app.get('/api/users/me', requireAuth, async (req, res) => {
// Update the current user's address + coordinates
app.put('/api/users/me', requireAuth, async (req, res) => {
try {
const { first_name, last_name, avatar_url, email, city, address, latitude, longitude } = req.body || {};
const body = req.body || {};
const sets = [];
const vals = [];
const fName = first_name == null ? null : String(first_name).trim().slice(0, 100);
const lName = last_name == null ? null : String(last_name).trim().slice(0, 100);
const avatar = avatar_url == null ? null : String(avatar_url).slice(0, 500);
const userEmail = email == null ? null : String(email).trim().slice(0, 200);
const userCity = city == null ? null : String(city).trim().slice(0, 200);
const addr =
address == null || String(address).trim() === ''
if ('first_name' in body) {
const v = body.first_name == null ? null : String(body.first_name).trim().slice(0, 100);
sets.push('first_name = ?');
vals.push(v);
}
if ('last_name' in body) {
const v = body.last_name == null ? null : String(body.last_name).trim().slice(0, 100);
sets.push('last_name = ?');
vals.push(v);
}
if ('avatar_url' in body) {
const v = body.avatar_url == null ? null : String(body.avatar_url);
sets.push('avatar_url = ?');
vals.push(v);
}
if ('email' in body) {
const v = body.email == null ? null : String(body.email).trim().slice(0, 200);
sets.push('email = ?');
vals.push(v);
}
if ('city' in body) {
const v = body.city == null ? null : String(body.city).trim().slice(0, 200);
sets.push('city = ?');
vals.push(v);
}
if ('address' in body) {
const v = body.address == null || String(body.address).trim() === ''
? null
: String(address).trim().slice(0, 500);
let lat = null;
let lon = null;
if (latitude !== '' && latitude != null) {
lat = typeof latitude === 'number' ? latitude : parseFloat(latitude);
if (!Number.isFinite(lat) || lat < -90 || lat > 90) {
return res.status(400).json({ error: 'Invalid latitude' });
: String(body.address).trim().slice(0, 500);
sets.push('address = ?');
vals.push(v);
}
if ('latitude' in body) {
if (body.latitude !== '' && body.latitude != null) {
const lat = typeof body.latitude === 'number' ? body.latitude : parseFloat(body.latitude);
if (!Number.isFinite(lat) || lat < -90 || lat > 90) {
return res.status(400).json({ error: 'Invalid latitude' });
}
sets.push('latitude = ?');
vals.push(lat);
} else {
sets.push('latitude = ?');
vals.push(null);
}
}
if (longitude !== '' && longitude != null) {
lon = typeof longitude === 'number' ? longitude : parseFloat(longitude);
if (!Number.isFinite(lon) || lon < -180 || lon > 180) {
return res.status(400).json({ error: 'Invalid longitude' });
if ('longitude' in body) {
if (body.longitude !== '' && body.longitude != null) {
const lon = typeof body.longitude === 'number' ? body.longitude : parseFloat(body.longitude);
if (!Number.isFinite(lon) || lon < -180 || lon > 180) {
return res.status(400).json({ error: 'Invalid longitude' });
}
sets.push('longitude = ?');
vals.push(lon);
} else {
sets.push('longitude = ?');
vals.push(null);
}
}
await userDbRun(
'UPDATE users SET first_name = ?, last_name = ?, avatar_url = ?, email = ?, city = ?, address = ?, latitude = ?, longitude = ? WHERE id = ?',
[fName, lName, avatar, userEmail, userCity, addr, lat, lon, req.session.userId]
);
if (sets.length > 0) {
vals.push(req.session.userId);
await userDbRun(
'UPDATE users SET ' + sets.join(', ') + ' WHERE id = ?',
vals
);
}
const user = await userDbGet(
'SELECT id, username, first_name, last_name, avatar_url, email, city, is_admin, address, latitude, longitude FROM users WHERE id = ?',
@@ -1038,6 +1108,153 @@ app.delete('/api/search-history/:id', requireAuth, async (req, res) => {
}
});
// ========== USER ADDRESSES API ==========
// List all addresses for the current user
app.get('/api/addresses', requireAuth, async (req, res) => {
try {
const rows = await userDbAll(
'SELECT id, address, latitude, longitude, label, is_default, created_at FROM user_addresses WHERE user_id = ? ORDER BY is_default DESC, created_at ASC',
[req.session.userId]
);
res.json(rows);
} catch (error) {
console.error('Error fetching addresses:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Add a new address
app.post('/api/addresses', requireAuth, async (req, res) => {
try {
const { address, latitude, longitude, label, is_default } = req.body || {};
if (!address) {
return res.status(400).json({ error: 'Address is required' });
}
if (is_default) {
await userDbRun(
'UPDATE user_addresses SET is_default = 0 WHERE user_id = ?',
[req.session.userId]
);
}
const result = await userDbRun(
'INSERT INTO user_addresses (user_id, address, latitude, longitude, label, is_default) VALUES (?, ?, ?, ?, ?, ?)',
[req.session.userId, address, latitude || null, longitude || null, label || '', is_default ? 1 : 0]
);
const newAddress = await userDbGet(
'SELECT id, address, latitude, longitude, label, is_default, created_at FROM user_addresses WHERE id = ?',
[result.lastID]
);
res.status(201).json(newAddress);
} catch (error) {
console.error('Error adding address:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Update an address
app.put('/api/addresses/:id', requireAuth, async (req, res) => {
try {
const addressId = parseInt(req.params.id);
const { address, latitude, longitude, label, is_default } = req.body || {};
if (!address) {
return res.status(400).json({ error: 'Address is required' });
}
if (is_default) {
await userDbRun(
'UPDATE user_addresses SET is_default = 0 WHERE user_id = ?',
[req.session.userId]
);
}
await userDbRun(
'UPDATE user_addresses SET address = ?, latitude = ?, longitude = ?, label = ?, is_default = ? WHERE id = ? AND user_id = ?',
[address, latitude || null, longitude || null, label || '', is_default ? 1 : 0, addressId, req.session.userId]
);
const updated = await userDbGet(
'SELECT id, address, latitude, longitude, label, is_default, created_at FROM user_addresses WHERE id = ?',
[addressId]
);
if (!updated) {
return res.status(404).json({ error: 'Address not found' });
}
res.json(updated);
} catch (error) {
console.error('Error updating address:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Set address as default
app.put('/api/addresses/:id/default', requireAuth, async (req, res) => {
try {
const addressId = parseInt(req.params.id);
const existing = await userDbGet(
'SELECT id FROM user_addresses WHERE id = ? AND user_id = ?',
[addressId, req.session.userId]
);
if (!existing) {
return res.status(404).json({ error: 'Address not found' });
}
await userDbRun(
'UPDATE user_addresses SET is_default = 0 WHERE user_id = ?',
[req.session.userId]
);
await userDbRun(
'UPDATE user_addresses SET is_default = 1 WHERE id = ?',
[addressId]
);
res.json({ ok: true });
} catch (error) {
console.error('Error setting default address:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Delete an address
app.delete('/api/addresses/:id', requireAuth, async (req, res) => {
try {
const addressId = parseInt(req.params.id);
const deleted = await userDbGet(
'SELECT is_default FROM user_addresses WHERE id = ? AND user_id = ?',
[addressId, req.session.userId]
);
await userDbRun(
'DELETE FROM user_addresses WHERE id = ? AND user_id = ?',
[addressId, req.session.userId]
);
// If the deleted address was default, set the oldest remaining as default
if (deleted?.is_default) {
const oldest = await userDbGet(
'SELECT id FROM user_addresses WHERE user_id = ? ORDER BY created_at ASC LIMIT 1',
[req.session.userId]
);
if (oldest) {
await userDbRun('UPDATE user_addresses SET is_default = 1 WHERE id = ?', [oldest.id]);
}
}
res.json({ ok: true });
} catch (error) {
console.error('Error deleting address:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Geocode for any authenticated user (rate-limited). Same impl as /api/admin/geocode.
app.get('/api/geocode', requireAuth, geocodeLimiter, async (req, res) => {
const q = (req.query.q || '').trim();