WellNuo/backend/src/services/notifications.js
Sergei 7d9e7e37bf Remove console.log statements and add structured logging
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>
2026-01-29 11:58:06 -08:00

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