From c3acbc161ae0fb1834731cd9e25b46359845f7d5 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 06:50:40 +0100 Subject: [PATCH 01/38] build(deps): update core package reference - Update core package ref from d047d9cc to 93ca1905 - This update ensures the project uses the latest version of the core package --- pubspec.lock | 4 ++-- pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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 From 8bcffea5f78236f8de32598e151dfd15ceeebf3a Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 07:26:05 +0100 Subject: [PATCH 02/38] feat(rbac): define permissions for interest and in-app notification models --- lib/src/rbac/permissions.dart | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/lib/src/rbac/permissions.dart b/lib/src/rbac/permissions.dart index 1f7ff09..bb23f14 100644 --- a/lib/src/rbac/permissions.dart +++ b/lib/src/rbac/permissions.dart @@ -95,4 +95,30 @@ abstract class Permissions { 'push_notification_device.create_owned'; static const String pushNotificationDeviceDeleteOwned = 'push_notification_device.delete_owned'; + + // Interest Permissions (User-owned) + /// Allows creating a new interest for the authenticated user. + static const String interestCreateOwned = 'interest.create_owned'; + + /// Allows reading the authenticated user's own interests. + static const String interestReadOwned = 'interest.read_owned'; + + /// Allows updating the authenticated user's own interests. + static const String interestUpdateOwned = 'interest.update_owned'; + + /// Allows deleting the authenticated user's own interests. + static const String interestDeleteOwned = 'interest.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'; } From d37b3cd199caa92907f5d617b0c5fe2bfe0c0f7d Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 07:27:17 +0100 Subject: [PATCH 03/38] feat(rbac): grant interest and notification permissions to app roles --- lib/src/rbac/role_permissions.dart | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/src/rbac/role_permissions.dart b/lib/src/rbac/role_permissions.dart index 07fce02..b74dcbb 100644 --- a/lib/src/rbac/role_permissions.dart +++ b/lib/src/rbac/role_permissions.dart @@ -25,6 +25,17 @@ final Set _appGuestUserPermissions = { // notifications. Permissions.pushNotificationDeviceCreateOwned, Permissions.pushNotificationDeviceDeleteOwned, + + // Allow all app users to manage their own interests. + Permissions.interestCreateOwned, + Permissions.interestReadOwned, + Permissions.interestUpdateOwned, + Permissions.interestDeleteOwned, + + // Allow all app users to manage their own in-app notifications. + Permissions.inAppNotificationReadOwned, + Permissions.inAppNotificationUpdateOwned, + Permissions.inAppNotificationDeleteOwned, }; final Set _appStandardUserPermissions = { From 3795ac8d9e4dcdcff66bdae795ef94cc68b749c6 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 07:29:23 +0100 Subject: [PATCH 04/38] feat(registry): add permission configuration for interests and notifications - Add ModelConfig for Interest and InAppNotification - Define permissions for CRUD operations on interests and notifications - Set appropriate ownership checks and permission types - Handle special cases like unsupported POST for notifications --- lib/src/registry/model_registry.dart | 68 ++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/lib/src/registry/model_registry.dart b/lib/src/registry/model_registry.dart index 0f44523..124aa5e 100644 --- a/lib/src/registry/model_registry.dart +++ b/lib/src/registry/model_registry.dart @@ -463,6 +463,74 @@ final modelRegistry = >{ requiresOwnershipCheck: true, ), ), + 'interest': ModelConfig( + fromJson: Interest.fromJson, + getId: (i) => i.id, + getOwnerId: (dynamic item) => (item as Interest).userId, + // Collection GET is admin-only to prevent listing all users' interests. + getCollectionPermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, + ), + // Item GET is unsupported. Interests are managed as part of the + // UserContentPreferences object, not as individual top-level documents. + getItemPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + ), + // POST is allowed for any authenticated user to create their own interest. + // A custom creator in DataOperationRegistry will enforce role-based limits. + postPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.interestCreateOwned, + requiresOwnershipCheck: false, + ), + // PUT is allowed for any authenticated user to update their own interest. + // A custom updater in DataOperationRegistry will enforce role-based limits. + putPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.interestUpdateOwned, + requiresOwnershipCheck: true, + ), + // DELETE is allowed for any authenticated user to delete their own interest. + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.interestDeleteOwned, + 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. From bf67080aa863bf370bc4d3092535c5e9664af82c Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 07:31:46 +0100 Subject: [PATCH 05/38] feat(database): unify interests and remote config - Refactor SavedFilter and PushNotificationSubscription into a unified Interest model - Update RemoteConfig to use a new interestConfig field - Migrate user_content_preferences and remote_configs collections - Add migration script to convert legacy data to new format --- ...000_unify_interests_and_remote_config.dart | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 lib/src/database/migrations/20251111000000_unify_interests_and_remote_config.dart 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..a2fc0c0 --- /dev/null +++ b/lib/src/database/migrations/20251111000000_unify_interests_and_remote_config.dart @@ -0,0 +1,209 @@ +import 'package:collection/collection.dart'; +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 criteria = InterestCriteria.fromJson( + filter['criteria'] as Map, + ); + 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 criteria = InterestCriteria.fromJson( + subscription['criteria'] as Map, + ); + final key = _generateCriteriaKey(criteria); + final deliveryTypes = + (subscription['deliveryTypes'] as List? ?? []) + .map( + (e) => PushNotificationSubscriptionDeliveryType.values.byName( + e as String, + ), + ) + .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(',')}'; + } +} From 35846b11118b62e0b807c14061bdabfda58de5fa Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 07:32:40 +0100 Subject: [PATCH 06/38] feat(database): add new migration to unify interests and remote config - Add new migration file for unifying interests and remote config - Update migration list to include the new migration --- lib/src/database/migrations/all_migrations.dart | 2 ++ 1 file changed, 2 insertions(+) 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(), ]; From ca1decfd290956206b75823db703c3c4b753fdc1 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 07:33:53 +0100 Subject: [PATCH 07/38] feat(dependencies): add repositories for Interest and InAppNotification - Initialize Data Clients for new Interest and InAppNotification models - Add corresponding repositories to AppDependencies class - Update pushNotificationService to use new interestRepository - Remove unused pushNotificationSubscriptionRepository --- lib/src/config/app_dependencies.dart | 44 +++++++++++++++++++--------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index fe4ca89..e6260ae 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -70,9 +70,10 @@ class AppDependencies { userContentPreferencesRepository; late final DataRepository pushNotificationDeviceRepository; - late final DataRepository - pushNotificationSubscriptionRepository; late final DataRepository remoteConfigRepository; + late final DataRepository interestRepository; + late final DataRepository inAppNotificationRepository; + late final EmailRepository emailRepository; // Services @@ -220,14 +221,27 @@ 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'), - ); + + // Initialize Data Clients for new Interest and InAppNotification models + final interestClient = DataMongodb( + connectionManager: _mongoDbConnectionManager, + modelName: 'interests', + fromJson: Interest.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 clients for Interest and InAppNotification.', + ); // --- Conditionally Initialize Push Notification Clients --- @@ -314,8 +328,11 @@ class AppDependencies { pushNotificationDeviceRepository = DataRepository( dataClient: pushNotificationDeviceClient, ); - pushNotificationSubscriptionRepository = DataRepository( - dataClient: pushNotificationSubscriptionClient, + interestRepository = DataRepository( + dataClient: interestClient, + ); + inAppNotificationRepository = DataRepository( + dataClient: inAppNotificationClient, ); // Configure the HTTP client for SendGrid. // The HttpClient's AuthInterceptor will use the tokenProvider to add @@ -382,8 +399,7 @@ class AppDependencies { ); pushNotificationService = DefaultPushNotificationService( pushNotificationDeviceRepository: pushNotificationDeviceRepository, - pushNotificationSubscriptionRepository: - pushNotificationSubscriptionRepository, + interestRepository: interestRepository, remoteConfigRepository: remoteConfigRepository, firebaseClient: firebasePushNotificationClient, oneSignalClient: oneSignalPushNotificationClient, From 944240d7bb3b648e9f2357f5d2b10e06e663413c Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 07:35:43 +0100 Subject: [PATCH 08/38] refactor(service): update user preference limit logic - Remove redundant checkAddItem method - Update checkUpdatePreferences to be a placeholder - Prepare for interest-specific validation using new InterestConfig --- ...default_user_preference_limit_service.dart | 207 +----------------- 1 file changed, 11 insertions(+), 196 deletions(-) diff --git a/lib/src/services/default_user_preference_limit_service.dart b/lib/src/services/default_user_preference_limit_service.dart index dbf404b..46447f2 100644 --- a/lib/src/services/default_user_preference_limit_service.dart +++ b/lib/src/services/default_user_preference_limit_service.dart @@ -1,13 +1,13 @@ 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 new `InterestConfig` within [RemoteConfig]. +/// {@template default_user_preference_limit_service} /// {@endtemplate} class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { /// {@macro default_user_preference_limit_service} @@ -26,204 +26,19 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { // 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, - ); - final limits = remoteConfig.userPreferenceConfig; - - // Users with the bypass permission (e.g., admins) have no limits. - if (_permissionService.hasPermission( - user, - Permissions.userPreferenceBypassLimits, - )) { - return; - } - - // 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; - } - - // 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', - ); - throw const OperationFailedException( - 'Failed to check user preference limits.', - ); - } - } - @override Future checkUpdatePreferences( User user, UserContentPreferences updatedPreferences, ) async { - try { - // 1. Fetch the remote configuration to get limits - final remoteConfig = await _remoteConfigRepository.read( - id: _remoteConfigId, - ); - final limits = remoteConfig.userPreferenceConfig; - - // Users with the bypass permission (e.g., admins) have no limits. - if (_permissionService.hasPermission( - user, - Permissions.userPreferenceBypassLimits, - )) { - return; - } - - // 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; - } - - // 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).', - ); - } - if (updatedPreferences.followedTopics.length > followedItemsLimit) { - throw ForbiddenException( - 'You have reached the maximum number of followed topics allowed ' - 'for your account type ($accountType).', - ); - } - if (updatedPreferences.savedHeadlines.length > savedHeadlinesLimit) { - throw ForbiddenException( - 'You have reached the maximum number of saved headlines allowed ' - 'for your account type ($accountType).', - ); - } - if (updatedPreferences.savedFilters.length > savedFiltersLimit) { - throw ForbiddenException( - 'You have reached the maximum number of saved filters allowed ' - 'for your account type ($accountType).', - ); - } - - // 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 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.', - ); - } + // This method is now a placeholder. The new, granular limit checking + // is handled by custom creators/updaters in the DataOperationRegistry + // for the 'interest' model, which will call a more specific method. + // For now, this method does nothing to avoid incorrect validation + // on the full UserContentPreferences object. + _log.info( + 'checkUpdatePreferences is a placeholder and performs no validation.', + ); + return Future.value(); } } From 9a8d5c195b0a1f64e3841e7e3212b1f2d2bf56f8 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 07:41:10 +0100 Subject: [PATCH 09/38] refactor(push-notification): replace subscriptions with interests in breaking news flow - Remove PushNotificationSubscription repository, replace with Interest repository - Update logic to query interests instead of subscriptions - Adjust variable names and comments to reflect new terminology --- .../services/push_notification_service.dart | 51 +++++++++---------- 1 file changed, 24 insertions(+), 27 deletions(-) diff --git a/lib/src/services/push_notification_service.dart b/lib/src/services/push_notification_service.dart index b17c2ec..941b165 100644 --- a/lib/src/services/push_notification_service.dart +++ b/lib/src/services/push_notification_service.dart @@ -32,15 +32,13 @@ class DefaultPushNotificationService implements IPushNotificationService { DefaultPushNotificationService({ required DataRepository pushNotificationDeviceRepository, - required DataRepository - pushNotificationSubscriptionRepository, + required DataRepository interestRepository, required DataRepository remoteConfigRepository, required IPushNotificationClient? firebaseClient, required IPushNotificationClient? oneSignalClient, required Logger log, }) : _pushNotificationDeviceRepository = pushNotificationDeviceRepository, - _pushNotificationSubscriptionRepository = - pushNotificationSubscriptionRepository, + _interestRepository = interestRepository, _remoteConfigRepository = remoteConfigRepository, _firebaseClient = firebaseClient, _oneSignalClient = oneSignalClient, @@ -48,8 +46,7 @@ class DefaultPushNotificationService implements IPushNotificationService { final DataRepository _pushNotificationDeviceRepository; - final DataRepository - _pushNotificationSubscriptionRepository; + final DataRepository _interestRepository; final DataRepository _remoteConfigRepository; final IPushNotificationClient? _firebaseClient; final IPushNotificationClient? _oneSignalClient; @@ -108,42 +105,42 @@ 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( - filter: { - 'deliveryTypes': { - r'$in': [ - PushNotificationSubscriptionDeliveryType.breakingOnly.name, - ], - }, - }, - ); + // 2. Find all interests subscribed to breaking news. + // The query now correctly finds interests where the 'deliveryTypes' + // set *contains* the 'breakingOnly' value. + final breakingNewsInterests = await _interestRepository.readAll( + filter: { + 'deliveryTypes': { + r'$in': [ + PushNotificationSubscriptionDeliveryType.breakingOnly.name, + ], + }, + }, + ); - if (breakingNewsSubscriptions.items.isEmpty) { + if (breakingNewsInterests.items.isEmpty) { _log.info('No users subscribed to breaking news. Aborting.'); return; } // 3. Collect all unique user IDs from the subscriptions. // Using a Set automatically handles deduplication. - final userIds = breakingNewsSubscriptions.items - .map((sub) => sub.userId) + final userIds = breakingNewsInterests.items + .map((interest) => interest.userId) .toSet(); _log.info( - 'Found ${breakingNewsSubscriptions.items.length} subscriptions for ' + 'Found ${breakingNewsInterests.items.length} interests subscribed to ' 'breaking news, corresponding to ${userIds.length} unique users.', ); From b8009529751591d109ead67025fa52cdf88ac3c0 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 07:42:57 +0100 Subject: [PATCH 10/38] fix(auth_service): update user preferences initialization - Remove savedFilters and notificationSubscriptions from initial values - Add interests to initial values --- lib/src/services/auth_service.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/src/services/auth_service.dart b/lib/src/services/auth_service.dart index 5f56b7b..bb9fed5 100644 --- a/lib/src/services/auth_service.dart +++ b/lib/src/services/auth_service.dart @@ -563,9 +563,8 @@ class AuthService { followedCountries: const [], followedSources: const [], followedTopics: const [], - savedHeadlines: const [], - savedFilters: const [], - notificationSubscriptions: const [], + savedHeadlines: const [], + interests: const [], ); await _userContentPreferencesRepository.create( item: defaultUserPreferences, From 187578962859356bc393b31fa6cd5a82fc4718b5 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 07:44:44 +0100 Subject: [PATCH 11/38] feat(database): add indexes for interests and in-app notifications - Add indexes for the interests collection - Add indexes for the in-app notifications collection, including a TTL index for automatic document expiration - Rename notificationSubscriptions to interests in user_content_preferences --- .../services/database_seeding_service.dart | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index e6d728d..7ce6154 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -252,20 +252,39 @@ class DatabaseSeedingService { }); _log.info('Ensured indexes for "push_notification_devices".'); - // Indexes for the push notification subscriptions collection + // Indexes for the interests collection await _db.runCommand({ - 'createIndexes': 'push_notification_subscriptions', + 'createIndexes': 'interests', '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 interests for a specific user. 'key': {'userId': 1}, 'name': 'userId_index', }, ], }); - _log.info('Ensured indexes for "push_notification_subscriptions".'); + _log.info('Ensured indexes for "interests".'); + + // Indexes for the in-app notifications collection + await _db.runCommand({ + 'createIndexes': 'in_app_notifications', + 'indexes': [ + { + // 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 "in_app_notifications".'); _log.info('Database indexes are set up correctly.'); } on Exception catch (e, s) { @@ -389,8 +408,7 @@ class DatabaseSeedingService { followedSources: const [], followedTopics: const [], savedHeadlines: const [], - savedFilters: const [], - notificationSubscriptions: const [], + interests: const [], ); await _db.collection('user_content_preferences').insertOne({ '_id': userId, From 4bf79d70331a34ae7cb1a0e1910375b96ff1c19b Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 07:47:28 +0100 Subject: [PATCH 12/38] feat(data): add interest and in-app notification support - Implement read, readAll, create, update, and delete operations for interest and in-app notification - Add custom creator and updater for interest with limit checking using UserPreferenceLimitService - Register new data operations in DataOperationRegistry --- lib/src/registry/data_operation_registry.dart | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/lib/src/registry/data_operation_registry.dart b/lib/src/registry/data_operation_registry.dart index 61bfbbb..983774a 100644 --- a/lib/src/registry/data_operation_registry.dart +++ b/lib/src/registry/data_operation_registry.dart @@ -8,6 +8,7 @@ import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permission 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 +121,11 @@ class DataOperationRegistry { c.read>().read(id: id, userId: null), 'dashboard_summary': (c, id) => c.read().getSummary(), + 'interest': (c, id) => + c.read>().read(id: id, userId: null), + 'in_app_notification': (c, id) => c + .read>() + .read(id: id, userId: null), }); // --- Register "Read All" Readers --- @@ -169,6 +175,19 @@ class DataOperationRegistry { sort: s, pagination: p, ), + 'interest': (c, uid, f, s, p) => c.read>().readAll( + userId: uid, + filter: f, + 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 --- @@ -257,6 +276,30 @@ class DataOperationRegistry { userId: null, ); }, + 'interest': (context, item, uid) async { + _log.info('Executing custom creator for interest.'); + final authenticatedUser = context.read(); + final interestToCreate = (item as Interest).copyWith( + userId: authenticatedUser.id, + ); + + // 1. Fetch current user preferences to get existing interests. + final preferences = await context + .read>() + .read(id: authenticatedUser.id); + + // 2. Check limits before creating. + await context.read().checkInterestLimits( + authenticatedUser, + interestToCreate, + existingInterests: preferences.interests, + ); + + // 3. Proceed with creation. + return context.read>().create( + item: interestToCreate, + ); + }, }); // --- Register Item Updaters --- @@ -376,6 +419,30 @@ class DataOperationRegistry { 'remote_config': (c, id, item, uid) => c .read>() .update(id: id, item: item as RemoteConfig, userId: uid), + 'interest': (context, id, item, uid) async { + _log.info('Executing custom updater for interest ID: $id.'); + final authenticatedUser = context.read(); + final interestToUpdate = item as Interest; + + // 1. Fetch current user preferences to get existing interests. + final preferences = await context + .read>() + .read(id: authenticatedUser.id); + + // Exclude the interest being updated from the list for limit checking. + final otherInterests = + preferences.interests.where((i) => i.id != id).toList(); + + // 2. Check limits before updating. + await context.read().checkInterestLimits( + authenticatedUser, + interestToUpdate, + existingInterests: otherInterests, + ); + + // 3. Proceed with update. + return context.read>().update(id: id, item: interestToUpdate); + }, }); // --- Register Item Deleters --- @@ -400,6 +467,11 @@ class DataOperationRegistry { 'push_notification_device': (c, id, uid) => c .read>() .delete(id: id, userId: uid), + 'interest': (c, id, uid) => + c.read>().delete(id: id, userId: uid), + 'in_app_notification': (c, id, uid) => c + .read>() + .delete(id: id, userId: uid), }); } } From 79d0f1df241d62eda66cd625855dcbf56469fac2 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 07:50:09 +0100 Subject: [PATCH 13/38] feat(routes): add middleware providers for Interest and InAppNotification - Replace PushNotificationSubscription provider with Interest provider - Add InAppNotification provider - Update repository dependencies accordingly --- routes/_middleware.dart | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/routes/_middleware.dart b/routes/_middleware.dart index edb9fb7..39b5f7c 100644 --- a/routes/_middleware.dart +++ b/routes/_middleware.dart @@ -135,8 +135,13 @@ Handler middleware(Handler handler) { ), ) .use( - provider>( - (_) => deps.pushNotificationSubscriptionRepository, + provider>( + (_) => deps.interestRepository, + ), + ) + .use( + provider>( + (_) => deps.inAppNotificationRepository, ), ) .use( From 115204f3de6920182acf38972016e8c1e311320c Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 07:52:29 +0100 Subject: [PATCH 14/38] feat(firebase): enhance logging for push notification batches - Add batch number and total batches to log messages - Improve log information for failed batch sends - Include batch details in error logging --- .../firebase_push_notification_client.dart | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/lib/src/services/firebase_push_notification_client.dart b/lib/src/services/firebase_push_notification_client.dart index 5db5dfe..d67f03f 100644 --- a/lib/src/services/firebase_push_notification_client.dart +++ b/lib/src/services/firebase_push_notification_client.dart @@ -77,6 +77,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 +86,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 +126,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 +150,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'); } } From b629b5aaf3e6bcec6d32f37cfc1a69ba75737f8e Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 07:53:53 +0100 Subject: [PATCH 15/38] docs(core): update abstract class documentation - Add missing space in IPushNotificationClient documentation - Improve clarity of the class purpose and functionality --- lib/src/services/push_notification_client.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 { From f9f11e23b9a5eb6964b873136b804fbac5dc2e30 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 07:55:02 +0100 Subject: [PATCH 16/38] docs(auth): add documentation comments to FirebaseAuthenticator - Add doc comments to class and constructor - Clarify purpose and functionality of the class - Explain OAuth2 token exchange process - Describe scope claim and its usage --- lib/src/services/firebase_authenticator.dart | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lib/src/services/firebase_authenticator.dart b/lib/src/services/firebase_authenticator.dart index cd10e08..a646c71 100644 --- a/lib/src/services/firebase_authenticator.dart +++ b/lib/src/services/firebase_authenticator.dart @@ -10,11 +10,20 @@ 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. // This internal HttpClient is used exclusively for the token exchange. // It does not have an auth interceptor, which is crucial to prevent // an infinite loop. @@ -28,6 +37,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 +45,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'), From 64e923668e8b33089a8cc0f8276367d954b95be9 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 08:12:22 +0100 Subject: [PATCH 17/38] refactor(push-notification): enhance _sendBatch function with additional parameters - Add batchNumber and totalBatches parameters to _sendBatch function - Update batch sending logic to use new parameters - Improve tracking and monitoring of batch processing --- lib/src/services/firebase_push_notification_client.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/src/services/firebase_push_notification_client.dart b/lib/src/services/firebase_push_notification_client.dart index d67f03f..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, + ); } } From b2824237062c55e2867db961323c3afe5b173754 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 08:17:22 +0100 Subject: [PATCH 18/38] style: fix --- .../20251111000000_unify_interests_and_remote_config.dart | 1 - lib/src/services/default_user_preference_limit_service.dart | 1 - 2 files changed, 2 deletions(-) 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 index a2fc0c0..37283e7 100644 --- a/lib/src/database/migrations/20251111000000_unify_interests_and_remote_config.dart +++ b/lib/src/database/migrations/20251111000000_unify_interests_and_remote_config.dart @@ -1,4 +1,3 @@ -import 'package:collection/collection.dart'; import 'package:core/core.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/database/migration.dart'; import 'package:logging/logging.dart'; diff --git a/lib/src/services/default_user_preference_limit_service.dart b/lib/src/services/default_user_preference_limit_service.dart index 46447f2..702bb68 100644 --- a/lib/src/services/default_user_preference_limit_service.dart +++ b/lib/src/services/default_user_preference_limit_service.dart @@ -7,7 +7,6 @@ import 'package:logging/logging.dart'; /// {@template default_user_preference_limit_service} /// Default implementation of [UserPreferenceLimitService] that enforces limits /// based on user role and the new `InterestConfig` within [RemoteConfig]. -/// {@template default_user_preference_limit_service} /// {@endtemplate} class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { /// {@macro default_user_preference_limit_service} From 10ab7663255ff0889658ab765d613d7081d95892 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 09:01:23 +0100 Subject: [PATCH 19/38] refactor(api)!: re-implement DefaultUserPreferenceLimitService BREAKING CHANGE: The DefaultUserPreferenceLimitService has been completely rewritten to implement the new UserPreferenceLimitService interface. The obsolete checkUpdatePreferences method has been removed. The unused _permissionService field has been removed. A new checkInterestLimits method is implemented to enforce all InterestConfig limits (total, pinned, and notification subscriptions) for a given user role. A new checkUserContentPreferencesLimits method is implemented to enforce all UserPreferenceConfig limits (followed items and saved headlines). --- ...default_user_preference_limit_service.dart | 186 ++++++++++++++++-- .../user_preference_limit_service.dart | 57 +++--- 2 files changed, 200 insertions(+), 43 deletions(-) diff --git a/lib/src/services/default_user_preference_limit_service.dart b/lib/src/services/default_user_preference_limit_service.dart index 702bb68..6b23294 100644 --- a/lib/src/services/default_user_preference_limit_service.dart +++ b/lib/src/services/default_user_preference_limit_service.dart @@ -1,43 +1,197 @@ 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/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 the new `InterestConfig` within [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 checkUpdatePreferences( - User user, - UserContentPreferences updatedPreferences, - ) async { - // This method is now a placeholder. The new, granular limit checking - // is handled by custom creators/updaters in the DataOperationRegistry - // for the 'interest' model, which will call a more specific method. - // For now, this method does nothing to avoid incorrect validation - // on the full UserContentPreferences object. + Future checkInterestLimits({ + required User user, + required Interest interest, + required List existingInterests, + }) async { + _log.info('Checking interest limits for user ${user.id}.'); + final remoteConfig = await _remoteConfigRepository.read( + id: _remoteConfigId, + ); + final limits = remoteConfig.interestConfig.limits[user.appRole]; + + if (limits == null) { + _log.severe( + 'Interest limits not found for role ${user.appRole}. ' + 'Denying request by default.', + ); + throw const ForbiddenException('Interest limits are not configured.'); + } + + // 1. Check total number of interests. + final newTotal = existingInterests.length + 1; + if (newTotal > limits.total) { + _log.warning( + 'User ${user.id} exceeded total interest limit: ' + '${limits.total} (attempted $newTotal).', + ); + throw ForbiddenException( + 'You have reached your limit of ${limits.total} saved interests.', + ); + } + + // 2. Check total number of pinned feed filters. + if (interest.isPinnedFeedFilter) { + final pinnedCount = + existingInterests.where((i) => i.isPinnedFeedFilter).length + 1; + if (pinnedCount > limits.pinnedFeedFilters) { + _log.warning( + 'User ${user.id} exceeded pinned feed filter limit: ' + '${limits.pinnedFeedFilters} (attempted $pinnedCount).', + ); + throw ForbiddenException( + 'You have reached your limit of ${limits.pinnedFeedFilters} ' + 'pinned feed filters.', + ); + } + } + + // 3. Check notification subscription limits for each type. + for (final deliveryType in interest.deliveryTypes) { + final notificationLimit = limits.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.', + ); + } + + final subscriptionCount = + existingInterests + .where((i) => i.deliveryTypes.contains(deliveryType)) + .length + + 1; + + 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.', + ); + } + } + + _log.info('Interest limits check passed for user ${user.id}.'); + } + + @override + Future checkUserContentPreferencesLimits({ + required User user, + required UserContentPreferences updatedPreferences, + }) async { + _log.info('Checking 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, + ); + + // Check followed countries + if (updatedPreferences.followedCountries.length > followedItemsLimit) { + _log.warning( + 'User ${user.id} exceeded followed countries limit: ' + '$followedItemsLimit (attempted ' + '${updatedPreferences.followedCountries.length}).', + ); + throw ForbiddenException( + 'You have reached your limit of $followedItemsLimit followed countries.', + ); + } + + // Check followed sources + 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.', + ); + } + + // Check followed topics + if (updatedPreferences.followedTopics.length > followedItemsLimit) { + _log.warning( + 'User ${user.id} exceeded followed topics limit: ' + '$followedItemsLimit (attempted ' + '${updatedPreferences.followedTopics.length}).', + ); + throw ForbiddenException( + 'You have reached your limit of $followedItemsLimit followed topics.', + ); + } + + // Check saved headlines + if (updatedPreferences.savedHeadlines.length > savedHeadlinesLimit) { + _log.warning( + 'User ${user.id} exceeded saved headlines limit: ' + '$savedHeadlinesLimit (attempted ' + '${updatedPreferences.savedHeadlines.length}).', + ); + throw ForbiddenException( + 'You have reached your limit of $savedHeadlinesLimit saved headlines.', + ); + } + _log.info( - 'checkUpdatePreferences is a placeholder and performs no validation.', + 'User content preferences limits check passed for user ${user.id}.', ); - return Future.value(); + } + + /// 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/user_preference_limit_service.dart b/lib/src/services/user_preference_limit_service.dart index 566fc8a..49189a3 100644 --- a/lib/src/services/user_preference_limit_service.dart +++ b/lib/src/services/user_preference_limit_service.dart @@ -1,42 +1,45 @@ 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 a new or updated [Interest] against the user's role-based + /// limits defined in `InterestConfig`. /// - /// 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. + /// This method checks multiple limits: + /// - The total number of interests. + /// - The number of interests marked as pinned feed filters. + /// - The number of subscriptions for each notification delivery type across + /// all of the user's interests. /// /// - [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. + /// - [interest]: The `Interest` object being created or updated. + /// - [existingInterests]: A list of the user's other existing interests, + /// used to calculate total counts. /// - /// Throws [ForbiddenException] if adding the item would exceed the user's - /// limit for their role. - Future checkAddItem(User user, String itemType, int currentCount); + /// Throws a [ForbiddenException] if any limit is exceeded. + Future checkInterestLimits({ + required User user, + required Interest interest, + required List existingInterests, + }); - /// Checks if the proposed *entire state* of the user's preferences, - /// represented by [updatedPreferences], exceeds the limits based on their role. + /// Validates an updated [UserContentPreferences] object against the limits + /// defined in `UserPreferenceConfig`. /// - /// 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, - ); + /// This method checks the total counts for followed items (countries, + /// sources, topics) and saved headlines. + /// Throws a [ForbiddenException] if any limit is exceeded. + Future checkUserContentPreferencesLimits({ + required User user, + required UserContentPreferences updatedPreferences, + }); } From 5d6153965cfbbeb51186254564215b0f892a9e04 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 09:03:08 +0100 Subject: [PATCH 20/38] fix(api): update data operation registry to use new limit services This change updates the DataOperationRegistry to correctly call the new methods on the UserPreferenceLimitService. The custom creator and updater for the 'interest' model now use the correct named arguments (user, interest) when calling checkInterestLimits, resolving the compile-time errors. A custom updater has been added for 'user_content_preferences' to call the new checkUserContentPreferencesLimits method, ensuring that limits for followed items and saved headlines are now correctly enforced before any update. --- lib/src/registry/data_operation_registry.dart | 77 ++++++++++++------- 1 file changed, 50 insertions(+), 27 deletions(-) diff --git a/lib/src/registry/data_operation_registry.dart b/lib/src/registry/data_operation_registry.dart index 983774a..72aac9b 100644 --- a/lib/src/registry/data_operation_registry.dart +++ b/lib/src/registry/data_operation_registry.dart @@ -175,19 +175,20 @@ class DataOperationRegistry { sort: s, pagination: p, ), - 'interest': (c, uid, f, s, p) => c.read>().readAll( - userId: uid, - filter: f, - sort: s, - pagination: p, - ), + 'interest': (c, uid, f, s, p) => + c.read>().readAll( + userId: uid, + filter: f, + sort: s, + pagination: p, + ), 'in_app_notification': (c, uid, f, s, p) => c.read>().readAll( - userId: uid, - filter: f, - sort: s, - pagination: p, - ), + userId: uid, + filter: f, + sort: s, + pagination: p, + ), }); // --- Register Item Creators --- @@ -290,15 +291,15 @@ class DataOperationRegistry { // 2. Check limits before creating. await context.read().checkInterestLimits( - authenticatedUser, - interestToCreate, - existingInterests: preferences.interests, - ); + user: authenticatedUser, + interest: interestToCreate, + existingInterests: preferences.interests, + ); // 3. Proceed with creation. return context.read>().create( - item: interestToCreate, - ); + item: interestToCreate, + ); }, }); @@ -413,9 +414,27 @@ 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 preferencesToUpdate = item as UserContentPreferences; + + // 1. Check limits before updating. + await context + .read() + .checkUserContentPreferencesLimits( + user: authenticatedUser, + updatedPreferences: preferencesToUpdate, + ); + + // 2. Proceed with update. + return context.read>().update( + id: id, + item: preferencesToUpdate, + ); + }, 'remote_config': (c, id, item, uid) => c .read>() .update(id: id, item: item as RemoteConfig, userId: uid), @@ -430,18 +449,22 @@ class DataOperationRegistry { .read(id: authenticatedUser.id); // Exclude the interest being updated from the list for limit checking. - final otherInterests = - preferences.interests.where((i) => i.id != id).toList(); + final otherInterests = preferences.interests + .where((i) => i.id != id) + .toList(); // 2. Check limits before updating. await context.read().checkInterestLimits( - authenticatedUser, - interestToUpdate, - existingInterests: otherInterests, - ); + user: authenticatedUser, + interest: interestToUpdate, + existingInterests: otherInterests, + ); // 3. Proceed with update. - return context.read>().update(id: id, item: interestToUpdate); + return context.read>().update( + id: id, + item: interestToUpdate, + ); }, }); From 11403f0c6826f5977ae5e6cfc2120e8233000a2b Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 09:10:05 +0100 Subject: [PATCH 21/38] refactor(dependencies): remove permissionService from AppDependencies - Removed permissionService parameter from DefaultUserPreferenceLimitService initialization --- lib/src/config/app_dependencies.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index e6260ae..280e3ab 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -385,7 +385,6 @@ class AppDependencies { ); userPreferenceLimitService = DefaultUserPreferenceLimitService( remoteConfigRepository: remoteConfigRepository, - permissionService: permissionService, log: Logger('DefaultUserPreferenceLimitService'), ); rateLimitService = MongoDbRateLimitService( From 93562d0f5757a31495f38c1bf4e7528a09d9e537 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 09:10:09 +0100 Subject: [PATCH 22/38] fix(api): remove obsolete validation call in PUT handler The PUT handler in routes/api/v1/data/[id]/index.dart contained a manual check for user_content_preferences that called the old checkUpdatePreferences method, which has been removed. This caused a compile-time error. This change removes the obsolete if block. The validation logic is now correctly and exclusively handled by the custom updater for user_content_preferences within the DataOperationRegistry, which was implemented in a previous step. This resolves the error and ensures the correct validation path is used. --- routes/api/v1/data/[id]/index.dart | 24 ------------------------ 1 file changed, 24 deletions(-) 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, From db55ea29053188c9c64af98c215003d614f7a8c6 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 09:42:57 +0100 Subject: [PATCH 23/38] refactor(dependencies): remove interest repository and update push notification service - Remove DataRepository and related initialization - Update PushNotificationService to use userContentPreferencesRepository instead of interestRepository - Adjust log message for InAppNotification data client initialization --- lib/src/config/app_dependencies.dart | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index 280e3ab..91f3351 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -71,7 +71,6 @@ class AppDependencies { late final DataRepository pushNotificationDeviceRepository; late final DataRepository remoteConfigRepository; - late final DataRepository interestRepository; late final DataRepository inAppNotificationRepository; late final EmailRepository emailRepository; @@ -222,15 +221,6 @@ class AppDependencies { logger: Logger('DataMongodb'), ); - // Initialize Data Clients for new Interest and InAppNotification models - final interestClient = DataMongodb( - connectionManager: _mongoDbConnectionManager, - modelName: 'interests', - fromJson: Interest.fromJson, - toJson: (item) => item.toJson(), - logger: Logger('DataMongodb'), - ); - final inAppNotificationClient = DataMongodb( connectionManager: _mongoDbConnectionManager, modelName: 'in_app_notifications', @@ -239,9 +229,7 @@ class AppDependencies { logger: Logger('DataMongodb'), ); - _log.info( - 'Initialized data clients for Interest and InAppNotification.', - ); + _log.info('Initialized data client for InAppNotification.'); // --- Conditionally Initialize Push Notification Clients --- @@ -328,9 +316,6 @@ class AppDependencies { pushNotificationDeviceRepository = DataRepository( dataClient: pushNotificationDeviceClient, ); - interestRepository = DataRepository( - dataClient: interestClient, - ); inAppNotificationRepository = DataRepository( dataClient: inAppNotificationClient, ); @@ -398,7 +383,7 @@ class AppDependencies { ); pushNotificationService = DefaultPushNotificationService( pushNotificationDeviceRepository: pushNotificationDeviceRepository, - interestRepository: interestRepository, + userContentPreferencesRepository: userContentPreferencesRepository, remoteConfigRepository: remoteConfigRepository, firebaseClient: firebasePushNotificationClient, oneSignalClient: oneSignalPushNotificationClient, From 8774c2ef63fd0330c6f313f8c8c9788e9068ee50 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 09:43:08 +0100 Subject: [PATCH 24/38] refactor(rbac): remove interest permissions - Removed user-owned interest permissions from the Permissions class - These permissions are no longer used in the application - This change simplifies the permissions structure and reduces redundancy --- lib/src/rbac/permissions.dart | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/lib/src/rbac/permissions.dart b/lib/src/rbac/permissions.dart index bb23f14..92bb710 100644 --- a/lib/src/rbac/permissions.dart +++ b/lib/src/rbac/permissions.dart @@ -96,19 +96,6 @@ abstract class Permissions { static const String pushNotificationDeviceDeleteOwned = 'push_notification_device.delete_owned'; - // Interest Permissions (User-owned) - /// Allows creating a new interest for the authenticated user. - static const String interestCreateOwned = 'interest.create_owned'; - - /// Allows reading the authenticated user's own interests. - static const String interestReadOwned = 'interest.read_owned'; - - /// Allows updating the authenticated user's own interests. - static const String interestUpdateOwned = 'interest.update_owned'; - - /// Allows deleting the authenticated user's own interests. - static const String interestDeleteOwned = 'interest.delete_owned'; - // In-App Notification Permissions (User-owned) /// Allows reading the user's own in-app notifications. static const String inAppNotificationReadOwned = From b798ffde89ddb37f2c33ca8ecd64f0acf3af8848 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 09:43:17 +0100 Subject: [PATCH 25/38] fix(rbac): remove self-manage interests permissions for app guests - Remove permissions related to creating, reading, updating, and deleting owned interests for app guest users - This change aligns with the requirement that app guests should not be able to manage their interests --- lib/src/rbac/role_permissions.dart | 7 ------- 1 file changed, 7 deletions(-) diff --git a/lib/src/rbac/role_permissions.dart b/lib/src/rbac/role_permissions.dart index b74dcbb..04c5fad 100644 --- a/lib/src/rbac/role_permissions.dart +++ b/lib/src/rbac/role_permissions.dart @@ -25,13 +25,6 @@ final Set _appGuestUserPermissions = { // notifications. Permissions.pushNotificationDeviceCreateOwned, Permissions.pushNotificationDeviceDeleteOwned, - - // Allow all app users to manage their own interests. - Permissions.interestCreateOwned, - Permissions.interestReadOwned, - Permissions.interestUpdateOwned, - Permissions.interestDeleteOwned, - // Allow all app users to manage their own in-app notifications. Permissions.inAppNotificationReadOwned, Permissions.inAppNotificationUpdateOwned, From 64d3373ceea3c09911d2ec860b2b8543c4a95084 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 09:44:11 +0100 Subject: [PATCH 26/38] refactor(data): remove interest model and related operations - Remove interest model from model registry - Remove interest operations from data operation registry - Update user preference handling to manage interests within UserContentPreferences - Simplify interest-related permission checks and validations --- lib/src/registry/data_operation_registry.dart | 161 ++++++++++-------- lib/src/registry/model_registry.dart | 34 ---- 2 files changed, 92 insertions(+), 103 deletions(-) diff --git a/lib/src/registry/data_operation_registry.dart b/lib/src/registry/data_operation_registry.dart index 72aac9b..b62f1e4 100644 --- a/lib/src/registry/data_operation_registry.dart +++ b/lib/src/registry/data_operation_registry.dart @@ -121,8 +121,6 @@ class DataOperationRegistry { c.read>().read(id: id, userId: null), 'dashboard_summary': (c, id) => c.read().getSummary(), - 'interest': (c, id) => - c.read>().read(id: id, userId: null), 'in_app_notification': (c, id) => c .read>() .read(id: id, userId: null), @@ -170,13 +168,6 @@ class DataOperationRegistry { .read>() .readAll(userId: uid, filter: f, sort: s, pagination: p), 'user': (c, uid, f, s, p) => c.read>().readAll( - userId: uid, - filter: f, - sort: s, - pagination: p, - ), - 'interest': (c, uid, f, s, p) => - c.read>().readAll( userId: uid, filter: f, sort: s, @@ -277,30 +268,6 @@ class DataOperationRegistry { userId: null, ); }, - 'interest': (context, item, uid) async { - _log.info('Executing custom creator for interest.'); - final authenticatedUser = context.read(); - final interestToCreate = (item as Interest).copyWith( - userId: authenticatedUser.id, - ); - - // 1. Fetch current user preferences to get existing interests. - final preferences = await context - .read>() - .read(id: authenticatedUser.id); - - // 2. Check limits before creating. - await context.read().checkInterestLimits( - user: authenticatedUser, - interest: interestToCreate, - existingInterests: preferences.interests, - ); - - // 3. Proceed with creation. - return context.read>().create( - item: interestToCreate, - ); - }, }); // --- Register Item Updaters --- @@ -419,53 +386,111 @@ class DataOperationRegistry { 'Executing custom updater for user_content_preferences ID: $id.', ); final authenticatedUser = context.read(); - final preferencesToUpdate = item as UserContentPreferences; + final userPreferenceLimitService = + context.read(); + final userContentPreferencesRepository = + context.read>(); - // 1. Check limits before updating. - await context - .read() - .checkUserContentPreferencesLimits( - user: authenticatedUser, - updatedPreferences: preferencesToUpdate, - ); + final preferencesToUpdate = item as UserContentPreferences; - // 2. Proceed with update. - return context.read>().update( + // 1. Fetch the current state of the user's preferences. + final currentPreferences = await userContentPreferencesRepository.read( id: id, - item: preferencesToUpdate, ); - }, - 'remote_config': (c, id, item, uid) => c - .read>() - .update(id: id, item: item as RemoteConfig, userId: uid), - 'interest': (context, id, item, uid) async { - _log.info('Executing custom updater for interest ID: $id.'); - final authenticatedUser = context.read(); - final interestToUpdate = item as Interest; - // 1. Fetch current user preferences to get existing interests. - final preferences = await context - .read>() - .read(id: authenticatedUser.id); + // 2. Detect changes in the interests list. + final currentIds = + currentPreferences.interests.map((i) => i.id).toSet(); + final updatedIds = + preferencesToUpdate.interests.map((i) => i.id).toSet(); - // Exclude the interest being updated from the list for limit checking. - final otherInterests = preferences.interests - .where((i) => i.id != id) - .toList(); + final addedIds = updatedIds.difference(currentIds); + final removedIds = currentIds.difference(updatedIds); - // 2. Check limits before updating. - await context.read().checkInterestLimits( + // For simplicity and clear validation, enforce one change at a time. + if (addedIds.length + removedIds.length > 1) { + throw const BadRequestException( + 'Only one interest can be added or removed per request.', + ); + } + + // 3. Perform permission and limit checks based on the detected action. + if (addedIds.isNotEmpty) { + // --- Interest Added --- + final addedInterestId = addedIds.first; + _log.info( + 'Detected interest addition for user ${authenticatedUser.id}.', + ); + + final addedInterest = preferencesToUpdate.interests + .firstWhere((i) => i.id == addedInterestId); + + // Check business logic limits. + await userPreferenceLimitService.checkInterestLimits( + user: authenticatedUser, + interest: addedInterest, + existingInterests: currentPreferences.interests, + ); + } else if (removedIds.isNotEmpty) { + // --- Interest Removed --- + _log.info( + 'Detected interest removal for user ${authenticatedUser.id}.', + ); + + } else { + // --- Interest Potentially Updated --- + // Check if any existing interest was modified. + Interest? updatedInterest; + for (final newInterest in preferencesToUpdate.interests) { + // Find the corresponding interest in the old list. + final oldInterest = currentPreferences.interests.firstWhere( + (i) => i.id == newInterest.id, + // This should not be hit if add/remove is handled, but as a + // safeguard, we use the newInterest to avoid null issues. + orElse: () => newInterest, + ); + if (newInterest != oldInterest) { + updatedInterest = newInterest; + break; // Found the updated one, no need to continue loop. + } + } + + if (updatedInterest != null) { + _log.info( + 'Detected interest update for user ${authenticatedUser.id}.', + ); + + // Check business logic limits. + final otherInterests = currentPreferences.interests + .where((i) => i.id != updatedInterest!.id) + .toList(); + await userPreferenceLimitService.checkInterestLimits( + user: authenticatedUser, + interest: updatedInterest, + existingInterests: otherInterests, + ); + } + } + + // 4. Always validate general preference limits (followed items, etc.). + await userPreferenceLimitService.checkUserContentPreferencesLimits( user: authenticatedUser, - interest: interestToUpdate, - existingInterests: otherInterests, + updatedPreferences: preferencesToUpdate, ); - // 3. Proceed with update. - return context.read>().update( + // 5. 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: interestToUpdate, + item: preferencesToUpdate, ); }, + 'remote_config': (c, id, item, uid) => c + .read>() + .update(id: id, item: item as RemoteConfig, userId: uid), }); // --- Register Item Deleters --- @@ -490,8 +515,6 @@ class DataOperationRegistry { 'push_notification_device': (c, id, uid) => c .read>() .delete(id: id, userId: uid), - 'interest': (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 124aa5e..1bcaa4d 100644 --- a/lib/src/registry/model_registry.dart +++ b/lib/src/registry/model_registry.dart @@ -463,40 +463,6 @@ final modelRegistry = >{ requiresOwnershipCheck: true, ), ), - 'interest': ModelConfig( - fromJson: Interest.fromJson, - getId: (i) => i.id, - getOwnerId: (dynamic item) => (item as Interest).userId, - // Collection GET is admin-only to prevent listing all users' interests. - getCollectionPermission: const ModelActionPermission( - type: RequiredPermissionType.adminOnly, - ), - // Item GET is unsupported. Interests are managed as part of the - // UserContentPreferences object, not as individual top-level documents. - getItemPermission: const ModelActionPermission( - type: RequiredPermissionType.unsupported, - ), - // POST is allowed for any authenticated user to create their own interest. - // A custom creator in DataOperationRegistry will enforce role-based limits. - postPermission: const ModelActionPermission( - type: RequiredPermissionType.specificPermission, - permission: Permissions.interestCreateOwned, - requiresOwnershipCheck: false, - ), - // PUT is allowed for any authenticated user to update their own interest. - // A custom updater in DataOperationRegistry will enforce role-based limits. - putPermission: const ModelActionPermission( - type: RequiredPermissionType.specificPermission, - permission: Permissions.interestUpdateOwned, - requiresOwnershipCheck: true, - ), - // DELETE is allowed for any authenticated user to delete their own interest. - deletePermission: const ModelActionPermission( - type: RequiredPermissionType.specificPermission, - permission: Permissions.interestDeleteOwned, - requiresOwnershipCheck: true, - ), - ), 'in_app_notification': ModelConfig( fromJson: InAppNotification.fromJson, getId: (n) => n.id, From 918de42b61682acc7775907f0a46385ccba895b7 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 09:46:26 +0100 Subject: [PATCH 27/38] refactor(database): remove indexes creation for interests collection - Removed the code responsible for creating indexes for the 'interests' collection - This change optimizes the database seeding process by eliminating unnecessary indexing --- lib/src/services/database_seeding_service.dart | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 7ce6154..26ca56f 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -252,19 +252,6 @@ class DatabaseSeedingService { }); _log.info('Ensured indexes for "push_notification_devices".'); - // Indexes for the interests collection - await _db.runCommand({ - 'createIndexes': 'interests', - 'indexes': [ - { - // Optimizes fetching interests for a specific user. - 'key': {'userId': 1}, - 'name': 'userId_index', - }, - ], - }); - _log.info('Ensured indexes for "interests".'); - // Indexes for the in-app notifications collection await _db.runCommand({ 'createIndexes': 'in_app_notifications', From 2cf110be445a0cb8758e4dc73a28996f4c90632d Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 09:46:43 +0100 Subject: [PATCH 28/38] refactor(push-notification): update breaking news subscription query - Replace interest-based query with user preferences-based query - Improve efficiency by directly targeting embedded interests array - Simplify user ID collection process - Update logging to reflect new query approach --- .../services/push_notification_service.dart | 45 ++++++++++--------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/lib/src/services/push_notification_service.dart b/lib/src/services/push_notification_service.dart index 941b165..992b035 100644 --- a/lib/src/services/push_notification_service.dart +++ b/lib/src/services/push_notification_service.dart @@ -32,13 +32,14 @@ class DefaultPushNotificationService implements IPushNotificationService { DefaultPushNotificationService({ required DataRepository pushNotificationDeviceRepository, - required DataRepository interestRepository, + required DataRepository + userContentPreferencesRepository, required DataRepository remoteConfigRepository, required IPushNotificationClient? firebaseClient, required IPushNotificationClient? oneSignalClient, required Logger log, }) : _pushNotificationDeviceRepository = pushNotificationDeviceRepository, - _interestRepository = interestRepository, + _userContentPreferencesRepository = userContentPreferencesRepository, _remoteConfigRepository = remoteConfigRepository, _firebaseClient = firebaseClient, _oneSignalClient = oneSignalClient, @@ -46,7 +47,8 @@ class DefaultPushNotificationService implements IPushNotificationService { final DataRepository _pushNotificationDeviceRepository; - final DataRepository _interestRepository; + final DataRepository + _userContentPreferencesRepository; final DataRepository _remoteConfigRepository; final IPushNotificationClient? _firebaseClient; final IPushNotificationClient? _oneSignalClient; @@ -115,33 +117,34 @@ class DefaultPushNotificationService implements IPushNotificationService { return; } - // 2. Find all interests subscribed to breaking news. - // The query now correctly finds interests where the 'deliveryTypes' - // set *contains* the 'breakingOnly' value. - final breakingNewsInterests = await _interestRepository.readAll( - filter: { - 'deliveryTypes': { - r'$in': [ - PushNotificationSubscriptionDeliveryType.breakingOnly.name, - ], - }, - }, - ); + // 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: { + 'interests.deliveryTypes': { + r'$in': [ + PushNotificationSubscriptionDeliveryType.breakingOnly.name, + ], + }, + }, + ); - if (breakingNewsInterests.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 = breakingNewsInterests.items - .map((interest) => interest.userId) + // The ID of the UserContentPreferences document is the user's ID. + final userIds = subscribedUserPreferences.items + .map((preference) => preference.id) .toSet(); _log.info( - 'Found ${breakingNewsInterests.items.length} interests subscribed to ' - '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. From 9c3948be8b37c4cf9af7e17d5ac1688bc5f052e5 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 09:46:51 +0100 Subject: [PATCH 29/38] refactor(routes): remove unused interestRepository provider - Remove the unused provider for DataRepository - This change simplifies the middleware setup and removes unnecessary code --- routes/_middleware.dart | 5 ----- 1 file changed, 5 deletions(-) diff --git a/routes/_middleware.dart b/routes/_middleware.dart index 39b5f7c..a0dfa9b 100644 --- a/routes/_middleware.dart +++ b/routes/_middleware.dart @@ -134,11 +134,6 @@ Handler middleware(Handler handler) { (_) => deps.pushNotificationDeviceRepository, ), ) - .use( - provider>( - (_) => deps.interestRepository, - ), - ) .use( provider>( (_) => deps.inAppNotificationRepository, From 978427222dd001dd64ad48eb64479f5bf4601798 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 10:16:18 +0100 Subject: [PATCH 30/38] fix(database): handle malformed criteria in saved filters and subscriptions - Add validation for 'criteria' in saved filters and notification subscriptions - Skip processing for malformed criteria instead of throwing an error - Log a warning message for skipped filters or subscriptions --- ...000_unify_interests_and_remote_config.dart | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) 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 index 37283e7..a30e6d7 100644 --- a/lib/src/database/migrations/20251111000000_unify_interests_and_remote_config.dart +++ b/lib/src/database/migrations/20251111000000_unify_interests_and_remote_config.dart @@ -63,9 +63,16 @@ class UnifyInterestsAndRemoteConfig extends Migration { // Process saved filters for (final filter in savedFilters) { - final criteria = InterestCriteria.fromJson( - filter['criteria'] as Map, - ); + 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( @@ -84,9 +91,16 @@ class UnifyInterestsAndRemoteConfig extends Migration { // Process notification subscriptions for (final subscription in notificationSubscriptions) { - final criteria = InterestCriteria.fromJson( - subscription['criteria'] as Map, - ); + 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? ?? []) From 935a53199ad046fa194e9e561335ab1d15709885 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 10:16:23 +0100 Subject: [PATCH 31/38] style: format --- lib/src/registry/data_operation_registry.dart | 34 ++++++++++--------- lib/src/services/auth_service.dart | 2 +- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/lib/src/registry/data_operation_registry.dart b/lib/src/registry/data_operation_registry.dart index b62f1e4..d6e3bdf 100644 --- a/lib/src/registry/data_operation_registry.dart +++ b/lib/src/registry/data_operation_registry.dart @@ -168,11 +168,11 @@ class DataOperationRegistry { .read>() .readAll(userId: uid, filter: f, sort: s, pagination: p), 'user': (c, uid, f, s, p) => c.read>().readAll( - userId: uid, - filter: f, - sort: s, - pagination: p, - ), + userId: uid, + filter: f, + sort: s, + pagination: p, + ), 'in_app_notification': (c, uid, f, s, p) => c.read>().readAll( userId: uid, @@ -386,10 +386,10 @@ class DataOperationRegistry { 'Executing custom updater for user_content_preferences ID: $id.', ); final authenticatedUser = context.read(); - final userPreferenceLimitService = - context.read(); - final userContentPreferencesRepository = - context.read>(); + final userPreferenceLimitService = context + .read(); + final userContentPreferencesRepository = context + .read>(); final preferencesToUpdate = item as UserContentPreferences; @@ -399,10 +399,12 @@ class DataOperationRegistry { ); // 2. Detect changes in the interests list. - final currentIds = - currentPreferences.interests.map((i) => i.id).toSet(); - final updatedIds = - preferencesToUpdate.interests.map((i) => i.id).toSet(); + final currentIds = currentPreferences.interests + .map((i) => i.id) + .toSet(); + final updatedIds = preferencesToUpdate.interests + .map((i) => i.id) + .toSet(); final addedIds = updatedIds.difference(currentIds); final removedIds = currentIds.difference(updatedIds); @@ -422,8 +424,9 @@ class DataOperationRegistry { 'Detected interest addition for user ${authenticatedUser.id}.', ); - final addedInterest = preferencesToUpdate.interests - .firstWhere((i) => i.id == addedInterestId); + final addedInterest = preferencesToUpdate.interests.firstWhere( + (i) => i.id == addedInterestId, + ); // Check business logic limits. await userPreferenceLimitService.checkInterestLimits( @@ -436,7 +439,6 @@ class DataOperationRegistry { _log.info( 'Detected interest removal for user ${authenticatedUser.id}.', ); - } else { // --- Interest Potentially Updated --- // Check if any existing interest was modified. diff --git a/lib/src/services/auth_service.dart b/lib/src/services/auth_service.dart index bb9fed5..734d89f 100644 --- a/lib/src/services/auth_service.dart +++ b/lib/src/services/auth_service.dart @@ -563,7 +563,7 @@ class AuthService { followedCountries: const [], followedSources: const [], followedTopics: const [], - savedHeadlines: const [], + savedHeadlines: const [], interests: const [], ); await _userContentPreferencesRepository.create( From 14b8c5e52ec7ee8d9bc98d52ad6f5a0ca01604f8 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 10:20:02 +0100 Subject: [PATCH 32/38] docs(README): enhance notification streams description - Introduce the concept of "Interests" to explain user-created content filters - Clarify that users subscribe to notifications for specific Interests - Improve readability and understanding of the user-crafted notification feature --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. From 39c471a037e7f96a60327e660d68f65cc9e941e6 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 10:31:32 +0100 Subject: [PATCH 33/38] refactor(api): unify user preference limit checking This refactoring consolidates the UserPreferenceLimitService to use a single, holistic method (checkUserContentPreferencesLimits) for all UserContentPreferences validations. The service interface and implementation have been updated to use a single method that compares the updated preferences with the current state to determine what has changed and apply the correct limits for interests, followed items, and saved headlines. The custom updater for user_content_preferences in DataOperationRegistry has been significantly simplified. It no longer contains complex logic to detect changes and now makes a single call to the unified service method. This change improves architectural purity by centralizing all limit-checking logic within the service, making the system more robust and easier to maintain. --- lib/src/registry/data_operation_registry.dart | 84 +------- ...default_user_preference_limit_service.dart | 192 ++++++++++-------- .../user_preference_limit_service.dart | 29 +-- 3 files changed, 113 insertions(+), 192 deletions(-) diff --git a/lib/src/registry/data_operation_registry.dart b/lib/src/registry/data_operation_registry.dart index d6e3bdf..f5b85c6 100644 --- a/lib/src/registry/data_operation_registry.dart +++ b/lib/src/registry/data_operation_registry.dart @@ -398,89 +398,17 @@ class DataOperationRegistry { id: id, ); - // 2. Detect changes in the interests list. - final currentIds = currentPreferences.interests - .map((i) => i.id) - .toSet(); - final updatedIds = preferencesToUpdate.interests - .map((i) => i.id) - .toSet(); - - final addedIds = updatedIds.difference(currentIds); - final removedIds = currentIds.difference(updatedIds); - - // For simplicity and clear validation, enforce one change at a time. - if (addedIds.length + removedIds.length > 1) { - throw const BadRequestException( - 'Only one interest can be added or removed per request.', - ); - } - - // 3. Perform permission and limit checks based on the detected action. - if (addedIds.isNotEmpty) { - // --- Interest Added --- - final addedInterestId = addedIds.first; - _log.info( - 'Detected interest addition for user ${authenticatedUser.id}.', - ); - - final addedInterest = preferencesToUpdate.interests.firstWhere( - (i) => i.id == addedInterestId, - ); - - // Check business logic limits. - await userPreferenceLimitService.checkInterestLimits( - user: authenticatedUser, - interest: addedInterest, - existingInterests: currentPreferences.interests, - ); - } else if (removedIds.isNotEmpty) { - // --- Interest Removed --- - _log.info( - 'Detected interest removal for user ${authenticatedUser.id}.', - ); - } else { - // --- Interest Potentially Updated --- - // Check if any existing interest was modified. - Interest? updatedInterest; - for (final newInterest in preferencesToUpdate.interests) { - // Find the corresponding interest in the old list. - final oldInterest = currentPreferences.interests.firstWhere( - (i) => i.id == newInterest.id, - // This should not be hit if add/remove is handled, but as a - // safeguard, we use the newInterest to avoid null issues. - orElse: () => newInterest, - ); - if (newInterest != oldInterest) { - updatedInterest = newInterest; - break; // Found the updated one, no need to continue loop. - } - } - - if (updatedInterest != null) { - _log.info( - 'Detected interest update for user ${authenticatedUser.id}.', - ); - - // Check business logic limits. - final otherInterests = currentPreferences.interests - .where((i) => i.id != updatedInterest!.id) - .toList(); - await userPreferenceLimitService.checkInterestLimits( - user: authenticatedUser, - interest: updatedInterest, - existingInterests: otherInterests, - ); - } - } - - // 4. Always validate general preference limits (followed items, etc.). + // 2. Validate all limits using the consolidated service method. + // The service now contains all logic to compare the updated and + // current preferences and check all relevant limits (interests, + // followed items, etc.) in one go. await userPreferenceLimitService.checkUserContentPreferencesLimits( user: authenticatedUser, updatedPreferences: preferencesToUpdate, + currentPreferences: currentPreferences, ); - // 5. If all checks pass, proceed with the update. + // 3. If all checks pass, proceed with the update. _log.info( 'All preference validations passed for user ${authenticatedUser.id}. ' 'Proceeding with update.', diff --git a/lib/src/services/default_user_preference_limit_service.dart b/lib/src/services/default_user_preference_limit_service.dart index 6b23294..e9d1f3b 100644 --- a/lib/src/services/default_user_preference_limit_service.dart +++ b/lib/src/services/default_user_preference_limit_service.dart @@ -1,5 +1,6 @@ import 'package:core/core.dart'; import 'package:data_repository/data_repository.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/helpers/set_equality_helper.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/user_preference_limit_service.dart'; import 'package:logging/logging.dart'; @@ -22,95 +23,15 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { // Assuming a fixed ID for the RemoteConfig document static const String _remoteConfigId = kRemoteConfigId; - @override - Future checkInterestLimits({ - required User user, - required Interest interest, - required List existingInterests, - }) async { - _log.info('Checking interest limits for user ${user.id}.'); - final remoteConfig = await _remoteConfigRepository.read( - id: _remoteConfigId, - ); - final limits = remoteConfig.interestConfig.limits[user.appRole]; - - if (limits == null) { - _log.severe( - 'Interest limits not found for role ${user.appRole}. ' - 'Denying request by default.', - ); - throw const ForbiddenException('Interest limits are not configured.'); - } - - // 1. Check total number of interests. - final newTotal = existingInterests.length + 1; - if (newTotal > limits.total) { - _log.warning( - 'User ${user.id} exceeded total interest limit: ' - '${limits.total} (attempted $newTotal).', - ); - throw ForbiddenException( - 'You have reached your limit of ${limits.total} saved interests.', - ); - } - - // 2. Check total number of pinned feed filters. - if (interest.isPinnedFeedFilter) { - final pinnedCount = - existingInterests.where((i) => i.isPinnedFeedFilter).length + 1; - if (pinnedCount > limits.pinnedFeedFilters) { - _log.warning( - 'User ${user.id} exceeded pinned feed filter limit: ' - '${limits.pinnedFeedFilters} (attempted $pinnedCount).', - ); - throw ForbiddenException( - 'You have reached your limit of ${limits.pinnedFeedFilters} ' - 'pinned feed filters.', - ); - } - } - - // 3. Check notification subscription limits for each type. - for (final deliveryType in interest.deliveryTypes) { - final notificationLimit = limits.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.', - ); - } - - final subscriptionCount = - existingInterests - .where((i) => i.deliveryTypes.contains(deliveryType)) - .length + - 1; - - 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.', - ); - } - } - - _log.info('Interest limits check passed for user ${user.id}.'); - } - @override Future checkUserContentPreferencesLimits({ required User user, required UserContentPreferences updatedPreferences, + required UserContentPreferences currentPreferences, }) async { - _log.info('Checking user content preferences limits for user ${user.id}.'); + _log.info( + 'Checking all user content preferences limits for user ${user.id}.', + ); final remoteConfig = await _remoteConfigRepository.read( id: _remoteConfigId, ); @@ -121,7 +42,7 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { limits, ); - // Check followed countries + // --- 1. Check general preference limits --- if (updatedPreferences.followedCountries.length > followedItemsLimit) { _log.warning( 'User ${user.id} exceeded followed countries limit: ' @@ -133,7 +54,6 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { ); } - // Check followed sources if (updatedPreferences.followedSources.length > followedItemsLimit) { _log.warning( 'User ${user.id} exceeded followed sources limit: ' @@ -145,7 +65,6 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { ); } - // Check followed topics if (updatedPreferences.followedTopics.length > followedItemsLimit) { _log.warning( 'User ${user.id} exceeded followed topics limit: ' @@ -157,7 +76,6 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { ); } - // Check saved headlines if (updatedPreferences.savedHeadlines.length > savedHeadlinesLimit) { _log.warning( 'User ${user.id} exceeded saved headlines limit: ' @@ -169,8 +87,104 @@ 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) { + _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.', + ); + } + + // Find the interest that was added or updated to check its specific limits. + // This logic assumes only one interest is added or updated per request. + final currentInterestIds = currentPreferences.interests + .map((i) => i.id) + .toSet(); + Interest? changedInterest; + + for (final updatedInterest in updatedPreferences.interests) { + if (!currentInterestIds.contains(updatedInterest.id)) { + // This is a newly added interest. + changedInterest = updatedInterest; + break; + } else { + // This is a potentially updated interest. Find the original. + final originalInterest = currentPreferences.interests.firstWhere( + (i) => i.id == updatedInterest.id, + ); + if (updatedInterest != originalInterest) { + changedInterest = updatedInterest; + break; + } + } + } + + // If an interest was added or updated, check its specific limits. + if (changedInterest != null) { + _log.info('Checking limits for changed interest: ${changedInterest.id}'); + + // 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 type. + for (final deliveryType in changedInterest.deliveryTypes) { + 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.', + ); + } + + 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.', + ); + } + } + } + _log.info( - 'User content preferences limits check passed for user ${user.id}.', + 'All user content preferences limits check passed for user ${user.id}.', ); } diff --git a/lib/src/services/user_preference_limit_service.dart b/lib/src/services/user_preference_limit_service.dart index 49189a3..bb823e3 100644 --- a/lib/src/services/user_preference_limit_service.dart +++ b/lib/src/services/user_preference_limit_service.dart @@ -11,35 +11,14 @@ abstract class UserPreferenceLimitService { /// {@macro user_preference_limit_service} const UserPreferenceLimitService(); - /// Validates a new or updated [Interest] against the user's role-based - /// limits defined in `InterestConfig`. + /// Validates an updated [UserContentPreferences] object against all limits + /// defined in `RemoteConfig`, including interests, followed items, and + /// saved headlines. /// - /// This method checks multiple limits: - /// - The total number of interests. - /// - The number of interests marked as pinned feed filters. - /// - The number of subscriptions for each notification delivery type across - /// all of the user's interests. - /// - /// - [user]: The authenticated user. - /// - [interest]: The `Interest` object being created or updated. - /// - [existingInterests]: A list of the user's other existing interests, - /// used to calculate total counts. - /// - /// Throws a [ForbiddenException] if any limit is exceeded. - Future checkInterestLimits({ - required User user, - required Interest interest, - required List existingInterests, - }); - - /// Validates an updated [UserContentPreferences] object against the limits - /// defined in `UserPreferenceConfig`. - /// - /// This method checks the total counts for followed items (countries, - /// sources, topics) and saved headlines. /// Throws a [ForbiddenException] if any limit is exceeded. Future checkUserContentPreferencesLimits({ required User user, required UserContentPreferences updatedPreferences, + required UserContentPreferences currentPreferences, }); } From aaae462863fdfdcb1019749523cf14fffc1d5b8a Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 10:40:13 +0100 Subject: [PATCH 34/38] refactor: remove unused import - Removed unused import statement for SetEqualityHelper - This change simplifies the code and removes an unnecessary dependency --- lib/src/services/default_user_preference_limit_service.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/src/services/default_user_preference_limit_service.dart b/lib/src/services/default_user_preference_limit_service.dart index e9d1f3b..fc55f3e 100644 --- a/lib/src/services/default_user_preference_limit_service.dart +++ b/lib/src/services/default_user_preference_limit_service.dart @@ -1,6 +1,5 @@ import 'package:core/core.dart'; import 'package:data_repository/data_repository.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/helpers/set_equality_helper.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/user_preference_limit_service.dart'; import 'package:logging/logging.dart'; From 8a45a96e5e239559231a0b0669bd27e07aa6ecda Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 10:46:02 +0100 Subject: [PATCH 35/38] fix(api): harden migration script and correctly implement admin limit bypass This commit applies two important fixes based on code review feedback: Robust Migration Script: The UnifyInterestsAndRemoteConfig migration script has been hardened. It now wraps the parsing of deliveryTypes in a try-catch block. This prevents the entire server from crashing on startup if it encounters malformed data (an invalid enum string) in a user's legacy notificationSubscriptions, making the migration process more resilient. Correct Admin Bypass: The administrator bypass for user preference limits has been re-implemented in the architecturally correct location. Instead of coupling the UserPreferenceLimitService with the PermissionService, the check is now performed in the user_content_preferences custom updater within data_operation_registry.dart. This ensures that the limit service is only called if the user does not have the userPreferenceBypassLimits permission, maintaining a clean separation of concerns. --- ...000_unify_interests_and_remote_config.dart | 17 ++++++++---- lib/src/registry/data_operation_registry.dart | 26 ++++++++++++++----- 2 files changed, 31 insertions(+), 12 deletions(-) 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 index a30e6d7..a4050d8 100644 --- a/lib/src/database/migrations/20251111000000_unify_interests_and_remote_config.dart +++ b/lib/src/database/migrations/20251111000000_unify_interests_and_remote_config.dart @@ -104,11 +104,18 @@ class UnifyInterestsAndRemoteConfig extends Migration { final key = _generateCriteriaKey(criteria); final deliveryTypes = (subscription['deliveryTypes'] as List? ?? []) - .map( - (e) => PushNotificationSubscriptionDeliveryType.values.byName( - e as String, - ), - ) + .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( diff --git a/lib/src/registry/data_operation_registry.dart b/lib/src/registry/data_operation_registry.dart index f5b85c6..4252cb6 100644 --- a/lib/src/registry/data_operation_registry.dart +++ b/lib/src/registry/data_operation_registry.dart @@ -4,6 +4,7 @@ import 'package:core/core.dart'; 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/permissions.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/services/country_query_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/dashboard_summary_service.dart'; @@ -386,6 +387,7 @@ class DataOperationRegistry { 'Executing custom updater for user_content_preferences ID: $id.', ); final authenticatedUser = context.read(); + final permissionService = context.read(); final userPreferenceLimitService = context .read(); final userContentPreferencesRepository = context @@ -400,13 +402,23 @@ class DataOperationRegistry { // 2. Validate all limits using the consolidated service method. // The service now contains all logic to compare the updated and - // current preferences and check all relevant limits (interests, - // followed items, etc.) in one go. - await userPreferenceLimitService.checkUserContentPreferencesLimits( - user: authenticatedUser, - updatedPreferences: preferencesToUpdate, - currentPreferences: currentPreferences, - ); + // current preferences and check all relevant limits. + // + // 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, + currentPreferences: currentPreferences, + ); + } // 3. If all checks pass, proceed with the update. _log.info( From 553a00ac643d1f98cbff20bd41066d3b5d7650b4 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 10:58:58 +0100 Subject: [PATCH 36/38] fix(api): correct critical bug in preference limit validation This commit fixes a critical bug in the DefaultUserPreferenceLimitService where interest-specific limits (pinned filters and notification subscriptions) were not being checked correctly. Previously, the logic only ran if it detected a single added or updated interest, which meant the limits were completely bypassed if a user updated their preferences without changing an interest, or if they changed multiple interests at once. The logic has been corrected to be stateless. It now validates the entire proposed updatedPreferences state on every request, ensuring that the total counts for pinned filters and all notification subscription types are always checked against the user's role-based limits. This closes the loophole that could have allowed users to exceed their configured limits. --- ...default_user_preference_limit_service.dart | 99 +++++++------------ 1 file changed, 36 insertions(+), 63 deletions(-) diff --git a/lib/src/services/default_user_preference_limit_service.dart b/lib/src/services/default_user_preference_limit_service.dart index fc55f3e..406a716 100644 --- a/lib/src/services/default_user_preference_limit_service.dart +++ b/lib/src/services/default_user_preference_limit_service.dart @@ -108,78 +108,51 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { ); } - // Find the interest that was added or updated to check its specific limits. - // This logic assumes only one interest is added or updated per request. - final currentInterestIds = currentPreferences.interests - .map((i) => i.id) - .toSet(); - Interest? changedInterest; - - for (final updatedInterest in updatedPreferences.interests) { - if (!currentInterestIds.contains(updatedInterest.id)) { - // This is a newly added interest. - changedInterest = updatedInterest; - break; - } else { - // This is a potentially updated interest. Find the original. - final originalInterest = currentPreferences.interests.firstWhere( - (i) => i.id == updatedInterest.id, - ); - if (updatedInterest != originalInterest) { - changedInterest = updatedInterest; - break; - } - } + // 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.', + ); } - // If an interest was added or updated, check its specific limits. - if (changedInterest != null) { - _log.info('Checking limits for changed interest: ${changedInterest.id}'); + // 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.', + ); + } - // Check total number of pinned feed filters. - final pinnedCount = updatedPreferences.interests - .where((i) => i.isPinnedFeedFilter) + // Count how many of the user's proposed interests include this type. + final subscriptionCount = updatedPreferences.interests + .where((i) => i.deliveryTypes.contains(deliveryType)) .length; - if (pinnedCount > interestLimits.pinnedFeedFilters) { + + if (subscriptionCount > notificationLimit) { _log.warning( - 'User ${user.id} exceeded pinned feed filter limit: ' - '${interestLimits.pinnedFeedFilters} (attempted $pinnedCount).', + 'User ${user.id} exceeded notification limit for ' + '${deliveryType.name}: $notificationLimit ' + '(attempted $subscriptionCount).', ); throw ForbiddenException( - 'You have reached your limit of ${interestLimits.pinnedFeedFilters} ' - 'pinned feed filters.', + 'You have reached your limit of $notificationLimit ' + '${deliveryType.name} notification subscriptions.', ); } - - // Check notification subscription limits for each type. - for (final deliveryType in changedInterest.deliveryTypes) { - 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.', - ); - } - - 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.', - ); - } - } } _log.info( From 0e49173e0fcb8da567767096cd38ae2c01439bfe Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 11:01:16 +0100 Subject: [PATCH 37/38] style(firebase_auth): remove outdated comment - Remove unnecessary comment block in FirebaseAuthenticator constructor - Retain important inline comment about HttpClient usage --- lib/src/services/firebase_authenticator.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/src/services/firebase_authenticator.dart b/lib/src/services/firebase_authenticator.dart index a646c71..d58173b 100644 --- a/lib/src/services/firebase_authenticator.dart +++ b/lib/src/services/firebase_authenticator.dart @@ -23,7 +23,6 @@ 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. // This internal HttpClient is used exclusively for the token exchange. // It does not have an auth interceptor, which is crucial to prevent // an infinite loop. From acb356e19954629a326c91de9412b79ff11b14d3 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 11 Nov 2025 11:08:04 +0100 Subject: [PATCH 38/38] refactor(api): remove unused currentPreferences from limit service This refactoring removes the unused currentPreferences parameter from the UserPreferenceLimitService and its implementation. The checkUserContentPreferencesLimits method signature has been updated in both the interface and the concrete implementation. The custom updater in data_operation_registry.dart no longer performs an unnecessary database read to fetch the current preferences, improving efficiency. This change simplifies the code and aligns it with the stateless validation logic, where only the final proposed state (updatedPreferences) is needed for limit checks. --- lib/src/registry/data_operation_registry.dart | 14 +++----------- .../default_user_preference_limit_service.dart | 1 - .../services/user_preference_limit_service.dart | 1 - 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/lib/src/registry/data_operation_registry.dart b/lib/src/registry/data_operation_registry.dart index 4252cb6..342de48 100644 --- a/lib/src/registry/data_operation_registry.dart +++ b/lib/src/registry/data_operation_registry.dart @@ -4,8 +4,8 @@ import 'package:core/core.dart'; 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/permissions.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'; @@ -395,16 +395,9 @@ class DataOperationRegistry { final preferencesToUpdate = item as UserContentPreferences; - // 1. Fetch the current state of the user's preferences. - final currentPreferences = await userContentPreferencesRepository.read( - id: id, - ); - // 2. Validate all limits using the consolidated service method. - // The service now contains all logic to compare the updated and - // current preferences and check all relevant limits. - // - // We first check if the user has permission to bypass these limits. + // 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, @@ -416,7 +409,6 @@ class DataOperationRegistry { await userPreferenceLimitService.checkUserContentPreferencesLimits( user: authenticatedUser, updatedPreferences: preferencesToUpdate, - currentPreferences: currentPreferences, ); } diff --git a/lib/src/services/default_user_preference_limit_service.dart b/lib/src/services/default_user_preference_limit_service.dart index 406a716..ecd6654 100644 --- a/lib/src/services/default_user_preference_limit_service.dart +++ b/lib/src/services/default_user_preference_limit_service.dart @@ -26,7 +26,6 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { Future checkUserContentPreferencesLimits({ required User user, required UserContentPreferences updatedPreferences, - required UserContentPreferences currentPreferences, }) async { _log.info( 'Checking all user content preferences limits for user ${user.id}.', diff --git a/lib/src/services/user_preference_limit_service.dart b/lib/src/services/user_preference_limit_service.dart index bb823e3..affc522 100644 --- a/lib/src/services/user_preference_limit_service.dart +++ b/lib/src/services/user_preference_limit_service.dart @@ -19,6 +19,5 @@ abstract class UserPreferenceLimitService { Future checkUserContentPreferencesLimits({ required User user, required UserContentPreferences updatedPreferences, - required UserContentPreferences currentPreferences, }); }