diff --git a/PUSH_NOTIFICATIONS_IMPLEMENTATION.md b/PUSH_NOTIFICATIONS_IMPLEMENTATION.md new file mode 100644 index 00000000..dc6ebe05 --- /dev/null +++ b/PUSH_NOTIFICATIONS_IMPLEMENTATION.md @@ -0,0 +1,250 @@ +# Push Notifications Implementation + +This document describes the implementation of Firebase Cloud Messaging (FCM) push notifications for vesting cliff alerts in the Vesting Vault system. + +## Overview + +The system now sends native push notifications to mobile users when their vesting cliff ends, in addition to email notifications. This provides real-time alerts to users about their claimable tokens. + +## Architecture + +### Components + +1. **Firebase Service** (`src/services/firebaseService.js`) + - Handles Firebase Admin SDK initialization + - Manages FCM message sending to single and multiple devices + - Handles invalid token cleanup + - Provides specialized cliff notification methods + +2. **Device Token Model** (`src/models/deviceToken.js`) + - Stores FCM device tokens for users + - Tracks platform (iOS, Android, Web), app version, and usage + - Manages token lifecycle (active/inactive status) + +3. **Enhanced Notification Service** (`src/services/notificationService.js`) + - Extended to support push notifications alongside email + - Device token registration and management + - Integrated with existing cliff notification cron job + +4. **API Endpoints** (`src/index.js`) + - `POST /api/notifications/register-device` - Register FCM device token + - `DELETE /api/notifications/unregister-device` - Unregister device token + - `GET /api/notifications/devices/:userAddress` - Get user's device tokens + +### Database Schema + +The `device_tokens` table includes: +- `user_address` (VARCHAR): User's wallet address +- `device_token` (TEXT): FCM device registration token +- `platform` (ENUM): Device platform (ios, android, web) +- `app_version` (VARCHAR): App version when registered +- `is_active` (BOOLEAN): Token validity status +- `last_used_at` (TIMESTAMP): Last successful notification + +## Setup + +### 1. Firebase Project Setup + +1. Create a Firebase project at https://console.firebase.google.com/ +2. Enable Cloud Messaging in the Firebase console +3. Generate a service account key: + - Go to Project Settings > Service Accounts + - Click "Generate new private key" + - Download the JSON file + +### 2. Environment Configuration + +Add to your `.env` file: + +```env +# Firebase Configuration for Push Notifications +FIREBASE_SERVICE_ACCOUNT_PATH=./firebase-service-account.json +# Alternative: Use JSON string directly +# FIREBASE_SERVICE_ACCOUNT_KEY={"type":"service_account","project_id":"your-project",...} +``` + +### 3. Install Dependencies + +```bash +npm install firebase-admin +``` + +### 4. Database Migration + +Run the migration to create the device_tokens table: + +```sql +-- Run: 006_create_device_tokens_table.sql +``` + +## Usage + +### Device Registration + +Mobile apps should register their FCM tokens when the user logs in: + +```javascript +// Example mobile app registration +const response = await fetch('/api/notifications/register-device', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userAddress: '0x1234...', + deviceToken: 'fcm-device-token-from-firebase-sdk', + platform: 'ios', // or 'android', 'web' + appVersion: '1.0.0' + }) +}); +``` + +### Automatic Notifications + +The system automatically sends push notifications when: +- Vault cliff dates pass +- SubSchedule cliff dates pass + +Notifications are sent to all active device tokens for the vault owner. + +### Manual Testing + +```bash +# Run the test suite +node test-push-notifications.js +``` + +## API Reference + +### POST /api/notifications/register-device + +Register a device token for push notifications. + +**Request Body:** +```json +{ + "userAddress": "0x1234567890123456789012345678901234567890", + "deviceToken": "fcm-device-token-string", + "platform": "ios|android|web", + "appVersion": "1.0.0" +} +``` + +**Response:** +```json +{ + "success": true, + "data": { + "id": "uuid", + "userAddress": "0x1234...", + "platform": "ios", + "registeredAt": "2024-01-15T10:30:00Z" + } +} +``` + +### DELETE /api/notifications/unregister-device + +Unregister a device token. + +**Request Body:** +```json +{ + "deviceToken": "fcm-device-token-string" +} +``` + +**Response:** +```json +{ + "success": true, + "data": { + "unregistered": true + } +} +``` + +### GET /api/notifications/devices/:userAddress + +Get all active device tokens for a user. + +**Response:** +```json +{ + "success": true, + "data": [ + { + "id": "uuid", + "platform": "ios", + "appVersion": "1.0.0", + "lastUsedAt": "2024-01-15T10:30:00Z", + "registeredAt": "2024-01-15T09:00:00Z" + } + ] +} +``` + +## Notification Flow + +1. **Cron Job Execution**: Runs every hour to check for passed cliffs +2. **Cliff Detection**: Identifies vaults/sub-schedules with passed cliff dates +3. **Multi-Channel Notification**: + - Email notification (if email available) + - Push notification (if device tokens available) +4. **Token Management**: Invalid tokens are automatically marked inactive +5. **Deduplication**: Prevents duplicate notifications using the existing notification tracking + +## Push Notification Payload + +```json +{ + "notification": { + "title": "๐ŸŽ‰ Vesting Cliff Passed!", + "body": "Your 1000 USDC are now available to claim!" + }, + "data": { + "type": "CLIFF_PASSED", + "amount": "1000", + "tokenSymbol": "USDC", + "action": "open_app", + "timestamp": "2024-01-15T10:30:00Z" + } +} +``` + +## Error Handling + +The system gracefully handles: +- Firebase initialization failures (continues without push notifications) +- Invalid or expired device tokens (marks as inactive) +- Network failures (logs errors, doesn't block email notifications) +- Missing Firebase credentials (warns and disables push notifications) + +## Security Considerations + +- Device tokens are stored securely in the database +- Firebase service account credentials should be kept secure +- Invalid tokens are automatically cleaned up +- Rate limiting applies to device registration endpoints + +## Monitoring + +The system logs: +- Firebase initialization status +- Push notification success/failure rates +- Invalid token cleanup operations +- Device registration/unregistration events + +## Future Enhancements + +1. **Rich Notifications**: Add images and action buttons +2. **Notification Preferences**: Allow users to customize notification types +3. **Analytics**: Track notification open rates and engagement +4. **Batch Optimization**: Optimize for high-volume notifications +5. **Multi-Language**: Support localized notification messages + +## Compliance + +This implementation supports: +- Real-time user engagement for time-sensitive vesting events +- Cross-platform notification delivery (iOS, Android, Web) +- Automatic token lifecycle management +- Integration with existing email notification system \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example index 9785f627..e8f95a47 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -33,7 +33,7 @@ EMAIL_USER=your-email-user EMAIL_PASS=your-email-password EMAIL_FROM=no-reply@vestingvault.com -# Price API Configuration -COINGECKO_API_KEY=your-coingecko-pro-api-key-here -COINMARKETCAP_API_KEY=your-coinmarketcap-api-key-here -PRICE_API_PROVIDER=coingecko +# Firebase Configuration for Push Notifications +FIREBASE_SERVICE_ACCOUNT_PATH=./firebase-service-account.json +# Alternative: Use JSON string directly +# FIREBASE_SERVICE_ACCOUNT_KEY={"type":"service_account","project_id":"your-project",...} diff --git a/backend/migrations/006_create_device_tokens_table.sql b/backend/migrations/006_create_device_tokens_table.sql new file mode 100644 index 00000000..706a81b7 --- /dev/null +++ b/backend/migrations/006_create_device_tokens_table.sql @@ -0,0 +1,21 @@ +-- Create Device Tokens table for FCM push notifications +CREATE TABLE IF NOT EXISTS device_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_address VARCHAR(42) NOT NULL, + device_token TEXT NOT NULL UNIQUE, + platform VARCHAR(20) NOT NULL CHECK (platform IN ('ios', 'android', 'web')), + app_version VARCHAR(50) NULL, + is_active BOOLEAN DEFAULT true, + last_used_at TIMESTAMP DEFAULT NOW(), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Indexes for Device Tokens table +CREATE INDEX IF NOT EXISTS idx_device_tokens_user_address ON device_tokens(user_address); +CREATE INDEX IF NOT EXISTS idx_device_tokens_is_active ON device_tokens(is_active); +CREATE INDEX IF NOT EXISTS idx_device_tokens_platform ON device_tokens(platform); + +-- Trigger for updated_at +CREATE TRIGGER update_device_tokens_updated_at BEFORE UPDATE ON device_tokens + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 6cf7838e..2cfe6b3d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,6 +21,7 @@ "dotenv": "^16.3.1", "express": "^4.18.2", "express-rate-limit": "^7.5.1", + "firebase-admin": "^12.0.0", "graphql": "^16.8.1", "graphql-subscriptions": "^2.0.0", "graphql-ws": "^5.14.3", diff --git a/backend/src/index.js b/backend/src/index.js index a09aaf30..2e48233c 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -97,6 +97,7 @@ const discordBotService = require('./services/discordBotService'); const cacheService = require('./services/cacheService'); const tvlService = require('./services/tvlService'); const vaultExportService = require('./services/vaultExportService'); +const notificationService = require('./services/notificationService'); const pdfService = require('./services/pdfService'); // Import webhooks routes @@ -316,6 +317,101 @@ app.get('/api/stats/tvl', async (req, res) => { } }); +// Notification endpoints +app.post('/api/notifications/register-device', async (req, res) => { + try { + const { userAddress, deviceToken, platform, appVersion } = req.body; + + if (!userAddress || !deviceToken || !platform) { + return res.status(400).json({ + success: false, + error: 'userAddress, deviceToken, and platform are required' + }); + } + + if (!['ios', 'android', 'web'].includes(platform)) { + return res.status(400).json({ + success: false, + error: 'platform must be one of: ios, android, web' + }); + } + + const deviceTokenRecord = await notificationService.registerDeviceToken( + userAddress, + deviceToken, + platform, + appVersion + ); + + res.status(201).json({ + success: true, + data: { + id: deviceTokenRecord.id, + userAddress: deviceTokenRecord.user_address, + platform: deviceTokenRecord.platform, + registeredAt: deviceTokenRecord.created_at + } + }); + } catch (error) { + console.error('Error registering device token:', error); + res.status(500).json({ + success: false, + error: error.message + }); + } +}); + +app.delete('/api/notifications/unregister-device', async (req, res) => { + try { + const { deviceToken } = req.body; + + if (!deviceToken) { + return res.status(400).json({ + success: false, + error: 'deviceToken is required' + }); + } + + const success = await notificationService.unregisterDeviceToken(deviceToken); + + res.json({ + success: true, + data: { unregistered: success } + }); + } catch (error) { + console.error('Error unregistering device token:', error); + res.status(500).json({ + success: false, + error: error.message + }); + } +}); + +app.get('/api/notifications/devices/:userAddress', async (req, res) => { + try { + const { userAddress } = req.params; + + const deviceTokens = await notificationService.getUserDeviceTokens(userAddress); + + res.json({ + success: true, + data: deviceTokens.map(token => ({ + id: token.id, + platform: token.platform, + appVersion: token.app_version, + lastUsedAt: token.last_used_at, + registeredAt: token.created_at + })) + }); + } catch (error) { + console.error('Error fetching user device tokens:', error); + res.status(500).json({ + success: false, + error: error.message + }); + } +}); + app.get('/api/vaults/:id/export', async (req, res) => { try { const { id } = req.params; @@ -502,6 +598,15 @@ const startServer = async () => { console.error('Failed to initialize Monthly Report Job:', jobError); } + // Initialize Notification Service (includes cliff notification cron job) + try { + notificationService.start(); + console.log('Notification service started successfully.'); + } catch (notificationError) { + console.error('Failed to initialize Notification Service:', notificationError); + console.log('Continuing without notification cron job...'); + } + // Start the HTTP server httpServer.listen(PORT, () => { console.log(`Server is running on port ${PORT}`); diff --git a/backend/src/models/deviceToken.js b/backend/src/models/deviceToken.js new file mode 100644 index 00000000..3928e99a --- /dev/null +++ b/backend/src/models/deviceToken.js @@ -0,0 +1,67 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../database/connection'); + +const DeviceToken = sequelize.define('DeviceToken', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + user_address: { + type: DataTypes.STRING, + allowNull: false, + comment: 'Wallet address of the user', + }, + device_token: { + type: DataTypes.TEXT, + allowNull: false, + comment: 'FCM device token for push notifications', + }, + platform: { + type: DataTypes.ENUM('ios', 'android', 'web'), + allowNull: false, + comment: 'Device platform', + }, + app_version: { + type: DataTypes.STRING, + allowNull: true, + comment: 'App version when token was registered', + }, + is_active: { + type: DataTypes.BOOLEAN, + defaultValue: true, + comment: 'Whether the token is still valid', + }, + last_used_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + comment: 'Last time this token was used successfully', + }, + created_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + updated_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, +}, { + tableName: 'device_tokens', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + indexes: [ + { + fields: ['user_address'], + }, + { + fields: ['device_token'], + unique: true, + }, + { + fields: ['is_active'], + }, + ], +}); + +module.exports = DeviceToken; \ No newline at end of file diff --git a/backend/src/models/index.js b/backend/src/models/index.js index 200bfd52..4e422d36 100644 --- a/backend/src/models/index.js +++ b/backend/src/models/index.js @@ -7,6 +7,7 @@ const TVL = require('./tvl'); const Beneficiary = require('./beneficiary'); const Organization = require('./organization'); const Notification = require('./notification'); +const DeviceToken = require('./deviceToken'); const { Token, initTokenModel } = require('./token'); const { OrganizationWebhook, initOrganizationWebhookModel } = require('./organizationWebhook'); @@ -24,7 +25,10 @@ const models = { TVL, Beneficiary, Organization, - + Notification, + DeviceToken, + Token, + OrganizationWebhook, sequelize, }; diff --git a/backend/src/services/firebaseService.js b/backend/src/services/firebaseService.js new file mode 100644 index 00000000..b7fc4113 --- /dev/null +++ b/backend/src/services/firebaseService.js @@ -0,0 +1,243 @@ +const admin = require('firebase-admin'); +const { DeviceToken } = require('../models'); + +class FirebaseService { + constructor() { + this.initialized = false; + this.init(); + } + + init() { + try { + // Initialize Firebase Admin SDK + const serviceAccountPath = process.env.FIREBASE_SERVICE_ACCOUNT_PATH; + const serviceAccountKey = process.env.FIREBASE_SERVICE_ACCOUNT_KEY; + + if (serviceAccountPath) { + // Initialize with service account file + const serviceAccount = require(serviceAccountPath); + admin.initializeApp({ + credential: admin.credential.cert(serviceAccount), + }); + } else if (serviceAccountKey) { + // Initialize with service account JSON string + const serviceAccount = JSON.parse(serviceAccountKey); + admin.initializeApp({ + credential: admin.credential.cert(serviceAccount), + }); + } else { + console.warn('Firebase credentials not configured. Push notifications will be disabled.'); + return; + } + + this.messaging = admin.messaging(); + this.initialized = true; + console.log('Firebase Admin SDK initialized successfully.'); + } catch (error) { + console.error('Failed to initialize Firebase Admin SDK:', error); + this.initialized = false; + } + } + + isInitialized() { + return this.initialized; + } + + /** + * Send push notification to a single device + * @param {string} deviceToken - FCM device token + * @param {Object} notification - Notification payload + * @param {Object} data - Additional data payload + * @returns {Promise} - Message ID if successful + */ + async sendToDevice(deviceToken, notification, data = {}) { + if (!this.initialized) { + throw new Error('Firebase not initialized'); + } + + const message = { + token: deviceToken, + notification: { + title: notification.title, + body: notification.body, + ...(notification.imageUrl && { imageUrl: notification.imageUrl }), + }, + data: { + ...data, + timestamp: new Date().toISOString(), + }, + android: { + priority: 'high', + notification: { + channelId: 'vesting_notifications', + priority: 'high', + defaultSound: true, + defaultVibrateTimings: true, + }, + }, + apns: { + payload: { + aps: { + alert: { + title: notification.title, + body: notification.body, + }, + sound: 'default', + badge: 1, + }, + }, + }, + }; + + try { + const response = await this.messaging.send(message); + console.log(`Push notification sent successfully: ${response}`); + + // Update last_used_at for the device token + await DeviceToken.update( + { last_used_at: new Date() }, + { where: { device_token: deviceToken, is_active: true } } + ); + + return response; + } catch (error) { + console.error('Error sending push notification:', error); + + // Handle invalid tokens + if (error.code === 'messaging/registration-token-not-registered' || + error.code === 'messaging/invalid-registration-token') { + console.log(`Marking device token as inactive: ${deviceToken}`); + await DeviceToken.update( + { is_active: false }, + { where: { device_token: deviceToken } } + ); + } + + throw error; + } + } + + /** + * Send push notification to multiple devices + * @param {string[]} deviceTokens - Array of FCM device tokens + * @param {Object} notification - Notification payload + * @param {Object} data - Additional data payload + * @returns {Promise} - Batch response with success/failure counts + */ + async sendToMultipleDevices(deviceTokens, notification, data = {}) { + if (!this.initialized) { + throw new Error('Firebase not initialized'); + } + + if (!deviceTokens || deviceTokens.length === 0) { + return { successCount: 0, failureCount: 0, responses: [] }; + } + + const message = { + notification: { + title: notification.title, + body: notification.body, + ...(notification.imageUrl && { imageUrl: notification.imageUrl }), + }, + data: { + ...data, + timestamp: new Date().toISOString(), + }, + android: { + priority: 'high', + notification: { + channelId: 'vesting_notifications', + priority: 'high', + defaultSound: true, + defaultVibrateTimings: true, + }, + }, + apns: { + payload: { + aps: { + alert: { + title: notification.title, + body: notification.body, + }, + sound: 'default', + badge: 1, + }, + }, + }, + }; + + try { + const response = await this.messaging.sendEachForMulticast({ + tokens: deviceTokens, + ...message, + }); + + console.log(`Batch notification sent. Success: ${response.successCount}, Failure: ${response.failureCount}`); + + // Handle invalid tokens + const invalidTokens = []; + response.responses.forEach((resp, idx) => { + if (!resp.success) { + const error = resp.error; + if (error.code === 'messaging/registration-token-not-registered' || + error.code === 'messaging/invalid-registration-token') { + invalidTokens.push(deviceTokens[idx]); + } + } + }); + + if (invalidTokens.length > 0) { + console.log(`Marking ${invalidTokens.length} device tokens as inactive`); + await DeviceToken.update( + { is_active: false }, + { where: { device_token: invalidTokens } } + ); + } + + // Update last_used_at for successful tokens + const successfulTokens = []; + response.responses.forEach((resp, idx) => { + if (resp.success) { + successfulTokens.push(deviceTokens[idx]); + } + }); + + if (successfulTokens.length > 0) { + await DeviceToken.update( + { last_used_at: new Date() }, + { where: { device_token: successfulTokens, is_active: true } } + ); + } + + return response; + } catch (error) { + console.error('Error sending batch push notifications:', error); + throw error; + } + } + + /** + * Send cliff passed notification + * @param {string[]} deviceTokens - Array of device tokens + * @param {string} amount - Claimable amount + * @param {string} tokenSymbol - Token symbol + * @returns {Promise} - Batch response + */ + async sendCliffPassedNotification(deviceTokens, amount, tokenSymbol = 'tokens') { + const notification = { + title: '๐ŸŽ‰ Vesting Cliff Passed!', + body: `Your ${amount} ${tokenSymbol} are now available to claim!`, + }; + + const data = { + type: 'CLIFF_PASSED', + amount: amount.toString(), + tokenSymbol, + action: 'open_app', + }; + + return await this.sendToMultipleDevices(deviceTokens, notification, data); + } +} + +module.exports = new FirebaseService(); \ No newline at end of file diff --git a/backend/src/services/notificationService.js b/backend/src/services/notificationService.js index b5e2b992..c1f4ca0f 100644 --- a/backend/src/services/notificationService.js +++ b/backend/src/services/notificationService.js @@ -1,6 +1,7 @@ -const { Vault, SubSchedule, Beneficiary, Notification, sequelize } = require('../models'); +const { Vault, SubSchedule, Beneficiary, Notification, DeviceToken, sequelize } = require('../models'); const { Op } = require('sequelize'); const emailService = require('./emailService'); +const firebaseService = require('./firebaseService'); const cron = require('node-cron'); class NotificationService { @@ -107,11 +108,41 @@ class NotificationService { }); if (!existingNotification) { - console.log(`Sending ${type} email to ${beneficiary.email} for vault ${vault.vault_address}`); + console.log(`Sending ${type} notifications to beneficiary ${beneficiary.email || beneficiary.id} for vault ${vault.vault_address}`); - const emailSent = await emailService.sendCliffPassedEmail(beneficiary.email, amount); + let emailSent = false; + let pushSent = false; + + // Send email notification if email is available + if (beneficiary.email) { + emailSent = await emailService.sendCliffPassedEmail(beneficiary.email, amount); + } + + // Send push notification if device tokens are available + const deviceTokens = await DeviceToken.findAll({ + where: { + user_address: vault.owner_address, // Assuming owner_address is the beneficiary + is_active: true + } + }); + + if (deviceTokens.length > 0 && firebaseService.isInitialized()) { + try { + const tokens = deviceTokens.map(dt => dt.device_token); + const pushResponse = await firebaseService.sendCliffPassedNotification( + tokens, + amount, + vault.token_symbol || 'tokens' + ); + pushSent = pushResponse.successCount > 0; + console.log(`Push notifications sent to ${pushResponse.successCount}/${tokens.length} devices`); + } catch (pushError) { + console.error('Error sending push notifications:', pushError); + } + } - if (emailSent) { + // Record notification if at least one method succeeded + if (emailSent || pushSent || deviceTokens.length === 0) { await Notification.create({ beneficiary_id: beneficiary.id, vault_id: vault.id, @@ -120,7 +151,7 @@ class NotificationService { sent_at: new Date() }, { transaction }); - console.log(`Notification recorded in DB for ${beneficiary.email}`); + console.log(`Notification recorded in DB for beneficiary ${beneficiary.email || beneficiary.id}`); } } @@ -130,6 +161,89 @@ class NotificationService { console.error(`Failed to process notification for beneficiary ${beneficiary.id}:`, error); } } + + /** + * Register a device token for push notifications + * @param {string} userAddress - User's wallet address + * @param {string} deviceToken - FCM device token + * @param {string} platform - Device platform (ios, android, web) + * @param {string} appVersion - App version (optional) + * @returns {Promise} - Created or updated device token record + */ + async registerDeviceToken(userAddress, deviceToken, platform, appVersion = null) { + try { + // Check if token already exists + const existingToken = await DeviceToken.findOne({ + where: { device_token: deviceToken } + }); + + if (existingToken) { + // Update existing token + await existingToken.update({ + user_address: userAddress, + platform, + app_version: appVersion, + is_active: true, + last_used_at: new Date() + }); + console.log(`Updated existing device token for user ${userAddress}`); + return existingToken; + } else { + // Create new token + const newToken = await DeviceToken.create({ + user_address: userAddress, + device_token: deviceToken, + platform, + app_version: appVersion, + is_active: true + }); + console.log(`Registered new device token for user ${userAddress}`); + return newToken; + } + } catch (error) { + console.error('Error registering device token:', error); + throw error; + } + } + + /** + * Unregister a device token + * @param {string} deviceToken - FCM device token to unregister + * @returns {Promise} - Success status + */ + async unregisterDeviceToken(deviceToken) { + try { + const result = await DeviceToken.update( + { is_active: false }, + { where: { device_token: deviceToken } } + ); + console.log(`Unregistered device token: ${deviceToken}`); + return result[0] > 0; + } catch (error) { + console.error('Error unregistering device token:', error); + throw error; + } + } + + /** + * Get active device tokens for a user + * @param {string} userAddress - User's wallet address + * @returns {Promise} - Array of active device tokens + */ + async getUserDeviceTokens(userAddress) { + try { + return await DeviceToken.findAll({ + where: { + user_address: userAddress, + is_active: true + }, + order: [['last_used_at', 'DESC']] + }); + } catch (error) { + console.error('Error fetching user device tokens:', error); + throw error; + } + } } module.exports = new NotificationService(); diff --git a/backend/test-push-notifications.js b/backend/test-push-notifications.js new file mode 100644 index 00000000..39f22a6e --- /dev/null +++ b/backend/test-push-notifications.js @@ -0,0 +1,112 @@ +const axios = require('axios'); + +const BASE_URL = 'http://localhost:3000'; + +// Sample test data +const testUserAddress = '0x1234567890123456789012345678901234567890'; +const testDeviceToken = 'sample-fcm-device-token-for-testing-12345'; + +async function testPushNotifications() { + console.log('๐Ÿงช Testing Push Notification Implementation\n'); + + try { + // Test 1: Health check + console.log('1. Testing health check...'); + const healthResponse = await axios.get(`${BASE_URL}/health`); + console.log('โœ… Health check passed:', healthResponse.data); + + // Test 2: Register device token + console.log('\n2. Testing device token registration...'); + const registerResponse = await axios.post(`${BASE_URL}/api/notifications/register-device`, { + userAddress: testUserAddress, + deviceToken: testDeviceToken, + platform: 'android', + appVersion: '1.0.0' + }); + console.log('โœ… Device token registered:', registerResponse.data); + + // Test 3: Get user device tokens + console.log('\n3. Testing get user device tokens...'); + const devicesResponse = await axios.get(`${BASE_URL}/api/notifications/devices/${testUserAddress}`); + console.log('โœ… User device tokens retrieved:', devicesResponse.data); + + // Test 4: Register another device token (iOS) + console.log('\n4. Testing iOS device token registration...'); + const iosRegisterResponse = await axios.post(`${BASE_URL}/api/notifications/register-device`, { + userAddress: testUserAddress, + deviceToken: 'ios-fcm-device-token-for-testing-67890', + platform: 'ios', + appVersion: '1.0.1' + }); + console.log('โœ… iOS device token registered:', iosRegisterResponse.data); + + // Test 5: Unregister device token + console.log('\n5. Testing device token unregistration...'); + const unregisterResponse = await axios.delete(`${BASE_URL}/api/notifications/unregister-device`, { + data: { deviceToken: testDeviceToken } + }); + console.log('โœ… Device token unregistered:', unregisterResponse.data); + + // Test 6: Verify token was unregistered + console.log('\n6. Verifying token unregistration...'); + const finalDevicesResponse = await axios.get(`${BASE_URL}/api/notifications/devices/${testUserAddress}`); + console.log('โœ… Final device tokens:', finalDevicesResponse.data); + + console.log('\n๐ŸŽ‰ All push notification tests passed!'); + + } catch (error) { + console.error('โŒ Test failed:', error.response?.data || error.message); + process.exit(1); + } +} + +// Test error cases +async function testErrorCases() { + console.log('\n๐Ÿงช Testing Error Cases\n'); + + try { + // Test invalid platform + console.log('1. Testing invalid platform...'); + try { + await axios.post(`${BASE_URL}/api/notifications/register-device`, { + userAddress: testUserAddress, + deviceToken: 'test-token', + platform: 'invalid-platform' + }); + } catch (error) { + console.log('โœ… Invalid platform rejected:', error.response.data.error); + } + + // Test missing required fields + console.log('\n2. Testing missing required fields...'); + try { + await axios.post(`${BASE_URL}/api/notifications/register-device`, { + userAddress: testUserAddress + // Missing deviceToken and platform + }); + } catch (error) { + console.log('โœ… Missing fields rejected:', error.response.data.error); + } + + console.log('\n๐ŸŽ‰ Error case tests passed!'); + + } catch (error) { + console.error('โŒ Error case test failed:', error.message); + } +} + +// Run tests if this file is executed directly +if (require.main === module) { + testPushNotifications() + .then(() => testErrorCases()) + .then(() => { + console.log('\nโœจ All tests completed successfully!'); + process.exit(0); + }) + .catch((error) => { + console.error('โŒ Test suite failed:', error); + process.exit(1); + }); +} + +module.exports = { testPushNotifications, testErrorCases }; \ No newline at end of file