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:
parent
7cb29bd874
commit
0da9ccf02d
75
backend/migrations/010_create_notification_history.sql
Normal file
75
backend/migrations/010_create_notification_history.sql
Normal 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;
|
||||
@ -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;
|
||||
|
||||
590
backend/src/services/notifications.js
Normal file
590
backend/src/services/notifications.js
Normal 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
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user