diff --git a/README.md b/README.md index 4e3c893..4e2b33a 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 **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. +- **User-Crafted Notification Streams:** Users can create and save persistent **Saved Headline Filters** based on any combination of content filters (such as topics, sources, or regions). They can then subscribe to notifications for that filter, 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/database/migrations/20251111000000_unify_interests_and_remote_config.dart b/lib/src/database/migrations/20251111000000_unify_interests_and_remote_config.dart deleted file mode 100644 index a4050d8..0000000 --- a/lib/src/database/migrations/20251111000000_unify_interests_and_remote_config.dart +++ /dev/null @@ -1,229 +0,0 @@ -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/20251112000000_refactor_user_preferences_and_remote_config.dart b/lib/src/database/migrations/20251112000000_refactor_user_preferences_and_remote_config.dart new file mode 100644 index 0000000..0e7811f --- /dev/null +++ b/lib/src/database/migrations/20251112000000_refactor_user_preferences_and_remote_config.dart @@ -0,0 +1,160 @@ +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 refactor_user_preferences_and_remote_config} +/// A migration to refactor the database schema to align with the updated +/// `UserContentPreferences` and `RemoteConfig` models from the core package. +/// +/// This migration performs two critical transformations: +/// +/// 1. **User Preferences Transformation:** It iterates through all +/// `user_content_preferences` documents. For each user, it adds the new +/// `savedHeadlineFilters` and `savedSourceFilters` fields as empty lists +/// and removes the now-obsolete `interests` field. +/// +/// 2. **Remote Config Transformation:** It updates the single `remote_configs` +/// document by removing the deprecated `interestConfig` and replacing the +/// individual limit fields in `userPreferenceConfig` with the new, +/// flexible role-based map structure. +/// {@endtemplate} +class RefactorUserPreferencesAndRemoteConfig extends Migration { + /// {@macro refactor_user_preferences_and_remote_config} + RefactorUserPreferencesAndRemoteConfig() + : super( + prDate: '20251112000000', + prId: '78', + prSummary: + 'Refactors UserContentPreferences and RemoteConfig to support new SavedFilter models.', + ); + + @override + Future up(Db db, Logger log) async { + log.info('Starting migration: RefactorUserPreferencesAndRemoteConfig.up'); + + // --- 1. Migrate user_content_preferences --- + log.info('Migrating user_content_preferences collection...'); + final preferencesCollection = db.collection('user_content_preferences'); + final result = await preferencesCollection.updateMany( + where.exists('interests'), + modify + .set('savedHeadlineFilters', []) + .set('savedSourceFilters', []) + .unset('interests'), + ); + log.info( + 'Updated user_content_preferences: ${result.nModified} documents modified.', + ); + + // --- 2. Migrate remote_configs --- + log.info('Migrating remote_configs collection...'); + final remoteConfigCollection = db.collection('remote_configs'); + final remoteConfig = await remoteConfigCollection.findOne(); + + if (remoteConfig != null) { + // Define the new UserPreferenceConfig structure based on the new model. + // This uses the structure from the "NEW REMOTE CONFIG" example. + const newConfig = UserPreferenceConfig( + followedItemsLimit: { + AppUserRole.guestUser: 5, + AppUserRole.standardUser: 15, + AppUserRole.premiumUser: 30, + }, + savedHeadlinesLimit: { + AppUserRole.guestUser: 10, + AppUserRole.standardUser: 30, + AppUserRole.premiumUser: 100, + }, + savedHeadlineFiltersLimit: { + AppUserRole.guestUser: SavedFilterLimits( + total: 3, + pinned: 3, + notificationSubscriptions: { + PushNotificationSubscriptionDeliveryType.breakingOnly: 1, + PushNotificationSubscriptionDeliveryType.dailyDigest: 0, + PushNotificationSubscriptionDeliveryType.weeklyRoundup: 0, + }, + ), + AppUserRole.standardUser: SavedFilterLimits( + total: 10, + pinned: 5, + notificationSubscriptions: { + PushNotificationSubscriptionDeliveryType.breakingOnly: 3, + PushNotificationSubscriptionDeliveryType.dailyDigest: 2, + PushNotificationSubscriptionDeliveryType.weeklyRoundup: 2, + }, + ), + AppUserRole.premiumUser: SavedFilterLimits( + total: 25, + pinned: 10, + notificationSubscriptions: { + PushNotificationSubscriptionDeliveryType.breakingOnly: 10, + PushNotificationSubscriptionDeliveryType.dailyDigest: 10, + PushNotificationSubscriptionDeliveryType.weeklyRoundup: 10, + }, + ), + }, + savedSourceFiltersLimit: { + AppUserRole.guestUser: SavedFilterLimits(total: 3, pinned: 3), + AppUserRole.standardUser: SavedFilterLimits(total: 10, pinned: 5), + AppUserRole.premiumUser: SavedFilterLimits(total: 25, pinned: 10), + }, + ); + + await remoteConfigCollection.updateOne( + where.id(remoteConfig['_id'] as ObjectId), + modify + // Set the entire userPreferenceConfig to the new structure + .set('userPreferenceConfig', newConfig.toJson()) + // Remove the obsolete interestConfig + .unset('interestConfig'), + ); + log.info('Successfully migrated remote_configs document.'); + } else { + log.warning('Remote config document not found. Skipping migration.'); + } + + log.info('Migration RefactorUserPreferencesAndRemoteConfig.up completed.'); + } + + @override + Future down(Db db, Logger log) async { + log.warning( + 'Executing "down" for RefactorUserPreferencesAndRemoteConfig. ' + '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('savedHeadlineFilters'), // Target documents to revert + modify + .unset('savedHeadlineFilters') + .unset('savedSourceFilters') + .set('interests', []), + ); + log.info( + 'Reverted user_content_preferences: removed new filter fields and ' + 're-added empty "interests" field.', + ); + + // --- 2. Revert remote_configs --- + // This is a best-effort revert and will not restore the exact previous + // state but will remove the new fields. + final remoteConfigCollection = db.collection('remote_configs'); + await remoteConfigCollection.updateMany( + where.exists('userPreferenceConfig.followedItemsLimit'), + modify + .unset('userPreferenceConfig') + .set('interestConfig', {}), + ); + log.info( + 'Reverted remote_configs: removed new userPreferenceConfig structure.', + ); + + log.info( + 'Migration RefactorUserPreferencesAndRemoteConfig.down completed.', + ); + } +} diff --git a/lib/src/database/migrations/all_migrations.dart b/lib/src/database/migrations/all_migrations.dart index a72b8d9..454ebd2 100644 --- a/lib/src/database/migrations/all_migrations.dart +++ b/lib/src/database/migrations/all_migrations.dart @@ -6,7 +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/database/migrations/20251112000000_refactor_user_preferences_and_remote_config.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/database_migration_service.dart' show DatabaseMigrationService; @@ -23,5 +23,5 @@ final List allMigrations = [ RemoveLocalAdPlatform(), AddIsBreakingToHeadlines(), AddPushNotificationConfigToRemoteConfig(), - UnifyInterestsAndRemoteConfig(), + RefactorUserPreferencesAndRemoteConfig(), ]; diff --git a/lib/src/rbac/permissions.dart b/lib/src/rbac/permissions.dart index 92bb710..5ca56f6 100644 --- a/lib/src/rbac/permissions.dart +++ b/lib/src/rbac/permissions.dart @@ -73,12 +73,6 @@ abstract class Permissions { static const String userPreferenceBypassLimits = 'user_preference.bypass_limits'; - // Local Ad Permissions - static const String localAdCreate = 'local_ad.create'; - static const String localAdRead = 'local_ad.read'; - static const String localAdUpdate = 'local_ad.update'; - static const String localAdDelete = 'local_ad.delete'; - // General System Permissions static const String rateLimitingBypass = 'rate_limiting.bypass'; diff --git a/lib/src/rbac/role_permissions.dart b/lib/src/rbac/role_permissions.dart index 04c5fad..5984237 100644 --- a/lib/src/rbac/role_permissions.dart +++ b/lib/src/rbac/role_permissions.dart @@ -14,7 +14,6 @@ final Set _appGuestUserPermissions = { Permissions.userContentPreferencesReadOwned, Permissions.userContentPreferencesUpdateOwned, Permissions.remoteConfigRead, - Permissions.localAdRead, // Allows a user to update their own User object. This is essential for // features like updating the `feedActionStatus` (e.g., when a user // dismisses an in-feed prompt, etc). The endpoint handler ensures only @@ -90,11 +89,6 @@ final Set _dashboardAdminPermissions = { Permissions.remoteConfigUpdate, Permissions.remoteConfigDelete, Permissions.userPreferenceBypassLimits, - // Added localAd CRUD permissions for admins - Permissions.localAdCreate, - Permissions.localAdRead, - Permissions.localAdUpdate, - Permissions.localAdDelete, }; /// Defines the mapping between user roles (both app and dashboard) and the diff --git a/lib/src/services/auth_service.dart b/lib/src/services/auth_service.dart index 734d89f..1b65484 100644 --- a/lib/src/services/auth_service.dart +++ b/lib/src/services/auth_service.dart @@ -558,13 +558,15 @@ class AuthService { _log.info( 'UserContentPreferences not found for user ${user.id}. Creating with defaults.', ); + // Initialize with empty lists for all user-managed content. final defaultUserPreferences = UserContentPreferences( id: user.id, followedCountries: const [], followedSources: const [], followedTopics: const [], savedHeadlines: const [], - interests: const [], + savedHeadlineFilters: const [], + savedSourceFilters: 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 26ca56f..295a7f5 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -389,13 +389,15 @@ class DatabaseSeedingService { ...defaultAppSettings.toJson()..remove('id'), }); + // Initialize with empty lists for all user-managed content. final defaultUserPreferences = UserContentPreferences( id: userId.oid, followedCountries: const [], followedSources: const [], followedTopics: const [], savedHeadlines: const [], - interests: const [], + savedHeadlineFilters: const [], + savedSourceFilters: 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 ecd6654..e182cca 100644 --- a/lib/src/services/default_user_preference_limit_service.dart +++ b/lib/src/services/default_user_preference_limit_service.dart @@ -35,7 +35,13 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { ); final limits = remoteConfig.userPreferenceConfig; - final (followedItemsLimit, savedHeadlinesLimit) = _getLimitsForRole( + // Retrieve all relevant limits for the user's role from the remote configuration. + final ( + followedItemsLimit, + savedHeadlinesLimit, + savedHeadlineFiltersLimit, + savedSourceFiltersLimit, + ) = _getLimitsForRole( user.appRole, limits, ); @@ -85,73 +91,100 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { ); } - // --- 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.'); - } - - // Check total number of interests. - if (updatedPreferences.interests.length > interestLimits.total) { + // --- 2. Check saved headline filter limits --- + // Validate the total number of saved headline filters. + if (updatedPreferences.savedHeadlineFilters.length > + savedHeadlineFiltersLimit.total) { _log.warning( - 'User ${user.id} exceeded total interest limit: ' - '${interestLimits.total} (attempted ' - '${updatedPreferences.interests.length}).', + 'User ${user.id} exceeded total saved headline filter limit: ' + '${savedHeadlineFiltersLimit.total} (attempted ' + '${updatedPreferences.savedHeadlineFilters.length}).', ); throw ForbiddenException( - 'You have reached your limit of ${interestLimits.total} saved interests.', + 'You have reached your limit of ${savedHeadlineFiltersLimit.total} ' + 'saved headline filters.', ); } - // Check total number of pinned feed filters. - final pinnedCount = updatedPreferences.interests - .where((i) => i.isPinnedFeedFilter) + // Validate the number of pinned saved headline filters. + final pinnedHeadlineFilterCount = updatedPreferences.savedHeadlineFilters + .where((f) => f.isPinned) .length; - if (pinnedCount > interestLimits.pinnedFeedFilters) { + if (pinnedHeadlineFilterCount > savedHeadlineFiltersLimit.pinned) { _log.warning( - 'User ${user.id} exceeded pinned feed filter limit: ' - '${interestLimits.pinnedFeedFilters} (attempted $pinnedCount).', + 'User ${user.id} exceeded pinned saved headline filter limit: ' + '${savedHeadlineFiltersLimit.pinned} (attempted $pinnedHeadlineFilterCount).', ); throw ForbiddenException( - 'You have reached your limit of ${interestLimits.pinnedFeedFilters} ' - 'pinned feed filters.', + 'You have reached your limit of ${savedHeadlineFiltersLimit.pinned} ' + 'pinned saved headline 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.', - ); - throw ForbiddenException( - 'Notification limits for ${deliveryType.name} are not configured.', - ); + // Validate notification subscription limits for each delivery type for saved headline filters. + if (savedHeadlineFiltersLimit.notificationSubscriptions != null) { + for (final deliveryType + in PushNotificationSubscriptionDeliveryType.values) { + final notificationLimit = + savedHeadlineFiltersLimit.notificationSubscriptions![deliveryType]; + if (notificationLimit == null) { + // This indicates a misconfiguration in RemoteConfig if a deliveryType is expected but not present. + _log.severe( + 'Notification limit for type ${deliveryType.name} not configured for ' + 'role ${user.appRole} in savedHeadlineFiltersLimit. Denying request.', + ); + throw ForbiddenException( + 'Notification limits for ${deliveryType.name} are not configured ' + 'for saved headline filters.', + ); + } + + final subscriptionCount = updatedPreferences.savedHeadlineFilters + .where((f) => f.deliveryTypes.contains(deliveryType)) + .length; + + if (subscriptionCount > notificationLimit) { + _log.warning( + 'User ${user.id} exceeded notification limit for ' + '${deliveryType.name} in saved headline filters: $notificationLimit ' + '(attempted $subscriptionCount).', + ); + throw ForbiddenException( + 'You have reached your limit of $notificationLimit ' + '${deliveryType.name} notification subscriptions for saved headline filters.', + ); + } } + } - // 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).', - ); - throw ForbiddenException( - 'You have reached your limit of $notificationLimit ' - '${deliveryType.name} notification subscriptions.', - ); - } + // --- 3. Check saved source filter limits --- + // Validate the total number of saved source filters. + if (updatedPreferences.savedSourceFilters.length > + savedSourceFiltersLimit.total) { + _log.warning( + 'User ${user.id} exceeded total saved source filter limit: ' + '${savedSourceFiltersLimit.total} (attempted ' + '${updatedPreferences.savedSourceFilters.length}).', + ); + throw ForbiddenException( + 'You have reached your limit of ${savedSourceFiltersLimit.total} ' + 'saved source filters.', + ); + } + + // Validate the number of pinned saved source filters. + final pinnedSourceFilterCount = updatedPreferences.savedSourceFilters + .where((f) => f.isPinned) + .length; + if (pinnedSourceFilterCount > savedSourceFiltersLimit.pinned) { + _log.warning( + 'User ${user.id} exceeded pinned saved source filter limit: ' + '${savedSourceFiltersLimit.pinned} (attempted $pinnedSourceFilterCount).', + ); + throw ForbiddenException( + 'You have reached your limit of ${savedSourceFiltersLimit.pinned} ' + 'pinned saved source filters.', + ); } _log.info( @@ -159,24 +192,49 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { ); } - /// Helper to get the correct limits based on the user's role. - (int, int) _getLimitsForRole( + /// Helper to retrieve all relevant user preference limits based on the user's role. + /// + /// Throws [StateError] if a required limit is not configured for the given role, + /// indicating a misconfiguration in the remote config. + ( + int followedItemsLimit, + int savedHeadlinesLimit, + SavedFilterLimits savedHeadlineFiltersLimit, + SavedFilterLimits savedSourceFiltersLimit, + ) + _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, - ), - }; + final followedItemsLimit = limits.followedItemsLimit[role]; + if (followedItemsLimit == null) { + throw StateError('Followed items limit not configured for role: $role'); + } + + final savedHeadlinesLimit = limits.savedHeadlinesLimit[role]; + if (savedHeadlinesLimit == null) { + throw StateError('Saved headlines limit not configured for role: $role'); + } + + final savedHeadlineFiltersLimit = limits.savedHeadlineFiltersLimit[role]; + if (savedHeadlineFiltersLimit == null) { + throw StateError( + 'Saved headline filters limit not configured for role: $role', + ); + } + + final savedSourceFiltersLimit = limits.savedSourceFiltersLimit[role]; + if (savedSourceFiltersLimit == null) { + throw StateError( + 'Saved source filters limit not configured for role: $role', + ); + } + + return ( + followedItemsLimit, + savedHeadlinesLimit, + savedHeadlineFiltersLimit, + savedSourceFiltersLimit, + ); } } diff --git a/lib/src/services/push_notification_service.dart b/lib/src/services/push_notification_service.dart index 992b035..78ff93a 100644 --- a/lib/src/services/push_notification_service.dart +++ b/lib/src/services/push_notification_service.dart @@ -117,12 +117,12 @@ class DefaultPushNotificationService implements IPushNotificationService { return; } - // 2. Find all user preferences that contain an interest subscribed to - // breaking news. This query targets the embedded 'interests' array. + // 2. Find all user preferences that contain a saved headline filter + // subscribed to breaking news. This query targets the embedded 'savedHeadlineFilters' array. final subscribedUserPreferences = await _userContentPreferencesRepository .readAll( filter: { - 'interests.deliveryTypes': { + 'savedHeadlineFilters.deliveryTypes': { r'$in': [ PushNotificationSubscriptionDeliveryType.breakingOnly.name, ], diff --git a/pubspec.lock b/pubspec.lock index a27365d..1023f8e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -117,8 +117,8 @@ packages: dependency: "direct main" description: path: "." - ref: "93ca1905f72d708a156721519d663b2853b865f3" - resolved-ref: "93ca1905f72d708a156721519d663b2853b865f3" + ref: "064c4387b3f7df835565c41c918dc2d80dd2f49a" + resolved-ref: "064c4387b3f7df835565c41c918dc2d80dd2f49a" 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 50722e7..cfd093c 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: 93ca1905f72d708a156721519d663b2853b865f3 + ref: 064c4387b3f7df835565c41c918dc2d80dd2f49a http_client: git: url: https://github.com/flutter-news-app-full-source-code/http-client.git