Some checks failed
ParentFlow CI/CD Pipeline / Backend Tests (push) Has been cancelled
ParentFlow CI/CD Pipeline / Frontend Tests (push) Has been cancelled
ParentFlow CI/CD Pipeline / Security Scanning (push) Has been cancelled
ParentFlow CI/CD Pipeline / Build Docker Images (map[context:maternal-app/maternal-app-backend dockerfile:Dockerfile.production name:backend]) (push) Has been cancelled
ParentFlow CI/CD Pipeline / Build Docker Images (map[context:maternal-web dockerfile:Dockerfile.production name:frontend]) (push) Has been cancelled
ParentFlow CI/CD Pipeline / Deploy to Development (push) Has been cancelled
ParentFlow CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / Lint and Test (push) Has been cancelled
CI/CD Pipeline / E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Application (push) Has been cancelled
Backend CI/CD Pipeline / Lint and Test Backend (push) Has been cancelled
Backend CI/CD Pipeline / E2E Tests Backend (push) Has been cancelled
Backend CI/CD Pipeline / Build Backend Application (push) Has been cancelled
Backend CI/CD Pipeline / Performance Testing (push) Has been cancelled
Implement database-backed notification settings that persist across restarts: Backend changes: - Updated DashboardService to read/write notification settings from database - Added 6 notification settings keys to dbSettingsMap: * enable_email_notifications (boolean) * enable_push_notifications (boolean) * admin_notifications (boolean) * error_alerts (boolean) * new_user_alerts (boolean) * system_health_alerts (boolean) - Settings are now retrieved from database with fallback defaults Database: - Seeded 6 default notification settings in settings table - All notification toggles default to 'true' - Settings persist across server restarts Frontend: - Admin settings page at /settings already configured - Notifications tab contains all 6 toggle switches - Settings are loaded from GET /api/v1/admin/dashboard/settings - Settings are saved via POST /api/v1/admin/dashboard/settings API Endpoints (already existed, now enhanced): - GET /api/v1/admin/dashboard/settings - Returns all settings including notifications - POST /api/v1/admin/dashboard/settings - Persists notification settings to database Files modified: - maternal-app-backend/src/modules/admin/dashboard/dashboard.service.ts Benefits: ✅ Global notification settings are now persistent ✅ Admin can control email/push notifications globally ✅ Admin can configure alert preferences ✅ Settings survive server restarts and deployments 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
325 lines
8.5 KiB
TypeScript
325 lines
8.5 KiB
TypeScript
// Push Notifications Utility for ParentFlow Web App
|
|
// Handles Web Push subscription and management
|
|
|
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://maternal-api.noru1.ro';
|
|
|
|
export interface PushSubscriptionData {
|
|
endpoint: string;
|
|
keys: {
|
|
p256dh: string;
|
|
auth: string;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Convert VAPID public key from base64 to Uint8Array
|
|
*/
|
|
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
|
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
|
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
|
|
|
const rawData = window.atob(base64);
|
|
const outputArray = new Uint8Array(rawData.length);
|
|
|
|
for (let i = 0; i < rawData.length; ++i) {
|
|
outputArray[i] = rawData.charCodeAt(i);
|
|
}
|
|
return outputArray;
|
|
}
|
|
|
|
/**
|
|
* Check if push notifications are supported
|
|
*/
|
|
export function isPushNotificationSupported(): boolean {
|
|
if (typeof window === 'undefined') return false;
|
|
return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
|
|
}
|
|
|
|
/**
|
|
* Get current notification permission status
|
|
*/
|
|
export function getNotificationPermission(): NotificationPermission {
|
|
if (typeof window === 'undefined' || !('Notification' in window)) {
|
|
return 'default';
|
|
}
|
|
return Notification.permission;
|
|
}
|
|
|
|
/**
|
|
* Request notification permission
|
|
*/
|
|
export async function requestNotificationPermission(): Promise<NotificationPermission> {
|
|
if (!isPushNotificationSupported()) {
|
|
throw new Error('Push notifications are not supported in this browser');
|
|
}
|
|
|
|
const permission = await Notification.requestPermission();
|
|
console.log('[Push] Permission result:', permission);
|
|
return permission;
|
|
}
|
|
|
|
/**
|
|
* Get or fetch the VAPID public key
|
|
*/
|
|
export async function getVapidPublicKey(token?: string): Promise<string> {
|
|
try {
|
|
const headers: HeadersInit = {
|
|
'Content-Type': 'application/json',
|
|
};
|
|
|
|
if (token) {
|
|
headers['Authorization'] = `Bearer ${token}`;
|
|
}
|
|
|
|
const response = await fetch(`${API_BASE_URL}/api/v1/push/vapid-public-key`, {
|
|
method: 'GET',
|
|
headers,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to get VAPID key: ${response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
return data.publicKey;
|
|
} catch (error) {
|
|
console.error('[Push] Error getting VAPID key:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Register service worker for push notifications
|
|
*/
|
|
export async function registerPushServiceWorker(): Promise<ServiceWorkerRegistration> {
|
|
if (!('serviceWorker' in navigator)) {
|
|
throw new Error('Service Worker not supported');
|
|
}
|
|
|
|
try {
|
|
// Check if already registered
|
|
const existingRegistration = await navigator.serviceWorker.getRegistration('/push-sw.js');
|
|
if (existingRegistration) {
|
|
console.log('[Push] Service Worker already registered');
|
|
return existingRegistration;
|
|
}
|
|
|
|
// Register new service worker
|
|
const registration = await navigator.serviceWorker.register('/push-sw.js', {
|
|
scope: '/',
|
|
});
|
|
|
|
console.log('[Push] Service Worker registered:', registration.scope);
|
|
|
|
// Wait for the service worker to be ready
|
|
await navigator.serviceWorker.ready;
|
|
|
|
return registration;
|
|
} catch (error) {
|
|
console.error('[Push] Service Worker registration failed:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Subscribe to push notifications
|
|
*/
|
|
export async function subscribeToPush(token: string): Promise<PushSubscription> {
|
|
if (!isPushNotificationSupported()) {
|
|
throw new Error('Push notifications are not supported');
|
|
}
|
|
|
|
// Request permission first
|
|
const permission = await requestNotificationPermission();
|
|
if (permission !== 'granted') {
|
|
throw new Error('Notification permission denied');
|
|
}
|
|
|
|
try {
|
|
// Register service worker
|
|
const registration = await registerPushServiceWorker();
|
|
|
|
// Get VAPID public key
|
|
const vapidPublicKey = await getVapidPublicKey(token);
|
|
const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey);
|
|
|
|
// Subscribe to push
|
|
const subscription = await registration.pushManager.subscribe({
|
|
userVisibleOnly: true,
|
|
applicationServerKey,
|
|
});
|
|
|
|
console.log('[Push] Subscribed to push notifications');
|
|
|
|
// Send subscription to backend
|
|
await savePushSubscription(subscription, token);
|
|
|
|
return subscription;
|
|
} catch (error) {
|
|
console.error('[Push] Error subscribing to push:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save push subscription to backend
|
|
*/
|
|
export async function savePushSubscription(
|
|
subscription: PushSubscription,
|
|
token: string
|
|
): Promise<void> {
|
|
try {
|
|
const subscriptionData = subscription.toJSON();
|
|
|
|
const response = await fetch(`${API_BASE_URL}/api/v1/push/subscriptions`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify(subscriptionData),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to save subscription: ${response.statusText}`);
|
|
}
|
|
|
|
console.log('[Push] Subscription saved to backend');
|
|
} catch (error) {
|
|
console.error('[Push] Error saving subscription:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get current push subscription
|
|
*/
|
|
export async function getPushSubscription(): Promise<PushSubscription | null> {
|
|
if (!isPushNotificationSupported()) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const registration = await navigator.serviceWorker.getRegistration('/push-sw.js');
|
|
if (!registration) {
|
|
return null;
|
|
}
|
|
|
|
const subscription = await registration.pushManager.getSubscription();
|
|
return subscription;
|
|
} catch (error) {
|
|
console.error('[Push] Error getting subscription:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unsubscribe from push notifications
|
|
*/
|
|
export async function unsubscribeFromPush(token: string): Promise<void> {
|
|
try {
|
|
const subscription = await getPushSubscription();
|
|
if (!subscription) {
|
|
console.log('[Push] No active subscription to unsubscribe');
|
|
return;
|
|
}
|
|
|
|
const endpoint = subscription.endpoint;
|
|
|
|
// Unsubscribe locally
|
|
await subscription.unsubscribe();
|
|
console.log('[Push] Unsubscribed locally');
|
|
|
|
// Remove from backend
|
|
await fetch(`${API_BASE_URL}/api/v1/push/subscriptions?endpoint=${encodeURIComponent(endpoint)}`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
});
|
|
|
|
console.log('[Push] Subscription removed from backend');
|
|
} catch (error) {
|
|
console.error('[Push] Error unsubscribing:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if user is subscribed to push notifications
|
|
*/
|
|
export async function isPushSubscribed(): Promise<boolean> {
|
|
const subscription = await getPushSubscription();
|
|
return subscription !== null;
|
|
}
|
|
|
|
/**
|
|
* Send a test push notification
|
|
*/
|
|
export async function sendTestPushNotification(token: string): Promise<void> {
|
|
try {
|
|
const response = await fetch(`${API_BASE_URL}/api/v1/push/test`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to send test notification: ${response.statusText}`);
|
|
}
|
|
|
|
console.log('[Push] Test notification sent');
|
|
} catch (error) {
|
|
console.error('[Push] Error sending test notification:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get push notification statistics
|
|
*/
|
|
export async function getPushStatistics(token: string): Promise<any> {
|
|
try {
|
|
const response = await fetch(`${API_BASE_URL}/api/v1/push/statistics`, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to get statistics: ${response.statusText}`);
|
|
}
|
|
|
|
return await response.json();
|
|
} catch (error) {
|
|
console.error('[Push] Error getting statistics:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show a local test notification (no server needed)
|
|
*/
|
|
export async function showLocalTestNotification(): Promise<void> {
|
|
if (getNotificationPermission() !== 'granted') {
|
|
await requestNotificationPermission();
|
|
}
|
|
|
|
if (getNotificationPermission() === 'granted') {
|
|
const registration = await navigator.serviceWorker.getRegistration('/push-sw.js');
|
|
if (registration) {
|
|
await registration.showNotification('ParentFlow Test', {
|
|
body: 'This is a local test notification',
|
|
icon: '/icons/icon-192x192.png',
|
|
badge: '/icons/icon-72x72.png',
|
|
tag: 'test',
|
|
data: { url: '/' },
|
|
});
|
|
}
|
|
}
|
|
}
|