Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b00c9aa4f | |||
| 86962dfa3a | |||
| c8b830017b | |||
| e641a35b82 | |||
| ff36748634 | |||
| 795d935413 | |||
| 2f24dd4ff8 |
@@ -6,8 +6,3 @@ EXPO_PUBLIC_API_URL=http://localhost:3001/api
|
|||||||
|
|
||||||
# For production builds, update this to:
|
# For production builds, update this to:
|
||||||
# EXPO_PUBLIC_API_URL=https://api.yourdomain.com/api
|
# 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",
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
"backgroundColor": "#007AFF"
|
"backgroundColor": "#007AFF"
|
||||||
},
|
},
|
||||||
"package": "com.farmafinder.app",
|
"package": "com.farmafinder.app"
|
||||||
"googleServicesFile": "./google-services.json",
|
|
||||||
"config": {
|
|
||||||
"googleMaps": {
|
|
||||||
"apiKey": ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { View, StyleSheet, Text } from 'react-native';
|
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 { useRouter } from 'expo-router';
|
||||||
import { getPharmacies } from '../../services/pharmacies';
|
import { getPharmacies } from '../../services/pharmacies';
|
||||||
import { LoadingSpinner } from '../../components/LoadingSpinner';
|
import { LoadingSpinner } from '../../components/LoadingSpinner';
|
||||||
@@ -54,11 +54,18 @@ export default function MapScreen() {
|
|||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<MapView
|
<MapView
|
||||||
style={styles.map}
|
style={styles.map}
|
||||||
|
mapType="none"
|
||||||
region={region}
|
region={region}
|
||||||
onRegionChangeComplete={setRegion}
|
onRegionChangeComplete={setRegion}
|
||||||
showsUserLocation={true}
|
showsUserLocation={true}
|
||||||
showsMyLocationButton={true}
|
showsMyLocationButton={true}
|
||||||
>
|
>
|
||||||
|
<UrlTile
|
||||||
|
urlTemplate="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
maximumZ={19}
|
||||||
|
flipY={false}
|
||||||
|
tileSize={256}
|
||||||
|
/>
|
||||||
{pharmacies.map((pharmacy) => (
|
{pharmacies.map((pharmacy) => (
|
||||||
<Marker
|
<Marker
|
||||||
key={pharmacy.id}
|
key={pharmacy.id}
|
||||||
|
|||||||
@@ -174,9 +174,11 @@ export default function SearchScreen() {
|
|||||||
data={results}
|
data={results}
|
||||||
keyExtractor={(item) => item.nregistro}
|
keyExtractor={(item) => item.nregistro}
|
||||||
renderItem={({ item }) => <MedicineCard medicine={item} />}
|
renderItem={({ item }) => <MedicineCard medicine={item} />}
|
||||||
ListHeaderComponent={
|
ListEmptyComponent={
|
||||||
<>
|
products.length === 0 ? null : undefined
|
||||||
{products.length > 0 && (
|
}
|
||||||
|
ListFooterComponent={
|
||||||
|
products.length > 0 ? (
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>{t('search.parapharmacy')}</Text>
|
<Text style={[styles.sectionTitle, { color: colors.text }]}>{t('search.parapharmacy')}</Text>
|
||||||
{products.map((product) => (
|
{products.map((product) => (
|
||||||
@@ -198,8 +200,7 @@ export default function SearchScreen() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
)}
|
) : null
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
contentContainerStyle={[styles.list, isTablet && styles.listTablet]}
|
contentContainerStyle={[styles.list, isTablet && styles.listTablet]}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
|
|||||||
@@ -9,10 +9,6 @@ import { useAuthStore } from '../store/authStore';
|
|||||||
import { registerForPushNotifications, addNotificationListener, addNotificationResponseListener } from '../services/notifications';
|
import { registerForPushNotifications, addNotificationListener, addNotificationResponseListener } from '../services/notifications';
|
||||||
import { ThemeProvider, useThemeContext } from '../components/ThemeProvider';
|
import { ThemeProvider, useThemeContext } from '../components/ThemeProvider';
|
||||||
import { LanguageProvider, useTranslation } from '../src/i18n';
|
import { LanguageProvider, useTranslation } from '../src/i18n';
|
||||||
import { initFaro } from '../services/faro';
|
|
||||||
|
|
||||||
// Boot Faro RUM once, as early as possible.
|
|
||||||
initFaro();
|
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { View, Text, ScrollView, StyleSheet, TouchableOpacity, Linking } from 'r
|
|||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import * as Location from 'expo-location';
|
import * as Location from 'expo-location';
|
||||||
|
import MapView, { Marker, UrlTile } from 'react-native-maps';
|
||||||
import { getMedicine, getMedicinePharmacies } from '../../services/medicines';
|
import { getMedicine, getMedicinePharmacies } from '../../services/medicines';
|
||||||
import { subscribeToMedicine, unsubscribeFromMedicine } from '../../services/notifications';
|
import { subscribeToMedicine, unsubscribeFromMedicine } from '../../services/notifications';
|
||||||
import { useAuth } from '../../hooks/useAuth';
|
import { useAuth } from '../../hooks/useAuth';
|
||||||
@@ -205,11 +206,38 @@ export default function MedicineDetailScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{locatedPharmacies.length > 0 && (
|
{locatedPharmacies.length > 0 && (
|
||||||
<View style={[styles.mapContainer, { backgroundColor: colors.surfaceVariant }]}>
|
<View style={styles.mapContainer}>
|
||||||
<Ionicons name="map-outline" size={48} color={colors.textSecondary} />
|
<MapView
|
||||||
<Text style={[styles.mapPlaceholder, { color: colors.textSecondary }]}>
|
style={styles.map}
|
||||||
{t('medicine.mapSoon')}
|
mapType="none"
|
||||||
</Text>
|
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>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -362,13 +390,9 @@ const styles = StyleSheet.create({
|
|||||||
height: 200,
|
height: 200,
|
||||||
borderRadius: borderRadius.lg,
|
borderRadius: borderRadius.lg,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: spacing.sm,
|
|
||||||
},
|
},
|
||||||
mapPlaceholder: {
|
map: {
|
||||||
fontSize: 14,
|
flex: 1,
|
||||||
fontWeight: '500',
|
|
||||||
},
|
},
|
||||||
pharmaciesSection: {
|
pharmaciesSection: {
|
||||||
marginTop: spacing.sm,
|
marginTop: spacing.sm,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
|
|||||||
import { View, Text, ScrollView, StyleSheet, TouchableOpacity, Linking } from 'react-native';
|
import { View, Text, ScrollView, StyleSheet, TouchableOpacity, Linking } from 'react-native';
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
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 { getPharmacy, getPharmacyMedicines } from '../../services/pharmacies';
|
||||||
import { subscribeToMedicine, unsubscribeFromMedicine } from '../../services/notifications';
|
import { subscribeToMedicine, unsubscribeFromMedicine } from '../../services/notifications';
|
||||||
import { useAuth } from '../../hooks/useAuth';
|
import { useAuth } from '../../hooks/useAuth';
|
||||||
@@ -125,6 +125,7 @@ export default function PharmacyDetailScreen() {
|
|||||||
<View style={styles.mapContainer}>
|
<View style={styles.mapContainer}>
|
||||||
<MapView
|
<MapView
|
||||||
style={styles.map}
|
style={styles.map}
|
||||||
|
mapType="none"
|
||||||
initialRegion={{
|
initialRegion={{
|
||||||
latitude: pharmacy.latitude,
|
latitude: pharmacy.latitude,
|
||||||
longitude: pharmacy.longitude,
|
longitude: pharmacy.longitude,
|
||||||
@@ -133,6 +134,12 @@ export default function PharmacyDetailScreen() {
|
|||||||
}}
|
}}
|
||||||
scrollEnabled={false}
|
scrollEnabled={false}
|
||||||
>
|
>
|
||||||
|
<UrlTile
|
||||||
|
urlTemplate="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
maximumZ={19}
|
||||||
|
flipY={false}
|
||||||
|
tileSize={256}
|
||||||
|
/>
|
||||||
<Marker
|
<Marker
|
||||||
coordinate={{
|
coordinate={{
|
||||||
latitude: pharmacy.latitude,
|
latitude: pharmacy.latitude,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
"main": "expo-router/entry",
|
"main": "expo-router/entry",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@grafana/faro-react-native": "^1.3.0",
|
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"@tanstack/react-query": "^5.101.2",
|
"@tanstack/react-query": "^5.101.2",
|
||||||
"axios": "^1.18.1",
|
"axios": "^1.18.1",
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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
-161
@@ -139,7 +139,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@grafana/faro-react-native": "^1.3.0",
|
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"@tanstack/react-query": "^5.101.2",
|
"@tanstack/react-query": "^5.101.2",
|
||||||
"axios": "^1.18.1",
|
"axios": "^1.18.1",
|
||||||
@@ -404,158 +403,6 @@
|
|||||||
"react-native": "*"
|
"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": {
|
"apps/frontend-mobile/node_modules/@react-native-async-storage/async-storage": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
|
||||||
@@ -1278,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": {
|
"apps/frontend-mobile/node_modules/react-native-drawer-layout": {
|
||||||
"version": "4.2.7",
|
"version": "4.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-drawer-layout/-/react-native-drawer-layout-4.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-drawer-layout/-/react-native-drawer-layout-4.2.7.tgz",
|
||||||
|
|||||||
Reference in New Issue
Block a user