diff --git a/README.md b/README.md index acd6c29..4e3c893 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index fe4ca89..91f3351 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -70,9 +70,9 @@ class AppDependencies { userContentPreferencesRepository; late final DataRepository pushNotificationDeviceRepository; - late final DataRepository - pushNotificationSubscriptionRepository; late final DataRepository remoteConfigRepository; + late final DataRepository inAppNotificationRepository; + late final EmailRepository emailRepository; // Services @@ -220,14 +220,16 @@ class AppDependencies { 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'), - ); + + final inAppNotificationClient = DataMongodb( + connectionManager: _mongoDbConnectionManager, + modelName: 'in_app_notifications', + fromJson: InAppNotification.fromJson, + toJson: (item) => item.toJson(), + logger: Logger('DataMongodb'), + ); + + _log.info('Initialized data client for InAppNotification.'); // --- Conditionally Initialize Push Notification Clients --- @@ -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 @@ -368,7 +370,6 @@ class AppDependencies { ); userPreferenceLimitService = DefaultUserPreferenceLimitService( remoteConfigRepository: remoteConfigRepository, - permissionService: permissionService, log: Logger('DefaultUserPreferenceLimitService'), ); rateLimitService = MongoDbRateLimitService( @@ -382,8 +383,7 @@ class AppDependencies { ); pushNotificationService = DefaultPushNotificationService( pushNotificationDeviceRepository: pushNotificationDeviceRepository, - pushNotificationSubscriptionRepository: - pushNotificationSubscriptionRepository, + userContentPreferencesRepository: userContentPreferencesRepository, remoteConfigRepository: remoteConfigRepository, firebaseClient: firebasePushNotificationClient, oneSignalClient: oneSignalPushNotificationClient, diff --git a/lib/src/database/migrations/20251111000000_unify_interests_and_remote_config.dart b/lib/src/database/migrations/20251111000000_unify_interests_and_remote_config.dart new file mode 100644 index 0000000..a4050d8 --- /dev/null +++ b/lib/src/database/migrations/20251111000000_unify_interests_and_remote_config.dart @@ -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 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? ?? []) + .map((e) => e as Map) + .toList(); + final notificationSubscriptions = + (preferenceDoc['notificationSubscriptions'] as List? ?? []) + .map((e) => e as Map) + .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 = {}; + + // Process saved filters + for (final filter in savedFilters) { + final criteriaData = filter['criteria']; + if (criteriaData is! Map) { + 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) { + 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? ?? []) + .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() + .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 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', []) + .set('notificationSubscriptions', []), + ); + 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(',')}'; + } +} diff --git a/lib/src/database/migrations/all_migrations.dart b/lib/src/database/migrations/all_migrations.dart index 3657272..a72b8d9 100644 --- a/lib/src/database/migrations/all_migrations.dart +++ b/lib/src/database/migrations/all_migrations.dart @@ -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; @@ -22,4 +23,5 @@ final List allMigrations = [ RemoveLocalAdPlatform(), AddIsBreakingToHeadlines(), AddPushNotificationConfigToRemoteConfig(), + UnifyInterestsAndRemoteConfig(), ]; diff --git a/lib/src/rbac/permissions.dart b/lib/src/rbac/permissions.dart index 1f7ff09..92bb710 100644 --- a/lib/src/rbac/permissions.dart +++ b/lib/src/rbac/permissions.dart @@ -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'; } diff --git a/lib/src/rbac/role_permissions.dart b/lib/src/rbac/role_permissions.dart index 07fce02..04c5fad 100644 --- a/lib/src/rbac/role_permissions.dart +++ b/lib/src/rbac/role_permissions.dart @@ -25,6 +25,10 @@ final Set _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 _appStandardUserPermissions = { diff --git a/lib/src/registry/data_operation_registry.dart b/lib/src/registry/data_operation_registry.dart index 61bfbbb..342de48 100644 --- a/lib/src/registry/data_operation_registry.dart +++ b/lib/src/registry/data_operation_registry.dart @@ -5,9 +5,11 @@ 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'; 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/rbac/permissions.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/user_preference_limit_service.dart'; import 'package:logging/logging.dart'; // --- Typedefs for Data Operations --- @@ -120,6 +122,9 @@ class DataOperationRegistry { c.read>().read(id: id, userId: null), 'dashboard_summary': (c, id) => c.read().getSummary(), + 'in_app_notification': (c, id) => c + .read>() + .read(id: id, userId: null), }); // --- Register "Read All" Readers --- @@ -169,6 +174,13 @@ class DataOperationRegistry { sort: s, pagination: p, ), + 'in_app_notification': (c, uid, f, s, p) => + c.read>().readAll( + userId: uid, + filter: f, + sort: s, + pagination: p, + ), }); // --- Register Item Creators --- @@ -370,9 +382,46 @@ class DataOperationRegistry { 'user_app_settings': (c, id, item, uid) => c .read>() .update(id: id, item: item as UserAppSettings, userId: uid), - 'user_content_preferences': (c, id, item, uid) => c - .read>() - .update(id: id, item: item as UserContentPreferences, userId: uid), + 'user_content_preferences': (context, id, item, uid) async { + _log.info( + 'Executing custom updater for user_content_preferences ID: $id.', + ); + final authenticatedUser = context.read(); + final permissionService = context.read(); + final userPreferenceLimitService = context + .read(); + final userContentPreferencesRepository = context + .read>(); + + final preferencesToUpdate = item as UserContentPreferences; + + // 2. Validate all limits using the consolidated service method. + // The service validates the entire proposed state. We first check + // if the user has permission to bypass these limits. + if (permissionService.hasPermission( + authenticatedUser, + Permissions.userPreferenceBypassLimits, + )) { + _log.info( + 'User ${authenticatedUser.id} has bypass permission. Skipping limit checks.', + ); + } else { + await userPreferenceLimitService.checkUserContentPreferencesLimits( + user: authenticatedUser, + updatedPreferences: preferencesToUpdate, + ); + } + + // 3. If all checks pass, proceed with the update. + _log.info( + 'All preference validations passed for user ${authenticatedUser.id}. ' + 'Proceeding with update.', + ); + return userContentPreferencesRepository.update( + id: id, + item: preferencesToUpdate, + ); + }, 'remote_config': (c, id, item, uid) => c .read>() .update(id: id, item: item as RemoteConfig, userId: uid), @@ -400,6 +449,9 @@ class DataOperationRegistry { 'push_notification_device': (c, id, uid) => c .read>() .delete(id: id, userId: uid), + 'in_app_notification': (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 0f44523..1bcaa4d 100644 --- a/lib/src/registry/model_registry.dart +++ b/lib/src/registry/model_registry.dart @@ -463,6 +463,40 @@ final modelRegistry = >{ requiresOwnershipCheck: true, ), ), + 'in_app_notification': ModelConfig( + fromJson: InAppNotification.fromJson, + getId: (n) => n.id, + getOwnerId: (dynamic item) => (item as InAppNotification).userId, + // Collection GET is allowed for a user to fetch their own notification inbox. + // The ownership check ensures they only see their own notifications. + getCollectionPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.inAppNotificationReadOwned, + requiresOwnershipCheck: true, + ), + // Item GET is allowed for a user to fetch a single notification. + getItemPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.inAppNotificationReadOwned, + requiresOwnershipCheck: true, + ), + // POST is unsupported as notifications are created by the system, not users. + postPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + ), + // PUT is allowed for a user to update their own notification (e.g., mark as read). + putPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.inAppNotificationUpdateOwned, + requiresOwnershipCheck: true, + ), + // DELETE is allowed for a user to delete their own notification. + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.inAppNotificationDeleteOwned, + requiresOwnershipCheck: true, + ), + ), }; /// Type alias for the ModelRegistry map for easier provider usage. diff --git a/lib/src/services/auth_service.dart b/lib/src/services/auth_service.dart index 5f56b7b..734d89f 100644 --- a/lib/src/services/auth_service.dart +++ b/lib/src/services/auth_service.dart @@ -564,8 +564,7 @@ class AuthService { followedSources: const [], followedTopics: const [], savedHeadlines: const [], - savedFilters: const [], - notificationSubscriptions: const [], + interests: const [], ); await _userContentPreferencesRepository.create( item: defaultUserPreferences, diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index e6d728d..26ca56f 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -252,20 +252,26 @@ class DatabaseSeedingService { }); _log.info('Ensured indexes for "push_notification_devices".'); - // Indexes for the push notification subscriptions collection + // Indexes for the in-app notifications collection await _db.runCommand({ - 'createIndexes': 'push_notification_subscriptions', + 'createIndexes': 'in_app_notifications', 'indexes': [ { - // This index optimizes queries that fetch subscriptions for a - // specific user, which is a common operation when sending - // notifications or managing user preferences. + // Optimizes fetching notifications for a specific user. 'key': {'userId': 1}, 'name': 'userId_index', }, + { + // This is a TTL (Time-To-Live) index. MongoDB will automatically + // delete documents from this collection when the `createdAt` + // field's value is older than the specified number of seconds. + 'key': {'createdAt': 1}, + 'name': 'createdAt_ttl_index', + 'expireAfterSeconds': 7776000, // 90 days + }, ], }); - _log.info('Ensured indexes for "push_notification_subscriptions".'); + _log.info('Ensured indexes for "in_app_notifications".'); _log.info('Database indexes are set up correctly.'); } on Exception catch (e, s) { @@ -389,8 +395,7 @@ class DatabaseSeedingService { followedSources: const [], followedTopics: const [], savedHeadlines: const [], - savedFilters: const [], - notificationSubscriptions: const [], + interests: const [], ); await _db.collection('user_content_preferences').insertOne({ '_id': userId, diff --git a/lib/src/services/default_user_preference_limit_service.dart b/lib/src/services/default_user_preference_limit_service.dart index dbf404b..ecd6654 100644 --- a/lib/src/services/default_user_preference_limit_service.dart +++ b/lib/src/services/default_user_preference_limit_service.dart @@ -1,229 +1,182 @@ import 'package:core/core.dart'; import 'package:data_repository/data_repository.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/rbac/permissions.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/user_preference_limit_service.dart'; import 'package:logging/logging.dart'; /// {@template default_user_preference_limit_service} /// Default implementation of [UserPreferenceLimitService] that enforces limits -/// based on user role and [RemoteConfig]. +/// based on user role and the `InterestConfig` and `UserPreferenceConfig` +/// sections within the application's [RemoteConfig]. /// {@endtemplate} class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { /// {@macro default_user_preference_limit_service} const DefaultUserPreferenceLimitService({ required DataRepository remoteConfigRepository, - required PermissionService permissionService, required Logger log, }) : _remoteConfigRepository = remoteConfigRepository, - _permissionService = permissionService, _log = log; final DataRepository _remoteConfigRepository; - final PermissionService _permissionService; final Logger _log; // Assuming a fixed ID for the RemoteConfig document static const String _remoteConfigId = kRemoteConfigId; @override - Future checkAddItem( - User user, - String itemType, - int currentCount, - ) async { - try { - // 1. Fetch the remote configuration to get limits - final remoteConfig = await _remoteConfigRepository.read( - id: _remoteConfigId, + Future checkUserContentPreferencesLimits({ + required User user, + required UserContentPreferences updatedPreferences, + }) async { + _log.info( + 'Checking all user content preferences limits for user ${user.id}.', + ); + final remoteConfig = await _remoteConfigRepository.read( + id: _remoteConfigId, + ); + final limits = remoteConfig.userPreferenceConfig; + + final (followedItemsLimit, savedHeadlinesLimit) = _getLimitsForRole( + user.appRole, + limits, + ); + + // --- 1. Check general preference limits --- + if (updatedPreferences.followedCountries.length > followedItemsLimit) { + _log.warning( + 'User ${user.id} exceeded followed countries limit: ' + '$followedItemsLimit (attempted ' + '${updatedPreferences.followedCountries.length}).', ); - final limits = remoteConfig.userPreferenceConfig; - - // Users with the bypass permission (e.g., admins) have no limits. - if (_permissionService.hasPermission( - user, - Permissions.userPreferenceBypassLimits, - )) { - return; - } + throw ForbiddenException( + 'You have reached your limit of $followedItemsLimit followed countries.', + ); + } - // 2. Determine the limit based on the user's app role. - int limit; - String accountType; - final isFollowedItem = - itemType == 'country' || itemType == 'source' || itemType == 'topic'; - - switch (user.appRole) { - case AppUserRole.premiumUser: - accountType = 'premium'; - limit = isFollowedItem - ? limits.premiumFollowedItemsLimit - : (itemType == 'headline') - ? limits.premiumSavedHeadlinesLimit - : limits.premiumSavedFiltersLimit; - case AppUserRole.standardUser: - accountType = 'standard'; - limit = isFollowedItem - ? limits.authenticatedFollowedItemsLimit - : (itemType == 'headline') - ? limits.authenticatedSavedHeadlinesLimit - : limits.authenticatedSavedFiltersLimit; - case AppUserRole.guestUser: - accountType = 'guest'; - limit = isFollowedItem - ? limits.guestFollowedItemsLimit - : (itemType == 'headline') - ? limits.guestSavedHeadlinesLimit - : limits.guestSavedFiltersLimit; - } + if (updatedPreferences.followedSources.length > followedItemsLimit) { + _log.warning( + 'User ${user.id} exceeded followed sources limit: ' + '$followedItemsLimit (attempted ' + '${updatedPreferences.followedSources.length}).', + ); + throw ForbiddenException( + 'You have reached your limit of $followedItemsLimit followed sources.', + ); + } - // 3. Check if adding the item would exceed the limit - if (currentCount >= limit) { - throw ForbiddenException( - 'You have reached the maximum number of $itemType items allowed ' - 'for your account type ($accountType).', - ); - } - } on HttpException { - // Propagate known exceptions from repositories - rethrow; - } catch (e) { - // Catch unexpected errors - _log.severe( - 'Error checking limit for user ${user.id}, itemType $itemType: $e', + if (updatedPreferences.followedTopics.length > followedItemsLimit) { + _log.warning( + 'User ${user.id} exceeded followed topics limit: ' + '$followedItemsLimit (attempted ' + '${updatedPreferences.followedTopics.length}).', ); - throw const OperationFailedException( - 'Failed to check user preference limits.', + throw ForbiddenException( + 'You have reached your limit of $followedItemsLimit followed topics.', ); } - } - @override - Future checkUpdatePreferences( - User user, - UserContentPreferences updatedPreferences, - ) async { - try { - // 1. Fetch the remote configuration to get limits - final remoteConfig = await _remoteConfigRepository.read( - id: _remoteConfigId, + if (updatedPreferences.savedHeadlines.length > savedHeadlinesLimit) { + _log.warning( + 'User ${user.id} exceeded saved headlines limit: ' + '$savedHeadlinesLimit (attempted ' + '${updatedPreferences.savedHeadlines.length}).', ); - final limits = remoteConfig.userPreferenceConfig; - - // Users with the bypass permission (e.g., admins) have no limits. - if (_permissionService.hasPermission( - user, - Permissions.userPreferenceBypassLimits, - )) { - return; - } + throw ForbiddenException( + 'You have reached your limit of $savedHeadlinesLimit saved headlines.', + ); + } - // 2. Determine limits based on the user's app role. - int followedItemsLimit; - int savedHeadlinesLimit; - int savedFiltersLimit; - String accountType; - - switch (user.appRole) { - case AppUserRole.premiumUser: - accountType = 'premium'; - followedItemsLimit = limits.premiumFollowedItemsLimit; - savedHeadlinesLimit = limits.premiumSavedHeadlinesLimit; - savedFiltersLimit = limits.premiumSavedFiltersLimit; - case AppUserRole.standardUser: - accountType = 'standard'; - followedItemsLimit = limits.authenticatedFollowedItemsLimit; - savedHeadlinesLimit = limits.authenticatedSavedHeadlinesLimit; - savedFiltersLimit = limits.authenticatedSavedFiltersLimit; - case AppUserRole.guestUser: - accountType = 'guest'; - followedItemsLimit = limits.guestFollowedItemsLimit; - savedHeadlinesLimit = limits.guestSavedHeadlinesLimit; - savedFiltersLimit = limits.guestSavedFiltersLimit; - } + // --- 2. Check interest-specific limits --- + final interestLimits = remoteConfig.interestConfig.limits[user.appRole]; + if (interestLimits == null) { + _log.severe( + 'Interest limits not found for role ${user.appRole}. ' + 'Denying request by default.', + ); + throw const ForbiddenException('Interest limits are not configured.'); + } - // 3. Check if proposed preferences exceed limits - if (updatedPreferences.followedCountries.length > followedItemsLimit) { - throw ForbiddenException( - 'You have reached the maximum number of followed countries allowed ' - 'for your account type ($accountType).', - ); - } - if (updatedPreferences.followedSources.length > followedItemsLimit) { - throw ForbiddenException( - 'You have reached the maximum number of followed sources allowed ' - 'for your account type ($accountType).', + // Check total number of interests. + if (updatedPreferences.interests.length > interestLimits.total) { + _log.warning( + 'User ${user.id} exceeded total interest limit: ' + '${interestLimits.total} (attempted ' + '${updatedPreferences.interests.length}).', + ); + throw ForbiddenException( + 'You have reached your limit of ${interestLimits.total} saved interests.', + ); + } + + // Check total number of pinned feed filters. + final pinnedCount = updatedPreferences.interests + .where((i) => i.isPinnedFeedFilter) + .length; + if (pinnedCount > interestLimits.pinnedFeedFilters) { + _log.warning( + 'User ${user.id} exceeded pinned feed filter limit: ' + '${interestLimits.pinnedFeedFilters} (attempted $pinnedCount).', + ); + throw ForbiddenException( + 'You have reached your limit of ${interestLimits.pinnedFeedFilters} ' + 'pinned feed filters.', + ); + } + + // Check notification subscription limits for each possible delivery type. + for (final deliveryType + in PushNotificationSubscriptionDeliveryType.values) { + final notificationLimit = interestLimits.notifications[deliveryType]; + if (notificationLimit == null) { + _log.severe( + 'Notification limit for type ${deliveryType.name} not found for ' + 'role ${user.appRole}. Denying request by default.', ); - } - if (updatedPreferences.followedTopics.length > followedItemsLimit) { throw ForbiddenException( - 'You have reached the maximum number of followed topics allowed ' - 'for your account type ($accountType).', + 'Notification limits for ${deliveryType.name} are not configured.', ); } - if (updatedPreferences.savedHeadlines.length > savedHeadlinesLimit) { - throw ForbiddenException( - 'You have reached the maximum number of saved headlines allowed ' - 'for your account type ($accountType).', + + // Count how many of the user's proposed interests include this type. + final subscriptionCount = updatedPreferences.interests + .where((i) => i.deliveryTypes.contains(deliveryType)) + .length; + + if (subscriptionCount > notificationLimit) { + _log.warning( + 'User ${user.id} exceeded notification limit for ' + '${deliveryType.name}: $notificationLimit ' + '(attempted $subscriptionCount).', ); - } - if (updatedPreferences.savedFilters.length > savedFiltersLimit) { throw ForbiddenException( - 'You have reached the maximum number of saved filters allowed ' - 'for your account type ($accountType).', + 'You have reached your limit of $notificationLimit ' + '${deliveryType.name} notification subscriptions.', ); } + } - // 4. Check notification subscription limits (per delivery type). - _log.info( - 'Checking notification subscription limits for user ${user.id}...', - ); - final pushConfig = remoteConfig.pushNotificationConfig; - - // Iterate through each possible delivery type defined in the enum. - for (final deliveryType - in PushNotificationSubscriptionDeliveryType.values) { - // Get the specific limit for this delivery type and user role. - final limit = - pushConfig - .deliveryConfigs[deliveryType] - ?.visibleTo[user.appRole] - ?.subscriptionLimit ?? - 0; - - // Count how many of the user's current subscriptions include this - // specific delivery type. - final count = updatedPreferences.notificationSubscriptions - .where((sub) => sub.deliveryTypes.contains(deliveryType)) - .length; - - _log.finer( - 'User ${user.id} has $count subscriptions of type ' - '${deliveryType.name} (limit: $limit).', - ); - - // If the count for this specific type exceeds its limit, throw. - if (count > limit) { - throw ForbiddenException( - 'You have reached the maximum number of subscriptions for ' - '${deliveryType.name} notifications allowed for your account ' - 'type ($accountType).', - ); - } - } + _log.info( + 'All user content preferences limits check passed for user ${user.id}.', + ); + } - _log.info( - 'All user preference limits for user ${user.id} are within range.', - ); - } on HttpException { - // Propagate known exceptions from repositories - rethrow; - } catch (e) { - // Catch unexpected errors - _log.severe('Error checking update limits for user ${user.id}: $e'); - throw const OperationFailedException( - 'Failed to check user preference update limits.', - ); - } + /// Helper to get the correct limits based on the user's role. + (int, int) _getLimitsForRole( + AppUserRole role, + UserPreferenceConfig limits, + ) { + return switch (role) { + AppUserRole.guestUser => ( + limits.guestFollowedItemsLimit, + limits.guestSavedHeadlinesLimit, + ), + AppUserRole.standardUser => ( + limits.authenticatedFollowedItemsLimit, + limits.authenticatedSavedHeadlinesLimit, + ), + AppUserRole.premiumUser => ( + limits.premiumFollowedItemsLimit, + limits.premiumSavedHeadlinesLimit, + ), + }; } } diff --git a/lib/src/services/firebase_authenticator.dart b/lib/src/services/firebase_authenticator.dart index cd10e08..d58173b 100644 --- a/lib/src/services/firebase_authenticator.dart +++ b/lib/src/services/firebase_authenticator.dart @@ -10,9 +10,17 @@ abstract class IFirebaseAuthenticator { Future getAccessToken(); } +/// {@template firebase_authenticator} /// A concrete implementation of [IFirebaseAuthenticator] that uses a /// two-legged OAuth flow to obtain an access token from Google. +/// +/// This service is responsible for generating a signed JWT using the service +/// account credentials and exchanging it for a short-lived OAuth2 access token +/// that can be used to authenticate with Google APIs, such as the Firebase +/// Cloud Messaging (FCM) v1 API. +/// {@endtemplate} class FirebaseAuthenticator implements IFirebaseAuthenticator { + /// {@macro firebase_authenticator} /// Creates an instance of [FirebaseAuthenticator]. FirebaseAuthenticator({required Logger log}) : _log = log { // This internal HttpClient is used exclusively for the token exchange. @@ -28,6 +36,7 @@ class FirebaseAuthenticator implements IFirebaseAuthenticator { late final HttpClient _tokenClient; @override + /// Retrieves a short-lived OAuth2 access token for Firebase. Future getAccessToken() async { _log.info('Requesting new Firebase access token...'); try { @@ -35,6 +44,8 @@ class FirebaseAuthenticator implements IFirebaseAuthenticator { final pem = EnvironmentConfig.firebasePrivateKey!.replaceAll(r'\n', '\n'); final privateKey = RSAPrivateKey(pem); final jwt = JWT( + // The 'scope' claim defines the permissions the access token will have. + // 'cloud-platform' is a broad scope suitable for many Google Cloud APIs. {'scope': 'https://www.googleapis.com/auth/cloud-platform'}, issuer: EnvironmentConfig.firebaseClientEmail, audience: Audience.one('https://oauth2.googleapis.com/token'), diff --git a/lib/src/services/firebase_push_notification_client.dart b/lib/src/services/firebase_push_notification_client.dart index 5db5dfe..dc2a8a4 100644 --- a/lib/src/services/firebase_push_notification_client.dart +++ b/lib/src/services/firebase_push_notification_client.dart @@ -66,7 +66,12 @@ class FirebasePushNotificationClient implements IPushNotificationClient { ); // Send each chunk as a separate batch request. - await _sendBatch(deviceTokens: batch, payload: payload); + await _sendBatch( + batchNumber: (i ~/ batchSize) + 1, + totalBatches: (deviceTokens.length / batchSize).ceil(), + deviceTokens: batch, + payload: payload, + ); } } @@ -77,6 +82,8 @@ class FirebasePushNotificationClient implements IPushNotificationClient { /// as it avoids the complexity of constructing a multipart request body and /// provides clearer error handling for individual message failures. Future _sendBatch({ + required int batchNumber, + required int totalBatches, required List deviceTokens, required PushNotificationPayload payload, }) async { @@ -84,6 +91,10 @@ class FirebasePushNotificationClient implements IPushNotificationClient { // The final URL will be: // `https://fcm.googleapis.com/v1/projects//messages:send` const url = 'messages:send'; + _log.info( + 'Sending Firebase batch $batchNumber of $totalBatches ' + 'to ${deviceTokens.length} devices.', + ); // Create a list of futures, one for each notification to be sent. final sendFutures = deviceTokens.map((token) { @@ -120,12 +131,14 @@ class FirebasePushNotificationClient implements IPushNotificationClient { ); } else { _log.warning( - '${failedResults.length} out of ${deviceTokens.length} Firebase ' - 'notifications failed to send in batch for project "$projectId".', + 'Batch $batchNumber/$totalBatches: ' + '${failedResults.length} of ${deviceTokens.length} Firebase ' + 'notifications failed to send for project "$projectId".', ); for (final error in failedResults) { if (error is HttpException) { _log.severe( + 'Batch $batchNumber/$totalBatches: ' 'HTTP error sending Firebase notification: ${error.message}', error, ); @@ -142,7 +155,12 @@ class FirebasePushNotificationClient implements IPushNotificationClient { ); } } catch (e, s) { - _log.severe('Unexpected error processing Firebase batch results.', e, s); + _log.severe( + 'Unexpected error processing Firebase batch $batchNumber/$totalBatches ' + 'results.', + e, + s, + ); throw OperationFailedException('Failed to process Firebase batch: $e'); } } diff --git a/lib/src/services/push_notification_client.dart b/lib/src/services/push_notification_client.dart index 4762e5d..7277684 100644 --- a/lib/src/services/push_notification_client.dart +++ b/lib/src/services/push_notification_client.dart @@ -1,6 +1,7 @@ import 'package:core/core.dart'; -/// An abstract interface for push notification clients./// +/// 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 { diff --git a/lib/src/services/push_notification_service.dart b/lib/src/services/push_notification_service.dart index b17c2ec..992b035 100644 --- a/lib/src/services/push_notification_service.dart +++ b/lib/src/services/push_notification_service.dart @@ -32,15 +32,14 @@ class DefaultPushNotificationService implements IPushNotificationService { DefaultPushNotificationService({ required DataRepository pushNotificationDeviceRepository, - required DataRepository - pushNotificationSubscriptionRepository, + required DataRepository + userContentPreferencesRepository, required DataRepository remoteConfigRepository, required IPushNotificationClient? firebaseClient, required IPushNotificationClient? oneSignalClient, required Logger log, }) : _pushNotificationDeviceRepository = pushNotificationDeviceRepository, - _pushNotificationSubscriptionRepository = - pushNotificationSubscriptionRepository, + _userContentPreferencesRepository = userContentPreferencesRepository, _remoteConfigRepository = remoteConfigRepository, _firebaseClient = firebaseClient, _oneSignalClient = oneSignalClient, @@ -48,8 +47,8 @@ class DefaultPushNotificationService implements IPushNotificationService { final DataRepository _pushNotificationDeviceRepository; - final DataRepository - _pushNotificationSubscriptionRepository; + final DataRepository + _userContentPreferencesRepository; final DataRepository _remoteConfigRepository; final IPushNotificationClient? _firebaseClient; final IPushNotificationClient? _oneSignalClient; @@ -108,22 +107,22 @@ class DefaultPushNotificationService implements IPushNotificationService { } // Check if breaking news notifications are enabled. - final breakingNewsDeliveryConfig = + final isBreakingNewsEnabled = pushConfig.deliveryConfigs[PushNotificationSubscriptionDeliveryType - .breakingOnly]; - if (breakingNewsDeliveryConfig == null || - !breakingNewsDeliveryConfig.enabled) { + .breakingOnly] ?? + false; + + if (!isBreakingNewsEnabled) { _log.info('Breaking news notifications are disabled. Aborting.'); return; } - // 2. Find all subscriptions for breaking news. - // The query now correctly finds subscriptions where 'deliveryTypes' - // array *contains* the 'breakingOnly' value. - final breakingNewsSubscriptions = - await _pushNotificationSubscriptionRepository.readAll( + // 2. Find all user preferences that contain an interest subscribed to + // breaking news. This query targets the embedded 'interests' array. + final subscribedUserPreferences = await _userContentPreferencesRepository + .readAll( filter: { - 'deliveryTypes': { + 'interests.deliveryTypes': { r'$in': [ PushNotificationSubscriptionDeliveryType.breakingOnly.name, ], @@ -131,20 +130,21 @@ class DefaultPushNotificationService implements IPushNotificationService { }, ); - if (breakingNewsSubscriptions.items.isEmpty) { + if (subscribedUserPreferences.items.isEmpty) { _log.info('No users subscribed to breaking news. Aborting.'); return; } - // 3. Collect all unique user IDs from the subscriptions. + // 3. Collect all unique user IDs from the preference documents. // Using a Set automatically handles deduplication. - final userIds = breakingNewsSubscriptions.items - .map((sub) => sub.userId) + // The ID of the UserContentPreferences document is the user's ID. + final userIds = subscribedUserPreferences.items + .map((preference) => preference.id) .toSet(); _log.info( - 'Found ${breakingNewsSubscriptions.items.length} subscriptions for ' - 'breaking news, corresponding to ${userIds.length} unique users.', + 'Found ${subscribedUserPreferences.items.length} users with ' + 'subscriptions to breaking news.', ); // 4. Fetch all devices for all subscribed users in a single bulk query. diff --git a/lib/src/services/user_preference_limit_service.dart b/lib/src/services/user_preference_limit_service.dart index 566fc8a..affc522 100644 --- a/lib/src/services/user_preference_limit_service.dart +++ b/lib/src/services/user_preference_limit_service.dart @@ -1,42 +1,23 @@ import 'package:core/core.dart'; /// {@template user_preference_limit_service} -/// Service responsible for enforcing user preference limits based on user role. +/// A service responsible for enforcing all user preference limits based on +/// the user's role and the application's remote configuration. +/// +/// This service centralizes validation for both the `Interest` model and +/// the `UserContentPreferences` model (e.g., followed items, saved headlines). /// {@endtemplate} abstract class UserPreferenceLimitService { /// {@macro user_preference_limit_service} const UserPreferenceLimitService(); - /// Checks if the user is allowed to add a *single* item of the given type, - /// considering their current count of that item type and their role. + /// Validates an updated [UserContentPreferences] object against all limits + /// defined in `RemoteConfig`, including interests, followed items, and + /// saved headlines. /// - /// This method is typically used when a user performs an action that adds - /// one item, such as saving a single headline or following a single source. - /// - /// - [user]: The authenticated user. - /// - [itemType]: The type of item being added (e.g., 'country', 'source', - /// 'category', 'headline'). - /// - [currentCount]: The current number of items of this type the user has. - /// - /// Throws [ForbiddenException] if adding the item would exceed the user's - /// limit for their role. - Future checkAddItem(User user, String itemType, int currentCount); - - /// Checks if the proposed *entire state* of the user's preferences, - /// represented by [updatedPreferences], exceeds the limits based on their role. - /// - /// This method is typically used when the full [UserContentPreferences] object - /// is being updated, such as when a user saves changes from a preferences screen. - /// It validates the total counts across all relevant lists (followed countries, - /// sources, categories, and saved headlines). - /// - /// - [user]: The authenticated user. - /// - [updatedPreferences]: The proposed [UserContentPreferences] object. - /// - /// Throws [ForbiddenException] if any list within the preferences exceeds - /// the user's limit for their role. - Future checkUpdatePreferences( - User user, - UserContentPreferences updatedPreferences, - ); + /// Throws a [ForbiddenException] if any limit is exceeded. + Future checkUserContentPreferencesLimits({ + required User user, + required UserContentPreferences updatedPreferences, + }); } diff --git a/pubspec.lock b/pubspec.lock index c44273d..a27365d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -117,8 +117,8 @@ packages: dependency: "direct main" description: path: "." - ref: d047d9cca684de28848203c564823bb85de4f474 - resolved-ref: d047d9cca684de28848203c564823bb85de4f474 + ref: "93ca1905f72d708a156721519d663b2853b865f3" + resolved-ref: "93ca1905f72d708a156721519d663b2853b865f3" 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 d8ede36..50722e7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,7 +59,7 @@ dependency_overrides: core: git: url: https://github.com/flutter-news-app-full-source-code/core.git - ref: d047d9cca684de28848203c564823bb85de4f474 + ref: 93ca1905f72d708a156721519d663b2853b865f3 http_client: git: url: https://github.com/flutter-news-app-full-source-code/http-client.git diff --git a/routes/_middleware.dart b/routes/_middleware.dart index edb9fb7..a0dfa9b 100644 --- a/routes/_middleware.dart +++ b/routes/_middleware.dart @@ -135,8 +135,8 @@ Handler middleware(Handler handler) { ), ) .use( - provider>( - (_) => deps.pushNotificationSubscriptionRepository, + provider>( + (_) => deps.inAppNotificationRepository, ), ) .use( diff --git a/routes/api/v1/data/[id]/index.dart b/routes/api/v1/data/[id]/index.dart index 8b929b0..2c02e8e 100644 --- a/routes/api/v1/data/[id]/index.dart +++ b/routes/api/v1/data/[id]/index.dart @@ -7,7 +7,6 @@ import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/own import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permission_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/registry/data_operation_registry.dart'; 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/user_preference_limit_service.dart'; import 'package:logging/logging.dart'; // Create a logger for this file. @@ -57,7 +56,6 @@ Future _handlePut(RequestContext context, String id) async { final modelConfig = context.read>(); final authenticatedUser = context.read(); final permissionService = context.read(); - final userPreferenceLimitService = context.read(); _logger.info('Handling PUT request for model "$modelName", id "$id".'); @@ -105,28 +103,6 @@ Future _handlePut(RequestContext context, String id) async { } } - if (modelName == 'user_content_preferences') { - // User content preferences can only be updated by an authenticated user. - if (authenticatedUser == null) { - throw const UnauthorizedException( - 'Authentication required to update user content preferences.', - ); - } - if (itemToUpdate is UserContentPreferences) { - await userPreferenceLimitService.checkUpdatePreferences( - authenticatedUser, - itemToUpdate, - ); - } else { - _logger.severe( - 'Type Error: Expected UserContentPreferences for limit check, but got ${itemToUpdate.runtimeType}.', - ); - throw const OperationFailedException( - 'Internal Server Error: Model type mismatch for limit check.', - ); - } - } - final userIdForRepoCall = _getUserIdForRepoCall( modelConfig: modelConfig, permissionService: permissionService,