Created a centralized logger utility (src/utils/logger.js) that provides: - Structured logging with context labels - Log levels (ERROR, WARN, INFO, DEBUG) - Environment-based log level control via LOG_LEVEL env variable - Consistent timestamp and JSON data formatting Removed console.log/error/warn statements from: - All service files (mqtt, notifications, legacyAPI, email, storage, subscription-sync) - All route handlers (auth, beneficiaries, deployments, webhook, admin, etc) - Controllers (dashboard, auth, alarm) - Database connection handler - Main server file (index.js) Preserved: - Critical startup validation error for JWT_SECRET in index.js Benefits: - Production-ready logging that can be easily integrated with log aggregation services - Reduced noise in production logs - Easier debugging with structured context and data - Configurable log levels per environment 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
575 lines
15 KiB
JavaScript
575 lines
15 KiB
JavaScript
/**
|
|
* 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');
|
|
const { NOTIFICATIONS } = require('../config/constants');
|
|
|
|
// 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 * NOTIFICATIONS.MINUTES_PER_HOUR + currentMin;
|
|
const startMinutes = startHour * NOTIFICATIONS.MINUTES_PER_HOUR + startMin;
|
|
const endMinutes = endHour * NOTIFICATIONS.MINUTES_PER_HOUR + 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 || NOTIFICATIONS.DEFAULT_QUIET_START;
|
|
const quietEnd = settings.quiet_hours_end || NOTIFICATIONS.DEFAULT_QUIET_END;
|
|
|
|
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) {
|
|
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') {
|
|
}
|
|
|
|
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) {
|
|
return null;
|
|
}
|
|
|
|
return data?.id || null;
|
|
} catch (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) {
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
return { success: false, error: result };
|
|
}
|
|
|
|
return { success: true, tickets: result.data || [] };
|
|
} catch (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 = NOTIFICATIONS.DEFAULT_SOUND,
|
|
channelId = NOTIFICATIONS.DEFAULT_CHANNEL_ID,
|
|
badge,
|
|
ttl = NOTIFICATIONS.DEFAULT_TTL_SECONDS,
|
|
priority = NOTIFICATIONS.DEFAULT_PRIORITY,
|
|
beneficiaryId = null
|
|
}) {
|
|
// Normalize userIds to array
|
|
const userIdList = Array.isArray(userIds) ? userIds : [userIds];
|
|
|
|
|
|
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) {
|
|
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) {
|
|
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[')) {
|
|
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 = NOTIFICATIONS.BATCH_SIZE;
|
|
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') {
|
|
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;
|
|
|
|
// 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)
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
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) {
|
|
return { error: error.message };
|
|
}
|
|
|
|
if (!accessRecords || accessRecords.length === 0) {
|
|
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 = NOTIFICATIONS.HISTORY_DEFAULT_LIMIT,
|
|
offset = NOTIFICATIONS.HISTORY_DEFAULT_OFFSET,
|
|
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) {
|
|
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
|
|
};
|