/** * Push Notifications Service * * Sends push notifications via Expo Push API with: * - Notification settings check (push_enabled, alert types, quiet hours) * - Batch sending support * - Error handling and ticket tracking */ const { supabase } = require('../config/supabase'); // Expo Push API endpoint const EXPO_PUSH_URL = 'https://exp.host/--/api/v2/push/send'; /** * Notification types that map to settings */ const NotificationType = { EMERGENCY: 'emergency', // Falls, SOS, critical alerts ACTIVITY: 'activity', // Unusual activity patterns LOW_BATTERY: 'low_battery', // Device battery warnings DAILY_SUMMARY: 'daily', // Daily wellness report WEEKLY_SUMMARY: 'weekly', // Weekly health digest SYSTEM: 'system' // System messages (always delivered) }; /** * Check if current time is within quiet hours * * @param {string} quietStart - Start time in HH:MM format * @param {string} quietEnd - End time in HH:MM format * @param {string} timezone - User timezone (default: UTC) * @returns {boolean} True if currently in quiet hours */ function isInQuietHours(quietStart, quietEnd, timezone = 'UTC') { const now = new Date(); // Parse quiet hours times const [startHour, startMin] = quietStart.split(':').map(Number); const [endHour, endMin] = quietEnd.split(':').map(Number); // Get current time in user's timezone const formatter = new Intl.DateTimeFormat('en-US', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: timezone }); const timeStr = formatter.format(now); const [currentHour, currentMin] = timeStr.split(':').map(Number); // Convert to minutes since midnight for easier comparison const currentMinutes = currentHour * 60 + currentMin; const startMinutes = startHour * 60 + startMin; const endMinutes = endHour * 60 + endMin; // Handle overnight quiet hours (e.g., 22:00 - 07:00) if (startMinutes > endMinutes) { // Quiet hours span midnight return currentMinutes >= startMinutes || currentMinutes < endMinutes; } else { // Same day quiet hours return currentMinutes >= startMinutes && currentMinutes < endMinutes; } } /** * Check if notification should be sent based on user settings * * @param {Object} settings - User notification settings * @param {string} notificationType - Type of notification * @returns {Object} { allowed: boolean, reason?: string } */ function shouldSendNotification(settings, notificationType) { // No settings = use defaults (allow all) if (!settings) { return { allowed: true }; } // Check if push is enabled globally if (!settings.push_enabled) { return { allowed: false, reason: 'push_disabled' }; } // System notifications always go through if (notificationType === NotificationType.SYSTEM) { return { allowed: true }; } // Check specific notification type switch (notificationType) { case NotificationType.EMERGENCY: if (!settings.emergency_alerts) { return { allowed: false, reason: 'emergency_alerts_disabled' }; } // Emergency alerts bypass quiet hours return { allowed: true }; case NotificationType.ACTIVITY: if (!settings.activity_alerts) { return { allowed: false, reason: 'activity_alerts_disabled' }; } break; case NotificationType.LOW_BATTERY: if (!settings.low_battery) { return { allowed: false, reason: 'low_battery_disabled' }; } break; case NotificationType.DAILY_SUMMARY: if (!settings.daily_summary) { return { allowed: false, reason: 'daily_summary_disabled' }; } break; case NotificationType.WEEKLY_SUMMARY: if (!settings.weekly_summary) { return { allowed: false, reason: 'weekly_summary_disabled' }; } break; default: // Unknown type - allow by default break; } // Check quiet hours (except for emergency which returns early) if (settings.quiet_hours_enabled) { const quietStart = settings.quiet_hours_start || '22:00'; const quietEnd = settings.quiet_hours_end || '07:00'; if (isInQuietHours(quietStart, quietEnd)) { return { allowed: false, reason: 'quiet_hours' }; } } return { allowed: true }; } /** * Get active push tokens for a user * * @param {number} userId - User ID * @returns {Promise} Array of Expo push tokens */ async function getUserPushTokens(userId) { const { data: tokens, error } = await supabase .from('push_tokens') .select('token') .eq('user_id', userId) .eq('is_active', true); if (error) { console.error(`[Notifications] Error fetching tokens for user ${userId}:`, error); return []; } return tokens?.map(t => t.token) || []; } /** * Get notification settings for a user * * @param {number} userId - User ID * @returns {Promise} Settings object or null */ async function getUserNotificationSettings(userId) { const { data: settings, error } = await supabase .from('notification_settings') .select('*') .eq('user_id', userId) .single(); if (error && error.code !== 'PGRST116') { console.error(`[Notifications] Error fetching settings for user ${userId}:`, error); } return settings || null; } /** * Log notification to history table * * @param {Object} entry - Notification history entry * @returns {Promise} Inserted record ID or null on error */ async function logNotificationHistory(entry) { try { const { data, error } = await supabase .from('notification_history') .insert({ user_id: entry.userId, beneficiary_id: entry.beneficiaryId || null, title: entry.title, body: entry.body, type: entry.type, channel: entry.channel || 'push', status: entry.status, skip_reason: entry.skipReason || null, data: entry.data || null, expo_ticket_id: entry.expoTicketId || null, error_message: entry.errorMessage || null, sent_at: entry.status === 'sent' ? new Date().toISOString() : null }) .select('id') .single(); if (error) { console.error('[Notifications] Failed to log history:', error); return null; } return data?.id || null; } catch (err) { console.error('[Notifications] Error logging history:', err); return null; } } /** * Update notification history record * * @param {number} id - History record ID * @param {Object} updates - Fields to update */ async function updateNotificationHistory(id, updates) { if (!id) return; try { await supabase .from('notification_history') .update(updates) .eq('id', id); } catch (err) { console.error('[Notifications] Error updating history:', err); } } /** * Send push notifications to Expo API * * @param {Object[]} messages - Array of Expo push messages * @returns {Promise} Result with tickets */ async function sendToExpo(messages) { if (!messages || messages.length === 0) { return { success: true, tickets: [] }; } try { const response = await fetch(EXPO_PUSH_URL, { method: 'POST', headers: { 'Accept': 'application/json', 'Accept-encoding': 'gzip, deflate', 'Content-Type': 'application/json', }, body: JSON.stringify(messages), }); const result = await response.json(); if (!response.ok) { console.error('[Notifications] Expo API error:', result); return { success: false, error: result }; } return { success: true, tickets: result.data || [] }; } catch (error) { console.error('[Notifications] Failed to send to Expo:', error); return { success: false, error: error.message }; } } /** * Send push notifications to one or more users * * @param {Object} options * @param {number|number[]} options.userIds - User ID(s) to send to * @param {string} options.title - Notification title * @param {string} options.body - Notification body text * @param {string} options.type - Notification type (see NotificationType) * @param {Object} [options.data] - Custom data payload * @param {string} [options.sound] - Sound name ('default' or custom) * @param {string} [options.channelId] - Android channel ID * @param {number} [options.badge] - iOS badge count * @param {number} [options.ttl] - Time to live in seconds * @param {string} [options.priority] - 'default', 'normal', or 'high' * @param {number} [options.beneficiaryId] - Related beneficiary ID (for logging) * @returns {Promise} Result with sent count and details */ async function sendPushNotifications({ userIds, title, body, type = NotificationType.SYSTEM, data = {}, sound = 'default', channelId = 'default', badge, ttl = 86400, // 24 hours default priority = 'high', beneficiaryId = null }) { // Normalize userIds to array const userIdList = Array.isArray(userIds) ? userIds : [userIds]; console.log(`[Notifications] Sending "${type}" notification to ${userIdList.length} user(s)`); const results = { sent: 0, skipped: 0, failed: 0, details: [] }; const messagesToSend = []; const historyMap = new Map(); // Maps message index to history entry // Process each user for (const userId of userIdList) { // Get user's notification settings const settings = await getUserNotificationSettings(userId); // Check if notification should be sent const check = shouldSendNotification(settings, type); if (!check.allowed) { console.log(`[Notifications] Skipped user ${userId}: ${check.reason}`); results.skipped++; results.details.push({ userId, status: 'skipped', reason: check.reason }); // Log skipped notification to history await logNotificationHistory({ userId, beneficiaryId, title, body, type, channel: 'push', status: 'skipped', skipReason: check.reason, data }); continue; } // Get user's push tokens const tokens = await getUserPushTokens(userId); if (tokens.length === 0) { console.log(`[Notifications] No active tokens for user ${userId}`); results.skipped++; results.details.push({ userId, status: 'skipped', reason: 'no_tokens' }); // Log skipped notification to history await logNotificationHistory({ userId, beneficiaryId, title, body, type, channel: 'push', status: 'skipped', skipReason: 'no_tokens', data }); continue; } // Create message for each token for (const token of tokens) { // Validate Expo push token format if (!token.startsWith('ExponentPushToken[') && !token.startsWith('ExpoPushToken[')) { console.warn(`[Notifications] Invalid token format for user ${userId}: ${token.substring(0, 20)}...`); continue; } const messageIndex = messagesToSend.length; messagesToSend.push({ to: token, title, body, sound, channelId, badge, ttl, priority, data: { ...data, type, userId, timestamp: new Date().toISOString() } }); // Store history entry info for later update historyMap.set(messageIndex, { userId, beneficiaryId, title, body, type, data }); } results.details.push({ userId, status: 'queued', tokenCount: tokens.length }); } // Send all messages in batch if (messagesToSend.length > 0) { // Expo recommends batches of 100 const batchSize = 100; const batches = []; for (let i = 0; i < messagesToSend.length; i += batchSize) { batches.push({ messages: messagesToSend.slice(i, i + batchSize), startIndex: i }); } for (const batch of batches) { const result = await sendToExpo(batch.messages); if (result.success) { results.sent += batch.messages.length; // Log successful notifications and track any failed tickets for (let i = 0; i < result.tickets.length; i++) { const ticket = result.tickets[i]; const globalIndex = batch.startIndex + i; const historyEntry = historyMap.get(globalIndex); if (historyEntry) { if (ticket.status === 'error') { console.error(`[Notifications] Ticket error:`, ticket); results.failed++; results.sent--; // Log failed notification await logNotificationHistory({ ...historyEntry, channel: 'push', status: 'failed', errorMessage: ticket.message || 'Expo ticket error', expoTicketId: ticket.id }); } else { // Log successful notification await logNotificationHistory({ ...historyEntry, channel: 'push', status: 'sent', expoTicketId: ticket.id }); } } } } else { results.failed += batch.messages.length; console.error(`[Notifications] Batch send failed:`, result.error); // Log failed notifications for the batch for (let i = 0; i < batch.messages.length; i++) { const globalIndex = batch.startIndex + i; const historyEntry = historyMap.get(globalIndex); if (historyEntry) { await logNotificationHistory({ ...historyEntry, channel: 'push', status: 'failed', errorMessage: typeof result.error === 'string' ? result.error : JSON.stringify(result.error) }); } } } } } console.log(`[Notifications] Complete: ${results.sent} sent, ${results.skipped} skipped, ${results.failed} failed`); return results; } /** * Send notification to all caretakers of a beneficiary * * @param {number} beneficiaryId - Beneficiary user ID * @param {Object} notification - Notification options (title, body, type, data) * @returns {Promise} Result */ async function notifyCaretakers(beneficiaryId, notification) { // Get all users with access to this beneficiary const { data: accessRecords, error } = await supabase .from('user_access') .select('accessor_id') .eq('beneficiary_id', beneficiaryId); if (error) { console.error(`[Notifications] Error fetching caretakers for beneficiary ${beneficiaryId}:`, error); return { error: error.message }; } if (!accessRecords || accessRecords.length === 0) { console.log(`[Notifications] No caretakers found for beneficiary ${beneficiaryId}`); return { sent: 0, skipped: 0, failed: 0 }; } const caretakerIds = accessRecords.map(r => r.accessor_id); return sendPushNotifications({ userIds: caretakerIds, beneficiaryId, ...notification }); } /** * Get notification history for a user * * @param {number} userId - User ID * @param {Object} options - Query options * @param {number} [options.limit=50] - Max records to return * @param {number} [options.offset=0] - Pagination offset * @param {string} [options.type] - Filter by notification type * @param {string} [options.status] - Filter by status * @returns {Promise} { data: [], total: number } */ async function getNotificationHistory(userId, options = {}) { const { limit = 50, offset = 0, type, status } = options; let query = supabase .from('notification_history') .select('*', { count: 'exact' }) .eq('user_id', userId) .order('created_at', { ascending: false }) .range(offset, offset + limit - 1); if (type) { query = query.eq('type', type); } if (status) { query = query.eq('status', status); } const { data, error, count } = await query; if (error) { console.error(`[Notifications] Error fetching history for user ${userId}:`, error); return { data: [], total: 0, error: error.message }; } return { data: data || [], total: count || 0 }; } module.exports = { sendPushNotifications, notifyCaretakers, getNotificationHistory, NotificationType, // Exported for testing shouldSendNotification, isInQuietHours, logNotificationHistory };