Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b00c9aa4f | |||
| 86962dfa3a | |||
| c8b830017b | |||
| e641a35b82 | |||
| ff36748634 | |||
| 795d935413 | |||
| 2f24dd4ff8 |
@@ -5,29 +5,8 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
schedule:
|
||||
- cron: '0 6 * * 1'
|
||||
|
||||
jobs:
|
||||
audit-dependencies:
|
||||
name: Dependency Audit
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
- name: Audit for known vulnerabilities
|
||||
run: npm audit --omit=dev --audit-level=high
|
||||
- name: Audit (all, informational)
|
||||
if: always()
|
||||
run: npm audit --audit-level=moderate || true
|
||||
|
||||
detect-changes:
|
||||
name: Detect Changes
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -12,11 +12,7 @@ const PG_URL = process.env.PG_URL;
|
||||
|
||||
async function createAdmin() {
|
||||
const username = process.env.ADMIN_USERNAME || 'admin';
|
||||
const password = process.env.ADMIN_PASSWORD;
|
||||
if (!password) {
|
||||
console.error('Error: ADMIN_PASSWORD environment variable is required');
|
||||
process.exit(1);
|
||||
}
|
||||
const password = process.env.ADMIN_PASSWORD || 'admin123';
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
if (PG_URL) {
|
||||
|
||||
@@ -39,7 +39,6 @@
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"express-session": "^1.17.3",
|
||||
"helmet": "^8.1.0",
|
||||
"multer": "^2.2.0",
|
||||
"nodemailer": "^6.10.1",
|
||||
"pg": "^8.13.0",
|
||||
|
||||
@@ -9,7 +9,6 @@ import axios from 'axios';
|
||||
import redisClient from './redis-client.js';
|
||||
import * as appMetrics from './src/metrics.js';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import sqlite3 from 'sqlite3';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
@@ -40,23 +39,6 @@ const __dirname = path.dirname(__filename);
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// Security headers via helmet. Strict-Transport-Security is only sent when
|
||||
// behind HTTPS (NODE_ENV=production). CSP is permissive enough for an API
|
||||
// that serves JSON + serves the frontend from a different origin.
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:', 'https:'],
|
||||
connectSrc: ["'self'"],
|
||||
frameAncestors: ["'none'"],
|
||||
},
|
||||
},
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||
}));
|
||||
|
||||
// Structured JSON logger. The Pino OTel instrumentation attaches trace_id /
|
||||
// span_id to every log line so they can be correlated in Grafana.
|
||||
const logger = pino({
|
||||
@@ -650,50 +632,6 @@ if (!pgPool) {
|
||||
`);
|
||||
await dbRun(`CREATE INDEX IF NOT EXISTS idx_pwd_reset_token ON password_reset_tokens(token)`);
|
||||
}
|
||||
// ========== USER CONSENTS ==========
|
||||
if (pgPool) {
|
||||
await pgPool.query(`
|
||||
CREATE TABLE IF NOT EXISTS user_consents (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
session_id VARCHAR(255),
|
||||
category VARCHAR(20) NOT NULL CHECK (category IN ('essential', 'analytics', 'preferences', 'health_data')),
|
||||
granted BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
await pgPool.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_consents_user_cat
|
||||
ON user_consents(user_id, category) WHERE user_id IS NOT NULL
|
||||
`);
|
||||
await pgPool.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_consents_sess_cat
|
||||
ON user_consents(session_id, category) WHERE session_id IS NOT NULL
|
||||
`);
|
||||
await pgPool.query(`CREATE INDEX IF NOT EXISTS idx_user_consents_user_id ON user_consents(user_id) WHERE user_id IS NOT NULL`);
|
||||
await pgPool.query(`CREATE INDEX IF NOT EXISTS idx_user_consents_session_id ON user_consents(session_id) WHERE session_id IS NOT NULL`);
|
||||
}
|
||||
if (!pgPool) {
|
||||
await dbRun(`
|
||||
CREATE TABLE IF NOT EXISTS user_consents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
session_id TEXT,
|
||||
category TEXT NOT NULL CHECK (category IN ('essential', 'analytics', 'preferences', 'health_data')),
|
||||
granted INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
try {
|
||||
await dbRun(`CREATE UNIQUE INDEX IF NOT EXISTS idx_user_consents_user_cat ON user_consents(user_id, category) WHERE user_id IS NOT NULL`);
|
||||
} catch (e) { if (!/duplicate/i.test(e.message)) throw e; }
|
||||
try {
|
||||
await dbRun(`CREATE UNIQUE INDEX IF NOT EXISTS idx_user_consents_sess_cat ON user_consents(session_id, category) WHERE session_id IS NOT NULL`);
|
||||
} catch (e) { if (!/duplicate/i.test(e.message)) throw e; }
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('initDatabase failed:', err);
|
||||
throw err;
|
||||
@@ -955,110 +893,6 @@ const requireAdmin = (req, res, next) => {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
};
|
||||
|
||||
// Middleware to check if user has granted a specific consent category
|
||||
const requireConsent = (category) => {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.session?.userId;
|
||||
const sessionId = req.sessionID;
|
||||
let consent;
|
||||
if (userId) {
|
||||
consent = await userDbGet('SELECT granted FROM user_consents WHERE user_id = ? AND category = ?', [userId, category]);
|
||||
} else if (sessionId) {
|
||||
consent = await userDbGet('SELECT granted FROM user_consents WHERE session_id = ? AND category = ?', [sessionId, category]);
|
||||
}
|
||||
if (consent && consent.granted) return next();
|
||||
return res.status(403).json({ error: `Consent required: ${category}` });
|
||||
} catch (error) {
|
||||
console.error('Consent check error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// ========== CONSENT MANAGEMENT ==========
|
||||
app.get('/api/consents', async (req, res) => {
|
||||
try {
|
||||
const userId = req.session?.userId;
|
||||
const sessionId = req.sessionID;
|
||||
const categories = ['essential', 'analytics', 'preferences', 'health_data'];
|
||||
const result = {};
|
||||
categories.forEach(c => { result[c] = c === 'essential'; });
|
||||
if (userId) {
|
||||
const rows = await userDbAll('SELECT category, granted FROM user_consents WHERE user_id = ?', [userId]);
|
||||
rows.forEach(r => { result[r.category] = r.granted; });
|
||||
} else if (sessionId) {
|
||||
const rows = await userDbAll('SELECT category, granted FROM user_consents WHERE session_id = ?', [sessionId]);
|
||||
rows.forEach(r => { result[r.category] = r.granted; });
|
||||
}
|
||||
result.essential = true;
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Error fetching consents:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/consents', async (req, res) => {
|
||||
try {
|
||||
const userId = req.session?.userId;
|
||||
const sessionId = req.sessionID;
|
||||
const { categories } = req.body;
|
||||
if (!categories || typeof categories !== 'object') {
|
||||
return res.status(400).json({ error: 'categories object required' });
|
||||
}
|
||||
const allowedCategories = ['analytics', 'preferences', 'health_data'];
|
||||
for (const cat of allowedCategories) {
|
||||
if (cat in categories) {
|
||||
const granted = Boolean(categories[cat]);
|
||||
if (userId) {
|
||||
await userDbRun(
|
||||
`INSERT INTO user_consents (user_id, category, granted, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (user_id, category) DO UPDATE SET granted = ?, updated_at = CURRENT_TIMESTAMP`,
|
||||
[userId, cat, granted, granted]
|
||||
);
|
||||
} else if (sessionId) {
|
||||
await userDbRun(
|
||||
`INSERT INTO user_consents (session_id, category, granted, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (session_id, category) DO UPDATE SET granted = ?, updated_at = CURRENT_TIMESTAMP`,
|
||||
[sessionId, cat, granted, granted]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Return updated consents
|
||||
const result = { essential: true };
|
||||
const cats = ['analytics', 'preferences', 'health_data'];
|
||||
if (userId) {
|
||||
const rows = await userDbAll('SELECT category, granted FROM user_consents WHERE user_id = ?', [userId]);
|
||||
rows.forEach(r => { result[r.category] = r.granted; });
|
||||
} else if (sessionId) {
|
||||
const rows = await userDbAll('SELECT category, granted FROM user_consents WHERE session_id = ?', [sessionId]);
|
||||
rows.forEach(r => { result[r.category] = r.granted; });
|
||||
}
|
||||
cats.forEach(c => { if (!(c in result)) result[c] = false; });
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Error saving consents:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Migrate anonymous (session-based) consents to a user account on login/register
|
||||
async function migrateSessionConsents(userId, sessionId) {
|
||||
if (!userId || !sessionId) return;
|
||||
try {
|
||||
const sessionConsents = await userDbAll('SELECT category, granted FROM user_consents WHERE session_id = ?', [sessionId]);
|
||||
for (const consent of sessionConsents) {
|
||||
await userDbRun(
|
||||
`INSERT INTO user_consents (user_id, category, granted, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (user_id, category) DO UPDATE SET granted = ?, updated_at = CURRENT_TIMESTAMP`,
|
||||
[userId, consent.category, consent.granted, consent.granted]
|
||||
);
|
||||
}
|
||||
await userDbRun('DELETE FROM user_consents WHERE session_id = ?', [sessionId]);
|
||||
} catch (error) {
|
||||
console.error('Error migrating consents:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== RECENT SEARCHES (database-based) ==========
|
||||
|
||||
const MAX_RECENT = 5;
|
||||
@@ -1319,9 +1153,6 @@ app.post('/api/auth/login', loginLimiter, async (req, res) => {
|
||||
req.session.username = user.username;
|
||||
req.session.isAdmin = Boolean(user.is_admin);
|
||||
|
||||
// Migrate anonymous consents to user account
|
||||
await migrateSessionConsents(user.id, req.sessionID);
|
||||
|
||||
appMetrics.loginSuccessTotal.add(1);
|
||||
res.json({
|
||||
message: 'Login successful',
|
||||
@@ -1372,9 +1203,6 @@ app.post('/api/auth/register', registerLimiter, async (req, res) => {
|
||||
req.session.username = u;
|
||||
req.session.isAdmin = false;
|
||||
|
||||
// Migrate anonymous consents to new user account
|
||||
await migrateSessionConsents(result.lastID, req.sessionID);
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Registered',
|
||||
user: {
|
||||
|
||||
@@ -6,8 +6,3 @@ EXPO_PUBLIC_API_URL=http://localhost:3001/api
|
||||
|
||||
# For production builds, update this to:
|
||||
# EXPO_PUBLIC_API_URL=https://api.yourdomain.com/api
|
||||
|
||||
# Grafana Alloy OTLP/HTTP endpoint for Faro RUM (must be reachable from the
|
||||
# device — use a LAN IP / public hostname, NOT localhost).
|
||||
# Open router :4318 → srv84-macos:4318, or proxy /faro via Nginx Proxy Manager.
|
||||
EXPO_PUBLIC_FARO_URL=http://grafana.hacecalor.net:4318
|
||||
|
||||
@@ -24,13 +24,7 @@
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#007AFF"
|
||||
},
|
||||
"package": "com.farmafinder.app",
|
||||
"googleServicesFile": "./google-services.json",
|
||||
"config": {
|
||||
"googleMaps": {
|
||||
"apiKey": ""
|
||||
}
|
||||
}
|
||||
"package": "com.farmafinder.app"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, StyleSheet, Text } from 'react-native';
|
||||
import MapView, { Marker } from 'react-native-maps';
|
||||
import MapView, { Marker, UrlTile } from 'react-native-maps';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { getPharmacies } from '../../services/pharmacies';
|
||||
import { LoadingSpinner } from '../../components/LoadingSpinner';
|
||||
@@ -54,11 +54,18 @@ export default function MapScreen() {
|
||||
<View style={styles.container}>
|
||||
<MapView
|
||||
style={styles.map}
|
||||
mapType="none"
|
||||
region={region}
|
||||
onRegionChangeComplete={setRegion}
|
||||
showsUserLocation={true}
|
||||
showsMyLocationButton={true}
|
||||
>
|
||||
<UrlTile
|
||||
urlTemplate="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
maximumZ={19}
|
||||
flipY={false}
|
||||
tileSize={256}
|
||||
/>
|
||||
{pharmacies.map((pharmacy) => (
|
||||
<Marker
|
||||
key={pharmacy.id}
|
||||
|
||||
@@ -379,14 +379,6 @@ export default function ProfileScreen() {
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
<View style={[styles.menuDivider, { backgroundColor: colors.surfaceLow }]} />
|
||||
<TouchableOpacity style={styles.menuRow} onPress={() => router.push('/privacy')}>
|
||||
<View style={[styles.menuIconCircle, { backgroundColor: colors.surfaceLow }]}>
|
||||
<Ionicons name="shield-checkmark-outline" size={20} color={colors.primary} />
|
||||
</View>
|
||||
<Text style={[styles.menuLabel, { color: colors.text }]}>{t('nav.privacy')}</Text>
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Search history */}
|
||||
|
||||
@@ -174,9 +174,11 @@ export default function SearchScreen() {
|
||||
data={results}
|
||||
keyExtractor={(item) => item.nregistro}
|
||||
renderItem={({ item }) => <MedicineCard medicine={item} />}
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
{products.length > 0 && (
|
||||
ListEmptyComponent={
|
||||
products.length === 0 ? null : undefined
|
||||
}
|
||||
ListFooterComponent={
|
||||
products.length > 0 ? (
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>{t('search.parapharmacy')}</Text>
|
||||
{products.map((product) => (
|
||||
@@ -198,8 +200,7 @@ export default function SearchScreen() {
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
contentContainerStyle={[styles.list, isTablet && styles.listTablet]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { Image, StyleSheet, View } from 'react-native';
|
||||
@@ -9,12 +9,6 @@ import { useAuthStore } from '../store/authStore';
|
||||
import { registerForPushNotifications, addNotificationListener, addNotificationResponseListener } from '../services/notifications';
|
||||
import { ThemeProvider, useThemeContext } from '../components/ThemeProvider';
|
||||
import { LanguageProvider, useTranslation } from '../src/i18n';
|
||||
import { initFaro } from '../services/faro';
|
||||
import { hasConsentChoice } from '../services/consent';
|
||||
import CookieBanner from '../components/CookieBanner';
|
||||
|
||||
// Boot Faro RUM once, as early as possible.
|
||||
initFaro();
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
@@ -25,7 +19,6 @@ function RootLayoutInner() {
|
||||
const { checkAuth } = useAuthStore();
|
||||
const { colors, isDark } = useThemeContext();
|
||||
const { t } = useTranslation();
|
||||
const [showCookieBanner, setShowCookieBanner] = useState(false);
|
||||
const notificationListener = useRef<ReturnType<typeof addNotificationListener>>();
|
||||
const responseListener = useRef<ReturnType<typeof addNotificationResponseListener>>();
|
||||
|
||||
@@ -34,11 +27,6 @@ function RootLayoutInner() {
|
||||
|
||||
registerForPushNotifications();
|
||||
|
||||
(async () => {
|
||||
const consentChoice = await hasConsentChoice();
|
||||
if (!consentChoice) setShowCookieBanner(true);
|
||||
})();
|
||||
|
||||
notificationListener.current = addNotificationListener((notification) => {
|
||||
console.log('Notification received:', notification);
|
||||
});
|
||||
@@ -93,18 +81,7 @@ function RootLayoutInner() {
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="privacy" options={{ title: 'Política de Privacidad' }} />
|
||||
</Stack>
|
||||
{showCookieBanner && (
|
||||
<CookieBanner
|
||||
onConsent={async (consents) => {
|
||||
const { saveConsents } = await import('../services/consent');
|
||||
await saveConsents(consents);
|
||||
setShowCookieBanner(false);
|
||||
}}
|
||||
onPrivacyPress={() => setShowCookieBanner(false)}
|
||||
/>
|
||||
)}
|
||||
<StatusBar style={isDark ? 'light' : 'auto'} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { View, Text, ScrollView, StyleSheet, TouchableOpacity, Linking } from 'r
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import * as Location from 'expo-location';
|
||||
import MapView, { Marker, UrlTile } from 'react-native-maps';
|
||||
import { getMedicine, getMedicinePharmacies } from '../../services/medicines';
|
||||
import { subscribeToMedicine, unsubscribeFromMedicine } from '../../services/notifications';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
@@ -205,11 +206,38 @@ export default function MedicineDetailScreen() {
|
||||
</View>
|
||||
|
||||
{locatedPharmacies.length > 0 && (
|
||||
<View style={[styles.mapContainer, { backgroundColor: colors.surfaceVariant }]}>
|
||||
<Ionicons name="map-outline" size={48} color={colors.textSecondary} />
|
||||
<Text style={[styles.mapPlaceholder, { color: colors.textSecondary }]}>
|
||||
{t('medicine.mapSoon')}
|
||||
</Text>
|
||||
<View style={styles.mapContainer}>
|
||||
<MapView
|
||||
style={styles.map}
|
||||
mapType="none"
|
||||
initialRegion={{
|
||||
latitude: mapCenter.latitude,
|
||||
longitude: mapCenter.longitude,
|
||||
latitudeDelta: 0.05,
|
||||
longitudeDelta: 0.05,
|
||||
}}
|
||||
>
|
||||
<UrlTile
|
||||
urlTemplate="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
maximumZ={19}
|
||||
flipY={false}
|
||||
tileSize={256}
|
||||
/>
|
||||
{locatedPharmacies.map((pharm) => {
|
||||
const lat = getPharmacyLat(pharm);
|
||||
const lon = getPharmacyLon(pharm);
|
||||
if (lat == null || lon == null) return null;
|
||||
return (
|
||||
<Marker
|
||||
key={pharm.id}
|
||||
coordinate={{ latitude: lat, longitude: lon }}
|
||||
title={pharm.pharmacy?.name || pharm.name}
|
||||
description={pharm.pharmacy?.address || pharm.address}
|
||||
onCalloutPress={() => router.push(`/pharmacy/${pharm.pharmacy_id || pharm.id}`)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</MapView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -362,13 +390,9 @@ const styles = StyleSheet.create({
|
||||
height: 200,
|
||||
borderRadius: borderRadius.lg,
|
||||
overflow: 'hidden',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
mapPlaceholder: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
map: {
|
||||
flex: 1,
|
||||
},
|
||||
pharmaciesSection: {
|
||||
marginTop: spacing.sm,
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, ScrollView, StyleSheet, TouchableOpacity, Linking } from 'react-native';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import MapView, { Marker } from 'react-native-maps';
|
||||
import MapView, { Marker, UrlTile } from 'react-native-maps';
|
||||
import { getPharmacy, getPharmacyMedicines } from '../../services/pharmacies';
|
||||
import { subscribeToMedicine, unsubscribeFromMedicine } from '../../services/notifications';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
@@ -125,6 +125,7 @@ export default function PharmacyDetailScreen() {
|
||||
<View style={styles.mapContainer}>
|
||||
<MapView
|
||||
style={styles.map}
|
||||
mapType="none"
|
||||
initialRegion={{
|
||||
latitude: pharmacy.latitude,
|
||||
longitude: pharmacy.longitude,
|
||||
@@ -133,6 +134,12 @@ export default function PharmacyDetailScreen() {
|
||||
}}
|
||||
scrollEnabled={false}
|
||||
>
|
||||
<UrlTile
|
||||
urlTemplate="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
maximumZ={19}
|
||||
flipY={false}
|
||||
tileSize={256}
|
||||
/>
|
||||
<Marker
|
||||
coordinate={{
|
||||
latitude: pharmacy.latitude,
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import React from 'react';
|
||||
import { View, Text, ScrollView, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useThemeContext } from '../components/ThemeProvider';
|
||||
import { useTranslation } from '../src/i18n';
|
||||
import { spacing, borderRadius } from '../constants/theme';
|
||||
|
||||
const SECTIONS = ['controller', 'data_collected', 'purpose', 'legal_basis', 'external_services', 'retention', 'rights', 'contact', 'cookies'] as const;
|
||||
|
||||
export default function PrivacyScreen() {
|
||||
const router = useRouter();
|
||||
const { colors } = useThemeContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ScrollView style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={[styles.backBtn, { backgroundColor: colors.card }]}>
|
||||
<Ionicons name="arrow-back" size={20} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.title, { color: colors.text }]}>{t('privacy.title')}</Text>
|
||||
</View>
|
||||
<Text style={[styles.updated, { color: colors.textSecondary }]}>{t('privacy.last_updated')}</Text>
|
||||
{SECTIONS.map(section => (
|
||||
<View key={section} style={[styles.section, { backgroundColor: colors.card }]}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>{t(`privacy.section.${section}.title`)}</Text>
|
||||
<Text style={[styles.sectionContent, { color: colors.textSecondary }]}>{t(`privacy.section.${section}.content`)}</Text>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, padding: spacing.md },
|
||||
header: { flexDirection: 'row', alignItems: 'center', gap: 12, marginBottom: 16 },
|
||||
backBtn: { width: 36, height: 36, borderRadius: borderRadius.md, justifyContent: 'center', alignItems: 'center' },
|
||||
title: { fontSize: 22, fontWeight: '700' },
|
||||
updated: { fontSize: 12, marginBottom: 16 },
|
||||
section: { borderRadius: borderRadius.md, padding: 16, marginBottom: 12 },
|
||||
sectionTitle: { fontSize: 16, fontWeight: '700', marginBottom: 8 },
|
||||
sectionContent: { fontSize: 14, lineHeight: 22 },
|
||||
});
|
||||
@@ -3,16 +3,12 @@ import { View, StyleSheet } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { BarcodeScanner } from '../components/BarcodeScanner';
|
||||
import { searchMedicines } from '../services/medicines';
|
||||
import { hasConsent } from '../services/consent';
|
||||
import HealthConsentModal from '../components/HealthConsentModal';
|
||||
|
||||
export default function ScannerScreen() {
|
||||
const router = useRouter();
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [showHealthConsent, setShowHealthConsent] = useState(false);
|
||||
const [pendingBarcode, setPendingBarcode] = useState<string | null>(null);
|
||||
|
||||
const processBarcode = async (barcode: string) => {
|
||||
const handleBarcodeScanned = async (barcode: string) => {
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const results = await searchMedicines(barcode);
|
||||
@@ -29,16 +25,6 @@ export default function ScannerScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBarcodeScanned = async (barcode: string) => {
|
||||
const healthConsent = await hasConsent('health_data');
|
||||
if (!healthConsent) {
|
||||
setPendingBarcode(barcode);
|
||||
setShowHealthConsent(true);
|
||||
return;
|
||||
}
|
||||
await processBarcode(barcode);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
router.back();
|
||||
};
|
||||
@@ -49,24 +35,6 @@ export default function ScannerScreen() {
|
||||
onBarcodeScanned={handleBarcodeScanned}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
{showHealthConsent && (
|
||||
<HealthConsentModal
|
||||
onAccept={async () => {
|
||||
setShowHealthConsent(false);
|
||||
if (pendingBarcode) {
|
||||
const { saveConsents, getLocalConsents } = await import('../services/consent');
|
||||
const current = await getLocalConsents();
|
||||
await saveConsents({ ...current, health_data: true });
|
||||
await processBarcode(pendingBarcode);
|
||||
setPendingBarcode(null);
|
||||
}
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowHealthConsent(false);
|
||||
setPendingBarcode(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Modal } from 'react-native';
|
||||
import { useThemeContext } from './ThemeProvider';
|
||||
import { useTranslation } from '../src/i18n';
|
||||
import { borderRadius } from '../constants/theme';
|
||||
|
||||
interface CookieBannerProps {
|
||||
onConsent: (consents: { essential: boolean; analytics: boolean; preferences: boolean; health_data: boolean }) => void;
|
||||
onPrivacyPress: () => void;
|
||||
}
|
||||
|
||||
export default function CookieBanner({ onConsent, onPrivacyPress }: CookieBannerProps) {
|
||||
const { colors } = useThemeContext();
|
||||
const { t } = useTranslation();
|
||||
const [categories, setCategories] = useState({ analytics: false, preferences: false, health_data: false });
|
||||
|
||||
const toggleCategory = (cat: keyof typeof categories) => {
|
||||
setCategories(prev => ({ ...prev, [cat]: !prev[cat] }));
|
||||
};
|
||||
|
||||
const categoryKeys = ['analytics', 'preferences', 'health_data'] as const;
|
||||
|
||||
return (
|
||||
<Modal transparent animationType="slide" visible>
|
||||
<View style={styles.overlay}>
|
||||
<View style={[styles.container, { backgroundColor: colors.card }]}>
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
<Text style={[styles.title, { color: colors.text }]}>{t('cookie_banner.title')}</Text>
|
||||
<Text style={[styles.desc, { color: colors.textSecondary }]}>{t('cookie_banner.description')}</Text>
|
||||
<View style={styles.categories}>
|
||||
<View style={[styles.category, { backgroundColor: colors.background }]}>
|
||||
<View style={styles.categoryInfo}>
|
||||
<Text style={[styles.categoryName, { color: colors.text }]}>{t('cookie_banner.category.essential')}</Text>
|
||||
<Text style={[styles.categoryDesc, { color: colors.textSecondary }]}>{t('cookie_banner.category.essential_desc')}</Text>
|
||||
</View>
|
||||
<View style={[styles.toggle, styles.toggleLocked, { backgroundColor: colors.primary }]}>
|
||||
<Text style={styles.toggleLabel}>ON</Text>
|
||||
</View>
|
||||
</View>
|
||||
{categoryKeys.map(cat => (
|
||||
<View key={cat} style={[styles.category, { backgroundColor: colors.background }]}>
|
||||
<View style={styles.categoryInfo}>
|
||||
<Text style={[styles.categoryName, { color: colors.text }]}>{t(`cookie_banner.category.${cat}`)}</Text>
|
||||
<Text style={[styles.categoryDesc, { color: colors.textSecondary }]}>{t(`cookie_banner.category.${cat}_desc`)}</Text>
|
||||
</View>
|
||||
<TouchableOpacity style={[styles.toggle, categories[cat] && { backgroundColor: colors.primary }]} onPress={() => toggleCategory(cat)} activeOpacity={0.7}>
|
||||
<View style={[styles.toggleThumb, categories[cat] && styles.toggleThumbOn]} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity style={[styles.btn, { backgroundColor: colors.primary }]} onPress={() => onConsent({ essential: true, analytics: true, preferences: true, health_data: true })}>
|
||||
<Text style={[styles.btnText, { color: colors.background }]}>{t('cookie_banner.accept_all')}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.btn, { backgroundColor: colors.background, borderColor: colors.border, borderWidth: 1 }]} onPress={() => onConsent({ essential: true, analytics: false, preferences: false, health_data: false })}>
|
||||
<Text style={[styles.btnText, { color: colors.text }]}>{t('cookie_banner.reject_optional')}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.btn, { borderColor: colors.primary, borderWidth: 1 }]} onPress={() => onConsent({ essential: true, ...categories })}>
|
||||
<Text style={[styles.btnText, { color: colors.primary }]}>{t('cookie_banner.save')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<TouchableOpacity onPress={onPrivacyPress}>
|
||||
<Text style={[styles.moreInfo, { color: colors.textSecondary }]}>{t('cookie_banner.more_info')} →</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' },
|
||||
container: { borderTopLeftRadius: borderRadius.lg, borderTopRightRadius: borderRadius.lg, padding: 20, maxHeight: '85%' },
|
||||
title: { fontSize: 18, fontWeight: '700', marginBottom: 8 },
|
||||
desc: { fontSize: 14, lineHeight: 20, marginBottom: 16 },
|
||||
categories: { gap: 10, marginBottom: 16 },
|
||||
category: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', padding: 12, borderRadius: borderRadius.md, gap: 12 },
|
||||
categoryInfo: { flex: 1, gap: 2 },
|
||||
categoryName: { fontSize: 14, fontWeight: '600' },
|
||||
categoryDesc: { fontSize: 12, lineHeight: 16 },
|
||||
toggle: { width: 44, height: 24, borderRadius: 12, backgroundColor: '#ccc', justifyContent: 'center', alignItems: 'center', padding: 2 },
|
||||
toggleLocked: { opacity: 0.6 },
|
||||
toggleLabel: { fontSize: 9, fontWeight: '700', color: 'white' },
|
||||
toggleThumb: { width: 20, height: 20, borderRadius: 10, backgroundColor: 'white', alignSelf: 'flex-start' },
|
||||
toggleThumbOn: { alignSelf: 'flex-end' },
|
||||
actions: { gap: 8, marginBottom: 12 },
|
||||
btn: { paddingVertical: 12, borderRadius: 999, alignItems: 'center' },
|
||||
btnText: { fontSize: 14, fontWeight: '600' },
|
||||
moreInfo: { textAlign: 'center', fontSize: 12, textDecorationLine: 'underline' },
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet, Modal } from 'react-native';
|
||||
import { useThemeContext } from './ThemeProvider';
|
||||
import { useTranslation } from '../src/i18n';
|
||||
import { borderRadius } from '../constants/theme';
|
||||
|
||||
interface HealthConsentModalProps {
|
||||
onAccept: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function HealthConsentModal({ onAccept, onCancel }: HealthConsentModalProps) {
|
||||
const { colors } = useThemeContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Modal transparent animationType="fade" visible>
|
||||
<View style={styles.overlay}>
|
||||
<View style={[styles.container, { backgroundColor: colors.card }]}>
|
||||
<Text style={[styles.title, { color: colors.text }]}>{t('health_consent.title')}</Text>
|
||||
<Text style={[styles.desc, { color: colors.textSecondary }]}>{t('health_consent.description')}</Text>
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity style={[styles.btn, { backgroundColor: colors.primary }]} onPress={onAccept}>
|
||||
<Text style={[styles.btnText, { color: colors.background }]}>{t('health_consent.accept')}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.btn, { backgroundColor: 'transparent', borderWidth: 1, borderColor: colors.border }]} onPress={onCancel}>
|
||||
<Text style={[styles.btnText, { color: colors.text }]}>{t('health_consent.cancel')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'center', alignItems: 'center', padding: 24 },
|
||||
container: { borderRadius: borderRadius.lg, padding: 24, width: '100%', maxWidth: 340 },
|
||||
title: { fontSize: 18, fontWeight: '700', marginBottom: 12 },
|
||||
desc: { fontSize: 14, lineHeight: 20, marginBottom: 20 },
|
||||
actions: { gap: 10 },
|
||||
btn: { paddingVertical: 12, borderRadius: 999, alignItems: 'center' },
|
||||
btnText: { fontSize: 14, fontWeight: '600' },
|
||||
});
|
||||
@@ -4,7 +4,6 @@
|
||||
"main": "expo-router/entry",
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@grafana/faro-react-native": "^1.3.0",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"axios": "^1.18.1",
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { api } from './api';
|
||||
|
||||
const CONSENT_KEY = 'consents';
|
||||
|
||||
export interface Consents {
|
||||
essential: boolean;
|
||||
analytics: boolean;
|
||||
preferences: boolean;
|
||||
health_data: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_CONSENTS: Consents = {
|
||||
essential: true,
|
||||
analytics: false,
|
||||
preferences: false,
|
||||
health_data: false,
|
||||
};
|
||||
|
||||
export async function getLocalConsents(): Promise<Consents> {
|
||||
try {
|
||||
const stored = await SecureStore.getItemAsync(CONSENT_KEY);
|
||||
if (stored) return { ...DEFAULT_CONSENTS, ...JSON.parse(stored), essential: true };
|
||||
} catch {}
|
||||
return { ...DEFAULT_CONSENTS };
|
||||
}
|
||||
|
||||
export async function setLocalConsents(consents: Consents): Promise<void> {
|
||||
try { await SecureStore.setItemAsync(CONSENT_KEY, JSON.stringify(consents)); } catch {}
|
||||
}
|
||||
|
||||
export async function hasConsentChoice(): Promise<boolean> {
|
||||
try { return (await SecureStore.getItemAsync(CONSENT_KEY)) !== null; } catch { return false; }
|
||||
}
|
||||
|
||||
export async function fetchServerConsents(): Promise<Consents | null> {
|
||||
try {
|
||||
const res = await api.get('/consents');
|
||||
await setLocalConsents(res.data);
|
||||
return res.data;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export async function saveConsents(consents: Consents): Promise<Consents> {
|
||||
const toSave = { ...consents, essential: true };
|
||||
await setLocalConsents(toSave);
|
||||
try {
|
||||
const res = await api.put('/consents', {
|
||||
categories: { analytics: toSave.analytics, preferences: toSave.preferences, health_data: toSave.health_data },
|
||||
});
|
||||
await setLocalConsents(res.data);
|
||||
return res.data;
|
||||
} catch { return toSave; }
|
||||
}
|
||||
|
||||
export async function hasConsent(category: keyof Consents): Promise<boolean> {
|
||||
const consents = await getLocalConsents();
|
||||
return consents[category] === true;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { initializeFaro } from '@grafana/faro-react-native';
|
||||
|
||||
let initialized = false;
|
||||
|
||||
/**
|
||||
* Initializes Grafana Faro RUM for the React Native app.
|
||||
*
|
||||
* Telemetry (crashes, JS errors, console, app-start, memory/ANR vitals,
|
||||
* session + screen tracking) is sent OTLP/HTTP to the Alloy collector.
|
||||
*
|
||||
* NOTE: `@grafana/faro-react-native` ships a native module, so it requires a
|
||||
* custom dev build / EAS build (not Expo Go). Initialization is wrapped in a
|
||||
* try/catch so a collector outage never crashes the app.
|
||||
*/
|
||||
export function initFaro(): void {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
try {
|
||||
const url =
|
||||
process.env.EXPO_PUBLIC_FARO_URL || 'http://grafana.hacecalor.net:4318';
|
||||
|
||||
initializeFaro({
|
||||
app: {
|
||||
name: 'farmafinder-mobile',
|
||||
version: '1.0.0',
|
||||
environment: __DEV__ ? 'development' : 'production',
|
||||
},
|
||||
url,
|
||||
sessionTracking: { enabled: true },
|
||||
// Crash/JS error/console capture are on by default.
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[faro] initialization failed:', err);
|
||||
}
|
||||
}
|
||||
@@ -230,31 +230,6 @@ const ca = {
|
||||
'barcodeScanner.cancel': 'Cancel·lar',
|
||||
'barcodeScanner.scanningHint': 'Apunteu la càmera al codi de barres del medicament',
|
||||
'barcodeScanner.scanAgain': 'Escanejar de nou',
|
||||
|
||||
// Cookie Banner
|
||||
'cookie_banner.title': 'Utilitzem cookies i dades personals',
|
||||
'cookie_banner.description': 'Utilitzem cookies i tecnologies similars per millorar la teva experiència i, si ho permetes, escanejar la teva targeta sanitària.',
|
||||
'cookie_banner.category.essential': 'Essencials',
|
||||
'cookie_banner.category.essential_desc': 'Necessàries per al funcionament de l\'app.',
|
||||
'cookie_banner.category.analytics': 'Analítica',
|
||||
'cookie_banner.category.analytics_desc': 'Ens ajuden a millorar l\'app.',
|
||||
'cookie_banner.category.preferences': 'Preferències',
|
||||
'cookie_banner.category.preferences_desc': 'Recordar el teu idioma i tema.',
|
||||
'cookie_banner.category.health_data': 'Dades de salut',
|
||||
'cookie_banner.category.health_data_desc': 'Escaneig de targeta sanitària.',
|
||||
'cookie_banner.accept_all': 'Acceptar tot',
|
||||
'cookie_banner.reject_optional': 'Rebutjar',
|
||||
'cookie_banner.save': 'Desar',
|
||||
'cookie_banner.more_info': 'Més informació',
|
||||
// Health Consent
|
||||
'health_consent.title': 'Consentiment per a dades de salut',
|
||||
'health_consent.description': 'Per escanejar la teva targeta sanitària (TSI), necessitem extreure el teu codi CIP i accedir a les teves receptes.',
|
||||
'health_consent.accept': 'Acceptar i escanejar',
|
||||
'health_consent.cancel': 'Cancel·lar',
|
||||
// Privacy
|
||||
'privacy.title': 'Política de Privacitat',
|
||||
'privacy.last_updated': 'Última actualització: 26/08/2026',
|
||||
'nav.privacy': 'Política de privacitat',
|
||||
};
|
||||
|
||||
export default ca;
|
||||
|
||||
@@ -230,31 +230,6 @@ const es = {
|
||||
'barcodeScanner.cancel': 'Cancelar',
|
||||
'barcodeScanner.scanningHint': 'Apunta la cámara al código de barras del medicamento',
|
||||
'barcodeScanner.scanAgain': 'Escanear de nuevo',
|
||||
|
||||
// Cookie Banner
|
||||
'cookie_banner.title': 'Utilizamos cookies y datos personales',
|
||||
'cookie_banner.description': 'Utilizamos cookies y tecnologías similares para mejorar tu experiencia, analizar el uso de la app y, si lo permites, escanear tu tarjeta sanitaria.',
|
||||
'cookie_banner.category.essential': 'Esenciales',
|
||||
'cookie_banner.category.essential_desc': 'Necesarias para el funcionamiento de la app.',
|
||||
'cookie_banner.category.analytics': 'Analítica',
|
||||
'cookie_banner.category.analytics_desc': 'Nos ayudan a mejorar la app.',
|
||||
'cookie_banner.category.preferences': 'Preferencias',
|
||||
'cookie_banner.category.preferences_desc': 'Recordar tu idioma y tema.',
|
||||
'cookie_banner.category.health_data': 'Datos de salud',
|
||||
'cookie_banner.category.health_data_desc': 'Escaneo de tarjeta sanitaria.',
|
||||
'cookie_banner.accept_all': 'Aceptar todo',
|
||||
'cookie_banner.reject_optional': 'Rechazar',
|
||||
'cookie_banner.save': 'Guardar',
|
||||
'cookie_banner.more_info': 'Más información',
|
||||
// Health Consent
|
||||
'health_consent.title': 'Consentimiento para datos de salud',
|
||||
'health_consent.description': 'Para escanear tu tarjeta sanitaria (TSI), necesitamos extraer tu código CIP y acceder a tus recetas.',
|
||||
'health_consent.accept': 'Aceptar y escanear',
|
||||
'health_consent.cancel': 'Cancelar',
|
||||
// Privacy
|
||||
'privacy.title': 'Política de Privacidad',
|
||||
'privacy.last_updated': 'Última actualización: 26/08/2026',
|
||||
'nav.privacy': 'Política de privacidad',
|
||||
};
|
||||
|
||||
export default es;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_tokens off;
|
||||
@@ -12,8 +10,7 @@ server {
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
location /api/ {
|
||||
set $backend http://backend:3001;
|
||||
proxy_pass $backend;
|
||||
proxy_pass http://backend:3001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
@@ -12,12 +12,7 @@ import ForgotPasswordModal from './components/ForgotPasswordModal';
|
||||
import ResetPasswordView from './views/ResetPasswordView';
|
||||
import SavedNotifications from './components/SavedNotifications';
|
||||
import BottomNav from './components/BottomNav';
|
||||
import PrivacySidebar from './components/PrivacySidebar';
|
||||
import CookieBanner from './components/CookieBanner';
|
||||
import HealthConsentModal from './components/HealthConsentModal';
|
||||
import PrivacyView from './views/PrivacyView';
|
||||
import { getFaro } from './utils/faro';
|
||||
import { hasConsentChoice, getLocalConsents, saveConsents, fetchServerConsents } from './utils/consent';
|
||||
|
||||
function App() {
|
||||
const [screen, setScreen] = useState('home');
|
||||
@@ -31,10 +26,6 @@ function App() {
|
||||
const [badgeCount, setBadgeCount] = useState(0);
|
||||
const [prescriptionSearch, setPrescriptionSearch] = useState('');
|
||||
const [productScreen, setProductScreen] = useState(null);
|
||||
const [showCookieBanner, setShowCookieBanner] = useState(!hasConsentChoice());
|
||||
const [showHealthConsent, setShowHealthConsent] = useState(false);
|
||||
const [pendingTsiScan, setPendingTsiScan] = useState(null);
|
||||
const [consents, setConsents] = useState(getLocalConsents);
|
||||
const [screenSize, setScreenSize] = useState({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight
|
||||
@@ -118,14 +109,6 @@ function App() {
|
||||
return () => { cancelled = true; };
|
||||
}, [currentUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser) {
|
||||
fetchServerConsents().then(serverConsents => {
|
||||
if (serverConsents) setConsents(serverConsents);
|
||||
});
|
||||
}
|
||||
}, [currentUser]);
|
||||
|
||||
function refreshBadgeCount() {
|
||||
if (!currentUser) return;
|
||||
fetch('/api/notifications/mine', { credentials: 'include' })
|
||||
@@ -167,36 +150,6 @@ function App() {
|
||||
window.history.replaceState({}, '', '/');
|
||||
}
|
||||
|
||||
async function handleCookieConsent(newConsents) {
|
||||
const saved = await saveConsents(newConsents);
|
||||
setConsents(saved);
|
||||
setShowCookieBanner(false);
|
||||
}
|
||||
|
||||
function handleTsiScanRequest(scanFn) {
|
||||
if (consents.health_data) {
|
||||
scanFn();
|
||||
} else {
|
||||
setPendingTsiScan(() => scanFn);
|
||||
setShowHealthConsent(true);
|
||||
}
|
||||
}
|
||||
|
||||
function handleHealthConsentAccept() {
|
||||
saveConsents({ ...consents, health_data: true });
|
||||
setConsents(prev => ({ ...prev, health_data: true }));
|
||||
setShowHealthConsent(false);
|
||||
if (pendingTsiScan) {
|
||||
pendingTsiScan();
|
||||
setPendingTsiScan(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleHealthConsentCancel() {
|
||||
setShowHealthConsent(false);
|
||||
setPendingTsiScan(null);
|
||||
}
|
||||
|
||||
function handleAdminClick() {
|
||||
setScreen('admin');
|
||||
}
|
||||
@@ -217,7 +170,6 @@ function App() {
|
||||
else setShowLogin(true);
|
||||
return;
|
||||
}
|
||||
if (tab === 'privacy') { setScreen('privacy'); return; }
|
||||
}
|
||||
|
||||
let activeView;
|
||||
@@ -280,8 +232,6 @@ function App() {
|
||||
setPrescriptionSearch(name);
|
||||
setScreen('search');
|
||||
}}
|
||||
onTsiScanRequest={handleTsiScanRequest}
|
||||
consents={consents}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
@@ -296,9 +246,6 @@ function App() {
|
||||
case 'admin':
|
||||
activeView = <AdminView />;
|
||||
break;
|
||||
case 'privacy':
|
||||
activeView = <PrivacyView onBack={() => setScreen('home')} />;
|
||||
break;
|
||||
default:
|
||||
activeView = (
|
||||
<HomeView
|
||||
@@ -324,11 +271,6 @@ function App() {
|
||||
badgeCount={badgeCount}
|
||||
/>
|
||||
|
||||
<PrivacySidebar
|
||||
onNavigate={handleNavChange}
|
||||
isVisible={screen !== 'privacy'}
|
||||
/>
|
||||
|
||||
{showLogin && (
|
||||
<LoginModal
|
||||
onLogin={handleLogin}
|
||||
@@ -351,16 +293,6 @@ function App() {
|
||||
{showSaved && currentUser && (
|
||||
<SavedNotifications onClose={() => setShowSaved(false)} onNotificationChange={refreshBadgeCount} />
|
||||
)}
|
||||
|
||||
{showCookieBanner && (
|
||||
<CookieBanner
|
||||
onConsent={handleCookieConsent}
|
||||
onPrivacyClick={() => { setShowCookieBanner(false); setScreen('privacy'); }}
|
||||
/>
|
||||
)}
|
||||
{showHealthConsent && (
|
||||
<HealthConsentModal onAccept={handleHealthConsentAccept} onCancel={handleHealthConsentCancel} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
.cookie-banner-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(28, 25, 23, 0.5);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
.cookie-banner {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
padding: 1.5rem 1.25rem 1.25rem;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.15);
|
||||
animation: slideUp 0.25s ease;
|
||||
}
|
||||
.cookie-banner-title { font-size: 1.1rem; font-weight: 700; color: var(--text-main); margin: 0 0 0.5rem; }
|
||||
.cookie-banner-desc { font-size: 0.85rem; color: var(--text-muted); line-height: 1.5; margin: 0 0 1rem; }
|
||||
.cookie-categories { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1.25rem; }
|
||||
.cookie-category { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.75rem; background: var(--surface-muted); border-radius: var(--radius); }
|
||||
.cookie-category-info { display: flex; flex-direction: column; gap: 0.15rem; flex: 1; }
|
||||
.cookie-category-name { font-size: 0.9rem; font-weight: 600; color: var(--text-main); }
|
||||
.cookie-category-desc { font-size: 0.78rem; color: var(--text-muted); line-height: 1.4; }
|
||||
.cookie-toggle { background: none; border: none; cursor: pointer; padding: 0; }
|
||||
.cookie-toggle-track { display: block; width: 44px; height: 24px; background: var(--border); border-radius: 12px; position: relative; transition: background 0.2s; }
|
||||
.cookie-toggle--on .cookie-toggle-track { background: var(--primary); }
|
||||
.cookie-toggle-thumb { display: block; width: 20px; height: 20px; background: white; border-radius: 50%; position: absolute; top: 2px; left: 2px; transition: transform 0.2s; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); }
|
||||
.cookie-toggle--on .cookie-toggle-thumb { transform: translateX(20px); }
|
||||
.cookie-toggle--locked .cookie-toggle-track { background: var(--primary); opacity: 0.6; cursor: not-allowed; }
|
||||
.cookie-banner-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.cookie-btn { flex: 1; min-width: 0; padding: 0.6rem 1rem; border-radius: 999px; border: none; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: opacity 0.15s; white-space: nowrap; }
|
||||
.cookie-btn:hover { opacity: 0.85; }
|
||||
.cookie-btn--primary { background: var(--primary); color: var(--on-primary); }
|
||||
.cookie-btn--secondary { background: var(--surface-muted); color: var(--text-main); border: 1px solid var(--border); }
|
||||
.cookie-btn--tertiary { background: transparent; color: var(--primary); border: 1px solid var(--primary); }
|
||||
.cookie-more-info { display: block; width: 100%; margin-top: 0.75rem; padding: 0; background: none; border: none; color: var(--text-muted); font-size: 0.8rem; cursor: pointer; text-align: center; text-decoration: underline; }
|
||||
.cookie-more-info:hover { color: var(--text-main); }
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
||||
@media (min-width: 769px) {
|
||||
.cookie-banner-overlay { align-items: center; padding: 1rem; }
|
||||
.cookie-banner { border-radius: var(--radius-lg); max-width: 420px; }
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from '../i18n';
|
||||
import './CookieBanner.css';
|
||||
|
||||
function CookieBanner({ onConsent, onPrivacyClick }) {
|
||||
const { t } = useTranslation();
|
||||
const [categories, setCategories] = useState({
|
||||
analytics: false,
|
||||
preferences: false,
|
||||
health_data: false,
|
||||
});
|
||||
|
||||
function toggleCategory(cat) {
|
||||
setCategories(prev => ({ ...prev, [cat]: !prev[cat] }));
|
||||
}
|
||||
|
||||
function handleAcceptAll() {
|
||||
onConsent({ essential: true, analytics: true, preferences: true, health_data: true });
|
||||
}
|
||||
|
||||
function handleRejectOptional() {
|
||||
onConsent({ essential: true, analytics: false, preferences: false, health_data: false });
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
onConsent({ essential: true, ...categories });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cookie-banner-overlay">
|
||||
<div className="cookie-banner">
|
||||
<h2 className="cookie-banner-title">{t('cookie_banner.title')}</h2>
|
||||
<p className="cookie-banner-desc">{t('cookie_banner.description')}</p>
|
||||
<div className="cookie-categories">
|
||||
<div className="cookie-category">
|
||||
<div className="cookie-category-info">
|
||||
<span className="cookie-category-name">{t('cookie_banner.category.essential')}</span>
|
||||
<span className="cookie-category-desc">{t('cookie_banner.category.essential_desc')}</span>
|
||||
</div>
|
||||
<div className="cookie-toggle cookie-toggle--locked">
|
||||
<span className="cookie-toggle-track"><span className="cookie-toggle-thumb" /></span>
|
||||
</div>
|
||||
</div>
|
||||
{['analytics', 'preferences', 'health_data'].map(cat => (
|
||||
<div className="cookie-category" key={cat}>
|
||||
<div className="cookie-category-info">
|
||||
<span className="cookie-category-name">{t(`cookie_banner.category.${cat}`)}</span>
|
||||
<span className="cookie-category-desc">{t(`cookie_banner.category.${cat}_desc`)}</span>
|
||||
</div>
|
||||
<button type="button" className={`cookie-toggle ${categories[cat] ? 'cookie-toggle--on' : ''}`} onClick={() => toggleCategory(cat)} aria-pressed={categories[cat]}>
|
||||
<span className="cookie-toggle-track"><span className="cookie-toggle-thumb" /></span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="cookie-banner-actions">
|
||||
<button type="button" className="cookie-btn cookie-btn--primary" onClick={handleAcceptAll}>{t('cookie_banner.accept_all')}</button>
|
||||
<button type="button" className="cookie-btn cookie-btn--secondary" onClick={handleRejectOptional}>{t('cookie_banner.reject_optional')}</button>
|
||||
<button type="button" className="cookie-btn cookie-btn--tertiary" onClick={handleSave}>{t('cookie_banner.save')}</button>
|
||||
</div>
|
||||
<button type="button" className="cookie-more-info" onClick={onPrivacyClick}>{t('cookie_banner.more_info')} →</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CookieBanner;
|
||||
@@ -1,8 +0,0 @@
|
||||
.health-consent-modal { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 2rem 2.25rem 1.75rem; width: 100%; max-width: 360px; box-shadow: 0 20px 60px rgba(28, 25, 23, 0.18); animation: slideUp 0.18s ease; }
|
||||
.health-consent-title { font-size: 1.15rem; font-weight: 700; color: var(--text-main); margin: 0 0 0.75rem; }
|
||||
.health-consent-desc { font-size: 0.9rem; color: var(--text-muted); line-height: 1.6; margin: 0 0 1.5rem; }
|
||||
.health-consent-actions { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.health-consent-btn { width: 100%; padding: 0.65rem 1rem; border-radius: 999px; border: none; font-size: 0.9rem; font-weight: 600; cursor: pointer; transition: opacity 0.15s; }
|
||||
.health-consent-btn:hover { opacity: 0.85; }
|
||||
.health-consent-btn--accept { background: var(--primary); color: var(--on-primary); }
|
||||
.health-consent-btn--cancel { background: var(--surface-muted); color: var(--text-main); border: 1px solid var(--border); }
|
||||
@@ -1,28 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useTranslation } from '../i18n';
|
||||
import './HealthConsentModal.css';
|
||||
|
||||
function HealthConsentModal({ onAccept, onCancel }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
function handleKey(e) { if (e.key === 'Escape') onCancel(); }
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => document.removeEventListener('keydown', handleKey);
|
||||
}, [onCancel]);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onCancel}>
|
||||
<div className="health-consent-modal" onClick={e => e.stopPropagation()} role="dialog" aria-modal="true" aria-label={t('health_consent.title')}>
|
||||
<h2 className="health-consent-title">{t('health_consent.title')}</h2>
|
||||
<p className="health-consent-desc">{t('health_consent.description')}</p>
|
||||
<div className="health-consent-actions">
|
||||
<button type="button" className="health-consent-btn health-consent-btn--accept" onClick={onAccept}>{t('health_consent.accept')}</button>
|
||||
<button type="button" className="health-consent-btn health-consent-btn--cancel" onClick={onCancel}>{t('health_consent.cancel')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HealthConsentModal;
|
||||
@@ -1,78 +0,0 @@
|
||||
.privacy-sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.privacy-sidebar-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
width: 2.5rem;
|
||||
padding: 0.75rem 0.25rem;
|
||||
background: var(--surface-container-low);
|
||||
border: none;
|
||||
border-radius: 0 var(--radius-md) var(--radius-md) 0;
|
||||
color: var(--on-surface-variant);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.08);
|
||||
min-height: unset;
|
||||
}
|
||||
|
||||
.privacy-sidebar-btn:hover {
|
||||
background: var(--surface-container);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.privacy-sidebar-btn:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--primary-ring), 2px 0 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.privacy-sidebar-text {
|
||||
font-size: 0.55rem;
|
||||
font-weight: 500;
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* Desktop - wider sidebar */
|
||||
@media (min-width: 1025px) {
|
||||
.privacy-sidebar-btn {
|
||||
width: 2.75rem;
|
||||
padding: 1rem 0.35rem;
|
||||
}
|
||||
|
||||
.privacy-sidebar-text {
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile - adjust for safe area */
|
||||
@media (max-width: 768px) {
|
||||
.privacy-sidebar {
|
||||
top: auto;
|
||||
bottom: 6rem;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.privacy-sidebar-btn {
|
||||
width: 2.25rem;
|
||||
padding: 0.5rem 0.2rem;
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
}
|
||||
|
||||
.privacy-sidebar-text {
|
||||
font-size: 0.5rem;
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useTranslation } from '../i18n';
|
||||
import './PrivacySidebar.css';
|
||||
|
||||
function PrivacySidebar({ onNavigate, isVisible }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<aside className="privacy-sidebar" aria-label="Enlace de privacidad">
|
||||
<button
|
||||
type="button"
|
||||
className="privacy-sidebar-btn"
|
||||
onClick={() => onNavigate('privacy')}
|
||||
aria-label={t('nav.privacy')}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
</svg>
|
||||
<span className="privacy-sidebar-text">{t('nav.privacy')}</span>
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default PrivacySidebar;
|
||||
@@ -487,49 +487,6 @@ const ca = {
|
||||
'admin.linkProduct.deleteConfirm': 'Eliminar aquest producte de la farmàcia?',
|
||||
'admin.linkProduct.deleteSuccess': 'Producte eliminat de la farmàcia!',
|
||||
'admin.linkProduct.deleteError': 'Error en eliminar producte',
|
||||
|
||||
// Cookie Banner
|
||||
'cookie_banner.title': 'Utilitzem cookies i dades personals',
|
||||
'cookie_banner.description': 'Utilitzem cookies i tecnologies similars per millorar la teva experiència, analitzar l\'ús de l\'aplicació i, si ho permetes, escanejar la teva targeta sanitària per buscar les teves receptes.',
|
||||
'cookie_banner.category.essential': 'Essencials',
|
||||
'cookie_banner.category.essential_desc': 'Necessàries per al funcionament de l\'aplicació. No es poden desactivar.',
|
||||
'cookie_banner.category.analytics': 'Analítica',
|
||||
'cookie_banner.category.analytics_desc': 'Ens ajuden a entendre com s\'usa l\'aplicació per millorar-la.',
|
||||
'cookie_banner.category.preferences': 'Preferències',
|
||||
'cookie_banner.category.preferences_desc': 'Recordar el teu idioma, tema i cerques guardades.',
|
||||
'cookie_banner.category.health_data': 'Dades de salut',
|
||||
'cookie_banner.category.health_data_desc': 'Escaneig de targeta sanitària (TSI) per buscar receptes i medicaments.',
|
||||
'cookie_banner.accept_all': 'Acceptar tot',
|
||||
'cookie_banner.reject_optional': 'Rebutjar opcionals',
|
||||
'cookie_banner.save': 'Desar',
|
||||
'cookie_banner.more_info': 'Més informació',
|
||||
// Health Consent Modal
|
||||
'health_consent.title': 'Consentiment per a dades de salut',
|
||||
'health_consent.description': 'Per escanejar la teva targeta sanitària (TSI), necessitem extreure el teu codi CIP i accedir a les teves receptes del sistema sanitari. Aquesta informació s\'usa únicament per buscar-te els medicaments de les teves receptes.',
|
||||
'health_consent.accept': 'Acceptar i escanejar',
|
||||
'health_consent.cancel': 'Cancel·lar',
|
||||
// Privacy View
|
||||
'privacy.title': 'Política de Privacitat',
|
||||
'privacy.last_updated': 'Última actualització: 26/08/2026',
|
||||
'privacy.section.controller.title': 'Responsable del Tractament',
|
||||
'privacy.section.controller.content': 'Hacecalor S.L. és la responsable del tractament de les teves dades personals.',
|
||||
'privacy.section.data_collected.title': 'Dades Recollides',
|
||||
'privacy.section.data_collected.content': 'Recollim les següents dades:\n• Dades de perfil: nom d\'usuari, email, nom, cognoms, ciutat, avatar\n• Adreces: adreça física amb coordenades geogràfiques\n• Ubicació: ubicació en temps real per ordenar farmàcies per distància\n• Historial de cerques: medicaments i ubicacions buscats\n• Dades de salut: codi CIP de la teva targeta sanitària i receptes associades\n• Tokens de notificacions: per enviar-te avisos de disponibilitat de medicaments\n• Dades de sessió: identificador de sessió per mantenir-te connectat',
|
||||
'privacy.section.purpose.title': 'Finalitat del Tractament',
|
||||
'privacy.section.purpose.content': 'Les teves dades s\'utilitzen per:\n• Buscar medicaments i farmàcies properes\n• Gestionar el teu compte i preferències\n• Enviar-te notificacions de disponibilitat de medicaments\n• Escanejar la teva targeta sanitària per trobar les teves receptes\n• Millorar l\'aplicació mitjançant l\'anàlisi d\'ús',
|
||||
'privacy.section.legal_basis.title': 'Base Legal',
|
||||
'privacy.section.legal_basis.content': 'El tractament es basa en:\n• Consentiment explícit: dades de salut (targeta sanitària) i anàlisi d\'ús\n• Execució de contracte: gestió del teu compte i serveis sol·licitats\n• Interès legítim: seguretat de l\'aplicació i prevenció de frau',
|
||||
'privacy.section.external_services.title': 'Serveis Externs',
|
||||
'privacy.section.external_services.content': 'Utilitzem els següents serveis externs:\n• CIMA (Agència Espanyola de Medicaments): base de dades de medicaments\n• Grafana Faro: anàlisi d\'ús i rendiment de l\'aplicació\n• Nominatim/OpenStreetMap: geolocalització de farmàcies\n• N8N: automatització de processos interns\n• Proveïdor d\'email: enviament d\'emails de recuperació de contrasenya',
|
||||
'privacy.section.retention.title': 'Conservació de Dades',
|
||||
'privacy.section.retention.content': '• Sessones: 24 hores\n• Compte d\'usuari: fins que sol·licitis la seva eliminació\n• Registres de consentiment: 3 anys\n• Historial de cerques: 6 mesos\n• Dades d\'ubicació: s\'eliminen en tancar la sessió',
|
||||
'privacy.section.rights.title': 'Els Teus Drets',
|
||||
'privacy.section.rights.content': 'Tens dret a:\n• Accedir a les teves dades personals\n• Rectificar dades inexactes\n• Sol·licitar l\'eliminació de les teves dades\n• Portabilitat de dades\n• Oposar-te al tractament\n• Retirar el teu consentiment en qualsevol moment\n\nPer exercir aquests drets, contacta amb nosaltres a l\'adreça indicada a continuació.',
|
||||
'privacy.section.contact.title': 'Contacte',
|
||||
'privacy.section.contact.content': 'Per exercir els teus drets o consultar sobre el tractament de les teves dades, contacta amb Hacecalor S.L. a través de l\'adreça de email de suport de l\'aplicació.',
|
||||
'privacy.section.cookies.title': 'Política de Cookies',
|
||||
'privacy.section.cookies.content': 'Utilitzem les següents categories de cookies:\n• Essencials: necessàries per al funcionament de l\'app (sessió, autenticació)\n• Analítica: per mesurar l\'ús i rendiment de l\'app\n• Preferències: per recordar el teu idioma, tema i configuració\n• Dades de salut: per a l\'escaneig de targeta sanitària i receptes\n\nPots gestionar les teves preferències de cookies en qualsevol moment des de la configuració de l\'aplicació.',
|
||||
'nav.privacy': 'Política de privacitat',
|
||||
};
|
||||
|
||||
export default ca;
|
||||
|
||||
@@ -489,49 +489,6 @@ const es = {
|
||||
'admin.linkProduct.deleteConfirm': '¿Eliminar este producto de la farmacia?',
|
||||
'admin.linkProduct.deleteSuccess': '¡Producto eliminado de la farmacia!',
|
||||
'admin.linkProduct.deleteError': 'Error al eliminar producto',
|
||||
|
||||
// Cookie Banner
|
||||
'cookie_banner.title': 'Utilizamos cookies y datos personales',
|
||||
'cookie_banner.description': 'Utilizamos cookies y tecnologías similares para mejorar tu experiencia, analizar el uso de la app y, si lo permites, escanear tu tarjeta sanitaria para buscar tus recetas.',
|
||||
'cookie_banner.category.essential': 'Esenciales',
|
||||
'cookie_banner.category.essential_desc': 'Necesarias para el funcionamiento de la app. No se pueden desactivar.',
|
||||
'cookie_banner.category.analytics': 'Analítica',
|
||||
'cookie_banner.category.analytics_desc': 'Nos ayudan a entender cómo se usa la app para mejorarla.',
|
||||
'cookie_banner.category.preferences': 'Preferencias',
|
||||
'cookie_banner.category.preferences_desc': 'Recordar tu idioma, tema y búsquedas guardadas.',
|
||||
'cookie_banner.category.health_data': 'Datos de salud',
|
||||
'cookie_banner.category.health_data_desc': 'Escaneo de tarjeta sanitaria (TSI) para buscar recetas y medicamentos.',
|
||||
'cookie_banner.accept_all': 'Aceptar todo',
|
||||
'cookie_banner.reject_optional': 'Rechazar opcionales',
|
||||
'cookie_banner.save': 'Guardar',
|
||||
'cookie_banner.more_info': 'Más información',
|
||||
// Health Consent Modal
|
||||
'health_consent.title': 'Consentimiento para datos de salud',
|
||||
'health_consent.description': 'Para escanear tu tarjeta sanitaria (TSI), necesitamos extraer tu código CIP y acceder a tus recetas del sistema sanitario. Esta información se usa únicamente para buscarte los medicamentos de tus recetas.',
|
||||
'health_consent.accept': 'Aceptar y escanear',
|
||||
'health_consent.cancel': 'Cancelar',
|
||||
// Privacy View
|
||||
'privacy.title': 'Política de Privacidad',
|
||||
'privacy.last_updated': 'Última actualización: 26/08/2026',
|
||||
'privacy.section.controller.title': 'Responsable del Tratamiento',
|
||||
'privacy.section.controller.content': 'Hacecalor S.L. es la responsable del tratamiento de tus datos personales.',
|
||||
'privacy.section.data_collected.title': 'Datos Recopilados',
|
||||
'privacy.section.data_collected.content': 'Recopilamos los siguientes datos:\n• Datos de perfil: nombre de usuario, email, nombre, apellidos, ciudad, avatar\n• Direcciones: dirección física con coordenadas geográficas\n• Ubicación: ubicación en tiempo real para ordenar farmacias por distancia\n• Historial de búsquedas: medicamentos y ubicaciones buscadas\n• Datos de salud: código CIP de tu tarjeta sanitaria y recetas asociadas\n• Tokens de notificaciones: para enviarte avisos de disponibilidad de medicamentos\n• Datos de sesión: identificador de sesión para mantenerte conectado',
|
||||
'privacy.section.purpose.title': 'Finalidad del Tratamiento',
|
||||
'privacy.section.purpose.content': 'Tus datos se utilizan para:\n• Buscar medicamentos y farmacias cercanas\n• Gestionar tu cuenta y preferencias\n• Enviarte notificaciones de disponibilidad de medicamentos\n• Escanear tu tarjeta sanitaria para encontrar tus recetas\n• Mejorar la aplicación mediante análisis de uso',
|
||||
'privacy.section.legal_basis.title': 'Base Legal',
|
||||
'privacy.section.legal_basis.content': 'El tratamiento se basa en:\n• Consentimiento explícito: datos de salud (tarjeta sanitaria) y análisis de uso\n• Ejecución de contrato: gestión de tu cuenta y servicios solicitados\n• Interés legítimo: seguridad de la aplicación y prevención de fraude',
|
||||
'privacy.section.external_services.title': 'Servicios Externos',
|
||||
'privacy.section.external_services.content': 'Utilizamos los siguientes servicios externos:\n• CIMA (Agencia Española de Medicamentos): base de datos de medicamentos\n• Grafana Faro: análisis de uso y rendimiento de la aplicación\n• Nominatim/OpenStreetMap: geolocalización de farmacias\n• N8N: automatización de procesos internos\n• Proveedor de email: envío de emails de recuperación de contraseña',
|
||||
'privacy.section.retention.title': 'Conservación de Datos',
|
||||
'privacy.section.retention.content': '• Sesiones: 24 horas\n• Cuenta de usuario: hasta que solicites su eliminación\n• Registros de consentimiento: 3 años\n• Historial de búsquedas: 6 meses\n• Datos de ubicación: se eliminan al cerrar la sesión',
|
||||
'privacy.section.rights.title': 'Tus Derechos',
|
||||
'privacy.section.rights.content': 'Tienes derecho a:\n• Acceder a tus datos personales\n• Rectificar datos inexactos\n• Solicitar la eliminación de tus datos\n• Portabilidad de datos\n• Oponerte al tratamiento\n• Retirar tu consentimiento en cualquier momento\n\nPara ejercer estos derechos, contacta con nosotros en la dirección indicada abajo.',
|
||||
'privacy.section.contact.title': 'Contacto',
|
||||
'privacy.section.contact.content': 'Para ejercer tus derechos o consultar sobre el tratamiento de tus datos, contacta con Hacecalor S.L. a través de la dirección de email de soporte de la aplicación.',
|
||||
'privacy.section.cookies.title': 'Política de Cookies',
|
||||
'privacy.section.cookies.content': 'Utilizamos las siguientes categorías de cookies:\n• Esenciales: necesarias para el funcionamiento de la app (sesión, autenticación)\n• Analítica: para medir el uso y rendimiento de la app\n• Preferencias: para recordar tu idioma, tema y configuración\n• Datos de salud: para el escaneo de tarjeta sanitaria y recetas\n\nPuedes gestionar tus preferencias de cookies en cualquier momento desde la configuración de la aplicación.',
|
||||
'nav.privacy': 'Política de privacidad',
|
||||
};
|
||||
|
||||
export default es;
|
||||
|
||||
@@ -9,12 +9,7 @@ import { initFaro } from './utils/faro';
|
||||
|
||||
// Initialize Grafana Faro (browser RUM) before rendering.
|
||||
// No-op if VITE_FARO_ENDPOINT is not configured.
|
||||
try {
|
||||
const consents = JSON.parse(localStorage.getItem('farmafinder_consents') || '{}');
|
||||
if (consents.analytics) {
|
||||
initFaro();
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Global unhandled promise rejection handler — report to Faro
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
const CONSENT_KEY = 'farmafinder_consents';
|
||||
|
||||
const DEFAULT_CONSENTS = {
|
||||
essential: true,
|
||||
analytics: false,
|
||||
preferences: false,
|
||||
health_data: false,
|
||||
};
|
||||
|
||||
export function getLocalConsents() {
|
||||
try {
|
||||
const stored = localStorage.getItem(CONSENT_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
return { ...DEFAULT_CONSENTS, ...parsed, essential: true };
|
||||
}
|
||||
} catch {}
|
||||
return { ...DEFAULT_CONSENTS };
|
||||
}
|
||||
|
||||
export function setLocalConsents(consents) {
|
||||
try {
|
||||
localStorage.setItem(CONSENT_KEY, JSON.stringify(consents));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function hasConsentChoice() {
|
||||
try {
|
||||
return localStorage.getItem(CONSENT_KEY) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchServerConsents() {
|
||||
try {
|
||||
const res = await fetch('/api/consents', { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const serverConsents = await res.json();
|
||||
setLocalConsents(serverConsents);
|
||||
return serverConsents;
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function saveConsents(consents) {
|
||||
const toSave = { ...consents, essential: true };
|
||||
setLocalConsents(toSave);
|
||||
try {
|
||||
const res = await fetch('/api/consents', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
categories: {
|
||||
analytics: toSave.analytics,
|
||||
preferences: toSave.preferences,
|
||||
health_data: toSave.health_data,
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const serverConsents = await res.json();
|
||||
setLocalConsents(serverConsents);
|
||||
return serverConsents;
|
||||
}
|
||||
} catch {}
|
||||
return toSave;
|
||||
}
|
||||
|
||||
export function hasConsent(category) {
|
||||
const consents = getLocalConsents();
|
||||
return consents[category] === true;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
.privacy-view { width: 100%; max-width: 640px; margin: 0 auto; padding: 1rem 1.25rem 2rem; animation: fadeInUp 0.3s ease-out; }
|
||||
.privacy-header { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1.5rem; }
|
||||
.privacy-back { background: var(--surface-muted); border: 1px solid var(--border); border-radius: var(--radius); width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; cursor: pointer; font-size: 1.1rem; color: var(--text-main); flex-shrink: 0; transition: background 0.15s; }
|
||||
.privacy-back:hover { background: var(--border); }
|
||||
.privacy-title { font-size: 1.3rem; font-weight: 700; color: var(--text-main); margin: 0; }
|
||||
.privacy-updated { font-size: 0.8rem; color: var(--text-muted); margin: 0 0 1.5rem; }
|
||||
.privacy-content { display: flex; flex-direction: column; gap: 1.5rem; }
|
||||
.privacy-section { background: var(--surface-muted); border-radius: var(--radius); padding: 1.25rem; }
|
||||
.privacy-section-title { font-size: 1rem; font-weight: 700; color: var(--text-main); margin: 0 0 0.75rem; }
|
||||
.privacy-section-content { font-size: 0.88rem; color: var(--text-muted); line-height: 1.6; }
|
||||
.privacy-section-content p { margin: 0 0 0.5rem; }
|
||||
.privacy-section-content p:last-child { margin-bottom: 0; }
|
||||
@keyframes fadeInUp { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@media (max-width: 768px) { .privacy-view { padding: 0.75rem 1rem 2rem; } }
|
||||
@@ -1,32 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from '../i18n';
|
||||
import './PrivacyView.css';
|
||||
|
||||
function PrivacyView({ onBack }) {
|
||||
const { t } = useTranslation();
|
||||
const sections = ['controller', 'data_collected', 'purpose', 'legal_basis', 'external_services', 'retention', 'rights', 'contact', 'cookies'];
|
||||
|
||||
return (
|
||||
<div className="privacy-view">
|
||||
<div className="privacy-header">
|
||||
<button type="button" className="privacy-back" onClick={onBack}>←</button>
|
||||
<h1 className="privacy-title">{t('privacy.title')}</h1>
|
||||
</div>
|
||||
<div className="privacy-content">
|
||||
<p className="privacy-updated">{t('privacy.last_updated')}</p>
|
||||
{sections.map(section => (
|
||||
<section key={section} className="privacy-section">
|
||||
<h2 className="privacy-section-title">{t(`privacy.section.${section}.title`)}</h2>
|
||||
<div className="privacy-section-content">
|
||||
{t(`privacy.section.${section}.content`).split('\n').map((line, i) => (
|
||||
<p key={i}>{line}</p>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PrivacyView;
|
||||
@@ -25,7 +25,7 @@ function playBeep() {
|
||||
} catch (_) { }
|
||||
}
|
||||
|
||||
function ScannerView({ onClose, onSelectMedicine, onTsiScanRequest, consents }) {
|
||||
function ScannerView({ onClose, onSelectMedicine }) {
|
||||
const { t } = useTranslation();
|
||||
const videoRef = useRef(null);
|
||||
const streamRef = useRef(null);
|
||||
@@ -219,13 +219,10 @@ function ScannerView({ onClose, onSelectMedicine, onTsiScanRequest, consents })
|
||||
}
|
||||
|
||||
function handleStartScan() {
|
||||
const doScan = () => {
|
||||
if (isNative) { handleNativeScan(); } else { handleWebScan(); }
|
||||
};
|
||||
if (onTsiScanRequest) {
|
||||
onTsiScanRequest(doScan);
|
||||
if (isNative) {
|
||||
handleNativeScan();
|
||||
} else {
|
||||
doScan();
|
||||
handleWebScan();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -343,21 +343,9 @@ router.post('/bulk', requireServiceKey('INGEST_API_KEY'), async (req, res) => {
|
||||
*/
|
||||
router.put('/:id', requireServiceKey('ADMIN_API_KEY'), async (req, res) => {
|
||||
try {
|
||||
// Allowlist: only these fields can be modified via PUT
|
||||
const ALLOWED_FIELDS = [
|
||||
'name', 'brand', 'category', 'subcategory', 'description',
|
||||
'image_url', 'source_url', 'price', 'original_price', 'source',
|
||||
'source_product_id', 'available', 'rating', 'review_count',
|
||||
];
|
||||
const update = {};
|
||||
for (const field of ALLOWED_FIELDS) {
|
||||
if (field in req.body) update[field] = req.body[field];
|
||||
}
|
||||
update.updated_at = new Date();
|
||||
|
||||
const product = await Product.findByIdAndUpdate(
|
||||
req.params.id,
|
||||
update,
|
||||
{ ...req.body, updated_at: new Date() },
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ class Settings(BaseSettings):
|
||||
DEBUG: bool = False
|
||||
NODE_ENV: str = "development"
|
||||
|
||||
DATABASE_URL: str
|
||||
DATABASE_URL: str = "postgresql+asyncpg://pip:pip@localhost:5432/pip"
|
||||
DATABASE_POOL_SIZE: int = 20
|
||||
DATABASE_MAX_OVERFLOW: int = 10
|
||||
DATABASE_POOL_RECYCLE: int = 3600
|
||||
@@ -23,9 +23,9 @@ class Settings(BaseSettings):
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
REDIS_CACHE_TTL: int = 300
|
||||
|
||||
RABBITMQ_URL: str
|
||||
RABBITMQ_URL: str = "amqp://pip:pip@localhost:5672/pip"
|
||||
|
||||
JWT_SECRET_KEY: str
|
||||
JWT_SECRET_KEY: str = "change-me-in-production"
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
|
||||
@@ -158,12 +158,6 @@ services:
|
||||
mailpit:
|
||||
image: axllent/mailpit:latest
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO", "/dev/null", "http://localhost:8025/"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
ports:
|
||||
- "8025:8025" # Web UI
|
||||
- "1025:1025" # SMTP
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,316 +0,0 @@
|
||||
# Design: Cookies Banner & Privacy Page
|
||||
|
||||
## Overview
|
||||
|
||||
Add GDPR/RGPD-compliant cookie consent management, a health data consent flow, and a privacy policy page to FarmaFinder across both web and mobile platforms. The app collects significant personal data including health/medical data (TSI card scanning), requiring special category consent under GDPR Article 9.
|
||||
|
||||
**Company/Data Controller:** Hacecalor S.L.
|
||||
|
||||
**Languages:** Spanish (primary) + Catalan
|
||||
|
||||
**Platforms:** Web frontend (apps/frontend) + Mobile frontend (apps/frontend-mobile)
|
||||
|
||||
---
|
||||
|
||||
## Consent Categories
|
||||
|
||||
| Category | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| **essential** | Yes (always on) | ON | Session cookie (express-session, HTTP-only), CSRF, authentication |
|
||||
| **analytics** | No (opt-in) | OFF | Grafana Faro (browser RUM, Web Vitals, error tracking) |
|
||||
| **preferences** | No (opt-in) | OFF | Theme (dark/light), language (es/ca), saved searches |
|
||||
| **health_data** | No (opt-in) | OFF | TSI card scanning, CIP code extraction, prescription lookup |
|
||||
|
||||
**health_data consent** is shown in two places:
|
||||
1. In the cookie banner (as a 4th toggle, like other categories)
|
||||
2. Re-prompted via a dedicated modal when the user first attempts to scan their TSI card
|
||||
|
||||
If the user already accepted health_data via the banner, the TSI modal is skipped.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
**Approach:** Server-side consent + client sync
|
||||
|
||||
- Consent stored in database (`user_consents` table) for logged-in users
|
||||
- Consent cached in `localStorage` (web) or `expo-secure-store` (mobile) for fast access and anonymous users
|
||||
- On login/register: Anonymous consents migrated to the user account
|
||||
- Backend can enforce consent via middleware
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
### New table: `user_consents`
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_consents (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
session_id VARCHAR(255),
|
||||
category VARCHAR(20) NOT NULL CHECK (category IN ('essential', 'analytics', 'preferences', 'health_data')),
|
||||
granted BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, category),
|
||||
UNIQUE(session_id, category)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_consents_user_id ON user_consents(user_id) WHERE user_id IS NOT NULL;
|
||||
CREATE INDEX idx_user_consents_session_id ON user_consents(session_id) WHERE session_id IS NOT NULL;
|
||||
```
|
||||
|
||||
- `user_id` is NULL for anonymous users (consent tied to session)
|
||||
- `session_id` is NULL for logged-in users (consent tied to account)
|
||||
- `essential` category is always forced to `true` by the backend
|
||||
|
||||
---
|
||||
|
||||
## Backend API
|
||||
|
||||
### Endpoints
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/api/consents` | Optional | Get current user's/session's consents |
|
||||
| `PUT` | `/api/consents` | Optional | Save consent preferences |
|
||||
|
||||
#### GET /api/consents
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"essential": true,
|
||||
"analytics": false,
|
||||
"preferences": true,
|
||||
"health_data": false
|
||||
}
|
||||
```
|
||||
|
||||
#### PUT /api/consents
|
||||
|
||||
Request:
|
||||
```json
|
||||
{
|
||||
"categories": {
|
||||
"analytics": true,
|
||||
"preferences": false,
|
||||
"health_data": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Response: Same as GET (full consent state after save).
|
||||
|
||||
- `essential` is always forced to `true` in the response, regardless of what is sent
|
||||
- For logged-in users: saved with `user_id`
|
||||
- For anonymous users: saved with `session_id` from express-session
|
||||
|
||||
### Consent Migration on Login/Register
|
||||
|
||||
When a user logs in or registers:
|
||||
1. Fetch any consents stored under the current `session_id`
|
||||
2. Merge with any existing `user_id` consents (user_id takes precedence for conflicts)
|
||||
3. Delete session-based consents
|
||||
4. Return merged consents
|
||||
|
||||
### Middleware
|
||||
|
||||
`requireConsent(category)` — checks if the current user/session has granted consent for the given category. Returns 403 if not granted.
|
||||
|
||||
Applied to:
|
||||
- `POST /api/tsi/scan` — requires `health_data`
|
||||
|
||||
---
|
||||
|
||||
## Web Frontend (apps/frontend)
|
||||
|
||||
### New Components
|
||||
|
||||
#### CookieBanner.jsx + CookieBanner.css
|
||||
|
||||
- Fixed banner at bottom of screen, shown on first visit (no consent record in localStorage)
|
||||
- Brief explanation text
|
||||
- 4 category toggles: Esenciales (locked ON), Analítica, Preferencias, Datos de Salud
|
||||
- Buttons: "Aceptar todo" / "Rechazar opcionales" / "Guardar"
|
||||
- "Más información" link → opens PrivacyView
|
||||
- Styled with existing CSS custom properties (Material Design 3 tokens)
|
||||
- Responsive: full-width on mobile, centered card on desktop
|
||||
|
||||
#### HealthConsentModal.jsx + HealthConsentModal.css
|
||||
|
||||
- Modal shown when user first taps TSI scanner (if health_data consent not yet granted)
|
||||
- Explains what data is collected (CIP code, prescriptions) and how it's used
|
||||
- Buttons: "Aceptar y escanear" / "Cancelar"
|
||||
- Sets health_data consent on accept, then proceeds to scan
|
||||
- Skipped if user previously accepted via banner
|
||||
|
||||
#### PrivacyView.jsx + PrivacyView.css
|
||||
|
||||
- Full privacy policy page, new screen in SPA routing
|
||||
- Sections: Data Controller, Data Collected, Purpose, Legal Basis, External Services, Retention, User Rights, Contact, Cookie Policy
|
||||
- Content in Spanish with Catalan toggle (using existing i18n pattern)
|
||||
- Accessible via route `privacy`
|
||||
|
||||
#### Footer / BottomNav update
|
||||
|
||||
- Adds "Política de privacidad" link to existing navigation
|
||||
- Opens PrivacyView
|
||||
|
||||
### Consent Sync Utility
|
||||
|
||||
- `utils/consent.js` — manages localStorage ↔ API sync
|
||||
- On app load: fetch from API if logged in, else read localStorage
|
||||
- On consent change: update localStorage + call PUT /api/consents
|
||||
- Grafana Faro initialization gated on analytics consent
|
||||
|
||||
### Grafana Faro Gating
|
||||
|
||||
In `utils/faro.js`:
|
||||
- Check consent before calling `init()`
|
||||
- If analytics consent not granted, Faro is not initialized
|
||||
- If consent is granted later (via banner), re-initialize Faro
|
||||
|
||||
### TSI Scanner Gating
|
||||
|
||||
In `ScannerView.jsx`:
|
||||
- Before opening TSI scan, check health_data consent
|
||||
- If not granted, show HealthConsentModal
|
||||
- If granted, proceed directly to scan
|
||||
|
||||
---
|
||||
|
||||
## Mobile Frontend (apps/frontend-mobile)
|
||||
|
||||
### New Components
|
||||
|
||||
#### components/CookieBanner.tsx
|
||||
|
||||
- Bottom sheet / slide-up panel with same 4 categories
|
||||
- Same toggle logic as web
|
||||
- Buttons: "Aceptar todo" / "Rechazar opcionales" / "Guardar"
|
||||
- Styled with ThemeProvider tokens (dark/light theme)
|
||||
- Shown on first launch (checked via expo-secure-store key `consents_initialized`)
|
||||
|
||||
#### components/HealthConsentModal.tsx
|
||||
|
||||
- Same purpose as web: shown when user first tries to scan TSI
|
||||
- Explains data collection, two buttons
|
||||
- Sets consent via API, then proceeds to scanner
|
||||
- Skipped if consent already granted
|
||||
|
||||
#### app/privacy.tsx (or app/(tabs)/privacy.tsx)
|
||||
|
||||
- New Expo Router screen for privacy policy
|
||||
- Same content structure as web
|
||||
- Uses ScrollView for long content
|
||||
- Accessible from profile screen
|
||||
|
||||
### Consent Sync Utility
|
||||
|
||||
- `services/consent.ts` — manages expo-secure-store ↔ API sync
|
||||
- On app load: fetch from API if logged in, else read secure-store
|
||||
- On consent change: update secure-store + call PUT /api/consents
|
||||
|
||||
### API Client Additions
|
||||
|
||||
- `services/api.ts` gets: `getConsents()`, `updateConsents(categories)`
|
||||
- Same endpoints as web
|
||||
|
||||
### TSI Scanner Gating
|
||||
|
||||
In scanner flow:
|
||||
- Before opening TSI scan, check health_data consent
|
||||
- If not granted, show HealthConsentModal
|
||||
- If granted, proceed directly
|
||||
|
||||
---
|
||||
|
||||
## i18n Keys
|
||||
|
||||
### Cookie Banner
|
||||
- `cookie_banner.title`
|
||||
- `cookie_banner.description`
|
||||
- `cookie_banner.category.essential`
|
||||
- `cookie_banner.category.essential_desc`
|
||||
- `cookie_banner.category.analytics`
|
||||
- `cookie_banner.category.analytics_desc`
|
||||
- `cookie_banner.category.preferences`
|
||||
- `cookie_banner.category.preferences_desc`
|
||||
- `cookie_banner.category.health_data`
|
||||
- `cookie_banner.category.health_data_desc`
|
||||
- `cookie_banner.accept_all`
|
||||
- `cookie_banner.reject_optional`
|
||||
- `cookie_banner.save`
|
||||
- `cookie_banner.more_info`
|
||||
|
||||
### Health Consent Modal
|
||||
- `health_consent.title`
|
||||
- `health_consent.description`
|
||||
- `health_consent.accept`
|
||||
- `health_consent.cancel`
|
||||
|
||||
### Privacy Page
|
||||
- `privacy.title`
|
||||
- `privacy.section.*` (all 9 sections with headings and content)
|
||||
|
||||
### Navigation
|
||||
- `nav.privacy`
|
||||
|
||||
---
|
||||
|
||||
## Privacy Policy Content (9 Sections)
|
||||
|
||||
1. **Data Controller** — Hacecalor S.L., contact info
|
||||
2. **Data Collected** — User profiles (username, email, name, city, avatar), addresses, geolocation, search history, health data (TSI/CIP codes, prescriptions), push notification tokens, session data
|
||||
3. **Purpose of Processing** — Medicine search, pharmacy locator, availability alerts, health card scanning, app functionality
|
||||
4. **Legal Basis** — Consent (health data, analytics), legitimate interest (security, fraud prevention), contract (account services)
|
||||
5. **External Services** — CIMA API (Spanish Medicines Agency), Grafana Faro (analytics), Nominatim/OpenStreetMap (geocoding), N8N (automation), email provider (password reset)
|
||||
6. **Data Retention** — Session: 24h, Account: until deletion, Consent records: 3 years, Search history: 6 months
|
||||
7. **User Rights** — Access, rectification, erasure, portability, objection, withdraw consent
|
||||
8. **Contact** — How to exercise rights / contact the data controller
|
||||
9. **Cookie Policy** — List of cookies by category, purpose, duration
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Backend + Consent Infrastructure
|
||||
- Create `user_consents` DB migration (PostgreSQL + SQLite)
|
||||
- Add `GET/PUT /api/consents` endpoints
|
||||
- Add `requireConsent()` middleware
|
||||
- Add consent migration on login/register
|
||||
- **Deliverable**: Consent API working
|
||||
|
||||
### Phase 2: Web Frontend
|
||||
- CookieBanner.jsx + CSS
|
||||
- HealthConsentModal.jsx + CSS
|
||||
- PrivacyView.jsx + CSS
|
||||
- Footer/nav link to privacy page
|
||||
- Consent sync utility (localStorage ↔ API)
|
||||
- Grafana Faro gated on analytics consent
|
||||
- TSI scanner gated on health_data consent
|
||||
- i18n keys (es + ca)
|
||||
- **Deliverable**: Full web consent flow working
|
||||
|
||||
### Phase 3: Mobile Frontend
|
||||
- CookieBanner.tsx
|
||||
- HealthConsentModal.tsx
|
||||
- privacy.tsx screen
|
||||
- Consent sync utility (expo-secure-store ↔ API)
|
||||
- API client methods (getConsents, updateConsents)
|
||||
- TSI scanner gated on health_data consent
|
||||
- i18n keys (es + ca)
|
||||
- **Deliverable**: Full mobile consent flow working
|
||||
|
||||
### Final: Privacy Policy Content
|
||||
- Write full Spanish text for all 9 sections
|
||||
- Write full Catalan translation
|
||||
- Add to both web and mobile i18n files
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
None — all decisions finalized during brainstorming.
|
||||
Generated
-173
@@ -53,7 +53,6 @@
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"express-session": "^1.17.3",
|
||||
"helmet": "^8.1.0",
|
||||
"multer": "^2.2.0",
|
||||
"nodemailer": "^6.10.1",
|
||||
"pg": "^8.13.0",
|
||||
@@ -140,7 +139,6 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@grafana/faro-react-native": "^1.3.0",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"axios": "^1.18.1",
|
||||
@@ -405,158 +403,6 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"apps/frontend-mobile/node_modules/@grafana/faro-core": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@grafana/faro-core/-/faro-core-2.8.2.tgz",
|
||||
"integrity": "sha512-63C6+N/P9/ySUMaGut8yVb1DkPuHS5I/lfRm97+aDXFj3+8pxRT/QzyXNe0r6KMU5O95wC9FLVGxG9ZvkwEhJQ==",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/otlp-transformer": "^0.219.0"
|
||||
}
|
||||
},
|
||||
"apps/frontend-mobile/node_modules/@grafana/faro-react-native": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@grafana/faro-react-native/-/faro-react-native-1.3.0.tgz",
|
||||
"integrity": "sha512-KeohD3S2xmiukL5YW3mOGDqvFiwpD9IzJ2EtZ50ktEsnA+6EMt2AtJ8RzJfAamGTYqemBpDiw51rqB4ID+C2xg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@grafana/faro-core": "^2.7.0",
|
||||
"@react-native-async-storage/async-storage": "^1.21.0",
|
||||
"react-native-device-info": "^11.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@react-navigation/native": ">=6.0.0",
|
||||
"react": ">=18.0.0",
|
||||
"react-native": ">=0.70.0",
|
||||
"react-native-mmkv": ">=2.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@react-navigation/native": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native-mmkv": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"apps/frontend-mobile/node_modules/@grafana/faro-react-native/node_modules/@react-native-async-storage/async-storage": {
|
||||
"version": "1.24.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-1.24.0.tgz",
|
||||
"integrity": "sha512-W4/vbwUOYOjco0x3toB8QCr7EjIP6nE9G7o8PMguvvjYT5Awg09lyV4enACRx4s++PPulBiBSjL0KTFx2u0Z/g==",
|
||||
"dependencies": {
|
||||
"merge-options": "^3.0.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-native": "^0.0.0-0 || >=0.60 <1.0"
|
||||
}
|
||||
},
|
||||
"apps/frontend-mobile/node_modules/@opentelemetry/api-logs": {
|
||||
"version": "0.219.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.219.0.tgz",
|
||||
"integrity": "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"apps/frontend-mobile/node_modules/@opentelemetry/core": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz",
|
||||
"integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==",
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"apps/frontend-mobile/node_modules/@opentelemetry/otlp-transformer": {
|
||||
"version": "0.219.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.219.0.tgz",
|
||||
"integrity": "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ==",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api-logs": "0.219.0",
|
||||
"@opentelemetry/core": "2.8.0",
|
||||
"@opentelemetry/resources": "2.8.0",
|
||||
"@opentelemetry/sdk-logs": "0.219.0",
|
||||
"@opentelemetry/sdk-metrics": "2.8.0",
|
||||
"@opentelemetry/sdk-trace-base": "2.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"apps/frontend-mobile/node_modules/@opentelemetry/resources": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz",
|
||||
"integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.8.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"apps/frontend-mobile/node_modules/@opentelemetry/sdk-logs": {
|
||||
"version": "0.219.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.219.0.tgz",
|
||||
"integrity": "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA==",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api-logs": "0.219.0",
|
||||
"@opentelemetry/core": "2.8.0",
|
||||
"@opentelemetry/resources": "2.8.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.4.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"apps/frontend-mobile/node_modules/@opentelemetry/sdk-metrics": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.8.0.tgz",
|
||||
"integrity": "sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg==",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.8.0",
|
||||
"@opentelemetry/resources": "2.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.9.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"apps/frontend-mobile/node_modules/@opentelemetry/sdk-trace-base": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz",
|
||||
"integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.8.0",
|
||||
"@opentelemetry/resources": "2.8.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"apps/frontend-mobile/node_modules/@react-native-async-storage/async-storage": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
|
||||
@@ -1279,14 +1125,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"apps/frontend-mobile/node_modules/react-native-device-info": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-device-info/-/react-native-device-info-11.1.0.tgz",
|
||||
"integrity": "sha512-hzXJSObJdezEz0hF7MAJ3tGeoesuQWenXXt9mrQR9Mjb8kXpZ09rqSsZ/quNpJdZpQ3rYiFa3/0GFG5KNn9PBg==",
|
||||
"peerDependencies": {
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"apps/frontend-mobile/node_modules/react-native-drawer-layout": {
|
||||
"version": "4.2.7",
|
||||
"resolved": "https://registry.npmjs.org/react-native-drawer-layout/-/react-native-drawer-layout-4.2.7.tgz",
|
||||
@@ -17757,17 +17595,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/helmet": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
|
||||
"integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/EvanHahn"
|
||||
}
|
||||
},
|
||||
"node_modules/hermes-compiler": {
|
||||
"version": "250829098.0.14",
|
||||
"resolved": "https://registry.npmjs.org/hermes-compiler/-/hermes-compiler-250829098.0.14.tgz",
|
||||
|
||||
Reference in New Issue
Block a user