From 7b7167ab7d7f287e0bbf0a4080505295e64500fe Mon Sep 17 00:00:00 2001 From: mahyargdz Date: Thu, 24 Jul 2025 13:03:29 +0330 Subject: [PATCH] chore: push notification test --- WEB_PUSH_SETUP.md | 579 ++++++++++++++++++ .../add_push_notifications_to_users.sql | 23 + package.json | 4 +- pnpm-lock.yaml | 88 +++ src/configs/push-notifications.config.ts | 23 + .../services/email-monitoring.service.ts | 7 + .../handlers/new-email.handler.ts | 19 +- .../handlers/push-notification.handler.ts | 75 +++ .../interfaces/ISendNotificationData.ts | 3 +- .../notifications/notifications.module.ts | 9 +- .../services/push-notification.service.ts | 277 +++++++++ .../settings/enums/notif-settings.enum.ts | 3 + .../users/DTO/push-notification.dto.ts | 112 ++++ src/modules/users/entities/user.entity.ts | 22 + src/modules/users/services/users.service.ts | 260 +++++++- src/modules/users/users.controller.ts | 56 ++ src/modules/users/users.module.ts | 10 +- 17 files changed, 1558 insertions(+), 12 deletions(-) create mode 100644 WEB_PUSH_SETUP.md create mode 100644 database/migrations/add_push_notifications_to_users.sql create mode 100644 src/configs/push-notifications.config.ts create mode 100644 src/modules/notifications/handlers/push-notification.handler.ts create mode 100644 src/modules/notifications/services/push-notification.service.ts create mode 100644 src/modules/users/DTO/push-notification.dto.ts diff --git a/WEB_PUSH_SETUP.md b/WEB_PUSH_SETUP.md new file mode 100644 index 0000000..0361cdd --- /dev/null +++ b/WEB_PUSH_SETUP.md @@ -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 + + + + +``` + +## 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 diff --git a/database/migrations/add_push_notifications_to_users.sql b/database/migrations/add_push_notifications_to_users.sql new file mode 100644 index 0000000..c4e71d4 --- /dev/null +++ b/database/migrations/add_push_notifications_to_users.sql @@ -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'; \ No newline at end of file diff --git a/package.json b/package.json index ccffaf4..eb5b737 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,8 @@ "rxjs": "^7.8.2", "slugify": "^1.6.6", "socket.io": "^4.8.1", - "uuid": "^11.1.0" + "uuid": "^11.1.0", + "web-push": "^3.6.7" }, "devDependencies": { "@commitlint/cli": "^19.8.1", @@ -90,6 +91,7 @@ "@types/nodemailer": "^6.4.17", "@types/passport-jwt": "^4.0.1", "@types/supertest": "^6.0.3", + "@types/web-push": "^3.6.4", "@typescript-eslint/eslint-plugin": "^8.35.1", "@typescript-eslint/parser": "^8.35.1", "eslint": "^9.30.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5e6548..c62085e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,6 +137,9 @@ importers: uuid: specifier: ^11.1.0 version: 11.1.0 + web-push: + specifier: ^3.6.7 + version: 3.6.7 devDependencies: '@commitlint/cli': specifier: ^19.8.1 @@ -180,6 +183,9 @@ importers: '@types/supertest': specifier: ^6.0.3 version: 6.0.3 + '@types/web-push': + specifier: ^3.6.4 + version: 3.6.4 '@typescript-eslint/eslint-plugin': 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) @@ -2037,6 +2043,9 @@ packages: '@types/validator@13.15.2': resolution: {integrity: sha512-y7pa/oEJJ4iGYBxOpfAKn5b9+xuihvzDVnC/OSvlVnGxVg0pOqmjiMafiJ1KVNQEaPZf9HsEp5icEwGg8uIe5Q==} + '@types/web-push@3.6.4': + resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -2329,6 +2338,10 @@ packages: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} 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: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -2465,6 +2478,9 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + asn1.js@5.4.1: + resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + assert-never@1.4.0: resolution: {integrity: sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==} @@ -2556,6 +2572,9 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + bn.js@4.12.2: + resolution: {integrity: sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -3839,10 +3858,18 @@ packages: resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==} 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: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -4368,9 +4395,15 @@ packages: jwa@1.4.2: resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + jws@3.2.2: resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + jws@4.0.0: + resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -4672,6 +4705,9 @@ packages: resolution: {integrity: sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==} 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: resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==} engines: {node: 20 || >=22} @@ -6151,6 +6187,11 @@ packages: wcwidth@1.0.1: 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: resolution: {integrity: sha512-kfqDxt5dTB1JhqsCUQVFDj0rmY+4HLwGQIsLPbyrsN9y9WV/1oFDSx3BQ4GfCv9X+jVeQ7rouTqwK53rA/7t8A==} engines: {node: '>=10.0.0'} @@ -8786,6 +8827,10 @@ snapshots: '@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@17.0.33': @@ -9127,6 +9172,8 @@ snapshots: transitivePeerDependencies: - supports-color + agent-base@7.1.4: {} + ajv-formats@2.1.1(ajv@8.17.1): optionalDependencies: ajv: 8.17.1 @@ -9270,6 +9317,13 @@ snapshots: 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: optional: true @@ -9397,6 +9451,8 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + bn.js@4.12.2: {} + boolbase@1.0.0: optional: true @@ -10921,6 +10977,8 @@ snapshots: quick-lru: 5.1.1 resolve-alpn: 1.2.1 + http_ece@1.2.0: {} + https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 @@ -10928,6 +10986,13 @@ snapshots: transitivePeerDependencies: - 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@5.0.0: {} @@ -11651,11 +11716,22 @@ snapshots: ecdsa-sig-formatter: 1.0.11 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: dependencies: jwa: 1.4.2 safe-buffer: 5.2.1 + jws@4.0.0: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -11928,6 +12004,8 @@ snapshots: mimic-response@4.0.0: {} + minimalistic-assert@1.0.1: {} + minimatch@10.0.3: dependencies: '@isaacs/brace-expansion': 5.0.0 @@ -13716,6 +13794,16 @@ snapshots: dependencies: 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: dependencies: ansi-colors: 4.1.3 diff --git a/src/configs/push-notifications.config.ts b/src/configs/push-notifications.config.ts new file mode 100644 index 0000000..7bfe6b8 --- /dev/null +++ b/src/configs/push-notifications.config.ts @@ -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; + +export const pushNotificationsConfigFactory = () => ({ + imports: [], + useFactory: (configService: ConfigService) => configService.get("pushNotifications"), + inject: [ConfigService], +}); diff --git a/src/modules/email/services/email-monitoring.service.ts b/src/modules/email/services/email-monitoring.service.ts index df80128..d4bfb52 100644 --- a/src/modules/email/services/email-monitoring.service.ts +++ b/src/modules/email/services/email-monitoring.service.ts @@ -7,6 +7,7 @@ import { firstValueFrom } from "rxjs"; import { MailboxResolverService } from "./mailbox-resolver.service"; import { MailServerService } from "../../mail-server/services/mail-server.service"; import { MailboxEnum } from "../../mailbox/enums/mailbox.enum"; +import { NotificationQueue } from "../../notifications/queue/notification.queue"; import { User } from "../../users/entities/user.entity"; import { EMAIL_QUEUE_CONSTANTS } from "../constants/email-events.constant"; import { EmailGateway } from "../gateways/email.gateway"; @@ -23,6 +24,7 @@ export class EmailMonitoringService implements OnModuleInit { private readonly emailGateway: EmailGateway, private readonly mailboxResolver: MailboxResolverService, private readonly mailServerService: MailServerService, + private readonly notificationQueue: NotificationQueue, private readonly em: EntityManager, ) {} @@ -219,6 +221,11 @@ export class EmailMonitoringService implements OnModuleInit { timestamp: message.timestamp, }; + await this.notificationQueue.addNewEmailNotification(userId, { + fromEmail: message.from[0].address, + fromName: message.from[0].name, + }); + await this.emailGateway.notifyNewEmail(userId, emailPayload); } catch (error) { this.logger.error(`Error processing new email message for user ${userId}:`, error); diff --git a/src/modules/notifications/handlers/new-email.handler.ts b/src/modules/notifications/handlers/new-email.handler.ts index 25a5ec1..3554a7a 100644 --- a/src/modules/notifications/handlers/new-email.handler.ts +++ b/src/modules/notifications/handlers/new-email.handler.ts @@ -2,6 +2,7 @@ import { EntityManager } from "@mikro-orm/postgresql"; import { Injectable, Logger } from "@nestjs/common"; import { BaseNotificationHandler } from "./base-notification.handler"; +import { PushNotificationHandler } from "./push-notification.handler"; import { INewEmailNotificationData } from "../interfaces/ISendNotificationData"; import { NotificationsService } from "../services/notifications.service"; @@ -9,12 +10,26 @@ import { NotificationsService } from "../services/notifications.service"; export class NewEmailHandler extends BaseNotificationHandler { protected readonly logger = new Logger(NewEmailHandler.name); - constructor(private readonly notificationsService: NotificationsService) { + constructor( + private readonly notificationsService: NotificationsService, + private readonly pushNotificationHandler: PushNotificationHandler, + ) { super(); } protected async handle(recipientId: string, data: INewEmailNotificationData, em: EntityManager): Promise { - 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 { diff --git a/src/modules/notifications/handlers/push-notification.handler.ts b/src/modules/notifications/handlers/push-notification.handler.ts new file mode 100644 index 0000000..ee519ba --- /dev/null +++ b/src/modules/notifications/handlers/push-notification.handler.ts @@ -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 { + protected readonly logger = new Logger(PushNotificationHandler.name); + + constructor(private readonly pushNotificationService: PushNotificationService) { + super(); + } + + protected async handle(recipientId: string, data: INewEmailNotificationData, em: EntityManager): Promise { + 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"; + } +} diff --git a/src/modules/notifications/interfaces/ISendNotificationData.ts b/src/modules/notifications/interfaces/ISendNotificationData.ts index 24483bd..91d31ac 100755 --- a/src/modules/notifications/interfaces/ISendNotificationData.ts +++ b/src/modules/notifications/interfaces/ISendNotificationData.ts @@ -13,5 +13,6 @@ export interface IGenericNotificationData extends IBaseNotificationData { } export interface INewEmailNotificationData extends IBaseNotificationData { - newEmail: string; + fromEmail: string; + fromName: string; } diff --git a/src/modules/notifications/notifications.module.ts b/src/modules/notifications/notifications.module.ts index 1de017c..4e9de95 100755 --- a/src/modules/notifications/notifications.module.ts +++ b/src/modules/notifications/notifications.module.ts @@ -8,10 +8,13 @@ import { Notification } from "./entities/notification.entity"; import { ChangePasswordHandler } from "./handlers/change-password.handler"; import { NewEmailHandler } from "./handlers/new-email.handler"; import { NotificationHandlerFactory } from "./handlers/notification-handler.factory"; +import { PushNotificationHandler } from "./handlers/push-notification.handler"; import { NotificationController } from "./notifications.controller"; import { NotificationProcessor } from "./queue/notification.processor"; import { NotificationQueue } from "./queue/notification.queue"; 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 { SettingModule } from "../settings/settings.module"; import { UtilsModule } from "../utils/utils.module"; @@ -19,7 +22,7 @@ import { UserLoginHandler } from "./handlers/user-login.handler"; @Module({ imports: [ - ConfigModule, + ConfigModule.forFeature(pushNotificationsConfig), MikroOrmModule.forFeature([Notification, NotificationSetting]), BullModule.registerQueue({ name: NOTIFICATION.QUEUE_NAME, @@ -41,13 +44,15 @@ import { UserLoginHandler } from "./handlers/user-login.handler"; NotificationsService, NotificationQueue, NotificationProcessor, + PushNotificationService, // notification handlers NotificationHandlerFactory, UserLoginHandler, NewEmailHandler, ChangePasswordHandler, + PushNotificationHandler, ], controllers: [NotificationController], - exports: [NotificationsService, NotificationQueue], + exports: [NotificationsService, NotificationQueue, PushNotificationService], }) export class NotificationModule {} diff --git a/src/modules/notifications/services/push-notification.service.ts b/src/modules/notifications/services/push-notification.service.ts new file mode 100644 index 0000000..6ba9e32 --- /dev/null +++ b/src/modules/notifications/services/push-notification.service.ts @@ -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("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 { + 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 { + 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 { + 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, + }; + } +} diff --git a/src/modules/settings/enums/notif-settings.enum.ts b/src/modules/settings/enums/notif-settings.enum.ts index 27159d2..a87a5e6 100755 --- a/src/modules/settings/enums/notif-settings.enum.ts +++ b/src/modules/settings/enums/notif-settings.enum.ts @@ -1,15 +1,18 @@ export enum NotifCategory { ACCOUNT = "ACCOUNT", + PUSH = "PUSH", } export enum NotifType { USER_LOGIN = "USER_LOGIN", NEW_EMAIL = "NEW_EMAIL", CHANGE_PASSWORD = "CHANGE_PASSWORD", + PUSH_NEW_EMAIL = "PUSH_NEW_EMAIL", } export const NotifDescriptions: Record = { [NotifType.USER_LOGIN]: { fa: "ورود به ناحیه کاربری", category: NotifCategory.ACCOUNT }, [NotifType.NEW_EMAIL]: { fa: "ایمیل جدید", category: NotifCategory.ACCOUNT }, [NotifType.CHANGE_PASSWORD]: { fa: "تغییر رمز عبور", category: NotifCategory.ACCOUNT }, + [NotifType.PUSH_NEW_EMAIL]: { fa: "اعلان موبایل ایمیل جدید", category: NotifCategory.PUSH }, }; diff --git a/src/modules/users/DTO/push-notification.dto.ts b/src/modules/users/DTO/push-notification.dto.ts new file mode 100644 index 0000000..3ba2ef4 --- /dev/null +++ b/src/modules/users/DTO/push-notification.dto.ts @@ -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; +} diff --git a/src/modules/users/entities/user.entity.ts b/src/modules/users/entities/user.entity.ts index b790230..528f68c 100644 --- a/src/modules/users/entities/user.entity.ts +++ b/src/modules/users/entities/user.entity.ts @@ -8,6 +8,18 @@ import { Domain } from "../../domains/entities/domain.entity"; import { Template } from "../../templates/entities/template.entity"; 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 }) @Unique({ properties: ["userName", "domain"], name: "unique_user_name_domain", options: { where: "deleted_at is null" } }) export class User extends BaseEntity { @@ -51,6 +63,16 @@ export class User extends BaseEntity { @Property({ default: true, nullable: false }) 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) diff --git a/src/modules/users/services/users.service.ts b/src/modules/users/services/users.service.ts index e5e14dd..49bb49d 100644 --- a/src/modules/users/services/users.service.ts +++ b/src/modules/users/services/users.service.ts @@ -1,20 +1,37 @@ 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 { 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 { Business } from "../../businesses/entities/business.entity"; import { DomainStatus } from "../../domains/enums/domain-status.enum"; import { DomainAutomationService } from "../../domains/services/domain-automation.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 { 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 { UserSettingsService } from "../../settings/services/user-settings.service"; import { Template } from "../../templates/entities/template.entity"; import { PasswordService } from "../../utils/services/password.service"; 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 { UpdateUserProfileDto } from "../DTO/update-user-profile.dto"; import { UserListQueryDto } from "../DTO/user-list-query.dto"; @@ -36,6 +53,9 @@ export class UsersService { private readonly domainAutomationService: DomainAutomationService, private readonly quotaSyncService: QuotaSyncService, private readonly userSettingsService: UserSettingsService, + private readonly mailboxResolver: MailboxResolverService, + private readonly notificationQueue: NotificationQueue, + private readonly pushNotificationService: PushNotificationService, ) {} 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}`); } + + // 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; + } + } } diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts index 470b616..dced7fa 100644 --- a/src/modules/users/users.controller.ts +++ b/src/modules/users/users.controller.ts @@ -2,6 +2,12 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseIntercepto import { ApiOperation, ApiResponse } from "@nestjs/swagger"; 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 { UpdateUserProfileDto } from "./DTO/update-user-profile.dto"; import { UserListQueryDto } from "./DTO/user-list-query.dto"; @@ -39,6 +45,56 @@ export class UsersController { 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() @UseInterceptors(BusinessInterceptor) @ApiOperation({ summary: "Create a new email user" }) diff --git a/src/modules/users/users.module.ts b/src/modules/users/users.module.ts index fb86359..81d1f6f 100644 --- a/src/modules/users/users.module.ts +++ b/src/modules/users/users.module.ts @@ -1,27 +1,29 @@ import { MikroOrmModule } from "@mikro-orm/nestjs"; 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 { UsersService } from "./services/users.service"; import { UsersController } from "./users.controller"; import { BusinessesModule } from "../businesses/businesses.module"; import { DomainsModule } from "../domains/domains.module"; +import { EmailModule } from "../email/email.module"; import { MailServerModule } from "../mail-server/mail-server.module"; +import { NotificationModule } from "../notifications/notifications.module"; import { QuotaSyncModule } from "../quota-sync/quota-sync.module"; import { SettingModule } from "../settings/settings.module"; import { UtilsModule } from "../utils/utils.module"; @Module({ imports: [ - MikroOrmModule.forFeature([User, RefreshToken, Role]), + MikroOrmModule.forFeature([User]), UtilsModule, MailServerModule, DomainsModule, - BusinessesModule, QuotaSyncModule, SettingModule, + BusinessesModule, + EmailModule, + NotificationModule, ], controllers: [UsersController], providers: [UsersService],