diff --git a/README.md b/README.md index 7aaf2bd..1a8ba32 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ A complete, multi-provider notification engine that empowers you to engage users - **User-Crafted Notification Streams:** Users can create and save persistent **Saved Headline Filters** based on any combination of content filters (such as topics, sources, or regions). They can then subscribe to notifications for that filter, receiving alerts only for the news they care about. - **Flexible Delivery Mechanisms:** The system is architected to support multiple notification types for each subscription, from immediate alerts to scheduled daily or weekly digests. - **Provider Agnostic:** The engine is built to be provider-agnostic, with out-of-the-box support for Firebase (FCM) and OneSignal. The active provider can be switched remotely without any code changes. +- **Intelligent, Self-Healing Delivery:** The notification engine is designed for long-term reliability. It automatically detects and prunes invalid device tokens—for example, when a user uninstalls the app—ensuring the system remains efficient and notifications are only sent to active devices. > **Your Advantage:** You get a complete, secure, and scalable notification system that enhances user engagement and can be managed entirely from the web dashboard. diff --git a/lib/src/services/firebase_push_notification_client.dart b/lib/src/services/firebase_push_notification_client.dart index 09013fd..d4135e6 100644 --- a/lib/src/services/firebase_push_notification_client.dart +++ b/lib/src/services/firebase_push_notification_client.dart @@ -27,28 +27,30 @@ class FirebasePushNotificationClient implements IPushNotificationClient { final Logger _log; @override - Future sendNotification({ + Future sendNotification({ required String deviceToken, required PushNotificationPayload payload, - }) async { + }) { // For consistency, the single send method now delegates to the bulk // method with a list containing just one token. - await sendBulkNotifications( + return sendBulkNotifications( deviceTokens: [deviceToken], payload: payload, ); } @override - Future sendBulkNotifications({ + Future sendBulkNotifications({ required List deviceTokens, required PushNotificationPayload payload, }) async { if (deviceTokens.isEmpty) { _log.info('No device tokens provided for Firebase bulk send. Aborting.'); - return; + return const PushNotificationResult( + sentTokens: [], + failedTokens: [], + ); } - _log.info( 'Sending Firebase bulk notification to ${deviceTokens.length} devices ' 'for project "$projectId".', @@ -57,6 +59,9 @@ class FirebasePushNotificationClient implements IPushNotificationClient { // The FCM v1 batch API has a limit of 500 messages per request. // We must chunk the tokens into batches of this size. const batchSize = 500; + final allSentTokens = []; + final allFailedTokens = []; + for (var i = 0; i < deviceTokens.length; i += batchSize) { final batch = deviceTokens.sublist( i, @@ -66,22 +71,29 @@ class FirebasePushNotificationClient implements IPushNotificationClient { ); // Send each chunk as a separate batch request. - await _sendBatch( + final batchResult = await _sendBatch( batchNumber: (i ~/ batchSize) + 1, totalBatches: (deviceTokens.length / batchSize).ceil(), deviceTokens: batch, payload: payload, ); + + allSentTokens.addAll(batchResult.sentTokens); + allFailedTokens.addAll(batchResult.failedTokens); } + + return PushNotificationResult( + sentTokens: allSentTokens, + failedTokens: allFailedTokens, + ); } /// Sends a batch of notifications by dispatching individual requests in /// parallel. /// - /// This approach is simpler and more robust than using the `batch` endpoint, - /// as it avoids the complexity of constructing a multipart request body and - /// provides clearer error handling for individual message failures. - Future _sendBatch({ + /// This method processes the results to distinguish between successful and + /// failed sends, returning a [PushNotificationResult]. + Future _sendBatch({ required int batchNumber, required int totalBatches, required List deviceTokens, @@ -114,63 +126,55 @@ class FirebasePushNotificationClient implements IPushNotificationClient { return _httpClient.post(url, data: requestBody); }).toList(); - try { - // `eagerError: false` ensures that all futures complete, even if some - // fail. The results list will contain Exception objects for failures. - final results = await Future.wait( - sendFutures, - eagerError: false, - ); + // `eagerError: false` ensures that all futures complete, even if some + // fail. The results list will contain Exception objects for failures. + final results = await Future.wait( + sendFutures, + eagerError: false, + ); - final failedResults = results.whereType().toList(); + final sentTokens = []; + final failedTokens = []; - if (failedResults.isEmpty) { - _log.info( - 'Successfully sent Firebase batch of ${deviceTokens.length} ' - 'notifications for project "$projectId".', - ); - } else { - _log.warning( - 'Batch $batchNumber/$totalBatches: ' - '${failedResults.length} of ${deviceTokens.length} Firebase ' - 'notifications failed to send for project "$projectId".', - ); - for (final error in failedResults) { - if (error is HttpException) { - // Downgrade log level for invalid tokens (NotFoundException), which - // is an expected occurrence. Other HTTP errors are still severe. - if (error is NotFoundException) { - _log.info( - 'Batch $batchNumber/$totalBatches: Failed to send to an ' - 'invalid/unregistered token: ${error.message}', - ); - } else { - _log.severe( - 'Batch $batchNumber/$totalBatches: HTTP error sending ' - 'Firebase notification: ${error.message}', - error, - ); - } - } else { - _log.severe( - 'Unexpected error sending Firebase notification.', - error, - ); - } + for (var i = 0; i < results.length; i++) { + final result = results[i]; + final token = deviceTokens[i]; + + if (result is Exception) { + if (result is NotFoundException) { + // This is the only case where we treat the token as permanently + // invalid and mark it for cleanup. + failedTokens.add(token); + _log.info( + 'Batch $batchNumber/$totalBatches: Failed to send to an ' + 'invalid/unregistered token: ${result.message}', + ); + } else if (result is HttpException) { + // For other HTTP errors (e.g., 500), we log it as severe but do + // not mark the token for deletion as the error may be transient. + _log.severe( + 'Batch $batchNumber/$totalBatches: HTTP error sending ' + 'Firebase notification to token "$token": ${result.message}', + result, + ); + } else { + // For any other unexpected exception. + _log.severe( + 'Unexpected error sending Firebase notification to token "$token".', + result, + ); } - // Throw an exception to indicate that the batch send was not fully successful. - throw OperationFailedException( - 'Failed to send ${failedResults.length} Firebase notifications.', - ); + } else { + // If there's no exception, the send was successful. + sentTokens.add(token); } - } catch (e, s) { - _log.severe( - 'Unexpected error processing Firebase batch $batchNumber/$totalBatches ' - 'results.', - e, - s, - ); - throw OperationFailedException('Failed to process Firebase batch: $e'); } + _log.info( + 'Firebase batch $batchNumber/$totalBatches complete. Success: ${sentTokens.length}, Failed: ${failedTokens.length}.', + ); + return PushNotificationResult( + sentTokens: sentTokens, + failedTokens: failedTokens, + ); } } diff --git a/lib/src/services/onesignal_push_notification_client.dart b/lib/src/services/onesignal_push_notification_client.dart index 6265eeb..47f3251 100644 --- a/lib/src/services/onesignal_push_notification_client.dart +++ b/lib/src/services/onesignal_push_notification_client.dart @@ -27,25 +27,25 @@ class OneSignalPushNotificationClient implements IPushNotificationClient { final Logger _log; @override - Future sendNotification({ + Future sendNotification({ required String deviceToken, required PushNotificationPayload payload, - }) async { + }) { // For consistency, delegate to the bulk sending method with a single token. - await sendBulkNotifications( + return sendBulkNotifications( deviceTokens: [deviceToken], payload: payload, ); } @override - Future sendBulkNotifications({ + Future sendBulkNotifications({ required List deviceTokens, required PushNotificationPayload payload, }) async { if (deviceTokens.isEmpty) { _log.info('No device tokens provided for OneSignal bulk send. Aborting.'); - return; + return const PushNotificationResult(); } _log.info( @@ -55,6 +55,9 @@ class OneSignalPushNotificationClient implements IPushNotificationClient { // OneSignal has a limit of 2000 player_ids per API request. const batchSize = 2000; + final allSentTokens = []; + final allFailedTokens = []; + for (var i = 0; i < deviceTokens.length; i += batchSize) { final batch = deviceTokens.sublist( i, @@ -63,15 +66,26 @@ class OneSignalPushNotificationClient implements IPushNotificationClient { : i + batchSize, ); - await _sendBatch( + final batchResult = await _sendBatch( deviceTokens: batch, payload: payload, ); + + allSentTokens.addAll(batchResult.sentTokens); + allFailedTokens.addAll(batchResult.failedTokens); } + + return PushNotificationResult( + sentTokens: allSentTokens, + failedTokens: allFailedTokens, + ); } /// Sends a single batch of notifications to the OneSignal API. - Future _sendBatch({ + /// + /// This method processes the API response to distinguish between successful + /// and failed sends, returning a [PushNotificationResult]. + Future _sendBatch({ required List deviceTokens, required PushNotificationPayload payload, }) async { @@ -95,17 +109,49 @@ class OneSignalPushNotificationClient implements IPushNotificationClient { ); try { - await _httpClient.post(url, data: requestBody); + // The OneSignal API returns a JSON object with details about the send, + // including errors for invalid player IDs. + final response = await _httpClient.post>( + url, + data: requestBody, + ); + + final sentTokens = {...deviceTokens}; + final failedTokens = {}; + + // Check for errors in the response body. + if (response.containsKey('errors') && response['errors'] != null) { + final errors = response['errors']; + if (errors is Map && errors.containsKey('invalid_player_ids')) { + final invalidIds = List.from( + errors['invalid_player_ids'] as List, + ); + if (invalidIds.isNotEmpty) { + _log.info( + 'OneSignal reported ${invalidIds.length} invalid player IDs. ' + 'These will be marked as failed.', + ); + failedTokens.addAll(invalidIds); + sentTokens.removeAll(invalidIds); + } + } + } + _log.info( - 'Successfully sent OneSignal batch of ${deviceTokens.length} ' - 'notifications for app ID "$appId".', + 'OneSignal batch complete. Success: ${sentTokens.length}, ' + 'Failed: ${failedTokens.length}.', + ); + return PushNotificationResult( + sentTokens: sentTokens.toList(), + failedTokens: failedTokens.toList(), ); } on HttpException catch (e) { _log.severe( 'HTTP error sending OneSignal batch notification: ${e.message}', e, ); - rethrow; + // If the entire request fails, all tokens in this batch are considered failed. + return PushNotificationResult(failedTokens: deviceTokens); } catch (e, s) { _log.severe( 'Unexpected error sending OneSignal batch notification.', diff --git a/lib/src/services/push_notification_client.dart b/lib/src/services/push_notification_client.dart index 7277684..19d3173 100644 --- a/lib/src/services/push_notification_client.dart +++ b/lib/src/services/push_notification_client.dart @@ -1,4 +1,32 @@ import 'package:core/core.dart'; +import 'package:equatable/equatable.dart'; +import 'package:meta/meta.dart'; + +/// {@template push_notification_result} +/// Encapsulates the result of a bulk push notification send operation. +/// +/// This class provides structured feedback on which notifications were sent +/// successfully and which ones failed, including the specific device tokens +/// for each category. This is crucial for implementing self-healing mechanisms, +/// such as cleaning up invalid or unregistered device tokens from the database. +/// {@endtemplate} +@immutable +class PushNotificationResult extends Equatable { + /// {@macro push_notification_result} + const PushNotificationResult({ + this.sentTokens = const [], + this.failedTokens = const [], + }); + + /// A list of device tokens to which the notification was successfully sent. + final List sentTokens; + + /// A list of device tokens to which the notification failed to be sent. + final List failedTokens; + + @override + List get props => [sentTokens, failedTokens]; +} /// An abstract interface for push notification clients. /// @@ -9,7 +37,7 @@ abstract class IPushNotificationClient { /// /// [deviceToken]: The unique token identifying the target device. /// [payload]: The data payload to be sent with the notification. - Future sendNotification({ + Future sendNotification({ required String deviceToken, required PushNotificationPayload payload, }); @@ -21,7 +49,7 @@ abstract class IPushNotificationClient { /// /// [deviceTokens]: A list of unique tokens identifying the target devices. /// [payload]: The data payload to be sent with the notification. - Future sendBulkNotifications({ + Future sendBulkNotifications({ required List deviceTokens, required PushNotificationPayload payload, }); diff --git a/lib/src/services/push_notification_service.dart b/lib/src/services/push_notification_service.dart index 85bf059..cb1fe65 100644 --- a/lib/src/services/push_notification_service.dart +++ b/lib/src/services/push_notification_service.dart @@ -187,6 +187,18 @@ class DefaultPushNotificationService implements IPushNotificationService { 'Found ${targetedDevices.length} devices to target via $primaryProvider.', ); + // 6. Group device tokens by user ID for efficient lookup. + // This avoids iterating through all devices for each user. + final userDeviceTokensMap = >{}; + for (final device in targetedDevices) { + final token = device.providerTokens[primaryProvider]; + if (token != null) { + // The `putIfAbsent` method provides a concise way to ensure the list + // exists before adding the token to it. + userDeviceTokensMap.putIfAbsent(device.userId, () => []).add(token); + } + } + // 7. Iterate through each subscribed user to create and send a // personalized notification. final sendFutures = >[]; @@ -214,19 +226,29 @@ class DefaultPushNotificationService implements IPushNotificationService { try { await _inAppNotificationRepository.create(item: notification); - // Find all device tokens for the current user. - final userDeviceTokens = targetedDevices - .where((d) => d.userId == userId) - .map((d) => d.providerTokens[primaryProvider]!) - .toList(); + // Efficiently retrieve the tokens for the current user from the map. + final userDeviceTokens = userDeviceTokensMap[userId] ?? []; if (userDeviceTokens.isNotEmpty) { // Add the send operation to the list of futures. + // The result of this future will contain information about which + // tokens succeeded and which failed. sendFutures.add( - client.sendBulkNotifications( - deviceTokens: userDeviceTokens, - payload: notification.payload, - ), + client + .sendBulkNotifications( + deviceTokens: userDeviceTokens, + payload: notification.payload, + ) + .then((result) { + // After the send completes, trigger the cleanup process for + // any failed tokens. This is a fire-and-forget operation. + unawaited( + _cleanupInvalidDevices( + result.failedTokens, + primaryProvider, + ), + ); + }), ); } } catch (e, s) { @@ -255,4 +277,47 @@ class DefaultPushNotificationService implements IPushNotificationService { ); } } + + /// Deletes device registrations associated with a list of invalid tokens. + /// + /// This method is called after a push notification send operation to prune + /// the database of tokens that the provider has identified as unregistered + /// or invalid (e.g., because the app was uninstalled). + /// + /// - [invalidTokens]: A list of device tokens that failed to be delivered. + /// - [provider]: The push notification provider that reported the tokens as + /// invalid. + Future _cleanupInvalidDevices( + List invalidTokens, + PushNotificationProvider provider, + ) async { + if (invalidTokens.isEmpty) { + return; // Nothing to clean up. + } + + _log.info( + 'Cleaning up ${invalidTokens.length} invalid device tokens for provider "$provider".', + ); + + // Retrieve the list of devices that match the filter criteria. + final devicesToDelete = await _pushNotificationDeviceRepository.readAll( + filter: { + 'providerTokens.${provider.name}': {r'$in': invalidTokens}, + }, + ); + + try { + // Delete the devices in parallel for better performance. + final deleteFutures = devicesToDelete.items.map( + (device) => _pushNotificationDeviceRepository.delete(id: device.id), + ); + await Future.wait(deleteFutures); + + _log.info('Successfully cleaned up invalid device tokens.'); + } catch (e, s) { + _log.severe('Failed to clean up invalid device tokens.', e, s); + // We log the error but do not rethrow, as this is a background + // cleanup task and should not crash the main application flow. + } + } }