diff --git a/.env.example b/.env.example index a1a8dd8..7249b9f 100644 --- a/.env.example +++ b/.env.example @@ -56,3 +56,29 @@ # OPTIONAL: The cache duration for the CountryQueryService, in minutes. # Defaults to 15 minutes if not specified. # COUNTRY_SERVICE_CACHE_MINUTES=15 + +# CONDITIONALLY REQUIRED: The Firebase Project ID for push notifications. +# The server will start without this, but it is required to use Firebase for notifications. +# This is part of your Firebase service account credentials. +# FIREBASE_PROJECT_ID="your-firebase-project-id" + +# CONDITIONALLY REQUIRED: The Firebase Client Email for push notifications. +# The server will start without this, but it is required to use Firebase for notifications. +# This is part of your Firebase service account credentials. +# FIREBASE_CLIENT_EMAIL="your-firebase-client-email" + +# CONDITIONALLY REQUIRED: The Firebase Private Key for push notifications. +# The server will start without this, but it is required to use Firebase for notifications. +# This is part of your Firebase service account credentials. +# Ensure this is stored securely and correctly formatted (e.g., replace newlines with \n if needed for single-line env var). +# FIREBASE_PRIVATE_KEY="your-firebase-private-key" + +# CONDITIONALLY REQUIRED: The OneSignal App ID for push notifications. +# The server will start without this, but it is required to use OneSignal for notifications. +# This identifies your application within OneSignal. +# ONESIGNAL_APP_ID="your-onesignal-app-id" + +# CONDITIONALLY REQUIRED: The OneSignal REST API Key for server-side push notifications. +# The server will start without this, but it is required to use OneSignal for notifications. +# This is used to authenticate with the OneSignal API. +# ONESIGNAL_REST_API_KEY="your-onesignal-rest-api-key" diff --git a/README.md b/README.md index 0844fb9..acd6c29 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,16 @@ The data API is equipped with powerful querying capabilities, enabling rich, hig The API automatically validates the structure of all incoming data, ensuring that every request is well-formed before it's processed. This built-in mechanism catches missing fields, incorrect data types, and invalid enum values at the gateway, providing clear, immediate feedback to the client. > **Your Advantage:** This eliminates an entire class of runtime errors and saves you from writing tedious, repetitive validation code. Your data models remain consistent and your API stays resilient against malformed requests. +--- + +### 📲 Dynamic & Personalized Notifications +A complete, multi-provider notification engine that empowers you to engage users with timely, relevant, and personalized alerts. +- **Editorial-Driven Alerts:** Any piece of content can be designated as "breaking news" from the content dashboard, triggering immediate, high-priority alerts to subscribed users. +- **User-Crafted Notification Streams:** Users can create and save persistent notification subscriptions based on any combination of content filters (such as topics, sources, or regions), allowing them to receive 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. +> **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/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index 20ac872..fe4ca89 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -1,6 +1,7 @@ // ignore_for_file: public_member_api_docs import 'dart:async'; + import 'package:core/core.dart'; import 'package:data_mongodb/data_mongodb.dart'; import 'package:data_repository/data_repository.dart'; @@ -16,10 +17,15 @@ import 'package:flutter_news_app_api_server_full_source_code/src/services/dashbo import 'package:flutter_news_app_api_server_full_source_code/src/services/database_migration_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/database_seeding_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/default_user_preference_limit_service.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/services/firebase_authenticator.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/services/firebase_push_notification_client.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/jwt_auth_token_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/mongodb_rate_limit_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/mongodb_token_blacklist_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/mongodb_verification_code_storage_service.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/services/onesignal_push_notification_client.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/services/push_notification_client.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/services/push_notification_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/rate_limit_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/token_blacklist_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/user_preference_limit_service.dart'; @@ -62,6 +68,10 @@ class AppDependencies { late final DataRepository userAppSettingsRepository; late final DataRepository userContentPreferencesRepository; + late final DataRepository + pushNotificationDeviceRepository; + late final DataRepository + pushNotificationSubscriptionRepository; late final DataRepository remoteConfigRepository; late final EmailRepository emailRepository; @@ -76,6 +86,10 @@ class AppDependencies { late final UserPreferenceLimitService userPreferenceLimitService; late final RateLimitService rateLimitService; late final CountryQueryService countryQueryService; + late final IPushNotificationService pushNotificationService; + late final IFirebaseAuthenticator? firebaseAuthenticator; + late final IPushNotificationClient? firebasePushNotificationClient; + late final IPushNotificationClient? oneSignalPushNotificationClient; /// Initializes all application dependencies. /// @@ -198,6 +212,91 @@ class AppDependencies { logger: Logger('DataMongodb'), ); + // Initialize Data Clients for Push Notifications + final pushNotificationDeviceClient = DataMongodb( + connectionManager: _mongoDbConnectionManager, + modelName: 'push_notification_devices', + fromJson: PushNotificationDevice.fromJson, + toJson: (item) => item.toJson(), + logger: Logger('DataMongodb'), + ); + final pushNotificationSubscriptionClient = + DataMongodb( + connectionManager: _mongoDbConnectionManager, + modelName: 'push_notification_subscriptions', + fromJson: PushNotificationSubscription.fromJson, + toJson: (item) => item.toJson(), + logger: Logger('DataMongodb'), + ); + + // --- Conditionally Initialize Push Notification Clients --- + + // Firebase + final fcmProjectId = EnvironmentConfig.firebaseProjectId; + final fcmClientEmail = EnvironmentConfig.firebaseClientEmail; + final fcmPrivateKey = EnvironmentConfig.firebasePrivateKey; + + if (fcmProjectId != null && + fcmClientEmail != null && + fcmPrivateKey != null) { + _log.info('Firebase credentials found. Initializing Firebase client.'); + firebaseAuthenticator = FirebaseAuthenticator( + log: Logger('FirebaseAuthenticator'), + ); + + final firebaseHttpClient = HttpClient( + baseUrl: 'https://fcm.googleapis.com/v1/projects/$fcmProjectId/', + tokenProvider: firebaseAuthenticator!.getAccessToken, + logger: Logger('FirebasePushNotificationClient'), + ); + + firebasePushNotificationClient = FirebasePushNotificationClient( + httpClient: firebaseHttpClient, + projectId: fcmProjectId, + log: Logger('FirebasePushNotificationClient'), + ); + } else { + _log.warning( + 'One or more Firebase credentials not found. Firebase push notifications will be disabled.', + ); + firebaseAuthenticator = null; + firebasePushNotificationClient = null; + } + + // OneSignal + final osAppId = EnvironmentConfig.oneSignalAppId; + final osApiKey = EnvironmentConfig.oneSignalRestApiKey; + + if (osAppId != null && osApiKey != null) { + _log.info( + 'OneSignal credentials found. Initializing OneSignal client.', + ); + final oneSignalHttpClient = HttpClient( + baseUrl: 'https://onesignal.com/api/v1/', + tokenProvider: () async => null, + interceptors: [ + InterceptorsWrapper( + onRequest: (options, handler) { + options.headers['Authorization'] = 'Basic $osApiKey'; + return handler.next(options); + }, + ), + ], + logger: Logger('OneSignalPushNotificationClient'), + ); + + oneSignalPushNotificationClient = OneSignalPushNotificationClient( + httpClient: oneSignalHttpClient, + appId: osAppId, + log: Logger('OneSignalPushNotificationClient'), + ); + } else { + _log.warning( + 'One or more OneSignal credentials not found. OneSignal push notifications will be disabled.', + ); + oneSignalPushNotificationClient = null; + } + // 4. Initialize Repositories headlineRepository = DataRepository(dataClient: headlineClient); topicRepository = DataRepository(dataClient: topicClient); @@ -212,6 +311,12 @@ class AppDependencies { dataClient: userContentPreferencesClient, ); remoteConfigRepository = DataRepository(dataClient: remoteConfigClient); + pushNotificationDeviceRepository = DataRepository( + dataClient: pushNotificationDeviceClient, + ); + pushNotificationSubscriptionRepository = DataRepository( + dataClient: pushNotificationSubscriptionClient, + ); // Configure the HTTP client for SendGrid. // The HttpClient's AuthInterceptor will use the tokenProvider to add // the 'Authorization: Bearer ' header. @@ -275,6 +380,15 @@ class AppDependencies { log: Logger('CountryQueryService'), cacheDuration: EnvironmentConfig.countryServiceCacheDuration, ); + pushNotificationService = DefaultPushNotificationService( + pushNotificationDeviceRepository: pushNotificationDeviceRepository, + pushNotificationSubscriptionRepository: + pushNotificationSubscriptionRepository, + remoteConfigRepository: remoteConfigRepository, + firebaseClient: firebasePushNotificationClient, + oneSignalClient: oneSignalPushNotificationClient, + log: Logger('DefaultPushNotificationService'), + ); _log.info('Application dependencies initialized successfully.'); // Signal that initialization has completed successfully. diff --git a/lib/src/config/environment_config.dart b/lib/src/config/environment_config.dart index 01cb664..ca56968 100644 --- a/lib/src/config/environment_config.dart +++ b/lib/src/config/environment_config.dart @@ -60,12 +60,13 @@ abstract final class EnvironmentConfig { return env; // Return even if fallback } - static String _getRequiredEnv(String key) { + static String? _getEnv(String key) { final value = _env[key]; if (value == null || value.isEmpty) { - _log.severe('$key not found in environment variables.'); - throw StateError('FATAL: $key environment variable is not set.'); + _log.warning('$key not found or is empty in environment variables.'); + return null; } + return value; } @@ -75,7 +76,18 @@ abstract final class EnvironmentConfig { /// /// Throws a [StateError] if the `DATABASE_URL` environment variable is not /// set, as the application cannot function without it. - static String get databaseUrl => _getRequiredEnv('DATABASE_URL'); + static String get databaseUrl => _getRequiredEnv( + 'DATABASE_URL', + ); + + static String _getRequiredEnv(String key) { + final value = _env[key]; + if (value == null || value.isEmpty) { + _log.severe('$key not found in environment variables.'); + throw StateError('FATAL: $key environment variable is not set.'); + } + return value; + } /// Retrieves the JWT secret key from the environment. /// @@ -182,4 +194,29 @@ abstract final class EnvironmentConfig { int.tryParse(_env['COUNTRY_SERVICE_CACHE_MINUTES'] ?? '15') ?? 15; return Duration(minutes: minutes); } + + /// Retrieves the Firebase Project ID from the environment. + /// + /// The value is read from the `FIREBASE_PROJECT_ID` environment variable, if available. + static String? get firebaseProjectId => _getEnv('FIREBASE_PROJECT_ID'); + + /// Retrieves the Firebase Client Email from the environment. + /// + /// The value is read from the `FIREBASE_CLIENT_EMAIL` environment variable, if available. + static String? get firebaseClientEmail => _getEnv('FIREBASE_CLIENT_EMAIL'); + + /// Retrieves the Firebase Private Key from the environment. + /// + /// The value is read from the `FIREBASE_PRIVATE_KEY` environment variable, if available. + static String? get firebasePrivateKey => _getEnv('FIREBASE_PRIVATE_KEY'); + + /// Retrieves the OneSignal App ID from the environment. + /// + /// The value is read from the `ONESIGNAL_APP_ID` environment variable, if available. + static String? get oneSignalAppId => _getEnv('ONESIGNAL_APP_ID'); + + /// Retrieves the OneSignal REST API Key from the environment. + /// + /// The value is read from the `ONESIGNAL_REST_API_KEY` environment variable, if available. + static String? get oneSignalRestApiKey => _getEnv('ONESIGNAL_REST_API_KEY'); } diff --git a/lib/src/database/migrations/20251107000000_add_is_breaking_to_headlines.dart b/lib/src/database/migrations/20251107000000_add_is_breaking_to_headlines.dart new file mode 100644 index 0000000..01abe0f --- /dev/null +++ b/lib/src/database/migrations/20251107000000_add_is_breaking_to_headlines.dart @@ -0,0 +1,51 @@ +import 'package:flutter_news_app_api_server_full_source_code/src/database/migration.dart'; +import 'package:logging/logging.dart'; +import 'package:mongo_dart/mongo_dart.dart'; + +/// Migration to add the `isBreaking` field to existing `Headline` documents. +/// +/// This migration ensures that all existing documents in the `headlines` +/// collection have the `isBreaking` boolean field, defaulting to `false` +/// if it does not already exist. This is crucial for schema consistency +/// when introducing the breaking news feature. +class AddIsBreakingToHeadlines extends Migration { + /// {@macro add_is_breaking_to_headlines} + AddIsBreakingToHeadlines() + : super( + prDate: '20251107000000', + prId: '71', + prSummary: + 'Adds the isBreaking field to existing Headline documents, ' + 'defaulting to false.', + ); + + @override + Future up(Db db, Logger log) async { + final collection = db.collection('headlines'); + + log.info( + 'Attempting to add "isBreaking: false" to Headline documents ' + 'where the field is missing...', + ); + + // Update all documents in the 'headlines' collection that do not + // already have the 'isBreaking' field, setting it to false. + final updateResult = await collection.updateMany( + // Select documents where 'isBreaking' does not exist. + where.notExists('isBreaking'), + modify.set('isBreaking', false), // Set isBreaking to false + ); + + log.info( + 'Added "isBreaking: false" to ${updateResult.nModified} Headline documents.', + ); + } + + @override + Future down(Db db, Logger log) async { + log.warning( + 'Reverting "AddIsBreakingToHeadlines" is not supported. ' + 'The "isBreaking" field would need to be manually removed if required.', + ); + } +} diff --git a/lib/src/database/migrations/20251108103300_add_push_notification_config_to_remote_config.dart b/lib/src/database/migrations/20251108103300_add_push_notification_config_to_remote_config.dart new file mode 100644 index 0000000..6f2d1a3 --- /dev/null +++ b/lib/src/database/migrations/20251108103300_add_push_notification_config_to_remote_config.dart @@ -0,0 +1,62 @@ +import 'package:core/core.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/database/migration.dart'; +import 'package:logging/logging.dart'; +import 'package:mongo_dart/mongo_dart.dart'; + +/// {@template add_push_notification_config_to_remote_config} +/// A database migration to add the `pushNotificationConfig` field to the +/// `remote_configs` document. +/// +/// This migration ensures that the `remote_configs` document contains the +/// necessary structure for push notification settings, preventing errors when +/// the application starts and tries to access this configuration. It is +/// designed to be idempotent and safe to run multiple times. +/// {@endtemplate} +class AddPushNotificationConfigToRemoteConfig extends Migration { + /// {@macro add_push_notification_config_to_remote_config} + AddPushNotificationConfigToRemoteConfig() + : super( + prId: '71', + prSummary: + 'Add pushNotificationConfig field to the remote_configs document.', + prDate: '20251108103300', + ); + + @override + Future up(Db db, Logger log) async { + log.info('Running up migration: $prSummary'); + + final remoteConfigCollection = db.collection('remote_configs'); + final remoteConfigId = ObjectId.fromHexString(kRemoteConfigId); + + // Default structure for the push notification configuration. + final pushNotificationConfig = + remoteConfigsFixturesData.first.pushNotificationConfig; + + // Use $set to add the field only if it doesn't exist. + // This is an idempotent operation. + await remoteConfigCollection.updateOne( + where + .id(remoteConfigId) + .and( + where.notExists('pushNotificationConfig'), + ), + modify.set('pushNotificationConfig', pushNotificationConfig.toJson()), + ); + + log.info('Successfully completed up migration for $prDate.'); + } + + @override + Future down(Db db, Logger log) async { + log.info('Running down migration: $prSummary'); + // This migration is additive. The `down` method will unset the field. + final remoteConfigCollection = db.collection('remote_configs'); + final remoteConfigId = ObjectId.fromHexString(kRemoteConfigId); + await remoteConfigCollection.updateOne( + where.id(remoteConfigId), + modify.unset('pushNotificationConfig'), + ); + log.info('Successfully completed down migration for $prDate.'); + } +} diff --git a/lib/src/database/migrations/all_migrations.dart b/lib/src/database/migrations/all_migrations.dart index 9f5095d..3657272 100644 --- a/lib/src/database/migrations/all_migrations.dart +++ b/lib/src/database/migrations/all_migrations.dart @@ -4,6 +4,8 @@ import 'package:flutter_news_app_api_server_full_source_code/src/database/migrat import 'package:flutter_news_app_api_server_full_source_code/src/database/migrations/20251013000057_add_saved_filters_to_remote_config.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/database/migrations/20251024000000_add_logo_url_to_sources.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/database/migrations/20251103073226_remove_local_ad_platform.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/database/migrations/20251107000000_add_is_breaking_to_headlines.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/database/migrations/20251108103300_add_push_notification_config_to_remote_config.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/database_migration_service.dart' show DatabaseMigrationService; @@ -18,4 +20,6 @@ final List allMigrations = [ AddSavedFiltersToRemoteConfig(), AddLogoUrlToSources(), RemoveLocalAdPlatform(), + AddIsBreakingToHeadlines(), + AddPushNotificationConfigToRemoteConfig(), ]; diff --git a/lib/src/rbac/permissions.dart b/lib/src/rbac/permissions.dart index 0f66f9f..1f7ff09 100644 --- a/lib/src/rbac/permissions.dart +++ b/lib/src/rbac/permissions.dart @@ -81,4 +81,18 @@ abstract class Permissions { // General System Permissions static const String rateLimitingBypass = 'rate_limiting.bypass'; + + // Push Notification Permissions + /// Allows sending breaking news push notifications. + /// + /// This permission is typically granted to dashboard roles like + /// 'publisher' or 'admin'. + static const String pushNotificationSendBreakingNews = + 'push_notification.send_breaking_news'; + + // Push Notification Device Permissions (User-owned) + static const String pushNotificationDeviceCreateOwned = + 'push_notification_device.create_owned'; + static const String pushNotificationDeviceDeleteOwned = + 'push_notification_device.delete_owned'; } diff --git a/lib/src/rbac/role_permissions.dart b/lib/src/rbac/role_permissions.dart index 303f069..07fce02 100644 --- a/lib/src/rbac/role_permissions.dart +++ b/lib/src/rbac/role_permissions.dart @@ -20,6 +20,11 @@ final Set _appGuestUserPermissions = { // dismisses an in-feed prompt, etc). The endpoint handler ensures only // non-sensitive fields can be modified. Permissions.userUpdateOwned, + + // Allow all app users to register and unregister their devices for push + // notifications. + Permissions.pushNotificationDeviceCreateOwned, + Permissions.pushNotificationDeviceDeleteOwned, }; final Set _appStandardUserPermissions = { @@ -52,6 +57,9 @@ final Set _dashboardPublisherPermissions = { // Core dashboard access and quality-of-life permissions. Permissions.dashboardLogin, Permissions.rateLimitingBypass, + + // Publishers can send breaking news notifications. + Permissions.pushNotificationSendBreakingNews, }; final Set _dashboardAdminPermissions = { diff --git a/lib/src/registry/data_operation_registry.dart b/lib/src/registry/data_operation_registry.dart index c15c686..61bfbbb 100644 --- a/lib/src/registry/data_operation_registry.dart +++ b/lib/src/registry/data_operation_registry.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:core/core.dart'; import 'package:dart_frog/dart_frog.dart'; import 'package:data_repository/data_repository.dart'; @@ -5,6 +7,7 @@ import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/own import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permission_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/country_query_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/dashboard_summary_service.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/services/push_notification_service.dart'; import 'package:logging/logging.dart'; // --- Typedefs for Data Operations --- @@ -170,10 +173,39 @@ class DataOperationRegistry { // --- Register Item Creators --- _itemCreators.addAll({ - 'headline': (c, item, uid) => c.read>().create( - item: item as Headline, - userId: uid, - ), + 'headline': (c, item, uid) async { + final createdHeadline = await c.read>().create( + item: item as Headline, + userId: uid, + ); + + // If the created headline is marked as breaking news, trigger the + // push notification service. The service itself contains all the + // logic for fetching subscribers and sending notifications. + // + // CRITICAL: This is a "fire-and-forget" operation. We do NOT `await` + // the result. The API response for creating the headline should return + // immediately, while the notification service runs in the background. + // The service itself is responsible for its own internal error logging. + // We wrap this in a try-catch to prevent any unexpected synchronous + // error from crashing the headline creation process. + if (createdHeadline.isBreaking) { + try { + final pushNotificationService = c.read(); + unawaited( + pushNotificationService.sendBreakingNewsNotification( + headline: createdHeadline, + ), + ); + _log.info( + 'Successfully dispatched breaking news notification for headline: ${createdHeadline.id}', + ); + } catch (e, s) { + _log.severe('Failed to send breaking news notification: $e', e, s); + } + } + return createdHeadline; + }, 'topic': (c, item, uid) => c.read>().create( item: item as Topic, userId: uid, @@ -193,6 +225,38 @@ class DataOperationRegistry { 'remote_config': (c, item, uid) => c .read>() .create(item: item as RemoteConfig, userId: uid), + 'push_notification_device': (context, item, uid) async { + _log.info('Executing custom creator for push_notification_device.'); + final authenticatedUser = context.read(); + final deviceToCreate = item as PushNotificationDevice; + + // Security Check: Ensure the userId in the payload matches the + // authenticated user's ID. This prevents a user from registering a + // device on behalf of another user. + if (deviceToCreate.userId != authenticatedUser.id) { + _log.warning( + 'Forbidden attempt by user ${authenticatedUser.id} to create a ' + 'device for user ${deviceToCreate.userId}.', + ); + throw const ForbiddenException( + 'You can only register devices for your own account.', + ); + } + + _log.info( + 'User ${authenticatedUser.id} is registering a new device. ' + 'Validation passed.', + ); + + // The validation passed, so we can now safely call the repository. + // The `uid` (userIdForRepoCall) is passed as null because for + // user-owned resources, the scoping is handled by the creator logic + // itself, not a generic filter in the repository. + return context.read>().create( + item: deviceToCreate, + userId: null, + ); + }, }); // --- Register Item Updaters --- @@ -333,6 +397,9 @@ class DataOperationRegistry { .delete(id: id, userId: uid), 'remote_config': (c, id, uid) => c.read>().delete(id: id, userId: uid), + 'push_notification_device': (c, id, uid) => c + .read>() + .delete(id: id, userId: uid), }); } } diff --git a/lib/src/registry/model_registry.dart b/lib/src/registry/model_registry.dart index fa28e4f..0f44523 100644 --- a/lib/src/registry/model_registry.dart +++ b/lib/src/registry/model_registry.dart @@ -425,6 +425,44 @@ final modelRegistry = >{ requiresAuthentication: true, ), ), + 'push_notification_device': ModelConfig( + fromJson: PushNotificationDevice.fromJson, + getId: (d) => d.id, + getOwnerId: (dynamic item) => (item as PushNotificationDevice).userId, + // Collection GET is not supported for this model as there is no use case + // for a client to list all device registrations. + getCollectionPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + ), + // Item GET is not supported for this model. A client registers a device + // and then forgets about it until it needs to be deleted. + getItemPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + ), + // POST is allowed for any authenticated user to register their own device. + // A custom check within the DataOperationRegistry's creator function will + // ensure the `userId` in the request body matches the authenticated user. + postPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.pushNotificationDeviceCreateOwned, + // Ownership check is on the *new* item's payload, which is handled + // by the creator function, not the standard ownership middleware. + requiresOwnershipCheck: false, + ), + // PUT is not supported. To update a token, the client should delete the + // old device registration and create a new one. + putPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + ), + // DELETE is allowed for any authenticated user to delete their own device + // registration (e.g., on sign-out). The ownership check middleware will + // verify the user owns the device record before allowing deletion. + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.pushNotificationDeviceDeleteOwned, + requiresOwnershipCheck: true, + ), + ), }; /// Type alias for the ModelRegistry map for easier provider usage. diff --git a/lib/src/services/auth_service.dart b/lib/src/services/auth_service.dart index 8e1280c..5f56b7b 100644 --- a/lib/src/services/auth_service.dart +++ b/lib/src/services/auth_service.dart @@ -565,6 +565,7 @@ class AuthService { followedTopics: const [], savedHeadlines: const [], savedFilters: const [], + notificationSubscriptions: const [], ); await _userContentPreferencesRepository.create( item: defaultUserPreferences, diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 44d101b..e6d728d 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -226,6 +226,47 @@ class DatabaseSeedingService { ], }); + // Indexes for the push notification devices collection + await _db.runCommand({ + 'createIndexes': 'push_notification_devices', + 'indexes': [ + { + // Ensures no two devices can have the same Firebase token. + // The index is sparse, so it only applies to documents that + // actually have a 'providerTokens.firebase' field. + 'key': {'providerTokens.firebase': 1}, + 'name': 'firebase_token_unique_sparse_index', + 'unique': true, + 'sparse': true, + }, + { + // Ensures no two devices can have the same OneSignal token. + // The index is sparse, so it only applies to documents that + // actually have a 'providerTokens.oneSignal' field. + 'key': {'providerTokens.oneSignal': 1}, + 'name': 'oneSignal_token_unique_sparse_index', + 'unique': true, + 'sparse': true, + }, + ], + }); + _log.info('Ensured indexes for "push_notification_devices".'); + + // Indexes for the push notification subscriptions collection + await _db.runCommand({ + 'createIndexes': 'push_notification_subscriptions', + 'indexes': [ + { + // This index optimizes queries that fetch subscriptions for a + // specific user, which is a common operation when sending + // notifications or managing user preferences. + 'key': {'userId': 1}, + 'name': 'userId_index', + }, + ], + }); + _log.info('Ensured indexes for "push_notification_subscriptions".'); + _log.info('Database indexes are set up correctly.'); } on Exception catch (e, s) { _log.severe('Failed to create database indexes.', e, s); @@ -349,6 +390,7 @@ class DatabaseSeedingService { followedTopics: const [], savedHeadlines: const [], savedFilters: const [], + notificationSubscriptions: const [], ); await _db.collection('user_content_preferences').insertOne({ '_id': userId, diff --git a/lib/src/services/default_user_preference_limit_service.dart b/lib/src/services/default_user_preference_limit_service.dart index b299cb5..dbf404b 100644 --- a/lib/src/services/default_user_preference_limit_service.dart +++ b/lib/src/services/default_user_preference_limit_service.dart @@ -56,27 +56,25 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { switch (user.appRole) { case AppUserRole.premiumUser: accountType = 'premium'; - if (isFollowedItem) { - limit = limits.premiumFollowedItemsLimit; - } else if (itemType == 'headline') { - limit = limits.premiumSavedHeadlinesLimit; - } else { - limit = limits.premiumSavedFiltersLimit; - } + limit = isFollowedItem + ? limits.premiumFollowedItemsLimit + : (itemType == 'headline') + ? limits.premiumSavedHeadlinesLimit + : limits.premiumSavedFiltersLimit; case AppUserRole.standardUser: accountType = 'standard'; - if (isFollowedItem) { - limit = limits.authenticatedFollowedItemsLimit; - } else if (itemType == 'headline') { - limit = limits.authenticatedSavedHeadlinesLimit; - } else { - limit = limits.authenticatedSavedFiltersLimit; - } + limit = isFollowedItem + ? limits.authenticatedFollowedItemsLimit + : (itemType == 'headline') + ? limits.authenticatedSavedHeadlinesLimit + : limits.authenticatedSavedFiltersLimit; case AppUserRole.guestUser: accountType = 'guest'; - limit = (itemType == 'headline') + limit = isFollowedItem + ? limits.guestFollowedItemsLimit + : (itemType == 'headline') ? limits.guestSavedHeadlinesLimit - : limits.guestFollowedItemsLimit; + : limits.guestSavedFiltersLimit; } // 3. Check if adding the item would exceed the limit @@ -175,6 +173,48 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { 'for your account type ($accountType).', ); } + + // 4. Check notification subscription limits (per delivery type). + _log.info( + 'Checking notification subscription limits for user ${user.id}...', + ); + final pushConfig = remoteConfig.pushNotificationConfig; + + // Iterate through each possible delivery type defined in the enum. + for (final deliveryType + in PushNotificationSubscriptionDeliveryType.values) { + // Get the specific limit for this delivery type and user role. + final limit = + pushConfig + .deliveryConfigs[deliveryType] + ?.visibleTo[user.appRole] + ?.subscriptionLimit ?? + 0; + + // Count how many of the user's current subscriptions include this + // specific delivery type. + final count = updatedPreferences.notificationSubscriptions + .where((sub) => sub.deliveryTypes.contains(deliveryType)) + .length; + + _log.finer( + 'User ${user.id} has $count subscriptions of type ' + '${deliveryType.name} (limit: $limit).', + ); + + // If the count for this specific type exceeds its limit, throw. + if (count > limit) { + throw ForbiddenException( + 'You have reached the maximum number of subscriptions for ' + '${deliveryType.name} notifications allowed for your account ' + 'type ($accountType).', + ); + } + } + + _log.info( + 'All user preference limits for user ${user.id} are within range.', + ); } on HttpException { // Propagate known exceptions from repositories rethrow; diff --git a/lib/src/services/firebase_authenticator.dart b/lib/src/services/firebase_authenticator.dart new file mode 100644 index 0000000..cd10e08 --- /dev/null +++ b/lib/src/services/firebase_authenticator.dart @@ -0,0 +1,78 @@ +import 'package:core/core.dart'; +import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/config/environment_config.dart'; +import 'package:http_client/http_client.dart'; +import 'package:logging/logging.dart'; + +/// An abstract interface for a service that provides Firebase access tokens. +abstract class IFirebaseAuthenticator { + /// Retrieves a short-lived OAuth2 access token for Firebase. + Future getAccessToken(); +} + +/// A concrete implementation of [IFirebaseAuthenticator] that uses a +/// two-legged OAuth flow to obtain an access token from Google. +class FirebaseAuthenticator implements IFirebaseAuthenticator { + /// Creates an instance of [FirebaseAuthenticator]. + FirebaseAuthenticator({required Logger log}) : _log = log { + // This internal HttpClient is used exclusively for the token exchange. + // It does not have an auth interceptor, which is crucial to prevent + // an infinite loop. + _tokenClient = HttpClient( + baseUrl: 'https://oauth2.googleapis.com', + tokenProvider: () async => null, + ); + } + + final Logger _log; + late final HttpClient _tokenClient; + + @override + Future getAccessToken() async { + _log.info('Requesting new Firebase access token...'); + try { + // Step 1: Create and sign the JWT. + final pem = EnvironmentConfig.firebasePrivateKey!.replaceAll(r'\n', '\n'); + final privateKey = RSAPrivateKey(pem); + final jwt = JWT( + {'scope': 'https://www.googleapis.com/auth/cloud-platform'}, + issuer: EnvironmentConfig.firebaseClientEmail, + audience: Audience.one('https://oauth2.googleapis.com/token'), + ); + final signedToken = jwt.sign( + privateKey, + algorithm: JWTAlgorithm.RS256, + expiresIn: const Duration(minutes: 5), + ); + _log.finer('Successfully signed JWT for token exchange.'); + + // Step 2: Exchange the JWT for an access token. + final response = await _tokenClient.post>( + '/token', + data: { + 'grant_type': 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'assertion': signedToken, + }, + ); + + final accessToken = response['access_token'] as String?; + if (accessToken == null) { + _log.severe('Google OAuth response did not contain an access_token.'); + throw const OperationFailedException( + 'Could not retrieve Firebase access token.', + ); + } + _log.info('Successfully retrieved new Firebase access token.'); + return accessToken; + } on HttpException { + // Re-throw known HTTP exceptions directly. + rethrow; + } catch (e, s) { + _log.severe('Error during Firebase token exchange: $e', e, s); + // Wrap other errors in a standard exception. + throw OperationFailedException( + 'Failed to authenticate with Firebase: $e', + ); + } + } +} diff --git a/lib/src/services/firebase_push_notification_client.dart b/lib/src/services/firebase_push_notification_client.dart new file mode 100644 index 0000000..5db5dfe --- /dev/null +++ b/lib/src/services/firebase_push_notification_client.dart @@ -0,0 +1,149 @@ +import 'package:core/core.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/services/push_notification_client.dart'; +import 'package:http_client/http_client.dart'; +import 'package:logging/logging.dart'; + +/// A concrete implementation of [IPushNotificationClient] for sending +/// notifications via Firebase Cloud Messaging (FCM). +/// +/// This client constructs and sends the appropriate HTTP request to the +/// FCM v1 API. It relies on an [HttpClient] that must be pre-configured +/// with an `AuthInterceptor` to handle the OAuth2 authentication required +/// by Google APIs. +class FirebasePushNotificationClient implements IPushNotificationClient { + /// Creates an instance of [FirebasePushNotificationClient]. + /// + /// Requires an [HttpClient] to make API requests and a [Logger] for logging. + const FirebasePushNotificationClient({ + required this.projectId, + required HttpClient httpClient, + required Logger log, + }) : _httpClient = httpClient, + _log = log; + + /// The Firebase Project ID for push notifications. + final String projectId; + final HttpClient _httpClient; + final Logger _log; + + @override + 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( + deviceTokens: [deviceToken], + payload: payload, + ); + } + + @override + Future sendBulkNotifications({ + required List deviceTokens, + required PushNotificationPayload payload, + }) async { + if (deviceTokens.isEmpty) { + _log.info('No device tokens provided for Firebase bulk send. Aborting.'); + return; + } + + _log.info( + 'Sending Firebase bulk notification to ${deviceTokens.length} devices ' + 'for project "$projectId".', + ); + + // 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; + for (var i = 0; i < deviceTokens.length; i += batchSize) { + final batch = deviceTokens.sublist( + i, + i + batchSize > deviceTokens.length + ? deviceTokens.length + : i + batchSize, + ); + + // Send each chunk as a separate batch request. + await _sendBatch(deviceTokens: batch, payload: payload); + } + } + + /// 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({ + required List deviceTokens, + required PushNotificationPayload payload, + }) async { + // The base URL is configured in app_dependencies.dart. + // The final URL will be: + // `https://fcm.googleapis.com/v1/projects//messages:send` + const url = 'messages:send'; + + // Create a list of futures, one for each notification to be sent. + final sendFutures = deviceTokens.map((token) { + final requestBody = { + 'message': { + 'token': token, + 'notification': { + 'title': payload.title, + 'body': payload.body, + if (payload.imageUrl != null) 'image': payload.imageUrl, + }, + 'data': payload.data, + }, + }; + + // Return the future from the post request. + 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, + ); + + final failedResults = results.whereType().toList(); + + if (failedResults.isEmpty) { + _log.info( + 'Successfully sent Firebase batch of ${deviceTokens.length} ' + 'notifications for project "$projectId".', + ); + } else { + _log.warning( + '${failedResults.length} out of ${deviceTokens.length} Firebase ' + 'notifications failed to send in batch for project "$projectId".', + ); + for (final error in failedResults) { + if (error is HttpException) { + _log.severe( + 'HTTP error sending Firebase notification: ${error.message}', + error, + ); + } else { + _log.severe( + 'Unexpected error sending Firebase notification.', + error, + ); + } + } + // Throw an exception to indicate that the batch send was not fully successful. + throw OperationFailedException( + 'Failed to send ${failedResults.length} Firebase notifications.', + ); + } + } catch (e, s) { + _log.severe('Unexpected error processing Firebase batch results.', e, s); + throw OperationFailedException('Failed to process Firebase batch: $e'); + } + } +} diff --git a/lib/src/services/onesignal_push_notification_client.dart b/lib/src/services/onesignal_push_notification_client.dart new file mode 100644 index 0000000..6265eeb --- /dev/null +++ b/lib/src/services/onesignal_push_notification_client.dart @@ -0,0 +1,120 @@ +import 'package:core/core.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/services/push_notification_client.dart'; +import 'package:http_client/http_client.dart'; +import 'package:logging/logging.dart'; + +/// A concrete implementation of [IPushNotificationClient] for sending +/// notifications via the OneSignal REST API. +/// +/// This client constructs and sends the appropriate HTTP request to the +/// OneSignal API. It relies on an [HttpClient] that must be pre-configured +/// with an `AuthInterceptor` to handle the `Authorization: Basic ` +/// header required by OneSignal. +class OneSignalPushNotificationClient implements IPushNotificationClient { + /// Creates an instance of [OneSignalPushNotificationClient]. + /// + /// Requires an [HttpClient] to make API requests and a [Logger] for logging. + const OneSignalPushNotificationClient({ + required this.appId, + required HttpClient httpClient, + required Logger log, + }) : _httpClient = httpClient, + _log = log; + + /// The OneSignal App ID for push notifications. + final String appId; + final HttpClient _httpClient; + final Logger _log; + + @override + Future sendNotification({ + required String deviceToken, + required PushNotificationPayload payload, + }) async { + // For consistency, delegate to the bulk sending method with a single token. + await sendBulkNotifications( + deviceTokens: [deviceToken], + payload: payload, + ); + } + + @override + Future sendBulkNotifications({ + required List deviceTokens, + required PushNotificationPayload payload, + }) async { + if (deviceTokens.isEmpty) { + _log.info('No device tokens provided for OneSignal bulk send. Aborting.'); + return; + } + + _log.info( + 'Sending OneSignal bulk notification to ${deviceTokens.length} ' + 'devices for app ID "$appId".', + ); + + // OneSignal has a limit of 2000 player_ids per API request. + const batchSize = 2000; + for (var i = 0; i < deviceTokens.length; i += batchSize) { + final batch = deviceTokens.sublist( + i, + i + batchSize > deviceTokens.length + ? deviceTokens.length + : i + batchSize, + ); + + await _sendBatch( + deviceTokens: batch, + payload: payload, + ); + } + } + + /// Sends a single batch of notifications to the OneSignal API. + Future _sendBatch({ + required List deviceTokens, + required PushNotificationPayload payload, + }) async { + // The REST API key is provided by the HttpClient's tokenProvider and + // injected by its AuthInterceptor. The base URL is also configured in + // app_dependencies.dart. The final URL will be: `https://onesignal.com/api/v1/notifications` + const url = 'notifications'; + + // Construct the OneSignal API request body. + final requestBody = { + 'app_id': appId, + 'include_player_ids': deviceTokens, + 'headings': {'en': payload.title}, + 'contents': {'en': payload.body}, + if (payload.imageUrl != null) 'big_picture': payload.imageUrl, + 'data': payload.data, + }; + + _log.finer( + 'Sending OneSignal batch of ${deviceTokens.length} notifications.', + ); + + try { + await _httpClient.post(url, data: requestBody); + _log.info( + 'Successfully sent OneSignal batch of ${deviceTokens.length} ' + 'notifications for app ID "$appId".', + ); + } on HttpException catch (e) { + _log.severe( + 'HTTP error sending OneSignal batch notification: ${e.message}', + e, + ); + rethrow; + } catch (e, s) { + _log.severe( + 'Unexpected error sending OneSignal batch notification.', + e, + s, + ); + throw OperationFailedException( + 'Failed to send OneSignal batch notification: $e', + ); + } + } +} diff --git a/lib/src/services/push_notification_client.dart b/lib/src/services/push_notification_client.dart new file mode 100644 index 0000000..4762e5d --- /dev/null +++ b/lib/src/services/push_notification_client.dart @@ -0,0 +1,27 @@ +import 'package:core/core.dart'; + +/// An abstract interface for push notification clients./// +/// This interface defines the contract for sending push notifications +/// through different providers (e.g., Firebase Cloud Messaging, OneSignal). +abstract class IPushNotificationClient { + /// Sends a push notification to a specific device. + /// + /// [deviceToken]: The unique token identifying the target device. + /// [payload]: The data payload to be sent with the notification. + Future sendNotification({ + required String deviceToken, + required PushNotificationPayload payload, + }); + + /// Sends a push notification to a batch of devices. + /// + /// This method is more efficient for sending the same notification to + /// multiple recipients, as it can reduce the number of API calls. + /// + /// [deviceTokens]: A list of unique tokens identifying the target devices. + /// [payload]: The data payload to be sent with the notification. + 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 new file mode 100644 index 0000000..b17c2ec --- /dev/null +++ b/lib/src/services/push_notification_service.dart @@ -0,0 +1,227 @@ +import 'dart:async'; + +import 'package:core/core.dart'; +import 'package:data_repository/data_repository.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/services/push_notification_client.dart'; +import 'package:logging/logging.dart'; + +/// An abstract interface for the push notification service. +/// +/// This service orchestrates the process of sending push notifications, +/// abstracting away the details of specific providers and data retrieval. +abstract class IPushNotificationService { + /// Sends a breaking news notification based on the provided [headline]. + /// + /// This method is responsible for identifying relevant user subscriptions, + /// fetching device tokens, and dispatching the notification through the + /// appropriate push notification client. + Future sendBreakingNewsNotification({required Headline headline}); +} + +/// {@template default_push_notification_service} +/// A concrete implementation of [IPushNotificationService] that handles +/// the end-to-end process of sending push notifications. +/// +/// This service integrates with various data repositories to fetch user +/// subscriptions, device registrations, and remote configuration. It then +/// uses specific [IPushNotificationClient] implementations to send the +/// actual notifications. +/// {@endtemplate} +class DefaultPushNotificationService implements IPushNotificationService { + /// {@macro default_push_notification_service} + DefaultPushNotificationService({ + required DataRepository + pushNotificationDeviceRepository, + required DataRepository + pushNotificationSubscriptionRepository, + required DataRepository remoteConfigRepository, + required IPushNotificationClient? firebaseClient, + required IPushNotificationClient? oneSignalClient, + required Logger log, + }) : _pushNotificationDeviceRepository = pushNotificationDeviceRepository, + _pushNotificationSubscriptionRepository = + pushNotificationSubscriptionRepository, + _remoteConfigRepository = remoteConfigRepository, + _firebaseClient = firebaseClient, + _oneSignalClient = oneSignalClient, + _log = log; + + final DataRepository + _pushNotificationDeviceRepository; + final DataRepository + _pushNotificationSubscriptionRepository; + final DataRepository _remoteConfigRepository; + final IPushNotificationClient? _firebaseClient; + final IPushNotificationClient? _oneSignalClient; + final Logger _log; + + // Assuming a fixed ID for the RemoteConfig document + static const String _remoteConfigId = kRemoteConfigId; + + @override + Future sendBreakingNewsNotification({ + required Headline headline, + }) async { + _log.info( + 'Attempting to send breaking news notification for headline: ' + '"${headline.title}" (ID: ${headline.id}).', + ); + + try { + // 1. Fetch RemoteConfig to get push notification settings. + final remoteConfig = await _remoteConfigRepository.read( + id: _remoteConfigId, + ); + final pushConfig = remoteConfig.pushNotificationConfig; + + // Check if push notifications are globally enabled. + if (!pushConfig.enabled) { + _log.info('Push notifications are globally disabled. Aborting.'); + return; + } + + // Get the primary provider from the remote config. This is the single + // source of truth for which provider to use. + final primaryProvider = pushConfig.primaryProvider; + _log.info( + 'Push notifications are enabled. Primary provider is "$primaryProvider".', + ); + + // Determine which client to use based on the primary provider. + final IPushNotificationClient? client; + if (primaryProvider == PushNotificationProvider.firebase) { + client = _firebaseClient; + } else { + client = _oneSignalClient; + } + + // CRITICAL: Check if the selected primary provider's client was + // actually initialized. If not (due to missing .env credentials), + // log a severe error and abort. + if (client == null) { + _log.severe( + 'Push notifications are enabled with "$primaryProvider" as the ' + 'primary provider, but the client could not be initialized. ' + 'Please ensure all required environment variables for this provider are set. Aborting.', + ); + return; + } + + // Check if breaking news notifications are enabled. + final breakingNewsDeliveryConfig = + pushConfig.deliveryConfigs[PushNotificationSubscriptionDeliveryType + .breakingOnly]; + if (breakingNewsDeliveryConfig == null || + !breakingNewsDeliveryConfig.enabled) { + _log.info('Breaking news notifications are disabled. Aborting.'); + return; + } + + // 2. Find all subscriptions for breaking news. + // The query now correctly finds subscriptions where 'deliveryTypes' + // array *contains* the 'breakingOnly' value. + final breakingNewsSubscriptions = + await _pushNotificationSubscriptionRepository.readAll( + filter: { + 'deliveryTypes': { + r'$in': [ + PushNotificationSubscriptionDeliveryType.breakingOnly.name, + ], + }, + }, + ); + + if (breakingNewsSubscriptions.items.isEmpty) { + _log.info('No users subscribed to breaking news. Aborting.'); + return; + } + + // 3. Collect all unique user IDs from the subscriptions. + // Using a Set automatically handles deduplication. + final userIds = breakingNewsSubscriptions.items + .map((sub) => sub.userId) + .toSet(); + + _log.info( + 'Found ${breakingNewsSubscriptions.items.length} subscriptions for ' + 'breaking news, corresponding to ${userIds.length} unique users.', + ); + + // 4. Fetch all devices for all subscribed users in a single bulk query. + final allDevicesResponse = await _pushNotificationDeviceRepository + .readAll( + filter: { + 'userId': {r'$in': userIds.toList()}, + }, + ); + + final allDevices = allDevicesResponse.items; + if (allDevices.isEmpty) { + _log.info('No registered devices found for any subscribed users.'); + return; + } + + _log.info( + 'Found ${allDevices.length} total devices for subscribed users.', + ); + + // 5. Filter devices to include only those that have a token for the + // system's primary provider. + final targetedDevices = allDevices + .where((d) => d.providerTokens.containsKey(primaryProvider)) + .toList(); + + if (targetedDevices.isEmpty) { + _log.info( + 'No devices found with a token for the primary provider ' + '("$primaryProvider"). Aborting.', + ); + return; + } + + // 6. Extract the specific tokens for the primary provider. + final tokens = targetedDevices + .map((d) => d.providerTokens[primaryProvider]!) + .toList(); + + _log.info( + 'Found ${tokens.length} devices to target via $primaryProvider.', + ); + + // 7. Construct the notification payload. + final payload = PushNotificationPayload( + title: headline.title, + body: headline.excerpt, + imageUrl: headline.imageUrl, + data: { + 'headlineId': headline.id, + 'contentType': 'headline', + 'notificationType': + PushNotificationSubscriptionDeliveryType.breakingOnly.name, + }, + ); + + await client.sendBulkNotifications( + deviceTokens: tokens, + payload: payload, + ); + + _log.info( + 'Successfully dispatched breaking news notification for headline: ' + '${headline.id}.', + ); + } on HttpException { + rethrow; // Propagate known HTTP exceptions + } catch (e, s) { + _log.severe( + 'Failed to send breaking news notification for headline ' + '${headline.id}: $e', + e, + s, + ); + throw const OperationFailedException( + 'An internal error occurred while sending breaking news notification.', + ); + } + } +} diff --git a/pubspec.lock b/pubspec.lock index 41169ec..c44273d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -117,8 +117,8 @@ packages: dependency: "direct main" description: path: "." - ref: e7c808c9d459233196e2eac3137a9c87d3976af3 - resolved-ref: e7c808c9d459233196e2eac3137a9c87d3976af3 + ref: d047d9cca684de28848203c564823bb85de4f474 + resolved-ref: d047d9cca684de28848203c564823bb85de4f474 url: "https://github.com/flutter-news-app-full-source-code/core.git" source: git version: "1.3.1" @@ -316,11 +316,11 @@ packages: dependency: "direct main" description: path: "." - ref: "v1.0.1" - resolved-ref: "22a1531a279769ec472f698e9c727cd2c29a81b9" + ref: "v1.1.0" + resolved-ref: e3540bcd27de93f96f4bce79cc20ff55dfe3b2bf url: "https://github.com/flutter-news-app-full-source-code/http-client.git" source: git - version: "1.0.1" + version: "1.1.0" http_methods: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index c0305ad..d8ede36 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,4 +59,8 @@ dependency_overrides: core: git: url: https://github.com/flutter-news-app-full-source-code/core.git - ref: e7c808c9d459233196e2eac3137a9c87d3976af3 \ No newline at end of file + ref: d047d9cca684de28848203c564823bb85de4f474 + http_client: + git: + url: https://github.com/flutter-news-app-full-source-code/http-client.git + ref: v1.1.0 diff --git a/routes/_middleware.dart b/routes/_middleware.dart index 51acc21..449134f 100644 --- a/routes/_middleware.dart +++ b/routes/_middleware.dart @@ -11,7 +11,9 @@ import 'package:flutter_news_app_api_server_full_source_code/src/registry/model_ import 'package:flutter_news_app_api_server_full_source_code/src/services/auth_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/auth_token_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/country_query_service.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/services/firebase_authenticator.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/dashboard_summary_service.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/services/push_notification_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/rate_limit_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/token_blacklist_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/user_preference_limit_service.dart'; @@ -127,6 +129,26 @@ Handler middleware(Handler handler) { (_) => deps.remoteConfigRepository, ), ) + .use( + provider>( + (_) => deps.pushNotificationDeviceRepository, + ), + ) + .use( + provider>( + (_) => deps.pushNotificationSubscriptionRepository, + ), + ) + .use( + provider( + (_) => deps.pushNotificationService, + ), + ) + .use( + provider( + (_) => deps.firebaseAuthenticator, + ), + ) .use(provider((_) => deps.emailRepository)) .use( provider(