From 0c43d32cf1cae604ebc9488052f435d73c0825e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Mon, 13 Jul 2026 12:21:25 +0200 Subject: [PATCH] feat: add Expo Push notifications for mobile and configure EXPO_ACCESS_TOKEN - Backend: add expo_push_tokens table, expo-register/unregister endpoints, hybrid VAPID+Expo push sender - Mobile: register Expo push token on app launch, bell toggle on medicine/pharmacy detail screens - .env.example: document EXPO_ACCESS_TOKEN configuration - Dark mode hover fixes for search suggestions and recent buttons --- apps/backend/.env.example | 4 + apps/backend/server.js | 272 +++++++++++++++--- apps/frontend-mobile/app/medicine/[id].tsx | 52 +++- apps/frontend-mobile/app/pharmacy/[id].tsx | 93 ++++-- .../frontend-mobile/services/notifications.ts | 54 ++++ apps/frontend/src/views/SearchView.css | 12 + 6 files changed, 436 insertions(+), 51 deletions(-) diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 8cbdf78..701e422 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -15,3 +15,7 @@ PG_PASSWORD=change-me VAPID_PUBLIC_KEY= VAPID_PRIVATE_KEY= VAPID_SUBJECT=mailto:admin@example.com + +# Expo Push Notifications (mobile). Get token from: +# https://expo.dev/accounts/[username]/settings/access-tokens +EXPO_ACCESS_TOKEN= diff --git a/apps/backend/server.js b/apps/backend/server.js index c3c06cc..19d4270 100644 --- a/apps/backend/server.js +++ b/apps/backend/server.js @@ -96,11 +96,12 @@ const geocodeLimiter = rateLimit({ windowMs: 60_000, max: 10, standardHeaders: t const VAPID_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY || ''; const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY || ''; const VAPID_SUBJECT = process.env.VAPID_SUBJECT || 'mailto:admin@example.com'; +const EXPO_ACCESS_TOKEN = process.env.EXPO_ACCESS_TOKEN || ''; const PUSH_ENABLED = Boolean(VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY); if (PUSH_ENABLED) { webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY); } else if (process.env.NODE_ENV !== 'test') { - console.warn('[push] VAPID keys not configured — push notifications disabled'); + console.warn('[push] VAPID keys not configured — web push disabled'); } // Database setup @@ -313,6 +314,18 @@ async function initDatabase() { `); await pgPool.query(`CREATE INDEX IF NOT EXISTS idx_push_subs_pharm ON push_subscriptions_pharmacy(medicine_nregistro, pharmacy_id)`); await pgPool.query(`CREATE INDEX IF NOT EXISTS idx_push_subs_pharm_user ON push_subscriptions_pharmacy(user_id)`); + + // Expo Push tokens (mobile) + await pgPool.query(` + CREATE TABLE IF NOT EXISTS expo_push_tokens ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expo_token TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(user_id, expo_token) + ) + `); + await pgPool.query(`CREATE INDEX IF NOT EXISTS idx_expo_push_tokens_user ON expo_push_tokens(user_id)`); } else { await dbRun(` CREATE TABLE IF NOT EXISTS push_subscriptions ( @@ -342,6 +355,18 @@ async function initDatabase() { ) `); await dbRun(`CREATE INDEX IF NOT EXISTS idx_push_subs_pharm ON push_subscriptions_pharmacy(medicine_nregistro, pharmacy_id)`); + + // Expo Push tokens (mobile) + await dbRun(` + CREATE TABLE IF NOT EXISTS expo_push_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + expo_token TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, expo_token) + ) + `); + await dbRun(`CREATE INDEX IF NOT EXISTS idx_expo_push_tokens_user ON expo_push_tokens(user_id)`); } if (!pgPool) { @@ -1919,6 +1944,101 @@ app.delete('/api/notifications/unsubscribe', requireAuth, async (req, res) => { } }); +// Expo Push tokens (mobile app) + +app.post('/api/notifications/expo-register', requireAuth, async (req, res) => { + try { + const { token, medicine_nregistro, medicine_name, pharmacy_id } = req.body || {}; + const userId = req.session.userId; + if (!token) { + return res.status(400).json({ error: 'expo token is required' }); + } + + // Upsert the token for this user + const existing = await userDbGet( + 'SELECT id FROM expo_push_tokens WHERE user_id = ? AND expo_token = ?', + [userId, token] + ); + if (!existing) { + await userDbRun( + 'INSERT INTO expo_push_tokens (user_id, expo_token) VALUES (?, ?)', + [userId, token] + ); + } + + // Also store the medicine subscription (reuse existing tables with expo prefix for endpoint) + if (medicine_nregistro) { + const expoEndpoint = `expo://${token}`; + if (pharmacy_id) { + const existingSub = await userDbGet( + 'SELECT id FROM push_subscriptions_pharmacy WHERE user_id = ? AND medicine_nregistro = ? AND pharmacy_id = ? AND endpoint = ?', + [userId, medicine_nregistro, pharmacy_id, expoEndpoint] + ); + if (!existingSub) { + await userDbRun( + 'INSERT INTO push_subscriptions_pharmacy (user_id, medicine_nregistro, medicine_name, pharmacy_id, endpoint, p256dh, auth) VALUES (?, ?, ?, ?, ?, ?, ?)', + [userId, medicine_nregistro, medicine_name || null, pharmacy_id, expoEndpoint, '', ''] + ); + } + } else { + const existingSub = await userDbGet( + 'SELECT id FROM push_subscriptions WHERE user_id = ? AND medicine_nregistro = ? AND endpoint = ?', + [userId, medicine_nregistro, expoEndpoint] + ); + if (!existingSub) { + await userDbRun( + 'INSERT INTO push_subscriptions (user_id, medicine_nregistro, medicine_name, endpoint, p256dh, auth) VALUES (?, ?, ?, ?, ?, ?)', + [userId, medicine_nregistro, medicine_name || null, expoEndpoint, '', ''] + ); + } + } + } + + res.status(201).json({ ok: true }); + } catch (error) { + console.error('Error registering Expo push token:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.delete('/api/notifications/expo-unregister', requireAuth, async (req, res) => { + try { + const { medicine_nregistro, pharmacy_id } = req.body || {}; + const userId = req.session.userId; + + // Get all expo tokens for this user to find the endpoints + const tokens = await userDbAll( + 'SELECT expo_token FROM expo_push_tokens WHERE user_id = ?', + [userId] + ); + + for (const t of tokens || []) { + const expoEndpoint = `expo://${t.expo_token}`; + if (pharmacy_id && medicine_nregistro) { + await userDbRun( + 'DELETE FROM push_subscriptions_pharmacy WHERE user_id = ? AND medicine_nregistro = ? AND pharmacy_id = ? AND endpoint = ?', + [userId, medicine_nregistro, pharmacy_id, expoEndpoint] + ); + } else if (medicine_nregistro) { + await userDbRun( + 'DELETE FROM push_subscriptions WHERE user_id = ? AND medicine_nregistro = ? AND endpoint = ?', + [userId, medicine_nregistro, expoEndpoint] + ); + } + } + + // If no medicine specified, remove the token entirely + if (!medicine_nregistro) { + await userDbRun('DELETE FROM expo_push_tokens WHERE user_id = ?', [userId]); + } + + res.status(204).end(); + } catch (error) { + console.error('Error unregistering Expo push token:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + // List the current user's saved notifications across all their devices app.get('/api/notifications/mine', requireAuth, async (req, res) => { try { @@ -1979,7 +2099,7 @@ app.delete('/api/notifications/mine', requireAuth, async (req, res) => { }); async function sendPushForMedicine({ medicine_nregistro, medicine_name, pharmacy }) { - if (!PUSH_ENABLED) return { sent: 0, failed: 0, pruned: 0 }; + if (!PUSH_ENABLED && !EXPO_ACCESS_TOKEN) return { sent: 0, failed: 0, pruned: 0 }; const globalSubs = await userDbAll( 'SELECT id, endpoint, p256dh, auth FROM push_subscriptions WHERE medicine_nregistro = ?', @@ -1990,7 +2110,7 @@ async function sendPushForMedicine({ medicine_nregistro, medicine_name, pharmacy [medicine_nregistro, pharmacy.id] ) : []; - // Deduplicate by endpoint; pharmacy-specific overrides global so one notification per browser + // Deduplicate by endpoint; pharmacy-specific overrides global so one notification per device const endpointMap = new Map(); for (const sub of (globalSubs || [])) endpointMap.set(sub.endpoint, { ...sub, _pharmSub: false }); for (const sub of (pharmacySubs || [])) endpointMap.set(sub.endpoint, { ...sub, _pharmSub: true }); @@ -1998,43 +2118,129 @@ async function sendPushForMedicine({ medicine_nregistro, medicine_name, pharmacy if (subs.length === 0) return { sent: 0, failed: 0, pruned: 0 }; - const payload = JSON.stringify({ - title: 'Medicine available', - body: `${medicine_name || medicine_nregistro} is now available at ${pharmacy?.name || 'a pharmacy near you'}`, - url: `/?nregistro=${encodeURIComponent(medicine_nregistro)}`, - medicine_nregistro, - medicine_name: medicine_name || null, - pharmacy_id: pharmacy?.id ?? null, - pharmacy_name: pharmacy?.name ?? null, - }); + // Split into web push (VAPID) and Expo push (mobile) + const webSubs = []; + const expoTokens = []; + for (const sub of subs) { + if (sub.endpoint?.startsWith('expo://')) { + const token = sub.endpoint.replace('expo://', ''); + if (!expoTokens.includes(token)) expoTokens.push(token); + } else { + webSubs.push(sub); + } + } + + const title = 'Medicamento disponible'; + const body = `${medicine_name || medicine_nregistro} está disponible en ${pharmacy?.name || 'una farmacia cercana'}`; + const url = `/?nregistro=${encodeURIComponent(medicine_nregistro)}`; let sent = 0; let failed = 0; let pruned = 0; - await Promise.all(subs.map(async (sub) => { - try { - await webpush.sendNotification( - { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }, - payload - ); - sent++; - } catch (err) { - failed++; - const status = err?.statusCode; - if (status === 404 || status === 410) { - try { - const deleteQuery = sub._pharmSub - ? 'DELETE FROM push_subscriptions_pharmacy WHERE id = ?' - : 'DELETE FROM push_subscriptions WHERE id = ?'; - await userDbRun(deleteQuery, [sub.id]); - pruned++; - } catch {} - } else { - console.error('[push] send failed:', status, err?.body || err?.message); + // Web Push via VAPID + if (PUSH_ENABLED && webSubs.length > 0) { + const payload = JSON.stringify({ + title, + body, + url, + medicine_nregistro, + medicine_name: medicine_name || null, + pharmacy_id: pharmacy?.id ?? null, + pharmacy_name: pharmacy?.name ?? null, + }); + + await Promise.all(webSubs.map(async (sub) => { + try { + await webpush.sendNotification( + { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }, + payload + ); + sent++; + } catch (err) { + failed++; + const status = err?.statusCode; + if (status === 404 || status === 410) { + try { + const deleteQuery = sub._pharmSub + ? 'DELETE FROM push_subscriptions_pharmacy WHERE id = ?' + : 'DELETE FROM push_subscriptions WHERE id = ?'; + await userDbRun(deleteQuery, [sub.id]); + pruned++; + } catch {} + } else { + console.error('[push] web send failed:', status, err?.body || err?.message); + } + } + })); + } + + // Expo Push (mobile) + if (EXPO_ACCESS_TOKEN && expoTokens.length > 0) { + const chunks = []; + for (let i = 0; i < expoTokens.length; i += 100) { + chunks.push(expoTokens.slice(i, i + 100)); + } + + for (const chunk of chunks) { + const messages = chunk.map((token) => ({ + to: token, + sound: 'default', + title, + body, + data: { + url, + medicine_nregistro, + medicine_name: medicine_name || null, + pharmacy_id: pharmacy?.id ?? null, + pharmacy_name: pharmacy?.name ?? null, + }, + })); + + try { + const response = await fetch('https://exp.host/--/api/v2/push/send', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${EXPO_ACCESS_TOKEN}`, + }, + body: JSON.stringify(messages), + }); + + if (response.ok) { + const result = await response.json(); + // Handle individual ticket errors + if (result.data) { + for (const ticket of result.data) { + if (ticket.status === 'ok') { + sent++; + } else { + failed++; + // Prune invalid tokens + if (ticket.message?.includes('InvalidCredentials') || ticket.message?.includes('DeviceNotRegistered')) { + const expoEndpoint = `expo://${ticket.id || ''}`; + try { + await userDbRun('DELETE FROM push_subscriptions WHERE endpoint = ?', [expoEndpoint]); + await userDbRun('DELETE FROM push_subscriptions_pharmacy WHERE endpoint = ?', [expoEndpoint]); + await userDbRun('DELETE FROM expo_push_tokens WHERE expo_token = ?', [ticket.id || '']); + pruned++; + } catch {} + } + } + } + } else { + sent += chunk.length; + } + } else { + failed += chunk.length; + console.error('[push] expo send failed:', response.status); + } + } catch (err) { + failed += chunk.length; + console.error('[push] expo send error:', err.message); } } - })); + } return { sent, failed, pruned }; } diff --git a/apps/frontend-mobile/app/medicine/[id].tsx b/apps/frontend-mobile/app/medicine/[id].tsx index 07da0c7..e384c87 100644 --- a/apps/frontend-mobile/app/medicine/[id].tsx +++ b/apps/frontend-mobile/app/medicine/[id].tsx @@ -4,6 +4,8 @@ import { useLocalSearchParams, useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import * as Location from 'expo-location'; import { getMedicine, getMedicinePharmacies } from '../../services/medicines'; +import { subscribeToMedicine, unsubscribeFromMedicine } from '../../services/notifications'; +import { useAuth } from '../../hooks/useAuth'; import { StockBadge } from '../../components/StockBadge'; import { LoadingSpinner } from '../../components/LoadingSpinner'; import { useThemeContext } from '../../components/ThemeProvider'; @@ -40,6 +42,7 @@ export default function MedicineDetailScreen() { const { id } = useLocalSearchParams<{ id: string }>(); const router = useRouter(); const { colors } = useThemeContext(); + const { isAuthenticated } = useAuth(); const [medicine, setMedicine] = useState(null); const [pharmacies, setPharmacies] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -47,6 +50,8 @@ export default function MedicineDetailScreen() { const [userPosition, setUserPosition] = useState<{ lat: number; lon: number } | null>(null); const [locating, setLocating] = useState(false); const [locationError, setLocationError] = useState(null); + const [isSubscribed, setIsSubscribed] = useState(false); + const [togglingSub, setTogglingSub] = useState(false); useEffect(() => { if (!id) return; @@ -96,6 +101,24 @@ export default function MedicineDetailScreen() { } }; + const handleToggleSubscription = async () => { + if (!isAuthenticated || !id) return; + setTogglingSub(true); + try { + if (isSubscribed) { + await unsubscribeFromMedicine(id as string); + setIsSubscribed(false); + } else { + await subscribeToMedicine(id as string, medicine?.name || null); + setIsSubscribed(true); + } + } catch { + // silently fail + } finally { + setTogglingSub(false); + } + }; + const sortedPharmacies = useMemo(() => { if (!sortByDistance || !userPosition) return pharmacies; @@ -148,7 +171,22 @@ export default function MedicineDetailScreen() { {medicine.name} - {medicine.stock != null && } + + {medicine.stock != null && } + {isAuthenticated && ( + + + + )} + @@ -277,12 +315,24 @@ const styles = StyleSheet.create({ alignItems: 'flex-start', padding: spacing.lg, }, + headerRight: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + }, name: { flex: 1, fontSize: 22, fontWeight: 'bold', marginRight: spacing.sm, }, + bellButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + }, infoSection: { marginTop: spacing.sm, padding: spacing.lg, diff --git a/apps/frontend-mobile/app/pharmacy/[id].tsx b/apps/frontend-mobile/app/pharmacy/[id].tsx index 6aafcca..3a8c2e5 100644 --- a/apps/frontend-mobile/app/pharmacy/[id].tsx +++ b/apps/frontend-mobile/app/pharmacy/[id].tsx @@ -4,6 +4,8 @@ import { useLocalSearchParams, useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import MapView, { Marker } from 'react-native-maps'; import { getPharmacy, getPharmacyMedicines } from '../../services/pharmacies'; +import { subscribeToMedicine, unsubscribeFromMedicine } from '../../services/notifications'; +import { useAuth } from '../../hooks/useAuth'; import { LoadingSpinner } from '../../components/LoadingSpinner'; import { useThemeContext } from '../../components/ThemeProvider'; import { spacing, borderRadius } from '../../constants/theme'; @@ -13,9 +15,11 @@ export default function PharmacyDetailScreen() { const { id } = useLocalSearchParams<{ id: string }>(); const router = useRouter(); const { colors } = useThemeContext(); + const { isAuthenticated } = useAuth(); const [pharmacy, setPharmacy] = useState(null); const [medicines, setMedicines] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [subscribedMeds, setSubscribedMeds] = useState>(new Set()); useEffect(() => { if (!id) return; @@ -51,6 +55,27 @@ export default function PharmacyDetailScreen() { } }; + const handleToggleMedSubscription = async (nregistro: string, medicineName: string) => { + if (!isAuthenticated) return; + const key = nregistro; + const isSub = subscribedMeds.has(key); + try { + if (isSub) { + await unsubscribeFromMedicine(nregistro, Number(id)); + setSubscribedMeds(prev => { + const next = new Set(prev); + next.delete(key); + return next; + }); + } else { + await subscribeToMedicine(nregistro, medicineName, Number(id)); + setSubscribedMeds(prev => new Set(prev).add(key)); + } + } catch { + // silently fail + } + }; + if (isLoading) { return ; } @@ -124,22 +149,41 @@ export default function PharmacyDetailScreen() { {medicines.length === 0 ? ( No hay medicamentos disponibles ) : ( - medicines.map((med) => ( - router.push(`/medicine/${med.medicine_nregistro}`)} - > - - {med.medicine_name} - Reg: {med.medicine_nregistro} + medicines.map((med) => { + const isSub = subscribedMeds.has(med.medicine_nregistro); + return ( + + router.push(`/medicine/${med.medicine_nregistro}`)} + > + + {med.medicine_name} + Reg: {med.medicine_nregistro} + + + {med.price.toFixed(2)} € + Stock: {med.stock} + + + {isAuthenticated && ( + handleToggleMedSubscription(med.medicine_nregistro, med.medicine_name)} + > + + + {isSub ? 'Notificaciones activas' : 'Notificarme cuando haya stock'} + + + )} - - {med.price.toFixed(2)} € - Stock: {med.stock} - - - )) + ); + }) )} @@ -207,11 +251,14 @@ const styles = StyleSheet.create({ padding: spacing.xl, }, medicineCard: { + borderRadius: borderRadius.md, + marginBottom: spacing.sm, + overflow: 'hidden', + }, + medicineCardContent: { flexDirection: 'row', justifyContent: 'space-between', - borderRadius: borderRadius.md, padding: spacing.md, - marginBottom: spacing.sm, }, medicineInfo: { flex: 1, @@ -235,6 +282,18 @@ const styles = StyleSheet.create({ fontSize: 12, marginTop: spacing.xs, }, + bellButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.xs, + paddingVertical: spacing.sm, + borderTopWidth: 1, + }, + bellText: { + fontSize: 13, + fontWeight: '600', + }, errorContainer: { flex: 1, justifyContent: 'center', diff --git a/apps/frontend-mobile/services/notifications.ts b/apps/frontend-mobile/services/notifications.ts index a008784..8acd4aa 100644 --- a/apps/frontend-mobile/services/notifications.ts +++ b/apps/frontend-mobile/services/notifications.ts @@ -2,6 +2,7 @@ import * as Notifications from 'expo-notifications'; import * as Device from 'expo-device'; import * as Constants from 'expo-constants'; import { Platform } from 'react-native'; +import api from './api'; Notifications.setNotificationHandler({ handleNotification: async () => ({ @@ -12,6 +13,8 @@ Notifications.setNotificationHandler({ }), }); +let _cachedToken: string | null = null; + export async function registerForPushNotifications() { if (!Device.isDevice) { console.log('Push notifications require a physical device'); @@ -48,6 +51,11 @@ export async function registerForPushNotifications() { projectId, }); + _cachedToken = token.data; + + // Register token with backend (fire and forget) + registerTokenWithBackend(token.data).catch(() => {}); + return token.data; } catch (e) { console.log('Error getting push token:', e); @@ -55,6 +63,52 @@ export async function registerForPushNotifications() { } } +async function registerTokenWithBackend(token: string) { + try { + await api.post('/notifications/expo-register', { token }); + } catch (e) { + console.log('Failed to register push token with backend:', e); + } +} + +export async function subscribeToMedicine( + medicineNregistro: string, + medicineName: string | null, + pharmacyId?: number | null +) { + const token = _cachedToken; + if (!token) { + console.log('No push token available for subscription'); + return; + } + try { + await api.post('/notifications/expo-register', { + token, + medicine_nregistro: medicineNregistro, + medicine_name: medicineName, + pharmacy_id: pharmacyId || null, + }); + } catch (e) { + console.log('Failed to subscribe to medicine notifications:', e); + } +} + +export async function unsubscribeFromMedicine( + medicineNregistro: string, + pharmacyId?: number | null +) { + try { + await api.delete('/notifications/expo-unregister', { + data: { + medicine_nregistro: medicineNregistro, + pharmacy_id: pharmacyId || null, + }, + }); + } catch (e) { + console.log('Failed to unsubscribe from medicine notifications:', e); + } +} + export async function scheduleMedicineAvailabilityNotification( medicineName: string, pharmacyName: string diff --git a/apps/frontend/src/views/SearchView.css b/apps/frontend/src/views/SearchView.css index c1d5bfd..9101f3a 100644 --- a/apps/frontend/src/views/SearchView.css +++ b/apps/frontend/src/views/SearchView.css @@ -57,12 +57,20 @@ font-weight: 700; } +[data-theme="dark"] .suggestion-btn:hover { + background: #07243d; +} + .suggestion-btn--neutral-1 { background: var(--suggestion-1); color: var(--suggestion-text); border: 1px solid var(--suggestion-border-1); } +[data-theme="dark"] .suggestion-btn--neutral-1:hover { + background: #1e2225; +} + .suggestion-btn--neutral-2 { background: var(--suggestion-2); color: var(--suggestion-text); @@ -179,6 +187,10 @@ transition: opacity 0.15s; } +[data-theme="dark"] .recent-btn { + background: #053d13; +} + .recent-btn:active { opacity: 0.85; } -- 2.52.0