From 0da9ccf02d9e729c0d9e36bf9fc936592d624775 Mon Sep 17 00:00:00 2001 From: Sergei Date: Mon, 26 Jan 2026 18:39:04 -0800 Subject: [PATCH] feat(notifications): add notification_history table and logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add migration 010_create_notification_history.sql with indexes - Update notifications.js to log all sent/skipped/failed notifications - Add getNotificationHistory() function for querying history - Add GET /api/notification-settings/history endpoint 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../010_create_notification_history.sql | 75 +++ backend/src/routes/notification-settings.js | 65 ++ backend/src/services/notifications.js | 590 ++++++++++++++++++ 3 files changed, 730 insertions(+) create mode 100644 backend/migrations/010_create_notification_history.sql create mode 100644 backend/src/services/notifications.js diff --git a/backend/migrations/010_create_notification_history.sql b/backend/migrations/010_create_notification_history.sql new file mode 100644 index 0000000..176f156 --- /dev/null +++ b/backend/migrations/010_create_notification_history.sql @@ -0,0 +1,75 @@ +-- ============================================================ +-- Migration: 010_create_notification_history +-- Date: 2025-01-26 +-- Description: Create table for logging all sent notifications +-- ============================================================ + +-- UP: Apply migration +-- ============================================================ + +CREATE TABLE IF NOT EXISTS notification_history ( + id SERIAL PRIMARY KEY, + + -- Who received the notification + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Related beneficiary (optional, for beneficiary-related notifications) + beneficiary_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + + -- Notification content + title VARCHAR(255) NOT NULL, + body TEXT NOT NULL, + + -- Notification type (emergency, activity, low_battery, daily, weekly, system) + type VARCHAR(50) NOT NULL, + + -- Delivery channel (push, email, sms) + channel VARCHAR(20) NOT NULL DEFAULT 'push', + + -- Delivery status + status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ( + 'pending', -- queued for delivery + 'sent', -- successfully sent to provider + 'delivered', -- confirmed delivered (if supported) + 'failed', -- delivery failed + 'skipped' -- skipped due to settings + )), + + -- Skip/failure reason (if applicable) + skip_reason VARCHAR(100), + + -- Additional data payload (JSON) + data JSONB, + + -- Expo push ticket ID (for tracking delivery status) + expo_ticket_id VARCHAR(255), + + -- Error details (if failed) + error_message TEXT, + + -- Timestamps + created_at TIMESTAMPTZ DEFAULT NOW(), + sent_at TIMESTAMPTZ, + delivered_at TIMESTAMPTZ +); + +-- Indexes for common queries +CREATE INDEX IF NOT EXISTS idx_notification_history_user ON notification_history(user_id); +CREATE INDEX IF NOT EXISTS idx_notification_history_beneficiary ON notification_history(beneficiary_id); +CREATE INDEX IF NOT EXISTS idx_notification_history_type ON notification_history(type); +CREATE INDEX IF NOT EXISTS idx_notification_history_status ON notification_history(status); +CREATE INDEX IF NOT EXISTS idx_notification_history_created ON notification_history(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_notification_history_user_created ON notification_history(user_id, created_at DESC); + +-- Comments +COMMENT ON TABLE notification_history IS 'Log of all sent/attempted notifications'; +COMMENT ON COLUMN notification_history.type IS 'Notification type: emergency, activity, low_battery, daily, weekly, system'; +COMMENT ON COLUMN notification_history.channel IS 'Delivery channel: push, email, sms'; +COMMENT ON COLUMN notification_history.status IS 'Delivery status: pending, sent, delivered, failed, skipped'; +COMMENT ON COLUMN notification_history.skip_reason IS 'Reason for skipping: push_disabled, quiet_hours, no_tokens, etc.'; +COMMENT ON COLUMN notification_history.expo_ticket_id IS 'Expo Push API ticket ID for delivery tracking'; + +-- ============================================================ +-- DOWN: Rollback migration (for reference only) +-- ============================================================ +-- DROP TABLE IF EXISTS notification_history; diff --git a/backend/src/routes/notification-settings.js b/backend/src/routes/notification-settings.js index baec1de..198055f 100644 --- a/backend/src/routes/notification-settings.js +++ b/backend/src/routes/notification-settings.js @@ -2,6 +2,7 @@ const express = require('express'); const router = express.Router(); const jwt = require('jsonwebtoken'); const { supabase } = require('../config/supabase'); +const { getNotificationHistory } = require('../services/notifications'); /** * Middleware to verify JWT token @@ -161,4 +162,68 @@ router.patch('/', async (req, res) => { } }); +/** + * GET /api/notification-settings/history + * Returns notification history for current user + * + * Query params: + * - limit: number (default 50, max 100) + * - offset: number (default 0) + * - type: string (filter by notification type) + * - status: string (filter by status) + */ +router.get('/history', async (req, res) => { + try { + const userId = req.user.userId; + const { + limit = 50, + offset = 0, + type, + status + } = req.query; + + // Validate and cap limit + const parsedLimit = Math.min(parseInt(limit) || 50, 100); + const parsedOffset = parseInt(offset) || 0; + + const result = await getNotificationHistory(userId, { + limit: parsedLimit, + offset: parsedOffset, + type, + status + }); + + if (result.error) { + return res.status(500).json({ error: result.error }); + } + + // Transform data for mobile app (camelCase) + const history = result.data.map(item => ({ + id: item.id, + title: item.title, + body: item.body, + type: item.type, + channel: item.channel, + status: item.status, + skipReason: item.skip_reason, + data: item.data, + beneficiaryId: item.beneficiary_id, + createdAt: item.created_at, + sentAt: item.sent_at, + deliveredAt: item.delivered_at + })); + + res.json({ + history, + total: result.total, + limit: parsedLimit, + offset: parsedOffset + }); + + } catch (error) { + console.error('Get notification history error:', error); + res.status(500).json({ error: error.message }); + } +}); + module.exports = router; diff --git a/backend/src/services/notifications.js b/backend/src/services/notifications.js new file mode 100644 index 0000000..c30a660 --- /dev/null +++ b/backend/src/services/notifications.js @@ -0,0 +1,590 @@ +/** + * 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 +};