feat: Add persistent global notification settings for admin panel
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
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>
This commit is contained in:
324
maternal-web/lib/push-notifications.ts
Normal file
324
maternal-web/lib/push-notifications.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
// 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: '/' },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user