From 4755f4e7e7968c2a96897d1f73dbc1f47bf68160 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 7 Nov 2025 15:50:41 +0100 Subject: [PATCH 01/64] build(deps): update core dependency to latest commit - Update core dependency in pubspec.yaml and pubspec.lock - Change ref from e7c808c9d459233196e2eac3137a9c87d3976af3 to abd044bc891c562a2758ce85f9b3893e982a554a --- pubspec.lock | 4 ++-- pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 41169ec..cb98d36 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -117,8 +117,8 @@ packages: dependency: "direct main" description: path: "." - ref: e7c808c9d459233196e2eac3137a9c87d3976af3 - resolved-ref: e7c808c9d459233196e2eac3137a9c87d3976af3 + ref: abd044bc891c562a2758ce85f9b3893e982a554a + resolved-ref: abd044bc891c562a2758ce85f9b3893e982a554a url: "https://github.com/flutter-news-app-full-source-code/core.git" source: git version: "1.3.1" diff --git a/pubspec.yaml b/pubspec.yaml index c0305ad..99b02a3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,4 +59,4 @@ 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: abd044bc891c562a2758ce85f9b3893e982a554a \ No newline at end of file From 62b43777381924dc044906d5e7789f4c954a4806 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 7 Nov 2025 15:51:19 +0100 Subject: [PATCH 02/64] feat(notifications): add environment variables for push notifications - Add Firebase project credentials (project ID, client email, private key) - Include OneSignal app ID and REST API key - These variables are required for implementing push notification functionality --- .env.example | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.env.example b/.env.example index a1a8dd8..ed2c174 100644 --- a/.env.example +++ b/.env.example @@ -56,3 +56,24 @@ # OPTIONAL: The cache duration for the CountryQueryService, in minutes. # Defaults to 15 minutes if not specified. # COUNTRY_SERVICE_CACHE_MINUTES=15 + +# REQUIRED: The Firebase Project ID for push notifications. +# This is part of your Firebase service account credentials. +# FIREBASE_PROJECT_ID="your-firebase-project-id" + +# REQUIRED: The Firebase Client Email for push notifications. +# This is part of your Firebase service account credentials. +# FIREBASE_CLIENT_EMAIL="your-firebase-client-email" + +# REQUIRED: The Firebase Private Key for push 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" + +# REQUIRED: The OneSignal App ID for push notifications. +# This identifies your application within OneSignal. +# ONESIGNAL_APP_ID="your-onesignal-app-id" + +# REQUIRED: The OneSignal REST API Key for server-side push notifications. +# This is used to authenticate with the OneSignal API. +# ONESIGNAL_REST_API_KEY="your-onesignal-rest-api-key" From a0df8f56d1700eccb9d45a6df289da3c7be2057c Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 7 Nov 2025 15:55:47 +0100 Subject: [PATCH 03/64] feat(database): add isBreaking field to existing Headline documents - Implement a new database migration to add the 'isBreaking' field to existing Headline documents - Set default value to false for documents where the field is missing - Ensure schema consistency for the introduction of breaking news feature - Log the number of modified documents during the migration process - Note: Downward migration is not supported for this change --- ...07000000_add_is_breaking_to_headlines.dart | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 lib/src/database/migrations/20251107000000_add_is_breaking_to_headlines.dart 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..b57adcc --- /dev/null +++ b/lib/src/database/migrations/20251107000000_add_is_breaking_to_headlines.dart @@ -0,0 +1,52 @@ +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( + where + .exists('isBreaking') + .not(), // Select documents where isBreaking does not exist + 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.', + ); + } +} From 35490a5ce7f8114bcc4e9a2c274f4c32a731234f Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 7 Nov 2025 15:56:12 +0100 Subject: [PATCH 04/64] feat(database): add migration to introduce isBreaking field in headlines - Import new migration file for adding isBreaking field to headlines - Add migration to the allMigrations list --- lib/src/database/migrations/all_migrations.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/src/database/migrations/all_migrations.dart b/lib/src/database/migrations/all_migrations.dart index 9f5095d..807f00f 100644 --- a/lib/src/database/migrations/all_migrations.dart +++ b/lib/src/database/migrations/all_migrations.dart @@ -4,6 +4,7 @@ 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/services/database_migration_service.dart' show DatabaseMigrationService; @@ -18,4 +19,5 @@ final List allMigrations = [ AddSavedFiltersToRemoteConfig(), AddLogoUrlToSources(), RemoveLocalAdPlatform(), + AddIsBreakingToHeadlines(), ]; From 04c2df56cf6f298a9b9f393001b979e93437ccfb Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 7 Nov 2025 15:56:37 +0100 Subject: [PATCH 05/64] feat(core): add IPushNotificationClient interface - Define a new abstract interface for push notification clients - Specify the contract for sending push notifications through different providers - Include method for sending notifications with device token, payload, and provider config --- lib/src/services/push_notification_client.dart | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 lib/src/services/push_notification_client.dart diff --git a/lib/src/services/push_notification_client.dart b/lib/src/services/push_notification_client.dart new file mode 100644 index 0000000..8cb72ee --- /dev/null +++ b/lib/src/services/push_notification_client.dart @@ -0,0 +1,18 @@ +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. + /// [providerConfig]: The specific configuration for the provider + /// (e.g., FirebaseProviderConfig, OneSignalProviderConfig). + Future sendNotification({ + required String deviceToken, + required PushNotificationPayload payload, + required PushNotificationProviderConfig providerConfig, + }); +} From 62355f5929613e75cfce0ef4da1bcfe2f9cefc43 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 7 Nov 2025 15:59:57 +0100 Subject: [PATCH 06/64] feat(notifications): add Firebase Cloud Messaging client implementation - Implement FirebasePushNotificationClient class - Add sendNotification method to handle FCM message sending - Include error handling and logging for various scenarios - Ensure compatibility with existing IPushNotificationClient interface --- .../firebase_push_notification_client.dart | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 lib/src/services/firebase_push_notification_client.dart 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..2b992c6 --- /dev/null +++ b/lib/src/services/firebase_push_notification_client.dart @@ -0,0 +1,78 @@ +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 HttpClient httpClient, + required Logger log, + }) : _httpClient = httpClient, + _log = log; + + final HttpClient _httpClient; + final Logger _log; + + @override + Future sendNotification({ + required String deviceToken, + required PushNotificationPayload payload, + required PushNotificationProviderConfig providerConfig, + }) async { + if (providerConfig is! FirebaseProviderConfig) { + _log.severe( + 'Invalid provider config type: ${providerConfig.runtimeType}. ' + 'Expected FirebaseProviderConfig.', + ); + throw const OperationFailedException( + 'Internal configuration error for Firebase push notification client.', + ); + } + + final projectId = providerConfig.projectId; + final url = 'messages:send'; + + _log.info( + 'Sending Firebase notification to token starting with ' + '"${deviceToken.substring(0, 10)}..." for project "$projectId".', + ); + + // Construct the FCM v1 API request body. + final requestBody = { + 'message': { + 'token': deviceToken, + 'notification': { + 'title': payload.title, + 'body': payload.body, + if (payload.imageUrl != null) 'image': payload.imageUrl, + }, + // The 'data' payload is crucial for client-side handling, + // such as deep-linking when the notification is tapped. + 'data': payload.data, + }, + }; + + try { + await _httpClient.post(url, data: requestBody); + _log.info( + 'Successfully sent Firebase notification for project "$projectId".', + ); + } on HttpException catch (e) { + _log.severe( + 'HTTP error sending Firebase notification: ${e.message}', + e, + ); + rethrow; + } + } +} From 76b6065ad2aec02592decf38b93b894713ec9342 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 7 Nov 2025 16:00:48 +0100 Subject: [PATCH 07/64] feat(push-notification): implement OneSignal push notification client Add OneSignalPushNotificationClient, a concrete implementation of IPushNotificationClient for sending notifications via the OneSignal REST API. - Implement sendNotification method to construct and send HTTP requests - Include support for logging and error handling - Require HttpClient with AuthInterceptor for API requests - Support for optional image URL in notification payload --- .../onesignal_push_notification_client.dart | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 lib/src/services/onesignal_push_notification_client.dart 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..53aea88 --- /dev/null +++ b/lib/src/services/onesignal_push_notification_client.dart @@ -0,0 +1,71 @@ +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 HttpClient httpClient, + required Logger log, + }) : _httpClient = httpClient, + _log = log; + + final HttpClient _httpClient; + final Logger _log; + + @override + Future sendNotification({ + required String deviceToken, + required PushNotificationPayload payload, + required PushNotificationProviderConfig providerConfig, + }) async { + if (providerConfig is! OneSignalProviderConfig) { + _log.severe( + 'Invalid provider config type: ${providerConfig.runtimeType}. ' + 'Expected OneSignalProviderConfig.', + ); + throw const OperationFailedException( + 'Internal configuration error for OneSignal push notification client.', + ); + } + + final appId = providerConfig.appId; + // The REST API key is expected to be set in the HttpClient's AuthInterceptor. + const url = 'notifications'; // Relative to the base URL + + _log.info( + 'Sending OneSignal notification to token starting with ' + '"${deviceToken.substring(0, 10)}..." for app ID "$appId".', + ); + + // Construct the OneSignal API request body. + final requestBody = { + 'app_id': appId, + 'include_player_ids': [deviceToken], + 'headings': {'en': payload.title}, + 'contents': {'en': payload.body}, + if (payload.imageUrl != null) 'big_picture': payload.imageUrl, + 'data': payload.data, + }; + + try { + await _httpClient.post(url, data: requestBody); + _log.info( + 'Successfully sent OneSignal notification for app ID "$appId".', + ); + } on HttpException catch (e) { + _log.severe('HTTP error sending OneSignal notification: ${e.message}', e); + rethrow; + } + } +} From 79e9beba95353433ec3a6cb63a973d9822dd21ea Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 7 Nov 2025 16:01:11 +0100 Subject: [PATCH 08/64] feat(push-notification): implement push notification service - Add IPushNotificationService abstract class - Implement DefaultPushNotificationService - Integrate with various data repositories for user subscriptions, device tokens, and remote config - Support multiple push notification providers (Firebase, OneSignal) - Handle breaking news notifications with configurable settings --- .../services/push_notification_service.dart | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 lib/src/services/push_notification_service.dart diff --git a/lib/src/services/push_notification_service.dart b/lib/src/services/push_notification_service.dart new file mode 100644 index 0000000..c268e63 --- /dev/null +++ b/lib/src/services/push_notification_service.dart @@ -0,0 +1,211 @@ +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 userRepository, + required DataRepository remoteConfigRepository, + required IPushNotificationClient firebaseClient, + required IPushNotificationClient oneSignalClient, + required Logger log, + }) : _pushNotificationDeviceRepository = pushNotificationDeviceRepository, + _pushNotificationSubscriptionRepository = + pushNotificationSubscriptionRepository, + _userRepository = userRepository, + _remoteConfigRepository = remoteConfigRepository, + _firebaseClient = firebaseClient, + _oneSignalClient = oneSignalClient, + _log = log; + + final DataRepository + _pushNotificationDeviceRepository; + final DataRepository + _pushNotificationSubscriptionRepository; + final DataRepository _userRepository; + 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; + } + + // 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; + } + + // Determine the primary push notification provider and its configuration. + final primaryProvider = pushConfig.primaryProvider; + final providerConfig = pushConfig.providerConfigs[primaryProvider]; + + if (providerConfig == null) { + _log.severe( + 'No configuration found for primary push notification provider: ' + '$primaryProvider. Cannot send notification.', + ); + throw const OperationFailedException( + 'Push notification provider not configured.', + ); + } + + // Select the appropriate client based on the primary provider. + final IPushNotificationClient client; + switch (primaryProvider) { + case PushNotificationProvider.firebase: + client = _firebaseClient; + break; + case PushNotificationProvider.oneSignal: + client = _oneSignalClient; + break; + } + + // 2. Find all subscriptions for breaking news. + // Filter for subscriptions that explicitly include 'breakingOnly' + // in their deliveryTypes. + final breakingNewsSubscriptions = + await _pushNotificationSubscriptionRepository.readAll( + filter: { + 'deliveryTypes': PushNotificationSubscriptionDeliveryType + .breakingOnly + .name, // Filter by enum name + }, + ); + + if (breakingNewsSubscriptions.items.isEmpty) { + _log.info('No users subscribed to breaking news. Aborting.'); + return; + } + + _log.info( + 'Found ${breakingNewsSubscriptions.items.length} subscriptions ' + 'for breaking news.', + ); + + // 3. For each subscription, find the user's registered devices. + for (final subscription in breakingNewsSubscriptions.items) { + _log.finer( + 'Processing subscription ${subscription.id} for user ${subscription.userId}.', + ); + + // Fetch devices for the user associated with this subscription. + final userDevices = await _pushNotificationDeviceRepository.readAll( + filter: {'userId': subscription.userId}, + ); + + if (userDevices.items.isEmpty) { + _log.finer( + 'User ${subscription.userId} has no registered devices. Skipping.', + ); + continue; + } + + _log.finer( + 'User ${subscription.userId} has ${userDevices.items.length} devices.', + ); + + // 4. 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, + }, + ); + + // 5. Send notification to each device. + for (final device in userDevices.items) { + _log.finer( + 'Sending notification to device ${device.id} ' + '(${device.platform.name}) via ${device.provider.name}.', + ); + // Note: We use the client determined by the primary provider, + // not necessarily the device's registered provider, for consistency. + await client.sendNotification( + deviceToken: device.token, + payload: payload, + providerConfig: providerConfig, + ); + _log.finer('Notification sent to device ${device.id}.'); + } + } + _log.info( + 'Finished processing breaking news notification for headline: ' + '"${headline.title}" (ID: ${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 OperationFailedException( + 'An internal error occurred while sending breaking news notification.', + ); + } + } +} From d771d16a1a1ea3304f208cc3b1e0a59e8a81a0fe Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 7 Nov 2025 16:01:31 +0100 Subject: [PATCH 09/64] feat(rbac): add permission for sending breaking news notifications - Add new permission constant 'push_notification.send_breaking_news' - This permission allows sending breaking news push notifications - Typically granted to roles like 'publisher' or 'admin' in the dashboard --- lib/src/rbac/permissions.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/src/rbac/permissions.dart b/lib/src/rbac/permissions.dart index 0f66f9f..9cb953b 100644 --- a/lib/src/rbac/permissions.dart +++ b/lib/src/rbac/permissions.dart @@ -81,4 +81,12 @@ 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'; } From 7bccf2017c836007ce0a1b1ac13c5f43975f8f8d Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 7 Nov 2025 16:02:29 +0100 Subject: [PATCH 10/64] feat(rbac): add push notification permission for dashboard publishers - Add Permissions.pushNotificationSendBreakingNews to _dashboardPublisherPermissions set - Allow dashboard publishers to send breaking news notifications --- lib/src/rbac/role_permissions.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/src/rbac/role_permissions.dart b/lib/src/rbac/role_permissions.dart index 303f069..9ce45c4 100644 --- a/lib/src/rbac/role_permissions.dart +++ b/lib/src/rbac/role_permissions.dart @@ -52,6 +52,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 = { From e6c770131b7728b05f28835a0e03eb0eb759db36 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 7 Nov 2025 16:03:39 +0100 Subject: [PATCH 11/64] feat(config): add environment variables for Firebase and OneSignal - Add getters for Firebase Project ID, Client Email, and Private Key - Add getters for OneSignal App ID and REST API Key - All new methods will throw a StateError if the required environment variable is not set --- lib/src/config/environment_config.dart | 33 ++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/lib/src/config/environment_config.dart b/lib/src/config/environment_config.dart index 01cb664..e587b3a 100644 --- a/lib/src/config/environment_config.dart +++ b/lib/src/config/environment_config.dart @@ -182,4 +182,37 @@ 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. + /// Throws a [StateError] if not set. + static String get firebaseProjectId => _getRequiredEnv('FIREBASE_PROJECT_ID'); + + /// Retrieves the Firebase Client Email from the environment. + /// + /// The value is read from the `FIREBASE_CLIENT_EMAIL` environment variable. + /// Throws a [StateError] if not set. + static String get firebaseClientEmail => + _getRequiredEnv('FIREBASE_CLIENT_EMAIL'); + + /// Retrieves the Firebase Private Key from the environment. + /// + /// The value is read from the `FIREBASE_PRIVATE_KEY` environment variable. + /// Throws a [StateError] if not set. + static String get firebasePrivateKey => + _getRequiredEnv('FIREBASE_PRIVATE_KEY'); + + /// Retrieves the OneSignal App ID from the environment. + /// + /// The value is read from the `ONESIGNAL_APP_ID` environment variable. + /// Throws a [StateError] if not set. + static String get oneSignalAppId => _getRequiredEnv('ONESIGNAL_APP_ID'); + + /// Retrieves the OneSignal REST API Key from the environment. + /// + /// The value is read from the `ONESIGNAL_REST_API_KEY` environment variable. + /// Throws a [StateError] if not set. + static String get oneSignalRestApiKey => + _getRequiredEnv('ONESIGNAL_API_KEY'); } From 2f69a26e98294a0b37d437f982569c5341ce8262 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 7 Nov 2025 16:04:02 +0100 Subject: [PATCH 12/64] feat(routes): integrate push notification service - Add PushNotificationService and related repositories to the middleware - Include DataRepository for PushNotificationDevice and PushNotificationSubscription - Provide IPushNotificationService for dependency injection --- routes/_middleware.dart | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/routes/_middleware.dart b/routes/_middleware.dart index 51acc21..987124f 100644 --- a/routes/_middleware.dart +++ b/routes/_middleware.dart @@ -12,6 +12,7 @@ import 'package:flutter_news_app_api_server_full_source_code/src/services/auth_s 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/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 +128,21 @@ Handler middleware(Handler handler) { (_) => deps.remoteConfigRepository, ), ) + .use( + provider>( + (_) => deps.pushNotificationDeviceRepository, + ), + ) + .use( + provider>( + (_) => deps.pushNotificationSubscriptionRepository, + ), + ) + .use( + provider( + (_) => deps.pushNotificationService, + ), + ) .use(provider((_) => deps.emailRepository)) .use( provider( From 2d2de0449508bda9972085b18d6631860cbba975 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 7 Nov 2025 16:08:25 +0100 Subject: [PATCH 13/64] feat(push-notification): implement push notification service and infrastructure - Add push notification device and subscription repositories - Implement Firebase and OneSignal push notification clients - Create push notification service interface and default implementation - Update app dependencies to include new push notification components - Initialize HTTP clients for push notification providers --- lib/src/config/app_dependencies.dart | 67 ++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index 20ac872..89ed862 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -20,6 +20,10 @@ import 'package:flutter_news_app_api_server_full_source_code/src/services/jwt_au 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/push_notification_client.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/onesignal_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 +66,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 +84,9 @@ class AppDependencies { late final UserPreferenceLimitService userPreferenceLimitService; late final RateLimitService rateLimitService; late final CountryQueryService countryQueryService; + late final IPushNotificationService pushNotificationService; + late final IPushNotificationClient firebasePushNotificationClient; + late final IPushNotificationClient oneSignalPushNotificationClient; /// Initializes all application dependencies. /// @@ -198,6 +209,36 @@ 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'), + ); + + // Initialize HTTP clients for push notification providers. + // The tokenProvider will be dynamically set by the PushNotificationService + // based on the RemoteConfig. For now, it's a placeholder. + final firebaseHttpClient = HttpClient( + baseUrl: 'https://fcm.googleapis.com/v1/projects/', // Base URL for FCM + tokenProvider: () async => '', + logger: Logger('FirebasePushNotificationClient'), + ); + final oneSignalHttpClient = HttpClient( + baseUrl: 'https://onesignal.com/api/v1/', // Base URL for OneSignal + tokenProvider: () async => '', + logger: Logger('OneSignalPushNotificationClient'), + ); // 4. Initialize Repositories headlineRepository = DataRepository(dataClient: headlineClient); topicRepository = DataRepository(dataClient: topicClient); @@ -212,6 +253,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. @@ -231,6 +278,16 @@ class AppDependencies { emailRepository = EmailRepository(emailClient: emailClient); + // Initialize Push Notification Clients + firebasePushNotificationClient = FirebasePushNotificationClient( + httpClient: firebaseHttpClient, + log: Logger('FirebasePushNotificationClient'), + ); + oneSignalPushNotificationClient = OneSignalPushNotificationClient( + httpClient: oneSignalHttpClient, + log: Logger('OneSignalPushNotificationClient'), + ); + // 5. Initialize Services tokenBlacklistService = MongoDbTokenBlacklistService( connectionManager: _mongoDbConnectionManager, @@ -275,6 +332,16 @@ class AppDependencies { log: Logger('CountryQueryService'), cacheDuration: EnvironmentConfig.countryServiceCacheDuration, ); + pushNotificationService = DefaultPushNotificationService( + pushNotificationDeviceRepository: pushNotificationDeviceRepository, + pushNotificationSubscriptionRepository: + pushNotificationSubscriptionRepository, + userRepository: userRepository, + remoteConfigRepository: remoteConfigRepository, + firebaseClient: firebasePushNotificationClient, + oneSignalClient: oneSignalPushNotificationClient, + log: Logger('DefaultPushNotificationService'), + ); _log.info('Application dependencies initialized successfully.'); // Signal that initialization has completed successfully. From ec916f76dd4c889fb20b7e68cd1d46a69e7f427a Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 7 Nov 2025 16:09:04 +0100 Subject: [PATCH 14/64] feat(headline): add breaking news notification - Implement push notification for breaking headlines - Integrate IPushNotificationService to send breaking news notifications - Add error handling for notification sending process --- lib/src/registry/data_operation_registry.dart | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/lib/src/registry/data_operation_registry.dart b/lib/src/registry/data_operation_registry.dart index c15c686..76a5312 100644 --- a/lib/src/registry/data_operation_registry.dart +++ b/lib/src/registry/data_operation_registry.dart @@ -4,6 +4,7 @@ import 'package:data_repository/data_repository.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/ownership_check_middleware.dart'; 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/push_notification_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/dashboard_summary_service.dart'; import 'package:logging/logging.dart'; @@ -170,10 +171,25 @@ 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, send a notification. + if (createdHeadline.isBreaking) { + try { + final pushNotificationService = c.read(); + await pushNotificationService.sendBreakingNewsNotification( + headline: createdHeadline, + ); + } 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, From f928619e3d69b66e6dfd8f22ffe162be7cba96d1 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 07:46:05 +0100 Subject: [PATCH 15/64] perf(database): optimize query for updating headlines with isBreaking field - Replace exists('isBreaking').not() with notExists('isBreaking') for better readability - Improve performance and code clarity in the updateMany query --- .../20251107000000_add_is_breaking_to_headlines.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 index b57adcc..01abe0f 100644 --- a/lib/src/database/migrations/20251107000000_add_is_breaking_to_headlines.dart +++ b/lib/src/database/migrations/20251107000000_add_is_breaking_to_headlines.dart @@ -31,9 +31,8 @@ class AddIsBreakingToHeadlines extends Migration { // Update all documents in the 'headlines' collection that do not // already have the 'isBreaking' field, setting it to false. final updateResult = await collection.updateMany( - where - .exists('isBreaking') - .not(), // Select documents where isBreaking does not exist + // Select documents where 'isBreaking' does not exist. + where.notExists('isBreaking'), modify.set('isBreaking', false), // Set isBreaking to false ); From f59192ff5d25894a3cd7565ba3ee4868a09cb8ac Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 07:48:51 +0100 Subject: [PATCH 16/64] feat(push-notification): implement token providers for Firebase and OneSignal - Add dart_jsonwebtoken dependency for JWT handling - Implement Firebase token provider using service account credentials - Set up OneSignal token provider using REST API key - Update HTTP client initialization for both providers --- lib/src/config/app_dependencies.dart | 51 ++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index 89ed862..c1dc0d1 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:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'package:core/core.dart'; import 'package:data_mongodb/data_mongodb.dart'; import 'package:data_repository/data_repository.dart'; @@ -226,17 +227,53 @@ class AppDependencies { logger: Logger('DataMongodb'), ); - // Initialize HTTP clients for push notification providers. - // The tokenProvider will be dynamically set by the PushNotificationService - // based on the RemoteConfig. For now, it's a placeholder. + // --- Initialize HTTP clients for push notification providers --- + + // The Firebase client requires a short-lived OAuth2 access token for the + // FCM v1 API. This tokenProvider generates a signed JWT using the + // service account credentials from the environment. For many Google + // Cloud APIs, this signed JWT can be used directly as a Bearer token. final firebaseHttpClient = HttpClient( - baseUrl: 'https://fcm.googleapis.com/v1/projects/', // Base URL for FCM - tokenProvider: () async => '', + baseUrl: + 'https://fcm.googleapis.com/v1/projects/${EnvironmentConfig.firebaseProjectId}/', + tokenProvider: () async { + // The private key from environment variables often has escaped + // newlines. We must replace them with actual newline characters + // for the key to be parsed correctly. + 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', + ), + ); + + // Sign the JWT, giving it a short expiry time. + final signedToken = jwt.sign( + privateKey, + algorithm: JWTAlgorithm.RS256, + expiresIn: const Duration(minutes: 5), + ); + + return signedToken; + }, logger: Logger('FirebasePushNotificationClient'), ); + + // The OneSignal client requires the REST API key for authentication. + // The HttpClient's AuthInterceptor will use this tokenProvider to add + // the 'Authorization: Basic ' header to each request. final oneSignalHttpClient = HttpClient( - baseUrl: 'https://onesignal.com/api/v1/', // Base URL for OneSignal - tokenProvider: () async => '', + baseUrl: 'https://onesignal.com/api/v1/', + tokenProvider: () async => EnvironmentConfig.oneSignalRestApiKey, logger: Logger('OneSignalPushNotificationClient'), ); // 4. Initialize Repositories From 83b05c6ca0fef3aae832a201f35c8f497a88b8e2 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 07:49:05 +0100 Subject: [PATCH 17/64] fix(config): correct environment variable name for OneSignal API key Update the method to read the correct environment variable name `ONESIGNAL_REST_API_KEY` instead of the previously incorrect `ONESIGNAL_API_KEY`. --- lib/src/config/environment_config.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/config/environment_config.dart b/lib/src/config/environment_config.dart index e587b3a..66efc4b 100644 --- a/lib/src/config/environment_config.dart +++ b/lib/src/config/environment_config.dart @@ -214,5 +214,5 @@ abstract final class EnvironmentConfig { /// The value is read from the `ONESIGNAL_REST_API_KEY` environment variable. /// Throws a [StateError] if not set. static String get oneSignalRestApiKey => - _getRequiredEnv('ONESIGNAL_API_KEY'); + _getRequiredEnv('ONESIGNAL_REST_API_KEY'); } From 13bfac83dd265c9053edf09c49d18c48d405041a Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 07:49:43 +0100 Subject: [PATCH 18/64] feat(DataOperationRegistry): enhance breaking news notification process - Add success log after triggering breaking news notification - Improve code comments for better understanding of the notification process --- lib/src/registry/data_operation_registry.dart | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/src/registry/data_operation_registry.dart b/lib/src/registry/data_operation_registry.dart index 76a5312..6664db3 100644 --- a/lib/src/registry/data_operation_registry.dart +++ b/lib/src/registry/data_operation_registry.dart @@ -177,13 +177,19 @@ class DataOperationRegistry { userId: uid, ); - // If the created headline is marked as breaking news, send a notification. + // 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. if (createdHeadline.isBreaking) { try { final pushNotificationService = c.read(); await pushNotificationService.sendBreakingNewsNotification( headline: createdHeadline, ); + _log.info( + 'Successfully triggered breaking news notification ' + 'for headline: ${createdHeadline.id}', + ); } catch (e, s) { _log.severe('Failed to send breaking news notification: $e', e, s); } From 4331d5eec22fbfed0042854544dba4849ededac7 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 07:51:35 +0100 Subject: [PATCH 19/64] feat(limit_service): add notification subscription limits - Implement subscription limit logic for premium, standard, and guest users - Add notification subscription limit calculation based on remote config - Update followed item limit logic to handle specific item types - Refactor constructor formatting for better readability --- ...default_user_preference_limit_service.dart | 59 +++++++++++++++++-- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/lib/src/services/default_user_preference_limit_service.dart b/lib/src/services/default_user_preference_limit_service.dart index b299cb5..814e5a6 100644 --- a/lib/src/services/default_user_preference_limit_service.dart +++ b/lib/src/services/default_user_preference_limit_service.dart @@ -15,9 +15,9 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { required DataRepository remoteConfigRepository, required PermissionService permissionService, required Logger log, - }) : _remoteConfigRepository = remoteConfigRepository, - _permissionService = permissionService, - _log = log; + }) : _remoteConfigRepository = remoteConfigRepository, + _permissionService = permissionService, + _log = log; final DataRepository _remoteConfigRepository; final PermissionService _permissionService; @@ -60,6 +60,12 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { limit = limits.premiumFollowedItemsLimit; } else if (itemType == 'headline') { limit = limits.premiumSavedHeadlinesLimit; + } else if (itemType == 'notificationSubscription') { + final pushConfig = remoteConfig.pushNotificationConfig; + limit = pushConfig?.deliveryConfigs.values + .map((c) => c.visibleTo[user.appRole]?.subscriptionLimit ?? 0) + .fold(0, (prev, element) => prev + element) ?? + 0; } else { limit = limits.premiumSavedFiltersLimit; } @@ -69,14 +75,30 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { limit = limits.authenticatedFollowedItemsLimit; } else if (itemType == 'headline') { limit = limits.authenticatedSavedHeadlinesLimit; + } else if (itemType == 'notificationSubscription') { + final pushConfig = remoteConfig.pushNotificationConfig; + limit = pushConfig?.deliveryConfigs.values + .map((c) => c.visibleTo[user.appRole]?.subscriptionLimit ?? 0) + .fold(0, (prev, element) => prev + element) ?? + 0; } else { limit = limits.authenticatedSavedFiltersLimit; } case AppUserRole.guestUser: accountType = 'guest'; - limit = (itemType == 'headline') - ? limits.guestSavedHeadlinesLimit - : limits.guestFollowedItemsLimit; + if (isFollowedItem) { + limit = limits.guestFollowedItemsLimit; + } else if (itemType == 'headline') { + limit = limits.guestSavedHeadlinesLimit; + } else if (itemType == 'notificationSubscription') { + final pushConfig = remoteConfig.pushNotificationConfig; + limit = pushConfig?.deliveryConfigs.values + .map((c) => c.visibleTo[user.appRole]?.subscriptionLimit ?? 0) + .fold(0, (prev, element) => prev + element) ?? + 0; + } else { + limit = limits.guestSavedFiltersLimit; + } } // 3. Check if adding the item would exceed the limit @@ -124,6 +146,7 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { int followedItemsLimit; int savedHeadlinesLimit; int savedFiltersLimit; + int notificationSubscriptionLimit; String accountType; switch (user.appRole) { @@ -132,16 +155,33 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { followedItemsLimit = limits.premiumFollowedItemsLimit; savedHeadlinesLimit = limits.premiumSavedHeadlinesLimit; savedFiltersLimit = limits.premiumSavedFiltersLimit; + // The total limit for subscriptions is the sum of limits for each + // delivery type available to the user's role. + notificationSubscriptionLimit = remoteConfig + .pushNotificationConfig?.deliveryConfigs.values + .map((c) => c.visibleTo[user.appRole]?.subscriptionLimit ?? 0) + .fold(0, (prev, element) => prev + element) ?? + 0; case AppUserRole.standardUser: accountType = 'standard'; followedItemsLimit = limits.authenticatedFollowedItemsLimit; savedHeadlinesLimit = limits.authenticatedSavedHeadlinesLimit; savedFiltersLimit = limits.authenticatedSavedFiltersLimit; + notificationSubscriptionLimit = remoteConfig + .pushNotificationConfig?.deliveryConfigs.values + .map((c) => c.visibleTo[user.appRole]?.subscriptionLimit ?? 0) + .fold(0, (prev, element) => prev + element) ?? + 0; case AppUserRole.guestUser: accountType = 'guest'; followedItemsLimit = limits.guestFollowedItemsLimit; savedHeadlinesLimit = limits.guestSavedHeadlinesLimit; savedFiltersLimit = limits.guestSavedFiltersLimit; + notificationSubscriptionLimit = remoteConfig + .pushNotificationConfig?.deliveryConfigs.values + .map((c) => c.visibleTo[user.appRole]?.subscriptionLimit ?? 0) + .fold(0, (prev, element) => prev + element) ?? + 0; } // 3. Check if proposed preferences exceed limits @@ -175,6 +215,13 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { 'for your account type ($accountType).', ); } + if (updatedPreferences.notificationSubscriptions.length > + notificationSubscriptionLimit) { + throw ForbiddenException( + 'You have reached the maximum number of notification subscriptions ' + 'allowed for your account type ($accountType).', + ); + } } on HttpException { // Propagate known exceptions from repositories rethrow; From bbdc33dca5e442cc90fae8a1a76476d81d07d8bf Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 07:51:55 +0100 Subject: [PATCH 20/64] feat(database): add indexes for push notification collections - Add unique index on 'token' field for 'push_notification_devices' collection - Add index on 'userId' field for 'push_notification_subscriptions' collection - These indexes improve data integrity and query performance for push notification operations --- .../services/database_seeding_service.dart | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 44d101b..1191482 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -226,6 +226,36 @@ class DatabaseSeedingService { ], }); + // Indexes for the push notification devices collection + await _db.runCommand({ + 'createIndexes': 'push_notification_devices', + 'indexes': [ + { + // This ensures that each device token is unique in the collection, + // preventing duplicate registrations for the same device. + 'key': {'token': 1}, + 'name': 'token_unique_index', + 'unique': 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); From ef8d582d1b260cdb858c75a17fe9540981db67b6 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 07:52:19 +0100 Subject: [PATCH 21/64] feat(push-notification): add bulk notification send function - Introduce new method `sendBulkNotifications` to IPushNotificationClient - Enhance efficiency for sending notifications to multiple devices - Maintain consistency in method parameters with existing `sendNotification` --- lib/src/services/push_notification_client.dart | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/src/services/push_notification_client.dart b/lib/src/services/push_notification_client.dart index 8cb72ee..8a5c596 100644 --- a/lib/src/services/push_notification_client.dart +++ b/lib/src/services/push_notification_client.dart @@ -15,4 +15,18 @@ abstract class IPushNotificationClient { required PushNotificationPayload payload, required PushNotificationProviderConfig providerConfig, }); + + /// 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. + /// [providerConfig]: The specific configuration for the provider. + Future sendBulkNotifications({ + required List deviceTokens, + required PushNotificationPayload payload, + required PushNotificationProviderConfig providerConfig, + }); } From c1a0d716b607a9d5eb9e9924e6f67461c9709bd8 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 07:55:31 +0100 Subject: [PATCH 22/64] feat(firebase_push_notification_client): implement bulk messaging with batch support - Add sendBulkNotifications method for sending to multiple devices - Implement chunking for batches larger than 500 tokens - Use FCM v1 batch endpoint for efficient bulk sending - Refactor existing sendNotification to delegate to new bulk method - Update logging and error handling for bulk operations --- .../firebase_push_notification_client.dart | 135 +++++++++++++++--- 1 file changed, 112 insertions(+), 23 deletions(-) diff --git a/lib/src/services/firebase_push_notification_client.dart b/lib/src/services/firebase_push_notification_client.dart index 2b992c6..24824b7 100644 --- a/lib/src/services/firebase_push_notification_client.dart +++ b/lib/src/services/firebase_push_notification_client.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + 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'; @@ -17,8 +19,8 @@ class FirebasePushNotificationClient implements IPushNotificationClient { const FirebasePushNotificationClient({ required HttpClient httpClient, required Logger log, - }) : _httpClient = httpClient, - _log = log; + }) : _httpClient = httpClient, + _log = log; final HttpClient _httpClient; final Logger _log; @@ -28,6 +30,21 @@ class FirebasePushNotificationClient implements IPushNotificationClient { required String deviceToken, required PushNotificationPayload payload, required PushNotificationProviderConfig providerConfig, + }) 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, + providerConfig: providerConfig, + ); + } + + @override + Future sendBulkNotifications({ + required List deviceTokens, + required PushNotificationPayload payload, + required PushNotificationProviderConfig providerConfig, }) async { if (providerConfig is! FirebaseProviderConfig) { _log.severe( @@ -35,44 +52,116 @@ class FirebasePushNotificationClient implements IPushNotificationClient { 'Expected FirebaseProviderConfig.', ); throw const OperationFailedException( - 'Internal configuration error for Firebase push notification client.', + 'Internal config error for Firebase push notification client.', ); } - final projectId = providerConfig.projectId; - final url = 'messages:send'; + if (deviceTokens.isEmpty) { + _log.info('No device tokens provided for Firebase bulk send. Aborting.'); + return; + } _log.info( - 'Sending Firebase notification to token starting with ' - '"${deviceToken.substring(0, 10)}..." for project "$projectId".', + 'Sending Firebase bulk notification to ${deviceTokens.length} ' + 'devices for project "${providerConfig.projectId}".', ); - // Construct the FCM v1 API request body. - final requestBody = { - 'message': { - 'token': deviceToken, - 'notification': { - 'title': payload.title, - 'body': payload.body, - if (payload.imageUrl != null) 'image': payload.imageUrl, + // 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, + providerConfig: providerConfig, + ); + } + } + + /// Sends a single batch of notifications to the FCM v1 batch endpoint. + /// + /// This method constructs a `multipart/mixed` request body, where each part + /// is a complete HTTP POST request to the standard `messages:send` endpoint. + Future _sendBatch({ + required List deviceTokens, + required PushNotificationPayload payload, + required FirebaseProviderConfig providerConfig, + }) async { + const boundary = 'batch_boundary'; + // The FCM v1 batch endpoint is at a different path. We must use a full + // URL here to override the HttpClient's default base URL. + const batchUrl = 'https://fcm.googleapis.com/batch'; + + // Map each device token to its corresponding sub-request string. + final subrequests = deviceTokens.map((token) { + // Construct the JSON body for a single message. + final messageBody = jsonEncode({ + 'message': { + 'token': token, + 'notification': { + 'title': payload.title, + 'body': payload.body, + if (payload.imageUrl != null) 'image': payload.imageUrl, + }, + 'data': payload.data, }, - // The 'data' payload is crucial for client-side handling, - // such as deep-linking when the notification is tapped. - 'data': payload.data, - }, - }; + }); + + // Construct the full HTTP request for this single message as a string. + // This is the format required for each part of the multipart request. + return ''' +--$boundary +Content-Type: application/http +Content-Transfer-Encoding: binary + +POST /v1/projects/${providerConfig.projectId}/messages:send +Content-Type: application/json +accept: application/json + +$messageBody'''; + }).join('\n'); + + // The final request body is the joined sub-requests followed by the + // closing boundary marker. + final batchRequestBody = '$subrequests\n--$boundary--'; try { - await _httpClient.post(url, data: requestBody); + // Post the raw multipart body with the correct `Content-Type` header + // to the specific batch endpoint URL. + await _httpClient.post( + batchUrl, + data: batchRequestBody, + options: Options( + headers: {'Content-Type': 'multipart/mixed; boundary=$boundary'}, + ), + ); _log.info( - 'Successfully sent Firebase notification for project "$projectId".', + 'Successfully sent Firebase batch of ${deviceTokens.length} ' + 'notifications for project "${providerConfig.projectId}".', ); } on HttpException catch (e) { _log.severe( - 'HTTP error sending Firebase notification: ${e.message}', + 'HTTP error sending Firebase batch notification: ${e.message}', e, ); rethrow; + } catch (e, s) { + _log.severe( + 'Unexpected error sending Firebase batch notification.', + e, + s, + ); + throw OperationFailedException( + 'Failed to send Firebase batch notification: $e', + ); } } } From 0f816470b8f31219cc61a14b82b6bf29b88ed465 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 07:57:06 +0100 Subject: [PATCH 23/64] refactor(notifications): implement bulk sending for OneSignal client - Add sendBulkNotifications method to OneSignalPushNotificationClient - Delegate single notification sending to bulk method - Implement batching for handling large numbers of device tokens - Add error handling for non-HttpExceptions - Update logging for batch processing - Improve method documentation and comments --- .../onesignal_push_notification_client.dart | 82 ++++++++++++++++--- 1 file changed, 72 insertions(+), 10 deletions(-) diff --git a/lib/src/services/onesignal_push_notification_client.dart b/lib/src/services/onesignal_push_notification_client.dart index 53aea88..8518c24 100644 --- a/lib/src/services/onesignal_push_notification_client.dart +++ b/lib/src/services/onesignal_push_notification_client.dart @@ -28,6 +28,20 @@ class OneSignalPushNotificationClient implements IPushNotificationClient { required String deviceToken, required PushNotificationPayload payload, required PushNotificationProviderConfig providerConfig, + }) async { + // For consistency, delegate to the bulk sending method with a single token. + await sendBulkNotifications( + deviceTokens: [deviceToken], + payload: payload, + providerConfig: providerConfig, + ); + } + + @override + Future sendBulkNotifications({ + required List deviceTokens, + required PushNotificationPayload payload, + required PushNotificationProviderConfig providerConfig, }) async { if (providerConfig is! OneSignalProviderConfig) { _log.severe( @@ -35,37 +49,85 @@ class OneSignalPushNotificationClient implements IPushNotificationClient { 'Expected OneSignalProviderConfig.', ); throw const OperationFailedException( - 'Internal configuration error for OneSignal push notification client.', + 'Internal config error for OneSignal push notification client.', ); } - final appId = providerConfig.appId; - // The REST API key is expected to be set in the HttpClient's AuthInterceptor. - const url = 'notifications'; // Relative to the base URL + if (deviceTokens.isEmpty) { + _log.info('No device tokens provided for OneSignal bulk send. Aborting.'); + return; + } _log.info( - 'Sending OneSignal notification to token starting with ' - '"${deviceToken.substring(0, 10)}..." for app ID "$appId".', + 'Sending OneSignal bulk notification to ${deviceTokens.length} ' + 'devices for app ID "${providerConfig.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, + providerConfig: providerConfig, + ); + } + } + + /// Sends a single batch of notifications to the OneSignal API. + Future _sendBatch({ + required List deviceTokens, + required PushNotificationPayload payload, + required OneSignalProviderConfig providerConfig, + }) async { + final appId = providerConfig.appId; + // 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': [deviceToken], + '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); + await _httpClient.post(url, data: requestBody); _log.info( - 'Successfully sent OneSignal notification for app ID "$appId".', + 'Successfully sent OneSignal batch of ${deviceTokens.length} ' + 'notifications for app ID "$appId".', ); } on HttpException catch (e) { - _log.severe('HTTP error sending OneSignal notification: ${e.message}', 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', + ); } } } From 87fdb793077b54fe34a273fa2d9e99b4737e6a0d Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 08:02:56 +0100 Subject: [PATCH 24/64] refactor(push-notification): optimize breaking news notification delivery - Refactor device lookup to use a single bulk query instead of per-user queries - Group devices by provider and dispatch notifications in bulk - Use environment variables for push notification provider configs - Improve logging and error handling - Optimize subscription filtering query --- .../services/push_notification_service.dart | 190 ++++++++++-------- 1 file changed, 108 insertions(+), 82 deletions(-) diff --git a/lib/src/services/push_notification_service.dart b/lib/src/services/push_notification_service.dart index c268e63..18b6bde 100644 --- a/lib/src/services/push_notification_service.dart +++ b/lib/src/services/push_notification_service.dart @@ -1,7 +1,9 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:core/core.dart'; import 'package:data_repository/data_repository.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/config/environment_config.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/push_notification_client.dart'; import 'package:logging/logging.dart'; @@ -78,7 +80,7 @@ class DefaultPushNotificationService implements IPushNotificationService { final pushConfig = remoteConfig.pushNotificationConfig; // Check if push notifications are globally enabled. - if (!pushConfig.enabled) { + if (pushConfig == null || !pushConfig.enabled) { _log.info('Push notifications are globally disabled. Aborting.'); return; } @@ -93,40 +95,17 @@ class DefaultPushNotificationService implements IPushNotificationService { return; } - // Determine the primary push notification provider and its configuration. - final primaryProvider = pushConfig.primaryProvider; - final providerConfig = pushConfig.providerConfigs[primaryProvider]; - - if (providerConfig == null) { - _log.severe( - 'No configuration found for primary push notification provider: ' - '$primaryProvider. Cannot send notification.', - ); - throw const OperationFailedException( - 'Push notification provider not configured.', - ); - } - - // Select the appropriate client based on the primary provider. - final IPushNotificationClient client; - switch (primaryProvider) { - case PushNotificationProvider.firebase: - client = _firebaseClient; - break; - case PushNotificationProvider.oneSignal: - client = _oneSignalClient; - break; - } - // 2. Find all subscriptions for breaking news. - // Filter for subscriptions that explicitly include 'breakingOnly' - // in their deliveryTypes. + // The query now correctly finds subscriptions where 'deliveryTypes' + // array *contains* the 'breakingOnly' value. final breakingNewsSubscriptions = await _pushNotificationSubscriptionRepository.readAll( filter: { - 'deliveryTypes': PushNotificationSubscriptionDeliveryType - .breakingOnly - .name, // Filter by enum name + 'deliveryTypes': { + r'$in': [ + PushNotificationSubscriptionDeliveryType.breakingOnly.name, + ], + }, }, ); @@ -135,62 +114,67 @@ class DefaultPushNotificationService implements IPushNotificationService { 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.', + 'Found ${breakingNewsSubscriptions.items.length} subscriptions for ' + 'breaking news, corresponding to ${userIds.length} unique users.', ); - // 3. For each subscription, find the user's registered devices. - for (final subscription in breakingNewsSubscriptions.items) { - _log.finer( - 'Processing subscription ${subscription.id} for user ${subscription.userId}.', - ); + // 4. Fetch all devices for all subscribed users in a single bulk query. + final allDevicesResponse = await _pushNotificationDeviceRepository + .readAll( + filter: { + 'userId': {r'$in': userIds.toList()}, + }, + ); - // Fetch devices for the user associated with this subscription. - final userDevices = await _pushNotificationDeviceRepository.readAll( - filter: {'userId': subscription.userId}, - ); + final allDevices = allDevicesResponse.items; + if (allDevices.isEmpty) { + _log.info('No registered devices found for any subscribed users.'); + return; + } - if (userDevices.items.isEmpty) { - _log.finer( - 'User ${subscription.userId} has no registered devices. Skipping.', - ); - continue; - } - - _log.finer( - 'User ${subscription.userId} has ${userDevices.items.length} devices.', - ); - - // 4. 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, - }, - ); - - // 5. Send notification to each device. - for (final device in userDevices.items) { - _log.finer( - 'Sending notification to device ${device.id} ' - '(${device.platform.name}) via ${device.provider.name}.', - ); - // Note: We use the client determined by the primary provider, - // not necessarily the device's registered provider, for consistency. - await client.sendNotification( - deviceToken: device.token, + _log.info( + 'Found ${allDevices.length} total devices for subscribed users.', + ); + + // 5. Group devices by their registered provider (Firebase or OneSignal). + // This is crucial because a device must be notified via the provider + // it registered with, regardless of the system's primary provider. + final devicesByProvider = allDevices.groupListsBy((d) => d.provider); + + // 6. 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, + }, + ); + + // 7. Dispatch notifications in bulk for each provider group. + await Future.wait([ + if (devicesByProvider.containsKey(PushNotificationProvider.firebase)) + _sendToFirebase( + devices: devicesByProvider[PushNotificationProvider.firebase]!, payload: payload, - providerConfig: providerConfig, - ); - _log.finer('Notification sent to device ${device.id}.'); - } - } + ), + if (devicesByProvider.containsKey(PushNotificationProvider.oneSignal)) + _sendToOneSignal( + devices: devicesByProvider[PushNotificationProvider.oneSignal]!, + payload: payload, + ), + ]); + _log.info( 'Finished processing breaking news notification for headline: ' '"${headline.title}" (ID: ${headline.id}).', @@ -199,7 +183,8 @@ class DefaultPushNotificationService implements IPushNotificationService { rethrow; // Propagate known HTTP exceptions } catch (e, s) { _log.severe( - 'Failed to send breaking news notification for headline ${headline.id}: $e', + 'Failed to send breaking news notification for headline ' + '${headline.id}: $e', e, s, ); @@ -208,4 +193,45 @@ class DefaultPushNotificationService implements IPushNotificationService { ); } } + + Future _sendToFirebase({ + required List devices, + required PushNotificationPayload payload, + }) async { + final tokens = devices.map((d) => d.token).toList(); + _log.info('Sending notification to ${tokens.length} Firebase devices.'); + await _firebaseClient.sendBulkNotifications( + deviceTokens: tokens, + payload: payload, + // The provider config is now created on-the-fly using non-sensitive + // data from environment variables, not from RemoteConfig. + providerConfig: FirebaseProviderConfig( + projectId: EnvironmentConfig.firebaseProjectId, + // These fields are not used by the client but are required by the + // model. They will be removed in a future refactor. + clientEmail: '', + privateKey: '', + ), + ); + } + + Future _sendToOneSignal({ + required List devices, + required PushNotificationPayload payload, + }) async { + final tokens = devices.map((d) => d.token).toList(); + _log.info('Sending notification to ${tokens.length} OneSignal devices.'); + await _oneSignalClient.sendBulkNotifications( + deviceTokens: tokens, + payload: payload, + // The provider config is now created on-the-fly using non-sensitive + // data from environment variables, not from RemoteConfig. + providerConfig: OneSignalProviderConfig( + appId: EnvironmentConfig.oneSignalAppId, + // This field is not used by the client but is required by the + // model. It will be removed in a future refactor. + restApiKey: '', + ), + ); + } } From b5dcc80e71834e687aaeda3b46a4855d2410d580 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 08:03:05 +0100 Subject: [PATCH 25/64] docs(README): add detailed description of notification features - Add comprehensive information about dynamic and personalized notifications - Include details on editorial-driven alerts, user-crafted notification streams, and flexible delivery mechanisms - Highlight provider-agnostic design and support for Firebase (FCM) and OneSignal - Emphasize the advantage of enhanced user engagement and web dashboard management --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) 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. +
From 28d9b52c90d8ab59867a888812c1a7e0cf54ad4f Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 08:10:26 +0100 Subject: [PATCH 26/64] style: format --- lib/src/config/app_dependencies.dart | 7 ++++--- lib/src/registry/data_operation_registry.dart | 2 +- .../default_user_preference_limit_service.dart | 12 ++++++------ lib/src/services/push_notification_service.dart | 4 ++-- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index c1dc0d1..ab7c090 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -1,8 +1,9 @@ // ignore_for_file: public_member_api_docs import 'dart:async'; -import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; + import 'package:core/core.dart'; +import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'package:data_mongodb/data_mongodb.dart'; import 'package:data_repository/data_repository.dart'; import 'package:email_repository/email_repository.dart'; @@ -17,13 +18,13 @@ 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_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/push_notification_client.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/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'; diff --git a/lib/src/registry/data_operation_registry.dart b/lib/src/registry/data_operation_registry.dart index 6664db3..2c14c7d 100644 --- a/lib/src/registry/data_operation_registry.dart +++ b/lib/src/registry/data_operation_registry.dart @@ -4,8 +4,8 @@ import 'package:data_repository/data_repository.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/ownership_check_middleware.dart'; 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/push_notification_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 --- diff --git a/lib/src/services/default_user_preference_limit_service.dart b/lib/src/services/default_user_preference_limit_service.dart index 814e5a6..5541ebc 100644 --- a/lib/src/services/default_user_preference_limit_service.dart +++ b/lib/src/services/default_user_preference_limit_service.dart @@ -62,7 +62,7 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { limit = limits.premiumSavedHeadlinesLimit; } else if (itemType == 'notificationSubscription') { final pushConfig = remoteConfig.pushNotificationConfig; - limit = pushConfig?.deliveryConfigs.values + limit = pushConfig.deliveryConfigs.values .map((c) => c.visibleTo[user.appRole]?.subscriptionLimit ?? 0) .fold(0, (prev, element) => prev + element) ?? 0; @@ -77,7 +77,7 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { limit = limits.authenticatedSavedHeadlinesLimit; } else if (itemType == 'notificationSubscription') { final pushConfig = remoteConfig.pushNotificationConfig; - limit = pushConfig?.deliveryConfigs.values + limit = pushConfig.deliveryConfigs.values .map((c) => c.visibleTo[user.appRole]?.subscriptionLimit ?? 0) .fold(0, (prev, element) => prev + element) ?? 0; @@ -92,7 +92,7 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { limit = limits.guestSavedHeadlinesLimit; } else if (itemType == 'notificationSubscription') { final pushConfig = remoteConfig.pushNotificationConfig; - limit = pushConfig?.deliveryConfigs.values + limit = pushConfig.deliveryConfigs.values .map((c) => c.visibleTo[user.appRole]?.subscriptionLimit ?? 0) .fold(0, (prev, element) => prev + element) ?? 0; @@ -158,7 +158,7 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { // The total limit for subscriptions is the sum of limits for each // delivery type available to the user's role. notificationSubscriptionLimit = remoteConfig - .pushNotificationConfig?.deliveryConfigs.values + .pushNotificationConfig.deliveryConfigs.values .map((c) => c.visibleTo[user.appRole]?.subscriptionLimit ?? 0) .fold(0, (prev, element) => prev + element) ?? 0; @@ -168,7 +168,7 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { savedHeadlinesLimit = limits.authenticatedSavedHeadlinesLimit; savedFiltersLimit = limits.authenticatedSavedFiltersLimit; notificationSubscriptionLimit = remoteConfig - .pushNotificationConfig?.deliveryConfigs.values + .pushNotificationConfig.deliveryConfigs.values .map((c) => c.visibleTo[user.appRole]?.subscriptionLimit ?? 0) .fold(0, (prev, element) => prev + element) ?? 0; @@ -178,7 +178,7 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { savedHeadlinesLimit = limits.guestSavedHeadlinesLimit; savedFiltersLimit = limits.guestSavedFiltersLimit; notificationSubscriptionLimit = remoteConfig - .pushNotificationConfig?.deliveryConfigs.values + .pushNotificationConfig.deliveryConfigs.values .map((c) => c.visibleTo[user.appRole]?.subscriptionLimit ?? 0) .fold(0, (prev, element) => prev + element) ?? 0; diff --git a/lib/src/services/push_notification_service.dart b/lib/src/services/push_notification_service.dart index 18b6bde..2acd541 100644 --- a/lib/src/services/push_notification_service.dart +++ b/lib/src/services/push_notification_service.dart @@ -80,7 +80,7 @@ class DefaultPushNotificationService implements IPushNotificationService { final pushConfig = remoteConfig.pushNotificationConfig; // Check if push notifications are globally enabled. - if (pushConfig == null || !pushConfig.enabled) { + if (!pushConfig.enabled) { _log.info('Push notifications are globally disabled. Aborting.'); return; } @@ -188,7 +188,7 @@ class DefaultPushNotificationService implements IPushNotificationService { e, s, ); - throw OperationFailedException( + throw const OperationFailedException( 'An internal error occurred while sending breaking news notification.', ); } From 957edd9b6b640a9804dd0138f6bf7bd8b9b49e69 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 08:34:24 +0100 Subject: [PATCH 27/64] refactor(user): improve notification subscription limit logic - Replace the total notification subscription limit check with per-delivery-type limit enforcement. - Simplify the limit assignment logic using ternary operators. - Enhance code readability by adopting a more concise style. - Remove unused notificationSubscriptionLimit variables. --- ...default_user_preference_limit_service.dart | 126 +++++++++--------- 1 file changed, 60 insertions(+), 66 deletions(-) diff --git a/lib/src/services/default_user_preference_limit_service.dart b/lib/src/services/default_user_preference_limit_service.dart index 5541ebc..40c445c 100644 --- a/lib/src/services/default_user_preference_limit_service.dart +++ b/lib/src/services/default_user_preference_limit_service.dart @@ -15,9 +15,9 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { required DataRepository remoteConfigRepository, required PermissionService permissionService, required Logger log, - }) : _remoteConfigRepository = remoteConfigRepository, - _permissionService = permissionService, - _log = log; + }) : _remoteConfigRepository = remoteConfigRepository, + _permissionService = permissionService, + _log = log; final DataRepository _remoteConfigRepository; final PermissionService _permissionService; @@ -56,49 +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 if (itemType == 'notificationSubscription') { - final pushConfig = remoteConfig.pushNotificationConfig; - limit = pushConfig.deliveryConfigs.values - .map((c) => c.visibleTo[user.appRole]?.subscriptionLimit ?? 0) - .fold(0, (prev, element) => prev + element) ?? - 0; - } 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 if (itemType == 'notificationSubscription') { - final pushConfig = remoteConfig.pushNotificationConfig; - limit = pushConfig.deliveryConfigs.values - .map((c) => c.visibleTo[user.appRole]?.subscriptionLimit ?? 0) - .fold(0, (prev, element) => prev + element) ?? - 0; - } else { - limit = limits.authenticatedSavedFiltersLimit; - } + limit = isFollowedItem + ? limits.authenticatedFollowedItemsLimit + : (itemType == 'headline') + ? limits.authenticatedSavedHeadlinesLimit + : limits.authenticatedSavedFiltersLimit; case AppUserRole.guestUser: accountType = 'guest'; - if (isFollowedItem) { - limit = limits.guestFollowedItemsLimit; - } else if (itemType == 'headline') { - limit = limits.guestSavedHeadlinesLimit; - } else if (itemType == 'notificationSubscription') { - final pushConfig = remoteConfig.pushNotificationConfig; - limit = pushConfig.deliveryConfigs.values - .map((c) => c.visibleTo[user.appRole]?.subscriptionLimit ?? 0) - .fold(0, (prev, element) => prev + element) ?? - 0; - } else { - limit = limits.guestSavedFiltersLimit; - } + limit = isFollowedItem + ? limits.guestFollowedItemsLimit + : (itemType == 'headline') + ? limits.guestSavedHeadlinesLimit + : limits.guestSavedFiltersLimit; } // 3. Check if adding the item would exceed the limit @@ -146,7 +122,6 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { int followedItemsLimit; int savedHeadlinesLimit; int savedFiltersLimit; - int notificationSubscriptionLimit; String accountType; switch (user.appRole) { @@ -155,33 +130,16 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { followedItemsLimit = limits.premiumFollowedItemsLimit; savedHeadlinesLimit = limits.premiumSavedHeadlinesLimit; savedFiltersLimit = limits.premiumSavedFiltersLimit; - // The total limit for subscriptions is the sum of limits for each - // delivery type available to the user's role. - notificationSubscriptionLimit = remoteConfig - .pushNotificationConfig.deliveryConfigs.values - .map((c) => c.visibleTo[user.appRole]?.subscriptionLimit ?? 0) - .fold(0, (prev, element) => prev + element) ?? - 0; case AppUserRole.standardUser: accountType = 'standard'; followedItemsLimit = limits.authenticatedFollowedItemsLimit; savedHeadlinesLimit = limits.authenticatedSavedHeadlinesLimit; savedFiltersLimit = limits.authenticatedSavedFiltersLimit; - notificationSubscriptionLimit = remoteConfig - .pushNotificationConfig.deliveryConfigs.values - .map((c) => c.visibleTo[user.appRole]?.subscriptionLimit ?? 0) - .fold(0, (prev, element) => prev + element) ?? - 0; case AppUserRole.guestUser: accountType = 'guest'; followedItemsLimit = limits.guestFollowedItemsLimit; savedHeadlinesLimit = limits.guestSavedHeadlinesLimit; savedFiltersLimit = limits.guestSavedFiltersLimit; - notificationSubscriptionLimit = remoteConfig - .pushNotificationConfig.deliveryConfigs.values - .map((c) => c.visibleTo[user.appRole]?.subscriptionLimit ?? 0) - .fold(0, (prev, element) => prev + element) ?? - 0; } // 3. Check if proposed preferences exceed limits @@ -215,13 +173,49 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { 'for your account type ($accountType).', ); } - if (updatedPreferences.notificationSubscriptions.length > - notificationSubscriptionLimit) { - throw ForbiddenException( - 'You have reached the maximum number of notification subscriptions ' - 'allowed 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; + if (pushConfig != null) { + // 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; From 3e87476196ff51eeab21c2328d1f7e03bead7120 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 08:52:18 +0100 Subject: [PATCH 28/64] refactor(push-notification): remove unused user repository dependency - Remove userRepository from AppDependencies and DefaultPushNotificationService - This change simplifies the push notification service by removing an unused dependency --- lib/src/config/app_dependencies.dart | 1 - lib/src/services/push_notification_service.dart | 3 --- 2 files changed, 4 deletions(-) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index ab7c090..266675a 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -374,7 +374,6 @@ class AppDependencies { pushNotificationDeviceRepository: pushNotificationDeviceRepository, pushNotificationSubscriptionRepository: pushNotificationSubscriptionRepository, - userRepository: userRepository, remoteConfigRepository: remoteConfigRepository, firebaseClient: firebasePushNotificationClient, oneSignalClient: oneSignalPushNotificationClient, diff --git a/lib/src/services/push_notification_service.dart b/lib/src/services/push_notification_service.dart index 2acd541..1b1ecbc 100644 --- a/lib/src/services/push_notification_service.dart +++ b/lib/src/services/push_notification_service.dart @@ -36,7 +36,6 @@ class DefaultPushNotificationService implements IPushNotificationService { pushNotificationDeviceRepository, required DataRepository pushNotificationSubscriptionRepository, - required DataRepository userRepository, required DataRepository remoteConfigRepository, required IPushNotificationClient firebaseClient, required IPushNotificationClient oneSignalClient, @@ -44,7 +43,6 @@ class DefaultPushNotificationService implements IPushNotificationService { }) : _pushNotificationDeviceRepository = pushNotificationDeviceRepository, _pushNotificationSubscriptionRepository = pushNotificationSubscriptionRepository, - _userRepository = userRepository, _remoteConfigRepository = remoteConfigRepository, _firebaseClient = firebaseClient, _oneSignalClient = oneSignalClient, @@ -54,7 +52,6 @@ class DefaultPushNotificationService implements IPushNotificationService { _pushNotificationDeviceRepository; final DataRepository _pushNotificationSubscriptionRepository; - final DataRepository _userRepository; final DataRepository _remoteConfigRepository; final IPushNotificationClient _firebaseClient; final IPushNotificationClient _oneSignalClient; From 1566650765d33213fe56b2578fbb312650baca7f Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 08:52:31 +0100 Subject: [PATCH 29/64] refactor(lib): remove unnecessary null check for pushConfig - Removed the null check for pushConfig as it is not needed. - This change simplifies the code structure and improves readability. --- ...default_user_preference_limit_service.dart | 59 +++++++++---------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/lib/src/services/default_user_preference_limit_service.dart b/lib/src/services/default_user_preference_limit_service.dart index 40c445c..957a0a7 100644 --- a/lib/src/services/default_user_preference_limit_service.dart +++ b/lib/src/services/default_user_preference_limit_service.dart @@ -179,37 +179,36 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { 'Checking notification subscription limits for user ${user.id}...', ); final pushConfig = remoteConfig.pushNotificationConfig; - if (pushConfig != null) { - // 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).', - ); + + // 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).', - ); - } + // 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).', + ); } } From a182a174d470d40099878c9938ac2cd4aeeead29 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 08:52:43 +0100 Subject: [PATCH 30/64] fix(database): add missing notificationSubscriptions field to user seed - Added notificationSubscriptions: const [] to the user seed data - This ensures that new users created through database seeding have an empty notificationSubscriptions array by default --- lib/src/services/database_seeding_service.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 1191482..6c3a6aa 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -379,6 +379,7 @@ class DatabaseSeedingService { followedTopics: const [], savedHeadlines: const [], savedFilters: const [], + notificationSubscriptions: const [], ); await _db.collection('user_content_preferences').insertOne({ '_id': userId, From 7328e4b07134fc64d64ea9ccd16b17b0f40057c0 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 08:53:01 +0100 Subject: [PATCH 31/64] fix(auth_service): add notificationSubscriptions to create method - Add notificationSubscriptions to the list of preferences initialized for a new user - This ensures that the notification subscriptions are properly set up when a user is created --- lib/src/services/auth_service.dart | 1 + 1 file changed, 1 insertion(+) 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, From 3baf1e89614237d0ec7af492a1247da020103fc4 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 09:03:18 +0100 Subject: [PATCH 32/64] feat(rbac): add user-owned push notification device permissions - Introduce new permissions for creating and deleting user-owned push notification devices - Grant these permissions to all app users in the guest user role set --- lib/src/rbac/permissions.dart | 6 ++++++ lib/src/rbac/role_permissions.dart | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/lib/src/rbac/permissions.dart b/lib/src/rbac/permissions.dart index 9cb953b..1f7ff09 100644 --- a/lib/src/rbac/permissions.dart +++ b/lib/src/rbac/permissions.dart @@ -89,4 +89,10 @@ abstract class Permissions { /// '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 9ce45c4..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 = { From f34f46ee8b55ba6026e76a259b6e764773807d69 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 09:04:19 +0100 Subject: [PATCH 33/64] feat(push_notification): implement device registration and deletion - Add custom creator for 'push_notification_device' with security check - Register delete operation for 'push_notification_device' - Define model config for 'push_notification_device' with permission settings --- lib/src/registry/data_operation_registry.dart | 35 +++++++++++++++++ lib/src/registry/model_registry.dart | 38 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/lib/src/registry/data_operation_registry.dart b/lib/src/registry/data_operation_registry.dart index 2c14c7d..7d49eab 100644 --- a/lib/src/registry/data_operation_registry.dart +++ b/lib/src/registry/data_operation_registry.dart @@ -215,6 +215,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 --- @@ -355,6 +387,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. From 133e4d55781e9ca415effc38931f9000db017709 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 10:29:08 +0100 Subject: [PATCH 34/64] build(deps): update core dependency - Update git ref from abd044bc891c562a2758ce85f9b3893e982a554a to d047d9cca684de28848203c564823bb85de4f474 --- pubspec.lock | 4 ++-- pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index cb98d36..66b24e0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -117,8 +117,8 @@ packages: dependency: "direct main" description: path: "." - ref: abd044bc891c562a2758ce85f9b3893e982a554a - resolved-ref: abd044bc891c562a2758ce85f9b3893e982a554a + ref: d047d9cca684de28848203c564823bb85de4f474 + resolved-ref: d047d9cca684de28848203c564823bb85de4f474 url: "https://github.com/flutter-news-app-full-source-code/core.git" source: git version: "1.3.1" diff --git a/pubspec.yaml b/pubspec.yaml index 99b02a3..8ef6836 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,4 +59,4 @@ dependency_overrides: core: git: url: https://github.com/flutter-news-app-full-source-code/core.git - ref: abd044bc891c562a2758ce85f9b3893e982a554a \ No newline at end of file + ref: d047d9cca684de28848203c564823bb85de4f474 From 5cb3e61142fc91bb2c8ddbd48455925a1da79483 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 11:54:32 +0100 Subject: [PATCH 35/64] feat(database): add push notification config to remote config - Implement a new database migration to add the `pushNotificationConfig` field to the `remote_configs` document - Set default configuration for push notifications, including enabled status, primary provider, and delivery configs for different user roles and subscription types - Ensure the migration is idempotent and can be safely applied multiple times - Provide a `down` migration to remove the `pushNotificationConfig` field if needed --- ..._notification_config_to_remote_config.dart | 112 ++++++++++++++++++ .../database/migrations/all_migrations.dart | 2 + 2 files changed, 114 insertions(+) create mode 100644 lib/src/database/migrations/20251108103300_add_push_notification_config_to_remote_config.dart 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..597c679 --- /dev/null +++ b/lib/src/database/migrations/20251108103300_add_push_notification_config_to_remote_config.dart @@ -0,0 +1,112 @@ +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: 'N/A', // This is a local refactoring task + 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. + // This matches the structure provided in the task description. + final defaultConfig = PushNotificationConfig( + enabled: true, + primaryProvider: PushNotificationProvider.firebase, + deliveryConfigs: { + PushNotificationSubscriptionDeliveryType.breakingOnly: + const PushNotificationDeliveryConfig( + enabled: true, + visibleTo: { + AppUserRole.guestUser: PushNotificationDeliveryRoleConfig( + subscriptionLimit: 1, + ), + AppUserRole.standardUser: PushNotificationDeliveryRoleConfig( + subscriptionLimit: 3, + ), + AppUserRole.premiumUser: PushNotificationDeliveryRoleConfig( + subscriptionLimit: 10, + ), + }, + ), + PushNotificationSubscriptionDeliveryType.dailyDigest: + const PushNotificationDeliveryConfig( + enabled: true, + visibleTo: { + AppUserRole.guestUser: PushNotificationDeliveryRoleConfig( + subscriptionLimit: 0, + ), + AppUserRole.standardUser: PushNotificationDeliveryRoleConfig( + subscriptionLimit: 2, + ), + AppUserRole.premiumUser: PushNotificationDeliveryRoleConfig( + subscriptionLimit: 10, + ), + }, + ), + PushNotificationSubscriptionDeliveryType.weeklyRoundup: + const PushNotificationDeliveryConfig( + enabled: true, + visibleTo: { + AppUserRole.guestUser: PushNotificationDeliveryRoleConfig( + subscriptionLimit: 0, + ), + AppUserRole.standardUser: PushNotificationDeliveryRoleConfig( + subscriptionLimit: 2, + ), + AppUserRole.premiumUser: PushNotificationDeliveryRoleConfig( + subscriptionLimit: 10, + ), + }, + ), + }, + ); + + // 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.exists('pushNotificationConfig', exists: false), + ), + modify.set('pushNotificationConfig', defaultConfig.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 807f00f..3657272 100644 --- a/lib/src/database/migrations/all_migrations.dart +++ b/lib/src/database/migrations/all_migrations.dart @@ -5,6 +5,7 @@ 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/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; @@ -20,4 +21,5 @@ final List allMigrations = [ AddLogoUrlToSources(), RemoveLocalAdPlatform(), AddIsBreakingToHeadlines(), + AddPushNotificationConfigToRemoteConfig(), ]; From e16d63cb0b3e7522644a729af36810d33faad7ab Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 11:54:41 +0100 Subject: [PATCH 36/64] refactor(config): correctly configure push notification clients Updates `app_dependencies.dart` to align with the new push notification architecture. - Modifies the Firebase `HttpClient` to perform the correct two-legged OAuth2 flow required by the FCM v1 API, exchanging a signed JWT for an access token. - Updates the OneSignal `HttpClient` to use a custom interceptor that provides the correct `Basic` authorization header with the REST API key. - Instantiates `FirebasePushNotificationClient` and `OneSignalPushNotificationClient` with their required credentials (`projectId`, `appId`) directly from `EnvironmentConfig`. --- lib/src/config/app_dependencies.dart | 95 ++++++++++++++++++---------- 1 file changed, 62 insertions(+), 33 deletions(-) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index 266675a..8b3b448 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:core/core.dart'; +import 'package:dio/dio.dart'; import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'package:data_mongodb/data_mongodb.dart'; import 'package:data_repository/data_repository.dart'; @@ -230,51 +231,77 @@ class AppDependencies { // --- Initialize HTTP clients for push notification providers --- - // The Firebase client requires a short-lived OAuth2 access token for the - // FCM v1 API. This tokenProvider generates a signed JWT using the - // service account credentials from the environment. For many Google - // Cloud APIs, this signed JWT can be used directly as a Bearer token. + // The Firebase client requires a short-lived OAuth2 access token. This + // tokenProvider implements the required two-legged OAuth flow: + // 1. Create a JWT signed with the service account's private key. + // 2. Exchange this JWT for an access token from Google's token endpoint. final firebaseHttpClient = HttpClient( baseUrl: 'https://fcm.googleapis.com/v1/projects/${EnvironmentConfig.firebaseProjectId}/', tokenProvider: () async { - // The private key from environment variables often has escaped - // newlines. We must replace them with actual newline characters - // for the key to be parsed correctly. - 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( + 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), + ); + + // Step 2: Exchange the JWT for an access token. + // We use a temporary Dio instance for this single request. + final response = await Dio().post>( 'https://oauth2.googleapis.com/token', - ), - ); - - // Sign the JWT, giving it a short expiry time. - final signedToken = jwt.sign( - privateKey, - algorithm: JWTAlgorithm.RS256, - expiresIn: const Duration(minutes: 5), - ); - - return signedToken; + data: + 'grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=$signedToken', + options: Options( + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + ), + ); + + final accessToken = response.data?['access_token'] as String?; + if (accessToken == null) { + _log.severe('Failed to get access token from Google OAuth.'); + throw const OperationFailedException( + 'Could not retrieve Firebase access token.', + ); + } + return accessToken; + } catch (e, s) { + _log.severe('Error during Firebase token exchange: $e', e, s); + throw OperationFailedException( + 'Failed to authenticate with Firebase: $e', + ); + } }, logger: Logger('FirebasePushNotificationClient'), ); // The OneSignal client requires the REST API key for authentication. - // The HttpClient's AuthInterceptor will use this tokenProvider to add - // the 'Authorization: Basic ' header to each request. + // We use a custom interceptor to add the 'Authorization: Basic ' + // header, as the default AuthInterceptor is hardcoded for 'Bearer'. final oneSignalHttpClient = HttpClient( baseUrl: 'https://onesignal.com/api/v1/', - tokenProvider: () async => EnvironmentConfig.oneSignalRestApiKey, + // The tokenProvider is not used here; auth is handled by the interceptor. + tokenProvider: () async => null, + interceptors: [ + InterceptorsWrapper( + onRequest: (options, handler) { + options.headers['Authorization'] = + 'Basic ${EnvironmentConfig.oneSignalRestApiKey}'; + return handler.next(options); + }, + ), + ], logger: Logger('OneSignalPushNotificationClient'), ); // 4. Initialize Repositories @@ -319,10 +346,12 @@ class AppDependencies { // Initialize Push Notification Clients firebasePushNotificationClient = FirebasePushNotificationClient( httpClient: firebaseHttpClient, + projectId: EnvironmentConfig.firebaseProjectId, log: Logger('FirebasePushNotificationClient'), ); oneSignalPushNotificationClient = OneSignalPushNotificationClient( httpClient: oneSignalHttpClient, + appId: EnvironmentConfig.oneSignalAppId, log: Logger('OneSignalPushNotificationClient'), ); From 1a351ac86696b168272658c94cd7b1eb7eb4761d Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 11:55:18 +0100 Subject: [PATCH 37/64] refactor(notifications): simplify IPushNotificationClient interface Removes the obsolete `providerConfig` parameter from the `sendNotification` and `sendBulkNotifications` methods in the `IPushNotificationClient` interface. This aligns the interface with the new architecture where clients are configured at instantiation and no longer require provider-specific configuration to be passed during method calls. --- lib/src/services/push_notification_client.dart | 5 ----- 1 file changed, 5 deletions(-) diff --git a/lib/src/services/push_notification_client.dart b/lib/src/services/push_notification_client.dart index 8a5c596..4762e5d 100644 --- a/lib/src/services/push_notification_client.dart +++ b/lib/src/services/push_notification_client.dart @@ -8,12 +8,9 @@ abstract class IPushNotificationClient { /// /// [deviceToken]: The unique token identifying the target device. /// [payload]: The data payload to be sent with the notification. - /// [providerConfig]: The specific configuration for the provider - /// (e.g., FirebaseProviderConfig, OneSignalProviderConfig). Future sendNotification({ required String deviceToken, required PushNotificationPayload payload, - required PushNotificationProviderConfig providerConfig, }); /// Sends a push notification to a batch of devices. @@ -23,10 +20,8 @@ abstract class IPushNotificationClient { /// /// [deviceTokens]: A list of unique tokens identifying the target devices. /// [payload]: The data payload to be sent with the notification. - /// [providerConfig]: The specific configuration for the provider. Future sendBulkNotifications({ required List deviceTokens, required PushNotificationPayload payload, - required PushNotificationProviderConfig providerConfig, }); } From acfaf32b91fe18b23dadcb9124095c0d4defbfd9 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 11:56:30 +0100 Subject: [PATCH 38/64] refactor(notifications): align Firebase client with new architecture Refactors `FirebasePushNotificationClient` to resolve all compile-time errors and align with the new, more secure architecture. - Implements the simplified `IPushNotificationClient` interface, removing the obsolete `providerConfig` parameter. - The client now accepts the `projectId` via its constructor, making it self-contained. - The `_sendBatch` method is rewritten to send individual messages in parallel via `Future.wait`, which is a more robust and simpler approach than the previous multipart request implementation. --- .../firebase_push_notification_client.dart | 101 ++++++------------ 1 file changed, 30 insertions(+), 71 deletions(-) diff --git a/lib/src/services/firebase_push_notification_client.dart b/lib/src/services/firebase_push_notification_client.dart index 24824b7..40c97a9 100644 --- a/lib/src/services/firebase_push_notification_client.dart +++ b/lib/src/services/firebase_push_notification_client.dart @@ -1,5 +1,3 @@ -import 'dart:convert'; - 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'; @@ -17,11 +15,13 @@ class FirebasePushNotificationClient implements IPushNotificationClient { /// /// 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; + final String projectId; final HttpClient _httpClient; final Logger _log; @@ -29,14 +29,12 @@ class FirebasePushNotificationClient implements IPushNotificationClient { Future sendNotification({ required String deviceToken, required PushNotificationPayload payload, - required PushNotificationProviderConfig providerConfig, }) 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, - providerConfig: providerConfig, ); } @@ -44,26 +42,15 @@ class FirebasePushNotificationClient implements IPushNotificationClient { Future sendBulkNotifications({ required List deviceTokens, required PushNotificationPayload payload, - required PushNotificationProviderConfig providerConfig, }) async { - if (providerConfig is! FirebaseProviderConfig) { - _log.severe( - 'Invalid provider config type: ${providerConfig.runtimeType}. ' - 'Expected FirebaseProviderConfig.', - ); - throw const OperationFailedException( - 'Internal config error for Firebase push notification client.', - ); - } - 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 "${providerConfig.projectId}".', + 'Sending Firebase bulk notification to ${deviceTokens.length} devices ' + 'for project "$projectId".', ); // The FCM v1 batch API has a limit of 500 messages per request. @@ -78,32 +65,28 @@ class FirebasePushNotificationClient implements IPushNotificationClient { ); // Send each chunk as a separate batch request. - await _sendBatch( - deviceTokens: batch, - payload: payload, - providerConfig: providerConfig, - ); + await _sendBatch(deviceTokens: batch, payload: payload); } } - /// Sends a single batch of notifications to the FCM v1 batch endpoint. + /// Sends a batch of notifications by dispatching individual requests in + /// parallel. /// - /// This method constructs a `multipart/mixed` request body, where each part - /// is a complete HTTP POST request to the standard `messages:send` endpoint. + /// 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, - required FirebaseProviderConfig providerConfig, }) async { - const boundary = 'batch_boundary'; - // The FCM v1 batch endpoint is at a different path. We must use a full - // URL here to override the HttpClient's default base URL. - const batchUrl = 'https://fcm.googleapis.com/batch'; - - // Map each device token to its corresponding sub-request string. - final subrequests = deviceTokens.map((token) { - // Construct the JSON body for a single message. - final messageBody = jsonEncode({ + // 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': { @@ -113,54 +96,30 @@ class FirebasePushNotificationClient implements IPushNotificationClient { }, 'data': payload.data, }, - }); + }; - // Construct the full HTTP request for this single message as a string. - // This is the format required for each part of the multipart request. - return ''' ---$boundary Content-Type: application/http Content-Transfer-Encoding: binary - -POST /v1/projects/${providerConfig.projectId}/messages:send -Content-Type: application/json -accept: application/json - -$messageBody'''; - }).join('\n'); - - // The final request body is the joined sub-requests followed by the - // closing boundary marker. - final batchRequestBody = '$subrequests\n--$boundary--'; + // Return the future from the post request. + return _httpClient.post(url, data: requestBody); + }).toList(); try { - // Post the raw multipart body with the correct `Content-Type` header - // to the specific batch endpoint URL. - await _httpClient.post( - batchUrl, - data: batchRequestBody, - options: Options( - headers: {'Content-Type': 'multipart/mixed; boundary=$boundary'}, - ), - ); + // Wait for all notifications in the batch to be sent. + // `eagerError: false` ensures that all futures complete, even if some + // fail. This is important for logging all failures, not just the first. + await Future.wait(sendFutures, eagerError: false); _log.info( 'Successfully sent Firebase batch of ${deviceTokens.length} ' - 'notifications for project "${providerConfig.projectId}".', + 'notifications for project "$projectId".', ); } on HttpException catch (e) { - _log.severe( - 'HTTP error sending Firebase batch notification: ${e.message}', - e, - ); + _log.severe('HTTP error sending Firebase batch: ${e.message}', e); rethrow; } catch (e, s) { - _log.severe( - 'Unexpected error sending Firebase batch notification.', - e, - s, - ); + _log.severe('Unexpected error sending Firebase batch.', e, s); throw OperationFailedException( - 'Failed to send Firebase batch notification: $e', + 'Failed to send Firebase batch: $e', ); } } From 3fea4a72c37a7d26dab2ff2470abc616bcb353f7 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 11:57:25 +0100 Subject: [PATCH 39/64] refactor(notifications): align OneSignal client with new architecture Refactors `OneSignalPushNotificationClient` to resolve all compile-time errors and align with the new, more secure architecture. - Implements the simplified `IPushNotificationClient` interface, removing the obsolete `providerConfig` parameter. - The client now accepts the `appId` via its constructor, making it self-contained and removing its reliance on external configuration models. --- .../onesignal_push_notification_client.dart | 20 +++---------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/lib/src/services/onesignal_push_notification_client.dart b/lib/src/services/onesignal_push_notification_client.dart index 8518c24..161684d 100644 --- a/lib/src/services/onesignal_push_notification_client.dart +++ b/lib/src/services/onesignal_push_notification_client.dart @@ -15,11 +15,13 @@ class OneSignalPushNotificationClient implements IPushNotificationClient { /// /// 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; + final String appId; final HttpClient _httpClient; final Logger _log; @@ -27,13 +29,11 @@ class OneSignalPushNotificationClient implements IPushNotificationClient { Future sendNotification({ required String deviceToken, required PushNotificationPayload payload, - required PushNotificationProviderConfig providerConfig, }) async { // For consistency, delegate to the bulk sending method with a single token. await sendBulkNotifications( deviceTokens: [deviceToken], payload: payload, - providerConfig: providerConfig, ); } @@ -41,18 +41,7 @@ class OneSignalPushNotificationClient implements IPushNotificationClient { Future sendBulkNotifications({ required List deviceTokens, required PushNotificationPayload payload, - required PushNotificationProviderConfig providerConfig, }) async { - if (providerConfig is! OneSignalProviderConfig) { - _log.severe( - 'Invalid provider config type: ${providerConfig.runtimeType}. ' - 'Expected OneSignalProviderConfig.', - ); - throw const OperationFailedException( - 'Internal config error for OneSignal push notification client.', - ); - } - if (deviceTokens.isEmpty) { _log.info('No device tokens provided for OneSignal bulk send. Aborting.'); return; @@ -60,7 +49,7 @@ class OneSignalPushNotificationClient implements IPushNotificationClient { _log.info( 'Sending OneSignal bulk notification to ${deviceTokens.length} ' - 'devices for app ID "${providerConfig.appId}".', + 'devices for app ID "$appId".', ); // OneSignal has a limit of 2000 player_ids per API request. @@ -76,7 +65,6 @@ class OneSignalPushNotificationClient implements IPushNotificationClient { await _sendBatch( deviceTokens: batch, payload: payload, - providerConfig: providerConfig, ); } } @@ -85,9 +73,7 @@ class OneSignalPushNotificationClient implements IPushNotificationClient { Future _sendBatch({ required List deviceTokens, required PushNotificationPayload payload, - required OneSignalProviderConfig providerConfig, }) async { - final appId = providerConfig.appId; // 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` From 2fb5649d336fd62ac7b3ed1ecc18c839e2715f05 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 11:59:28 +0100 Subject: [PATCH 40/64] refactor(notifications): implement server-authoritative push logic Rewrites `DefaultPushNotificationService` to be server-authoritative, resolving all compile-time errors and aligning with the new architecture. - The service now reads the `primaryProvider` from `RemoteConfig` and dispatches notifications exclusively to that provider. - It correctly filters devices to target only those with a token for the active provider, extracting the token from the `providerTokens` map. - The old, flawed logic of grouping devices by provider and sending to multiple clients has been removed. - All compile-time errors related to the refactored `PushNotificationDevice` model are now fixed. --- .../services/push_notification_service.dart | 104 +++++++----------- 1 file changed, 41 insertions(+), 63 deletions(-) diff --git a/lib/src/services/push_notification_service.dart b/lib/src/services/push_notification_service.dart index 1b1ecbc..bad05cb 100644 --- a/lib/src/services/push_notification_service.dart +++ b/lib/src/services/push_notification_service.dart @@ -1,9 +1,7 @@ import 'dart:async'; -import 'package:collection/collection.dart'; import 'package:core/core.dart'; import 'package:data_repository/data_repository.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/config/environment_config.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/push_notification_client.dart'; import 'package:logging/logging.dart'; @@ -82,6 +80,13 @@ class DefaultPushNotificationService implements IPushNotificationService { 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".', + ); + // Check if breaking news notifications are enabled. final breakingNewsDeliveryConfig = pushConfig.deliveryConfigs[PushNotificationSubscriptionDeliveryType @@ -140,12 +145,30 @@ class DefaultPushNotificationService implements IPushNotificationService { 'Found ${allDevices.length} total devices for subscribed users.', ); - // 5. Group devices by their registered provider (Firebase or OneSignal). - // This is crucial because a device must be notified via the provider - // it registered with, regardless of the system's primary provider. - final devicesByProvider = allDevices.groupListsBy((d) => d.provider); + // 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(); - // 6. Construct the notification payload. + _log.info( + 'Found ${tokens.length} devices to target via $primaryProvider.', + ); + + // 7. Construct the notification payload. final payload = PushNotificationPayload( title: headline.title, body: headline.excerpt, @@ -158,23 +181,19 @@ class DefaultPushNotificationService implements IPushNotificationService { }, ); - // 7. Dispatch notifications in bulk for each provider group. - await Future.wait([ - if (devicesByProvider.containsKey(PushNotificationProvider.firebase)) - _sendToFirebase( - devices: devicesByProvider[PushNotificationProvider.firebase]!, - payload: payload, - ), - if (devicesByProvider.containsKey(PushNotificationProvider.oneSignal)) - _sendToOneSignal( - devices: devicesByProvider[PushNotificationProvider.oneSignal]!, - payload: payload, - ), - ]); + // 8. Select the correct client and send the notifications. + final client = primaryProvider == PushNotificationProvider.firebase + ? _firebaseClient + : _oneSignalClient; + + await client.sendBulkNotifications( + deviceTokens: tokens, + payload: payload, + ); _log.info( - 'Finished processing breaking news notification for headline: ' - '"${headline.title}" (ID: ${headline.id}).', + 'Successfully dispatched breaking news notification for headline: ' + '${headline.id}.', ); } on HttpException { rethrow; // Propagate known HTTP exceptions @@ -190,45 +209,4 @@ class DefaultPushNotificationService implements IPushNotificationService { ); } } - - Future _sendToFirebase({ - required List devices, - required PushNotificationPayload payload, - }) async { - final tokens = devices.map((d) => d.token).toList(); - _log.info('Sending notification to ${tokens.length} Firebase devices.'); - await _firebaseClient.sendBulkNotifications( - deviceTokens: tokens, - payload: payload, - // The provider config is now created on-the-fly using non-sensitive - // data from environment variables, not from RemoteConfig. - providerConfig: FirebaseProviderConfig( - projectId: EnvironmentConfig.firebaseProjectId, - // These fields are not used by the client but are required by the - // model. They will be removed in a future refactor. - clientEmail: '', - privateKey: '', - ), - ); - } - - Future _sendToOneSignal({ - required List devices, - required PushNotificationPayload payload, - }) async { - final tokens = devices.map((d) => d.token).toList(); - _log.info('Sending notification to ${tokens.length} OneSignal devices.'); - await _oneSignalClient.sendBulkNotifications( - deviceTokens: tokens, - payload: payload, - // The provider config is now created on-the-fly using non-sensitive - // data from environment variables, not from RemoteConfig. - providerConfig: OneSignalProviderConfig( - appId: EnvironmentConfig.oneSignalAppId, - // This field is not used by the client but is required by the - // model. It will be removed in a future refactor. - restApiKey: '', - ), - ); - } } From 036427be97a16eccbc4ce09267055d3c024cb9b4 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 12:01:03 +0100 Subject: [PATCH 41/64] refactor(notifications): implement fire-and-forget for breaking news Updates the custom creator for `headline` in `data_operation_registry.dart` to implement a non-blocking "fire-and-forget" pattern for sending breaking news notifications. - The call to `sendBreakingNewsNotification` is now wrapped in `unawaited()` to ensure it runs in the background without blocking the API response. - A `try-catch` block is added for resilience, preventing notification failures from crashing the headline creation process. --- lib/src/registry/data_operation_registry.dart | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/lib/src/registry/data_operation_registry.dart b/lib/src/registry/data_operation_registry.dart index 7d49eab..5f0f5b6 100644 --- a/lib/src/registry/data_operation_registry.dart +++ b/lib/src/registry/data_operation_registry.dart @@ -1,4 +1,5 @@ import 'package:core/core.dart'; +import 'dart:async'; import 'package:dart_frog/dart_frog.dart'; import 'package:data_repository/data_repository.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/ownership_check_middleware.dart'; @@ -180,15 +181,23 @@ class DataOperationRegistry { // 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(); - await pushNotificationService.sendBreakingNewsNotification( - headline: createdHeadline, + unawaited( + pushNotificationService.sendBreakingNewsNotification( + headline: createdHeadline, + ), ); _log.info( - 'Successfully triggered breaking news notification ' - 'for headline: ${createdHeadline.id}', + 'Successfully dispatched breaking news notification for headline: ${createdHeadline.id}', ); } catch (e, s) { _log.severe('Failed to send breaking news notification: $e', e, s); @@ -243,9 +252,9 @@ class DataOperationRegistry { // 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, - ); + item: deviceToCreate, + userId: null, + ); }, }); From a6fba3ffca2f858eae4225af8989e9f1cb040320 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 13:13:03 +0100 Subject: [PATCH 42/64] feat(database): add unique indexes for push notification provider tokens - Replace the unique index on 'token' with separate sparse indexes for Firebase and OneSignal tokens - Ensure no two devices can have the same token for each provider - Allow devices to register with only one provider without requiring both --- .../services/database_seeding_service.dart | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 6c3a6aa..e6d728d 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -231,11 +231,22 @@ class DatabaseSeedingService { 'createIndexes': 'push_notification_devices', 'indexes': [ { - // This ensures that each device token is unique in the collection, - // preventing duplicate registrations for the same device. - 'key': {'token': 1}, - 'name': 'token_unique_index', + // 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, }, ], }); From 3827b21eba4ca2cdc2743654e214c3b0dc9d0c16 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 13:13:17 +0100 Subject: [PATCH 43/64] fix(notifications): remove unnecessary formatting and headers - Remove additional whitespace in constructor - Delete unnecessary 'Content-Type' and 'Content-Transfer-Encoding' headers --- lib/src/services/firebase_push_notification_client.dart | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/src/services/firebase_push_notification_client.dart b/lib/src/services/firebase_push_notification_client.dart index 40c97a9..6c57f4f 100644 --- a/lib/src/services/firebase_push_notification_client.dart +++ b/lib/src/services/firebase_push_notification_client.dart @@ -18,8 +18,8 @@ class FirebasePushNotificationClient implements IPushNotificationClient { required this.projectId, required HttpClient httpClient, required Logger log, - }) : _httpClient = httpClient, - _log = log; + }) : _httpClient = httpClient, + _log = log; final String projectId; final HttpClient _httpClient; @@ -98,8 +98,6 @@ class FirebasePushNotificationClient implements IPushNotificationClient { }, }; -Content-Type: application/http -Content-Transfer-Encoding: binary // Return the future from the post request. return _httpClient.post(url, data: requestBody); }).toList(); From 493e8cdb66ab827e4b60401a67aa440ce7a54ea0 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 13:16:27 +0100 Subject: [PATCH 44/64] fix(database): correctly implement push notification config migration - Update PR ID to '71' from 'N/A' - Modify condition to use notExists instead of exists with negation - Replace defaultConfig with pushNotificationConfig from fixtures data - Update log message to reflect completion of the migration --- ..._notification_config_to_remote_config.dart | 59 ++----------------- 1 file changed, 4 insertions(+), 55 deletions(-) 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 index 597c679..c23c646 100644 --- 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 @@ -16,7 +16,7 @@ class AddPushNotificationConfigToRemoteConfig extends Migration { /// {@macro add_push_notification_config_to_remote_config} AddPushNotificationConfigToRemoteConfig() : super( - prId: 'N/A', // This is a local refactoring task + prId: '71', prSummary: 'Add pushNotificationConfig field to the remote_configs document.', prDate: '20251108103300', @@ -30,58 +30,7 @@ class AddPushNotificationConfigToRemoteConfig extends Migration { final remoteConfigId = ObjectId.fromHexString(kRemoteConfigId); // Default structure for the push notification configuration. - // This matches the structure provided in the task description. - final defaultConfig = PushNotificationConfig( - enabled: true, - primaryProvider: PushNotificationProvider.firebase, - deliveryConfigs: { - PushNotificationSubscriptionDeliveryType.breakingOnly: - const PushNotificationDeliveryConfig( - enabled: true, - visibleTo: { - AppUserRole.guestUser: PushNotificationDeliveryRoleConfig( - subscriptionLimit: 1, - ), - AppUserRole.standardUser: PushNotificationDeliveryRoleConfig( - subscriptionLimit: 3, - ), - AppUserRole.premiumUser: PushNotificationDeliveryRoleConfig( - subscriptionLimit: 10, - ), - }, - ), - PushNotificationSubscriptionDeliveryType.dailyDigest: - const PushNotificationDeliveryConfig( - enabled: true, - visibleTo: { - AppUserRole.guestUser: PushNotificationDeliveryRoleConfig( - subscriptionLimit: 0, - ), - AppUserRole.standardUser: PushNotificationDeliveryRoleConfig( - subscriptionLimit: 2, - ), - AppUserRole.premiumUser: PushNotificationDeliveryRoleConfig( - subscriptionLimit: 10, - ), - }, - ), - PushNotificationSubscriptionDeliveryType.weeklyRoundup: - const PushNotificationDeliveryConfig( - enabled: true, - visibleTo: { - AppUserRole.guestUser: PushNotificationDeliveryRoleConfig( - subscriptionLimit: 0, - ), - AppUserRole.standardUser: PushNotificationDeliveryRoleConfig( - subscriptionLimit: 2, - ), - AppUserRole.premiumUser: PushNotificationDeliveryRoleConfig( - subscriptionLimit: 10, - ), - }, - ), - }, - ); + final pushNotificationConfig = remoteConfigsFixturesData.first.pushNotificationConfig; // Use $set to add the field only if it doesn't exist. // This is an idempotent operation. @@ -89,9 +38,9 @@ class AddPushNotificationConfigToRemoteConfig extends Migration { where .id(remoteConfigId) .and( - where.exists('pushNotificationConfig', exists: false), + where.notExists('pushNotificationConfig'), ), - modify.set('pushNotificationConfig', defaultConfig.toJson()), + modify.set('pushNotificationConfig', pushNotificationConfig.toJson()), ); log.info('Successfully completed up migration for $prDate.'); From f411cfdd61293de5e82e7fa49f5ac76c094c6ac2 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 13:19:05 +0100 Subject: [PATCH 45/64] style: format --- lib/src/config/app_dependencies.dart | 2 +- lib/src/registry/data_operation_registry.dart | 3 ++- lib/src/services/firebase_push_notification_client.dart | 1 + lib/src/services/onesignal_push_notification_client.dart | 1 + 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index 8b3b448..a01b037 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -3,10 +3,10 @@ import 'dart:async'; import 'package:core/core.dart'; -import 'package:dio/dio.dart'; import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'package:data_mongodb/data_mongodb.dart'; import 'package:data_repository/data_repository.dart'; +import 'package:dio/dio.dart'; import 'package:email_repository/email_repository.dart'; import 'package:email_sendgrid/email_sendgrid.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/config/environment_config.dart'; diff --git a/lib/src/registry/data_operation_registry.dart b/lib/src/registry/data_operation_registry.dart index 5f0f5b6..61bfbbb 100644 --- a/lib/src/registry/data_operation_registry.dart +++ b/lib/src/registry/data_operation_registry.dart @@ -1,5 +1,6 @@ -import 'package:core/core.dart'; import 'dart:async'; + +import 'package:core/core.dart'; import 'package:dart_frog/dart_frog.dart'; import 'package:data_repository/data_repository.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/ownership_check_middleware.dart'; diff --git a/lib/src/services/firebase_push_notification_client.dart b/lib/src/services/firebase_push_notification_client.dart index 6c57f4f..237e287 100644 --- a/lib/src/services/firebase_push_notification_client.dart +++ b/lib/src/services/firebase_push_notification_client.dart @@ -21,6 +21,7 @@ class FirebasePushNotificationClient implements IPushNotificationClient { }) : _httpClient = httpClient, _log = log; + /// The Firebase Project ID for push notifications. final String projectId; final HttpClient _httpClient; final Logger _log; diff --git a/lib/src/services/onesignal_push_notification_client.dart b/lib/src/services/onesignal_push_notification_client.dart index 161684d..6265eeb 100644 --- a/lib/src/services/onesignal_push_notification_client.dart +++ b/lib/src/services/onesignal_push_notification_client.dart @@ -21,6 +21,7 @@ class OneSignalPushNotificationClient implements IPushNotificationClient { }) : _httpClient = httpClient, _log = log; + /// The OneSignal App ID for push notifications. final String appId; final HttpClient _httpClient; final Logger _log; From 4b26c0199dbf64586e7ad22ddd0c630e28cae955 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 13:31:41 +0100 Subject: [PATCH 46/64] fix(firebase): replace Dio with HttpClient for OAuth token exchange - Remove Dio dependency and replace it with dart:convert for JSON handling - Implement a one-time, interceptor-free HttpClient for the token exchange request - Update the token exchange --- lib/src/config/app_dependencies.dart | 29 +++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index a01b037..28c128d 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -1,12 +1,11 @@ // ignore_for_file: public_member_api_docs import 'dart:async'; - +import 'dart:convert'; import 'package:core/core.dart'; import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'package:data_mongodb/data_mongodb.dart'; import 'package:data_repository/data_repository.dart'; -import 'package:dio/dio.dart'; import 'package:email_repository/email_repository.dart'; import 'package:email_sendgrid/email_sendgrid.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/config/environment_config.dart'; @@ -258,17 +257,29 @@ class AppDependencies { ); // Step 2: Exchange the JWT for an access token. - // We use a temporary Dio instance for this single request. - final response = await Dio().post>( - 'https://oauth2.googleapis.com/token', + // We use a temporary, interceptor-free HttpClient for this one + // specific request to avoid the infinite loop caused by the + // AuthInterceptor in the main firebaseHttpClient. + final tokenClient = HttpClient( + baseUrl: 'https://oauth2.googleapis.com', + // The tokenProvider for this client is null because this request + // does not use a Bearer token. + tokenProvider: () async => null, + ); + + final response = + await tokenClient.post>( + '/token', data: 'grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=$signedToken', options: Options( - headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, ), ); - final accessToken = response.data?['access_token'] as String?; + final accessToken = response['access_token'] as String?; if (accessToken == null) { _log.severe('Failed to get access token from Google OAuth.'); throw const OperationFailedException( @@ -288,12 +299,12 @@ class AppDependencies { // The OneSignal client requires the REST API key for authentication. // We use a custom interceptor to add the 'Authorization: Basic ' - // header, as the default AuthInterceptor is hardcoded for 'Bearer'. + // header, as the default AuthInterceptor is hardcoded for 'Bearer' tokens. final oneSignalHttpClient = HttpClient( baseUrl: 'https://onesignal.com/api/v1/', // The tokenProvider is not used here; auth is handled by the interceptor. tokenProvider: () async => null, - interceptors: [ + interceptors: const [ InterceptorsWrapper( onRequest: (options, handler) { options.headers['Authorization'] = From 569a8f78b293156bea7888b2d0a7ed9032b0312c Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 13:44:29 +0100 Subject: [PATCH 47/64] refactor(dependencies): improve code structure and remove unused import --- lib/src/config/app_dependencies.dart | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index 28c128d..89e0cc1 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -1,7 +1,6 @@ // ignore_for_file: public_member_api_docs import 'dart:async'; -import 'dart:convert'; import 'package:core/core.dart'; import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'package:data_mongodb/data_mongodb.dart'; @@ -267,8 +266,7 @@ class AppDependencies { tokenProvider: () async => null, ); - final response = - await tokenClient.post>( + final response = await tokenClient.post>( '/token', data: 'grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=$signedToken', @@ -304,7 +302,7 @@ class AppDependencies { baseUrl: 'https://onesignal.com/api/v1/', // The tokenProvider is not used here; auth is handled by the interceptor. tokenProvider: () async => null, - interceptors: const [ + interceptors: [ InterceptorsWrapper( onRequest: (options, handler) { options.headers['Authorization'] = From fe66da2f6e388b63b322497696661e0a51e39277 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 14:01:26 +0100 Subject: [PATCH 48/64] build(deps): update http-client to version 1.1.0 - Update http-client dependency in pubspec.lock from v1.0.1 to v1.1.0 - Add http-client dependency override in pubspec.yaml to ensure correct version is used --- pubspec.lock | 6 +++--- pubspec.yaml | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 66b24e0..c44273d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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 8ef6836..d8ede36 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -60,3 +60,7 @@ dependency_overrides: git: url: https://github.com/flutter-news-app-full-source-code/core.git ref: d047d9cca684de28848203c564823bb85de4f474 + http_client: + git: + url: https://github.com/flutter-news-app-full-source-code/http-client.git + ref: v1.1.0 From 80e87f9f6334e99686b417a0541a3a2bdaed578d Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 14:02:03 +0100 Subject: [PATCH 49/64] style: format --- ...08103300_add_push_notification_config_to_remote_config.dart | 3 ++- lib/src/services/default_user_preference_limit_service.dart | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) 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 index c23c646..6f2d1a3 100644 --- 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 @@ -30,7 +30,8 @@ class AddPushNotificationConfigToRemoteConfig extends Migration { final remoteConfigId = ObjectId.fromHexString(kRemoteConfigId); // Default structure for the push notification configuration. - final pushNotificationConfig = remoteConfigsFixturesData.first.pushNotificationConfig; + final pushNotificationConfig = + remoteConfigsFixturesData.first.pushNotificationConfig; // Use $set to add the field only if it doesn't exist. // This is an idempotent operation. diff --git a/lib/src/services/default_user_preference_limit_service.dart b/lib/src/services/default_user_preference_limit_service.dart index 957a0a7..dbf404b 100644 --- a/lib/src/services/default_user_preference_limit_service.dart +++ b/lib/src/services/default_user_preference_limit_service.dart @@ -179,7 +179,7 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { '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) { From 71d8b791848cc80865a858d0e76bf4032cb88619 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 14:10:55 +0100 Subject: [PATCH 50/64] refactor(notifications): implement Firebase token provider for HTTP client - Add Firebase token provider to create and exchange JWT for access token - Introduce reusable HttpClient instance for token exchange - Update OneSignal client initialization --- lib/src/config/app_dependencies.dart | 98 ++++++++++++++++++++++------ 1 file changed, 79 insertions(+), 19 deletions(-) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index 89e0cc1..bb87c26 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -234,6 +234,16 @@ class AppDependencies { // 1. Create a JWT signed with the service account's private key. // 2. Exchange this JWT for an access token from Google's token endpoint. final firebaseHttpClient = HttpClient( + baseUrl: + 'https://fcm.googleapis.com/v1/projects/${EnvironmentConfig.firebaseProjectId}/', + tokenProvider: _createFirebaseTokenProvider(), + logger: Logger('FirebasePushNotificationClient'), + ); + + // The OneSignal client requires the REST API key for authentication. + // We use a custom interceptor to add the 'Authorization: Basic ' + // header, as the default AuthInterceptor is hardcoded for 'Bearer' tokens. + final oneSignalHttpClient = HttpClient( baseUrl: 'https://fcm.googleapis.com/v1/projects/${EnvironmentConfig.firebaseProjectId}/', tokenProvider: () async { @@ -294,25 +304,6 @@ class AppDependencies { }, logger: Logger('FirebasePushNotificationClient'), ); - - // The OneSignal client requires the REST API key for authentication. - // We use a custom interceptor to add the 'Authorization: Basic ' - // header, as the default AuthInterceptor is hardcoded for 'Bearer' tokens. - final oneSignalHttpClient = HttpClient( - baseUrl: 'https://onesignal.com/api/v1/', - // The tokenProvider is not used here; auth is handled by the interceptor. - tokenProvider: () async => null, - interceptors: [ - InterceptorsWrapper( - onRequest: (options, handler) { - options.headers['Authorization'] = - 'Basic ${EnvironmentConfig.oneSignalRestApiKey}'; - return handler.next(options); - }, - ), - ], - logger: Logger('OneSignalPushNotificationClient'), - ); // 4. Initialize Repositories headlineRepository = DataRepository(dataClient: headlineClient); topicRepository = DataRepository(dataClient: topicClient); @@ -429,6 +420,75 @@ class AppDependencies { } } + /// Creates a token provider closure for the Firebase HTTP client. + /// + /// This method encapsulates the logic for obtaining a short-lived OAuth2 + /// access token from Google's token endpoint. It creates a single, reused + /// [HttpClient] instance (`tokenClient`) for efficiency. + Future Function() _createFirebaseTokenProvider() { + // This client is created once and reused for all token exchange requests. + // It is intentionally created without the standard AuthInterceptor to + // avoid an infinite loop, as this specific request does not use a + // Bearer token for its own authorization. + final tokenClient = HttpClient( + baseUrl: 'https://oauth2.googleapis.com', + tokenProvider: () async => null, + ); + + return () async { + 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), + ); + + // Step 2: Exchange the JWT for an access token using the reused client. + final response = await tokenClient.post>( + '/token', + data: + 'grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=$signedToken', + options: Options( + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + ), + ); + + final accessToken = response['access_token'] as String?; + if (accessToken == null) { + _log.severe('Failed to get access token from Google OAuth.'); + throw const OperationFailedException( + 'Could not retrieve Firebase access token.', + ); + } + return accessToken; + } catch (e, s) { + _log.severe('Error during Firebase token exchange: $e', e, s); + // Check if the error is already an OperationFailedException to avoid + // re-wrapping it. + if (e is OperationFailedException) { + rethrow; + } + // Wrap other exceptions for consistent error handling. + throw OperationFailedException( + 'Failed to authenticate with Firebase: $e', + ); + } + }; + } + /// Disposes of resources, such as closing the database connection. Future dispose() async { // Only attempt to dispose if initialization has been started. From 1e9ace675d7f25dea7b619c66d09761da5d09da6 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 14:16:22 +0100 Subject: [PATCH 51/64] Revert "refactor(notifications): implement Firebase token provider for HTTP client" This reverts commit 71d8b791848cc80865a858d0e76bf4032cb88619. --- lib/src/config/app_dependencies.dart | 98 ++++++---------------------- 1 file changed, 19 insertions(+), 79 deletions(-) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index bb87c26..89e0cc1 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -234,16 +234,6 @@ class AppDependencies { // 1. Create a JWT signed with the service account's private key. // 2. Exchange this JWT for an access token from Google's token endpoint. final firebaseHttpClient = HttpClient( - baseUrl: - 'https://fcm.googleapis.com/v1/projects/${EnvironmentConfig.firebaseProjectId}/', - tokenProvider: _createFirebaseTokenProvider(), - logger: Logger('FirebasePushNotificationClient'), - ); - - // The OneSignal client requires the REST API key for authentication. - // We use a custom interceptor to add the 'Authorization: Basic ' - // header, as the default AuthInterceptor is hardcoded for 'Bearer' tokens. - final oneSignalHttpClient = HttpClient( baseUrl: 'https://fcm.googleapis.com/v1/projects/${EnvironmentConfig.firebaseProjectId}/', tokenProvider: () async { @@ -304,6 +294,25 @@ class AppDependencies { }, logger: Logger('FirebasePushNotificationClient'), ); + + // The OneSignal client requires the REST API key for authentication. + // We use a custom interceptor to add the 'Authorization: Basic ' + // header, as the default AuthInterceptor is hardcoded for 'Bearer' tokens. + final oneSignalHttpClient = HttpClient( + baseUrl: 'https://onesignal.com/api/v1/', + // The tokenProvider is not used here; auth is handled by the interceptor. + tokenProvider: () async => null, + interceptors: [ + InterceptorsWrapper( + onRequest: (options, handler) { + options.headers['Authorization'] = + 'Basic ${EnvironmentConfig.oneSignalRestApiKey}'; + return handler.next(options); + }, + ), + ], + logger: Logger('OneSignalPushNotificationClient'), + ); // 4. Initialize Repositories headlineRepository = DataRepository(dataClient: headlineClient); topicRepository = DataRepository(dataClient: topicClient); @@ -420,75 +429,6 @@ class AppDependencies { } } - /// Creates a token provider closure for the Firebase HTTP client. - /// - /// This method encapsulates the logic for obtaining a short-lived OAuth2 - /// access token from Google's token endpoint. It creates a single, reused - /// [HttpClient] instance (`tokenClient`) for efficiency. - Future Function() _createFirebaseTokenProvider() { - // This client is created once and reused for all token exchange requests. - // It is intentionally created without the standard AuthInterceptor to - // avoid an infinite loop, as this specific request does not use a - // Bearer token for its own authorization. - final tokenClient = HttpClient( - baseUrl: 'https://oauth2.googleapis.com', - tokenProvider: () async => null, - ); - - return () async { - 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), - ); - - // Step 2: Exchange the JWT for an access token using the reused client. - final response = await tokenClient.post>( - '/token', - data: - 'grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=$signedToken', - options: Options( - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - ), - ); - - final accessToken = response['access_token'] as String?; - if (accessToken == null) { - _log.severe('Failed to get access token from Google OAuth.'); - throw const OperationFailedException( - 'Could not retrieve Firebase access token.', - ); - } - return accessToken; - } catch (e, s) { - _log.severe('Error during Firebase token exchange: $e', e, s); - // Check if the error is already an OperationFailedException to avoid - // re-wrapping it. - if (e is OperationFailedException) { - rethrow; - } - // Wrap other exceptions for consistent error handling. - throw OperationFailedException( - 'Failed to authenticate with Firebase: $e', - ); - } - }; - } - /// Disposes of resources, such as closing the database connection. Future dispose() async { // Only attempt to dispose if initialization has been started. From e47e4abdb9fce4a1ca1ffcb9649a99916779e32f Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 14:30:57 +0100 Subject: [PATCH 52/64] refactor(firebase): extract Firebase access token logic into separate function - Extracted Firebase access token retrieval logic into a separate private function - Improved code readability and maintainability - Reused the extracted function in the firebaseHttpClient initialization --- lib/src/config/app_dependencies.dart | 107 +++++++++++++-------------- 1 file changed, 51 insertions(+), 56 deletions(-) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index 89e0cc1..442ed09 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -115,6 +115,56 @@ class AppDependencies { /// The core logic for initializing all dependencies. /// This method is private and should only be called once by [init]. Future _initializeDependencies() async { + Future getFirebaseAccessToken() async { + 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), + ); + + // Step 2: Exchange the JWT for an access token. + final tokenClient = HttpClient( + baseUrl: 'https://oauth2.googleapis.com', + // The tokenProvider for this client is null because this request + // does not use a Bearer token. + tokenProvider: () async => null, + ); + + 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) { + throw const OperationFailedException( + 'Could not retrieve Firebase access token.', + ); + } + return accessToken; + } catch (e, s) { + _log.severe('Error during Firebase token exchange: $e', e, s); + throw OperationFailedException( + 'Failed to authenticate with Firebase: $e', + ); + } + } + _log.info('Initializing application dependencies...'); try { // 1. Initialize Database Connection @@ -236,62 +286,7 @@ class AppDependencies { final firebaseHttpClient = HttpClient( baseUrl: 'https://fcm.googleapis.com/v1/projects/${EnvironmentConfig.firebaseProjectId}/', - tokenProvider: () async { - 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), - ); - - // Step 2: Exchange the JWT for an access token. - // We use a temporary, interceptor-free HttpClient for this one - // specific request to avoid the infinite loop caused by the - // AuthInterceptor in the main firebaseHttpClient. - final tokenClient = HttpClient( - baseUrl: 'https://oauth2.googleapis.com', - // The tokenProvider for this client is null because this request - // does not use a Bearer token. - tokenProvider: () async => null, - ); - - final response = await tokenClient.post>( - '/token', - data: - 'grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=$signedToken', - options: Options( - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - ), - ); - - final accessToken = response['access_token'] as String?; - if (accessToken == null) { - _log.severe('Failed to get access token from Google OAuth.'); - throw const OperationFailedException( - 'Could not retrieve Firebase access token.', - ); - } - return accessToken; - } catch (e, s) { - _log.severe('Error during Firebase token exchange: $e', e, s); - throw OperationFailedException( - 'Failed to authenticate with Firebase: $e', - ); - } - }, + tokenProvider: getFirebaseAccessToken, logger: Logger('FirebasePushNotificationClient'), ); From b8e03b2b981eb7b3c7cb891b7be22204c247120a Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 14:31:42 +0100 Subject: [PATCH 53/64] fix(firebase_push_notification_client): improve error handling and logging for batch sending - Enhance logging to capture individual failures within a batch - Refine error messages to better reflect partial failures - Ensure all futures complete before processing results, even in case of errors - Introduce more granular error handling for HTTP and unexpected exceptions --- .../firebase_push_notification_client.dart | 50 ++++++++++++++----- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/lib/src/services/firebase_push_notification_client.dart b/lib/src/services/firebase_push_notification_client.dart index 237e287..5db5dfe 100644 --- a/lib/src/services/firebase_push_notification_client.dart +++ b/lib/src/services/firebase_push_notification_client.dart @@ -104,22 +104,46 @@ class FirebasePushNotificationClient implements IPushNotificationClient { }).toList(); try { - // Wait for all notifications in the batch to be sent. // `eagerError: false` ensures that all futures complete, even if some - // fail. This is important for logging all failures, not just the first. - await Future.wait(sendFutures, eagerError: false); - _log.info( - 'Successfully sent Firebase batch of ${deviceTokens.length} ' - 'notifications for project "$projectId".', + // fail. The results list will contain Exception objects for failures. + final results = await Future.wait( + sendFutures, + eagerError: false, ); - } on HttpException catch (e) { - _log.severe('HTTP error sending Firebase batch: ${e.message}', e); - rethrow; + + 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 sending Firebase batch.', e, s); - throw OperationFailedException( - 'Failed to send Firebase batch: $e', - ); + _log.severe('Unexpected error processing Firebase batch results.', e, s); + throw OperationFailedException('Failed to process Firebase batch: $e'); } } } From 99e39bd3d7ffc6b941cc32085a2aabbc039d0d41 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 14:46:14 +0100 Subject: [PATCH 54/64] feat(auth): create dedicated FirebaseAuthenticator service Extracts the Firebase JWT signing and token exchange logic from `app_dependencies.dart` into a new, dedicated `FirebaseAuthenticator` class. This refactoring improves separation of concerns, enhances readability of the dependency setup, and makes the authentication flow testable in isolation. --- lib/src/services/firebase_authenticator.dart | 78 ++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 lib/src/services/firebase_authenticator.dart diff --git a/lib/src/services/firebase_authenticator.dart b/lib/src/services/firebase_authenticator.dart new file mode 100644 index 0000000..c20aa69 --- /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', + ); + } + } +} From 44d43579937ca5457d51c3c2a6929011bd208765 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 14:48:28 +0100 Subject: [PATCH 55/64] refactor(auth): integrate FirebaseAuthenticator service Refactors `app_dependencies.dart` to use the new `FirebaseAuthenticator` service for obtaining Firebase access tokens. This change removes the inline token generation logic, instantiates the new service, and provides it to the `firebaseHttpClient`. This improves separation of concerns and makes the dependency setup more readable and maintainable. --- lib/src/config/app_dependencies.dart | 63 +++++----------------------- 1 file changed, 11 insertions(+), 52 deletions(-) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index 442ed09..9d2f660 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -1,8 +1,8 @@ // ignore_for_file: public_member_api_docs import 'dart:async'; + import 'package:core/core.dart'; -import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'package:data_mongodb/data_mongodb.dart'; import 'package:data_repository/data_repository.dart'; import 'package:email_repository/email_repository.dart'; @@ -17,6 +17,7 @@ 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'; @@ -86,6 +87,7 @@ class AppDependencies { late final RateLimitService rateLimitService; late final CountryQueryService countryQueryService; late final IPushNotificationService pushNotificationService; + late final IFirebaseAuthenticator firebaseAuthenticator; late final IPushNotificationClient firebasePushNotificationClient; late final IPushNotificationClient oneSignalPushNotificationClient; @@ -115,56 +117,6 @@ class AppDependencies { /// The core logic for initializing all dependencies. /// This method is private and should only be called once by [init]. Future _initializeDependencies() async { - Future getFirebaseAccessToken() async { - 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), - ); - - // Step 2: Exchange the JWT for an access token. - final tokenClient = HttpClient( - baseUrl: 'https://oauth2.googleapis.com', - // The tokenProvider for this client is null because this request - // does not use a Bearer token. - tokenProvider: () async => null, - ); - - 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) { - throw const OperationFailedException( - 'Could not retrieve Firebase access token.', - ); - } - return accessToken; - } catch (e, s) { - _log.severe('Error during Firebase token exchange: $e', e, s); - throw OperationFailedException( - 'Failed to authenticate with Firebase: $e', - ); - } - } - _log.info('Initializing application dependencies...'); try { // 1. Initialize Database Connection @@ -277,6 +229,13 @@ class AppDependencies { logger: Logger('DataMongodb'), ); + // --- Initialize Firebase Authenticator --- + // This dedicated service encapsulates the logic for obtaining a Firebase + // access token, keeping the dependency setup clean. + firebaseAuthenticator = FirebaseAuthenticator( + log: Logger('FirebaseAuthenticator'), + ); + // --- Initialize HTTP clients for push notification providers --- // The Firebase client requires a short-lived OAuth2 access token. This @@ -286,7 +245,7 @@ class AppDependencies { final firebaseHttpClient = HttpClient( baseUrl: 'https://fcm.googleapis.com/v1/projects/${EnvironmentConfig.firebaseProjectId}/', - tokenProvider: getFirebaseAccessToken, + tokenProvider: firebaseAuthenticator.getAccessToken, logger: Logger('FirebasePushNotificationClient'), ); From 0d0edbe6dd5ee6ee62e76c12dfc10130e86efbf0 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 14:51:09 +0100 Subject: [PATCH 56/64] refactor(auth): provide FirebaseAuthenticator in root middleware Updates the root middleware to provide the `IFirebaseAuthenticator` service to the request context. This makes the service available for injection throughout the application. --- routes/_middleware.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/routes/_middleware.dart b/routes/_middleware.dart index 987124f..dc90d3b 100644 --- a/routes/_middleware.dart +++ b/routes/_middleware.dart @@ -10,6 +10,7 @@ import 'package:flutter_news_app_api_server_full_source_code/src/registry/data_o import 'package:flutter_news_app_api_server_full_source_code/src/registry/model_registry.dart'; 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/firebase_authenticator.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'; @@ -143,6 +144,11 @@ Handler middleware(Handler handler) { (_) => deps.pushNotificationService, ), ) + .use( + provider( + (_) => deps.firebaseAuthenticator, + ), + ) .use(provider((_) => deps.emailRepository)) .use( provider( From 6c30ed5c150f4ac82e13175966d7a2746f344c1c Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 15:14:07 +0100 Subject: [PATCH 57/64] refactor(config): make push credentials optional Modify `EnvironmentConfig` to allow missing push notification credentials. The getters now return `null` instead of throwing an error, allowing the application to start even if push notifications are not configured. --- lib/src/config/environment_config.dart | 47 ++++++++++++++------------ 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/lib/src/config/environment_config.dart b/lib/src/config/environment_config.dart index 66efc4b..4980420 100644 --- a/lib/src/config/environment_config.dart +++ b/lib/src/config/environment_config.dart @@ -60,12 +60,12 @@ 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 in environment variables.'); } + return value; } @@ -75,7 +75,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. /// @@ -185,34 +196,26 @@ abstract final class EnvironmentConfig { /// Retrieves the Firebase Project ID from the environment. /// - /// The value is read from the `FIREBASE_PROJECT_ID` environment variable. - /// Throws a [StateError] if not set. - static String get firebaseProjectId => _getRequiredEnv('FIREBASE_PROJECT_ID'); + /// 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. - /// Throws a [StateError] if not set. - static String get firebaseClientEmail => - _getRequiredEnv('FIREBASE_CLIENT_EMAIL'); + /// 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. - /// Throws a [StateError] if not set. - static String get firebasePrivateKey => - _getRequiredEnv('FIREBASE_PRIVATE_KEY'); + /// 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. - /// Throws a [StateError] if not set. - static String get oneSignalAppId => _getRequiredEnv('ONESIGNAL_APP_ID'); + /// 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. - /// Throws a [StateError] if not set. - static String get oneSignalRestApiKey => - _getRequiredEnv('ONESIGNAL_REST_API_KEY'); + /// The value is read from the `ONESIGNAL_REST_API_KEY` environment variable, if available. + static String? get oneSignalRestApiKey => _getEnv('ONESIGNAL_REST_API_KEY'); } From 43c748ff7d13ad4f6cb6d1c5b08ad0f2af457509 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 15:25:16 +0100 Subject: [PATCH 58/64] refactor(push): implement conditional push notification client initialization - Add null safety to firebaseAuthenticator and push notification clients - Initialize FirebasePushNotificationClient only if credentials are present - Initialize OneSignalPushNotificationClient only if credentials are present - Move push notification client initialization before repository initialization - Log appropriate messages for initialized and disabled push notification clients --- lib/src/config/app_dependencies.dart | 139 +++++++++++++++------------ 1 file changed, 77 insertions(+), 62 deletions(-) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index 9d2f660..3a6c980 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -67,11 +67,11 @@ class AppDependencies { late final DataRepository userRepository; late final DataRepository userAppSettingsRepository; late final DataRepository - userContentPreferencesRepository; + userContentPreferencesRepository; late final DataRepository - pushNotificationDeviceRepository; + pushNotificationDeviceRepository; late final DataRepository - pushNotificationSubscriptionRepository; + pushNotificationSubscriptionRepository; late final DataRepository remoteConfigRepository; late final EmailRepository emailRepository; @@ -87,9 +87,9 @@ class AppDependencies { late final RateLimitService rateLimitService; late final CountryQueryService countryQueryService; late final IPushNotificationService pushNotificationService; - late final IFirebaseAuthenticator firebaseAuthenticator; - late final IPushNotificationClient firebasePushNotificationClient; - late final IPushNotificationClient oneSignalPushNotificationClient; + late final IFirebaseAuthenticator? firebaseAuthenticator; + late final IPushNotificationClient? firebasePushNotificationClient; + late final IPushNotificationClient? oneSignalPushNotificationClient; /// Initializes all application dependencies. /// @@ -222,51 +222,78 @@ class AppDependencies { ); final pushNotificationSubscriptionClient = DataMongodb( - connectionManager: _mongoDbConnectionManager, - modelName: 'push_notification_subscriptions', - fromJson: PushNotificationSubscription.fromJson, - toJson: (item) => item.toJson(), - logger: Logger('DataMongodb'), - ); - - // --- Initialize Firebase Authenticator --- - // This dedicated service encapsulates the logic for obtaining a Firebase - // access token, keeping the dependency setup clean. - firebaseAuthenticator = FirebaseAuthenticator( - log: Logger('FirebaseAuthenticator'), - ); - - // --- Initialize HTTP clients for push notification providers --- - - // The Firebase client requires a short-lived OAuth2 access token. This - // tokenProvider implements the required two-legged OAuth flow: - // 1. Create a JWT signed with the service account's private key. - // 2. Exchange this JWT for an access token from Google's token endpoint. - final firebaseHttpClient = HttpClient( - baseUrl: - 'https://fcm.googleapis.com/v1/projects/${EnvironmentConfig.firebaseProjectId}/', - tokenProvider: firebaseAuthenticator.getAccessToken, - logger: Logger('FirebasePushNotificationClient'), - ); + 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; + } - // The OneSignal client requires the REST API key for authentication. - // We use a custom interceptor to add the 'Authorization: Basic ' - // header, as the default AuthInterceptor is hardcoded for 'Bearer' tokens. - final oneSignalHttpClient = HttpClient( - baseUrl: 'https://onesignal.com/api/v1/', - // The tokenProvider is not used here; auth is handled by the interceptor. - tokenProvider: () async => null, - interceptors: [ - InterceptorsWrapper( - onRequest: (options, handler) { - options.headers['Authorization'] = - 'Basic ${EnvironmentConfig.oneSignalRestApiKey}'; - return handler.next(options); - }, - ), - ], - logger: Logger('OneSignalPushNotificationClient'), - ); // 4. Initialize Repositories headlineRepository = DataRepository(dataClient: headlineClient); topicRepository = DataRepository(dataClient: topicClient); @@ -306,18 +333,6 @@ class AppDependencies { emailRepository = EmailRepository(emailClient: emailClient); - // Initialize Push Notification Clients - firebasePushNotificationClient = FirebasePushNotificationClient( - httpClient: firebaseHttpClient, - projectId: EnvironmentConfig.firebaseProjectId, - log: Logger('FirebasePushNotificationClient'), - ); - oneSignalPushNotificationClient = OneSignalPushNotificationClient( - httpClient: oneSignalHttpClient, - appId: EnvironmentConfig.oneSignalAppId, - log: Logger('OneSignalPushNotificationClient'), - ); - // 5. Initialize Services tokenBlacklistService = MongoDbTokenBlacklistService( connectionManager: _mongoDbConnectionManager, From 5de379a8835d824c4da006938a1d3517aa0b5c6d Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 15:25:33 +0100 Subject: [PATCH 59/64] refactor(push): make notification service resilient to null clients Updates `DefaultPushNotificationService` to accept nullable `IPushNotificationClient` instances. This change makes the service resilient to missing push notification credentials. It now checks if the configured primary provider's client was initialized before attempting to send a notification, logging a severe error and aborting if the client is unavailable. This fixes the compilation errors in `app_dependencies.dart`. --- .../services/push_notification_service.dart | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/lib/src/services/push_notification_service.dart b/lib/src/services/push_notification_service.dart index bad05cb..b17c2ec 100644 --- a/lib/src/services/push_notification_service.dart +++ b/lib/src/services/push_notification_service.dart @@ -35,8 +35,8 @@ class DefaultPushNotificationService implements IPushNotificationService { required DataRepository pushNotificationSubscriptionRepository, required DataRepository remoteConfigRepository, - required IPushNotificationClient firebaseClient, - required IPushNotificationClient oneSignalClient, + required IPushNotificationClient? firebaseClient, + required IPushNotificationClient? oneSignalClient, required Logger log, }) : _pushNotificationDeviceRepository = pushNotificationDeviceRepository, _pushNotificationSubscriptionRepository = @@ -51,8 +51,8 @@ class DefaultPushNotificationService implements IPushNotificationService { final DataRepository _pushNotificationSubscriptionRepository; final DataRepository _remoteConfigRepository; - final IPushNotificationClient _firebaseClient; - final IPushNotificationClient _oneSignalClient; + final IPushNotificationClient? _firebaseClient; + final IPushNotificationClient? _oneSignalClient; final Logger _log; // Assuming a fixed ID for the RemoteConfig document @@ -87,6 +87,26 @@ class DefaultPushNotificationService implements IPushNotificationService { '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 @@ -181,11 +201,6 @@ class DefaultPushNotificationService implements IPushNotificationService { }, ); - // 8. Select the correct client and send the notifications. - final client = primaryProvider == PushNotificationProvider.firebase - ? _firebaseClient - : _oneSignalClient; - await client.sendBulkNotifications( deviceTokens: tokens, payload: payload, From 7ec5e75253b6d6c702688ae364f2e51d19644c78 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 15:28:59 +0100 Subject: [PATCH 60/64] docs(env): update documentation for conditional environment variables - Change REQUIRED to CONDITIONALLY REQUIRED for Firebase and OneSignal variables - Add clarification that the server will start without these variables - Emphasize the importance of these variables for their respective notification services --- .env.example | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index ed2c174..7249b9f 100644 --- a/.env.example +++ b/.env.example @@ -57,23 +57,28 @@ # Defaults to 15 minutes if not specified. # COUNTRY_SERVICE_CACHE_MINUTES=15 -# REQUIRED: The Firebase Project ID for push notifications. +# 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" -# REQUIRED: The Firebase Client Email for push notifications. +# 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" -# REQUIRED: The Firebase Private Key for push notifications. +# 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" -# REQUIRED: The OneSignal App ID for push notifications. +# 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" -# REQUIRED: The OneSignal REST API Key for server-side push notifications. +# 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" From f0badfbae1114202040080ecc8c0e59fc81674e5 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 15:33:47 +0100 Subject: [PATCH 61/64] fix(auth): correct null-safety in firebase authenticator Adds a null-assertion operator to `firebasePrivateKey` within the `FirebaseAuthenticator`. This is safe as the authenticator is only created when all Firebase credentials are confirmed to be non-null, resolving the compile-time error. --- lib/src/services/firebase_authenticator.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/services/firebase_authenticator.dart b/lib/src/services/firebase_authenticator.dart index c20aa69..cd10e08 100644 --- a/lib/src/services/firebase_authenticator.dart +++ b/lib/src/services/firebase_authenticator.dart @@ -32,7 +32,7 @@ class FirebaseAuthenticator implements IFirebaseAuthenticator { _log.info('Requesting new Firebase access token...'); try { // Step 1: Create and sign the JWT. - final pem = EnvironmentConfig.firebasePrivateKey.replaceAll(r'\n', '\n'); + final pem = EnvironmentConfig.firebasePrivateKey!.replaceAll(r'\n', '\n'); final privateKey = RSAPrivateKey(pem); final jwt = JWT( {'scope': 'https://www.googleapis.com/auth/cloud-platform'}, From ca9f23c5646d01de73fd75fce0455328f9b973a5 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 15:34:46 +0100 Subject: [PATCH 62/64] fix(deps): correct nullable provider and import order Updates the root middleware to correctly provide the nullable `IFirebaseAuthenticator?` type, resolving a compile-time error. Also sorts the import directives to fix a lint warning. --- routes/_middleware.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/routes/_middleware.dart b/routes/_middleware.dart index dc90d3b..449134f 100644 --- a/routes/_middleware.dart +++ b/routes/_middleware.dart @@ -10,8 +10,8 @@ import 'package:flutter_news_app_api_server_full_source_code/src/registry/data_o import 'package:flutter_news_app_api_server_full_source_code/src/registry/model_registry.dart'; 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/firebase_authenticator.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'; @@ -145,7 +145,7 @@ Handler middleware(Handler handler) { ), ) .use( - provider( + provider( (_) => deps.firebaseAuthenticator, ), ) From 85150c4191353e5026ae1656c44570d768f86d0e Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 15:35:42 +0100 Subject: [PATCH 63/64] style: format --- lib/src/config/app_dependencies.dart | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index 3a6c980..fe4ca89 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -67,11 +67,11 @@ class AppDependencies { late final DataRepository userRepository; late final DataRepository userAppSettingsRepository; late final DataRepository - userContentPreferencesRepository; + userContentPreferencesRepository; late final DataRepository - pushNotificationDeviceRepository; + pushNotificationDeviceRepository; late final DataRepository - pushNotificationSubscriptionRepository; + pushNotificationSubscriptionRepository; late final DataRepository remoteConfigRepository; late final EmailRepository emailRepository; @@ -222,12 +222,12 @@ class AppDependencies { ); final pushNotificationSubscriptionClient = DataMongodb( - connectionManager: _mongoDbConnectionManager, - modelName: 'push_notification_subscriptions', - fromJson: PushNotificationSubscription.fromJson, - toJson: (item) => item.toJson(), - logger: Logger('DataMongodb'), - ); + connectionManager: _mongoDbConnectionManager, + modelName: 'push_notification_subscriptions', + fromJson: PushNotificationSubscription.fromJson, + toJson: (item) => item.toJson(), + logger: Logger('DataMongodb'), + ); // --- Conditionally Initialize Push Notification Clients --- @@ -240,8 +240,9 @@ class AppDependencies { fcmClientEmail != null && fcmPrivateKey != null) { _log.info('Firebase credentials found. Initializing Firebase client.'); - firebaseAuthenticator = - FirebaseAuthenticator(log: Logger('FirebaseAuthenticator')); + firebaseAuthenticator = FirebaseAuthenticator( + log: Logger('FirebaseAuthenticator'), + ); final firebaseHttpClient = HttpClient( baseUrl: 'https://fcm.googleapis.com/v1/projects/$fcmProjectId/', @@ -267,7 +268,9 @@ class AppDependencies { final osApiKey = EnvironmentConfig.oneSignalRestApiKey; if (osAppId != null && osApiKey != null) { - _log.info('OneSignal credentials found. Initializing OneSignal client.'); + _log.info( + 'OneSignal credentials found. Initializing OneSignal client.', + ); final oneSignalHttpClient = HttpClient( baseUrl: 'https://onesignal.com/api/v1/', tokenProvider: () async => null, From 30d5d0213c01358439e1c87d05b9557e12b6e3de Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 8 Nov 2025 15:47:17 +0100 Subject: [PATCH 64/64] fix(config): treat empty env variables as null Updates the `_getEnv` helper to return `null` if an environment variable is found but is an empty string. This makes the configuration handling more robust by preventing services from being initialized with invalid empty credentials, which would cause runtime failures. --- lib/src/config/environment_config.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/src/config/environment_config.dart b/lib/src/config/environment_config.dart index 4980420..ca56968 100644 --- a/lib/src/config/environment_config.dart +++ b/lib/src/config/environment_config.dart @@ -63,7 +63,8 @@ abstract final class EnvironmentConfig { static String? _getEnv(String key) { final value = _env[key]; if (value == null || value.isEmpty) { - _log.warning('$key not found in environment variables.'); + _log.warning('$key not found or is empty in environment variables.'); + return null; } return value;