feat: add Expo Push notifications for mobile and configure EXPO_ACCESS_TOKEN
Run Tests on Branches / Detect Changes (push) Successful in 13s
Run Tests on Branches / Backend Tests (push) Successful in 1m58s
Run Tests on Branches / Frontend Tests (push) Successful in 1m53s
Run Tests on Branches / Frontend Mobile Tests (push) Successful in 1m49s

- 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
This commit is contained in:
Antoni Nuñez Romeu
2026-07-13 12:21:25 +02:00
parent a00cfe949c
commit 0c43d32cf1
6 changed files with 436 additions and 51 deletions
+4
View File
@@ -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=
+239 -33
View File
@@ -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 };
}