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
This commit is contained in:
+239
-33
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user