feat(notifications): add notification_history table and logging

- 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 <noreply@anthropic.com>
This commit is contained in:
Sergei 2026-01-26 18:39:04 -08:00
parent 7cb29bd874
commit 0da9ccf02d
3 changed files with 730 additions and 0 deletions

View File

@ -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;

View File

@ -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;

View File

@ -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<string[]>} 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<Object|null>} 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<number|null>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} { 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
};