Skip to content
Merged
Show file tree
Hide file tree
Changes from 37 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
c3acbc1
build(deps): update core package reference
fulleni Nov 11, 2025
8bcffea
feat(rbac): define permissions for interest and in-app notification m…
fulleni Nov 11, 2025
d37b3cd
feat(rbac): grant interest and notification permissions to app roles
fulleni Nov 11, 2025
3795ac8
feat(registry): add permission configuration for interests and notifi…
fulleni Nov 11, 2025
bf67080
feat(database): unify interests and remote config
fulleni Nov 11, 2025
35846b1
feat(database): add new migration to unify interests and remote config
fulleni Nov 11, 2025
ca1decf
feat(dependencies): add repositories for Interest and InAppNotification
fulleni Nov 11, 2025
944240d
refactor(service): update user preference limit logic
fulleni Nov 11, 2025
9a8d5c1
refactor(push-notification): replace subscriptions with interests in …
fulleni Nov 11, 2025
b800952
fix(auth_service): update user preferences initialization
fulleni Nov 11, 2025
1875789
feat(database): add indexes for interests and in-app notifications
fulleni Nov 11, 2025
4bf79d7
feat(data): add interest and in-app notification support
fulleni Nov 11, 2025
79d0f1d
feat(routes): add middleware providers for Interest and InAppNotifica…
fulleni Nov 11, 2025
115204f
feat(firebase): enhance logging for push notification batches
fulleni Nov 11, 2025
b629b5a
docs(core): update abstract class documentation
fulleni Nov 11, 2025
f9f11e2
docs(auth): add documentation comments to FirebaseAuthenticator
fulleni Nov 11, 2025
64e9236
refactor(push-notification): enhance _sendBatch function with additio…
fulleni Nov 11, 2025
b282423
style: fix
fulleni Nov 11, 2025
10ab766
refactor(api)!: re-implement DefaultUserPreferenceLimitService
fulleni Nov 11, 2025
5d61539
fix(api): update data operation registry to use new limit services
fulleni Nov 11, 2025
11403f0
refactor(dependencies): remove permissionService from AppDependencies
fulleni Nov 11, 2025
93562d0
fix(api): remove obsolete validation call in PUT handler
fulleni Nov 11, 2025
db55ea2
refactor(dependencies): remove interest repository and update push no…
fulleni Nov 11, 2025
8774c2e
refactor(rbac): remove interest permissions
fulleni Nov 11, 2025
b798ffd
fix(rbac): remove self-manage interests permissions for app guests
fulleni Nov 11, 2025
64d3373
refactor(data): remove interest model and related operations
fulleni Nov 11, 2025
918de42
refactor(database): remove indexes creation for interests collection
fulleni Nov 11, 2025
2cf110b
refactor(push-notification): update breaking news subscription query
fulleni Nov 11, 2025
9c3948b
refactor(routes): remove unused interestRepository provider
fulleni Nov 11, 2025
9784272
fix(database): handle malformed criteria in saved filters and subscri…
fulleni Nov 11, 2025
935a531
style: format
fulleni Nov 11, 2025
14b8c5e
docs(README): enhance notification streams description
fulleni Nov 11, 2025
39c471a
refactor(api): unify user preference limit checking
fulleni Nov 11, 2025
aaae462
refactor: remove unused import
fulleni Nov 11, 2025
8a45a96
fix(api): harden migration script and correctly implement admin limit…
fulleni Nov 11, 2025
553a00a
fix(api): correct critical bug in preference limit validation
fulleni Nov 11, 2025
0e49173
style(firebase_auth): remove outdated comment
fulleni Nov 11, 2025
acb356e
refactor(api): remove unused currentPreferences from limit service
fulleni Nov 11, 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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ The API automatically validates the structure of all incoming data, ensuring tha
### 📲 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.
- **User-Crafted Notification Streams:** Users can create and save persistent **Interests** based on any combination of content filters (such as topics, sources, or regions). They can then subscribe to notifications for that interest, receiving alerts only for the news they care about.
- **Flexible Delivery Mechanisms:** The system is architected to support multiple notification types for each subscription, from immediate alerts to scheduled daily or weekly digests.
- **Provider Agnostic:** The engine is built to be provider-agnostic, with out-of-the-box support for Firebase (FCM) and OneSignal. The active provider can be switched remotely without any code changes.
> **Your Advantage:** You get a complete, secure, and scalable notification system that enhances user engagement and can be managed entirely from the web dashboard.
Expand Down
30 changes: 15 additions & 15 deletions lib/src/config/app_dependencies.dart
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,9 @@ class AppDependencies {
userContentPreferencesRepository;
late final DataRepository<PushNotificationDevice>
pushNotificationDeviceRepository;
late final DataRepository<PushNotificationSubscription>
pushNotificationSubscriptionRepository;
late final DataRepository<RemoteConfig> remoteConfigRepository;
late final DataRepository<InAppNotification> inAppNotificationRepository;

late final EmailRepository emailRepository;

// Services
Expand Down Expand Up @@ -220,14 +220,16 @@ class AppDependencies {
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>'),
);

final inAppNotificationClient = DataMongodb<InAppNotification>(
connectionManager: _mongoDbConnectionManager,
modelName: 'in_app_notifications',
fromJson: InAppNotification.fromJson,
toJson: (item) => item.toJson(),
logger: Logger('DataMongodb<InAppNotification>'),
);

_log.info('Initialized data client for InAppNotification.');

// --- Conditionally Initialize Push Notification Clients ---

Expand Down Expand Up @@ -314,8 +316,8 @@ class AppDependencies {
pushNotificationDeviceRepository = DataRepository(
dataClient: pushNotificationDeviceClient,
);
pushNotificationSubscriptionRepository = DataRepository(
dataClient: pushNotificationSubscriptionClient,
inAppNotificationRepository = DataRepository(
dataClient: inAppNotificationClient,
);
// Configure the HTTP client for SendGrid.
// The HttpClient's AuthInterceptor will use the tokenProvider to add
Expand Down Expand Up @@ -368,7 +370,6 @@ class AppDependencies {
);
userPreferenceLimitService = DefaultUserPreferenceLimitService(
remoteConfigRepository: remoteConfigRepository,
permissionService: permissionService,
log: Logger('DefaultUserPreferenceLimitService'),
);
rateLimitService = MongoDbRateLimitService(
Expand All @@ -382,8 +383,7 @@ class AppDependencies {
);
pushNotificationService = DefaultPushNotificationService(
pushNotificationDeviceRepository: pushNotificationDeviceRepository,
pushNotificationSubscriptionRepository:
pushNotificationSubscriptionRepository,
userContentPreferencesRepository: userContentPreferencesRepository,
remoteConfigRepository: remoteConfigRepository,
firebaseClient: firebasePushNotificationClient,
oneSignalClient: oneSignalPushNotificationClient,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
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 unify_interests_and_remote_config}
/// A migration to refactor the database schema by unifying `SavedFilter` and
/// `PushNotificationSubscription` into a single `Interest` model.
///
/// This migration performs two critical transformations:
///
/// 1. **User Preferences Transformation:** It iterates through all
/// `user_content_preferences` documents. For each user, it reads the
/// legacy `savedFilters` and `notificationSubscriptions` arrays, converts
/// them into the new `Interest` format, and merges them. It then saves
/// this new list to an `interests` field and removes the old, obsolete
/// arrays.
///
/// 2. **Remote Config Transformation:** It updates the single `remote_configs`
/// document by adding the new `interestConfig` field with default limits
/// and removing the now-deprecated limit fields from `userPreferenceConfig`
/// and `pushNotificationConfig`.
/// {@endtemplate}
class UnifyInterestsAndRemoteConfig extends Migration {
/// {@macro unify_interests_and_remote_config}
UnifyInterestsAndRemoteConfig()
: super(
prDate: '20251111000000',
prId: '74',
prSummary:
'This pull request introduces a significant new Interest feature, designed to enhance user personalization by unifying content filtering and notification subscriptions.',
);

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

// --- 1. Migrate user_content_preferences ---
log.info('Migrating user_content_preferences collection...');
final preferencesCollection = db.collection('user_content_preferences');
final allPreferences = await preferencesCollection.find().toList();

for (final preferenceDoc in allPreferences) {
final userId = (preferenceDoc['_id'] as ObjectId).oid;
log.finer('Processing preferences for user: $userId');

final savedFilters =
(preferenceDoc['savedFilters'] as List<dynamic>? ?? [])
.map((e) => e as Map<String, dynamic>)
.toList();
final notificationSubscriptions =
(preferenceDoc['notificationSubscriptions'] as List<dynamic>? ?? [])
.map((e) => e as Map<String, dynamic>)
.toList();

if (savedFilters.isEmpty && notificationSubscriptions.isEmpty) {
log.finer('User $userId has no legacy data to migrate. Skipping.');
continue;
}

// Use a map to merge filters and subscriptions with the same criteria.
final interestMap = <String, Interest>{};

// Process saved filters
for (final filter in savedFilters) {
final criteriaData = filter['criteria'];
if (criteriaData is! Map<String, dynamic>) {
log.warning(
'User $userId has a malformed savedFilter with missing or invalid '
'"criteria". Skipping this filter.',
);
continue;
}

final criteria = InterestCriteria.fromJson(criteriaData);
final key = _generateCriteriaKey(criteria);

interestMap.update(
key,
(existing) => existing.copyWith(isPinnedFeedFilter: true),
ifAbsent: () => Interest(
id: ObjectId().oid,
userId: userId,
name: filter['name'] as String,
criteria: criteria,
isPinnedFeedFilter: true,
deliveryTypes: const {},
),
);
}

// Process notification subscriptions
for (final subscription in notificationSubscriptions) {
final criteriaData = subscription['criteria'];
if (criteriaData is! Map<String, dynamic>) {
log.warning(
'User $userId has a malformed notificationSubscription with '
'missing or invalid "criteria". Skipping this subscription.',
);
continue;
}

final criteria = InterestCriteria.fromJson(criteriaData);
final key = _generateCriteriaKey(criteria);
final deliveryTypes =
(subscription['deliveryTypes'] as List<dynamic>? ?? [])
.map((e) {
try {
return PushNotificationSubscriptionDeliveryType.values
.byName(e as String);
} catch (_) {
log.warning(
'User $userId has a notificationSubscription with an invalid deliveryType: "$e". Skipping this type.',
);
return null;
}
})
.whereType<PushNotificationSubscriptionDeliveryType>()
.toSet();

interestMap.update(
key,
(existing) => existing.copyWith(
deliveryTypes: {...existing.deliveryTypes, ...deliveryTypes},
),
ifAbsent: () => Interest(
id: ObjectId().oid,
userId: userId,
name: subscription['name'] as String,
criteria: criteria,
isPinnedFeedFilter: false,
deliveryTypes: deliveryTypes,
),
);
}

final newInterests = interestMap.values.map((i) => i.toJson()).toList();

await preferencesCollection.updateOne(
where.id(preferenceDoc['_id'] as ObjectId),
modify
.set('interests', newInterests)
.unset('savedFilters')
.unset('notificationSubscriptions'),
);
log.info(
'Successfully migrated ${newInterests.length} interests for user $userId.',
);
}

// --- 2. Migrate remote_configs ---
log.info('Migrating remote_configs collection...');
final remoteConfigCollection = db.collection('remote_configs');
final remoteConfig = await remoteConfigCollection.findOne();

if (remoteConfig != null) {
// Use the default from the core package fixtures as the base.
final defaultConfig = remoteConfigsFixturesData.first.interestConfig;

await remoteConfigCollection.updateOne(
where.id(remoteConfig['_id'] as ObjectId),
modify
.set('interestConfig', defaultConfig.toJson())
.unset('userPreferenceConfig.guestSavedFiltersLimit')
.unset('userPreferenceConfig.authenticatedSavedFiltersLimit')
.unset('userPreferenceConfig.premiumSavedFiltersLimit')
.unset('pushNotificationConfig.deliveryConfigs'),
);
log.info('Successfully migrated remote_configs document.');
} else {
log.warning('Remote config document not found. Skipping migration.');
}

log.info('Migration UnifyInterestsAndRemoteConfig.up completed.');
}

@override
Future<void> down(Db db, Logger log) async {
log.warning(
'Executing "down" for UnifyInterestsAndRemoteConfig. '
'This is a destructive operation and may result in data loss.',
);

// --- 1. Revert user_content_preferences ---
final preferencesCollection = db.collection('user_content_preferences');
await preferencesCollection.updateMany(
where.exists('interests'),
modify
.unset('interests')
.set('savedFilters', <dynamic>[])
.set('notificationSubscriptions', <dynamic>[]),
);
log.info(
'Removed "interests" field and re-added empty legacy fields to all '
'user_content_preferences documents.',
);

// --- 2. Revert remote_configs ---
final remoteConfigCollection = db.collection('remote_configs');
await remoteConfigCollection.updateMany(
where.exists('interestConfig'),
modify
.unset('interestConfig')
.set('userPreferenceConfig.guestSavedFiltersLimit', 5)
.set('userPreferenceConfig.authenticatedSavedFiltersLimit', 20)
.set('userPreferenceConfig.premiumSavedFiltersLimit', 50)
.set(
'pushNotificationConfig.deliveryConfigs',
{
'breakingOnly': true,
'dailyDigest': true,
'weeklyRoundup': true,
},
),
);
log.info('Reverted remote_configs document to legacy structure.');

log.info('Migration UnifyInterestsAndRemoteConfig.down completed.');
}

/// Generates a stable, sorted key from interest criteria to identify
/// duplicates.
String _generateCriteriaKey(InterestCriteria criteria) {
final topics = criteria.topics.map((t) => t.id).toList()..sort();
final sources = criteria.sources.map((s) => s.id).toList()..sort();
final countries = criteria.countries.map((c) => c.id).toList()..sort();
return 't:${topics.join(',')};s:${sources.join(',')};c:${countries.join(',')}';
}
}
2 changes: 2 additions & 0 deletions lib/src/database/migrations/all_migrations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,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/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/database/migrations/20251111000000_unify_interests_and_remote_config.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/services/database_migration_service.dart'
show DatabaseMigrationService;

Expand All @@ -22,4 +23,5 @@ final List<Migration> allMigrations = [
RemoveLocalAdPlatform(),
AddIsBreakingToHeadlines(),
AddPushNotificationConfigToRemoteConfig(),
UnifyInterestsAndRemoteConfig(),
];
13 changes: 13 additions & 0 deletions lib/src/rbac/permissions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,17 @@ abstract class Permissions {
'push_notification_device.create_owned';
static const String pushNotificationDeviceDeleteOwned =
'push_notification_device.delete_owned';

// In-App Notification Permissions (User-owned)
/// Allows reading the user's own in-app notifications.
static const String inAppNotificationReadOwned =
'in_app_notification.read_owned';

/// Allows updating the user's own in-app notifications (e.g., marking as read).
static const String inAppNotificationUpdateOwned =
'in_app_notification.update_owned';

/// Allows deleting the user's own in-app notifications.
static const String inAppNotificationDeleteOwned =
'in_app_notification.delete_owned';
}
4 changes: 4 additions & 0 deletions lib/src/rbac/role_permissions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ final Set<String> _appGuestUserPermissions = {
// notifications.
Permissions.pushNotificationDeviceCreateOwned,
Permissions.pushNotificationDeviceDeleteOwned,
// Allow all app users to manage their own in-app notifications.
Permissions.inAppNotificationReadOwned,
Permissions.inAppNotificationUpdateOwned,
Permissions.inAppNotificationDeleteOwned,
};

final Set<String> _appStandardUserPermissions = {
Expand Down
Loading
Loading