chore: push notification test
This commit is contained in:
@@ -0,0 +1,579 @@
|
|||||||
|
# Web Push Notifications Setup Guide
|
||||||
|
|
||||||
|
This guide explains how to implement and use **Web Push notifications** in the Dmail API. Web Push allows you to send notifications directly to users' browsers, even when your web app is not open.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Web Push notification system sends real-time notifications to users' browsers when new emails arrive. It uses the standard **Web Push API** supported by all modern browsers and works with service workers.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Email Received → EmailMonitoringService → NotificationQueue → NewEmailHandler → PushNotificationHandler → Browser Push Service → User's Browser
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup Instructions
|
||||||
|
|
||||||
|
### 1. Generate VAPID Keys
|
||||||
|
|
||||||
|
VAPID (Voluntary Application Server Identification) keys are required for Web Push notifications:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate VAPID keys using Node.js
|
||||||
|
node -e "
|
||||||
|
const webpush = require('web-push');
|
||||||
|
const vapidKeys = webpush.generateVAPIDKeys();
|
||||||
|
console.log('Public Key:', vapidKeys.publicKey);
|
||||||
|
console.log('Private Key:', vapidKeys.privateKey);
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configure Environment Variables
|
||||||
|
|
||||||
|
Add these environment variables to your `.env` file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Web Push Notifications Configuration
|
||||||
|
PUSH_NOTIFICATIONS_ENABLED=true
|
||||||
|
VAPID_PUBLIC_KEY=your_generated_public_key_here
|
||||||
|
VAPID_PRIVATE_KEY=your_generated_private_key_here
|
||||||
|
VAPID_SUBJECT=mailto:admin@yourdomain.com
|
||||||
|
|
||||||
|
# Optional: Push notification settings
|
||||||
|
PUSH_TTL=86400
|
||||||
|
PUSH_URGENCY=normal
|
||||||
|
PUSH_RETRY_ATTEMPTS=3
|
||||||
|
PUSH_RETRY_DELAY=5000
|
||||||
|
|
||||||
|
# Frontend URL for notification actions
|
||||||
|
FRONTEND_URL=https://mail.yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install web-push library
|
||||||
|
npm install web-push
|
||||||
|
# or
|
||||||
|
pnpm add web-push
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Run Database Migration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Apply the database migration for web push notification fields
|
||||||
|
psql -d your_database -f database/migrations/add_push_notifications_to_users.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Update App Module
|
||||||
|
|
||||||
|
Add the push notifications config to your `app.module.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { pushNotificationsConfig } from "./configs/push-notifications.config";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
load: [
|
||||||
|
// ... other configs
|
||||||
|
pushNotificationsConfig,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
// ... other modules
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend Implementation
|
||||||
|
|
||||||
|
### 1. Service Worker Setup
|
||||||
|
|
||||||
|
Create `public/sw.js` in your frontend project:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Service Worker for handling push notifications
|
||||||
|
self.addEventListener("push", function (event) {
|
||||||
|
if (event.data) {
|
||||||
|
const data = event.data.json();
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
body: data.body,
|
||||||
|
icon: data.icon || "/assets/email-icon.png",
|
||||||
|
badge: data.badge || "/assets/email-badge.png",
|
||||||
|
tag: data.tag || "email-notification",
|
||||||
|
requireInteraction: data.requireInteraction || false,
|
||||||
|
actions: data.actions || [],
|
||||||
|
data: data.data || {},
|
||||||
|
};
|
||||||
|
|
||||||
|
event.waitUntil(self.registration.showNotification(data.title, options));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle notification clicks
|
||||||
|
self.addEventListener("notificationclick", function (event) {
|
||||||
|
event.notification.close();
|
||||||
|
|
||||||
|
if (event.action === "view") {
|
||||||
|
// Open the email in the app
|
||||||
|
event.waitUntil(clients.openWindow(event.notification.data.url || "/"));
|
||||||
|
} else if (event.action === "dismiss") {
|
||||||
|
// Just close the notification
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
// Default action - open the app
|
||||||
|
event.waitUntil(clients.openWindow(event.notification.data.url || "/"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Client-side JavaScript
|
||||||
|
|
||||||
|
Add this to your main JavaScript file:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
class WebPushManager {
|
||||||
|
constructor(apiBaseUrl, getAuthToken) {
|
||||||
|
this.apiBaseUrl = apiBaseUrl;
|
||||||
|
this.getAuthToken = getAuthToken;
|
||||||
|
this.vapidPublicKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize and register service worker
|
||||||
|
async init() {
|
||||||
|
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
|
||||||
|
console.warn("Push messaging is not supported");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Register service worker
|
||||||
|
const registration = await navigator.serviceWorker.register("/sw.js");
|
||||||
|
console.log("Service Worker registered:", registration);
|
||||||
|
|
||||||
|
// Get VAPID public key
|
||||||
|
await this.getVAPIDKey();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Service Worker registration failed:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get VAPID public key from server
|
||||||
|
async getVAPIDKey() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.apiBaseUrl}/users/push-notifications/vapid-key`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.getAuthToken()}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
this.vapidPublicKey = data.vapidPublicKey;
|
||||||
|
return this.vapidPublicKey;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get VAPID key:", error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request permission and subscribe to push notifications
|
||||||
|
async subscribeToPush() {
|
||||||
|
if (!this.vapidPublicKey) {
|
||||||
|
await this.getVAPIDKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.vapidPublicKey) {
|
||||||
|
throw new Error("VAPID public key not available");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request permission
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
if (permission !== "granted") {
|
||||||
|
throw new Error("Push notification permission denied");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get service worker registration
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Subscribe to push notifications
|
||||||
|
const subscription = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: this.urlBase64ToUint8Array(this.vapidPublicKey),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send subscription to server
|
||||||
|
await this.sendSubscriptionToServer(subscription);
|
||||||
|
|
||||||
|
console.log("Successfully subscribed to push notifications");
|
||||||
|
return subscription;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to subscribe to push notifications:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send subscription to server
|
||||||
|
async sendSubscriptionToServer(subscription) {
|
||||||
|
const response = await fetch(`${this.apiBaseUrl}/users/push-subscriptions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${this.getAuthToken()}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
subscription: subscription.toJSON(),
|
||||||
|
deviceType: "web",
|
||||||
|
deviceName: navigator.userAgent.slice(0, 50),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to send subscription to server");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test push notification
|
||||||
|
async testNotification() {
|
||||||
|
const response = await fetch(`${this.apiBaseUrl}/users/push-notifications/test`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${this.getAuthToken()}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: "Test notification from Dmail Web App",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to send test notification");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is subscribed
|
||||||
|
async isSubscribed() {
|
||||||
|
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
const subscription = await registration.pushManager.getSubscription();
|
||||||
|
return !!subscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsubscribe from push notifications
|
||||||
|
async unsubscribe() {
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
const subscription = await registration.pushManager.getSubscription();
|
||||||
|
|
||||||
|
if (subscription) {
|
||||||
|
// Unsubscribe from browser
|
||||||
|
await subscription.unsubscribe();
|
||||||
|
|
||||||
|
// Remove from server
|
||||||
|
await fetch(`${this.apiBaseUrl}/users/push-subscriptions`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${this.getAuthToken()}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
endpoint: subscription.endpoint,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Successfully unsubscribed from push notifications");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility function to convert VAPID key
|
||||||
|
urlBase64ToUint8Array(base64String) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage example
|
||||||
|
const pushManager = new WebPushManager("https://api.yourdomain.com", () => localStorage.getItem("authToken"));
|
||||||
|
|
||||||
|
// Initialize on page load
|
||||||
|
pushManager.init().then((success) => {
|
||||||
|
if (success) {
|
||||||
|
console.log("Web Push initialized successfully");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export for use in your app
|
||||||
|
window.webPushManager = pushManager;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. User Interface Integration
|
||||||
|
|
||||||
|
Add UI components to your app:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Notification permission UI -->
|
||||||
|
<div id="push-notification-ui" style="display: none;">
|
||||||
|
<div class="notification-prompt">
|
||||||
|
<h3>📬 Enable Email Notifications</h3>
|
||||||
|
<p>Get instant notifications when new emails arrive, even when the app is closed.</p>
|
||||||
|
<button id="enable-notifications" class="btn btn-primary">Enable Notifications</button>
|
||||||
|
<button id="test-notification" class="btn btn-secondary" style="display: none;">Test Notification</button>
|
||||||
|
<button id="disable-notifications" class="btn btn-danger" style="display: none;">Disable</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", async () => {
|
||||||
|
const ui = document.getElementById("push-notification-ui");
|
||||||
|
const enableBtn = document.getElementById("enable-notifications");
|
||||||
|
const testBtn = document.getElementById("test-notification");
|
||||||
|
const disableBtn = document.getElementById("disable-notifications");
|
||||||
|
|
||||||
|
// Show UI if push is supported
|
||||||
|
if ("serviceWorker" in navigator && "PushManager" in window) {
|
||||||
|
ui.style.display = "block";
|
||||||
|
|
||||||
|
// Check current subscription status
|
||||||
|
const isSubscribed = await window.webPushManager.isSubscribed();
|
||||||
|
if (isSubscribed) {
|
||||||
|
enableBtn.style.display = "none";
|
||||||
|
testBtn.style.display = "inline-block";
|
||||||
|
disableBtn.style.display = "inline-block";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable notifications
|
||||||
|
enableBtn.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await window.webPushManager.subscribeToPush();
|
||||||
|
enableBtn.style.display = "none";
|
||||||
|
testBtn.style.display = "inline-block";
|
||||||
|
disableBtn.style.display = "inline-block";
|
||||||
|
alert("Push notifications enabled successfully!");
|
||||||
|
} catch (error) {
|
||||||
|
alert("Failed to enable notifications: " + error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test notification
|
||||||
|
testBtn.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await window.webPushManager.testNotification();
|
||||||
|
alert("Test notification sent!");
|
||||||
|
} catch (error) {
|
||||||
|
alert("Failed to send test notification: " + error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disable notifications
|
||||||
|
disableBtn.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await window.webPushManager.unsubscribe();
|
||||||
|
enableBtn.style.display = "inline-block";
|
||||||
|
testBtn.style.display = "none";
|
||||||
|
disableBtn.style.display = "none";
|
||||||
|
alert("Push notifications disabled");
|
||||||
|
} catch (error) {
|
||||||
|
alert("Failed to disable notifications: " + error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Push Subscription Management
|
||||||
|
|
||||||
|
- `POST /users/push-subscriptions` - Register a new push subscription
|
||||||
|
- `PATCH /users/push-subscriptions` - Update push subscriptions
|
||||||
|
- `DELETE /users/push-subscriptions` - Remove a push subscription
|
||||||
|
|
||||||
|
### Push Notification Settings
|
||||||
|
|
||||||
|
- `PATCH /users/push-notifications/settings` - Enable/disable push notifications
|
||||||
|
- `GET /users/push-notifications/info` - Get push notification info and settings
|
||||||
|
- `POST /users/push-notifications/test` - Send a test notification
|
||||||
|
- `GET /users/push-notifications/vapid-key` - Get VAPID public key
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Register Push Subscription
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://api.yourdomain.com/users/push-subscriptions \
|
||||||
|
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"subscription": {
|
||||||
|
"endpoint": "https://fcm.googleapis.com/fcm/send/...",
|
||||||
|
"keys": {
|
||||||
|
"p256dh": "BNbxxx...",
|
||||||
|
"auth": "abc123..."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"deviceType": "web",
|
||||||
|
"deviceName": "Chrome on MacBook Pro"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Push Notification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://api.yourdomain.com/users/push-notifications/test \
|
||||||
|
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"message": "Test notification from Dmail API"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get VAPID Public Key
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET https://api.yourdomain.com/users/push-notifications/vapid-key \
|
||||||
|
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. **Service Worker Registration**: Your web app registers a service worker that can receive push messages
|
||||||
|
2. **User Permission**: The user grants permission for notifications
|
||||||
|
3. **Subscription Creation**: The browser creates a push subscription with a unique endpoint
|
||||||
|
4. **Server Registration**: The subscription is sent to your API and stored in the database
|
||||||
|
5. **Email Detection**: When new emails arrive, the EmailMonitoringService detects them
|
||||||
|
6. **Notification Processing**: The NewEmailHandler triggers the PushNotificationHandler
|
||||||
|
7. **Push Message**: The server sends a push message to the browser's push service
|
||||||
|
8. **Notification Display**: The service worker receives the message and shows the notification
|
||||||
|
|
||||||
|
## Browser Support
|
||||||
|
|
||||||
|
Web Push is supported by:
|
||||||
|
|
||||||
|
- ✅ Chrome 50+
|
||||||
|
- ✅ Firefox 44+
|
||||||
|
- ✅ Safari 16.0+
|
||||||
|
- ✅ Edge 17+
|
||||||
|
- ✅ Opera 37+
|
||||||
|
|
||||||
|
## Notification Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as User's Browser
|
||||||
|
participant SW as Service Worker
|
||||||
|
participant API as Dmail API
|
||||||
|
participant PS as Push Service
|
||||||
|
participant E as Email System
|
||||||
|
|
||||||
|
U->>SW: Register Service Worker
|
||||||
|
U->>API: Request VAPID key
|
||||||
|
API->>U: Return VAPID public key
|
||||||
|
U->>U: Request notification permission
|
||||||
|
U->>PS: Create push subscription
|
||||||
|
PS->>U: Return subscription object
|
||||||
|
U->>API: Register subscription
|
||||||
|
API->>API: Store subscription
|
||||||
|
|
||||||
|
E->>API: New email detected
|
||||||
|
API->>PS: Send push message
|
||||||
|
PS->>SW: Deliver push message
|
||||||
|
SW->>U: Show notification
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **VAPID Keys**: Keep your private VAPID key secure and never expose it to the client
|
||||||
|
2. **Subscription Validation**: Always validate subscription objects before storing them
|
||||||
|
3. **Rate Limiting**: Implement rate limiting to prevent notification spam
|
||||||
|
4. **Data Privacy**: Be mindful of what data you include in push notifications
|
||||||
|
5. **User Consent**: Always request explicit user permission before subscribing to push notifications
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **Notifications not received**:
|
||||||
|
- Check if the user has granted notification permission
|
||||||
|
- Verify the service worker is registered correctly
|
||||||
|
- Ensure VAPID keys are properly configured
|
||||||
|
- Check browser console for errors
|
||||||
|
|
||||||
|
2. **VAPID key errors**:
|
||||||
|
- Ensure VAPID keys are correctly generated and configured
|
||||||
|
- Verify the VAPID subject is a valid mailto: or https: URL
|
||||||
|
|
||||||
|
3. **Service worker issues**:
|
||||||
|
- Check if the service worker file is accessible
|
||||||
|
- Verify the service worker scope includes your web app
|
||||||
|
- Ensure HTTPS is used (required for service workers)
|
||||||
|
|
||||||
|
### Debug Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test VAPID key configuration
|
||||||
|
curl -X GET https://api.yourdomain.com/users/push-notifications/vapid-key \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN"
|
||||||
|
|
||||||
|
# Check user's notification settings
|
||||||
|
curl -X GET https://api.yourdomain.com/users/push-notifications/info \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN"
|
||||||
|
|
||||||
|
# Send test notification
|
||||||
|
curl -X POST https://api.yourdomain.com/users/push-notifications/test \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"message": "Test"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring and Analytics
|
||||||
|
|
||||||
|
- **Subscription Management**: Track active subscriptions per user
|
||||||
|
- **Delivery Success**: Monitor push notification delivery rates
|
||||||
|
- **User Engagement**: Track notification click-through rates
|
||||||
|
- **Error Handling**: Log and monitor push notification failures
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
- **Payload Size**: Keep push notification payloads small (max 4KB)
|
||||||
|
- **Batch Processing**: Process multiple subscriptions efficiently
|
||||||
|
- **TTL Settings**: Configure appropriate Time-To-Live for notifications
|
||||||
|
- **Cleanup**: Regularly remove invalid/expired subscriptions
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- **Rich Notifications**: Add images and action buttons
|
||||||
|
- **Notification Categories**: Different notification types for different email categories
|
||||||
|
- **User Preferences**: Granular control over notification timing and content
|
||||||
|
- **Analytics Dashboard**: Web interface for monitoring push notification metrics
|
||||||
|
- **A/B Testing**: Test different notification strategies and content
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues and questions:
|
||||||
|
|
||||||
|
1. Check browser developer console for errors
|
||||||
|
2. Verify service worker registration and subscription
|
||||||
|
3. Test VAPID key configuration
|
||||||
|
4. Review application logs for error messages
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- Add web push notification fields to users table
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN push_tokens JSONB DEFAULT NULL,
|
||||||
|
ADD COLUMN push_notifications_enabled BOOLEAN DEFAULT true,
|
||||||
|
ADD COLUMN last_push_notification_at TIMESTAMPTZ DEFAULT NULL;
|
||||||
|
|
||||||
|
-- Add indexes for better performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_push_notifications_enabled ON users (push_notifications_enabled)
|
||||||
|
WHERE
|
||||||
|
push_notifications_enabled = true
|
||||||
|
AND deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_push_tokens ON users USING GIN (push_tokens)
|
||||||
|
WHERE
|
||||||
|
push_tokens IS NOT NULL
|
||||||
|
AND deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- Add comment for documentation
|
||||||
|
COMMENT ON COLUMN users.push_tokens IS 'JSON array of web push subscription objects containing endpoint and keys';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN users.push_notifications_enabled IS 'Whether web push notifications are enabled for this user';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN users.last_push_notification_at IS 'Timestamp of the last push notification sent to this user';
|
||||||
+3
-1
@@ -72,7 +72,8 @@
|
|||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"uuid": "^11.1.0"
|
"uuid": "^11.1.0",
|
||||||
|
"web-push": "^3.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^19.8.1",
|
"@commitlint/cli": "^19.8.1",
|
||||||
@@ -90,6 +91,7 @@
|
|||||||
"@types/nodemailer": "^6.4.17",
|
"@types/nodemailer": "^6.4.17",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/supertest": "^6.0.3",
|
"@types/supertest": "^6.0.3",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.35.1",
|
"@typescript-eslint/eslint-plugin": "^8.35.1",
|
||||||
"@typescript-eslint/parser": "^8.35.1",
|
"@typescript-eslint/parser": "^8.35.1",
|
||||||
"eslint": "^9.30.1",
|
"eslint": "^9.30.1",
|
||||||
|
|||||||
Generated
+88
@@ -137,6 +137,9 @@ importers:
|
|||||||
uuid:
|
uuid:
|
||||||
specifier: ^11.1.0
|
specifier: ^11.1.0
|
||||||
version: 11.1.0
|
version: 11.1.0
|
||||||
|
web-push:
|
||||||
|
specifier: ^3.6.7
|
||||||
|
version: 3.6.7
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@commitlint/cli':
|
'@commitlint/cli':
|
||||||
specifier: ^19.8.1
|
specifier: ^19.8.1
|
||||||
@@ -180,6 +183,9 @@ importers:
|
|||||||
'@types/supertest':
|
'@types/supertest':
|
||||||
specifier: ^6.0.3
|
specifier: ^6.0.3
|
||||||
version: 6.0.3
|
version: 6.0.3
|
||||||
|
'@types/web-push':
|
||||||
|
specifier: ^3.6.4
|
||||||
|
version: 3.6.4
|
||||||
'@typescript-eslint/eslint-plugin':
|
'@typescript-eslint/eslint-plugin':
|
||||||
specifier: ^8.35.1
|
specifier: ^8.35.1
|
||||||
version: 8.35.1(@typescript-eslint/parser@8.35.1(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3)
|
version: 8.35.1(@typescript-eslint/parser@8.35.1(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3))(eslint@9.30.1(jiti@2.4.2))(typescript@5.8.3)
|
||||||
@@ -2037,6 +2043,9 @@ packages:
|
|||||||
'@types/validator@13.15.2':
|
'@types/validator@13.15.2':
|
||||||
resolution: {integrity: sha512-y7pa/oEJJ4iGYBxOpfAKn5b9+xuihvzDVnC/OSvlVnGxVg0pOqmjiMafiJ1KVNQEaPZf9HsEp5icEwGg8uIe5Q==}
|
resolution: {integrity: sha512-y7pa/oEJJ4iGYBxOpfAKn5b9+xuihvzDVnC/OSvlVnGxVg0pOqmjiMafiJ1KVNQEaPZf9HsEp5icEwGg8uIe5Q==}
|
||||||
|
|
||||||
|
'@types/web-push@3.6.4':
|
||||||
|
resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==}
|
||||||
|
|
||||||
'@types/yargs-parser@21.0.3':
|
'@types/yargs-parser@21.0.3':
|
||||||
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==}
|
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==}
|
||||||
|
|
||||||
@@ -2329,6 +2338,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
|
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
|
||||||
engines: {node: '>= 6.0.0'}
|
engines: {node: '>= 6.0.0'}
|
||||||
|
|
||||||
|
agent-base@7.1.4:
|
||||||
|
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||||
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
ajv-formats@2.1.1:
|
ajv-formats@2.1.1:
|
||||||
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
|
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2465,6 +2478,9 @@ packages:
|
|||||||
asap@2.0.6:
|
asap@2.0.6:
|
||||||
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
|
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
|
||||||
|
|
||||||
|
asn1.js@5.4.1:
|
||||||
|
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
|
||||||
|
|
||||||
assert-never@1.4.0:
|
assert-never@1.4.0:
|
||||||
resolution: {integrity: sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==}
|
resolution: {integrity: sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==}
|
||||||
|
|
||||||
@@ -2556,6 +2572,9 @@ packages:
|
|||||||
bl@4.1.0:
|
bl@4.1.0:
|
||||||
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
||||||
|
|
||||||
|
bn.js@4.12.2:
|
||||||
|
resolution: {integrity: sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==}
|
||||||
|
|
||||||
boolbase@1.0.0:
|
boolbase@1.0.0:
|
||||||
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||||
|
|
||||||
@@ -3839,10 +3858,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==}
|
resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==}
|
||||||
engines: {node: '>=10.19.0'}
|
engines: {node: '>=10.19.0'}
|
||||||
|
|
||||||
|
http_ece@1.2.0:
|
||||||
|
resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
https-proxy-agent@5.0.1:
|
https-proxy-agent@5.0.1:
|
||||||
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
|
https-proxy-agent@7.0.6:
|
||||||
|
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||||
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
human-signals@2.1.0:
|
human-signals@2.1.0:
|
||||||
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
|
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
|
||||||
engines: {node: '>=10.17.0'}
|
engines: {node: '>=10.17.0'}
|
||||||
@@ -4368,9 +4395,15 @@ packages:
|
|||||||
jwa@1.4.2:
|
jwa@1.4.2:
|
||||||
resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==}
|
resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==}
|
||||||
|
|
||||||
|
jwa@2.0.1:
|
||||||
|
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
|
||||||
|
|
||||||
jws@3.2.2:
|
jws@3.2.2:
|
||||||
resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==}
|
resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==}
|
||||||
|
|
||||||
|
jws@4.0.0:
|
||||||
|
resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==}
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||||
|
|
||||||
@@ -4672,6 +4705,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==}
|
resolution: {integrity: sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==}
|
||||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||||
|
|
||||||
|
minimalistic-assert@1.0.1:
|
||||||
|
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
|
||||||
|
|
||||||
minimatch@10.0.3:
|
minimatch@10.0.3:
|
||||||
resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==}
|
resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==}
|
||||||
engines: {node: 20 || >=22}
|
engines: {node: 20 || >=22}
|
||||||
@@ -6151,6 +6187,11 @@ packages:
|
|||||||
wcwidth@1.0.1:
|
wcwidth@1.0.1:
|
||||||
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
|
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
|
||||||
|
|
||||||
|
web-push@3.6.7:
|
||||||
|
resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==}
|
||||||
|
engines: {node: '>= 16'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
web-resource-inliner@6.0.1:
|
web-resource-inliner@6.0.1:
|
||||||
resolution: {integrity: sha512-kfqDxt5dTB1JhqsCUQVFDj0rmY+4HLwGQIsLPbyrsN9y9WV/1oFDSx3BQ4GfCv9X+jVeQ7rouTqwK53rA/7t8A==}
|
resolution: {integrity: sha512-kfqDxt5dTB1JhqsCUQVFDj0rmY+4HLwGQIsLPbyrsN9y9WV/1oFDSx3BQ4GfCv9X+jVeQ7rouTqwK53rA/7t8A==}
|
||||||
engines: {node: '>=10.0.0'}
|
engines: {node: '>=10.0.0'}
|
||||||
@@ -8786,6 +8827,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/validator@13.15.2': {}
|
'@types/validator@13.15.2': {}
|
||||||
|
|
||||||
|
'@types/web-push@3.6.4':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 22.16.0
|
||||||
|
|
||||||
'@types/yargs-parser@21.0.3': {}
|
'@types/yargs-parser@21.0.3': {}
|
||||||
|
|
||||||
'@types/yargs@17.0.33':
|
'@types/yargs@17.0.33':
|
||||||
@@ -9127,6 +9172,8 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
agent-base@7.1.4: {}
|
||||||
|
|
||||||
ajv-formats@2.1.1(ajv@8.17.1):
|
ajv-formats@2.1.1(ajv@8.17.1):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
ajv: 8.17.1
|
ajv: 8.17.1
|
||||||
@@ -9270,6 +9317,13 @@ snapshots:
|
|||||||
|
|
||||||
asap@2.0.6: {}
|
asap@2.0.6: {}
|
||||||
|
|
||||||
|
asn1.js@5.4.1:
|
||||||
|
dependencies:
|
||||||
|
bn.js: 4.12.2
|
||||||
|
inherits: 2.0.4
|
||||||
|
minimalistic-assert: 1.0.1
|
||||||
|
safer-buffer: 2.1.2
|
||||||
|
|
||||||
assert-never@1.4.0:
|
assert-never@1.4.0:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -9397,6 +9451,8 @@ snapshots:
|
|||||||
inherits: 2.0.4
|
inherits: 2.0.4
|
||||||
readable-stream: 3.6.2
|
readable-stream: 3.6.2
|
||||||
|
|
||||||
|
bn.js@4.12.2: {}
|
||||||
|
|
||||||
boolbase@1.0.0:
|
boolbase@1.0.0:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -10921,6 +10977,8 @@ snapshots:
|
|||||||
quick-lru: 5.1.1
|
quick-lru: 5.1.1
|
||||||
resolve-alpn: 1.2.1
|
resolve-alpn: 1.2.1
|
||||||
|
|
||||||
|
http_ece@1.2.0: {}
|
||||||
|
|
||||||
https-proxy-agent@5.0.1:
|
https-proxy-agent@5.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
agent-base: 6.0.2
|
agent-base: 6.0.2
|
||||||
@@ -10928,6 +10986,13 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
https-proxy-agent@7.0.6:
|
||||||
|
dependencies:
|
||||||
|
agent-base: 7.1.4
|
||||||
|
debug: 4.4.1
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
human-signals@2.1.0: {}
|
human-signals@2.1.0: {}
|
||||||
|
|
||||||
human-signals@5.0.0: {}
|
human-signals@5.0.0: {}
|
||||||
@@ -11651,11 +11716,22 @@ snapshots:
|
|||||||
ecdsa-sig-formatter: 1.0.11
|
ecdsa-sig-formatter: 1.0.11
|
||||||
safe-buffer: 5.2.1
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
|
jwa@2.0.1:
|
||||||
|
dependencies:
|
||||||
|
buffer-equal-constant-time: 1.0.1
|
||||||
|
ecdsa-sig-formatter: 1.0.11
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
jws@3.2.2:
|
jws@3.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
jwa: 1.4.2
|
jwa: 1.4.2
|
||||||
safe-buffer: 5.2.1
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
|
jws@4.0.0:
|
||||||
|
dependencies:
|
||||||
|
jwa: 2.0.1
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
json-buffer: 3.0.1
|
json-buffer: 3.0.1
|
||||||
@@ -11928,6 +12004,8 @@ snapshots:
|
|||||||
|
|
||||||
mimic-response@4.0.0: {}
|
mimic-response@4.0.0: {}
|
||||||
|
|
||||||
|
minimalistic-assert@1.0.1: {}
|
||||||
|
|
||||||
minimatch@10.0.3:
|
minimatch@10.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@isaacs/brace-expansion': 5.0.0
|
'@isaacs/brace-expansion': 5.0.0
|
||||||
@@ -13716,6 +13794,16 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
defaults: 1.0.4
|
defaults: 1.0.4
|
||||||
|
|
||||||
|
web-push@3.6.7:
|
||||||
|
dependencies:
|
||||||
|
asn1.js: 5.4.1
|
||||||
|
http_ece: 1.2.0
|
||||||
|
https-proxy-agent: 7.0.6
|
||||||
|
jws: 4.0.0
|
||||||
|
minimist: 1.2.8
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
web-resource-inliner@6.0.1:
|
web-resource-inliner@6.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-colors: 4.1.3
|
ansi-colors: 4.1.3
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { ConfigType, registerAs } from "@nestjs/config";
|
||||||
|
|
||||||
|
export const pushNotificationsConfig = registerAs("pushNotifications", () => ({
|
||||||
|
webPush: {
|
||||||
|
vapidPublicKey: process.env.VAPID_PUBLIC_KEY || "",
|
||||||
|
vapidPrivateKey: process.env.VAPID_PRIVATE_KEY || "",
|
||||||
|
vapidSubject: process.env.VAPID_SUBJECT || "mailto:admin@danakcorp.com",
|
||||||
|
},
|
||||||
|
enabled: process.env.PUSH_NOTIFICATIONS_ENABLED === "true",
|
||||||
|
retryAttempts: parseInt(process.env.PUSH_RETRY_ATTEMPTS || "3", 10),
|
||||||
|
retryDelay: parseInt(process.env.PUSH_RETRY_DELAY || "5000", 10),
|
||||||
|
ttl: parseInt(process.env.PUSH_TTL || "86400", 10), // 24 hours
|
||||||
|
urgency: process.env.PUSH_URGENCY || "normal", // low, normal, high
|
||||||
|
}));
|
||||||
|
|
||||||
|
export type PushNotificationsConfigType = ConfigType<typeof pushNotificationsConfig>;
|
||||||
|
|
||||||
|
export const pushNotificationsConfigFactory = () => ({
|
||||||
|
imports: [],
|
||||||
|
useFactory: (configService: ConfigService) => configService.get("pushNotifications"),
|
||||||
|
inject: [ConfigService],
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import { firstValueFrom } from "rxjs";
|
|||||||
import { MailboxResolverService } from "./mailbox-resolver.service";
|
import { MailboxResolverService } from "./mailbox-resolver.service";
|
||||||
import { MailServerService } from "../../mail-server/services/mail-server.service";
|
import { MailServerService } from "../../mail-server/services/mail-server.service";
|
||||||
import { MailboxEnum } from "../../mailbox/enums/mailbox.enum";
|
import { MailboxEnum } from "../../mailbox/enums/mailbox.enum";
|
||||||
|
import { NotificationQueue } from "../../notifications/queue/notification.queue";
|
||||||
import { User } from "../../users/entities/user.entity";
|
import { User } from "../../users/entities/user.entity";
|
||||||
import { EMAIL_QUEUE_CONSTANTS } from "../constants/email-events.constant";
|
import { EMAIL_QUEUE_CONSTANTS } from "../constants/email-events.constant";
|
||||||
import { EmailGateway } from "../gateways/email.gateway";
|
import { EmailGateway } from "../gateways/email.gateway";
|
||||||
@@ -23,6 +24,7 @@ export class EmailMonitoringService implements OnModuleInit {
|
|||||||
private readonly emailGateway: EmailGateway,
|
private readonly emailGateway: EmailGateway,
|
||||||
private readonly mailboxResolver: MailboxResolverService,
|
private readonly mailboxResolver: MailboxResolverService,
|
||||||
private readonly mailServerService: MailServerService,
|
private readonly mailServerService: MailServerService,
|
||||||
|
private readonly notificationQueue: NotificationQueue,
|
||||||
private readonly em: EntityManager,
|
private readonly em: EntityManager,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -219,6 +221,11 @@ export class EmailMonitoringService implements OnModuleInit {
|
|||||||
timestamp: message.timestamp,
|
timestamp: message.timestamp,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
await this.notificationQueue.addNewEmailNotification(userId, {
|
||||||
|
fromEmail: message.from[0].address,
|
||||||
|
fromName: message.from[0].name,
|
||||||
|
});
|
||||||
|
|
||||||
await this.emailGateway.notifyNewEmail(userId, emailPayload);
|
await this.emailGateway.notifyNewEmail(userId, emailPayload);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Error processing new email message for user ${userId}:`, error);
|
this.logger.error(`Error processing new email message for user ${userId}:`, error);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { EntityManager } from "@mikro-orm/postgresql";
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
|
||||||
import { BaseNotificationHandler } from "./base-notification.handler";
|
import { BaseNotificationHandler } from "./base-notification.handler";
|
||||||
|
import { PushNotificationHandler } from "./push-notification.handler";
|
||||||
import { INewEmailNotificationData } from "../interfaces/ISendNotificationData";
|
import { INewEmailNotificationData } from "../interfaces/ISendNotificationData";
|
||||||
import { NotificationsService } from "../services/notifications.service";
|
import { NotificationsService } from "../services/notifications.service";
|
||||||
|
|
||||||
@@ -9,12 +10,26 @@ import { NotificationsService } from "../services/notifications.service";
|
|||||||
export class NewEmailHandler extends BaseNotificationHandler<INewEmailNotificationData> {
|
export class NewEmailHandler extends BaseNotificationHandler<INewEmailNotificationData> {
|
||||||
protected readonly logger = new Logger(NewEmailHandler.name);
|
protected readonly logger = new Logger(NewEmailHandler.name);
|
||||||
|
|
||||||
constructor(private readonly notificationsService: NotificationsService) {
|
constructor(
|
||||||
|
private readonly notificationsService: NotificationsService,
|
||||||
|
private readonly pushNotificationHandler: PushNotificationHandler,
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async handle(recipientId: string, data: INewEmailNotificationData, em: EntityManager): Promise<void> {
|
protected async handle(recipientId: string, data: INewEmailNotificationData, em: EntityManager): Promise<void> {
|
||||||
await this.notificationsService.createNewEmailNotification(recipientId, data, em);
|
try {
|
||||||
|
// Create in-app notification
|
||||||
|
await this.notificationsService.createNewEmailNotification(recipientId, data, em);
|
||||||
|
|
||||||
|
// Also send push notification
|
||||||
|
await this.pushNotificationHandler.processWithTransaction(recipientId, data, em);
|
||||||
|
|
||||||
|
this.logger.log(`New email notifications (in-app + push) processed for user: ${recipientId}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error processing new email notifications for user ${recipientId}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getHandlerName(): string {
|
protected getHandlerName(): string {
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { EntityManager } from "@mikro-orm/postgresql";
|
||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { BaseNotificationHandler } from "./base-notification.handler";
|
||||||
|
import { User } from "../../users/entities/user.entity";
|
||||||
|
import { INewEmailNotificationData } from "../interfaces/ISendNotificationData";
|
||||||
|
import { PushNotificationService, WebPushSubscription } from "../services/push-notification.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PushNotificationHandler extends BaseNotificationHandler<INewEmailNotificationData> {
|
||||||
|
protected readonly logger = new Logger(PushNotificationHandler.name);
|
||||||
|
|
||||||
|
constructor(private readonly pushNotificationService: PushNotificationService) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async handle(recipientId: string, data: INewEmailNotificationData, em: EntityManager): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Get user with push notification settings
|
||||||
|
const user = await em.findOne(User, {
|
||||||
|
id: recipientId,
|
||||||
|
deletedAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
this.logger.warn(`User not found: ${recipientId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if push notifications are enabled for this user
|
||||||
|
if (!user.pushNotificationsEnabled || !user.pushTokens || user.pushTokens.length === 0) {
|
||||||
|
this.logger.debug(`Push notifications not enabled or no subscriptions for user: ${recipientId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if push notification service is enabled
|
||||||
|
if (!this.pushNotificationService.isEnabled()) {
|
||||||
|
this.logger.debug("Push notification service is disabled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert stored subscriptions to WebPushSubscription format
|
||||||
|
const subscriptions: WebPushSubscription[] = user.pushTokens.map((sub) => ({
|
||||||
|
endpoint: sub.endpoint,
|
||||||
|
keys: sub.keys,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Send push notification using the data from INewEmailNotificationData
|
||||||
|
const success = await this.pushNotificationService.sendNewEmailNotification(
|
||||||
|
subscriptions,
|
||||||
|
"New Email", // Default subject - can be enhanced with more email data
|
||||||
|
data.fromName || "Unknown Sender",
|
||||||
|
data.fromEmail,
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
// Update last push notification timestamp
|
||||||
|
user.lastPushNotificationAt = new Date();
|
||||||
|
await em.flush();
|
||||||
|
|
||||||
|
this.logger.log(`Push notification sent successfully for user: ${recipientId}`);
|
||||||
|
} else {
|
||||||
|
this.logger.warn(`Failed to send push notification for user: ${recipientId}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error in push notification handler for user ${recipientId}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getHandlerName(): string {
|
||||||
|
return "PushNotification";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,5 +13,6 @@ export interface IGenericNotificationData extends IBaseNotificationData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface INewEmailNotificationData extends IBaseNotificationData {
|
export interface INewEmailNotificationData extends IBaseNotificationData {
|
||||||
newEmail: string;
|
fromEmail: string;
|
||||||
|
fromName: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ import { Notification } from "./entities/notification.entity";
|
|||||||
import { ChangePasswordHandler } from "./handlers/change-password.handler";
|
import { ChangePasswordHandler } from "./handlers/change-password.handler";
|
||||||
import { NewEmailHandler } from "./handlers/new-email.handler";
|
import { NewEmailHandler } from "./handlers/new-email.handler";
|
||||||
import { NotificationHandlerFactory } from "./handlers/notification-handler.factory";
|
import { NotificationHandlerFactory } from "./handlers/notification-handler.factory";
|
||||||
|
import { PushNotificationHandler } from "./handlers/push-notification.handler";
|
||||||
import { NotificationController } from "./notifications.controller";
|
import { NotificationController } from "./notifications.controller";
|
||||||
import { NotificationProcessor } from "./queue/notification.processor";
|
import { NotificationProcessor } from "./queue/notification.processor";
|
||||||
import { NotificationQueue } from "./queue/notification.queue";
|
import { NotificationQueue } from "./queue/notification.queue";
|
||||||
import { NotificationsService } from "./services/notifications.service";
|
import { NotificationsService } from "./services/notifications.service";
|
||||||
|
import { PushNotificationService } from "./services/push-notification.service";
|
||||||
|
import { pushNotificationsConfig } from "../../configs/push-notifications.config";
|
||||||
import { NotificationSetting } from "../settings/entities/notification-setting.entity";
|
import { NotificationSetting } from "../settings/entities/notification-setting.entity";
|
||||||
import { SettingModule } from "../settings/settings.module";
|
import { SettingModule } from "../settings/settings.module";
|
||||||
import { UtilsModule } from "../utils/utils.module";
|
import { UtilsModule } from "../utils/utils.module";
|
||||||
@@ -19,7 +22,7 @@ import { UserLoginHandler } from "./handlers/user-login.handler";
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule,
|
ConfigModule.forFeature(pushNotificationsConfig),
|
||||||
MikroOrmModule.forFeature([Notification, NotificationSetting]),
|
MikroOrmModule.forFeature([Notification, NotificationSetting]),
|
||||||
BullModule.registerQueue({
|
BullModule.registerQueue({
|
||||||
name: NOTIFICATION.QUEUE_NAME,
|
name: NOTIFICATION.QUEUE_NAME,
|
||||||
@@ -41,13 +44,15 @@ import { UserLoginHandler } from "./handlers/user-login.handler";
|
|||||||
NotificationsService,
|
NotificationsService,
|
||||||
NotificationQueue,
|
NotificationQueue,
|
||||||
NotificationProcessor,
|
NotificationProcessor,
|
||||||
|
PushNotificationService,
|
||||||
// notification handlers
|
// notification handlers
|
||||||
NotificationHandlerFactory,
|
NotificationHandlerFactory,
|
||||||
UserLoginHandler,
|
UserLoginHandler,
|
||||||
NewEmailHandler,
|
NewEmailHandler,
|
||||||
ChangePasswordHandler,
|
ChangePasswordHandler,
|
||||||
|
PushNotificationHandler,
|
||||||
],
|
],
|
||||||
controllers: [NotificationController],
|
controllers: [NotificationController],
|
||||||
exports: [NotificationsService, NotificationQueue],
|
exports: [NotificationsService, NotificationQueue, PushNotificationService],
|
||||||
})
|
})
|
||||||
export class NotificationModule {}
|
export class NotificationModule {}
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import * as webpush from "web-push";
|
||||||
|
|
||||||
|
import { PushNotificationsConfigType } from "../../../configs/push-notifications.config";
|
||||||
|
|
||||||
|
export interface WebPushSubscription {
|
||||||
|
endpoint: string;
|
||||||
|
keys: {
|
||||||
|
p256dh: string;
|
||||||
|
auth: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebPushNotificationPayload {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
icon?: string;
|
||||||
|
badge?: string;
|
||||||
|
image?: string;
|
||||||
|
data?: any;
|
||||||
|
actions?: Array<{
|
||||||
|
action: string;
|
||||||
|
title: string;
|
||||||
|
icon?: string;
|
||||||
|
}>;
|
||||||
|
tag?: string;
|
||||||
|
requireInteraction?: boolean;
|
||||||
|
silent?: boolean;
|
||||||
|
vibrate?: number[];
|
||||||
|
timestamp?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BulkWebPushPayload extends WebPushNotificationPayload {
|
||||||
|
subscriptions: WebPushSubscription[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PushNotificationService {
|
||||||
|
private readonly logger = new Logger(PushNotificationService.name);
|
||||||
|
private readonly config: PushNotificationsConfigType;
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {
|
||||||
|
this.config = this.configService.get<PushNotificationsConfigType>("pushNotifications")!;
|
||||||
|
|
||||||
|
// Configure web-push with VAPID keys
|
||||||
|
if (this.config.webPush.vapidPublicKey && this.config.webPush.vapidPrivateKey) {
|
||||||
|
webpush.setVapidDetails(this.config.webPush.vapidSubject, this.config.webPush.vapidPublicKey, this.config.webPush.vapidPrivateKey);
|
||||||
|
this.logger.log("Web Push service initialized with VAPID keys");
|
||||||
|
} else {
|
||||||
|
this.logger.warn("Web Push service initialized without VAPID keys - push notifications will not work");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send push notification to a single subscription
|
||||||
|
*/
|
||||||
|
async sendPushNotification(subscription: WebPushSubscription, payload: WebPushNotificationPayload, userId?: string): Promise<boolean> {
|
||||||
|
if (!this.config.enabled) {
|
||||||
|
this.logger.debug("Push notifications are disabled");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isConfigured()) {
|
||||||
|
this.logger.warn("Web Push is not properly configured - missing VAPID keys");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const options = {
|
||||||
|
TTL: this.config.ttl,
|
||||||
|
urgency: this.config.urgency as webpush.Urgency,
|
||||||
|
headers: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const payloadString = JSON.stringify({
|
||||||
|
...payload,
|
||||||
|
timestamp: payload.timestamp || Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await webpush.sendNotification(subscription, payloadString, options);
|
||||||
|
|
||||||
|
this.logger.log(`Push notification sent successfully${userId ? ` to user: ${userId}` : ""}`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to send push notification${userId ? ` to user ${userId}` : ""}:`, error);
|
||||||
|
|
||||||
|
// Handle specific web push errors
|
||||||
|
if (error instanceof Error && "statusCode" in error) {
|
||||||
|
const webPushError = error as any;
|
||||||
|
if (webPushError.statusCode === 410 || webPushError.statusCode === 404) {
|
||||||
|
this.logger.warn("Subscription is no longer valid - should be removed from database");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send push notifications to multiple subscriptions
|
||||||
|
*/
|
||||||
|
async sendBulkPushNotifications(bulkPayload: BulkWebPushPayload): Promise<{
|
||||||
|
successful: number;
|
||||||
|
failed: number;
|
||||||
|
invalidSubscriptions: WebPushSubscription[];
|
||||||
|
}> {
|
||||||
|
const results = {
|
||||||
|
successful: 0,
|
||||||
|
failed: 0,
|
||||||
|
invalidSubscriptions: [] as WebPushSubscription[],
|
||||||
|
};
|
||||||
|
|
||||||
|
const promises = bulkPayload.subscriptions.map(async (subscription) => {
|
||||||
|
try {
|
||||||
|
const success = await this.sendPushNotification(subscription, bulkPayload);
|
||||||
|
if (success) {
|
||||||
|
results.successful++;
|
||||||
|
} else {
|
||||||
|
results.failed++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
results.failed++;
|
||||||
|
if (error instanceof Error && "statusCode" in error) {
|
||||||
|
const webPushError = error as any;
|
||||||
|
if (webPushError.statusCode === 410 || webPushError.statusCode === 404) {
|
||||||
|
results.invalidSubscriptions.push(subscription);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Bulk push notifications completed. Successful: ${results.successful}, Failed: ${results.failed}, Invalid: ${results.invalidSubscriptions.length}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send new email notification
|
||||||
|
*/
|
||||||
|
async sendNewEmailNotification(
|
||||||
|
subscriptions: WebPushSubscription[],
|
||||||
|
emailSubject: string,
|
||||||
|
senderName: string,
|
||||||
|
senderEmail: string,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const truncatedSubject = emailSubject.length > 50 ? emailSubject.substring(0, 47) + "..." : emailSubject;
|
||||||
|
|
||||||
|
const payload: WebPushNotificationPayload = {
|
||||||
|
title: "📧 New Email",
|
||||||
|
body: `From: ${senderName || senderEmail}\nSubject: ${truncatedSubject}`,
|
||||||
|
icon: `${process.env.FRONTEND_URL || "https://mail.danakcorp.com"}/assets/email-icon.png`,
|
||||||
|
badge: `${process.env.FRONTEND_URL || "https://mail.danakcorp.com"}/assets/email-badge.png`,
|
||||||
|
tag: "new-email",
|
||||||
|
requireInteraction: true,
|
||||||
|
data: {
|
||||||
|
type: "new-email",
|
||||||
|
userId,
|
||||||
|
sender: senderEmail,
|
||||||
|
subject: emailSubject,
|
||||||
|
url: `${process.env.FRONTEND_URL || "https://mail.danakcorp.com"}/inbox`,
|
||||||
|
},
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
action: "view",
|
||||||
|
title: "View Email",
|
||||||
|
icon: `${process.env.FRONTEND_URL || "https://mail.danakcorp.com"}/assets/view-icon.png`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "dismiss",
|
||||||
|
title: "Dismiss",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
vibrate: [200, 100, 200],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (subscriptions.length === 1) {
|
||||||
|
return this.sendPushNotification(subscriptions[0], payload, userId);
|
||||||
|
} else {
|
||||||
|
const results = await this.sendBulkPushNotifications({
|
||||||
|
...payload,
|
||||||
|
subscriptions,
|
||||||
|
});
|
||||||
|
return results.successful > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send test notification
|
||||||
|
*/
|
||||||
|
async sendTestNotification(subscriptions: WebPushSubscription[], userId?: string): Promise<boolean> {
|
||||||
|
const payload: WebPushNotificationPayload = {
|
||||||
|
title: "🧪 Test Notification",
|
||||||
|
body: "Your web push notifications are working correctly!",
|
||||||
|
icon: `${process.env.FRONTEND_URL || "https://mail.danakcorp.com"}/assets/test-icon.png`,
|
||||||
|
tag: "test",
|
||||||
|
data: {
|
||||||
|
type: "test",
|
||||||
|
userId,
|
||||||
|
url: `${process.env.FRONTEND_URL || "https://mail.danakcorp.com"}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (subscriptions.length === 1) {
|
||||||
|
return this.sendPushNotification(subscriptions[0], payload, userId);
|
||||||
|
} else {
|
||||||
|
const results = await this.sendBulkPushNotifications({
|
||||||
|
...payload,
|
||||||
|
subscriptions,
|
||||||
|
});
|
||||||
|
return results.successful > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate VAPID keys (for initial setup)
|
||||||
|
*/
|
||||||
|
generateVAPIDKeys(): { publicKey: string; privateKey: string } {
|
||||||
|
const vapidKeys = webpush.generateVAPIDKeys();
|
||||||
|
return {
|
||||||
|
publicKey: vapidKeys.publicKey,
|
||||||
|
privateKey: vapidKeys.privateKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get VAPID public key for client-side subscription
|
||||||
|
*/
|
||||||
|
getVAPIDPublicKey(): string {
|
||||||
|
return this.config.webPush.vapidPublicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if push notifications are enabled and configured
|
||||||
|
*/
|
||||||
|
isEnabled(): boolean {
|
||||||
|
return this.config.enabled && this.isConfigured();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if VAPID keys are properly configured
|
||||||
|
*/
|
||||||
|
isConfigured(): boolean {
|
||||||
|
return !!(this.config.webPush.vapidPublicKey && this.config.webPush.vapidPrivateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a web push subscription object
|
||||||
|
*/
|
||||||
|
validateSubscription(subscription: any): subscription is WebPushSubscription {
|
||||||
|
return (
|
||||||
|
subscription &&
|
||||||
|
typeof subscription.endpoint === "string" &&
|
||||||
|
subscription.keys &&
|
||||||
|
typeof subscription.keys.p256dh === "string" &&
|
||||||
|
typeof subscription.keys.auth === "string"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get service configuration info
|
||||||
|
*/
|
||||||
|
getServiceInfo() {
|
||||||
|
return {
|
||||||
|
enabled: this.isEnabled(),
|
||||||
|
configured: this.isConfigured(),
|
||||||
|
vapidPublicKey: this.config.webPush.vapidPublicKey,
|
||||||
|
ttl: this.config.ttl,
|
||||||
|
urgency: this.config.urgency,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
export enum NotifCategory {
|
export enum NotifCategory {
|
||||||
ACCOUNT = "ACCOUNT",
|
ACCOUNT = "ACCOUNT",
|
||||||
|
PUSH = "PUSH",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum NotifType {
|
export enum NotifType {
|
||||||
USER_LOGIN = "USER_LOGIN",
|
USER_LOGIN = "USER_LOGIN",
|
||||||
NEW_EMAIL = "NEW_EMAIL",
|
NEW_EMAIL = "NEW_EMAIL",
|
||||||
CHANGE_PASSWORD = "CHANGE_PASSWORD",
|
CHANGE_PASSWORD = "CHANGE_PASSWORD",
|
||||||
|
PUSH_NEW_EMAIL = "PUSH_NEW_EMAIL",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NotifDescriptions: Record<NotifType, { fa: string; category: NotifCategory }> = {
|
export const NotifDescriptions: Record<NotifType, { fa: string; category: NotifCategory }> = {
|
||||||
[NotifType.USER_LOGIN]: { fa: "ورود به ناحیه کاربری", category: NotifCategory.ACCOUNT },
|
[NotifType.USER_LOGIN]: { fa: "ورود به ناحیه کاربری", category: NotifCategory.ACCOUNT },
|
||||||
[NotifType.NEW_EMAIL]: { fa: "ایمیل جدید", category: NotifCategory.ACCOUNT },
|
[NotifType.NEW_EMAIL]: { fa: "ایمیل جدید", category: NotifCategory.ACCOUNT },
|
||||||
[NotifType.CHANGE_PASSWORD]: { fa: "تغییر رمز عبور", category: NotifCategory.ACCOUNT },
|
[NotifType.CHANGE_PASSWORD]: { fa: "تغییر رمز عبور", category: NotifCategory.ACCOUNT },
|
||||||
|
[NotifType.PUSH_NEW_EMAIL]: { fa: "اعلان موبایل ایمیل جدید", category: NotifCategory.PUSH },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { ArrayMaxSize, ArrayMinSize, IsArray, IsBoolean, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from "class-validator";
|
||||||
|
|
||||||
|
export class WebPushKeysDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: "p256dh key for web push subscription",
|
||||||
|
example: "BNbxxx...",
|
||||||
|
})
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
p256dh: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "auth key for web push subscription",
|
||||||
|
example: "abc123...",
|
||||||
|
})
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
auth: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WebPushSubscriptionDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Push subscription endpoint URL",
|
||||||
|
example: "https://fcm.googleapis.com/fcm/send/...",
|
||||||
|
})
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
endpoint: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Subscription keys",
|
||||||
|
type: WebPushKeysDto,
|
||||||
|
})
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => WebPushKeysDto)
|
||||||
|
@IsObject()
|
||||||
|
keys: WebPushKeysDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RegisterPushSubscriptionDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Web push subscription object",
|
||||||
|
type: WebPushSubscriptionDto,
|
||||||
|
})
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => WebPushSubscriptionDto)
|
||||||
|
@IsObject()
|
||||||
|
subscription: WebPushSubscriptionDto;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Device type or platform",
|
||||||
|
example: "desktop",
|
||||||
|
required: false,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
deviceType?: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Device name or identifier",
|
||||||
|
example: "Chrome on MacBook Pro",
|
||||||
|
required: false,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
deviceName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdatePushSubscriptionsDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Array of web push subscriptions",
|
||||||
|
type: [WebPushSubscriptionDto],
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(0)
|
||||||
|
@ArrayMaxSize(10) // Limit to 10 subscriptions per user
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => WebPushSubscriptionDto)
|
||||||
|
subscriptions: WebPushSubscriptionDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdatePushNotificationSettingsDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Enable or disable push notifications",
|
||||||
|
example: true,
|
||||||
|
})
|
||||||
|
@IsBoolean()
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TestPushNotificationDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Test message to send",
|
||||||
|
example: "Test notification from Dmail",
|
||||||
|
required: false,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RemovePushSubscriptionDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Endpoint URL of the subscription to remove",
|
||||||
|
example: "https://fcm.googleapis.com/fcm/send/...",
|
||||||
|
})
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
endpoint: string;
|
||||||
|
}
|
||||||
@@ -8,6 +8,18 @@ import { Domain } from "../../domains/entities/domain.entity";
|
|||||||
import { Template } from "../../templates/entities/template.entity";
|
import { Template } from "../../templates/entities/template.entity";
|
||||||
import { UserRepository } from "../repositories/user.repository";
|
import { UserRepository } from "../repositories/user.repository";
|
||||||
|
|
||||||
|
export interface WebPushSubscriptionData {
|
||||||
|
endpoint: string;
|
||||||
|
keys: {
|
||||||
|
p256dh: string;
|
||||||
|
auth: string;
|
||||||
|
};
|
||||||
|
deviceType?: string;
|
||||||
|
deviceName?: string;
|
||||||
|
registeredAt?: Date;
|
||||||
|
updatedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
@Entity({ repository: () => UserRepository })
|
@Entity({ repository: () => UserRepository })
|
||||||
@Unique({ properties: ["userName", "domain"], name: "unique_user_name_domain", options: { where: "deleted_at is null" } })
|
@Unique({ properties: ["userName", "domain"], name: "unique_user_name_domain", options: { where: "deleted_at is null" } })
|
||||||
export class User extends BaseEntity {
|
export class User extends BaseEntity {
|
||||||
@@ -51,6 +63,16 @@ export class User extends BaseEntity {
|
|||||||
@Property({ default: true, nullable: false })
|
@Property({ default: true, nullable: false })
|
||||||
isActive: boolean & Opt;
|
isActive: boolean & Opt;
|
||||||
|
|
||||||
|
// Push notification fields - now stores web push subscription objects
|
||||||
|
@Property({ type: "json", nullable: true })
|
||||||
|
pushTokens?: WebPushSubscriptionData[];
|
||||||
|
|
||||||
|
@Property({ type: "boolean", default: true })
|
||||||
|
pushNotificationsEnabled: boolean & Opt;
|
||||||
|
|
||||||
|
@Property({ type: "timestamptz", nullable: true })
|
||||||
|
lastPushNotificationAt?: Date;
|
||||||
|
|
||||||
//=========================
|
//=========================
|
||||||
|
|
||||||
@OneToMany(() => RefreshToken, (token) => token.user)
|
@OneToMany(() => RefreshToken, (token) => token.user)
|
||||||
|
|||||||
@@ -1,20 +1,37 @@
|
|||||||
import { EntityManager } from "@mikro-orm/postgresql";
|
import { EntityManager } from "@mikro-orm/postgresql";
|
||||||
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||||
import { firstValueFrom } from "rxjs";
|
import { firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
import { BusinessMessage, DomainMessage, MailServerMessage, RoleMessage, TemplateMessage, UserMessage } from "../../../common/enums/message.enum";
|
import {
|
||||||
|
BusinessMessage,
|
||||||
|
CommonMessage,
|
||||||
|
DomainMessage,
|
||||||
|
MailServerMessage,
|
||||||
|
RoleMessage,
|
||||||
|
TemplateMessage,
|
||||||
|
UserMessage,
|
||||||
|
} from "../../../common/enums/message.enum";
|
||||||
import { QUOTA_CONSTANTS } from "../../businesses/constant";
|
import { QUOTA_CONSTANTS } from "../../businesses/constant";
|
||||||
import { Business } from "../../businesses/entities/business.entity";
|
import { Business } from "../../businesses/entities/business.entity";
|
||||||
import { DomainStatus } from "../../domains/enums/domain-status.enum";
|
import { DomainStatus } from "../../domains/enums/domain-status.enum";
|
||||||
import { DomainAutomationService } from "../../domains/services/domain-automation.service";
|
import { DomainAutomationService } from "../../domains/services/domain-automation.service";
|
||||||
import { DomainsService } from "../../domains/services/domains.service";
|
import { DomainsService } from "../../domains/services/domains.service";
|
||||||
|
import { MailboxResolverService } from "../../email/services/mailbox-resolver.service";
|
||||||
import { UpdateUserDto } from "../../mail-server/DTO/update-user.dto";
|
import { UpdateUserDto } from "../../mail-server/DTO/update-user.dto";
|
||||||
import { MailServerService } from "../../mail-server/services/mail-server.service";
|
import { MailServerService } from "../../mail-server/services/mail-server.service";
|
||||||
|
import { NotificationQueue } from "../../notifications/queue/notification.queue";
|
||||||
|
import { PushNotificationService } from "../../notifications/services/push-notification.service";
|
||||||
import { QuotaSyncService } from "../../quota-sync/services/quota-sync.service";
|
import { QuotaSyncService } from "../../quota-sync/services/quota-sync.service";
|
||||||
import { UserSettingsService } from "../../settings/services/user-settings.service";
|
import { UserSettingsService } from "../../settings/services/user-settings.service";
|
||||||
import { Template } from "../../templates/entities/template.entity";
|
import { Template } from "../../templates/entities/template.entity";
|
||||||
import { PasswordService } from "../../utils/services/password.service";
|
import { PasswordService } from "../../utils/services/password.service";
|
||||||
import { CreateEmailUserDto } from "../DTO/create-email-user.dto";
|
import { CreateEmailUserDto } from "../DTO/create-email-user.dto";
|
||||||
|
import {
|
||||||
|
RegisterPushSubscriptionDto,
|
||||||
|
TestPushNotificationDto,
|
||||||
|
UpdatePushNotificationSettingsDto,
|
||||||
|
UpdatePushSubscriptionsDto,
|
||||||
|
} from "../DTO/push-notification.dto";
|
||||||
import { UpdateEmailUserDto } from "../DTO/update-email-user.dto";
|
import { UpdateEmailUserDto } from "../DTO/update-email-user.dto";
|
||||||
import { UpdateUserProfileDto } from "../DTO/update-user-profile.dto";
|
import { UpdateUserProfileDto } from "../DTO/update-user-profile.dto";
|
||||||
import { UserListQueryDto } from "../DTO/user-list-query.dto";
|
import { UserListQueryDto } from "../DTO/user-list-query.dto";
|
||||||
@@ -36,6 +53,9 @@ export class UsersService {
|
|||||||
private readonly domainAutomationService: DomainAutomationService,
|
private readonly domainAutomationService: DomainAutomationService,
|
||||||
private readonly quotaSyncService: QuotaSyncService,
|
private readonly quotaSyncService: QuotaSyncService,
|
||||||
private readonly userSettingsService: UserSettingsService,
|
private readonly userSettingsService: UserSettingsService,
|
||||||
|
private readonly mailboxResolver: MailboxResolverService,
|
||||||
|
private readonly notificationQueue: NotificationQueue,
|
||||||
|
private readonly pushNotificationService: PushNotificationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getUserEmailIdFromId(userId: string) {
|
async getUserEmailIdFromId(userId: string) {
|
||||||
@@ -475,4 +495,240 @@ export class UsersService {
|
|||||||
|
|
||||||
this.logger.log(`Updated business quota for ${business.name}: used=${business.usedQuota}, remaining=${business.remainingQuota}`);
|
this.logger.log(`Updated business quota for ${business.name}: used=${business.usedQuota}, remaining=${business.remainingQuota}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Push Notification Methods
|
||||||
|
async registerPushSubscription(userId: string, registerPushSubscriptionDto: RegisterPushSubscriptionDto) {
|
||||||
|
try {
|
||||||
|
const user = await this.userRepository.findOne({ id: userId, deletedAt: null });
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException(UserMessage.USER_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the subscription
|
||||||
|
if (!this.pushNotificationService.validateSubscription(registerPushSubscriptionDto.subscription)) {
|
||||||
|
throw new BadRequestException("Invalid push subscription format");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize pushTokens array if it doesn't exist (now stores subscriptions)
|
||||||
|
if (!user.pushTokens) {
|
||||||
|
user.pushTokens = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if subscription endpoint already exists
|
||||||
|
const existingIndex = user.pushTokens.findIndex((sub: any) => sub.endpoint === registerPushSubscriptionDto.subscription.endpoint);
|
||||||
|
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
// Update existing subscription
|
||||||
|
user.pushTokens[existingIndex] = {
|
||||||
|
...registerPushSubscriptionDto.subscription,
|
||||||
|
deviceType: registerPushSubscriptionDto.deviceType,
|
||||||
|
deviceName: registerPushSubscriptionDto.deviceName,
|
||||||
|
registeredAt: new Date(),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Add new subscription (limit to 10 subscriptions per user)
|
||||||
|
if (user.pushTokens.length >= 10) {
|
||||||
|
// Remove oldest subscription
|
||||||
|
user.pushTokens.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
user.pushTokens.push({
|
||||||
|
...registerPushSubscriptionDto.subscription,
|
||||||
|
deviceType: registerPushSubscriptionDto.deviceType,
|
||||||
|
deviceName: registerPushSubscriptionDto.deviceName,
|
||||||
|
registeredAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.em.flush();
|
||||||
|
|
||||||
|
this.logger.log(`Push subscription registered for user ${userId}: ${registerPushSubscriptionDto.deviceType || "unknown device"}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: "Subscription registered successfully",
|
||||||
|
subscriptionCount: user.pushTokens.length,
|
||||||
|
endpoint: registerPushSubscriptionDto.subscription.endpoint,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error registering push subscription for user ${userId}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePushSubscriptions(userId: string, updatePushSubscriptionsDto: UpdatePushSubscriptionsDto) {
|
||||||
|
try {
|
||||||
|
const user = await this.userRepository.findOne({ id: userId, deletedAt: null });
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException(UserMessage.USER_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate all subscriptions
|
||||||
|
for (const subscription of updatePushSubscriptionsDto.subscriptions) {
|
||||||
|
if (!this.pushNotificationService.validateSubscription(subscription)) {
|
||||||
|
throw new BadRequestException(`Invalid subscription format for endpoint: ${(subscription as any).endpoint}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
user.pushTokens = updatePushSubscriptionsDto.subscriptions.map((sub) => ({
|
||||||
|
...sub,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}));
|
||||||
|
await this.em.flush();
|
||||||
|
|
||||||
|
this.logger.log(`Push subscriptions updated for user ${userId}: ${updatePushSubscriptionsDto.subscriptions.length} subscriptions`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: CommonMessage.UPDATE_SUCCESS,
|
||||||
|
subscriptionCount: user.pushTokens.length,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error updating push subscriptions for user ${userId}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removePushSubscription(userId: string, endpoint: string) {
|
||||||
|
try {
|
||||||
|
const user = await this.userRepository.findOne({ id: userId, deletedAt: null });
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException(UserMessage.USER_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.pushTokens || user.pushTokens.length === 0) {
|
||||||
|
throw new BadRequestException("No subscriptions found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptions = user.pushTokens;
|
||||||
|
const initialCount = subscriptions.length;
|
||||||
|
user.pushTokens = subscriptions.filter((sub) => sub.endpoint !== endpoint);
|
||||||
|
|
||||||
|
if (user.pushTokens.length === initialCount) {
|
||||||
|
throw new BadRequestException("Subscription not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.em.flush();
|
||||||
|
|
||||||
|
this.logger.log(`Push subscription removed for user ${userId}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: "Subscription removed successfully",
|
||||||
|
subscriptionCount: user.pushTokens.length,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error removing push subscription for user ${userId}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePushNotificationSettings(userId: string, settingsDto: UpdatePushNotificationSettingsDto) {
|
||||||
|
try {
|
||||||
|
const user = await this.userRepository.findOne({ id: userId, deletedAt: null });
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException(UserMessage.USER_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
user.pushNotificationsEnabled = settingsDto.enabled;
|
||||||
|
await this.em.flush();
|
||||||
|
|
||||||
|
this.logger.log(`Push notification settings updated for user ${userId}: ${settingsDto.enabled ? "enabled" : "disabled"}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: CommonMessage.UPDATE_SUCCESS,
|
||||||
|
pushNotificationsEnabled: user.pushNotificationsEnabled,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error updating push notification settings for user ${userId}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async testPushNotification(userId: string, _testDto: TestPushNotificationDto) {
|
||||||
|
try {
|
||||||
|
const user = await this.userRepository.findOne({ id: userId, deletedAt: null });
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException(UserMessage.USER_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.pushNotificationsEnabled) {
|
||||||
|
throw new BadRequestException("Push notifications are disabled for this user");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.pushTokens || user.pushTokens.length === 0) {
|
||||||
|
throw new BadRequestException("No push subscriptions registered for this user");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.pushNotificationService.isEnabled()) {
|
||||||
|
throw new BadRequestException("Push notification service is disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert stored subscriptions to proper format
|
||||||
|
const subscriptions = user.pushTokens.map((sub) => ({
|
||||||
|
endpoint: sub.endpoint,
|
||||||
|
keys: sub.keys,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const success = await this.pushNotificationService.sendTestNotification(subscriptions, user.id);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
throw new BadRequestException("Failed to send test notification");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Test push notification sent for user ${userId}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: "Test notification sent successfully",
|
||||||
|
subscriptionCount: subscriptions.length,
|
||||||
|
serviceInfo: this.pushNotificationService.getServiceInfo(),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error sending test push notification for user ${userId}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPushNotificationInfo(userId: string) {
|
||||||
|
try {
|
||||||
|
const user = await this.userRepository.findOne({ id: userId, deletedAt: null });
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException(UserMessage.USER_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pushNotificationsEnabled: user.pushNotificationsEnabled,
|
||||||
|
subscriptionCount: user.pushTokens?.length || 0,
|
||||||
|
lastPushNotificationAt: user.lastPushNotificationAt,
|
||||||
|
serviceEnabled: this.pushNotificationService.isEnabled(),
|
||||||
|
vapidPublicKey: this.pushNotificationService.getVAPIDPublicKey(),
|
||||||
|
serviceInfo: this.pushNotificationService.getServiceInfo(),
|
||||||
|
setup: {
|
||||||
|
instructions: "Register your browser for push notifications using the service worker",
|
||||||
|
documentation: {
|
||||||
|
webPushAPI: "https://developer.mozilla.org/en-US/docs/Web/API/Push_API",
|
||||||
|
serviceWorkers: "https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error getting push notification info for user ${userId}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVAPIDPublicKey() {
|
||||||
|
try {
|
||||||
|
const vapidPublicKey = this.pushNotificationService.getVAPIDPublicKey();
|
||||||
|
|
||||||
|
if (!vapidPublicKey) {
|
||||||
|
throw new BadRequestException("VAPID public key is not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
vapidPublicKey,
|
||||||
|
serviceInfo: this.pushNotificationService.getServiceInfo(),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("Error getting VAPID public key:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseIntercepto
|
|||||||
import { ApiOperation, ApiResponse } from "@nestjs/swagger";
|
import { ApiOperation, ApiResponse } from "@nestjs/swagger";
|
||||||
|
|
||||||
import { CreateEmailUserDto } from "./DTO/create-email-user.dto";
|
import { CreateEmailUserDto } from "./DTO/create-email-user.dto";
|
||||||
|
import {
|
||||||
|
RegisterPushSubscriptionDto,
|
||||||
|
TestPushNotificationDto,
|
||||||
|
UpdatePushNotificationSettingsDto,
|
||||||
|
UpdatePushSubscriptionsDto,
|
||||||
|
} from "./DTO/push-notification.dto";
|
||||||
import { UpdateEmailUserDto } from "./DTO/update-email-user.dto";
|
import { UpdateEmailUserDto } from "./DTO/update-email-user.dto";
|
||||||
import { UpdateUserProfileDto } from "./DTO/update-user-profile.dto";
|
import { UpdateUserProfileDto } from "./DTO/update-user-profile.dto";
|
||||||
import { UserListQueryDto } from "./DTO/user-list-query.dto";
|
import { UserListQueryDto } from "./DTO/user-list-query.dto";
|
||||||
@@ -39,6 +45,56 @@ export class UsersController {
|
|||||||
return this.usersService.updateProfile(userId, updateMeDto);
|
return this.usersService.updateProfile(userId, updateMeDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Push Notification Endpoints
|
||||||
|
@Post("push-subscriptions")
|
||||||
|
@ApiOperation({ summary: "Register web push subscription" })
|
||||||
|
@ApiResponse({ status: 201, description: "Push subscription registered successfully" })
|
||||||
|
registerPushSubscription(@UserDec("id") userId: string, @Body() registerPushSubscriptionDto: RegisterPushSubscriptionDto) {
|
||||||
|
return this.usersService.registerPushSubscription(userId, registerPushSubscriptionDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("push-subscriptions")
|
||||||
|
@ApiOperation({ summary: "Update web push subscriptions" })
|
||||||
|
@ApiResponse({ status: 200, description: "Push subscriptions updated successfully" })
|
||||||
|
updatePushSubscriptions(@UserDec("id") userId: string, @Body() updatePushSubscriptionsDto: UpdatePushSubscriptionsDto) {
|
||||||
|
return this.usersService.updatePushSubscriptions(userId, updatePushSubscriptionsDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete("push-subscriptions")
|
||||||
|
@ApiOperation({ summary: "Remove web push subscription by endpoint" })
|
||||||
|
@ApiResponse({ status: 200, description: "Push subscription removed successfully" })
|
||||||
|
removePushSubscription(@UserDec("id") userId: string, @Body() body: { endpoint: string }) {
|
||||||
|
return this.usersService.removePushSubscription(userId, body.endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("push-notifications/settings")
|
||||||
|
@ApiOperation({ summary: "Update push notification settings" })
|
||||||
|
@ApiResponse({ status: 200, description: "Push notification settings updated successfully" })
|
||||||
|
updatePushNotificationSettings(@UserDec("id") userId: string, @Body() settingsDto: UpdatePushNotificationSettingsDto) {
|
||||||
|
return this.usersService.updatePushNotificationSettings(userId, settingsDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("push-notifications/test")
|
||||||
|
@ApiOperation({ summary: "Test web push notification" })
|
||||||
|
@ApiResponse({ status: 200, description: "Test notification sent successfully" })
|
||||||
|
testPushNotification(@UserDec("id") userId: string, @Body() testDto: TestPushNotificationDto) {
|
||||||
|
return this.usersService.testPushNotification(userId, testDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("push-notifications/info")
|
||||||
|
@ApiOperation({ summary: "Get web push notification info and settings" })
|
||||||
|
@ApiResponse({ status: 200, description: "Push notification info retrieved successfully" })
|
||||||
|
getPushNotificationInfo(@UserDec("id") userId: string) {
|
||||||
|
return this.usersService.getPushNotificationInfo(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("push-notifications/vapid-key")
|
||||||
|
@ApiOperation({ summary: "Get VAPID public key for client-side subscription" })
|
||||||
|
@ApiResponse({ status: 200, description: "VAPID public key retrieved successfully" })
|
||||||
|
getVAPIDPublicKey(@UserDec("id") _userId: string) {
|
||||||
|
return this.usersService.getVAPIDPublicKey();
|
||||||
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseInterceptors(BusinessInterceptor)
|
@UseInterceptors(BusinessInterceptor)
|
||||||
@ApiOperation({ summary: "Create a new email user" })
|
@ApiOperation({ summary: "Create a new email user" })
|
||||||
|
|||||||
@@ -1,27 +1,29 @@
|
|||||||
import { MikroOrmModule } from "@mikro-orm/nestjs";
|
import { MikroOrmModule } from "@mikro-orm/nestjs";
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
import { RefreshToken } from "./entities/refresh-token.entity";
|
|
||||||
import { Role } from "./entities/role.entity";
|
|
||||||
import { User } from "./entities/user.entity";
|
import { User } from "./entities/user.entity";
|
||||||
import { UsersService } from "./services/users.service";
|
import { UsersService } from "./services/users.service";
|
||||||
import { UsersController } from "./users.controller";
|
import { UsersController } from "./users.controller";
|
||||||
import { BusinessesModule } from "../businesses/businesses.module";
|
import { BusinessesModule } from "../businesses/businesses.module";
|
||||||
import { DomainsModule } from "../domains/domains.module";
|
import { DomainsModule } from "../domains/domains.module";
|
||||||
|
import { EmailModule } from "../email/email.module";
|
||||||
import { MailServerModule } from "../mail-server/mail-server.module";
|
import { MailServerModule } from "../mail-server/mail-server.module";
|
||||||
|
import { NotificationModule } from "../notifications/notifications.module";
|
||||||
import { QuotaSyncModule } from "../quota-sync/quota-sync.module";
|
import { QuotaSyncModule } from "../quota-sync/quota-sync.module";
|
||||||
import { SettingModule } from "../settings/settings.module";
|
import { SettingModule } from "../settings/settings.module";
|
||||||
import { UtilsModule } from "../utils/utils.module";
|
import { UtilsModule } from "../utils/utils.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
MikroOrmModule.forFeature([User, RefreshToken, Role]),
|
MikroOrmModule.forFeature([User]),
|
||||||
UtilsModule,
|
UtilsModule,
|
||||||
MailServerModule,
|
MailServerModule,
|
||||||
DomainsModule,
|
DomainsModule,
|
||||||
BusinessesModule,
|
|
||||||
QuotaSyncModule,
|
QuotaSyncModule,
|
||||||
SettingModule,
|
SettingModule,
|
||||||
|
BusinessesModule,
|
||||||
|
EmailModule,
|
||||||
|
NotificationModule,
|
||||||
],
|
],
|
||||||
controllers: [UsersController],
|
controllers: [UsersController],
|
||||||
providers: [UsersService],
|
providers: [UsersService],
|
||||||
|
|||||||
Reference in New Issue
Block a user