Skip to content

Commit 7f69079

Browse files
authored
Merge pull request #93 from flutter-news-app-full-source-code/feat/integrate-with-notification-service-provider
Feat/integrate with notification service provider
2 parents e141a59 + 30d5d02 commit 7f69079

22 files changed

+1171
-30
lines changed

.env.example

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,29 @@
5656
# OPTIONAL: The cache duration for the CountryQueryService, in minutes.
5757
# Defaults to 15 minutes if not specified.
5858
# COUNTRY_SERVICE_CACHE_MINUTES=15
59+
60+
# CONDITIONALLY REQUIRED: The Firebase Project ID for push notifications.
61+
# The server will start without this, but it is required to use Firebase for notifications.
62+
# This is part of your Firebase service account credentials.
63+
# FIREBASE_PROJECT_ID="your-firebase-project-id"
64+
65+
# CONDITIONALLY REQUIRED: The Firebase Client Email for push notifications.
66+
# The server will start without this, but it is required to use Firebase for notifications.
67+
# This is part of your Firebase service account credentials.
68+
# FIREBASE_CLIENT_EMAIL="your-firebase-client-email"
69+
70+
# CONDITIONALLY REQUIRED: The Firebase Private Key for push notifications.
71+
# The server will start without this, but it is required to use Firebase for notifications.
72+
# This is part of your Firebase service account credentials.
73+
# Ensure this is stored securely and correctly formatted (e.g., replace newlines with \n if needed for single-line env var).
74+
# FIREBASE_PRIVATE_KEY="your-firebase-private-key"
75+
76+
# CONDITIONALLY REQUIRED: The OneSignal App ID for push notifications.
77+
# The server will start without this, but it is required to use OneSignal for notifications.
78+
# This identifies your application within OneSignal.
79+
# ONESIGNAL_APP_ID="your-onesignal-app-id"
80+
81+
# CONDITIONALLY REQUIRED: The OneSignal REST API Key for server-side push notifications.
82+
# The server will start without this, but it is required to use OneSignal for notifications.
83+
# This is used to authenticate with the OneSignal API.
84+
# ONESIGNAL_REST_API_KEY="your-onesignal-rest-api-key"

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,16 @@ The data API is equipped with powerful querying capabilities, enabling rich, hig
7171
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.
7272
> **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.
7373
74+
---
75+
76+
### 📲 Dynamic & Personalized Notifications
77+
A complete, multi-provider notification engine that empowers you to engage users with timely, relevant, and personalized alerts.
78+
- **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.
79+
- **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.
80+
- **Flexible Delivery Mechanisms:** The system is architected to support multiple notification types for each subscription, from immediate alerts to scheduled daily or weekly digests.
81+
- **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.
82+
> **Your Advantage:** You get a complete, secure, and scalable notification system that enhances user engagement and can be managed entirely from the web dashboard.
83+
7484
</details>
7585

7686
<details>

lib/src/config/app_dependencies.dart

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// ignore_for_file: public_member_api_docs
22

33
import 'dart:async';
4+
45
import 'package:core/core.dart';
56
import 'package:data_mongodb/data_mongodb.dart';
67
import 'package:data_repository/data_repository.dart';
@@ -16,10 +17,15 @@ import 'package:flutter_news_app_api_server_full_source_code/src/services/dashbo
1617
import 'package:flutter_news_app_api_server_full_source_code/src/services/database_migration_service.dart';
1718
import 'package:flutter_news_app_api_server_full_source_code/src/services/database_seeding_service.dart';
1819
import 'package:flutter_news_app_api_server_full_source_code/src/services/default_user_preference_limit_service.dart';
20+
import 'package:flutter_news_app_api_server_full_source_code/src/services/firebase_authenticator.dart';
21+
import 'package:flutter_news_app_api_server_full_source_code/src/services/firebase_push_notification_client.dart';
1922
import 'package:flutter_news_app_api_server_full_source_code/src/services/jwt_auth_token_service.dart';
2023
import 'package:flutter_news_app_api_server_full_source_code/src/services/mongodb_rate_limit_service.dart';
2124
import 'package:flutter_news_app_api_server_full_source_code/src/services/mongodb_token_blacklist_service.dart';
2225
import 'package:flutter_news_app_api_server_full_source_code/src/services/mongodb_verification_code_storage_service.dart';
26+
import 'package:flutter_news_app_api_server_full_source_code/src/services/onesignal_push_notification_client.dart';
27+
import 'package:flutter_news_app_api_server_full_source_code/src/services/push_notification_client.dart';
28+
import 'package:flutter_news_app_api_server_full_source_code/src/services/push_notification_service.dart';
2329
import 'package:flutter_news_app_api_server_full_source_code/src/services/rate_limit_service.dart';
2430
import 'package:flutter_news_app_api_server_full_source_code/src/services/token_blacklist_service.dart';
2531
import 'package:flutter_news_app_api_server_full_source_code/src/services/user_preference_limit_service.dart';
@@ -62,6 +68,10 @@ class AppDependencies {
6268
late final DataRepository<UserAppSettings> userAppSettingsRepository;
6369
late final DataRepository<UserContentPreferences>
6470
userContentPreferencesRepository;
71+
late final DataRepository<PushNotificationDevice>
72+
pushNotificationDeviceRepository;
73+
late final DataRepository<PushNotificationSubscription>
74+
pushNotificationSubscriptionRepository;
6575
late final DataRepository<RemoteConfig> remoteConfigRepository;
6676
late final EmailRepository emailRepository;
6777

@@ -76,6 +86,10 @@ class AppDependencies {
7686
late final UserPreferenceLimitService userPreferenceLimitService;
7787
late final RateLimitService rateLimitService;
7888
late final CountryQueryService countryQueryService;
89+
late final IPushNotificationService pushNotificationService;
90+
late final IFirebaseAuthenticator? firebaseAuthenticator;
91+
late final IPushNotificationClient? firebasePushNotificationClient;
92+
late final IPushNotificationClient? oneSignalPushNotificationClient;
7993

8094
/// Initializes all application dependencies.
8195
///
@@ -198,6 +212,91 @@ class AppDependencies {
198212
logger: Logger('DataMongodb<RemoteConfig>'),
199213
);
200214

215+
// Initialize Data Clients for Push Notifications
216+
final pushNotificationDeviceClient = DataMongodb<PushNotificationDevice>(
217+
connectionManager: _mongoDbConnectionManager,
218+
modelName: 'push_notification_devices',
219+
fromJson: PushNotificationDevice.fromJson,
220+
toJson: (item) => item.toJson(),
221+
logger: Logger('DataMongodb<PushNotificationDevice>'),
222+
);
223+
final pushNotificationSubscriptionClient =
224+
DataMongodb<PushNotificationSubscription>(
225+
connectionManager: _mongoDbConnectionManager,
226+
modelName: 'push_notification_subscriptions',
227+
fromJson: PushNotificationSubscription.fromJson,
228+
toJson: (item) => item.toJson(),
229+
logger: Logger('DataMongodb<PushNotificationSubscription>'),
230+
);
231+
232+
// --- Conditionally Initialize Push Notification Clients ---
233+
234+
// Firebase
235+
final fcmProjectId = EnvironmentConfig.firebaseProjectId;
236+
final fcmClientEmail = EnvironmentConfig.firebaseClientEmail;
237+
final fcmPrivateKey = EnvironmentConfig.firebasePrivateKey;
238+
239+
if (fcmProjectId != null &&
240+
fcmClientEmail != null &&
241+
fcmPrivateKey != null) {
242+
_log.info('Firebase credentials found. Initializing Firebase client.');
243+
firebaseAuthenticator = FirebaseAuthenticator(
244+
log: Logger('FirebaseAuthenticator'),
245+
);
246+
247+
final firebaseHttpClient = HttpClient(
248+
baseUrl: 'https://fcm.googleapis.com/v1/projects/$fcmProjectId/',
249+
tokenProvider: firebaseAuthenticator!.getAccessToken,
250+
logger: Logger('FirebasePushNotificationClient'),
251+
);
252+
253+
firebasePushNotificationClient = FirebasePushNotificationClient(
254+
httpClient: firebaseHttpClient,
255+
projectId: fcmProjectId,
256+
log: Logger('FirebasePushNotificationClient'),
257+
);
258+
} else {
259+
_log.warning(
260+
'One or more Firebase credentials not found. Firebase push notifications will be disabled.',
261+
);
262+
firebaseAuthenticator = null;
263+
firebasePushNotificationClient = null;
264+
}
265+
266+
// OneSignal
267+
final osAppId = EnvironmentConfig.oneSignalAppId;
268+
final osApiKey = EnvironmentConfig.oneSignalRestApiKey;
269+
270+
if (osAppId != null && osApiKey != null) {
271+
_log.info(
272+
'OneSignal credentials found. Initializing OneSignal client.',
273+
);
274+
final oneSignalHttpClient = HttpClient(
275+
baseUrl: 'https://onesignal.com/api/v1/',
276+
tokenProvider: () async => null,
277+
interceptors: [
278+
InterceptorsWrapper(
279+
onRequest: (options, handler) {
280+
options.headers['Authorization'] = 'Basic $osApiKey';
281+
return handler.next(options);
282+
},
283+
),
284+
],
285+
logger: Logger('OneSignalPushNotificationClient'),
286+
);
287+
288+
oneSignalPushNotificationClient = OneSignalPushNotificationClient(
289+
httpClient: oneSignalHttpClient,
290+
appId: osAppId,
291+
log: Logger('OneSignalPushNotificationClient'),
292+
);
293+
} else {
294+
_log.warning(
295+
'One or more OneSignal credentials not found. OneSignal push notifications will be disabled.',
296+
);
297+
oneSignalPushNotificationClient = null;
298+
}
299+
201300
// 4. Initialize Repositories
202301
headlineRepository = DataRepository(dataClient: headlineClient);
203302
topicRepository = DataRepository(dataClient: topicClient);
@@ -212,6 +311,12 @@ class AppDependencies {
212311
dataClient: userContentPreferencesClient,
213312
);
214313
remoteConfigRepository = DataRepository(dataClient: remoteConfigClient);
314+
pushNotificationDeviceRepository = DataRepository(
315+
dataClient: pushNotificationDeviceClient,
316+
);
317+
pushNotificationSubscriptionRepository = DataRepository(
318+
dataClient: pushNotificationSubscriptionClient,
319+
);
215320
// Configure the HTTP client for SendGrid.
216321
// The HttpClient's AuthInterceptor will use the tokenProvider to add
217322
// the 'Authorization: Bearer <SENDGRID_API_KEY>' header.
@@ -275,6 +380,15 @@ class AppDependencies {
275380
log: Logger('CountryQueryService'),
276381
cacheDuration: EnvironmentConfig.countryServiceCacheDuration,
277382
);
383+
pushNotificationService = DefaultPushNotificationService(
384+
pushNotificationDeviceRepository: pushNotificationDeviceRepository,
385+
pushNotificationSubscriptionRepository:
386+
pushNotificationSubscriptionRepository,
387+
remoteConfigRepository: remoteConfigRepository,
388+
firebaseClient: firebasePushNotificationClient,
389+
oneSignalClient: oneSignalPushNotificationClient,
390+
log: Logger('DefaultPushNotificationService'),
391+
);
278392

279393
_log.info('Application dependencies initialized successfully.');
280394
// Signal that initialization has completed successfully.

lib/src/config/environment_config.dart

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,13 @@ abstract final class EnvironmentConfig {
6060
return env; // Return even if fallback
6161
}
6262

63-
static String _getRequiredEnv(String key) {
63+
static String? _getEnv(String key) {
6464
final value = _env[key];
6565
if (value == null || value.isEmpty) {
66-
_log.severe('$key not found in environment variables.');
67-
throw StateError('FATAL: $key environment variable is not set.');
66+
_log.warning('$key not found or is empty in environment variables.');
67+
return null;
6868
}
69+
6970
return value;
7071
}
7172

@@ -75,7 +76,18 @@ abstract final class EnvironmentConfig {
7576
///
7677
/// Throws a [StateError] if the `DATABASE_URL` environment variable is not
7778
/// set, as the application cannot function without it.
78-
static String get databaseUrl => _getRequiredEnv('DATABASE_URL');
79+
static String get databaseUrl => _getRequiredEnv(
80+
'DATABASE_URL',
81+
);
82+
83+
static String _getRequiredEnv(String key) {
84+
final value = _env[key];
85+
if (value == null || value.isEmpty) {
86+
_log.severe('$key not found in environment variables.');
87+
throw StateError('FATAL: $key environment variable is not set.');
88+
}
89+
return value;
90+
}
7991

8092
/// Retrieves the JWT secret key from the environment.
8193
///
@@ -182,4 +194,29 @@ abstract final class EnvironmentConfig {
182194
int.tryParse(_env['COUNTRY_SERVICE_CACHE_MINUTES'] ?? '15') ?? 15;
183195
return Duration(minutes: minutes);
184196
}
197+
198+
/// Retrieves the Firebase Project ID from the environment.
199+
///
200+
/// The value is read from the `FIREBASE_PROJECT_ID` environment variable, if available.
201+
static String? get firebaseProjectId => _getEnv('FIREBASE_PROJECT_ID');
202+
203+
/// Retrieves the Firebase Client Email from the environment.
204+
///
205+
/// The value is read from the `FIREBASE_CLIENT_EMAIL` environment variable, if available.
206+
static String? get firebaseClientEmail => _getEnv('FIREBASE_CLIENT_EMAIL');
207+
208+
/// Retrieves the Firebase Private Key from the environment.
209+
///
210+
/// The value is read from the `FIREBASE_PRIVATE_KEY` environment variable, if available.
211+
static String? get firebasePrivateKey => _getEnv('FIREBASE_PRIVATE_KEY');
212+
213+
/// Retrieves the OneSignal App ID from the environment.
214+
///
215+
/// The value is read from the `ONESIGNAL_APP_ID` environment variable, if available.
216+
static String? get oneSignalAppId => _getEnv('ONESIGNAL_APP_ID');
217+
218+
/// Retrieves the OneSignal REST API Key from the environment.
219+
///
220+
/// The value is read from the `ONESIGNAL_REST_API_KEY` environment variable, if available.
221+
static String? get oneSignalRestApiKey => _getEnv('ONESIGNAL_REST_API_KEY');
185222
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import 'package:flutter_news_app_api_server_full_source_code/src/database/migration.dart';
2+
import 'package:logging/logging.dart';
3+
import 'package:mongo_dart/mongo_dart.dart';
4+
5+
/// Migration to add the `isBreaking` field to existing `Headline` documents.
6+
///
7+
/// This migration ensures that all existing documents in the `headlines`
8+
/// collection have the `isBreaking` boolean field, defaulting to `false`
9+
/// if it does not already exist. This is crucial for schema consistency
10+
/// when introducing the breaking news feature.
11+
class AddIsBreakingToHeadlines extends Migration {
12+
/// {@macro add_is_breaking_to_headlines}
13+
AddIsBreakingToHeadlines()
14+
: super(
15+
prDate: '20251107000000',
16+
prId: '71',
17+
prSummary:
18+
'Adds the isBreaking field to existing Headline documents, '
19+
'defaulting to false.',
20+
);
21+
22+
@override
23+
Future<void> up(Db db, Logger log) async {
24+
final collection = db.collection('headlines');
25+
26+
log.info(
27+
'Attempting to add "isBreaking: false" to Headline documents '
28+
'where the field is missing...',
29+
);
30+
31+
// Update all documents in the 'headlines' collection that do not
32+
// already have the 'isBreaking' field, setting it to false.
33+
final updateResult = await collection.updateMany(
34+
// Select documents where 'isBreaking' does not exist.
35+
where.notExists('isBreaking'),
36+
modify.set('isBreaking', false), // Set isBreaking to false
37+
);
38+
39+
log.info(
40+
'Added "isBreaking: false" to ${updateResult.nModified} Headline documents.',
41+
);
42+
}
43+
44+
@override
45+
Future<void> down(Db db, Logger log) async {
46+
log.warning(
47+
'Reverting "AddIsBreakingToHeadlines" is not supported. '
48+
'The "isBreaking" field would need to be manually removed if required.',
49+
);
50+
}
51+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import 'package:core/core.dart';
2+
import 'package:flutter_news_app_api_server_full_source_code/src/database/migration.dart';
3+
import 'package:logging/logging.dart';
4+
import 'package:mongo_dart/mongo_dart.dart';
5+
6+
/// {@template add_push_notification_config_to_remote_config}
7+
/// A database migration to add the `pushNotificationConfig` field to the
8+
/// `remote_configs` document.
9+
///
10+
/// This migration ensures that the `remote_configs` document contains the
11+
/// necessary structure for push notification settings, preventing errors when
12+
/// the application starts and tries to access this configuration. It is
13+
/// designed to be idempotent and safe to run multiple times.
14+
/// {@endtemplate}
15+
class AddPushNotificationConfigToRemoteConfig extends Migration {
16+
/// {@macro add_push_notification_config_to_remote_config}
17+
AddPushNotificationConfigToRemoteConfig()
18+
: super(
19+
prId: '71',
20+
prSummary:
21+
'Add pushNotificationConfig field to the remote_configs document.',
22+
prDate: '20251108103300',
23+
);
24+
25+
@override
26+
Future<void> up(Db db, Logger log) async {
27+
log.info('Running up migration: $prSummary');
28+
29+
final remoteConfigCollection = db.collection('remote_configs');
30+
final remoteConfigId = ObjectId.fromHexString(kRemoteConfigId);
31+
32+
// Default structure for the push notification configuration.
33+
final pushNotificationConfig =
34+
remoteConfigsFixturesData.first.pushNotificationConfig;
35+
36+
// Use $set to add the field only if it doesn't exist.
37+
// This is an idempotent operation.
38+
await remoteConfigCollection.updateOne(
39+
where
40+
.id(remoteConfigId)
41+
.and(
42+
where.notExists('pushNotificationConfig'),
43+
),
44+
modify.set('pushNotificationConfig', pushNotificationConfig.toJson()),
45+
);
46+
47+
log.info('Successfully completed up migration for $prDate.');
48+
}
49+
50+
@override
51+
Future<void> down(Db db, Logger log) async {
52+
log.info('Running down migration: $prSummary');
53+
// This migration is additive. The `down` method will unset the field.
54+
final remoteConfigCollection = db.collection('remote_configs');
55+
final remoteConfigId = ObjectId.fromHexString(kRemoteConfigId);
56+
await remoteConfigCollection.updateOne(
57+
where.id(remoteConfigId),
58+
modify.unset('pushNotificationConfig'),
59+
);
60+
log.info('Successfully completed down migration for $prDate.');
61+
}
62+
}

0 commit comments

Comments
 (0)