Skip to content
Merged
Show file tree
Hide file tree
Changes from 56 commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
4755f4e
build(deps): update core dependency to latest commit
fulleni Nov 7, 2025
62b4377
feat(notifications): add environment variables for push notifications
fulleni Nov 7, 2025
a0df8f5
feat(database): add isBreaking field to existing Headline documents
fulleni Nov 7, 2025
35490a5
feat(database): add migration to introduce isBreaking field in headlines
fulleni Nov 7, 2025
04c2df5
feat(core): add IPushNotificationClient interface
fulleni Nov 7, 2025
62355f5
feat(notifications): add Firebase Cloud Messaging client implementation
fulleni Nov 7, 2025
76b6065
feat(push-notification): implement OneSignal push notification client
fulleni Nov 7, 2025
79e9beb
feat(push-notification): implement push notification service
fulleni Nov 7, 2025
d771d16
feat(rbac): add permission for sending breaking news notifications
fulleni Nov 7, 2025
7bccf20
feat(rbac): add push notification permission for dashboard publishers
fulleni Nov 7, 2025
e6c7701
feat(config): add environment variables for Firebase and OneSignal
fulleni Nov 7, 2025
2f69a26
feat(routes): integrate push notification service
fulleni Nov 7, 2025
2d2de04
feat(push-notification): implement push notification service and infr…
fulleni Nov 7, 2025
ec916f7
feat(headline): add breaking news notification
fulleni Nov 7, 2025
f928619
perf(database): optimize query for updating headlines with isBreaking…
fulleni Nov 8, 2025
f59192f
feat(push-notification): implement token providers for Firebase and O…
fulleni Nov 8, 2025
83b05c6
fix(config): correct environment variable name for OneSignal API key
fulleni Nov 8, 2025
13bfac8
feat(DataOperationRegistry): enhance breaking news notification process
fulleni Nov 8, 2025
4331d5e
feat(limit_service): add notification subscription limits
fulleni Nov 8, 2025
bbdc33d
feat(database): add indexes for push notification collections
fulleni Nov 8, 2025
ef8d582
feat(push-notification): add bulk notification send function
fulleni Nov 8, 2025
c1a0d71
feat(firebase_push_notification_client): implement bulk messaging wit…
fulleni Nov 8, 2025
0f81647
refactor(notifications): implement bulk sending for OneSignal client
fulleni Nov 8, 2025
87fdb79
refactor(push-notification): optimize breaking news notification deli…
fulleni Nov 8, 2025
b5dcc80
docs(README): add detailed description of notification features
fulleni Nov 8, 2025
28d9b52
style: format
fulleni Nov 8, 2025
957edd9
refactor(user): improve notification subscription limit logic
fulleni Nov 8, 2025
3e87476
refactor(push-notification): remove unused user repository dependency
fulleni Nov 8, 2025
1566650
refactor(lib): remove unnecessary null check for pushConfig
fulleni Nov 8, 2025
a182a17
fix(database): add missing notificationSubscriptions field to user seed
fulleni Nov 8, 2025
7328e4b
fix(auth_service): add notificationSubscriptions to create method
fulleni Nov 8, 2025
3baf1e8
feat(rbac): add user-owned push notification device permissions
fulleni Nov 8, 2025
f34f46e
feat(push_notification): implement device registration and deletion
fulleni Nov 8, 2025
133e4d5
build(deps): update core dependency
fulleni Nov 8, 2025
5cb3e61
feat(database): add push notification config to remote config
fulleni Nov 8, 2025
e16d63c
refactor(config): correctly configure push notification clients
fulleni Nov 8, 2025
1a351ac
refactor(notifications): simplify IPushNotificationClient interface
fulleni Nov 8, 2025
acfaf32
refactor(notifications): align Firebase client with new architecture
fulleni Nov 8, 2025
3fea4a7
refactor(notifications): align OneSignal client with new architecture
fulleni Nov 8, 2025
2fb5649
refactor(notifications): implement server-authoritative push logic
fulleni Nov 8, 2025
036427b
refactor(notifications): implement fire-and-forget for breaking news
fulleni Nov 8, 2025
a6fba3f
feat(database): add unique indexes for push notification provider tokens
fulleni Nov 8, 2025
3827b21
fix(notifications): remove unnecessary formatting and headers
fulleni Nov 8, 2025
493e8cd
fix(database): correctly implement push notification config migration
fulleni Nov 8, 2025
f411cfd
style: format
fulleni Nov 8, 2025
4b26c01
fix(firebase): replace Dio with HttpClient for OAuth token exchange
fulleni Nov 8, 2025
569a8f7
refactor(dependencies): improve code structure and remove unused import
fulleni Nov 8, 2025
fe66da2
build(deps): update http-client to version 1.1.0
fulleni Nov 8, 2025
80e87f9
style: format
fulleni Nov 8, 2025
71d8b79
refactor(notifications): implement Firebase token provider for HTTP c…
fulleni Nov 8, 2025
1e9ace6
Revert "refactor(notifications): implement Firebase token provider fo…
fulleni Nov 8, 2025
e47e4ab
refactor(firebase): extract Firebase access token logic into separate…
fulleni Nov 8, 2025
b8e03b2
fix(firebase_push_notification_client): improve error handling and lo…
fulleni Nov 8, 2025
99e39bd
feat(auth): create dedicated FirebaseAuthenticator service
fulleni Nov 8, 2025
44d4357
refactor(auth): integrate FirebaseAuthenticator service
fulleni Nov 8, 2025
0d0edbe
refactor(auth): provide FirebaseAuthenticator in root middleware
fulleni Nov 8, 2025
6c30ed5
refactor(config): make push credentials optional
fulleni Nov 8, 2025
43c748f
refactor(push): implement conditional push notification client initia…
fulleni Nov 8, 2025
5de379a
refactor(push): make notification service resilient to null clients
fulleni Nov 8, 2025
7ec5e75
docs(env): update documentation for conditional environment variables
fulleni Nov 8, 2025
f0badfb
fix(auth): correct null-safety in firebase authenticator
fulleni Nov 8, 2025
ca9f23c
fix(deps): correct nullable provider and import order
fulleni Nov 8, 2025
85150c4
style: format
fulleni Nov 8, 2025
30d5d02
fix(config): treat empty env variables as null
fulleni Nov 8, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

</details>

<details>
Expand Down
96 changes: 96 additions & 0 deletions lib/src/config/app_dependencies.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// ignore_for_file: public_member_api_docs

import 'dart:async';

import 'package:core/core.dart';
import 'package:data_mongodb/data_mongodb.dart';
import 'package:data_repository/data_repository.dart';
Expand All @@ -16,10 +17,15 @@ import 'package:flutter_news_app_api_server_full_source_code/src/services/dashbo
import 'package:flutter_news_app_api_server_full_source_code/src/services/database_migration_service.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/services/database_seeding_service.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/services/default_user_preference_limit_service.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/services/firebase_authenticator.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/services/firebase_push_notification_client.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/services/jwt_auth_token_service.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/services/mongodb_rate_limit_service.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/services/mongodb_token_blacklist_service.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/services/mongodb_verification_code_storage_service.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/services/onesignal_push_notification_client.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/services/push_notification_client.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/services/push_notification_service.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/services/rate_limit_service.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/services/token_blacklist_service.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/services/user_preference_limit_service.dart';
Expand Down Expand Up @@ -62,6 +68,10 @@ class AppDependencies {
late final DataRepository<UserAppSettings> userAppSettingsRepository;
late final DataRepository<UserContentPreferences>
userContentPreferencesRepository;
late final DataRepository<PushNotificationDevice>
pushNotificationDeviceRepository;
late final DataRepository<PushNotificationSubscription>
pushNotificationSubscriptionRepository;
late final DataRepository<RemoteConfig> remoteConfigRepository;
late final EmailRepository emailRepository;

Expand All @@ -76,6 +86,10 @@ class AppDependencies {
late final UserPreferenceLimitService userPreferenceLimitService;
late final RateLimitService rateLimitService;
late final CountryQueryService countryQueryService;
late final IPushNotificationService pushNotificationService;
late final IFirebaseAuthenticator firebaseAuthenticator;
late final IPushNotificationClient firebasePushNotificationClient;
late final IPushNotificationClient oneSignalPushNotificationClient;

/// Initializes all application dependencies.
///
Expand Down Expand Up @@ -198,6 +212,61 @@ class AppDependencies {
logger: Logger('DataMongodb<RemoteConfig>'),
);

// Initialize Data Clients for Push Notifications
final pushNotificationDeviceClient = DataMongodb<PushNotificationDevice>(
connectionManager: _mongoDbConnectionManager,
modelName: 'push_notification_devices',
fromJson: PushNotificationDevice.fromJson,
toJson: (item) => item.toJson(),
logger: Logger('DataMongodb<PushNotificationDevice>'),
);
final pushNotificationSubscriptionClient =
DataMongodb<PushNotificationSubscription>(
connectionManager: _mongoDbConnectionManager,
modelName: 'push_notification_subscriptions',
fromJson: PushNotificationSubscription.fromJson,
toJson: (item) => item.toJson(),
logger: Logger('DataMongodb<PushNotificationSubscription>'),
);

// --- 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'),
);

// The OneSignal client requires the REST API key for authentication.
// We use a custom interceptor to add the 'Authorization: Basic <API_KEY>'
// 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);
Expand All @@ -212,6 +281,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 <SENDGRID_API_KEY>' header.
Expand All @@ -231,6 +306,18 @@ 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,
Expand Down Expand Up @@ -275,6 +362,15 @@ class AppDependencies {
log: Logger('CountryQueryService'),
cacheDuration: EnvironmentConfig.countryServiceCacheDuration,
);
pushNotificationService = DefaultPushNotificationService(
pushNotificationDeviceRepository: pushNotificationDeviceRepository,
pushNotificationSubscriptionRepository:
pushNotificationSubscriptionRepository,
remoteConfigRepository: remoteConfigRepository,
firebaseClient: firebasePushNotificationClient,
oneSignalClient: oneSignalPushNotificationClient,
log: Logger('DefaultPushNotificationService'),
);

_log.info('Application dependencies initialized successfully.');
// Signal that initialization has completed successfully.
Expand Down
33 changes: 33 additions & 0 deletions lib/src/config/environment_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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_REST_API_KEY');
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import 'package:flutter_news_app_api_server_full_source_code/src/database/migration.dart';
import 'package:logging/logging.dart';
import 'package:mongo_dart/mongo_dart.dart';

/// Migration to add the `isBreaking` field to existing `Headline` documents.
///
/// This migration ensures that all existing documents in the `headlines`
/// collection have the `isBreaking` boolean field, defaulting to `false`
/// if it does not already exist. This is crucial for schema consistency
/// when introducing the breaking news feature.
class AddIsBreakingToHeadlines extends Migration {
/// {@macro add_is_breaking_to_headlines}
AddIsBreakingToHeadlines()
: super(
prDate: '20251107000000',
prId: '71',
prSummary:
'Adds the isBreaking field to existing Headline documents, '
'defaulting to false.',
);

@override
Future<void> up(Db db, Logger log) async {
final collection = db.collection('headlines');

log.info(
'Attempting to add "isBreaking: false" to Headline documents '
'where the field is missing...',
);

// Update all documents in the 'headlines' collection that do not
// already have the 'isBreaking' field, setting it to false.
final updateResult = await collection.updateMany(
// Select documents where 'isBreaking' does not exist.
where.notExists('isBreaking'),
modify.set('isBreaking', false), // Set isBreaking to false
);

log.info(
'Added "isBreaking: false" to ${updateResult.nModified} Headline documents.',
);
}

@override
Future<void> down(Db db, Logger log) async {
log.warning(
'Reverting "AddIsBreakingToHeadlines" is not supported. '
'The "isBreaking" field would need to be manually removed if required.',
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import 'package:core/core.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/database/migration.dart';
import 'package:logging/logging.dart';
import 'package:mongo_dart/mongo_dart.dart';

/// {@template add_push_notification_config_to_remote_config}
/// A database migration to add the `pushNotificationConfig` field to the
/// `remote_configs` document.
///
/// This migration ensures that the `remote_configs` document contains the
/// necessary structure for push notification settings, preventing errors when
/// the application starts and tries to access this configuration. It is
/// designed to be idempotent and safe to run multiple times.
/// {@endtemplate}
class AddPushNotificationConfigToRemoteConfig extends Migration {
/// {@macro add_push_notification_config_to_remote_config}
AddPushNotificationConfigToRemoteConfig()
: super(
prId: '71',
prSummary:
'Add pushNotificationConfig field to the remote_configs document.',
prDate: '20251108103300',
);

@override
Future<void> up(Db db, Logger log) async {
log.info('Running up migration: $prSummary');

final remoteConfigCollection = db.collection('remote_configs');
final remoteConfigId = ObjectId.fromHexString(kRemoteConfigId);

// Default structure for the push notification configuration.
final pushNotificationConfig =
remoteConfigsFixturesData.first.pushNotificationConfig;

// Use $set to add the field only if it doesn't exist.
// This is an idempotent operation.
await remoteConfigCollection.updateOne(
where
.id(remoteConfigId)
.and(
where.notExists('pushNotificationConfig'),
),
modify.set('pushNotificationConfig', pushNotificationConfig.toJson()),
);

log.info('Successfully completed up migration for $prDate.');
}

@override
Future<void> 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.');
}
}
4 changes: 4 additions & 0 deletions lib/src/database/migrations/all_migrations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import 'package:flutter_news_app_api_server_full_source_code/src/database/migrat
import 'package:flutter_news_app_api_server_full_source_code/src/database/migrations/20251013000057_add_saved_filters_to_remote_config.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/database/migrations/20251024000000_add_logo_url_to_sources.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/database/migrations/20251103073226_remove_local_ad_platform.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/database/migrations/20251107000000_add_is_breaking_to_headlines.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/database/migrations/20251108103300_add_push_notification_config_to_remote_config.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/services/database_migration_service.dart'
show DatabaseMigrationService;

Expand All @@ -18,4 +20,6 @@ final List<Migration> allMigrations = [
AddSavedFiltersToRemoteConfig(),
AddLogoUrlToSources(),
RemoveLocalAdPlatform(),
AddIsBreakingToHeadlines(),
AddPushNotificationConfigToRemoteConfig(),
];
14 changes: 14 additions & 0 deletions lib/src/rbac/permissions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,18 @@ abstract class Permissions {

// General System Permissions
static const String rateLimitingBypass = 'rate_limiting.bypass';

// Push Notification Permissions
/// Allows sending breaking news push notifications.
///
/// This permission is typically granted to dashboard roles like
/// 'publisher' or 'admin'.
static const String pushNotificationSendBreakingNews =
'push_notification.send_breaking_news';

// Push Notification Device Permissions (User-owned)
static const String pushNotificationDeviceCreateOwned =
'push_notification_device.create_owned';
static const String pushNotificationDeviceDeleteOwned =
'push_notification_device.delete_owned';
}
Loading
Loading